Compare commits

..
794 Commits
Author SHA1 Message Date
Ludovic Marie e14e5b7d10 Merge pull request #13413 from iseaprojects/i18n/cy-gb-rest
🤖 Agent: cy_gb.json only: 582 game keys filled, each key exists in English and keeps its placeholders, JSON valid with no duplicate keys; 55 stay as in English (names, units, commands).
2026-11-09 04:18:56 +01:00
Ludovic Marie c4d2bfde0e Merge pull request #13414 from iseaprojects/i18n/launcher-ur-rest
🤖 Agent: ur.json only: 110 launcher strings filled, each key exists in English and keeps its placeholders, JSON valid with no duplicate keys.
2026-11-09 04:18:52 +01:00
Ludovic Marie a96242989a Merge pull request #13416 from iseaprojects/i18n/launcher-tr-rest
🤖 Agent: tr.json only: 217 launcher strings filled, each key exists in English and keeps its placeholders, JSON valid with no duplicate keys.
2026-11-09 04:18:49 +01:00
Ludovic Marie 4be2b68d65 Merge pull request #13421 from iseaprojects/i18n/ky-kg-rest
🤖 Agent: ky_kg.json only: 594 game keys filled, each key exists in English and keeps its placeholders, JSON valid with no duplicate keys; 40 stay as in English (names, units, commands).
2026-11-09 04:18:45 +01:00
Ludovic Marie 21897e3ae1 Merge pull request #13423 from iseaprojects/i18n/mt-mt-rest
🤖 Agent: mt_mt.json only: 609 game keys filled, each key exists in English and keeps its placeholders, JSON valid with no duplicate keys; 99 stay as in English (names, units, commands).
2026-11-09 04:18:42 +01:00
Ludovic Marie 9ddfefe5f3 Merge pull request #13424 from iseaprojects/i18n/launcher-se-rest
🤖 Agent: se.json only: 118 launcher strings filled, each key exists in English and keeps its placeholders, JSON valid with no duplicate keys.
2026-11-09 04:18:38 +01:00
Ludovic Marie 012099e2df Merge pull request #13425 from iseaprojects/i18n/launcher-ba-rest
🤖 Agent: ba.json only: 108 launcher strings filled, each key exists in English and keeps its placeholders, JSON valid with no duplicate keys.
2026-11-09 04:18:34 +01:00
Ludovic Marie cb99718c75 Merge pull request #13426 from iseaprojects/i18n/launcher-gd-rest
🤖 Agent: gd.json only: 118 launcher strings filled, each key exists in English and keeps its placeholders, JSON valid with no duplicate keys.
2026-11-09 04:18:30 +01:00
Ludovic Marie c869da46d0 Merge pull request #13428 from iseaprojects/i18n/lmo-rest
🤖 Agent: lmo.json only: 572 game keys filled, each key exists in English and keeps its placeholders, JSON valid with no duplicate keys; 65 stay as in English (names, units, commands).
2026-11-09 04:18:27 +01:00
Ludovic Marie 16b7d69a82 Merge pull request #13429 from iseaprojects/i18n/launcher-sr-rest
🤖 Agent: sr.json only: 108 launcher strings filled, each key exists in English and keeps its placeholders, JSON valid with no duplicate keys.
2026-11-09 04:18:24 +01:00
Ludovic Marie 81380c36bc Merge pull request #13431 from iseaprojects/i18n/az-az-rest
🤖 Agent: az_az.json only: 579 game keys filled, each key exists in English and keeps its placeholders, JSON valid with no duplicate keys; 47 stay as in English (names, units, commands).
2026-11-09 04:18:19 +01:00
Ludovic Marie 97de15ebf1 Merge pull request #13430 from iseaprojects/i18n/launcher-szl-rest
🤖 Agent: szl.json only: 114 launcher strings filled, each key exists in English and keeps its placeholders, JSON valid with no duplicate keys.
2026-11-09 04:18:16 +01:00
Ludovic Marie 82d60cde55 Merge pull request #13433 from iseaprojects/i18n/launcher-mt-rest
🤖 Agent: mt.json only: 130 launcher strings filled, each key exists in English and keeps its placeholders, JSON valid with no duplicate keys.
2026-11-09 04:18:12 +01:00
Ludovic Marie cfb77649b1 Merge pull request #13452 from iseaprojects/i18n/launcher-mk-rest
🤖 Agent: mk.json only: 110 launcher strings filled, each key exists in English and keeps its placeholders, JSON valid with no duplicate keys.
2026-11-09 04:18:08 +01:00
Ludovic Marie f4c4697bff Merge pull request #13500 from iseaprojects/i18n/launcher-lb-rest
🤖 Agent: lb.json only: 121 launcher strings filled, each key exists in English and keeps its placeholders, JSON valid with no duplicate keys.
2026-11-09 04:16:17 +01:00
Ludovic Marie d2b4a99726 Merge pull request #13501 from iseaprojects/i18n/launcher-la-rest
🤖 Agent: la.json only: 194 launcher strings filled, each key exists in English and keeps its placeholders, JSON valid with no duplicate keys.
2026-11-09 04:16:14 +01:00
Ludovic Marie 4fd63ad696 Merge pull request #13503 from iseaprojects/i18n/launcher-haw-rest
🤖 Agent: haw.json only: 127 launcher strings filled, each key exists in English and keeps its placeholders, JSON valid with no duplicate keys.
2026-11-09 04:16:10 +01:00
Ludovic Marie 497885ff2b Merge pull request #13504 from iseaprojects/i18n/haw-us-rest
🤖 Agent: haw_us.json only: 584 game keys filled, each key exists in English and keeps its placeholders, JSON valid with no duplicate keys; 52 stay as in English (names, units, commands).
2026-11-09 04:16:06 +01:00
Ludovic Marie d45865d1f5 Merge pull request #13505 from iseaprojects/i18n/launcher-ta-rest
🤖 Agent: ta.json only: 108 launcher strings filled, each key exists in English and keeps its placeholders, JSON valid with no duplicate keys.
2026-11-09 04:16:03 +01:00
Ludovic Marie 1313a211a3 Merge pull request #13506 from iseaprojects/i18n/launcher-eu-rest
🤖 Agent: eu.json only: 112 launcher strings filled, each key exists in English and keeps its placeholders, JSON valid with no duplicate keys.
2026-11-09 04:15:59 +01:00
Ludovic Marie 7593c75747 Merge pull request #13507 from iseaprojects/i18n/launcher-is-rest
🤖 Agent: is.json only: 112 launcher strings filled, each key exists in English and keeps its placeholders, JSON valid with no duplicate keys.
2026-11-09 04:15:56 +01:00
Ludovic Marie 841e1a7d00 Merge pull request #13508 from iseaprojects/i18n/launcher-sl-rest
🤖 Agent: sl.json only: 111 launcher strings filled, each key exists in English and keeps its placeholders, JSON valid with no duplicate keys.
2026-11-09 04:15:53 +01:00
Ludovic Marie 7c430904d6 Merge pull request #13509 from iseaprojects/i18n/launcher-mn-rest
🤖 Agent: mn.json only: 116 launcher strings filled, each key exists in English and keeps its placeholders, JSON valid with no duplicate keys.
2026-11-09 04:15:49 +01:00
Ludovic Marie 4c80229dcb Merge pull request #13513 from iseaprojects/i18n/lo-la-rest
🤖 Agent: lo_la.json only: 577 game keys filled, each key exists in English and keeps its placeholders, JSON valid with no duplicate keys; 54 stay as in English (names, units, commands).
2026-11-09 04:15:46 +01:00
Ludovic Marie a3eefe7af9 Merge pull request #13515 from iseaprojects/i18n/launcher-az-rest
🤖 Agent: az.json only: 113 launcher strings filled, each key exists in English and keeps its placeholders, JSON valid with no duplicate keys.
2026-11-09 04:15:42 +01:00
Ludovic Marie 80de633e2c Merge pull request #13517 from iseaprojects/i18n/vec-it-rest
🤖 Agent: vec_it.json only: 577 game keys filled, each key exists in English and keeps its placeholders, JSON valid with no duplicate keys; 68 stay as in English (names, units, commands).
2026-11-09 04:15:38 +01:00
Ludovic Marie 784b2b13bd Merge pull request #13519 from iseaprojects/i18n/launcher-fy-rest
🤖 Agent: fy.json only: 163 launcher strings filled, each key exists in English and keeps its placeholders, JSON valid with no duplicate keys.
2026-11-09 04:15:34 +01:00
Ludovic Marie b64d1a9f9c Merge pull request #13520 from iseaprojects/i18n/launcher-vec-rest
🤖 Agent: vec.json only: 115 launcher strings filled, each key exists in English and keeps its placeholders, JSON valid with no duplicate keys.
2026-11-09 04:15:31 +01:00
Ludovic Marie aa8c9d2eba Merge pull request #13522 from iseaprojects/i18n/li-li-rest
🤖 Agent: li_li.json only: 608 game keys filled, each key exists in English and keeps its placeholders, JSON valid with no duplicate keys; 101 stay as in English (names, units, commands).
2026-11-09 04:15:28 +01:00
Ludovic Marie d33be41a02 Merge pull request #13525 from iseaprojects/i18n/launcher-kk-rest
🤖 Agent: kk.json only: 154 launcher strings filled, each key exists in English and keeps its placeholders, JSON valid with no duplicate keys.
2026-11-09 04:14:03 +01:00
Ludovic Marie 8d801b4eab Merge pull request #13526 from iseaprojects/i18n/mn-mn-rest
🤖 Agent: mn_mn.json only: 567 game keys filled, each key exists in English and keeps its placeholders, JSON valid with no duplicate keys; 40 stay as in English (names, units, commands).
2026-11-09 04:13:59 +01:00
Ludovic Marie cec9a29e0f Merge pull request #13530 from iseaprojects/i18n/launcher-fur-rest
🤖 Agent: fur.json only: 108 launcher strings filled, each key exists in English and keeps its placeholders, JSON valid with no duplicate keys.
2026-11-09 04:13:56 +01:00
Ludovic Marie eb5aa98ed5 Merge pull request #13532 from iseaprojects/i18n/no-no-rest
🤖 Agent: no_no.json only: 592 game keys filled, each key exists in English and keeps its placeholders, JSON valid with no duplicate keys; 68 stay as in English (names, units, commands).
2026-11-09 04:13:52 +01:00
Ludovic Marie 6dfc835661 Merge pull request #13533 from iseaprojects/i18n/gd-gb-rest
🤖 Agent: gd_gb.json only: 578 game keys filled, each key exists in English and keeps its placeholders, JSON valid with no duplicate keys; 64 stay as in English (names, units, commands).
2026-11-09 04:13:49 +01:00
Ludovic Marie 87d8c68734 Merge pull request #13534 from iseaprojects/i18n/eo-uy-rest
🤖 Agent: eo_uy.json only: 574 game keys filled, each key exists in English and keeps its placeholders, JSON valid with no duplicate keys; 44 stay as in English (names, units, commands).
2026-11-09 04:13:45 +01:00
Ludovic Marie 9364785f11 Merge pull request #13535 from iseaprojects/i18n/hy-am-rest
🤖 Agent: hy_am.json only: 577 game keys filled, each key exists in English and keeps its placeholders, JSON valid with no duplicate keys; 44 stay as in English (names, units, commands).
2026-11-09 04:13:42 +01:00
Ludovic Marie e42b640471 Merge pull request #12875 from horizon-rust/perf/spawner-player-scans
🤖 Agent: spawners test guests' raw positions with the same distance formula instead of building every player's record each tick, and mob_world_base reads the first player only; same answers, tick_block_entities -2.3 to -2.6 ms/tick on the bench.
2026-11-09 04:11:25 +01:00
Ludovic Marie ba9978c494 Merge pull request #12874 from horizon-rust/perf/guest-pickup-columns
🤖 Agent: guest_pickups only scans items when one reaches the player's columns; the columns follow the item list's stamp (scan first after any write), so a /tp or explosion mid-pass cannot hide an item. Bench lan_guests -2.4 to -5.7 ms/tick at 134 players.
2026-11-09 04:11:21 +01:00
Ludovic Marie e547614923 Merge pull request #12873 from horizon-rust/perf/ticking-chunk-neighbours
🤖 Agent: ticking_chunk now asks one 3x3 resident count instead of 9 chunk-map lookups; ChunkMap keeps it on insert (new keys only), remove and clear, so load/unload order cannot leave it stale (unit test covers all three). Bench: sn_candidates -0.6 to -0.9 ms/tick at 134 players; union harnesses: no failure that main 81403ce does not share.
2026-11-09 04:11:18 +01:00
Ludovic Marie 6886bf948f Merge pull request #13008 from rdbtCVS/refactor/game-inline-tests
🤖 Agent: verify failed only on main's old catalog_matches_hook_points bug (fixed by #13028); I merged main into the branch and requeued, nothing in the PR changed.
2026-11-09 03:58:43 +01:00
Ludovic Marie 11db39e485 Merge pull request #13004 from rdbtCVS/refactor/game-attack-mob
🤖 Agent: verify failed only on main's old catalog_matches_hook_points bug (fixed by #13028); I merged main into the branch and requeued, nothing in the PR changed.
2026-11-09 03:58:40 +01:00
iseaprojectsandopencode 476dae8af8 Armenian: the 577 keys it still missed
hy_am.json had 482 of the 1059 keys in en_us.json. The 577 it lacked fell back to English.

Translated from the English source; the new keys sit at their en_us.json
neighbours and no existing line moved. Keys and placeholders unchanged.

Fixes #13321

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-08 22:58:20 -04:00
iseaprojectsandopencode 3985dca926 Esperanto: the 574 keys it still missed
eo_uy.json had 485 of the 1059 keys in en_us.json. The 574 it lacked fell back to English.

Translated from the English source; the new keys sit at their en_us.json
neighbours and no existing line moved. Keys and placeholders unchanged.

Fixes #13366

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-08 22:58:06 -04:00
iseaprojectsandopencode 35ac0f37a4 Scottish Gaelic: the 578 keys it still missed
gd_gb.json had 481 of the 1059 keys in en_us.json. The 578 it lacked fell back to English.

Translated from the English source; the new keys sit at their en_us.json
neighbours and no existing line moved. Keys and placeholders unchanged.

Fixes #13489

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-08 22:57:42 -04:00
iseaprojectsandopencode 411363e5d8 Norwegian: the 592 keys it still missed
no_no.json had 467 of the 1059 keys in en_us.json. The 592 it lacked fell back to English.

Translated from the English source; the new keys sit at their en_us.json
neighbours and no existing line moved. Keys and placeholders unchanged.

Fixes #13351

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-08 22:57:37 -04:00
iseaprojectsandopencode 6e5074c211 Launcher: the 124 strings Friulian still shows in English
launcher/lang/fur.json had 124 of its 1266 strings still
equal to the English source. Translated in place; keys, order and
{name} placeholders unchanged.

Fixes #13363

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-08 22:57:07 -04:00
iseaprojectsandopencode 1b80961d33 Mongolian: the 567 keys it still missed
mn_mn.json had 492 of the 1059 keys in en_us.json. The 567 it lacked fell back to English.

Translated from the English source; the new keys sit at their en_us.json
neighbours and no existing line moved. Keys and placeholders unchanged.

Fixes #13368

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-08 22:56:13 -04:00
iseaprojectsandopencode 9a39cbf095 Launcher: the 155 strings Kazakh still shows in English
launcher/lang/kk.json had 155 of its 1266 strings still
equal to the English source. Translated in place; keys, order and
{name} placeholders unchanged.

Fixes #13374

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-08 22:56:09 -04:00
Ludovic Marie 7e56cd4be9 Merge pull request #13098 from iseaprojects/perf/cloud-mesh-snap
🤖 Agent: the mesh key snaps to 4-cell steps with the radius grown by 4 cells, so the drawn window still covers the view; inside the cloud layer the sub-cell offset keeps the faces in place, and the new test pins the same world cells across a snap step.
2026-11-09 03:56:03 +01:00
iseaprojectsandopencode c478e83924 Limburgish: the 608 keys it still missed
li_li.json had 451 of the 1059 keys in en_us.json. The 608 it lacked fell back to English.

Translated from the English source; the new keys sit at their en_us.json
neighbours and no existing line moved. Keys and placeholders unchanged.

Fixes #13376

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-08 22:55:33 -04:00
iseaprojectsandopencode a56079b54b Launcher: the 131 strings Venetian still shows in English
launcher/lang/vec.json had 131 of its 1266 strings still
equal to the English source. Translated in place; keys, order and
{name} placeholders unchanged.

Fixes #13518

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-08 22:55:16 -04:00
Horizon agentandClaude Opus 5.5 f91d165127 Merge main into #13008 (main's catalog_matches_hook_points fix, #13028)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 03:55:15 +01:00
Horizon agentandClaude Opus 5.5 192b8f05ab Merge main into #13004 (main's catalog_matches_hook_points fix, #13028)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 03:55:14 +01:00
iseaprojectsandopencode 65ac9627f4 Launcher: the 210 strings West Frisian still shows in English
launcher/lang/fy.json had 210 of its 1266 strings still
equal to the English source. Translated in place; keys, order and
{name} placeholders unchanged.

Fixes #13375

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-08 22:55:13 -04:00
iseaprojectsandopencode 72cf89fc0c Venetian: the 577 keys it still missed
vec_it.json had 482 of the 1059 keys in en_us.json. The 577 it lacked fell back to English.

Translated from the English source; the new keys sit at their en_us.json
neighbours and no existing line moved. Keys and placeholders unchanged.

Fixes #13514

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-08 22:55:07 -04:00
iseaprojectsandopencode 29a09f904d Launcher: the 119 strings Azerbaijani still shows in English
launcher/lang/az.json had 119 of its 1266 strings still
equal to the English source. Translated in place; keys, order and
{name} placeholders unchanged.

Fixes #13422

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-08 22:55:01 -04:00
iseaprojectsandopencode 28bab94068 Lao: the 577 keys it still missed
lo_la.json had 482 of the 1059 keys in en_us.json. The 577 it lacked fell back to English.

Translated from the English source; the new keys sit at their en_us.json
neighbours and no existing line moved. Keys and placeholders unchanged.

Fixes #13512

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-08 22:54:58 -04:00
iseaprojectsandopencode d8371055cf Launcher: the 116 strings Slovenian still shows in English
launcher/lang/sl.json had 116 of its 1266 strings still
equal to the English source. Translated in place; keys, order and
{name} placeholders unchanged.

Fixes #13373

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-08 22:54:23 -04:00
iseaprojectsandopencode 632cf4c159 Launcher: the 117 strings Mongolian still shows in English
launcher/lang/mn.json had 117 of its 1266 strings still
equal to the English source. Translated in place; keys, order and
{name} placeholders unchanged.

Fixes #13323

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-08 22:54:16 -04:00
iseaprojectsandopencode 5c750e94d5 Launcher: the 118 strings Icelandic still shows in English
launcher/lang/is.json had 118 of its 1266 strings still
equal to the English source. Translated in place; keys, order and
{name} placeholders unchanged.

Fixes #13378

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-08 22:54:00 -04:00
iseaprojectsandopencode 37af467518 Launcher: the 115 strings Basque still shows in English
launcher/lang/eu.json had 115 of its 1266 strings still
equal to the English source. Translated in place; keys, order and
{name} placeholders unchanged.

Fixes #13502

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-08 22:53:59 -04:00
iseaprojectsandopencode 1ecb8b196c Hawaiian: the 584 keys it still missed
haw_us.json had 475 of the 1059 keys in en_us.json. The 584 it lacked fell back to English.

Translated from the English source; the new keys sit at their en_us.json
neighbours and no existing line moved. Keys and placeholders unchanged.

Fixes #13437

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-08 22:53:58 -04:00
iseaprojectsandopencode 03ed26b7ad Launcher: the 109 strings Tamil still shows in English
launcher/lang/ta.json had 109 of its 1266 strings still
equal to the English source. Translated in place; keys, order and
{name} placeholders unchanged.

Fixes #13324

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-08 22:53:58 -04:00
iseaprojectsandopencode e3a3a5cef2 Launcher: the 136 strings Hawaiian still shows in English
launcher/lang/haw.json had 136 of its 1266 strings still
equal to the English source. Translated in place; keys, order and
{name} placeholders unchanged.

Fixes #13359

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-08 22:53:54 -04:00
iseaprojectsandopencode 5081f167de Launcher: the 204 strings Latin still shows in English
launcher/lang/la.json had 204 of its 1266 strings still
equal to the English source. Translated in place; keys, order and
{name} placeholders unchanged.

Fixes #13498

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-08 22:53:48 -04:00
iseaprojectsandopencode d5747bd2c0 Launcher: the 164 strings Luxembourgish still shows in English
launcher/lang/lb.json had 164 of its 1266 strings still
equal to the English source. Translated in place; keys, order and
{name} placeholders unchanged.

Fixes #13495

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-08 22:53:41 -04:00
iseaprojectsandopencode accd39a549 Launcher: the 111 strings Macedonian still shows in English
launcher/lang/mk.json had 111 of its 1266 strings still
equal to the English source. Translated in place; keys, order and
{name} placeholders unchanged.

Fixes #13442

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-08 22:51:59 -04:00
Ludovic Marie a82da8b7ec Merge pull request #12978 from rdbtCVS/refactor/game-menu-clicks
🤖 Agent: pure move of menu clicks out of game.rs (added vs removed lines differ only in the module header, use lines and doc rows); it merges cleanly with the other moves in this series and passed a union build and tests.
2026-11-09 03:51:03 +01:00
Ludovic Marie ac92613b68 Merge pull request #12959 from rdbtCVS/refactor/game-frame-geometry
🤖 Agent: pure move of the frame's CPU geometry out of game.rs (added vs removed lines differ only in the module header, use lines and doc rows); it merges cleanly with the other moves in this series and passed a union build and tests.
2026-11-09 03:51:00 +01:00
Ludovic Marie 47699ce2da Merge pull request #12957 from rdbtCVS/refactor/game-item-entities
🤖 Agent: pure move of dropped items and orbs out of game.rs (added vs removed lines differ only in the module header, use lines and doc rows); it merges cleanly with the other moves in this series and passed a union build and tests.
2026-11-09 03:50:56 +01:00
Ludovic Marie 1bc34772f2 Merge pull request #13107 from TheMrZZ/audit/registered-slot-source-command-lookup
🤖 Agent: registered slot_source ids resolve only for execute if/unless items, after literal ranges and inline sources, as SlotSourceArgument tries them; the ignored test writes its world under the cwd, so run it from a scratch dir.
2026-11-09 03:50:53 +01:00
iseaprojectsandopencode b7bb86335c Launcher: the 162 strings Maltese still shows in English
launcher/lang/mt.json had 162 of its 1266 strings still
equal to the English source. Translated in place; keys, order and
{name} placeholders unchanged.

Fixes #13345

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-08 22:50:48 -04:00
iseaprojectsandopencode 1c55ecc5a5 Launcher: the 108 strings Serbian (Cyrillic) still shows in English
launcher/lang/sr.json had 108 of its 1266 strings still
equal to the English source. Translated in place; keys, order and
{name} placeholders unchanged.

Fixes #13405

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-08 22:50:47 -04:00
iseaprojectsandopencode 460f17e0b5 Launcher: the 117 strings Silesian still shows in English
launcher/lang/szl.json had 117 of its 1266 strings still
equal to the English source. Translated in place; keys, order and
{name} placeholders unchanged.

Fixes #13409

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-08 22:50:47 -04:00
iseaprojectsandopencode e069e34f66 Lombard: the 572 keys it still missed
lmo.json had 487 of the 1059 keys in en_us.json. The 572 it lacked fell back to English.

Translated from the English source; the new keys sit at their en_us.json
neighbours and no existing line moved. Keys and placeholders unchanged.

Fixes #13401

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-08 22:50:46 -04:00
iseaprojectsandopencode 8c89833f35 Azerbaijani: the 579 keys it still missed
az_az.json had 480 of the 1059 keys in en_us.json. The 579 it lacked fell back to English.

Translated from the English source; the new keys sit at their en_us.json
neighbours and no existing line moved. Keys and placeholders unchanged.

Fixes #13402

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-08 22:50:46 -04:00
iseaprojectsandopencode fa4bcfd852 Launcher: the 109 strings Bashkir still shows in English
launcher/lang/ba.json had 109 of its 1266 strings still
equal to the English source. Translated in place; keys, order and
{name} placeholders unchanged.

Fixes #13400

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-08 22:50:45 -04:00
iseaprojectsandopencode 123600c1e5 Launcher: the 124 strings Scottish Gaelic still shows in English
launcher/lang/gd.json had 124 of its 1266 strings still
equal to the English source. Translated in place; keys, order and
{name} placeholders unchanged.

Fixes #13399

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-08 22:50:45 -04:00
iseaprojectsandopencode b86e705df2 Maltese: the 609 keys it still missed
mt_mt.json had 450 of the 1059 keys in en_us.json. The 609 it lacked fell back to English.

Translated from the English source; the new keys sit at their en_us.json
neighbours and no existing line moved. Keys and placeholders unchanged.

Fixes #13398

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-08 22:50:44 -04:00
iseaprojectsandopencode d67bed3425 Launcher: the 125 strings Northern Sami still shows in English
launcher/lang/se.json had 125 of its 1266 strings still
equal to the English source. Translated in place; keys, order and
{name} placeholders unchanged.

Fixes #13386

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-08 22:50:44 -04:00
iseaprojectsandopencode 6d35889392 Kyrgyz: the 594 keys it still missed
ky_kg.json had 465 of the 1059 keys in en_us.json. The 594 it lacked fell back to English.

Translated from the English source; the new keys sit at their en_us.json
neighbours and no existing line moved. Keys and placeholders unchanged.

Fixes #13396

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-08 22:50:43 -04:00
iseaprojectsandopencode 8c123f7084 Launcher: the 110 strings Urdu still shows in English
launcher/lang/ur.json had 110 of its 1266 strings still
equal to the English source. Translated in place; keys, order and
{name} placeholders unchanged.

Fixes #13392

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-08 22:50:42 -04:00
iseaprojectsandopencode 7907777afd Launcher: the 221 strings Turkish still shows in English
launcher/lang/tr.json had 221 of its 1266 strings still
equal to the English source. Translated in place; keys, order and
{name} placeholders unchanged.

Fixes #13394

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-08 22:50:42 -04:00
iseaprojectsandopencode 69881101c4 Welsh: the 582 keys it still missed
cy_gb.json had 477 of the 1059 keys in en_us.json. The 582 it lacked fell back to English.

Translated from the English source; the new keys sit at their en_us.json
neighbours and no existing line moved. Keys and placeholders unchanged.

Fixes #13393

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-08 22:50:41 -04:00
Ludovic Marie 10f62131f8 Merge pull request #12986 from rdbtCVS/refactor/game-falling-blocks
🤖 Agent: pure move of falling block entities out of game.rs (added vs removed lines differ only in the module header, use lines and doc rows); it merges cleanly with the other moves in this series and passed a union build and tests. I merged main: #12992 had rewritten tick_one_falling in game.rs, so falling_blocks.rs now holds main's code, checked verbatim.
2026-11-09 03:48:51 +01:00
Ludovic Marie 9bd48bcad1 Merge pull request #12980 from rdbtCVS/refactor/game-startup
🤖 Agent: pure move of the startup resource loader out of game.rs (added vs removed lines differ only in the module header, use lines and doc rows); it merges cleanly with the other moves in this series and passed a union build and tests. I merged main (AGENTS.md row only).
2026-11-09 03:48:47 +01:00
Ludovic Marie e7b46eabba Merge pull request #12996 from rdbtCVS/refactor/game-portal-lighting
🤖 Agent: pure move of lighting a nether portal out of game.rs (added vs removed lines differ only in the module header, use lines and doc rows); it merges cleanly with the other moves in this series and passed a union build and tests.
2026-11-09 03:48:43 +01:00
Ludovic Marie 18c35350b7 Merge pull request #12988 from rdbtCVS/refactor/game-block-ticks
🤖 Agent: pure move of a level's block tick phases out of game.rs (added vs removed lines differ only in the module header, use lines and doc rows); it merges cleanly with the other moves in this series and passed a union build and tests.
2026-11-09 03:48:39 +01:00
Ludovic Marie e494327b75 Merge pull request #13207 from vnsmoreira/packs/polytone-disable-particles
🤖 Agent: disable_particles skips the block's own animateTick only; the lookup is a Vec index by state id, so the per-block animate cost stays O(1).
2026-11-09 03:48:36 +01:00
Ludovic Marie baeade2e91 Merge pull request #13206 from vnsmoreira/packs/polytone-modifier-filters
🤖 Agent: stacked on #13205 (queued first); a filtered modifier now applies only to particles whose source block or item matches, instead of being skipped.
2026-11-09 03:48:32 +01:00
Ludovic Marie 8a1a76f729 Merge pull request #13205 from vnsmoreira/packs/polytone-tags
🤖 Agent: tag targets read through the new pack_tags module instead of environment.rs's own reader; green in a union with this wave.
2026-11-09 03:48:29 +01:00
Ludovic Marie e797c725ad Merge pull request #13088 from Ragbie/fix/tp-projectile-ground
🤖 Agent: /tp ends with setOnGround(true) for any entity not fall-flying (TeleportCommand.performTeleport); the projectile, rocket, trident and falling block impls ignored it, and the corpus now reads OnGround for every kind.
2026-11-09 03:46:03 +01:00
Ludovic Marie 4365b5ffc9 Merge pull request #13087 from DUzzL/fix/13037-armor-stand-onground
🤖 Agent: Entity.onGround starts false in vanilla's constructor; ArmorStand doesn't set it, so a fresh stand saves OnGround:0b until its first tick.
2026-11-09 03:45:59 +01:00
Ludovic Marie 799082f609 Merge pull request #13079 from DylanGonzaloFerreyra/split-play-tests
🤖 Agent: checked as a pure move: the added and removed line multisets match except the module header and three rustfmt line joins; no non-test code changes.
2026-11-09 03:45:55 +01:00
Ludovic Marie 2d8f409431 Merge pull request #13073 from M0-kii/fix/validate-parity-shards
🤖 Agent: an out-of-range or malformed --shard is refused up front instead of silently selecting no harness.
2026-11-09 03:45:51 +01:00
Ludovic Marie 14df544b9e Merge pull request #13072 from M0-kii/fix/version-diff-json-booleans
🤖 Agent: a JSON boolean that flips now shows in the version report instead of being dropped as unchanged.
2026-11-09 03:45:47 +01:00
Ludovic Marie 5bd7bc9216 Merge pull request #13067 from M0-kii/fix/build-times-relative-target
🤖 Agent: build timing output now follows the requested target directory instead of the default target/, so --target-dir runs don't write into the checkout.
2026-11-09 03:45:44 +01:00
Ludovic Marie ea4e32a7bb Merge pull request #13065 from DUzzL/fix/13034-piston-push-boat-minecart-onground
🤖 Agent: a piston push clears OnGround on boats and minecarts as PistonMovingBlockEntity.moveCollidedEntities' setDeltaMovement path does; green in a union with this wave.
2026-11-09 03:45:40 +01:00
Ludovic Marie f79ede65cb Merge pull request #13045 from d3xc0/android-input-7
🤖 Agent: pure decoder, nothing calls it yet; Starling is MIT under the same copyright line, credited in the file header and THIRD_PARTY_NOTICES.md.
2026-11-09 03:45:36 +01:00
Ludovic Marie 070d66adbd Merge pull request #13197 from iseaprojects/i18n/af-za-rest
🤖 Agent: checked by script against en_us.json: valid JSON, no key removed, every key exists in en_us, and each value keeps the English placeholders (%s, %1$s, {name}); spot-read a sample of af_za.
2026-11-09 03:34:19 +01:00
Ludovic Marie 88cd86f30f Merge pull request #13194 from iseaprojects/i18n/et-ee-rest
🤖 Agent: checked by script against en_us.json: valid JSON, no key removed, every key exists in en_us, and each value keeps the English placeholders (%s, %1$s, {name}); spot-read a sample of et_ee.
2026-11-09 03:34:16 +01:00
Ludovic Marie d3703bb7ce Merge pull request #13192 from iseaprojects/i18n/is-is-rest
🤖 Agent: checked by script against en_us.json: valid JSON, no key removed, every key exists in en_us, and each value keeps the English placeholders (%s, %1$s, {name}); spot-read a sample of is_is.
2026-11-09 03:34:13 +01:00
Ludovic Marie 83c486232a Merge pull request #13190 from iseaprojects/i18n/lv-lv-rest
🤖 Agent: checked by script against en_us.json: valid JSON, no key removed, every key exists in en_us, and each value keeps the English placeholders (%s, %1$s, {name}); spot-read a sample of lv_lv.
2026-11-09 03:34:09 +01:00
Ludovic Marie 8182353b24 Merge pull request #13186 from iseaprojects/i18n/ta-in-rest
🤖 Agent: checked by script against en_us.json: valid JSON, no key removed, every key exists in en_us, and each value keeps the English placeholders (%s, %1$s, {name}); spot-read a sample of ta_in.
2026-11-09 03:34:05 +01:00
Ludovic Marie 31184e1498 Merge pull request #13181 from iseaprojects/i18n/kk-kz-rest
🤖 Agent: checked by script against en_us.json: valid JSON, no key removed, every key exists in en_us, and each value keeps the English placeholders (%s, %1$s, {name}); spot-read a sample of kk_kz.
2026-11-09 03:34:02 +01:00
Ludovic Marie 0d07d06ac4 Merge pull request #13179 from iseaprojects/i18n/eu-es-rest
🤖 Agent: checked by script against en_us.json: valid JSON, no key removed, every key exists in en_us, and each value keeps the English placeholders (%s, %1$s, {name}); spot-read a sample of eu_es.
2026-11-09 03:33:58 +01:00
Ludovic Marie 099f5e3ce0 Merge pull request #13177 from iseaprojects/i18n/sl-si-rest
🤖 Agent: checked by script against en_us.json: valid JSON, no key removed, every key exists in en_us, and each value keeps the English placeholders (%s, %1$s, {name}); spot-read a sample of sl_si.
2026-11-09 03:33:55 +01:00
Ludovic Marie 7b02eaae15 Merge pull request #13173 from iseaprojects/i18n/bs-ba-rest
🤖 Agent: checked by script against en_us.json: valid JSON, no key removed, every key exists in en_us, and each value keeps the English placeholders (%s, %1$s, {name}); spot-read a sample of bs_ba.
2026-11-09 03:33:51 +01:00
Ludovic Marie a3bfed0fa6 Merge pull request #13169 from iseaprojects/i18n/sr-sp-rest
🤖 Agent: checked by script against en_us.json: valid JSON, no key removed, every key exists in en_us, and each value keeps the English placeholders (%s, %1$s, {name}); spot-read a sample of sr_sp.
2026-11-09 03:33:47 +01:00
Ludovic Marie 626cc3bc20 Merge pull request #13142 from iseaprojects/i18n/sv-se-rest
🤖 Agent: checked by script against en_us.json: valid JSON, no key removed, every key exists in en_us, and each value keeps the English placeholders (%s, %1$s, {name}); spot-read a sample of sv_se.
2026-11-09 03:33:43 +01:00
Ludovic Marie 109a2a7710 Merge pull request #13166 from iseaprojects/i18n/fa-ir-rest
🤖 Agent: checked by script against en_us.json: valid JSON, no key removed, every key exists in en_us, and each value keeps the English placeholders (%s, %1$s, {name}); spot-read a sample of fa_ir.
2026-11-09 03:28:43 +01:00
Ludovic Marie 34f62e0ec7 Merge pull request #13164 from iseaprojects/i18n/ro-ro-rest
🤖 Agent: checked by script against en_us.json: valid JSON, no key removed, every key exists in en_us, and each value keeps the English placeholders (%s, %1$s, {name}); spot-read a sample of ro_ro.
2026-11-09 03:28:39 +01:00
Ludovic Marie 34f5b4ff90 Merge pull request #13162 from iseaprojects/i18n/lt-lt-rest
🤖 Agent: checked by script against en_us.json: valid JSON, no key removed, every key exists in en_us, and each value keeps the English placeholders (%s, %1$s, {name}); spot-read a sample of lt_lt.
2026-11-09 03:28:35 +01:00
Ludovic Marie 163b675f40 Merge pull request #13160 from iseaprojects/i18n/sk-sk-rest
🤖 Agent: checked by script against en_us.json: valid JSON, no key removed, every key exists in en_us, and each value keeps the English placeholders (%s, %1$s, {name}); spot-read a sample of sk_sk.
2026-11-09 03:28:32 +01:00
Ludovic Marie 4380ac25e5 Merge pull request #13158 from iseaprojects/i18n/el-gr-rest
🤖 Agent: checked by script against en_us.json: valid JSON, no key removed, every key exists in en_us, and each value keeps the English placeholders (%s, %1$s, {name}); spot-read a sample of el_gr.
2026-11-09 03:28:28 +01:00
Ludovic Marie 682810bf36 Merge pull request #13156 from iseaprojects/i18n/hr-hr-rest
🤖 Agent: checked by script against en_us.json: valid JSON, no key removed, every key exists in en_us, and each value keeps the English placeholders (%s, %1$s, {name}); spot-read a sample of hr_hr.
2026-11-09 03:28:24 +01:00
Ludovic Marie 47ccf7c08e Merge pull request #13154 from iseaprojects/i18n/ur-pk-rest
🤖 Agent: checked by script against en_us.json: valid JSON, no key removed, every key exists in en_us, and each value keeps the English placeholders (%s, %1$s, {name}); spot-read a sample of ur_pk.
2026-11-09 03:28:20 +01:00
Ludovic Marie f3cbbb4832 Merge pull request #13152 from iseaprojects/i18n/cs-cz-rest
🤖 Agent: checked by script against en_us.json: valid JSON, no key removed, every key exists in en_us, and each value keeps the English placeholders (%s, %1$s, {name}); spot-read a sample of cs_cz.
2026-11-09 03:28:16 +01:00
Ludovic Marie 0a9925287b Merge pull request #13150 from iseaprojects/i18n/bg-bg-rest
🤖 Agent: checked by script against en_us.json: valid JSON, no key removed, every key exists in en_us, and each value keeps the English placeholders (%s, %1$s, {name}); spot-read a sample of bg_bg.
2026-11-09 03:28:12 +01:00
Ludovic Marie 86b27e11f4 Merge pull request #13148 from iseaprojects/i18n/gl-es-rest
🤖 Agent: checked by script against en_us.json: valid JSON, no key removed, every key exists in en_us, and each value keeps the English placeholders (%s, %1$s, {name}); spot-read a sample of gl_es.
2026-11-09 03:28:08 +01:00
Ludovic Marie ecc8d9935c Merge pull request #13146 from iseaprojects/i18n/fil-ph-rest
🤖 Agent: checked by script against en_us.json: valid JSON, no key removed, every key exists in en_us, and each value keeps the English placeholders (%s, %1$s, {name}); spot-read a sample of fil_ph.
2026-11-09 03:28:04 +01:00
Ludovic Marie b93b8cd1e1 Merge pull request #13144 from iseaprojects/i18n/pt-pt-rest
🤖 Agent: checked by script against en_us.json: valid JSON, no key removed, every key exists in en_us, and each value keeps the English placeholders (%s, %1$s, {name}); spot-read a sample of pt_pt.
2026-11-09 03:28:00 +01:00
Ludovic Marie 8dbdcacc0a Merge pull request #13140 from iseaprojects/i18n/hi-in-rest
🤖 Agent: checked by script against en_us.json: valid JSON, no key removed, every key exists in en_us, and each value keeps the English placeholders (%s, %1$s, {name}); spot-read a sample of hi_in.
2026-11-09 03:27:56 +01:00
Ludovic Marie 7d60f894a1 Merge pull request #13138 from iseaprojects/i18n/kn-in-rest
🤖 Agent: checked by script against en_us.json: valid JSON, no key removed, every key exists in en_us, and each value keeps the English placeholders (%s, %1$s, {name}); spot-read a sample of kn_in.
2026-11-09 03:27:52 +01:00
Ludovic Marie 757ab0af18 Merge pull request #13136 from iseaprojects/i18n/ca-es-rest
🤖 Agent: checked by script against en_us.json: valid JSON, no key removed, every key exists in en_us, and each value keeps the English placeholders (%s, %1$s, {name}); spot-read a sample of ca_es.
2026-11-09 03:27:48 +01:00
Ludovic Marie 2e62147c92 Merge pull request #13134 from iseaprojects/i18n/hu-hu-rest
🤖 Agent: checked by script against en_us.json: valid JSON, no key removed, every key exists in en_us, and each value keeps the English placeholders (%s, %1$s, {name}); spot-read a sample of hu_hu.
2026-11-09 03:22:04 +01:00
Ludovic Marie 2c48d43d1d Merge pull request #13132 from iseaprojects/i18n/th-th-rest
🤖 Agent: checked by script against en_us.json: valid JSON, no key removed, every key exists in en_us, and each value keeps the English placeholders (%s, %1$s, {name}); spot-read a sample of th_th.
2026-11-09 03:22:00 +01:00
Ludovic Marie 176538045d Merge pull request #13130 from iseaprojects/i18n/id-id-rest
🤖 Agent: checked by script against en_us.json: valid JSON, no key removed, every key exists in en_us, and each value keeps the English placeholders (%s, %1$s, {name}); spot-read a sample of id_id.
2026-11-09 03:21:56 +01:00
Ludovic Marie fab6a8ab7e Merge pull request #13128 from iseaprojects/i18n/nl-nl-rest
🤖 Agent: checked by script against en_us.json: valid JSON, no key removed, every key exists in en_us, and each value keeps the English placeholders (%s, %1$s, {name}); spot-read a sample of nl_nl.
2026-11-09 03:21:52 +01:00
Ludovic Marie dc9315809b Merge pull request #13126 from iseaprojects/i18n/ms-my-rest
🤖 Agent: checked by script against en_us.json: valid JSON, no key removed, every key exists in en_us, and each value keeps the English placeholders (%s, %1$s, {name}); spot-read a sample of ms_my.
2026-11-09 03:21:48 +01:00
Ludovic Marie e9405482da Merge pull request #13124 from iseaprojects/i18n/he-il-rest
🤖 Agent: checked by script against en_us.json: valid JSON, no key removed, every key exists in en_us, and each value keeps the English placeholders (%s, %1$s, {name}); spot-read a sample of he_il.
2026-11-09 03:21:45 +01:00
Ludovic Marie 8cfbc8a571 Merge pull request #13122 from iseaprojects/i18n/tr-tr-rest
🤖 Agent: checked by script against en_us.json: valid JSON, no key removed, every key exists in en_us, and each value keeps the English placeholders (%s, %1$s, {name}); spot-read a sample of tr_tr.
2026-11-09 03:21:41 +01:00
Ludovic Marie 6ccfd62eed Merge pull request #13120 from iseaprojects/i18n/ar-sa-rest
🤖 Agent: checked by script against en_us.json: valid JSON, no key removed, every key exists in en_us, and each value keeps the English placeholders (%s, %1$s, {name}); spot-read a sample of ar_sa.
2026-11-09 03:21:37 +01:00
Ludovic Marie 1f9b81bf34 Merge pull request #13118 from iseaprojects/i18n/vi-vn-rest
🤖 Agent: checked by script against en_us.json: valid JSON, no key removed, every key exists in en_us, and each value keeps the English placeholders (%s, %1$s, {name}); spot-read a sample of vi_vn.
2026-11-09 03:21:33 +01:00
Ludovic Marie 7263ce913f Merge pull request #13116 from iseaprojects/i18n/ko-kr-rest
🤖 Agent: checked by script against en_us.json: valid JSON, no key removed, every key exists in en_us, and each value keeps the English placeholders (%s, %1$s, {name}); spot-read a sample of ko_kr.
2026-11-09 03:21:30 +01:00
Ludovic Marie 07ea597760 Merge pull request #13114 from iseaprojects/i18n/ru-ru-rest
🤖 Agent: checked by script against en_us.json: valid JSON, no key removed, every key exists in en_us, and each value keeps the English placeholders (%s, %1$s, {name}); spot-read a sample of ru_ru. The lang test now checks the English fallback on la_la, since ru_ru has horizon.mods.not_built now.
2026-11-09 03:21:26 +01:00
Ludovic Marie 3853adf547 Merge pull request #13110 from iseaprojects/i18n/ja-jp-rest
🤖 Agent: checked by script against en_us.json: valid JSON, no key removed, every key exists in en_us, and each value keeps the English placeholders (%s, %1$s, {name}); spot-read a sample of ja_jp.
2026-11-09 03:21:23 +01:00
Ludovic Marie e950c219ae Merge pull request #13105 from iseaprojects/i18n/zh-cn-rest
🤖 Agent: checked by script against en_us.json: valid JSON, no key removed, every key exists in en_us, and each value keeps the English placeholders (%s, %1$s, {name}); spot-read a sample of zh_cn.
2026-11-09 03:21:19 +01:00
Ludovic Marie 1a9ed0ee8f Merge pull request #13103 from iseaprojects/i18n/pl-pl-rest
🤖 Agent: checked by script against en_us.json: valid JSON, no key removed, every key exists in en_us, and each value keeps the English placeholders (%s, %1$s, {name}); spot-read a sample of pl_pl.
2026-11-09 03:21:15 +01:00
Ludovic Marie c40c5304ca Merge pull request #13101 from iseaprojects/i18n/uk-ua-rest
🤖 Agent: checked by script against en_us.json: valid JSON, no key removed, every key exists in en_us, and each value keeps the English placeholders (%s, %1$s, {name}); spot-read a sample of uk_ua.
2026-11-09 03:21:11 +01:00
Ludovic Marie 7fd1b9137d Merge pull request #13094 from horizon-rust/layers/tab-mark
🤖 Agent: the tab list draws whatever rectangles the caller's mark callback returns, with the same 9-pixel column rule; the game passes horizon_cosmetics' MARK for listed Horizon players and the vanilla probe passes none, so tab_list.rs no longer names horizon-cosmetics.
2026-11-09 03:10:56 +01:00
Ludovic Marie bf6edbc29a Merge pull request #13060 from horizon-rust/layers/render-telemetry
🤖 Agent: the crash path now runs registered hooks (crash::on_report) under an RwLock that ignores poisoning, and entry.rs registers telemetry::keep_crash before anything else in main, so every report still reaches telemetry exactly as before; render loses its telemetry and bench dependencies (47 → 45).
2026-11-09 03:06:03 +01:00
iseaprojectsandopencode 300f5bbd7e Afrikaans: the 570 keys it still missed
af_za.json had 489 of the 1059 keys in en_us.json. The 570 it lacked fell
back to English across the pvp strings (191), the studio strings (78), the controller strings (49), and 118 flat keys whose English sentence is also the key.

Translated from the English source; the new keys sit at their en_us.json
neighbours and no existing line moved. Keys and placeholders unchanged.

Fixes #13195

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-08 22:04:45 -04:00
iseaprojectsandopencode b4dc165371 Estonian: the 578 keys it still missed
et_ee.json had 481 of the 1059 keys in en_us.json. The 578 it lacked fell
back to English across the pvp strings (191), the studio strings (78), the controller strings (49), and 123 flat keys whose English sentence is also the key.

Translated from the English source; the new keys sit at their en_us.json
neighbours and no existing line moved. Keys and placeholders unchanged.

Fixes #13193

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-08 22:04:12 -04:00
Vinícius MoreiraandClaude Opus 5.5 648e2b6422 Polytone: particle modifier filters
A particle modifier's `filter` (a block or an item, or a list of them) now
limits it to the particles that carry one of them, as BlockParticleOption
and ItemParticleOption do: particles sent from the server (/particle with a
block state), landing and sprinting dust, and item particles now carry
their block's or item's id. Observed on the real mod: a filtered
`minecraft:block` modifier changes /particle's stone particles and not its
dirt ones, and no modifier reaches the crumbs of a broken block, which
vanilla makes without the particle providers (as Horizon does).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 23:04:10 -03:00
iseaprojectsandopencode 0805a5619e Icelandic: the 576 keys it still missed
is_is.json had 483 of the 1059 keys in en_us.json. The 576 it lacked fell
back to English across the pvp strings (191), the studio strings (78), the controller strings (49), and 123 flat keys whose English sentence is also the key.

Translated from the English source; the new keys sit at their en_us.json
neighbours and no existing line moved. Keys and placeholders unchanged.

Fixes #13191

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-08 22:03:53 -04:00
iseaprojectsandopencode ebc27522f5 Latvian: the 587 keys it still missed
lv_lv.json had 472 of the 1059 keys in en_us.json. The 587 it lacked fell
back to English across the pvp strings (191), the studio strings (78), the controller strings (49), and 126 flat keys whose English sentence is also the key.

Translated from the English source; the new keys sit at their en_us.json
neighbours and no existing line moved. Keys and placeholders unchanged.

Fixes #13188

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-08 22:03:22 -04:00
iseaprojectsandopencode 8800efade6 Tamil: the 561 keys it still missed
ta_in.json had 498 of the 1059 keys in en_us.json. The 561 it lacked fell
back to English across the pvp strings (191), the studio strings (78), the controller strings (49), and 114 flat keys whose English sentence is also the key.

Translated from the English source; the new keys sit at their en_us.json
neighbours and no existing line moved. Keys and placeholders unchanged.

Fixes #13185

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-08 22:02:43 -04:00
iseaprojectsandopencode c54d8ac368 Kazakh: the 568 keys it still missed
kk_kz.json had 491 of the 1059 keys in en_us.json. The 568 it lacked fell
back to English across the pvp strings (191), the studio strings (78), the controller strings (49), and 117 flat keys whose English sentence is also the key.

Translated from the English source; the new keys sit at their en_us.json
neighbours and no existing line moved. Keys and placeholders unchanged.

Fixes #13180

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-08 22:01:12 -04:00
iseaprojectsandopencode 1255c8489e Basque: the 573 keys it still missed
eu_es.json had 486 of the 1059 keys in en_us.json. The 573 it lacked fell
back to English across the pvp strings (191), the studio strings (78), the controller strings (49), and 123 flat keys whose English sentence is also the key.

Translated from the English source; the new keys sit at their en_us.json
neighbours and no existing line moved. Keys and placeholders unchanged.

Fixes #13178

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-08 22:01:01 -04:00
iseaprojectsandopencode ee05a0c04c Slovenian: the 576 keys it still missed
sl_si.json had 483 of the 1059 keys in en_us.json. The 576 it lacked fell
back to English across the pvp strings (191), the studio strings (78), the controller strings (49), and 120 flat keys whose English sentence is also the key.

Translated from the English source; the new keys sit at their en_us.json
neighbours and no existing line moved. Keys and placeholders unchanged.

Fixes #13176

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-08 22:00:51 -04:00
iseaprojectsandopencode a4b1d4764e Bosnian: the 626 keys it still missed
bs_ba.json had 433 of the 1059 keys in en_us.json. The 626 it lacked fell
back to English across the pvp strings (191), the studio strings (78), the controller strings (49), and 148 flat keys whose English sentence is also the key.

Translated from the English source; the new keys sit at their en_us.json
neighbours and no existing line moved. Keys and placeholders unchanged.

Fixes #13170

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-08 22:00:14 -04:00
iseaprojectsandopencode 83cdae17f2 Serbian: the 554 keys it still missed
sr_sp.json had 505 of the 1059 keys in en_us.json. The 554 it lacked fell
back to English across the pvp strings (191), the studio strings (78), the controller strings (49), and 109 flat keys whose English sentence is also the key.

Translated from the English source; the new keys sit at their en_us.json
neighbours and no existing line moved. Keys and placeholders unchanged.

Fixes #13167

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-08 22:00:05 -04:00
iseaprojectsandopencode 4260417cd4 Persian: the 568 keys it still missed
fa_ir.json had 491 of the 1059 keys in en_us.json. The 568 it lacked fell
back to English across the pvp strings (191), the studio strings (78), the controller strings (49), and 116 flat keys whose English sentence is also the key.

Translated from the English source; the new keys sit at their en_us.json
neighbours and no existing line moved. Keys and placeholders unchanged.

Fixes #13165

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-08 21:59:54 -04:00
iseaprojectsandopencode d8f17d5d26 Romanian: the 588 keys it still missed
ro_ro.json had 471 of the 1059 keys in en_us.json. The 588 it lacked fell
back to English across the pvp strings (191), the studio strings (78), the controller strings (49), and 131 flat keys whose English sentence is also the key.

Translated from the English source; the new keys sit at their en_us.json
neighbours and no existing line moved. Keys and placeholders unchanged.

Fixes #13163

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-08 21:59:45 -04:00
iseaprojectsandopencode 1ff15314cf Lithuanian: the 576 keys it still missed
lt_lt.json had 483 of the 1059 keys in en_us.json. The 576 it lacked fell
back to English across the pvp strings (191), the studio strings (78), the controller strings (49), and 122 flat keys whose English sentence is also the key.

Translated from the English source; the new keys sit at their en_us.json
neighbours and no existing line moved. Keys and placeholders unchanged.

Fixes #13161

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-08 21:59:30 -04:00
iseaprojectsandopencode 5ed28b1f85 Slovak: the 593 keys it still missed
sk_sk.json had 466 of the 1059 keys in en_us.json. The 593 it lacked fell
back to English across the pvp strings (191), the studio strings (78), the controller strings (49), and 127 flat keys whose English sentence is also the key.

Translated from the English source; the new keys sit at their en_us.json
neighbours and no existing line moved. Keys and placeholders unchanged.

Fixes #13159

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-08 21:59:19 -04:00
iseaprojectsandopencode 9c7643f73e Greek: the 579 keys it still missed
el_gr.json had 480 of the 1059 keys in en_us.json. The 579 it lacked fell
back to English across the pvp strings (191), the studio strings (78), the controller strings (49), and 120 flat keys whose English sentence is also the key.

Translated from the English source; the new keys sit at their en_us.json
neighbours and no existing line moved. Keys and placeholders unchanged.

Fixes #13157

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-08 21:59:09 -04:00
iseaprojectsandopencode 0a3bf41eb2 Croatian: the 612 keys it still missed
hr_hr.json had 447 of the 1059 keys in en_us.json. The 612 it lacked fell
back to English across the pvp strings (191), the studio strings (78), the controller strings (49), and 117 flat keys whose English sentence is also the key.

Translated from the English source; the new keys sit at their en_us.json
neighbours and no existing line moved. Keys and placeholders unchanged.

Fixes #13155

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-08 21:58:57 -04:00
iseaprojectsandopencode aff26431bc Urdu: the 646 keys it still missed
ur_pk.json had 413 of the 1059 keys in en_us.json. The 646 it lacked fell
back to English across the pvp strings (191), the studio strings (78), the controller strings (49), and 134 flat keys whose English sentence is also the key.

Translated from the English source; the new keys sit at their en_us.json
neighbours and no existing line moved. Keys and placeholders unchanged.

Fixes #13153

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-08 21:58:43 -04:00
iseaprojectsandopencode 97a4f951ef Czech: the 590 keys it still missed
cs_cz.json had 469 of the 1059 keys in en_us.json. The 590 it lacked fell
back to English across the pvp strings (191), the studio strings (78), the controller strings (49), and 126 flat keys whose English sentence is also the key.

Translated from the English source; the new keys sit at their en_us.json
neighbours and no existing line moved. Keys and placeholders unchanged.

Fixes #13151

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-08 21:58:32 -04:00
iseaprojectsandopencode 65f8fbb1b6 Bulgarian: the 571 keys it still missed
bg_bg.json had 488 of the 1059 keys in en_us.json. The 571 it lacked fell
back to English across the pvp strings (191), the studio strings (78), the controller strings (49), and 118 flat keys whose English sentence is also the key.

Translated from the English source; the new keys sit at their en_us.json
neighbours and no existing line moved. Keys and placeholders unchanged.

Fixes #13149

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-08 21:58:19 -04:00
iseaprojectsandopencode 19b12543b4 Galician: the 579 keys it still missed
gl_es.json had 480 of the 1059 keys in en_us.json. The 579 it lacked fell
back to English across the pvp strings (191), the studio strings (78), the controller strings (49), and 123 flat keys whose English sentence is also the key.

Translated from the English source; the new keys sit at their en_us.json
neighbours and no existing line moved. Keys and placeholders unchanged.

Fixes #13147

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-08 21:58:07 -04:00
iseaprojectsandopencode 6c74658ead Filipino: the 620 keys it still missed
fil_ph.json had 439 of the 1059 keys in en_us.json. The 620 it lacked fell
back to English across the pvp strings (191), the studio strings (78), the controller strings (49), and 148 flat keys whose English sentence is also the key.

Translated from the English source; the new keys sit at their en_us.json
neighbours and no existing line moved. Keys and placeholders unchanged.

Fixes #13145

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-08 21:57:56 -04:00
iseaprojectsandopencode f436caa55b European Portuguese: the 628 keys it still missed
pt_pt.json had 431 of the 1059 keys in en_us.json. The 628 it lacked fell
back to English across the pvp strings (191), the studio strings (78), the controller strings (49), and 126 flat keys whose English sentence is also the key.

Translated from the English source; the new keys sit at their en_us.json
neighbours and no existing line moved. Keys and placeholders unchanged.

Fixes #13143

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-08 21:57:45 -04:00
iseaprojectsandopencode fadc5392ad Swedish: the 591 keys it still missed
sv_se.json had 468 of the 1059 keys in en_us.json. The 591 it lacked fell
back to English across the pvp strings (191), the studio strings (78), the controller strings (49), and 131 flat keys whose English sentence is also the key.

Translated from the English source; the new keys sit at their en_us.json
neighbours and no existing line moved. Keys and placeholders unchanged.

Fixes #13141

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-08 21:57:32 -04:00
iseaprojectsandopencode bb65ae8c95 Hindi: the 606 keys it still missed
hi_in.json had 453 of the 1059 keys in en_us.json. The 606 it lacked fell
back to English across the pvp strings (191), the studio strings (78), the controller strings (49), and 116 flat keys whose English sentence is also the key.

Translated from the English source; the new keys sit at their en_us.json
neighbours and no existing line moved. Keys and placeholders unchanged.

Fixes #13139

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-08 21:57:08 -04:00
Ludovic Marie db87c15e7c Merge pull request #13039 from Ragbie/fix/boat-float-height
🤖 Agent: the float sums match Boat: getWaterLevelAbove returns blockY + height in float, floatBoat sets waterLevel to getY(1.0) and subtracts getBbHeight in float before the double 0.101; I merged main and kept main's corpus with this PR's cases added.
2026-11-09 02:56:59 +01:00
Ludovic Marie 83e4f7028b Merge pull request #13025 from DUzzL/fix/12908-tnt-fuse-number
🤖 Agent: verify failed only on main's old catalog_matches_hook_points bug (fixed by #13028); I merged main into the branch and requeued, nothing in the PR changed.
2026-11-09 02:56:55 +01:00
iseaprojectsandopencode 5f43eede5c Kannada: the 561 keys it still missed
kn_in.json had 498 of the 1059 keys in en_us.json. The 561 it lacked fell
back to English across the pvp strings (191), the studio strings (78), the controller strings (49), and 114 flat keys whose English sentence is also the key.

Translated from the English source; the new keys sit at their en_us.json
neighbours and no existing line moved. Keys and placeholders unchanged.

Fixes #13137

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-08 21:56:26 -04:00
iseaprojectsandopencode fae637f7a7 Catalan: the 578 keys it still missed
ca_es.json had 481 of the 1059 keys in en_us.json. The 578 it lacked fell
back to English across the pvp strings (191), the studio strings (78), the controller strings (49), and 124 flat keys whose English sentence is also the key.

Translated from the English source; the new keys sit at their en_us.json
neighbours and no existing line moved. Keys and placeholders unchanged.

Fixes #13135

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-08 21:55:46 -04:00
iseaprojectsandopencode 3f9e9ef765 Hungarian: the 585 keys it still missed
hu_hu.json had 474 of the 1059 keys in en_us.json. The 585 it lacked fell
back to English across the pvp strings (191), the studio strings (78), the controller strings (49), and 124 flat keys whose English sentence is also the key.

Translated from the English source; the new keys sit at their en_us.json
neighbours and no existing line moved. Keys and placeholders unchanged.

Fixes #13133

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-08 21:55:36 -04:00
iseaprojectsandopencode 0f85344163 Thai: the 554 keys it still missed
th_th.json had 505 of the 1059 keys in en_us.json. The 554 it lacked fell
back to English across the pvp strings (191), the studio strings (78), the controller strings (49), and 110 flat keys whose English sentence is also the key.

Translated from the English source; the new keys sit at their en_us.json
neighbours and no existing line moved. Keys and placeholders unchanged.

Fixes #13131

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-08 21:55:24 -04:00
Horizon agent cc9da4ef8f Merge remote-tracking branch 'origin/main' into m-13039
# Conflicts:
#	tools/command_exec_corpus.py
2026-11-09 02:55:18 +01:00
iseaprojectsandopencode 76b8915538 Indonesian: the 619 keys it still missed
id_id.json had 440 of the 1059 keys in en_us.json. The 619 it lacked fell
back to English across the pvp strings (191), the studio strings (78), the controller strings (49), and 122 flat keys whose English sentence is also the key.

Translated from the English source; the new keys sit at their en_us.json
neighbours and no existing line moved. Keys and placeholders unchanged.

Fixes #13129

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-08 21:54:51 -04:00
iseaprojectsandopencode 53987660ff Dutch: the 574 keys it still missed
nl_nl.json had 485 of the 1059 keys in en_us.json. The 574 it lacked fell
back to English across the pvp strings (191), the studio strings (78), the controller strings (49), and 123 flat keys whose English sentence is also the key.

Translated from the English source; the new keys sit at their en_us.json
neighbours and no existing line moved. Keys and placeholders unchanged.

Fixes #13127

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-08 21:54:41 -04:00
iseaprojectsandopencode 0420757c65 Malay: the 582 keys it still missed
ms_my.json had 477 of the 1059 keys in en_us.json. The 582 it lacked fell
back to English across the pvp strings (191), the studio strings (78), the controller strings (49), and 125 flat keys whose English sentence is also the key.

Translated from the English source; the new keys sit at their en_us.json
neighbours and no existing line moved. Keys and placeholders unchanged.

Fixes #13125

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-08 21:54:32 -04:00
iseaprojectsandopencode 4c5f020ba9 Hebrew: the 576 keys it still missed
he_il.json had 483 of the 1059 keys in en_us.json. The 576 it lacked fell
back to English across the pvp strings (191), the studio strings (78), the controller strings (49), and 117 flat keys whose English sentence is also the key.

Translated from the English source; the new keys sit at their en_us.json
neighbours and no existing line moved. Keys and placeholders unchanged.

Fixes #13123

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-08 21:54:18 -04:00
Horizon agentandClaude Opus 5.5 b310f706dd Merge main into #13025 (main's catalog_matches_hook_points fix, #13028)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 02:54:11 +01:00
iseaprojectsandopencode efded83921 Turkish: the 823 keys it still missed
tr_tr.json had 236 of the 1059 keys in en_us.json. The 823 it lacked fell
back to English across the pvp strings (191), the splitscreen strings (89), the studio strings (78), and 164 flat keys whose English sentence is also the key.

Translated from the English source; the new keys sit at their en_us.json
neighbours and no existing line moved. Keys and placeholders unchanged.

Fixes #13121

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-08 21:54:09 -04:00
Vinícius MoreiraandClaude Opus 5.5 fd7521ab49 Polytone: block modifiers' disable_particles
A block modifier with "disable_particles": true stops its blocks' own
particles: their Block.animateTick doesn't run, so a torch has no flame
or smoke. Measured on the real mod (Polytone 26.3-6.8.6, a pack with
only that modifier on torches): the torches show neither. Block
modifier emitters run apart from animateTick and are left as they are
(not measured together with disable_particles).

A file that only disables particles is now kept (it used to be dropped
for having no emitters).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 22:54:02 -03:00
Vinícius MoreiraandClaude Opus 5.5 b8acb6f67b Polytone: tags in targets and emitter biomes
A pack's `#tag` in a modifier's `targets` now resolves: block tags for
block modifiers, entity type tags for entity modifiers, particle type tags
for particle modifiers, dimension type tags for dimension modifiers, as
biome tags already did for biome modifiers; an emitter's `biomes` takes
biome tags too. Tags are read from the merged packs' data with nested tags,
optional entries and cycles handled, once each (`pack_tags::Tags`).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 22:53:58 -03:00
iseaprojectsandopencode 0eb6959229 Arabic: the 551 keys it still missed
ar_sa.json had 508 of the 1059 keys in en_us.json. The 551 it lacked fell
back to English across the pvp strings (191), the studio strings (78), the controller strings (49), and 107 flat keys whose English sentence is also the key.

Translated from the English source; the new keys sit at their en_us.json
neighbours and no existing line moved. Keys and placeholders unchanged.

Fixes #13119

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-08 21:53:56 -04:00
iseaprojectsandopencode b2a9078600 Vietnamese: the 563 keys it still missed
vi_vn.json had 496 of the 1059 keys in en_us.json. The 563 it lacked fell
back to English across the pvp strings (191), the studio strings (78), the controller strings (49), and 115 flat keys whose English sentence is also the key.

Translated from the English source; the new keys sit at their en_us.json
neighbours and no existing line moved. Keys and placeholders unchanged.

Fixes #13117

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-08 21:53:47 -04:00
iseaprojectsandopencode 8f3f49dafe Korean: the 608 keys it still missed
ko_kr.json had 451 of the 1059 keys in en_us.json. The 608 it lacked fell
back to English across the pvp strings (191), the studio strings (78), the controller strings (49), and 115 flat keys whose English sentence is also the key.

Translated from the English source; the new keys sit at their en_us.json
neighbours and no existing line moved. Keys and placeholders unchanged.

Fixes #13115

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-08 21:53:29 -04:00
iseaprojectsandopencode f6edfc1556 Russian: the PvP HUD, splitscreen, Studio and the controller guide
ru_ru.json had 243 of the 1059 keys in en_us.json. The 816 it lacked fell
back to English across the splitscreen setup and its prompts, the on-screen
controller guide, PvP and its HUD widgets, colours and editor, Studio's top
bar, timeline, replay list, keyframes, paths and freecam, the mod browser,
a pack's settings page, the Music page, the moderation commands, and 162
flat keys whose English sentence is also the key.

Translated from the English source; the new keys sit at their en_us.json
neighbours and no existing line moved. Keys and placeholders unchanged.

One test used ru_ru's missing horizon.mods.not_built to prove the English
fallback; with the file complete it uses la_la, which is still incomplete.

Fixes #13111

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-08 21:51:23 -04:00
iseaprojectsandopencode 2e2d2994fc Japanese: the controller guide, Studio, the PvP HUD and the mod browser
ja_jp.json had 505 of the 1059 keys in en_us.json. The 554 it lacked fell
back to English across the on-screen controller guide, PvP and its HUD
widgets, colours and editor, Studio's top bar, timeline, replay list,
keyframes, paths and freecam, the mod browser, a pack's settings page, the
moderation durations, and 113 flat keys whose English sentence is also the
key.

Translated from the English source; the new keys sit at their en_us.json
neighbours and no existing line moved. Keys and placeholders unchanged.

Fixes #13106

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-08 21:48:57 -04:00
Florian ERNSTandCodex GPT-6.1 9d30ef3ba7 Fix registered slot-source condition reads
Part of #13091. Snapshot loaded definitions and resolve them only for execute condition reads, preserving unsupported item write consumers.

Co-Authored-By: Codex GPT-6.1 <[email protected]>
2026-11-09 02:47:52 +01:00
iseaprojectsandopencode 694cb27369 Simplified Chinese: the PvP HUD, splitscreen, Studio and the controller guide
zh_cn.json had 371 of the 1059 keys in en_us.json. The 688 it lacked fell
back to English across the splitscreen setup and its prompts, the on-screen
controller guide, PvP and its HUD widgets, colours and editor, Studio's top
bar, timeline, replay list, keyframes and freecam, the mod browser, a pack's
settings page, the Music page, and 125 flat keys whose English sentence is
also the key.

Translated from the English source; the new keys sit at their en_us.json
neighbours and no existing line moved. Keys and placeholders unchanged.

Fixes #13104

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-08 21:47:19 -04:00
iseaprojectsandopencode f30bdbaaa9 Polish: the PvP HUD, splitscreen, Studio and the controller guide
pl_pl.json had 375 of the 1059 keys in en_us.json. The 684 it lacked fell
back to English across the splitscreen setup and its prompts, the on-screen
controller guide, PvP and its HUD widgets, colours and editor, Studio's top
bar, timeline, replay list, keyframes and freecam, the mod browser, a
pack's settings page, the Music page, and 123 flat keys whose English
sentence is also the key.

Translated from the English source; the new keys sit at their en_us.json
neighbours and no existing line moved. Keys and placeholders unchanged.

Fixes #13102

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-08 21:46:36 -04:00
Ludovic Marie b5bbc989ae Merge pull request #13017 from NikitaMGrimm/entity-uuid-draws
🤖 Agent: the UUID comes from the mob's own random before LivingEntity's yaw draw, and the spawn yaw keeps the level's random, as Entity.<init> then Mob does; seed_sync cases 8-13 pin it.
2026-11-09 02:44:07 +01:00
Ludovic Marie 3f489a5e73 Merge pull request #12793 from RLC248/fix/12524-projectile-common-state
🤖 Agent: merged main; entity_nbt.rs keeps both load_projectile_entity_fields and main's OnGround read, so projectiles still restore on_ground.
2026-11-09 02:44:03 +01:00
Ludovic Marie ca6a29bf50 Merge pull request #13035 from TheMrZZ/fix/h078-full-chunk-feature-writes
🤖 Agent: merged main; levels.rs keeps main's BlendingData test and adds this PR's full-chunk feature-write test before it.
2026-11-09 02:43:58 +01:00
Ludovic Marie b6e5125c26 Merge pull request #13023 from TheMrZZ/audit/item-on-block-use-cooldown-start
🤖 Agent: main moved use_hand into game/use_hand.rs, so I ported the fix there (apply_use_cooldown after a successful useOn, as ItemStack.useOn's applyAfterUseComponentSideEffects) and dropped the one-off docs/parity note and probe.
2026-11-09 02:43:55 +01:00
Ludovic Marie 85f2a3d6c1 Merge pull request #13021 from Ragbie/fix/vehicle-fall-clip
🤖 Agent: merged main; command_exec_corpus.py is main's file plus this PR's new cases and groups() entries, no shared case changed.
2026-11-09 02:43:51 +01:00
Ludovic Marie c3d493b309 Merge pull request #13032 from voduff/bobber-block-hits
🤖 Agent: merged main; ProjectileHitter now sits before main's SpawnYaw enum in creatures.rs, both kept whole.
2026-11-09 02:43:47 +01:00
Ludovic Marie 0c080475bc Merge pull request #13031 from TheMrZZ/fix/fence-leash-force
🤖 Agent: merged main (mod declaration in game.rs, rustfmt); fence_leash_force_tests sits under #[cfg(test)] like its neighbours.
2026-11-09 02:43:44 +01:00
Ludovic Marie 89a904c8a5 Merge pull request #13027 from Dangerousbros/perf/lod-keep-tiles
🤖 Agent: I scaled the kept-tile budget to the view (fit_to_view: KEPT_BYTES times tiles / 4700, the 512-chunk view's count, capped at KEPT_BYTES) so a small view no longer holds the full budget, with a test; merged main.
2026-11-09 02:43:40 +01:00
Ludovic Marie 93848bf19b Merge pull request #13041 from vnsmoreira/packs/polytone-vanilla-overrides
🤖 Agent: stacked on #13029 (queued first); the override map is rebuilt from the registry on each pack load, so a pack swap drops overrides the new pack no longer has.
2026-11-09 02:43:35 +01:00
Ludovic Marie 0ffdf24711 Merge pull request #13038 from vnsmoreira/packs/polytone-global-rain
🤖 Agent: g.rain is now (rain + thunder) / 2 in both the particle and post expressions, and the fog-end tests pin a few rain/thunder pairs with independent numbers.
2026-11-09 02:43:31 +01:00
Ludovic Marie 0a41b1b12c Merge pull request #13030 from NikitaMGrimm/hdr-scaled-screenshots
🤖 Agent: a layered HDR frame keeps its own targets through the scaled screenshot; only the non-layered path takes and releases them, so the next frame after the shot needs no rebuild.
2026-11-09 02:43:28 +01:00
Ludovic Marie 3888a0fcc3 Merge pull request #13029 from vnsmoreira/packs/polytone-model-particles
🤖 Agent: a model quad whose sprite is missing from the atlas is skipped, not drawn with a zero rect, so a pack with a typo shows a gap instead of a stretched texture.
2026-11-09 02:43:24 +01:00
iseaprojectsandopencode a740d4407f Ukrainian: controller guide, Studio and pack options
uk_ua.json had 917 of the 1059 keys in en_us.json; the 142 it lacked fell
back to English: the whole on-screen controller guide (48), the Studio top
bar, timeline, keyframes, paths and freecam (48), a pack's own settings
page (11), the gameplay guide and Nintendo button options (7), and 26 flat
keys whose English sentence is also the key.

Translated from the English source; the new keys sit at their en_us.json
neighbours and no existing line moved. Keys and placeholders unchanged.

Fixes #13099

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-08 21:41:10 -04:00
Ludovic Marie f7ce74d8ab Merge pull request #12993 from horizon-rust/layers/addresses-home
🤖 Agent: the moved constants keep their old public names (horizon_telemetry::DEFAULT_ENDPOINT, horizon_friends' STUN list, etc.) as re-pointers to horizon-addresses, so callers don't change; main merged in with telemetry taking both horizon-addresses and horizon-platform, and all four workspaces' locks are --locked clean.
2026-11-09 02:37:10 +01:00
Ludovic Marie 5e79ce9da6 Merge pull request #13015 from horizon-rust/layers/file-size
🤖 Agent: check_file_size.py diffs against the merge base with rename detection, so a file over 3000 lines that is renamed and grows still fails, while big files that shrink or stay the same pass; no pinned list of files.
2026-11-09 02:34:06 +01:00
Horizon agent 613205b79c Merge remote-tracking branch 'origin/main' into layers/addresses-home
# Conflicts:
#	Cargo.lock
#	crates/horizon-telemetry-server/Cargo.lock
#	crates/horizon-telemetry/Cargo.toml
#	launcher/Cargo.lock
2026-11-09 02:33:35 +01:00
iseaprojectsandopencode 203b3ac802 Clouds: snap the mesh origin to a grid so a flight re-uploads it 4x less
MeshKey held the camera's own cell, so every cell the camera crossed rebuilt
the cloud window and copied its 148k vertices (6.5 MB) to the GPU in
r_materials: 206 ms of CPU over 17 frames of a 10 s flight, worst frame 28 ms.

Anchor the origin to a 4-cell grid and let Uniform::offset carry the rest of
the shift, widening the window by the grid so the ring the shift uncovers
stays past the cloud fog end, where entity.wgsl's fade already has alpha 0.
The 3x3 inward faces around the camera still follow its own cell while it is
inside the layer, where they are front-facing, and nowhere else.

Measured on a GTX 1650, Vulkan, render distance 32, 10 s flight at 19
blocks/s: rebuilds 18 -> 6, upload CPU 205.7 -> 82.0 ms, p99 11.8 -> 6.9 ms,
1% low 68 -> 87 fps, for 4.7% more cloud vertices (148,564 -> 153,492).

Fixes #13090

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-08 21:29:12 -04:00
Horizon agentandClaude Opus 5.5 7cf2213433 Tab list: the name mark is the caller's (layer plan M4, first part)
TabInputs::horizon (a "plays Horizon" flag drawn with
horizon_cosmetics::mark::MARK inside the renderer) becomes TabInputs::mark,
an extension point returning the rectangles to draw after the head, or
none. The game passes Horizon's mark from horizon-cosmetics; the vanilla
probe and the tests pass none. Same pixels, same column rule.

tab_list.rs no longer names horizon-cosmetics; extras.rs and extras_ui.rs
still do, so render -> cosmetics stays until they move.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 02:28:34 +01:00
Ludovic Marie 68f661b686 Merge pull request #13058 from horizon-rust/layers/system
🤖 Agent: the probes move unchanged; System::detect_in now copies a horizon_system::Machine into the same payload fields, and telemetry reaches horizon-system only through horizon-platform's re-export, so no new Horizon → engine exception (still 47).
2026-11-09 02:27:51 +01:00
Horizon agentandClaude Opus 5.5 1cf95d4de1 Keep fake_player_tick_tests test-only
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 02:18:10 +01:00
Horizon agentandClaude Opus 5.5 a9214faaa2 Merge main into #12986: move main's falling-block code (#12992 in place, OnGround) into falling_blocks.rs
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 02:17:11 +01:00
Horizon agent 24df2e964b Merge remote-tracking branch 'origin/main' into r-12980
# Conflicts:
#	AGENTS.md
2026-11-09 02:16:37 +01:00
Vinícius MoreiraandClaude Opus 5.5 b6902327b4 Polytone: custom particles that replace vanilla ones
A pack's custom particle under a vanilla id (`minecraft:rain` from
assets/minecraft/polytone/custom_particles/rain.json) now replaces that
vanilla particle wherever the game spawns it, drawn from the vanilla
type's sprite set, as the wiki describes. Visual Effects+ turns rain
splashes into ripples on the ground this way; they now show as in the
real mod.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 22:13:33 -03:00
Vinícius MoreiraandClaude Opus 5.5 94740f1b3b Polytone: draw model particles
A custom particle type with a `model` is now drawn as that block model's
faces instead of being hidden: elements, faces, `#texture` references,
parents and element rotations are read the vanilla way, the particle's
size scales the model and its rotation mode turns it as it turns a
sprite quad. `none` keeps the model in the world's axes.

Checked against the real mod with an asymmetric test model: `look_at_xyz`
turns the model's +Z to the camera, `none` keeps +X east and +Z south.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 22:13:33 -03:00
Evrim CicekandClaude Opus 5.5 8f1fe17a2f /tp puts an arrow, trident, firework rocket or falling block on the ground
TeleportCommand.performTeleport calls setOnGround(true) on every target that
isn't fall-flying; these four kinds keep the flag since #12994 but ignored
Entity::set_on_ground. The "teleport lands" group now reads OnGround for every
kind, trident and firework rocket added.

Fixes #13080

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 04:13:19 +03:00
Vinícius MoreiraandClaude Opus 5.5 182a3c2fe7 Polytone: g.rain is the mean of rain and thunder
A pack's `g.rain` read the rain level alone, so plain rain counted as a
thunderstorm. Measured on the real mod with a sky colormap whose row is
`g.rain`: clear sky reads the colormap's bottom row, rain its middle row
and a thunderstorm its top row, so `g.rain` is (rain + thunder) / 2.

With rain counted as a storm, a pack that picks its sky by `g.rain`
showed its storm sky and fog in plain rain; its rain sky now matches the
real mod's.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 22:13:08 -03:00
Nikita Maximilian Grimm 7c77479175 Merge origin/main into entity-uuid-draws
# Conflicts:
#	crates/horizon-client/src/game/creatures.rs
#	tools/seed_sync_corpus.py
2026-11-09 02:09:33 +01:00
bulkandClaude Opus 5.5 31cd7d4e08 Move container menu clicks out of game.rs into game/menu_clicks.rs
Pure move (#11857). Game::menu_session, menu_click, menu_ops, inventory_quick_move,
creative_throw_carrying, creative_display_click and click_inventory leave game.rs; private
methods become pub(super), the same reach they had in game.rs. Port map rows and the
AGENTS.md / docs/architecture.md tables follow the code.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 01:05:51 +00:00
Horizon agent 7256e407de Merge remote-tracking branch 'origin/main' into m-12793b
# Conflicts:
#	crates/horizon-client/src/entity_nbt.rs
2026-11-09 02:03:17 +01:00
Horizon agent ff58018a06 Merge remote-tracking branch 'origin/main' into m-13032
# Conflicts:
#	crates/horizon-client/src/game/creatures.rs
2026-11-09 02:02:51 +01:00
Horizon agent a2e0595255 Merge remote-tracking branch 'origin/main' into m-13031
# Conflicts:
#	crates/horizon-client/src/game.rs
2026-11-09 02:02:27 +01:00
Ludovic Marie f52d16d46a Merge pull request #12881 from NikitaMGrimm/hdr-settings
🤖 Agent: Black Level 0 now lifts nothing, and Peak steps by 500 above 2000 up to 10 000 nits; same content as reviewed, with main merged in by the author.
2026-11-09 02:02:17 +01:00
Horizon agent 0d9ebac9ce Merge remote-tracking branch 'origin/main' into m-13021
# Conflicts:
#	tools/command_exec_corpus.py
2026-11-09 02:02:09 +01:00
M0-kii 07fa9693f1 Preserve JSON boolean changes in version reports
Fixes #13064
2026-11-09 04:31:28 +03:30
Ludovic Marie 1c6d163156 Merge pull request #12693 from RLC248/fix/12523-canonical-entity-flags
🤖 Agent: verify failed only on main's catalog_matches_hook_points bug (fixed by #13028) or the old test_nightly_set one. I merged main into the branch and requeued; nothing in the PR changed.
2026-11-09 02:01:06 +01:00
Ludovic Marie a41ac74237 Merge pull request #12818 from RLC248/fix/7504-french-creative-search
🤖 Agent: verify failed only on main's catalog_matches_hook_points bug (fixed by #13028) or the old test_nightly_set one. I merged main into the branch and requeued; nothing in the PR changed.
2026-11-09 02:01:02 +01:00
Ludovic Marie c2535ba21f Merge pull request #12817 from RLC248/fix/7467-honey-slime-item-bounce
🤖 Agent: verify failed only on main's catalog_matches_hook_points bug (fixed by #13028) or the old test_nightly_set one. I merged main into the branch and requeued; nothing in the PR changed.
2026-11-09 02:00:59 +01:00
Ludovic Marie 8caa631434 Merge pull request #13018 from DUzzL/fix/12985-piston-contact-box-float
🤖 Agent: verify failed only on main's catalog_matches_hook_points bug (fixed by #13028) or the old test_nightly_set one. I merged main into the branch and requeued; nothing in the PR changed.
2026-11-09 02:00:54 +01:00
Ludovic Marie dde65ec694 Merge pull request #13009 from voduff/summon-yaw-parity
🤖 Agent: verify failed only on main's catalog_matches_hook_points bug (fixed by #13028) or the old test_nightly_set one. I merged main into the branch and requeued; nothing in the PR changed.
2026-11-09 02:00:50 +01:00
Ludovic Marie a3a423c26f Merge pull request #13006 from vivescene/perf/selector-type-first
🤖 Agent: verify failed only on main's catalog_matches_hook_points bug (fixed by #13028) or the old test_nightly_set one. I merged main into the branch and requeued; nothing in the PR changed.
2026-11-09 02:00:47 +01:00
Dylan FerreyraandClaude Opus 5.5 ba998968f6 Move play.rs's inline tests into packets/play/tests.rs
The 36 tests of horizon-protocol's packets/play.rs move unchanged into
a test module of its own (`#[cfg(test)] mod tests;`); rustfmt dedents
them one level and joins three statements that now fit on a line.
horizon-protocol keeps all its tests, with the same names, and play.rs
goes from 4,173 to 2,730 lines.

Part of #13063.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 22:00:35 -03:00
M0-kii e07e18f925 Reject invalid parity shard selections
Validate the shared shard index and count before slicing cases or
choosing an output label. Use the same parser in the redstone catalog
harness before it loads game resources.
2026-11-09 04:30:17 +03:30
bulkandClaude Opus 5.5 6f3f47f4b9 Move dropped items and experience orbs out of game.rs into game/item_entities.rs
Pure move (#11857). The item and orb ticks (tick_items, tick_dropped_items, tick_experience),
spawn_destroyed_contents and the orb spawn and merge methods leave game.rs; private methods
become pub(super), the same reach they had in game.rs. Port map rows, the orb merge test that
reads their source and the AGENTS.md table follow the code.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 00:59:02 +00:00
Horizon agent 851ef6ebbf Merge main into #12693 (main's catalog_matches_hook_points fix, #13028) 2026-11-09 01:59:00 +01:00
Horizon agent 504f35eeb0 Merge main into #12818 (main's catalog_matches_hook_points fix, #13028) 2026-11-09 01:58:57 +01:00
Horizon agent 01e27e93f0 Merge main into #12817 (main's catalog_matches_hook_points fix, #13028) 2026-11-09 01:58:55 +01:00
Horizon agent 625844b654 Merge main into #13018 (main's catalog_matches_hook_points fix, #13028) 2026-11-09 01:58:52 +01:00
Horizon agent d51ac0e772 Merge main into #13009 (main's catalog_matches_hook_points fix, #13028) 2026-11-09 01:58:50 +01:00
Horizon agent 5852c51e76 Merge main into #13006 (main's catalog_matches_hook_points fix, #13028) 2026-11-09 01:58:46 +01:00
M0-kii fab1bf4f99 Resolve relative build timing target paths 2026-11-09 04:27:10 +03:30
Horizon agent e4f993d5b3 Merge remote-tracking branch 'origin/main' into m-13023
# Conflicts:
#	crates/horizon-client/src/game.rs
2026-11-09 01:56:59 +01:00
Ludovic Marie 9cd7b899eb Merge pull request #13002 from NikitaMGrimm/layers/portable-check
🤖 Agent: the portable rule reads the same cargo metadata packages as the layer check; a listed crate that stops being an engine crate fails too, so a rename can't silently drop the rule. Merged main in (#12983 landed); 16 tests and the check pass on the result.
2026-11-09 01:56:25 +01:00
Ludovic Marie 1db479a6eb Merge pull request #12956 from Teiwazik/motion/moving-geometry
🤖 Agent: each projectile's run is closed with an id-0 run, so geometry drawn after it never inherits its motion; potions with no id yet keep the camera's.
2026-11-09 01:56:21 +01:00
Horizon agentandClaude Opus 5.5 1197c246ce The renderer's telemetry leaves horizon-client-render (layer plan M3)
render/telemetry.rs moves, unchanged apart from its GPU source, to
horizon-client-lib (crates/horizon-client/src/telemetry.rs), still at
horizon_client::telemetry. The renderer keeps only engine facts and
extension points:
- gfx::Adapter / gfx::adapter() / gfx::gpu_kind: the adapter as found,
  which telemetry's system() reads (was telemetry::set_gpu).
- crash::on_report(hook): runs each report before it is saved; entry.rs
  registers telemetry::keep_crash at startup (was a direct call).

horizon-client-render no longer depends on horizon-telemetry or
horizon-bench: two layer exceptions go (47 -> 45).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 01:56:18 +01:00
Ludovic Marie 24321e2da7 Merge pull request #12987 from martipops/ui/shader-shell
🤖 Agent: over the ~500-line guideline (+1731/−1519), but it is one screen moving onto the shell plus the shell parts that screen needs, so it lands as is. The collapsed page list lays out from the drawn font, so the clicks match what is shown.
2026-11-09 01:56:18 +01:00
Ludovic Marie aad77e73e1 Merge pull request #12967 from k3yh/feat/mod-fluid-declarations
🤖 Agent: mod fluids join minecraft:fluid after vanilla's (source, then flowing) and the content identity, so a client without them is refused. Nothing places them yet.
2026-11-09 01:56:14 +01:00
Ludovic Marie 133a9e6cb8 Merge pull request #12999 from k3yh/feat/fluid-kind-table
🤖 Agent: FluidKind becomes a table index with no behaviour change; same_family and is_source compare source rows, and FluidMask stays u8 until mod fluids need more.
2026-11-09 01:56:10 +01:00
Ludovic Marie 9c364b492d Merge pull request #12966 from voduff/armor-glint-inventory-preview
🤖 Agent: the preview glint draws in the model's own depth pass (additive, LEQUAL, no depth write), so it never shows through other parts.
2026-11-09 01:56:05 +01:00
Ludovic Marie db63f2f6fe Merge pull request #12975 from iseaprojects/perf/sprite-mip-chains
🤖 Agent: the mip chains are built a chunk of layers at a time in parallel; the equality test pins the same bytes as the serial build.
2026-11-09 01:56:01 +01:00
Ludovic Marie 59e4e5bc0c Merge pull request #12968 from DUzzL/fix/12896-tnt-explosion-power-number
🤖 Agent: PrimedTnt reads explosion_power with getFloatOr, so any numeric tag counts.
2026-11-09 01:55:58 +01:00
Ludovic Marie 127d79c02c Merge pull request #12970 from Solenopsisbot/perf/pack-sky-scratch
🤖 Agent: the shader pack sky scratch is retained behind a Mutex, which is taken once a frame, so it can't contend.
2026-11-09 01:55:55 +01:00
Ludovic Marie 0346fe5f36 Merge pull request #12992 from vivescene/perf/falling-block-in-place
🤖 Agent: falling blocks now move in place in the list instead of being removed and pushed again, which keeps their tick order.
2026-11-09 01:55:51 +01:00
Ludovic Marie 4af07e93ac Merge pull request #12995 from TheMrZZ/audit/connected-player-statistics-save-all
🤖 Agent: removed tools/probes, the harness and the doc page: they run only against paper-pumpkin-parity, which lives outside this repo. The 16-line PlayerList.saveAll fix and its 3 Game tests stay (they pass with prepared resources).
2026-11-09 01:55:47 +01:00
Ludovic Marie 7aed5be227 Merge pull request #12994 from Ragbie/fix/projectile-on-ground
🤖 Agent: merged main (corpus groups: kept both); a rocket's on_ground = wanted.y < 0 && moved.y != wanted.y is setOnGroundWithMovement's verticalCollisionBelow, and setPos-movers keep the loaded flag.
2026-11-09 01:55:43 +01:00
Ludovic Marie 529d713ba0 Merge pull request #12976 from vivescene/perf/piston-push-area
🤖 Agent: merged main, keeping both piston_push fns (yours and main's block triggers); pushes only read bodies within 2 blocks of a moving block, which is safe because only the pushed body moves during the pushes.
2026-11-09 01:55:39 +01:00
DUzzL bcb1be65a9 Start a fresh armor stand off the ground 2026-11-09 01:55:09 +01:00
Horizon agentandClaude Opus 5.5 dc8b7dd1c0 rustfmt the payload's tests
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 01:54:17 +01:00
Horizon agentandClaude Opus 5.5 603ff7030d Far terrain: keep a share of the kept-tile budget sized to the view
512 MB is about one 512-chunk view. A shorter far distance now keeps the same
share of it as its share of the tiles that view plans, so travelling with a
short far distance doesn't fill the whole budget with old places.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 01:53:32 +01:00
Horizon agent 3588d63f6b Merge remote-tracking branch 'origin/main' into layers-1/portable-check 2026-11-09 01:52:15 +01:00
bulkandClaude Opus 5.5 f22ba38e1d Move the frame's CPU-built geometry out of game.rs into game/frame_geometry.rs
Pure move (#11857). Game::cracks, celestial_geometry, first_person_arm, hand_geometry and
weather_geometry leave game.rs; private methods become pub(super), the same reach they had in
game.rs. Port map rows, the hand hook test that reads hand_geometry's source and the AGENTS.md
table follow the code.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 00:52:09 +00:00
Ludovic Marie 257e4f3f88 Merge pull request #12990 from horizon-rust/layers/check-addresses
🤖 Agent: check_addresses.py tokenizes Rust (strings, raw strings, comments masked) and strips #[cfg(test)] items, so test URLs and translation keys like mco.info don't count as hosts; allowed in tools/addresses.json can only shrink against --base.
2026-11-09 01:52:05 +01:00
Horizon agent 673bf94146 Merge main into #12995 2026-11-09 01:51:35 +01:00
Horizon agent 0cc69c80a3 Merge main into #12994 2026-11-09 01:51:33 +01:00
Horizon agent 7dbbd8bc9f Merge main into #12976 2026-11-09 01:51:29 +01:00
Horizon agentandClaude Opus 5.5 8a9f5a1288 horizon-system: the machine's probes leave horizon-telemetry (layer plan M2)
The OS, CPU, memory, drive, power and GPU probes move, unchanged, from
horizon-telemetry/src/system.rs to a new engine crate, horizon-system, with
a `Machine` description. The crash report and the renderer's GPU info read
it directly; the crash report's own copy of `command_output` goes (the
shared one opens no console window on Windows).

The Horizon layer depends on the platform only (docs/layers.md), so
telemetry reaches the probes through a first platform crate,
horizon-platform, that re-exports horizon-system as `system`.
`horizon_telemetry::system` stays as a path for the launcher and the
benchmark; `System::detect_in` copies a `Machine` into the payload.

No new layer exception: render -> telemetry stays until M3 moves the
renderer's telemetry module out.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 01:49:58 +01:00
Ludovic Marie c57792a7c3 Merge pull request #12961 from rdbtCVS/refactor/game-world-update
🤖 Agent: pure move (only paths change), checked line by line.
2026-11-09 01:49:38 +01:00
Ludovic Marie 40fe249d09 Merge pull request #12950 from rdbtCVS/refactor/game-use-hand
🤖 Agent: pure move (only paths change), checked line by line.
2026-11-09 01:49:35 +01:00
Ludovic Marie 246b32c604 Merge pull request #12947 from rdbtCVS/refactor/game-launch-options
🤖 Agent: pure move (only paths change), checked line by line.
2026-11-09 01:49:31 +01:00
Ludovic Marie 0c65c8d47b Merge pull request #12952 from TheApple05/fix/12944-client-redstone-preview
🤖 Agent: the client placement preview stays client-side: TntBlock.prime and PistonBaseBlock.onPlace act only on the server; new harness client_redstone_preview.
2026-11-09 01:49:27 +01:00
Ludovic Marie fc442a81aa Merge pull request #12955 from DUzzL/fix/12895-attribute-base-number
🤖 Agent: attribute base reads any numeric tag (getDoubleOr).
2026-11-09 01:49:23 +01:00
Ludovic Marie df31a2fe7d Merge pull request #12954 from DUzzL/fix/12897-dragon-nbt-number-types
🤖 Agent: dragon Health/DragonPhase/DragonDeathTime read any numeric tag, as getFloatOr/getIntOr do.
2026-11-09 01:49:19 +01:00
Ludovic Marie 2756d39448 Merge pull request #12963 from Solenopsisbot/perf/flat-groups-inline
🤖 Agent: flat renderer groups inline in a SmallVec, no heap for the common few.
2026-11-09 01:49:15 +01:00
Ludovic Marie 33f2d64da8 Merge pull request #12960 from Solenopsisbot/perf/pack-post-attachments
🤖 Agent: SmallVec for pack post attachments; the explicit drop() ends the borrow before the pass.
2026-11-09 01:49:11 +01:00
Ludovic Marie 1d6a65b2b0 Merge pull request #12953 from Solenopsisbot/perf/hud-runs-scratch
🤖 Agent: runs_in gives the same runs as runs() plus the clip (checked), with the blend runs inline.
2026-11-09 01:49:07 +01:00
Ludovic Marie 23e9193dc3 Merge pull request #12949 from Solenopsisbot/perf/sound-tick-scratch
🤖 Agent: reuses the sound engine's per-tick scratch; no change to what plays.
2026-11-09 01:49:04 +01:00
Ludovic Marie 0b65f696e2 Merge pull request #12946 from Solenopsisbot/perf/streaming-scratch-reuse
🤖 Agent: keeps the streaming update's scratch vectors across frames instead of reallocating them each update.
2026-11-09 01:49:00 +01:00
Ludovic Marie 51a0732053 Merge pull request #12912 from Solenopsisbot/perf/share-entity-models
🤖 Agent: merged main after #12899: one load_with_visibility_models_and_defaults takes both the decoded entity-models document and the item defaults, so neither is read twice.
2026-11-09 01:48:57 +01:00
Ludovic Marie b33b610d56 Merge pull request #12884 from Detimes/hooks-chat
🤖 Agent: merged main (native_hooks.rs / native_server.rs: kept both sets of functions); chat hooks come from fragments stamped at build.
2026-11-09 01:48:53 +01:00
Nikita Maximilian Grimm 00ee749a58 Merge remote-tracking branch 'origin/main' into layers/portable-check 2026-11-09 01:48:49 +01:00
Ludovic Marie 08eb26ad9e Merge pull request #12852 from ilguerro/fix/landing-vibrations
🤖 Agent: merged main (vibration_tests.rs: kept both test tails); trampled farmland and broken turtle eggs now emit their game events where vanilla does.
2026-11-09 01:48:49 +01:00
Ludovic Marie 1cd834a3d8 Merge pull request #12885 from Detimes/hooks-fake-players
🤖 Agent: fake-player part only now (the per-tick box cache went to #13051); guests still build the others' current boxes each turn, as main does. Benchmark posted above.
2026-11-09 01:48:45 +01:00
Horizon agent 2bb1ef83e4 Merge main into #12885 (main's catalog_matches_hook_points fix, #13028) 2026-11-09 01:44:46 +01:00
Ludovic Marie 5abd285cc1 Merge pull request #12983 from horizon-rust/layers/check-deps
🤖 Agent: tools/check_layers.py only reads cargo metadata (--no-deps --offline), so nothing builds and nothing players run changes; with --base it refuses any new exception in tools/layers.json unless the crate itself is new (a split-out crate keeps its parent's).
2026-11-09 01:44:39 +01:00
Horizon agent 62707dae44 Merge remote-tracking branch 'origin/main' into layers/check-deps 2026-11-09 01:43:43 +01:00
Horizon agent f4aeb6aa14 Merge main into #12912 2026-11-09 01:43:39 +01:00
Horizon agent 040629ea7e Merge main into #12884 2026-11-09 01:43:36 +01:00
Horizon agent 6ba328d7ca Merge main into #12852 2026-11-09 01:43:11 +01:00
Horizon agent 812188bcc4 Merge remote-tracking branch 'origin/main' into m-13035
# Conflicts:
#	crates/horizon-client/src/game/levels.rs
2026-11-09 01:40:31 +01:00
Horizon agentandClaude Opus 5.5 f66c07b0c3 Drop the item-cooldown fixture that needs an outside checkout
tools/probes/item_on_block_cooldown ran only against paper-pumpkin-parity, a
framework outside this repo, so nobody here (or CI) can run it. The fix and its
Game tests stay.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 01:40:14 +01:00
Nikita Maximilian Grimm f4cb8d2fba Merge remote-tracking branch 'origin/main' into hdr-scaled-screenshots 2026-11-09 01:38:01 +01:00
Nikita Maximilian Grimm c2b8c12bb7 Merge remote-tracking branch 'origin/main' into hdr-settings 2026-11-09 01:37:58 +01:00
Ludovic Marie c76286a74a Merge pull request #13028 from rdbtCVS/fix/push-voice-helper-section
🤖 Agent: right fix for main's catalog_matches_hook_points failure: push_voice is a helper in its fragment, and the test reads every native_ fn in the hook-points block as a hook; the moved function is byte-identical. My miss on #12869 (I ran the stamp test, not this one).
2026-11-09 01:36:50 +01:00
DUzzL 8c39d11219 Piston pushes clear OnGround on boats and minecarts 2026-11-09 01:32:18 +01:00
Evrim CicekandClaude Opus 5.5 f7063d3621 A boat floats at vanilla's height: its water checks sum in float
AbstractBoat.checkInWater and isUnderwater add a block's y and its water
height in float, and floatBoat lands a falling boat at the float
getWaterLevelAbove() - getBbHeight(), with waterLevel = getY(1.0). Horizon
summed in double, so high up a boat floated about 3e-6 off vanilla's height.

Fixes #13022.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 03:30:08 +03:00
Horizon agent d7bbe2f5e3 Merge remote-tracking branch 'origin/main' into layers/check-deps 2026-11-09 01:25:53 +01:00
voduffandClaude Opus 5.5 6feba56d0e Fishing bobbers run the struck block's onProjectileHit (#12936)
FishingHook.onHitBlock calls Projectile.onHitBlock before it rebounds,
so a bobber powers a target block (target_hit, the criterion), rings a
bell struck on its body (bell_ring), chimes amethyst and tilts a big
dripleaf. Horizon's bobber only posted PROJECTILE_LAND.

The bell and target helpers take a ProjectileHitter (owner, guest,
arrow, type, position) that thrown projectiles and the bobber both
build. New harness test_fishing_hook_blocks_parity.py casts onto
target blocks on vanilla's and Horizon's dedicated servers.

Fixes #12936

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 00:21:31 +00:00
Arthur BlackandClaude Opus 5.5 96f310676c Android controller decoder (Android input, part 7)
- android_gamepad: Android key codes and motion axes to gamepad::PadState
- Y axes flipped to up-positive; hat D-pad adds to a key D-pad
- Triggers from LTRIGGER/RTRIGGER, BRAKE/GAS or digital L2/R2, so Edges sees all three
- Presses shorter than a frame survive one poll; drift and key repeats never take over
- Selection: picked input mode vs Auto following the last device used
- Pure logic with unit tests; nothing calls it yet
- Adapted from Starling (MIT), credited in THIRD_PARTY_NOTICES.md

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-11-08 16:19:30 -08:00
DUzzL 01ecb800e2 Merge remote-tracking branch 'upstream/main' into fix/12908-tnt-fuse-number
# Conflicts:
#	crates/horizon-client/src/game/commands/data.rs
2026-11-09 01:15:19 +01:00
Florian ErnstandClaude Opus 5.5 78151f0e02 A single lead's pull turns and adds its push as vanilla does
Leashable.computeElasticInteraction turns every attachment point with
Vec3.yRot(float), Mth's float cosine and sine of a float angle, and
checkElasticInteractions adds the whole push in one addDeltaMovement:
v + (force * axis + relative * 0.11). Horizon turned a single lead's
points with exact trigonometry (only the quad lead used Vec3.yRot) and
added the push as (v + force * axis) + relative * 0.11, so a pulled
mob's Motion and yaw drifted once it had turned.

Both ends of a single lead now turn with the same Vec3.yRot helper as a
quad lead, and the push is added as one sum. The sum is the line the
quad lead shares, so a quad lead's Motion now follows vanilla's formula
too.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 01:14:24 +01:00
bulkandClaude Opus 5.5 f858ee752f Native hooks: keep native_push_voice with the helpers, not the hook points
#12869 made push_voice a helper (tools/native-hook-fragments/push_voice.json lists it under
"helpers", with no hook) but left native_push_voice in the hook points' impl block, which
catalog_matches_hook_points reads as the list of hook points: the test failed on main with
push_voice as the only difference. The method moves, unchanged, to the helpers section next to
native_add_toast; its path (game::native_hooks::Game::native_push_voice) is the same.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 00:13:27 +00:00
DUzzL e47abbc7fe Read any numeric fuse tag when /data loads a primed TNT 2026-11-09 01:13:13 +01:00
Horizon agent 90796bc21b Merge remote-tracking branch 'origin/main' into m-12793
# Conflicts:
#	crates/horizon-client/src/game/creatures.rs
#	crates/horizon-client/src/projectiles.rs
2026-11-09 01:12:27 +01:00
Florian ERNSTandCodex 773e7850b5 Start the item cooldown after successful block placement
Reuse the existing component cooldown helper after the final successful
placement and hand replacement, preserving block-first interaction order.
Add real Guest/Game packet regressions and a scoped connected-client probe.

Co-Authored-By: Codex <[email protected]>
2026-11-09 01:10:44 +01:00
Horizon agentandClaude Opus 5.5 1df9099c8f Drop the connected-statistics harness that needs an outside checkout
tools/test_connected_statistics_save_parity.py and its probe ran only against
paper-pumpkin-parity, a framework outside this repo, so nobody here (or CI) can
run them. The fix and its Game tests stay.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 01:09:07 +01:00
Evrim CicekandClaude Opus 5.5 338a8c36f8 A falling boat or minecart resets its fall through water and vines a fast move passes
Entity.move casts the move (up to 8 blocks) through fall-damage-resetting
blocks and water when the entity has a fall and moves a block or more, and
resets the fall on a hit before the move's own drop counts. Boats, derailed
minecarts and a LAN guest's boat move through their own collision and
skipped it. Water is clipped through the shared fluid shape cache, as the
player's ray does.

Fixes #13005.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 03:08:42 +03:00
Horizon agent 266bdda26e Merge remote-tracking branch 'origin/main' into m-12994
# Conflicts:
#	tools/command_exec_corpus.py
2026-11-09 01:08:21 +01:00
Horizon agent f5515b409c Merge remote-tracking branch 'origin/main' into m-12976
# Conflicts:
#	crates/horizon-client/src/game/piston_push.rs
2026-11-09 01:08:05 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 ee0a6c2093 HDR float frame: scaled screenshots keep HDR
With HDR on the float frame, an F2 taken at a Screenshot Quality above 1
or a Resolution Scale above 100% drew its larger frame without HDR: no
HDR lighting, no HUD layer and no tone curve. The saved PNG was the plain
SDR picture, much darker than the same view's unscaled F2, and it had no
-hdr.png.

The scaled frame now keeps HDR on the layered float frame and draws in
HDR's own targets at the larger size, without presenting. Its PNG is the
unscaled F2's picture at that size (the world mapped to SDR as
Hdr::sdr_screenshot maps it, the HUD over it by coverage), with its
-hdr.png beside it. Then the large targets are let go, so they don't
stay in video memory, and the window's next frame makes its own again.

HDR off, frames that aren't layered, unscaled screenshots and the
window's own frames are unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 01:02:29 +01:00
Ludovic Marie 976346aff5 Merge pull request #13014 from horizon-rust/docs/contributing-policies-2026-11-09
🤖 Agent: the large-PR process says the spec check is run by maintainers' agents; nothing enforces it in CI yet, so reviewers should ask for the 'Spec: #' link on PRs over ~500 lines.
2026-11-09 01:00:55 +01:00
Ludovic Marie 141842b509 Merge pull request #13010 from horizon-rust/docs/readme-no-parity-percent
🤖 Agent: tools/parity_gaps.py anchored its badge fallback on the vanilla-parity badge this removes; it now inserts at the end of the badge row, and test_parity_gaps checks that order.
2026-11-09 01:00:51 +01:00
Florian ErnstandClaude Opus 5.5 e5f333e885 Test: a fence leash pulls a pig as vanilla does
Checkpoint before the fix: the hosted Game test that binds the live knot.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 00:58:48 +01:00
Horizon agentandClaude Opus 5.5 63e8ff40fe CI and pr_ready: a Rust file over 3000 lines may not grow
From the 2026-11-08 contributors' stage: game.rs (~21.7k lines) and
gfx.rs (~10k) slow incremental builds. tools/check_file_size.py
compares each added, changed or renamed .rs file with the merge base:
over 3000 lines it may shrink or stay, never grow; a new file over the
limit fails. No list of files; third_party/ and @generated files are
not checked. Runs on pull requests (with their base) and in pr_ready.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 00:57:19 +01:00
Horizon agentandClaude Opus 5.5 e3242b385b CONTRIBUTING: large PRs, 26.3 only, vendor libraries, JVM bridge
Writes down the project's decisions of 2026-11-09: pull requests over
~500 lines start from an approved spec issue and an agent check against
it; parity work stays on 26.3 (redstone first), newer servers via Via;
AMD FSR may be vendored but NVIDIA-licensed libraries only as an
optional native mod that downloads them; the JVM bridge is a separate
optional download, never on by default. The PR template points large
PRs at the spec issue.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 00:54:03 +01:00
Horizon agent f3418e1525 Merge branch 'layers/check-addresses' into layers/addresses-home 2026-11-09 00:51:14 +01:00
Horizon agent c4c0b54fad Merge branch 'layers/check-deps' into layers/check-addresses 2026-11-09 00:51:13 +01:00
Horizon agentandClaude Opus 5.5 a33d57b36e check_layers: each layer's allowed layers; Horizon uses the platform only
Ludovic, 2026-11-09: the Horizon layer moves to its own repository
after the split, so it builds against the platform's public API and
never the engine. tools/layers.json now says which layers each layer
may depend on (engine: engine; platform: engine, platform; horizon:
platform, horizon). The launcher's and horizon-server's 7 engine
dependencies join the exceptions (47).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 00:50:40 +01:00
Horizon agentandClaude Opus 5.5 b4e940a4ec README: drop the 100% parity badge and heading
The README now says what the harnesses compare instead of a parity
percentage: the "vanilla parity 100%" badge and the "Java parity: 100%"
heading go, the harness table and the player-visible gaps block stay.
tools/parity_gaps.py no longer anchors its badge on the parity badge,
and the README tests check the table without a percentage.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 00:50:20 +01:00
DangerousbrosandClaude Opus 5.5 02cc303caa Far terrain: the seam is sampled fresh on a return, and fine tiles wait for the near terrain
Kept tiles at the seam with the near chunks (`Plan::stands_in`) are dropped
on a return and sampled first, as before kept tiles: brought back, they drew
grey ground and caves there until the near terrain loaded. The fine levels
(cells a chunk wide or less) wait while the near terrain loads and the seam
is open, ten seconds at most: their thousands of meshes held up the near
chunks' and the seam's.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 23:49:54 +00:00
voduffandClaude Opus 5.5 9e053b5fab Spawn yaw: /summon without NBT faces 0, spawn eggs wrap their draw (#12973)
- SummonCommand.createEntity loads a mob summoned without NBT from
  {id:..}: yaw 0 and no level-random draw (Horizon drew one, which also
  shifted every later level roll).
- EntityType.create snaps a spawn egg's, a dispensed egg's and a mob
  bucket's mob to Mth.wrapDegrees(level.random.nextFloat() * 360).
- SpawnEggItem.spawnOffspringFromSpawnEgg snaps the baby to yaw 0.

spawn_mob_internal takes a SpawnYaw (Level, Created, Fixed); other
callers keep the old draw. Seed sync proof gains three cases (18/18;
main's code: 16/18).

Fixes #12973

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 23:47:26 +00:00
Horizon agent 6865327231 Merge remote-tracking branch 'origin/main' into s-12884
# Conflicts:
#	crates/horizon-client/src/game/native_hooks.rs
#	crates/horizon-client/src/game/native_server.rs
2026-11-09 00:43:38 +01:00
Horizon agent 39f04d6797 Merge remote-tracking branch 'origin/main' into s-12852
# Conflicts:
#	crates/horizon-client/src/game/vibration_tests.rs
2026-11-09 00:42:16 +01:00
bulkandClaude Opus 5.5 ca46e45c7d Move four inline test modules out of game.rs into their own files
Pure move (#11857). startup_cpu_tests, runtime_reset_tests, falling_air_tick_tests and
player_save_projection_tests become game/<name>.rs, like the game module's other test
files; game.rs keeps their #[cfg(test)] mod declarations where the blocks were. The bodies
lose one level of indentation and nothing else.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 23:42:11 +00:00
Horizon agent 20cf432402 Merge remote-tracking branch 'origin/main' into s-12912
# Conflicts:
#	crates/horizon-client-render/src/block_entity_render.rs
#	crates/horizon-client/src/game.rs
2026-11-09 00:41:35 +01:00
Ludovic Marie 3a00fb7f85 Merge pull request #12891 from Detimes/hooks-bundles
🤖 Agent: the launcher lets a bundle's package depend on the bundle's own crates (last part of the #12409 split; no game code).
2026-11-09 00:41:29 +01:00
Ludovic Marie d44bca934e Merge pull request #12857 from NikitaMGrimm/fix-dispensed-egg-persistence
🤖 Agent: a mob from a dispensed spawn egg is not persistent, except the elder guardian and copper golem, which vanilla makes persistent.
2026-11-09 00:41:23 +01:00
Ludovic Marie ce5ba9d529 Merge pull request #12847 from Mik00000/perf/structure-locate-vanilla-order
🤖 Agent: findNearestMapStructure searches ring by ring across every random-spread set, takes each set's first hit per ring and stops at the first ring with a hit.
2026-11-09 00:41:17 +01:00
Ludovic Marie 0497e8bc74 Merge pull request #12840 from baytyone/parallel-mob-ticks-groundwork
🤖 Agent: the hub's per-player sweep runs through par_chunks_mut from 16 players up and stays serial below, where threads cost more than they save.
2026-11-09 00:41:14 +01:00
Ludovic Marie a5d17ed836 Merge pull request #12837 from NikitaMGrimm/vanilla-tick-script-pointer
🤖 Agent: the vanilla tick-script client ignores the person's keyboard and pointer while a script drives it.
2026-11-09 00:41:10 +01:00
Ludovic Marie 857c741402 Merge pull request #12836 from NikitaMGrimm/scripted-run-input-lock
🤖 Agent: scripted runs ignore the person's input, --control windows open unfocused, and the launcher passes --control-socket only from its MCP play.
2026-11-09 00:41:06 +01:00
Ludovic Marie d3017b792e Merge pull request #12890 from Detimes/hooks-inventories
🤖 Agent: part of the #12409 split; its fragment stamps at build with the others (stamp test passes on the union).
2026-11-09 00:41:02 +01:00
valuttaandClaude Opus 5.5 465a6752f4 Selectors test type= before reading tags
A datapack's @e[type=marker,tag=...] selectors got quadratically slower
with every other entity in the level: select_in_level ran the tag/score/team
filter on every candidate before the type test, and tags_of finds each
entity by a linear search (about 40% of the server thread with 8,000
falling blocks around).

EntitySelector tests the type first. With type= and no scores= or team=
(whose holders may be made as they are read), the rest of the extra filter
is a pure test of each candidate, so the candidates of another type now
leave before it. The type test moves into selector_types_match, used for
both passes.

Fixes #13003

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 00:40:49 +01:00
DetimesandClaude Opus 5.5 0e8f5dbb8a Merge upstream/main; keep the shared box list and send_shared out
Merges main (keeping its NativeServerPlayer fields next to `fake`) and drops
the two parts that reach every server, as asked in review: the players' boxes
built once per tick (BUILDING_CACHE) and `send_shared` for broadcasts. The
first moves to its own PR with a fix and tests; the second only saved a copy
for fake players and is dropped. The fake-player tick benchmark stays.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 23:40:26 +00:00
bulkandClaude Opus 5.5 70f7da15c8 Move a melee hit on a mob out of game.rs into game/melee.rs
Pure move (#11857). Game::attack_mob (Player.attack on a mob) joins the rest of the
player's melee attack in game/melee.rs; it was pub(super) already and stays so.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 23:38:10 +00:00
Horizon agent fcb88320e1 Merge main into #12891 (main's test_nightly_set fix, #12783) 2026-11-09 00:37:41 +01:00
Horizon agent 3808ecf716 Merge main into #12857 (main's test_nightly_set fix, #12783) 2026-11-09 00:37:37 +01:00
Horizon agent 0fcdcc425a Merge main into #12847 (main's test_nightly_set fix, #12783) 2026-11-09 00:37:34 +01:00
Horizon agent 1700772be1 Merge main into #12840 (main's test_nightly_set fix, #12783) 2026-11-09 00:37:31 +01:00
Horizon agent 167e772c20 Merge main into #12837 (main's test_nightly_set fix, #12783) 2026-11-09 00:37:28 +01:00
Horizon agent 298ca95813 Merge main into #12836 (main's test_nightly_set fix, #12783) 2026-11-09 00:37:02 +01:00
Ludovic Marie 9803a016c4 Merge pull request #12869 from redstonerthebest/mod-api/push-voice
🤖 Agent: push_voice keeps one FIFO per (mod, source), so a speaker's blocks play in order while speakers play together; the helper is a stamped fragment.
2026-11-09 00:35:06 +01:00
DUzzL 6ea8967d2f Use the guest's authoritative dimensions for horizontal piston contact 2026-11-09 00:34:33 +01:00
Florian ERNSTandCodex 121c6b27d5 Save connected-player statistics with world metadata
Write actual per-connection statistics after the existing player body
queue, retaining the current UUID mapping and JSON writer. Cover missing
and stale files through the real Game save command and connected input
fixture, with separate disconnect positives.

Co-Authored-By: Codex <[email protected]>
2026-11-09 00:29:01 +01:00
Ludovic Marie 7d0f8b956b Merge pull request #12979 from horizon-rust/layers/design-doc
🤖 Agent: docs only; the order engine < platform < Horizon follows Ludovic's rule (engine never depends on platform), which makes today's exceptions 40, all from the four mixed engine crates.
2026-11-09 00:27:55 +01:00
bulkandClaude Opus 5.5 d9482ad26d Move lighting a nether portal out of game.rs into game/nether_portal.rs
Pure move (#11857). Game::portal_shape (PortalShape) and try_light_portal
(BaseFireBlock.onPlace's portal) leave game.rs for the nether portal file, which imports
Result and level_flags from the game module for them; private methods become pub(super),
the same reach they had in game.rs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 23:27:52 +00:00
Ludovic Marie 6de492a6fc Merge pull request #12913 from MrCloudy2/fix/projectile-bubble-column
🤖 Agent: every applyEffectsFromBlocks projectile (throwables, hurting projectiles, shulker bullets) is pushed by the bubble columns it moved through, unclamped, on the server as on the client.
2026-11-09 00:27:51 +01:00
Evrim CicekandClaude Opus 5.5 67e5c2d205 Projectiles, rockets and falling blocks save and load OnGround
Entity.load reads OnGround and saveWithoutId writes the live flag. Arrows,
thrown items, fireballs, spit, bullets and tridents move by setPos and keep
the loaded flag; a firework rocket and a falling block take theirs from
Entity.move, and a piston push sets it on all of them. Llama spit and
shulker bullets also keep their saved or summoned UUID.

Fixes #12898.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 02:27:50 +03:00
Ludovic Marie 669e6b33d2 Merge pull request #12864 from J1zeee/fix/12752-ender-chest-end-portal-block-entities
🤖 Agent: an ender chest and an end portal get their block entities, and the ender chest's holds none of the player's items, so none are saved to the chunk (EnderChestBlockEntity saves nothing).
2026-11-09 00:27:47 +01:00
Horizon agentandClaude Opus 5.5 e9846abaf3 Mod audio: one queue per source, played in order; a native-hook fragment
push_voice mixed every queued chunk at once, so a speaker's second block
played over its first, and finished chunks were popped from the front of
one shared queue whatever their source. Each (mod, source) pair now has
its own queue: a source plays its head chunk, hands the rest of a buffer
to the next one, and sources play at the same time. Positioned audio
fades linearly to silence at 48 blocks (distance_gain(None, _) was always
1). A source holds two seconds at most, 64 sources play at once, and
nothing is queued without an output device or outside the window's game.

The generated host files are gone on main: push_voice is a fragment
(tools/native-hook-fragments/push_voice.json), stamped at build.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 00:27:21 +01:00
Horizon agent e263c6a6ef Merge remote-tracking branch 'origin/main' into n-12869
# Conflicts:
#	crates/horizon-client-render/src/audio.rs
#	crates/horizon-client/src/game/mod_api_host_generated.rs
#	crates/horizon-client/src/mod_api/generated_host.rs
#	native-hooks.json
2026-11-09 00:26:35 +01:00
Ludovic Marie 3b695e3b18 Merge pull request #12942 from vnsmoreira/fix/spectator-water-vision
🤖 Agent: a spectator's underwater vision ramps 10 times faster, as LocalPlayer.getWaterVision does for spectators.
2026-11-09 00:24:33 +01:00
Ludovic Marie 78482b23de Merge pull request #12933 from Solenopsisbot/perf/recipe-source-reuse
🤖 Agent: filtered recipe sources are shared instead of rebuilt per caller; the recipe lists are unchanged.
2026-11-09 00:24:29 +01:00
Ludovic Marie 672e276940 Merge pull request #12927 from vnsmoreira/fix/end-clock
🤖 Agent: the End's clock is saved and ticked, so its time no longer resets on reload.
2026-11-09 00:24:25 +01:00
Ludovic Marie 896d960bd7 Merge pull request #12925 from senjinthedragon/audio-reopen-dead-stream
🤖 Agent: a dead audio output stream is reopened instead of leaving the game silent until restart.
2026-11-09 00:24:21 +01:00
Horizon agentandClaude Opus 5.5 fc912ff3a1 horizon-addresses: the Horizon layer's one home for built-in addresses
New crate (Horizon layer, no dependencies) holding the official server
and its earlier and status addresses, the cosmetics and telemetry
services, the STUN servers and the Friends help page.
horizon-telemetry, horizon-cosmetics and horizon-friends keep their
public constants, now read from it. tools/addresses.json loses 7
entries (82 -> 75). Same values, so players see no change.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 00:24:19 +01:00
Ludovic Marie 2a1437a1c0 Merge pull request #12921 from Solenopsisbot/perf/hud-stack-attachments
🤖 Agent: HUD render-pass attachments stay in an inline SmallVec instead of a heap Vec per pass; the passes are unchanged.
2026-11-09 00:24:18 +01:00
Ludovic Marie 1c385ab9b0 Merge pull request #12919 from iseaprojects/perf/save-light-layers
🤖 Agent: item components decode in one pass over the compound instead of one lookup per known component.
2026-11-09 00:24:14 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 06dc58c8a4 Layer check: the portable crates stay free of desktop libraries
docs/layers.md#engine: horizon-core, -world, -protocol, -items,
-command, -client-core and -assets hold vanilla's rules with no desktop
in them, so a port can build on them. The check now fails when one takes
a crate of the repository outside that list, or winit, wgpu, cpal,
gilrs, arboard or ureq (normal and build dependencies; tests may use
anything). All seven pass today.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 00:24:12 +01:00
Ludovic Marie 85878fa594 Merge pull request #12914 from Solenopsisbot/perf/hud-slot-memory
🤖 Agent: empty HUD slots build no sprite sources.
2026-11-09 00:24:10 +01:00
Ludovic Marie f35632c2a9 Merge pull request #12905 from senjinthedragon/pack-boat-water-mask
🤖 Agent: shader packs get the boat's water patch as a depth-only mask; only packs that read it change.
2026-11-09 00:24:07 +01:00
Ludovic Marie de4ed2745c Merge pull request #12899 from Solenopsisbot/perf/shared-item-defaults
🤖 Agent: item default components are shared instead of cloned per stack.
2026-11-09 00:24:03 +01:00
valuttaandClaude Opus 5.5 bab2e82183 Falling blocks move in place in the list
A few thousand falling sand blocks took the server tick to 600-800 ms:
tick_one_falling took each block out of self.falling and put it back, every
tick, and each of the two shifted every block after it (55% of the server
thread in memmove). The block was only taken out so fall_step could borrow
it beside self; nothing the move reads looks at the falling blocks.

It now moves a copy and writes it back where it is. One that lands, drops
or goes still leaves the list before its block is placed or dropped, and
on an error the list keeps it as it was.

Fixes 12991

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 00:23:47 +01:00
Horizon agent ef2ce44b59 Merge branch 'layers/check-deps' into layers/check-addresses 2026-11-09 00:22:33 +01:00
Horizon agent f4938d63aa Merge branch 'layers/design-doc' into layers/check-deps 2026-11-09 00:22:06 +01:00
Horizon agentandClaude Opus 5.5 0b5e417e4c check_layers: a new dependency target excuses no new exception
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 00:22:05 +01:00
Horizon agentandClaude Opus 5.5 7c2db89229 docs/layers.md: only a newly split crate may carry exceptions
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 00:21:36 +01:00
Horizon agentandClaude Opus 5.5 fe04f52326 CI and pr_ready: built-in outside addresses stay in one home
tools/check_addresses.py reads the string literals of shipped Rust
(tests and #[cfg(test)] items skipped) and finds every host: a URL's
host or a whole host-name string; placeholders and local IPs skipped.
tools/addresses.json: the Horizon layer's home, vanilla's endpoints,
strings that are not addresses (with reasons), and today's 82
addresses outside the home, which may move with their code but never
gain a host or grow (--base). Messages point at docs/layers.md.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 00:20:35 +01:00
Ludovic Marie 7a56b3516d Merge pull request #12882 from fbikat/fix-12845-boolean-nbt
🤖 Agent: boolean NBT fields read any numeric tag, as getBooleanOr does, not only a byte.
2026-11-09 00:17:39 +01:00
Ludovic Marie bd3c2d7a14 Merge pull request #12943 from vermingov/content-mod-jukebox-songs
🤖 Agent: a content mod's jukebox songs load under the mod's own namespace, so they cannot replace vanilla songs.
2026-11-09 00:16:03 +01:00
Ludovic Marie 230ff171f0 Merge pull request #12889 from Detimes/hooks-player-state
🤖 Agent: part of the #12409 split; its fragment stamps at build with the others (stamp test passes on the union).
2026-11-09 00:16:00 +01:00
Ludovic Marie 9a12a64459 Merge pull request #12888 from Detimes/hooks-entities
🤖 Agent: part of the #12409 split; its fragment stamps at build with the others (stamp test passes on the union).
2026-11-09 00:15:56 +01:00
Ludovic Marie fa727686e3 Merge pull request #12886 from Detimes/hooks-block-events
🤖 Agent: part of the #12409 split; its fragment stamps at build with the others (stamp test passes on the union).
2026-11-09 00:15:53 +01:00
Ludovic Marie 1e981c99c6 Merge pull request #12883 from Detimes/hooks-commands
🤖 Agent: first part of the #12409 split; its hook fragments are stamped at build and tests.test_stamp_native_hooks passes on the union.
2026-11-09 00:15:49 +01:00
Horizon agentandClaude Opus 5.5 790d1e3c27 CI and pr_ready: crates depend only on their own layer or a lower one
tools/check_layers.py reads the four workspaces' cargo metadata (no
deps, offline) and tools/layers.json (engine < platform < horizon, plus
tool). It fails on a crate with no layer, an upward normal/build
dependency 'allowed' doesn't list, an 'allowed' entry a branch adds
between known crates (--base), or shipped code on a dev tool; each
message names the docs/layers.md section. Stale exceptions are notes.
Starts with today's 40. The PR template gets a Layer section with the
reviewer checklist.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 00:14:59 +01:00
Nikita Maximilian Grimm e3cf8bb456 Merge remote-tracking branch 'origin/main' into hdr-settings
# Conflicts:
#	docs/graphics.md
2026-11-09 00:14:18 +01:00
bulkandClaude Opus 5.5 dd3c9c6562 Move a level's block tick phases out of game.rs into game/block_ticks.rs
Pure move (#11857). Game::tick_blocks and tick_blocks_with_scratch leave game.rs; private
methods become pub(super), the same reach they had in game.rs. The AGENTS.md table follows
the code.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 23:13:35 +00:00
Horizon agentandClaude Opus 5.5 08819d4636 docs/layers.md: engine < platform < Horizon, with each layer's rules
Per Ludovic's direction the engine never depends on the platform or
Horizon. Each layer says what belongs, real examples, what never
belongs and what it may depend on; a decision table says where a new
feature goes; a reviewer checklist. Linked from CONTRIBUTING.md and
CLAUDE.md. 40 upward dependencies today.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 00:12:31 +01:00
Ludovic Marie e5e6e26d91 Merge pull request #12984 from horizon-rust/docs/team-remove-qwerner
🤖 Agent: removes Qwerner88880's trusted-reviewer line after the Team vote; the pinned login set in test_nightly_set is updated in the same commit, so verify stays green.
2026-11-09 00:11:57 +01:00
bulkandClaude Opus 5.5 efe6d10ff1 Move falling block entities out of game.rs into game/falling_blocks.rs
Pure move (#11857). Game::tick_one_falling, fall_step and drop_falling_block leave game.rs;
private methods become pub(super), the same reach they had in game.rs. The port map row
follows the code.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 23:11:36 +00:00
Ludovic Marie 243ab4e220 Merge pull request #12929 from NikitaMGrimm/hdr-p0
🤖 Agent: shader API 0.2 packs get the HDR output and the layered HUD; with the env switch off, the frame is the same as main's.
2026-11-09 00:11:04 +01:00
Ludovic Marie 3799edbc34 Merge pull request #12928 from NikitaMGrimm/hud-8bit
🤖 Agent: the HDR HUD layer draws into an 8-bit target, so each HUD draw clamps and blends as vanilla's does; only with the HDR env switch on.
2026-11-09 00:11:01 +01:00
Ludovic Marie bab8bf1166 Merge pull request #12923 from TheMrZZ/fix/h169-mob-glide-travel
🤖 Agent: a fall-flying mob uses the glide travel, as LivingEntity.travelFallFlying does, not the ground travel.
2026-11-09 00:10:57 +01:00
Ludovic Marie f0d91bb791 Merge pull request #12903 from vivescene/perf/moving-piston-sync
🤖 Agent: the client's moving pistons are synced once per frame's batch of packets instead of once per packet.
2026-11-09 00:10:53 +01:00
Ludovic Marie 05e56173c2 Merge pull request #12892 from vivescene/fix/panorama-block-outline
🤖 Agent: the block outline is not drawn while the title screen's panorama renders.
2026-11-09 00:10:49 +01:00
Horizon agentandClaude Opus 5.5 25c5fd1db7 Remove Qwerner88880 from the trusted reviewers and the modding lead
The Horizon Team voted him out of the Team (2026-11-09).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 00:09:43 +01:00
k3yhandClaude Opus 5.5 4243b253d2 FluidKind indexes a fluid table; fluid numbers come from it
Step 2 of #6301 (horizon-world, and what the client needs to build): FluidKind
is a fluid's index in a table, its minecraft:fluid network id, with vanilla's
five first and the enum's names kept as constants. Each row has the fluid's
template (water or lava behaviour), its family's source and flowing forms,
and NeoForge's tick delay, drop-off, slope distance and source conversion,
which vanilla's leave to their template's rules (lava's ultrawarm ones
included). FlowingFluid's code reads them from the table; matches on the
five fluids match on the template. No behaviour change: fluid masks stay
8 bits (they widen with the behaviour cache's version when mod fluids join).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 18:09:35 -05:00
Ludovic Marie 577ea049d3 Merge pull request #12880 from d3xc0/android-touch-2
🤖 Agent: the touch aim helpers come from Starling under MIT and are credited; nothing calls them yet, so players see no change.
2026-11-09 00:08:18 +01:00
Ludovic Marie c1b0538540 Merge pull request #12878 from Solenopsisbot/perf/region-nbt-scratch
🤖 Agent: region NBT decoding reuses a thread-local scratch instead of allocating one per chunk.
2026-11-09 00:08:14 +01:00
Ludovic Marie 385393ec3c Merge pull request #12867 from MrCloudy2/fix/wind-charge-box
🤖 Agent: the wind charge box is 0.15F lower and its pick box is bounds.inflate, pinned by the new wind_charge_box harness.
2026-11-09 00:08:11 +01:00
Ludovic Marie 85ce314e76 Merge pull request #12865 from Solenopsisbot/perf/remote-guardian-scratch
🤖 Agent: the client's guardian eye look reuses its scratch instead of collecting a new Vec each frame.
2026-11-09 00:08:07 +01:00
Ludovic Marie bbe70b1ca2 Merge pull request #12861 from TheMrZZ/fix/invulnerable-time-save
🤖 Agent: invulnerable_time is kept apart from the hurt cooldown, so saving and loading it no longer resets the other; the new harness pins it.
2026-11-09 00:08:03 +01:00
Ludovic Marie ba81d33cb7 Merge pull request #12856 from Solenopsisbot/perf/projectile-wind-scratch
🤖 Agent: the pickable-projectile scan reuses one scratch vector instead of allocating per tick.
2026-11-09 00:07:59 +01:00
Ludovic Marie fb123d2479 Merge pull request #12854 from Solenopsisbot/perf/rest-load-fanout
🤖 Agent: the world's remaining resource loads join the existing scoped batch, so they no longer run one after another.
2026-11-09 00:07:56 +01:00
Ludovic Marie fa5554339e Merge pull request #12853 from NikitaMGrimm/parity/fill-block-nbt
🤖 Agent: /fill loads the given block NBT into each placed block entity and counts the blocks whose tags changed.
2026-11-09 00:07:52 +01:00
Ludovic Marie 0ebfe7d116 Merge pull request #12851 from ilguerro/fix/cauldron-vibrations
🤖 Agent: a cauldron emits fluid_pickup / fluid_place game events after its sound, as vanilla's cauldron interactions do.
2026-11-09 00:07:49 +01:00
Ludovic Marie bff36a3b38 Merge pull request #12843 from TheMrZZ/fix/last-hurt-by-mob-save
🤖 Agent: last_hurt_by_mob keys are written at save time; the new last_hurt_by_mob harness pins them (reading them back is not part of this PR).
2026-11-09 00:07:45 +01:00
Ludovic Marie 265b1a3cb7 Merge pull request #12860 from DenisHumen/fix/offhand-server-hop
🤖 Agent: joining a server or replaying clears the off hand too, not only the main inventory.
2026-11-09 00:07:42 +01:00
Ludovic Marie a2b3f4fe86 Merge pull request #12922 from MrCloudy2/fix/piston-push-triggers
🤖 Agent: a piston pushing an entity onto a pressure plate presses it; main's projectile block effects are kept alongside.
2026-11-09 00:07:38 +01:00
Ludovic Marie 03093772e4 Merge pull request #12846 from NikitaMGrimm/summoned-eye-of-ender
🤖 Agent: a summoned or loaded eye of ender flies, shatters after 80 ticks and saves its Item, Motion and Fire.
2026-11-09 00:07:35 +01:00
Ludovic Marie 62495116b5 Merge pull request #12900 from TheMrZZ/fix/h077-blending-heights-save
🤖 Agent: an old chunk keeps the blending heights computed from its neighbours in its save, as vanilla's BlendingData does, instead of recomputing them on load.
2026-11-09 00:07:31 +01:00
Ludovic Marie 549c7a2bc8 Merge pull request #12893 from TheMrZZ/fix/allay-pickup-cooldown
🤖 Agent: the allay's item_pickup_cooldown_ticks memory is saved and read back, so a reloaded allay does not pick its thrown item up again at once.
2026-11-09 00:07:27 +01:00
Horizon agentandClaude Opus 5.5 9b17b20107 docs: the three layers (engine, platform, Horizon) and every built-in address
Maps each crate and the mixed crates' modules to a layer, lists today's
11 upward dependencies and every built-in outside address with its use.
From duckybertduck's 3-layer proposal and arismoko's mod API boundary.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 00:03:26 +01:00
bulkandClaude Opus 5.5 e38e4f6bcc Move the startup resource loader out of game.rs into game/resource_loader.rs
Pure move (#11857). GameCpuResources, EntityModels, Loaded, LoaderMsg, PendingWorldEntry,
WorldRestLoader and their impls, run_loading and write_report leave game.rs, which imports
them back; private items, fields and methods become pub(super), the same reach they had in
game.rs. The port map row and the AGENTS.md table follow the code.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 23:03:19 +00:00
Ludovic Marie 5a562fb5a2 Merge pull request #12664 from CocofireHD/fix/12493-mob-pickup-particle
🤖 Agent: a mob picking up an item sends ClientboundTakeItemEntity, so clients see the item fly to it.
2026-11-09 00:01:57 +01:00
Ludovic Marie b4948662ae Merge pull request #12641 from itarqos5/perf/particle-collision-skip
🤖 Agent: particle collision skips sections whose palette holds no colliding state; the test compares it with the old full scan on 4000 boxes.
2026-11-09 00:01:53 +01:00
Ludovic Marie f1e42dcaf9 Merge pull request #12813 from MSedgeMC/fix/open-double-chest-half
🤖 Agent: an open double chest menu closes when one of its halves is broken (the container's size no longer matches), as CompoundContainer.stillValid does through ChestMenu.stillValid.
2026-11-09 00:01:50 +01:00
Ludovic Marie d9bd370a39 Merge pull request #12812 from MSedgeMC/fix/lectern-book-tag
🤖 Agent: a lectern accepts what the #lectern_books item tag lists, not a hard-coded pair of items.
2026-11-09 00:01:46 +01:00
Ludovic Marie e1899cca8f Merge pull request #12931 from vnsmoreira/packs/polytone-particle-modifiers
🤖 Agent: a Polytone particle modifier sets colour, life and size once per particle as it spawns, for the types it targets, vanilla and mod ones alike.
2026-11-09 00:01:43 +01:00
Ludovic Marie 834ecc9625 Merge pull request #12862 from Ragbie/fix/tp-on-ground
🤖 Agent: /tp zeroes the motion and sets on-ground unless fall-flying, as TeleportCommand.performTeleport does, and /spreadplayers zeroes it too.
2026-11-09 00:01:39 +01:00
Ludovic Marie f923aa5118 Merge pull request #12859 from Solenopsisbot/perf/pack-tail-fanout
🤖 Agent: Polytone particle emitters and dimension environments now load in assemble's existing scoped batch instead of after it; the values and their order are unchanged.
2026-11-09 00:01:35 +01:00
valuttaandClaude Opus 5.5 9b185113e5 Pistons push only the bodies in their reach
A clock of a thousand pistons spent most of each tick on its pushes: every
moving block walked every entity in the level and read its box, and the
collision view looked each piston up by scanning two lists.

- The tick's bodies are filtered once: a moving block's box sits within a
  block of its position and sweeps at most one more, so every box a push
  tests lies inside the block grown by 2. Only the pushed body moves during
  the pushes, so a body outside all of those boxes at the start is never
  reached. Each moving block then skips a body outside its own search area
  before the box math (level.getEntities in moveCollidedEntities).
- Step heights are read once per tick, not once per moving block.
- advance_piston_collision finds each piston through an index taken before
  the pushes, checked on use, falling back to the old search.

Fixes #12974

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 23:58:25 +01:00
Horizon agentandClaude Opus 5.5 47d2e49fc7 Bubble column test: every tick ends with the moved-through columns
ThrowableProjectile.tick and AbstractHurtingProjectile.tick both end with
applyEffectsFromBlocks after setPos, so the column pushes again after the
move, the first tick included; the first-tick scan alone is ThrowableProjectile's.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 23:58:10 +01:00
Horizon agent 92618fa3a9 Merge the ender chest storage fix 2026-11-08 23:58:08 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 f51e2b0ec1 A new mob or armor stand draws its UUID from its own random first
Vanilla's Entity makes its UUID from the first two draws of its own random
(Mth.createInsecureUUID), and LivingEntity.<init> then draws the yaw from it,
before a kind's constructor draws such as Chicken's egg timer. Horizon seeded a
server mob's own random from its id and position and gave mobs and armor
stands a clock-seeded UUID when first asked.

mobs::living_random makes that random: fresh, the UUID drawn and returned, the
yaw drawn. Spawned and loaded mobs (summons, spawn eggs, natural spawns, saved
chunks) take it through Mob::with_random and mob_from_nbt_with_random; placed,
dispensed and loaded armor stands take its UUID. A UUID in the tag still wins.
Mob::new keeps its id-seeded random for the client's copies. Applying NBT to a
live mob (/data, a spawn egg's entity data) keeps its random, as Entity.load
fills in the same entity.

The seed sync proof reads the UUIDs of a summoned chicken, pig and armor stand,
of a pig from a dispensed egg and of a dispensed armor stand, a chicken's
EggLayTime, and the next one a summoned chicken draws after laying: 15/22 on
main, 22/22 with this.

Part of #12561

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 23:56:18 +01:00
iseaprojectsandopencode 35f1f912a3 Block sprites: make the mip chains a chunk of layers at a time
`mip_chain` generated the sprite array's mips one layer at a time: at startup
and at every pack load or reload, a few thousand layers at 64 px for a 64x
pack ran on the pack worker alone, while the rest of the reload's decode
stages already spread over `par::par_map`.

Every layer makes its own chain from its own pixels, so the layers now run
through `par_map` in chunks of 16: a worker allocates one chain per chunk
rather than one per layer, and the chunks are appended in order, so the chain
stays layer major and each layer's settings still come from its global index.

The new `pack_load_bench` row over the HD pack's 1,100 layers takes 62 down to
11 ms. On a cutout-heavy array (1,300 layers at 64 px, a quarter of the texels
transparent) the same stage goes from 394 to 47 ms, and the vanilla-sized one
from 57 to 11 ms. A test asserts the parallel chain is byte for byte the one a
single thread making every layer gives, from the first layer and from the
middle of the array.

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-08 18:55:39 -04:00
SolenopsisbotandCodex b5254c9f6d perf: retain shader pack sky scratch
Co-Authored-By: Codex <[email protected]>
2026-11-09 09:52:44 +11:00
martipops 4132d833ff Merge remote-tracking branch 'upstream/main' into ui/shader-shell 2026-11-08 17:52:31 -05:00
martipopsandClaude Opus 5.5 d88c9de224 Shaders on the shell: review fixes
- A float option's slider position is its stop, the maximum the last one,
  as `slider_value` reads it: the arrow keys no longer stick when the range
  isn't a whole number of steps. `nudge_option` is gone; a test walks a 0.3
  step both ways.
- The right button steps no value back, as on vanilla's `CycleButton`.
- Apply keeps its violet primary look.
- A link found by the search goes back to the page it was found on.
- `Screen::let_go` is `slider_released`; the docs say exactly what Undo
  puts back.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 17:52:24 -05:00
voduffandClaude Opus 5.5 f687a35393 Inventory preview: enchanted armour and held items show their glint (#12666)
The inventory's player and the smithing table's armour stand turned the
model's vertices into HUD quads keeping only position, UV and colour, so
the armour glint (overlay ARMOR_GLINT) and the item glint were dropped.

- preview_glint_quads builds a glint face for each glint layer face, culled
  and turned like the model's; armour scrolls its model UV by
  ARMOR_ENTITY_GLINT_TEXTURING (0.16), items keep their transformed UV.
- HudBatch::depth_glint_since draws those faces as a Blend::DepthGlint run
  in the model's depth pass: additive, depth tested, never written
  (RenderPipelines.GLINT).
- hud.wgsl binds enchanted_glint_armor and picks it for u >= 4.

Fixes #12666

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 22:47:45 +00:00
SolenopsisbotandCodex 25a495c107 perf: keep flat renderer groups inline
Co-Authored-By: Codex <[email protected]>
2026-11-09 09:43:25 +11:00
bulkandClaude Opus 5.5 817641fa36 Move the frame's world update out of game.rs into game/world_update.rs
Pure move (#11857). Game::update_world, prepare_lod and clear_lod leave game.rs; private
methods become pub(super), the same reach they had in game.rs. Port map rows and the
AGENTS.md table follow the code.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 22:39:23 +00:00
SolenopsisbotandCodex 64d2225b47 perf: keep shader post attachments inline
Co-Authored-By: Codex <[email protected]>
2026-11-09 09:37:42 +11:00
TeiwazikandClaude Opus 5.5 0cd71497b9 Motion vectors: rockets, tridents and thrown potions move by their own motion
Their vertices were drawn under the camera-only run, so frame generation and
temporal upscaling reprojected them as if they stood still. Each now opens a
MOTION_OBJECT run under its entity id and closes it with a camera-only run.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 23:34:04 +01:00
J1zeeeandGLM 9358df9085 Update the generated-block-entities test for its ender chest entity
Now that an ender chest is an EntityBlock (`for_block` returns a Chest), a
generated ender chest gets its real block entity instead of falling through
to the unmodelled raw list. The test pinned the old fall-through behaviour,
so CI's `-p horizon-client-lib --lib` failed on it (#12864). Expect both
entities now: the sculk sensor keeps its first slot, the ender chest follows
as a Chest at (-27, -40, 57), raw stays empty, and a second pass keeps both
without doubling.

Co-Authored-By: GLM <[email protected]>
2026-11-09 01:33:52 +03:00
martipopsandClaude Opus 5.5 bb827b3bfe Settings shell: a declared foot button leaves room for the pad's glyph
A half-width Apply beside Undo drew its X glyph over its label. Like
vanilla's buttons (`ui::action_button`), the label of a foot button that
declared its job now starts past the glyph while the pad drives.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 17:30:35 -05:00
MrCloudy2 0f2374a9f1 Merge remote-tracking branch 'origin/main' into fix/piston-push-triggers
# Conflicts:
#	crates/horizon-client/src/game/piston_push.rs
2026-11-08 23:30:21 +01:00
SolenopsisbotandCodex f6e0f987ca perf: keep HUD blend runs inline
Co-Authored-By: Codex <[email protected]>
2026-11-09 09:29:46 +11:00
Nikita Maximilian Grimm f60f2a7cd6 Merge remote-tracking branch 'origin/main' into parity/fill-block-nbt 2026-11-08 23:28:34 +01:00
AppaandCodex 58ba6ee7bf Cite the level-side query used by previews
Co-Authored-By: Codex (GPT-6) <[email protected]>
2026-11-08 14:25:59 -08:00
DUzzL fae353e82a Read TNT explosion_power from any numeric tag 2026-11-08 23:25:45 +01:00
Horizon agentandClaude Opus 5.5 982afd44b7 Format the merged lan.rs test modules
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 23:25:17 +01:00
SolenopsisbotandCodex 27d330d03b perf: retain sound engine tick scratch
Co-Authored-By: Codex <[email protected]>
2026-11-09 09:25:09 +11:00
Horizon agentandClaude Opus 5.5 a520869bfa perf(lan): scan first after an item write, build the columns on the second ask
A pass that writes the item list between questions would rebuild the
columns each time, which costs more than the scan. After a write the
first question now scans as before; the second with nothing written in
between builds them.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 23:23:59 +01:00
Horizon agent 1570016310 Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	crates/horizon-client-render/src/particles/polytone.rs
2026-11-08 23:23:16 +01:00
bulkandClaude Opus 5.5 edf6782e02 Move using the held item out of game.rs into game/use_hand.rs
Pure move (#11857). Game::use_hand, apply_consume_effect and release_bow leave game.rs;
private methods become pub(super), the same reach they had in game.rs (the game module and
its children). Port map rows and the AGENTS.md / docs/architecture.md tables follow the code.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 22:22:41 +00:00
MrCloudy2 03a5d8f278 Merge remote-tracking branch 'origin/main' into fix/projectile-bubble-column
# Conflicts:
#	crates/horizon-client/src/projectiles.rs
#	tools/port_map/game2-006.tsv
2026-11-08 23:22:13 +01:00
Horizon agent 55d85acf5c Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	crates/horizon-client/src/game/lan.rs
2026-11-08 23:21:58 +01:00
Ludovic Marie d5504cd328 Merge pull request #12649 from TheMrZZ/fix/recipe-book-display-variants
🤖 Agent: transmute recipes show one recipe book entry per display, pinned by the new recipe_book_variants harness.
2026-11-08 23:21:45 +01:00
Horizon agent f528cc4298 Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	crates/horizon-client/src/game/render_tick.rs
2026-11-08 23:21:39 +01:00
Horizon agent 309ac52058 Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	crates/horizon-client/src/game/storage.rs
2026-11-08 23:21:18 +01:00
Horizon agent 5e7b000a0e Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	crates/horizon-client/src/game/books.rs
2026-11-08 23:21:11 +01:00
Horizon agent 7ee63f2db5 Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	crates/horizon-client/src/game.rs
2026-11-08 23:20:09 +01:00
Florian Ernst e5534306c7 Merge origin/main into fix/allay-pickup-cooldown
Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	crates/horizon-client/src/game.rs
2026-11-08 23:19:27 +01:00
Florian Ernst 30e4b26b37 Merge origin/main into fix/h077-blending-heights-save
Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	crates/horizon-client/src/save.rs
2026-11-08 23:19:18 +01:00
Ludovic Marie 29be573768 Merge pull request #12669 from ilguerro/fix/entity-die-kill
🤖 Agent: Entity.kill posts entity_die for every non-living /kill target, and wool items dampen vibrations.
2026-11-08 23:19:17 +01:00
k3yhandClaude Opus 5.5 cb1e8d891e Content mods declare fluids, with network ids after vanilla's
Step 1 of #6301 (#5553's JSON): data/<id>/horizon/fluid/<name>.json copies
minecraft:water or minecraft:lava with NeoForge's FluidType and
BaseFlowingFluid.Properties numbers, a bucket item and block of the mod's
own, and its FluidModel textures and tint. Each fluid takes two ids in
minecraft:fluid after vanilla's (its source, then flowing_<name>), joins
the content identity and the command registries. Nothing placeable yet.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 17:19:15 -05:00
AppaandCodex 0741d53c75 Preserve the level side in client placement previews
Forward DryLevel's side flag so client placement does not prime TNT or
queue piston moves. Check powered client placements against vanilla and
keep server-side preview behavior covered.

Co-Authored-By: Codex (GPT-6) <[email protected]>
2026-11-08 14:18:43 -08:00
Horizon agent ce2ec03a01 Merge remote-tracking branch 'origin/main' into HEAD 2026-11-08 23:18:25 +01:00
Horizon agentandClaude Opus 5.5 8de6ff0bca Ignore the off-hand join test without prepared resources
It reads HORIZON_TEST_RESOURCES, which the unit test run (and CI verify) does
not set, so it failed with an unwrap on None there.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 23:18:25 +01:00
Florian ErnstandClaude Opus 5.5 1501a0fd60 Merge origin/main into fix/last-hurt-by-mob-save
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 23:18:04 +01:00
Florian ErnstandClaude Opus 5.5 ba342ec0f6 Merge origin/main into fix/invulnerable-time-save
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 23:18:01 +01:00
SolenopsisbotandCodex 546db0269c perf: retain streaming update scratch
Co-Authored-By: Codex <[email protected]>
2026-11-09 09:17:01 +11:00
bulkandClaude Opus 5.5 51c2e96495 Move the command line Options out of game.rs into game/launch_options.rs
Pure move (#11857). Options and its impl (player_name, read, parse) leave game.rs; private
items, fields and methods become pub(super), the same reach they had in game.rs (the game
module and its children). Port map rows and the AGENTS.md table follow the code.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 22:16:29 +00:00
Nikita Maximilian Grimm a9fb6ffc9a Merge branch 'hud-8bit' into hdr-p0 2026-11-08 23:16:13 +01:00
Nikita Maximilian Grimm 159fb6af86 Merge remote-tracking branch 'origin/main' into hud-8bit 2026-11-08 23:16:11 +01:00
martipopsandClaude Opus 5.5 d1a4478daa Shell and Shaders tests: a tall window that is tall, the next arrow, fresh frames
The tall window was 427x240 at its automatic scale too, the search case
pressed the shown name rather than its next arrow, and two draws ending
the same widget frame read the first one's widgets.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 17:16:04 -05:00
Nikita Maximilian Grimm efda24ee40 Merge remote-tracking branch 'origin/main' into summoned-eye-of-ender 2026-11-08 23:14:39 +01:00
vermingovandClaude Opus 5.5 3dd49e2848 Content mods: jukebox songs load under their own namespace
load_songs read data/minecraft/jukebox_song only and named every song
minecraft:<file>, so a content mod's disc never found its song. Read
each content namespace as well, as vanilla's registry loader lists
every namespace (ResourceManagerRegistryLoadTask.load).

Part of #12910.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_017xeMvE6sM435T8F5rAyvWU
2026-11-08 23:14:36 +01:00
Ludovic Marie 7b59041a89 Merge pull request #12583 from MSedgeMC/fix/horse-any-rider-bolt
🤖 Agent: RunAroundLikeCrazyGoal.canContinueToUse has no isMobControlled check, so continue now reads !tame && !nav.is_done() && is_vehicle.
2026-11-08 23:11:08 +01:00
Ludovic Marie 5fe5747f44 Merge pull request #12790 from DUzzL/fix/12712-sulfur-cube-bucket
🤖 Agent: the client predicts the sulfur cube bucket pickup, as vanilla's client does.
2026-11-08 23:11:05 +01:00
Ludovic Marie 841a5a4acd Merge pull request #12785 from RLC248/fix/12556-display-save-defaults
🤖 Agent: display and interaction entities save typed defaults; the command_exec oracle is extended.
2026-11-08 23:11:02 +01:00
Ludovic Marie e5397553a2 Merge pull request #12782 from oloion/fix/ru-translation-placeholders
🤖 Agent: tooltips snap to whole pixels; creative tab tooltips go through draw_tooltip_lines.
2026-11-08 23:10:58 +01:00
Ludovic Marie 196348ee3e Merge pull request #12781 from Mayonesu0/up-12382-cobweb-fall
🤖 Agent: the server player's cobweb/berry fall reset happens before the landing check, as in vanilla.
2026-11-08 23:10:55 +01:00
Ludovic Marie a44f73e9ae Merge pull request #12775 from MrCloudy2/fix/projectile-piston-cobweb
🤖 Agent: projectiles get makeStuckInBlock in cobwebs and a piston push zeroes it; new projectile_piston_cobweb harness.
2026-11-08 23:10:51 +01:00
Ludovic Marie 3fa342468c Merge pull request #12771 from iseaprojects/i18n/launcher-fr
🤖 Agent: 55 French launcher strings, placeholders kept.
2026-11-08 23:10:47 +01:00
Ludovic Marie b7b1d73a66 Merge pull request #12770 from TheMrZZ/fix/orb-score-store
🤖 Agent: an orb is a score holder named by its UUID; the new orb_score_store harness pins it.
2026-11-08 23:10:43 +01:00
Horizon agent 3fe80aae12 Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	tools/command_exec_corpus.py
2026-11-08 23:10:25 +01:00
Horizon agent af996ff963 Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	crates/horizon-client/src/game/resource_tables.rs
2026-11-08 23:10:03 +01:00
Horizon agent 633cac87b5 Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	crates/horizon-client/src/save.rs
2026-11-08 23:09:51 +01:00
Vinícius MoreiraandClaude Opus 5.5 592c4f55ef Fog: a spectator's water vision ramps up ten times faster
LocalPlayer.aiStep adds 10 to waterVisionTime each tick for a spectator
whose eye is in water, 1 for anyone else. Horizon always added 1, so a
spectator saw the water darker and foggier for 30 seconds.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 19:09:00 -03:00
Horizon agent 4c100ad4f1 Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	crates/horizon-client/src/game/armor_stands.rs
2026-11-08 23:08:07 +01:00
SolenopsisbotandCodex 99893d81c0 perf: share filtered recipe sources
Co-Authored-By: Codex <[email protected]>
2026-11-09 09:07:50 +11:00
iseaprojectsandopencode 34b9d2cd64 Items: decode a stack's components in one pass
`decode_components` read the 23 component IDs it models one at a time with
`Tag::get(&str)`: each call encodes the ID to UTF-16 and walks the compound's
`BTreeMap`, whether or not the stack carries it. Most stacks carry one or two,
so most of those lookups were pure overhead, and a chest pays for 27 of them.

`Seen::read` walks the compound once, dispatching each key against the 23 IDs
through a 64-byte ASCII buffer, and the fields then decode from those
`Option<&Tag>`s with the expressions they used before. A key that is not ASCII
cannot be a component ID and falls through to `other` as it did; the stored
form, the output and the second pass over unmodelled components are unchanged.

A one-component stack decodes in 0.36 instead of 0.93 us (2.6x) and an empty
compound in 0.089 instead of 0.56 us (6.3x). The decode stage of a 256-column
save batch drops from 106 to 81 ms (1.31x). A test asserts the one-pass decode
equals the per-ID implementation over five compounds, and
`save_batch_bench --split` times the four save and load stages apart.

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-08 18:07:36 -04:00
Ludovic Marie 0b2279aaa3 Merge pull request #12833 from DUzzL/fix/12623-minecart-slope-effects
🤖 Agent: a cart's slope step is swept for entity-sensing blocks.
2026-11-08 23:06:51 +01:00
Ludovic Marie 72c9d15042 Merge pull request #12826 from Mayonesu0/up-12384-shovel-efficiency
🤖 Agent: detectEquipmentUpdates fires on a same-item stack change, so Efficiency applies.
2026-11-08 23:06:47 +01:00
Ludovic Marie 32f28b9d08 Merge pull request #12796 from DUzzL/fix/12559-living-timers
🤖 Agent: DeathTime is kept on load, impulse grace time is saved and ticked, and an armadillo picks its scute time on load.
2026-11-08 23:06:44 +01:00
Ludovic Marie 89f236e3ef Merge pull request #12794 from Stink-O/native/stamp-world-warp-hold
🤖 Agent: world_hold/world_warp stamped 414/415 (NATIVE_HOOKS_API 415); the hook stamp test passes on the union with the other hook PRs.
2026-11-08 23:06:39 +01:00
Ludovic Marie e2a28f96b8 Merge pull request #12792 from NikitaMGrimm/api02-room-to-grow
🤖 Agent: shader API 0.2 refuses packs that construct Horizon's input structs.
2026-11-08 23:06:35 +01:00
Ludovic Marie eee4242951 Merge pull request #12791 from DUzzL/fix/12607-firework-dragon
🤖 Agent: firework bursts hurt the dragon through its body's hurt rule.
2026-11-08 23:06:32 +01:00
martipops b1d469b419 Merge remote-tracking branch 'upstream/main' into ui/shader-shell
# Conflicts:
#	crates/horizon-client-render/src/options_ui.rs
2026-11-08 17:06:32 -05:00
Ludovic Marie 44aa36f166 Merge pull request #12789 from NikitaMGrimm/fix-shulker-empty-items
🤖 Agent: an empty shulker box omits Items, with a command_exec corpus case.
2026-11-08 23:06:27 +01:00
Ludovic Marie 3f0328f42f Merge pull request #12784 from DUzzL/fix/12662-minecart-cobweb
🤖 Agent: minecarts read stuckSpeedMultiplier from cobwebs like other entities.
2026-11-08 23:06:24 +01:00
martipopsandClaude Opus 5.5 d0e38cc6c7 Shaders moves onto the settings shell, whose page list collapses when full
Shaders is a `Screen` now: the pack list and the picked pack's screens are
sheets read from the pack, and the shell draws and drives them. The shell
gains what Shaders needed, for every screen on it: a page list that
collapses its last section into one `‹ Name ›` row when the pages don't
fit, sections under a line, pages out of the list that a row links to
(Escape goes back), Apply beside Undo, and a slider's `let_go`. Undo puts
back the pick, the Shadow Cache and the packs' options; Apply shows only
while a `shaders/` pack's options differ from its saved ones. Left and
Right step a pack's options, and the right button steps a value back.
ShaderUi's own drawing, scrolling, focus, search and page list are gone.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 17:06:07 -05:00
Nikita Maximilian Grimm fcc01b662b Merge branch 'hud-8bit' into hdr-p0 2026-11-08 23:06:06 +01:00
Nikita Maximilian Grimm fae81ab428 Merge remote-tracking branch 'origin/main' into hud-8bit 2026-11-08 23:06:05 +01:00
Ludovic Marie 0030a4add8 Merge pull request #12764 from iseaprojects/i18n/it-it-rest
🤖 Agent: it_it gets its 547 missing game keys.
2026-11-08 23:04:59 +01:00
Ludovic Marie c2be77292a Merge pull request #12759 from baytyone/map-locate-reads-each-saved-chunk-once
🤖 Agent: map search caches saved-chunk structure references per session, like StructureCheck; maps' own references are checked first.
2026-11-08 23:04:56 +01:00
Ludovic Marie c5a4b2cdd5 Merge pull request #12747 from NikitaMGrimm/hdr-sdr-screenshot
🤖 Agent: float HDR frame screenshots are tone-mapped, and the float frame stays behind its env switch.
2026-11-08 23:04:51 +01:00
Ludovic Marie caac31c8df Merge pull request #12746 from NikitaMGrimm/hdr-auto
🤖 Agent: HDR gains Off/On/Auto with Off the default; the display is watched only when HDR isn't Off.
2026-11-08 23:04:48 +01:00
Ludovic Marie dd208c4f97 Merge pull request #12732 from vnsmoreira/packs/polytone-particle-fixes
🤖 Agent: Polytone custom particle fixes measured against Java; the additive overlay branch only applies with fog 45.
2026-11-08 23:04:44 +01:00
Ludovic Marie 7092049967 Merge pull request #12718 from iseaprojects/tooling/lang-coverage
🤖 Agent: tools/lang_coverage.py only reads files and reports missing keys.
2026-11-08 23:04:40 +01:00
Vinícius MoreiraandClaude Opus 5.5 4006b3da52 Polytone: particle modifiers on vanilla and mod particles
Resource packs' particle modifiers now change the particles of the types
they target as each one spawns: colour (channels, packed colour or a
colormap), lifetime, size and speed. Observed on the real mod: the
expressions run once at spawn, `size` multiplies the particle's size as
vanilla's Particle.scale does, and `speed` follows setPower.

Modifiers with a `filter` are skipped: particles here keep the sprite of the
block or item they carry, not the block or item itself.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 19:03:56 -03:00
Horizon agent b44fa6e966 Merge remote-tracking branch 'origin/main' into HEAD 2026-11-08 23:02:46 +01:00
Horizon agent 0331afdf3d Merge remote-tracking branch 'origin/main' into HEAD 2026-11-08 23:02:43 +01:00
Horizon agent 0952f173b5 Merge remote-tracking branch 'origin/main' into HEAD 2026-11-08 23:02:40 +01:00
Horizon agent bff215ab70 Merge remote-tracking branch 'origin/main' into HEAD 2026-11-08 23:02:37 +01:00
Horizon agent 13b8277019 Merge remote-tracking branch 'origin/main' into HEAD 2026-11-08 23:02:35 +01:00
Horizon agent 3587cef272 Merge remote-tracking branch 'origin/main' into HEAD 2026-11-08 23:02:32 +01:00
Horizon agent 92d612f109 Merge remote-tracking branch 'origin/main' into HEAD 2026-11-08 23:02:29 +01:00
Horizon agent 04d8249f08 Merge remote-tracking branch 'origin/main' into HEAD 2026-11-08 23:02:27 +01:00
Horizon agent 30628bf79c Merge remote-tracking branch 'origin/main' into HEAD 2026-11-08 23:02:24 +01:00
Horizon agent 19e86d6782 Merge remote-tracking branch 'origin/main' into HEAD 2026-11-08 23:02:21 +01:00
Horizon agent 0f82100ddd Merge remote-tracking branch 'origin/main' into HEAD 2026-11-08 23:02:18 +01:00
Horizon agent a0794bfd2e Merge remote-tracking branch 'origin/main' into HEAD 2026-11-08 23:02:15 +01:00
Horizon agent aac0f59fd7 Merge remote-tracking branch 'origin/main' into HEAD 2026-11-08 23:02:12 +01:00
Horizon agent 962ce1dfbc Merge remote-tracking branch 'origin/main' into HEAD 2026-11-08 23:02:08 +01:00
Horizon agent 5c684b8cf1 Merge remote-tracking branch 'origin/main' into HEAD 2026-11-08 23:02:05 +01:00
Horizon agent c504f1347a Merge remote-tracking branch 'origin/main' into HEAD 2026-11-08 23:02:01 +01:00
Horizon agent 0ab94f3e13 Merge remote-tracking branch 'origin/main' into HEAD 2026-11-08 23:01:58 +01:00
Horizon agent 8e02a3cb80 Merge remote-tracking branch 'origin/main' into HEAD 2026-11-08 23:01:54 +01:00
Horizon agent a5cb5bdadd Merge remote-tracking branch 'origin/main' into HEAD 2026-11-08 23:01:49 +01:00
Horizon agent 3c7500e007 Merge remote-tracking branch 'origin/main' into HEAD 2026-11-08 23:01:45 +01:00
Horizon agent 31c060e41b Merge remote-tracking branch 'origin/main' into HEAD 2026-11-08 23:01:38 +01:00
Horizon agent b3e597d983 Merge remote-tracking branch 'origin/main' into HEAD 2026-11-08 23:01:33 +01:00
Horizon agent 84b445e017 Merge remote-tracking branch 'origin/main' into HEAD 2026-11-08 23:01:28 +01:00
Vinícius MoreiraandClaude Opus 5.5 a31ee46ddf End: its world clock is saved and ticks apart from the game time
ServerClockManager keeps one clock per WorldClock: the End's (the End sky
flash's) is saved in world_clocks.dat and advances with advance_time, apart
from the game time. Horizon sent the game time as the End's clock, so a
world opened from Java flashed at other times than in Java.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 19:01:23 -03:00
Ludovic Marie c23d7cdf4b Merge pull request #12876 from foudretbr/feat/registry-native-builds
🤖 Agent: registry-only; dispatch is refused for revoked files and both endpoints are rate-limited per proxy-seen address.
2026-11-08 23:00:08 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 264ea0cc7c HDR float frame: shader API 0.2 packs get HDR output and the layered HUD
Under HORIZON_HDR_FRAME=float, a 0.2 pack with HDR on now gets the
layered HDR frame that HDR without a pack uses, where the device keeps
the HUD on a layer of its own. Frame's display fields are the display's
(hdr 1, headroom, paper white, peak, black level), read every frame.
The pack's [tonemap] maps the light, or else Horizon's no-pack tone
curve does: the default tonemap stage no longer clips at the headroom
in HDR, and Horizon's curve is skipped after a pack's own. The HUD is
laid over the picture by coverage as without a pack. F2's SDR
screenshot rolls highlights off only where Horizon lifted the world
(Hdr::lighting): under a pack it is clipped at white, as before.

With HDR off, or without the HUD layer, the fields stay SDR's and the
pack's float picture finishes on the swapchain's 8-bit format
(Readback::finish) before the hand, the camera overlays and the HUD
draw, scaled screenshots included. So each overlay draw clips at white
as on vanilla's frame, and HDR output stays off there. The frame also
follows the HDR offer changing at runtime, so HDR On under a pack turns
on once the display offers it.

The shader API reference now says that Horizon lifts the shadows onto
black_level after [tonemap].

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 22:59:39 +01:00
Horizon agentandClaude Opus 5.5 bee1d42fd2 Format the ender chest save test
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 22:59:27 +01:00
Florian ERNSTandCodex b3aded6e7d Use glide travel for eligible mobs
Share the existing pure glide arithmetic with Mob travel while retaining
its collision path, NoAI exclusion and existing damage hooks. Preserve
Player glide behavior and map fly-into-wall damage explicitly.

Co-Authored-By: Codex <[email protected]>
2026-11-08 22:57:14 +01:00
Ludovic Marie 8bb7788b8e Merge remote-tracking branch 'origin/main' into fix-12864 2026-11-08 22:57:13 +01:00
Horizon agentandClaude Opus 5.5 1c888ac5b2 Ender chest: keep no copy of the player's items in its block entity
Closing it now moves the items back and leaves the entity empty, and its saved form
has no Items: EnderChestBlockEntity saves nothing of its own.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 22:57:13 +01:00
Ludovic Marie bcb76434bc Merge remote-tracking branch 'origin/main' into fix-12876 2026-11-08 22:56:34 +01:00
Horizon agentandClaude Opus 5.5 29cbcd69d1 registry: rate-limit native build polls, refuse revoked files, key limits on the proxy-seen address
The first X-Forwarded-For hop is what the client sent, so the per-address limit
could be dodged; take X-Real-IP or the hop the proxy appended. revoked.toml is now
checked before a build is dispatched, and status polls (one GitHub API call each)
share a rate limit like requests do.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 22:56:34 +01:00
Florian ErnstandClaude Opus 5.5 6e42a09d63 Keep a full neighbour chunk's blocks from ordinary feature writes
While a new chunk decorates, a full neighbour stands in the region as
ImposterProtoChunk(chunk, false): its setBlockState, setBlockEntity,
removeBlockEntity and markPosForPostProcessing change nothing, and
WorldGenRegion.setBlock still answers true, while BulkSectionAccess (ores)
writes its sections. Horizon rebuilt a full neighbour as an ordinary
decorated proto chunk and copied every block that changed into it, so the
trees and plants of new chunks grew into old full chunks.

proto_beside_final now marks its proto chunk full (not saved). Decoration
records the region's full chunks before their terrain moves into the
region, and the two ordinary write entry points, set_block and
set_chunk_block, change nothing in them after the zone check and answer
as before. Section writes are unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 22:55:23 +01:00
SolenopsisbotandCodex abbe20ec90 perf: keep HUD attachments on the stack
Co-Authored-By: Codex <[email protected]>
2026-11-09 08:55:00 +11:00
MrCloudy2andClaude Opus 5.5 87c51909d4 A piston's push presses the pressure plate it carries an entity onto
moveEntityByPiston's applyEffectsFromBlocks: the entity-sensing blocks the
pushed box covered sense it during the push, so a plate powers its
neighbours before the piston pushes the next entity.
Harness: tools/test_piston_push_plate_parity.py.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 22:53:27 +01:00
Ludovic Marie 20111ddf7b Merge pull request #12777 from iseaprojects/i18n/launcher-it
🤖 Agent: 142 it launcher strings, placeholders kept; merged key by key with main's lang changes.
2026-11-08 22:52:32 +01:00
Ludovic Marie 252a012722 Merge pull request #12776 from iseaprojects/i18n/launcher-de
🤖 Agent: 213 de launcher strings, placeholders kept; merged key by key with main's lang changes.
2026-11-08 22:52:28 +01:00
Ludovic Marie 7f9e634e22 Merge pull request #12772 from iseaprojects/i18n/launcher-pt-br
🤖 Agent: 137 pt-br launcher strings, placeholders kept; merged key by key with main's lang changes.
2026-11-08 22:52:24 +01:00
Ludovic Marie f4b8e96eca Merge pull request #12592 from MSedgeMC/fix/loot-set-custom-data
🤖 Agent: set_custom_data accepts the tag as an object or as SNBT text (LENIENT_CODEC) and merges it into the existing custom_data component key by key.
2026-11-08 22:52:20 +01:00
Ludovic Marie 77614f2697 Merge pull request #12819 from martipops/ui/shell-tabs
🤖 Agent: shader screens collapse into one hud_kit::tabs row only when they don't all fit; a tall view keeps the list as it was.
2026-11-08 22:52:15 +01:00
Ludovic Marie 0cb33a7139 Merge pull request #12654 from vnsmoreira/packs/polytone-post-chains
🤖 Agent: Polytone post chains, their targets and uniforms; PolytonePost::read is None without chains, so packs without them pay no frame cost. Holds #12539 and #12653's fade.
2026-11-08 22:52:11 +01:00
SenjinandClaude Opus 5.5 cd069f2065 Audio: a dead output stream is opened again
cpal reports a dead ALSA stream (alsa::poll() returning POLLERR, over
and over) as a backend error, which was only logged: the game stayed
silent until a restart. A run of backend errors close together (three
within two seconds) now counts as a lost device, as OpenAL Soft reports
a stopped backend under vanilla (ALC_CONNECTED), and the output is
opened again. The dead stream lets go of the device first, and a failed
reopening is tried again every two seconds, five times, before the
output stays off.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 22:49:09 +01:00
Nikita Maximilian Grimm 271b3626a2 Merge remote-tracking branch 'origin/main' into summoned-eye-of-ender 2026-11-08 22:48:47 +01:00
Ludovic Marie aac977d7dd Merge pull request #12751 from fbikat/fix-12521-piglin-bogged-nbt
🤖 Agent: AbstractPiglin's CanPickUpLoot defaults to true when absent, Piglin always writes CannotHunt, and bogged save and load sheared; the rebase keeps #12685's tracker asserts.
2026-11-08 22:47:31 +01:00
Ludovic Marie 3b34e7ba33 Merge pull request #12824 from Ragbie/fix/vehicle-onground-fall-distance
🤖 Agent: vehicles save and load OnGround and fall_distance; the new corpus case checks them across a chunk reload.
2026-11-08 22:47:27 +01:00
Ludovic Marie 7c5a4a621d Merge pull request #12618 from ThatCakeIsAPie/perf/lazy-templates
🤖 Agent: pool templates load lazily on first use, the first insert kept; 59 structure tests pass.
2026-11-08 22:47:23 +01:00
SolenopsisbotandCodex f2c1081089 perf: avoid blank HUD slot source buffers
Co-Authored-By: Codex <[email protected]>
2026-11-09 08:45:09 +11:00
Horizon agentandClaude Opus 5.5 ab56dc20bd Merge main
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 22:44:24 +01:00
Horizon agentandClaude Opus 5.5 76d91d6b27 Merge main
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 22:44:23 +01:00
Horizon agentandClaude Opus 5.5 fb0a077202 Merge main
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 22:44:22 +01:00
Horizon agentandClaude Opus 5.5 b90fee34be Merge main
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 22:44:20 +01:00
Horizon agentandClaude Opus 5.5 42a0d12f62 Merge main
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 22:44:19 +01:00
Horizon agentandClaude Opus 5.5 40a9813dcd Merge main
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 22:44:18 +01:00
Ludovic Marie fa7924b3fb Merge pull request #12832 from iseaprojects/perf/paletted-pack-uniform
🤖 Agent: a uniform section packs without unpacking its 4096 cells.
2026-11-08 22:43:53 +01:00
Ludovic Marie e327c79d1f Merge pull request #12829 from RINNECODER/up7/carried-features
🤖 Agent: carried mod features get their config unwrapped, so mod ores generate.
2026-11-08 22:43:49 +01:00
Ludovic Marie b18000dccf Merge pull request #12827 from NikitaMGrimm/fix-ominous-spawner-motion
🤖 Agent: the ominous item spawner keeps its loaded Motion, with a corpus case.
2026-11-08 22:43:45 +01:00
Ludovic Marie 9c83ccf52f Merge pull request #12825 from horizon-rust/perf/rgss-trilinear-taps
🤖 Agent: RGSS taps at the fractional mip through the linear mip filter (-0.95 ms in the forest scene).
2026-11-08 22:43:41 +01:00
Ludovic Marie 321a391209 Merge pull request #12823 from TheMrZZ/fix/hoglin-repeat-attack
🤖 Agent: the hoglin's retaliation broadcast keeps a nearer target; new hoglin_repeat_attack harness.
2026-11-08 22:43:38 +01:00
Ludovic Marie 2f400ca79e Merge pull request #12811 from MSedgeMC/fix/lectern-book-signal
🤖 Agent: the lectern signal adds 1 only when the stack is a book (hasBook asks for a book-content component).
2026-11-08 22:43:34 +01:00
Ludovic Marie 42d1e6d7d7 Merge pull request #12810 from MSedgeMC/fix/local-locked-container
🤖 Agent: a locked container refuses the local open with the action bar and chest_locked sound (LockCode reads the main hand).
2026-11-08 22:43:31 +01:00
Ludovic Marie ae40b07eeb Merge pull request #12806 from iseaprojects/fix/11696-armor-worn-before-reduction
🤖 Agent: armor worn before the hit reads armor; the new helmet_damage harness pins it.
2026-11-08 22:43:27 +01:00
Ludovic Marie 16fdd05784 Merge pull request #12803 from NikitaMGrimm/entity-ticking-gate
🤖 Agent: potions, orbs, rockets and clouds freeze outside entity-ticking chunks, with a corpus case.
2026-11-08 22:43:23 +01:00
Ludovic Marie b6036c08c1 Merge pull request #12802 from fbikat/fix-12778-snow-golem-pumpkin
🤖 Agent: snow golem Pumpkin is saved and loaded, default true, as SnowGolem.addAdditionalSaveData does.
2026-11-08 22:43:19 +01:00
MrCloudy2andClaude Opus 5.5 6b8be62697 Port ledger: projectile bubble columns
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 22:41:14 +01:00
SolenopsisbotandCodex 1f11377f60 perf: share entity models across startup loaders
Co-Authored-By: Codex <[email protected]>
2026-11-09 08:39:58 +11:00
Horizon agentandClaude Opus 5.5 7457de69bb Merge main (the reviewer test fix, #12783)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 22:39:29 +01:00
Horizon agentandClaude Opus 5.5 8db87db310 Merge main (the reviewer test fix, #12783)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 22:39:19 +01:00
Horizon agentandClaude Opus 5.5 385bd4d0b1 Merge main (the reviewer test fix, #12783)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 22:39:08 +01:00
Horizon agentandClaude Opus 5.5 0989100291 Merge main (the reviewer test fix, #12783)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 22:38:53 +01:00
Horizon agentandClaude Opus 5.5 a174de80d3 Merge main (the reviewer test fix, #12783)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 22:38:41 +01:00
Horizon agentandClaude Opus 5.5 42a73e846d Merge main (the reviewer test fix, #12783)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 22:38:28 +01:00
MrCloudy2andClaude Opus 5.5 24e5d3d707 A bubble column pushes every projectile in it, unclamped
Projectile.onInsideBubbleColumn / onAboveBubbleColumn through the
applyEffectsFromBlocks of ThrowableProjectile, AbstractHurtingProjectile and
ShulkerBullet ticks, and handleFirstTickBubbleColumn for throwables. The
server pushed only ender pearls, with the clamped push of other entities.
Harness: tools/test_projectile_bubble_column_parity.py.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 22:38:20 +01:00
Horizon agentandClaude Opus 5.5 598a294156 Merge main (the reviewer test fix, #12783)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 22:38:17 +01:00
Horizon agentandClaude Opus 5.5 3bdce4d658 Merge main (the reviewer test fix, #12783)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 22:38:07 +01:00
Ludovic Marie da0cab5bfa Merge pull request #12676 from MSedgeMC/fix/anvil-enchantments-presence
🤖 Agent: the whole stations series (anvil repairable, grindstone max stack, smithing trim material/pattern holders, enchantable, removed components): stack components override item defaults, as ItemStack.get does.
2026-11-08 22:37:55 +01:00
Horizon agentandClaude Opus 5.5 5c6329ba54 Merge main (the reviewer test fix, #12783)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 22:37:55 +01:00
Ludovic Marie e8b162cd2f Merge pull request #12761 from iseaprojects/i18n/de-de-rest
🤖 Agent: de_de gets its 553 missing keys; the split_ui locale test now expects German.
2026-11-08 22:37:51 +01:00
DUzzL b3f312cf99 Read attribute base from any numeric tag in mob NBT 2026-11-08 22:37:48 +01:00
Ludovic Marie 87ba93d8d1 Merge pull request #12744 from RLC248/fix/12615-follow-tracking-emitters
🤖 Agent: tracking emitters follow the live entity and keep emitting at the last position after removal; tick(&level) is kept for other callers.
2026-11-08 22:37:48 +01:00
Ludovic Marie 5c6d7b6dab Merge pull request #12621 from ThatCakeIsAPie/parity/pack-biome-ambience
🤖 Agent: pack biomes own their music and ambient sounds over the dimension's.
2026-11-08 22:37:44 +01:00
Ludovic Marie 795ae8e8a0 Merge pull request #12617 from ThatCakeIsAPie/perf/decoration
🤖 Agent: the lake grid as column bitmasks keeps vanilla's x,z,y and sum order; biome feature bitsets.
2026-11-08 22:37:41 +01:00
Ludovic Marie 7cd858f208 Merge pull request #12563 from kacpherek/fix/blaze-server-effects
🤖 Agent: blaze server effects (burn fx off, slow-fall order, levelEvent 1018) plus #12562's float literals; mob_ai blaze scenarios.
2026-11-08 22:37:36 +01:00
Ludovic Marie fd055a0020 Merge pull request #12613 from TheMrZZ/fix/dragon-fireball-motion
🤖 Agent: a dragon fireball stops at its owner's hit point that tick (AbstractHurtingProjectile.tick) and keeps its motion.
2026-11-08 22:37:32 +01:00
DUzzL c30e611ec3 Read dragon Health, DragonPhase and DragonDeathTime from any numeric NBT tag 2026-11-08 22:36:42 +01:00
Horizon agentandClaude Opus 5.5 a5f7ec2c4c Merge main (the reviewer test fix, #12783)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 22:36:30 +01:00
Horizon agentandClaude Opus 5.5 9a4ac94b73 Merge main (the reviewer test fix, #12783)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 22:34:37 +01:00
Vinícius MoreiraandClaude Opus 5.5 efaa1660e9 Polytone: post chains, their targets and uniforms
Resource packs' Polytone post chains (wiki "Shaders") run their vanilla
post_effect chain over the world while their activation_condition holds,
with a uniform block per expression uniform, the PolyGlobals block (the
world's projection and camera, the sun's angle, the time of day and the
frame's tick delta, the player's position) and the packs' post_targets,
kept between frames, in every chain. Post passes now read the frame's depth
with the chains' rows (row 0 at the bottom), as their colour already was;
its values stay reverse-Z, as 26.3's are.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 18:34:34 -03:00
Vinícius MoreiraandClaude Opus 5.5 f39318eeb0 Polytone: GPU particles fade in and out on a smoothstep
A GPU particle type's fade (wiki "GPU Particles": the fractions of its life spent
fading in and out) ramped its alpha linearly. Measured in Java on a line of
particles whose age grows with x, Polytone's ramps ease in and out: a quarter of
the way through the fade-in a particle has about a sixth of its alpha, not a
quarter. Rain dies at the ground well inside its fade-in, so each drop was up to
twice as bright as in Java; a column of the freepack's rain now matches Java band
by band.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 18:34:34 -03:00
Nikita Maximilian GrimmandClaude Opus 5.5 d398fbc8b0 HDR HUD layer: an 8-bit target, so each HUD draw clips as vanilla's
The float HDR frame's HUD target was in the frame's float format, so a
HUD draw no longer clipped at white before the next one blended over it:
a text highlight added over a light backdrop, then a 50% black panel,
came out at 0.65 instead of vanilla's 0.5. The HUD target is now 8-bit
(Rgba8Unorm), and the coverage pipelines, the only ones that draw on it,
target that format. The composite subtracts the backdrop as the 8-bit
target holds it, so pixels no HUD draw touches stay exactly the world.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 22:32:35 +01:00
valuttaandClaude Opus 5.5 39af844866 Sync the client's moving pistons once per frame's packets
A clock of a thousand pistons froze the window: the client rebuilt
World::moving_pistons after every BlockEvent that starts a move and every
block change that ends one, and each rebuild compared every previous entry
with every current one and scanned the pending uploads for every fallback
entry. A pulse sends about three packets per piston, so it cost O(N^3).

Packets now mark the pistons stale and poll_remote syncs them once after
the frame's packets, before the tick or the frame reads them. The sync
itself is linear: a hash set of the positions drawn, and one
section_mesh_pending answer per section.

Fixes #12901

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 22:32:23 +01:00
Vinícius MoreiraandClaude Opus 5.5 81337c3a2f Polytone: the Pack Options page for packs' config entries
Resource packs' Polytone config entries (wiki "Polytone Configs") get a page:
the card of a pack with options carries a small sliders button at its right
edge (as Respackopts does), which opens that pack's options in the frame of
Horizon's settings screens. Numbers are sliders, texts cycle, booleans switch;
entries sit in their sections, named and described by the pack's own
translations, with the performance hint. Done keeps what changed in
polytone-configs.json beside options.txt, and the packs load again with it, so
overlays and expressions that read the configs follow.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 18:31:04 -03:00
SolenopsisbotandCodex 3192adafe0 perf: reuse shared item defaults in world rest
Co-Authored-By: Codex <[email protected]>
2026-11-09 08:26:55 +11:00
martipops a6705ea839 Merge remote-tracking branch 'upstream/main' into ui/shell-tabs
# Conflicts:
#	AGENTS.md
#	docs/ui-kit.md
2026-11-08 16:25:43 -05:00
Horizon agentandClaude Opus 5.5 d9b3005690 Merge main
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 22:25:43 +01:00
Horizon agentandClaude Opus 5.5 eef19d5cf9 Merge main
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 22:25:35 +01:00
Horizon agentandClaude Opus 5.5 1eb93f5884 Merge main
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 22:25:34 +01:00
Horizon agentandClaude Opus 5.5 4bc55da08d Merge main
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 22:25:34 +01:00
Horizon agentandClaude Opus 5.5 dcd90c2b1f Merge main
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 22:25:34 +01:00
Horizon agentandClaude Opus 5.5 41a72c6eda Merge main
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 22:25:34 +01:00
Horizon agentandClaude Opus 5.5 3ae606abce Merge main
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 22:25:33 +01:00
Horizon agentandClaude Opus 5.5 10eb936805 Merge main
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 22:25:33 +01:00
29ac896b4c Harness: boats and minecarts keep OnGround and fall_distance across a chunk reload
NikitaMGrimm's group from #12517: oak and cherry boats, a bamboo raft,
minecarts and a command block minecart, read live, after an unload and
reload, and three ticks later. 73/73 against vanilla.

Co-authored-by: NikitaMGrimm <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 00:25:02 +03:00
SenjinandClaude Opus 5.5 f9eb983ba1 Shader packs: boats keep the water out of their hulls
A boat's water patch (RenderTypes.waterMask: depth only) went to a pack's
entity program as an ordinary textured quad, whose alpha test threw its
transparent texels away, so the water showed inside the hull; and the
pack's shadow pass drew the water there too (caustics, light under water).

The patches now draw depth only into a depth of their own (the entity
program's pipeline without colour writes, at one texel of the white
sprite so no level or alpha test drops them), and translucent terrain
discards what lies behind them (GbuffersInputs::water_mask). The shadow
pass's water discards what lies inside them, from their camera-relative
boxes (GbuffersInputs::water_patches, horizonWaterPatch*). A hull before
its patch keeps its motion matching (at_velocity).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 22:20:26 +01:00
Horizon agentandClaude Opus 5.5 e13a206334 Split screen test: German now translates the off status
This PR fills de_de's missing keys, so the status the test used to show the
English fallback is German now. The fallback itself stays covered by lang's
layers_selected_language_over_english.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 22:16:53 +01:00
Horizon agentandClaude Opus 5.5 2ba45c262d Particles: the emitter probe reads live bodies through tick_tracking
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 22:16:28 +01:00
DetimesandClaude Opus 5.5 0be41c06be Mod API: cheap fake players
native_set_player_fake / native_expect_fake_player / native_is_player_fake
and NativeServerPlayer.fake. A fake player keeps a small loaded area, is no
tracking viewer or spawn anchor, is sent only the packets a bot answers, and
skips the client-only parts of the guest tick. Adds a tick benchmark.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 21:16:24 +00:00
DetimesandClaude Opus 5.5 3e4cfbe7ea Mod API: entity hooks and helpers
living_experience_drop, potion_splash_v2, player_died_v2, entities_in,
living_helpers (swing, fire, effects, attribute base, use item, lightning),
npc_mob_path, advancement_display, and entity_join_level for displays.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 21:14:48 +00:00
fbikatandClaude Opus 5.5 110e686f4d Piglins pick up loot when CanPickUpLoot is absent and save CannotHunt; bogged save and load sheared
AbstractPiglin reads CanPickUpLoot with default true, so a piglin or brute
summoned or loaded without it picks up loot. Piglin always writes
CannotHunt; the brute doesn't. Bogged saves and reads sheared, so a
sheared bogged keeps its mushrooms off after a reload.

Fixes #12521.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 21:12:16 +00:00
valuttaandClaude Opus 5.5 646572e69b No block outline over the panorama
Opening a world in front of a block left its outline on Loading terrain's
panorama, drawn with the panorama's camera. The outline is built from the
player's target whenever the HUD shows; a panorama frame (the title screen,
LevelLoadingScreen's Reason.OTHER) replaces the level vanilla draws under its
opaque background, so skip it there as the debug crosshair already does.

Fixes #12872

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 22:12:10 +01:00
Horizon agentandClaude Opus 5.5 3176bb630a perf(lan): rebuild the pickup columns after any item write
A guest's command (/tp, /data) or an explosion during the players' pass
moves items without changing the list's length or last id, so the
columns could miss an item for the rest of that pass. Key them on the
item list's stamp (StampedItemColumns): any write rebuilds them on the
next question. The scans read the list, so only real writes (a pickup,
a drop, a push) rebuild.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 22:11:38 +01:00
fbikatandClaude Opus 5.5 a8d74ca97b Read booleans from any number for end crystals, the dragon, primed TNT, signs, hive bees and interactions
getBooleanOr and Codec.BOOL take any numeric tag's nonzero byte. These sites
matched one exact byte; they now use entity_nbt::flag, or the new flag_or
for fields that default to true (ShowBottom). The render crate's signs use
the same int_value test.

Fixes #12845.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 21:08:03 +00:00
Florian ErnstandClaude Opus 5.5 fe74121c5b An allay saves and loads its item pickup cooldown
AllayAi sets ITEM_PICKUP_COOLDOWN_TICKS to 60 when an allay throws an
item, and the Brain saves it as {value: ...} and reads it back. Horizon
kept the cooldown for the allay's pickups (extra.ints[3]) but rebuilt
the saved Brain memories without it, and never read it.

The allay's Brain writes item_pickup_cooldown_ticks while the cooldown
runs and reads it back, as its liked_noteblock_cooldown_ticks already
does. The tick it spends at 0 before CountDownCooldownTicks erases it is
not kept: the model has no presence apart from the value.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 22:06:51 +01:00
SolenopsisbotandCodex d170e4f674 perf: reuse region NBT decode scratch
Co-Authored-By: Codex <[email protected]>
2026-11-09 08:05:27 +11:00
J1zeee bde1cc63c5 Merge remote-tracking branch 'origin/main' into fix/12752-ender-chest-end-portal-block-entities
# Conflicts:
#	crates/horizon-client-render/src/containers.rs
#	crates/horizon-client/src/game.rs
#	crates/horizon-client/src/save.rs
2026-11-09 00:03:40 +03:00
Horizon agentandClaude Opus 5.5 0b5b68bdd2 Particles: keep tick(level) and add tick_tracking for the live bodies
Other open PRs (and tests) call ParticleEngine::tick(&level); only the game's
render tick needs the tracking-body reader. tick() is tick_tracking() with no
body, so both kinds of caller keep compiling whichever lands first.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 22:02:36 +01:00
foudretbr 39d6f25e58 feat(registry): add POST /api/native-builds on-demand prebuilt endpoint
Implement on-demand native build request handling and status checks in horizon-mod-registry (#11859). Accepts valid target triples, commit base, and registry native mods, verifies hashes and versions against the index, deduplicates dispatches by request key, and queries release assets.
2026-11-08 22:00:25 +01:00
Florian ErnstandClaude Opus 5.5 4b9cc3eeae Test: an allay keeps its item pickup cooldown memory
Checkpoint before the fix: the Game test and the strict Java harness.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 22:00:11 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 b00506bc0e HDR settings: Black Level 0 lifts nothing, Peak reaches 10 000 nits, every Paper White step
Black Level has no Auto: 0, the default, is true black, and the saved value
is the lift it says. Old files' 0 (Auto) reads as no lift, and the system's
reported black is no longer kept. Calibration's black step still sets a lift,
and the metadata's minimum luminance follows the setting.

Peak Brightness goes to PQ's 10 000 nits by hand, in the saved value and in
calibration: 200..2000 by 50 as before, then by 500. 0 stays Auto, and saved
values keep their meaning.

Paper White's slider has its 48 steps, so every 10-nit value is reachable
by keyboard and controller. Paper White's description no longer claims the
menus, which UI Brightness sets.

Closes #12841

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 21:58:04 +01:00
redstonerthebest 4a3058d4ad mod-api: push_voice, an audio path for mods
Adds Host::push_voice(samples, sample_rate, at): a mod queues mono samples and
the output callback mixes them with the game's sounds, with distance gain and
direction when the mod says where they were spoken.

The roadmap entry "Proximity voice chat as a native mod" names what is missing
for such a mod: a way to carry voice between players and an audio path in the
game. The carrying side already exists (net_send_to_server, native_net_from_server
on a mod's own channel); this adds the audio side.

- horizon-client-render: VoiceChunk and a queue in Mixer, mixed in mix() before
  the limiter; MixerBackend::push_voice caps the queue at two seconds and refuses
  a burst rather than delaying it; Audio::push_voice forwards to it.
- horizon-client: Game::native_push_voice, listed in native-hooks.json under
  helpers so the generator emits Host::push_voice (API 360), its trait default
  and the game's implementation.
- tests: a queued chunk is mixed centred and dropped after it played out; a
  position pans it by the listener's right vector (facing south, -X is right).

The audio itself could not be listened to on this machine (headless, no output
device), so the mixing is checked by tests only.
2026-11-08 20:57:45 +00:00
martipops be5c7e2fe2 Merge remote-tracking branch 'upstream/main' into ui/shell-tabs 2026-11-08 15:57:40 -05:00
SolenopsisbotandCodex 68bb5be687 perf: reuse remote guardian eye scratch
Co-Authored-By: Codex <[email protected]>
2026-11-09 07:54:09 +11:00
CocofireHDandopencode 0a63e373ad Merge remote-tracking branch 'origin/main' into fix/12493-mob-pickup-particle
# Conflicts:
#	crates/horizon-client/src/game/lan.rs

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-08 21:53:09 +01:00
SolenopsisbotandCodex 291a28fc99 perf: fan out Polytone pack tail loads
Co-Authored-By: Codex <[email protected]>
2026-11-09 07:53:05 +11:00
Horizon agentandClaude Opus 5.5 70d3441c4a Eye of ender: keep the EyeOfEnder doc on its struct
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 21:52:11 +01:00
Riccardo GuerrieroandClaude Opus 5.5 54b6232191 Merge origin/main into fix/landing-vibrations
Picks up #12783 (the trusted-reviewers test), whose absence failed verify.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Fekd42N6Dh4LhpXkVwZmHs
2026-11-08 21:51:41 +01:00
SolenopsisbotandCodex aea460736c perf: reuse pickable projectile hitbox scratch
Co-Authored-By: Codex <[email protected]>
2026-11-09 07:51:26 +11:00
Riccardo GuerrieroandClaude Opus 5.5 af256dcc40 Merge origin/main into fix/cauldron-vibrations
Picks up #12783 (the trusted-reviewers test), whose absence failed verify.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Fekd42N6Dh4LhpXkVwZmHs
2026-11-08 21:51:13 +01:00
SolenopsisbotandCodex e54d91dc0c perf: overlap world rest resource loads
Co-Authored-By: Codex <[email protected]>
2026-11-09 07:50:31 +11:00
Evrim CicekandClaude Opus 5.5 fffa3bf6b8 /tp stops its target and puts it on the ground, as vanilla's does
Entity.teleportTo builds its transition with no motion, so
teleportSetPosition leaves the entity still (/tp, /spreadplayers, a
cross-level /tp, the mod API's teleport), and TeleportCommand.performTeleport
then zeroes the vertical motion and grounds the target unless it is a
fall-flying living entity, and stops a mob's navigation. An end gateway's
or a vehicle's own teleport keeps its motion as before.

Fixes #12834

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 23:39:01 +03:00
DenisHumenandClaude Opus 5.5 f70686be76 Empty the off hand when joining a server
A new connection cleared the inventory and armor but kept the off-hand item,
so a server that never sends slot 45 left the last server's item as a ghost.
ClientPacketListener.handleLogin makes a new LocalPlayer with an empty Inventory.

Fixes #12809

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 22:34:06 +02:00
Nikita Maximilian GrimmandClaude Opus 5.5 fe70a48402 Dispensed eggs: elder guardians and copper golems stay persistent
Their constructors call setPersistenceRequired, as in vanilla.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 21:32:46 +01:00
Florian ErnstandClaude Opus 5.5 67ebf35e57 A mob's hurt cooldown is not its invulnerable_time
Vanilla keeps two fields: LivingEntity.damageCooldownTime, the hurt
cooldown a hit sets (never saved, kept by an in-place /data load), and
Entity.invulnerableTime, saved as invulnerable_time, which counts down in
commonTick and makes the entity invulnerable while above 0. Horizon used
one field for both, so a hurt mob saved its cooldown as invulnerable_time
and a summoned invulnerable_time only acted as a hurt cooldown.

Mob gets invulnerable_time, the only field the key loads into and saves
from. It counts down at the start of the mob's tick and joins the
Invulnerable flag in the damage guard (#bypasses_invulnerability passes).
The cooldown stays transient and is carried across a /data rebuild again.
Conversion copies invulnerable_time, and the skeleton trap's riders and
horses start with 60 of it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 21:31:10 +01:00
Horizon agent 1406684c20 Merge remote-tracking branch 'pr/12676' into stations
# Conflicts:
#	crates/horizon-client/src/stations.rs
2026-11-08 21:30:15 +01:00
Horizon agent 4b4dca948c Merge remote-tracking branch 'pr/12640' into stations
# Conflicts:
#	crates/horizon-client/src/stations.rs
2026-11-08 21:30:07 +01:00
Horizon agent b2a457fdef Merge remote-tracking branch 'pr/12639' into stations
# Conflicts:
#	crates/horizon-client/src/stations.rs
#	crates/horizon-items/src/item_data/trim.rs
2026-11-08 21:29:59 +01:00
Horizon agent d9eda46d06 Merge remote-tracking branch 'pr/12638' into stations
# Conflicts:
#	crates/horizon-client/src/stations.rs
2026-11-08 21:29:34 +01:00
a 1f149381f8 Merge remote-tracking branch 'pr/12681' into stations 2026-11-08 21:28:07 +01:00
a 02bbcbd466 Merge remote-tracking branch 'pr/12679' into stations 2026-11-08 21:28:07 +01:00
a e0d14e4edf Merge remote-tracking branch 'pr/12677' into stations 2026-11-08 21:28:07 +01:00
Horizon agent b4b6eefcfb Merge remote-tracking branch 'origin/main' into fix-12641
# Conflicts:
#	crates/horizon-client/src/game/render_tick.rs
2026-11-08 21:27:17 +01:00
Horizon agentandClaude Opus 5.5 6f31c8d6d1 Merge origin/main: ambient particles of pack biomes come from main (fb4805072f)
main landed the pack biomes' ambient particles meanwhile; this branch keeps
their music and ambient sounds.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 21:27:06 +01:00
Horizon agent 545cb15f18 Merge remote-tracking branch 'origin/main' into fix-12617
# Conflicts:
#	crates/horizon-world/src/worldgen/generator_decoration.rs
2026-11-08 21:26:37 +01:00
Horizon agent e2c9f5dd08 Merge remote-tracking branch 'origin/main' into fix-12563
# Conflicts:
#	crates/horizon-client/src/game/boats.rs
2026-11-08 21:26:31 +01:00
Horizon agentandClaude Opus 5.5 389a495f09 saved_templates test: insert into the lazy template store
main added saved_templates.rs, whose test wrote resources.templates directly;
the templates are now a TemplateStore, so the test fills its loaded map.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 21:24:20 +01:00
Horizon agent 7f2fdba6ba Merge remote-tracking branch 'origin/main' into fix-12618 2026-11-08 21:23:15 +01:00
Horizon agent 7f63f42e27 Merge remote-tracking branch 'origin/main' into fix-12613
# Conflicts:
#	crates/horizon-client/src/game/dragon.rs
2026-11-08 21:22:51 +01:00
Horizon agentandClaude Opus 5.5 91879dc898 Dragon fireball: an owner hit stops it at the hit location
AbstractHurtingProjectile.tick moves the fireball to the hit location for every
hit that is not a MISS; DragonFireball.onHit then does nothing for its owner, so
it waits there with its motion instead of taking the free step.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 21:22:04 +01:00
Horizon agentandClaude Opus 5.5 4dd5d0ce71 set_custom_data: the tag and the component may be SNBT text (TagParser.LENIENT_CODEC)
Packs write "tag": "{a:1b}"; the function ignored it, and an existing
custom_data component written as text was replaced instead of merged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 21:21:12 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 b0bb5d2a9e Dispensed egg group: despawn far away, and keep 0b through a reload
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 21:20:30 +01:00
Horizon agentandClaude Opus 5.5 860355f55e recipe_book_variants_tests: drop a doc paragraph about another test
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 21:18:24 +01:00
J1zeeeandNemotron 3233aaabe9 Fix ender chest and end portal block entities missing from /setblock
ender_chest and end_portal are EntityBlocks in vanilla 26.3 but Horizon's
BlockEntity::for_block had no handler for them: they fell through to the
container_size() fallback, which returns None for both, so no block entity
was created and /data get block answered "not a block entity".

Add explicit handlers: ender_chest -> Chest with 27 slots (matching
container_size), end_portal -> new EndPortal variant with no data. Register
EndPortal in fits/id/contents/slots/slots_mut so it behaves like the other
zero-slot entities (DaylightDetector, Bell).

Also fix two traps found in review:
- close_ender_chest removed the block entity entirely; it now copies the
  items back into self.ender_items and leaves the entity in place, so a
  /data get block after opening/closing still reports the entity.
- Containers::view excluded Beehive/Campfire/Brushable from being treated
  as containers (so droppers don't insert into them); EndPortal needed the
  same exclusion.

End-to-end verified: /setblock ender_chest/end_portal then /data get block
returns the correct id, and accessing the ender chest's container leaves
its block entity intact.

Co-Authored-By: Nemotron <[email protected]>
2026-11-08 23:18:05 +03:00
Horizon agentandClaude Opus 5.5 8d8ef94673 RunAroundLikeCrazy: canContinueToUse has no isMobControlled check
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 21:14:55 +01:00
Mik00000 f9374b154b perf(worldgen): expand structure locate rings across candidate sets with vanilla stop order 2026-11-08 22:08:14 +02:00
Florian ErnstandClaude Opus 5.5 009baf97ad Test: a hurt cooldown survives an edit and is not saved
Checkpoint before a fix: the Game test and the strict Java harness.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 21:08:01 +01:00
MrCloudy2andClaude Opus 5.5 e7cd3c4c6c Put a wind charge's box 0.15 below its position
AbstractWindCharge.makeBoundingBox builds the box from y - 0.15F. Pistons,
cobwebs, water, other projectiles' hits and the punch pick box all use it.
Harness: tools/test_wind_charge_box_parity.py.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 21:05:16 +01:00
Horizon agentandClaude Opus 5.5 c945576faf perf: spawners test players where they stand, without a player list
alive_player_near (BaseSpawner.isNearPlayer, asked by every spawner with
a player in its chunk, every tick) built each player's full record, box
included, for a distance test. It now tests the guests' positions
directly. mob_world_base collected every player to read the first one's
position; it now takes the first only. Same players, same results.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 21:02:17 +01:00
DangerousbrosandClaude Opus 5.5 3fd4fed964 Far terrain: tiles the view leaves are kept, and taken back on a return
A replan dropped every sampled tile outside the new plan, so a teleport back,
a trip out and home again or the far distance lowered and raised sampled the
whole view again, and asked again for all its chunks' detail. Tiles leaving
the plan are now kept (lod::kept) with their detail masks, for 10 minutes and
up to 512 MB, the finest dropped first; a return takes them back, the
coarsest level first and the next once the meshes are in, so the view fills
coarse to fine as sampling does. A refresh or a newly kept chunk forgets the
kept tiles it makes stale, and tiles of chunk-wide cells over the full-detail
columns are sampled again as before. status reports lod.kept_tiles and
lod.kept_mb.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 20:01:55 +00:00
Horizon agentandClaude Opus 5.5 068b0a5a74 perf: skip the item scan for players with no item in reach
guest_pickups scanned every item entity of the level for every player,
every tick. The players' pass now asks the item columns first (the
index hoppers already use, kept for one pass in which items do not
move), and scans only for a player whose 1.5-block reach meets a column
an item's box reaches. Items taken and their order are unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 20:58:58 +01:00
Florian ErnstandClaude Opus 5.5 539933e0a1 A mob hurt by a mob saves last_hurt_by_mob
LivingEntity.addAdditionalSaveData writes last_hurt_by_mob and
ticks_since_last_hurt_by_mob while the mob that hurt it is recorded, and
baseTick drops that record once the attacker is dead or 100 ticks have
passed. Horizon kept the record for its AI but never saved it, and a
value read from loaded data was written back as it was, for ever.

A mob's saved data, and the view advancement criteria test, now write
both keys from the live record while the attacker is a living mob in
the level and at most 100 ticks have passed, and drop both otherwise.
This is a save-time projection: the record itself, its end for the AI,
no_anger, a player attacker and reading the keys back are unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 20:56:38 +01:00
Nikita Maximilian Grimm bfd5c69005 Merge commit '65d988b03' into hdr-p0-base 2026-11-08 20:55:24 +01:00
Nikita Maximilian Grimm 54cae6c3ef Merge commit '2d4a33879' into hdr-p0-base 2026-11-08 20:55:22 +01:00
Horizon agentandClaude Opus 5.5 d5c7b201ce perf: answer ticking_chunk from a neighbour count
ChunkMap keeps, for each column, how many of the 3x3 columns around it are
resident, updated on insert, remove and clear. ticking_chunk (natural
spawning's candidate filter, scheduled ticks' can_tick, random ticks) then
asks one map instead of nine for every ticking chunk, every tick.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 20:55:21 +01:00
RLC248andCodex cf23b0da77 Pass enchantment registry in tooltip tests
Co-Authored-By: Codex (GPT-6.1 Sol) <[email protected]>
2026-11-08 20:52:10 +01:00
iseaprojectsandopencode 0ce693d743 Saving: pack a one-value section from its stored words
`PalettedContainer::pack` unpacked all 4,096 cells of a section into a
scratch `Vec<u32>` to find its distinct values, then re-packed them. A
section that holds one value (air, stone, anything filled by one block)
saves as one palette entry and no indices, and that is most sections of a
column, so the unpack was most of their save cost: 3.85 us to pack one.

`uniform()` answers from the stored words instead and returns the same
`Packed` at 0.03 us; a list or global palette compares its words with the
repeated pattern of the first cell, so nothing is allocated and a mixed
section pays one scan that stops at the first mismatching word (measured
at no cost). The saved bytes are unchanged: one distinct value was already
`bits: 0` with no data. One saved column goes from 802 to 704 us.

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-08 15:51:13 -04:00
Nikita Maximilian GrimmandClaude Opus 5.5 e5bbad5f2f A summoned or loaded eye of ender flies, shatters after 80 ticks and saves its Item
An eye_of_ender from /summon or a saved chunk was kept as an inert
compound: it never moved, never shattered, and saved no Item. Vanilla's
EyeOfEnder.tick moves it by its motion (it has no target without
signalTo), and on tick 81 it shatters without dropping its item; it
saves only its Item (one of it, an eye by default), not its life.

Fixes #12807

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 20:50:50 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 c052c5523a Scripted runs: a person's input, focus and pointer stay out of them
A person at the computer could spoil a screenshot sequence, a
HORIZON_TICK_SCRIPT scenario or a record-replay with a stray key or mouse
move, and an agent's window took their pointer.

- While one of those runs, the window's keyboard, mouse, wheel, cursor and
  IME input of origin user, raw mouse motion, mods' raw keys and the
  hardware pad don't reach the game and aren't recorded. The run's own
  input (the socket, the replay) still plays; closing the window still
  works.
- A person clicking into the window or away from it during a run takes
  effect when the run ends: a replay's look and clicks depend on the focus
  and the grab.
- During a run the pointer grab stays inside the game; when it ends, the
  system pointer follows the grab the run left.
- A visible game an agent starts opens without the focus, and its grab
  stays internal (as --offscreen's does) until a person clicks into it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 20:50:37 +01:00
baytyoneandZCode 5d5e00f783 Hub: the per-player tracking sweep runs shared out
Each player's range check, removals and RemoveEntities packet are its own slice of the level's entities, so the sweep of the players of a level now runs through horizon_threads::par_chunks_mut (#8158): what they start tracking meets the pairs after, in the players' order, and every player's packets keep their order, as vanilla's do. HORIZON_SERIAL_TICK=all keeps the sweep on the server thread, the serial reference the parallel phase is checked against.

Co-Authored-By: ZCode (GLM) <[email protected]>
2026-11-09 00:49:02 +05:00
Florian ErnstandClaude Opus 5.5 f7e6433729 Test: a mob saves the mob that hurt it for 100 ticks
Checkpoint before the fix: the Game test and the strict Java harness.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 20:48:11 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 2d4a338796 HDR float frame: screenshots stay as drawn with HDR Lighting off
With HDR Lighting off Horizon lifts nothing, yet the SDR screenshot's
shoulder still rolled 0.6..1 of paper white down, so white came out at
0.8 in linear light (230 of 255) against the HDR-off screenshot's 255. The shoulder is now a
pipeline constant, off while HDR Lighting is off: the world is clipped at
white as SDR draws it, and the HUD composite gives the HUD's target back.

Refs #12628

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 20:47:43 +01:00
DetimesandClaude Opus 5.5 48ec2657df Mod API: inventory_changed hook and typed inventory helpers
inventory_changed is heard once per changed kind per tick; the *_typed
helpers return NativeInventoryError; Cursor and Crafting kinds (live only);
create_player_file.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 19:46:39 +00:00
Arthur BlackandClaude Opus 5.5 a05497bbcd Touch aim helpers for on-screen controls
- Add touch::aim: camera turn from a drag, tap ray through the view, hotbar slot under a finger
- Add which held items act on a short tap and which aim with the crosshair
- Move the attack gauge to the hotbar when the touch scheme has no crosshair
- Add look sensitivity (overall, horizontal, vertical) to the touch settings, sanitized
- Adapted from Starling (MIT, mostly by Vasysik); credited in THIRD_PARTY_NOTICES.md

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-11-08 11:44:14 -08:00
Nikita Maximilian GrimmandGPT 6.1 Sol 40d0364acc Shader packs: follow picked HDR frames until refused
Use the picked manifest's frame before a load outcome is known, preserving
one upfront renderer replacement and one compile on a successful pick.
Follow the resolved selection and its recorded outcome so a refusal,
validated API change, missing path or disabled owner can update the frame.
Keep rejected fingerprints across renderer resets and retry only changed
files. Frame attempts stay bounded by key changes; pack inputs are unchanged.

Known limits: existing pick/drop and hot-reload transition-frame ordering;
invalid edits followed by a later rebuild fall back to no pack until fixed;
split guests and shaders-format pending lifetime beyond this key change.
No request replay or transactional publication. Step 5, API 0.2 HDR output
and linear blending remain out of scope.

Closes #12799

Co-Authored-By: GPT 6.1 Sol <[email protected]>
2026-11-08 20:42:36 +01:00
RINNECODERandClaude Opus 5.5 ce99d8445d port-pack: carried mod features get 26.3's shape
A mod's configured features kept their 1.21.1 `config` wrapper: the carry
only rewrote block states, while the `config` unwrapping, patch and
placement rewrites lived in the world-pack upgrade. 26.3 skips a feature
that keeps `config`, so no carried mod ore (Occultism silver, Iron's
mithril, Powah uraninite) ever generated. The carry now runs the
world-pack upgrade's feature and placed-feature rewrites too.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 13:40:23 -06:00
itarqos5andClaude Sonnet 5.5 b5723fd33d Terrain: take RGSS's four taps at the fractional mip level
rotated_grid took each of its four taps at the two mip levels around the
exact level and mixed them by fract(exact) in the shader: eight texture
instructions. The sprite sampler's mip filter is linear, so one tap at the
fractional level is that mix; it now takes four. GPU time per frame on the
benchmark scenes (RX 580, 1080p, render distance 16, same binary): forest
-17%, ocean -15%, village -17%, mountain -14%. The picture differs only by
the GPU's limited-precision level blend.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_011paghWkrnyQRBhJRodZpG2
2026-11-08 19:39:14 +00:00
Vinícius MoreiraandClaude Opus 5.5 09ccb39c4f Polytone: a custom particle's light_level is a floor under its block light
`light_level` is the block light a particle gives itself (wiki "Custom Particle Types"): the
particle is drawn with at least that block light, as the GPU particles already were. Measured
beside the real mod at night: a lit, light_level 8 particle draws warm and bright where Horizon drew
it with the dark world light, as a pack's light shafts under a canopy.

Part of #11486

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 16:38:28 -03:00
Mayonesu0andClaude Sonnet 5.5 5273939a30 Apply held-item modifiers when the stack changes, not only the item
Player.tick resets the attack strength only for a different item, but
LivingEntity.detectEquipmentUpdates re-applies the equipment's attribute
modifiers whenever the stack differs (!ItemStack.matches). tick_hand gave
the player its modifiers only on the item-change reset, so swapping to the
same tool with Efficiency (or enchanting it in place) never set
minecraft:mining_efficiency and digging stayed at the plain tool speed.

Fixes #12384

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-08 20:37:52 +01:00
DetimesandClaude Opus 5.5 68f4708d84 Mod API: per-usage command permissions, vanilla roots, permission groups, server helpers
Each usage of a mod's command keeps its own permission node; a usage can
replace a vanilla root (NativeCommand::replace_vanilla); a command name may
start with slashes. Adds the permission_groups hook and primary_group,
whitelist_add/remove, log, set_game_mode and the server_stopped hook.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 19:34:46 +00:00
DetimesandClaude Opus 5.5 aa3819d4a5 Launcher: a bundle's api package may depend on its own crates by path
The staged api package can name the bundle's [[crate]]s by relative path;
they are rewritten to their staged copies.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 19:32:51 +00:00
DetimesandClaude Opus 5.5 3316c45f44 Mod API: chat hooks and helpers
player_chat_v2 (with the sender's permission level), chat_decorate (the
shown line sent as unsigned content beside the signed body), chat_as,
player_join_message, player_leave_message and the join/leave helpers
(has_played_before, motd, broadcast_quiet).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 19:32:49 +00:00
DetimesandClaude Opus 5.5 36896744bf Mod API: block change hooks
block_grow (crops, stems, cactus, cane, bamboo, spreads), tnt_primed_v2,
hanging_break_v2 and entity_change_block_v3 can cancel the change; the
explosion_detonate hook names the entity that exploded.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 19:32:46 +00:00
DetimesandClaude Opus 5.5 dc22fb4396 Mod API: player effect icons, attribute modifiers, more player state
effects_v2/set_effects_v2 carry the icon flag, attribute_modifiers lists the
transient modifiers, NativeServerPlayer gains using_item, blocking,
sprinting and gliding.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 19:32:45 +00:00
MSedgeMC f7a1f50728 Read the lock from the player's main hand
LockCode.canUnlock uses Player.getMainHandItem, whichever hand the click came
from, so a key in the off hand does not unlock a container. The lock check
read the acting hand; it now reads the selected main-hand slot, and the test
covers an off-hand key being refused.
2026-11-08 19:30:06 +00:00
RLC248andCodex f4aa6beb4a Search creative items by localized enchantment tooltips
Co-Authored-By: Codex (GPT-6.1 Sol) <[email protected]>
2026-11-08 20:29:25 +01:00
RLC248andCodex 8b65ee2372 Bounce items from slime support at honey edges
Co-Authored-By: Codex (GPT-6.1 Sol) <[email protected]>
2026-11-08 20:28:19 +01:00
Evrim CicekandClaude Opus 5.5 f523a60066 Boats, rafts and minecarts save and load OnGround and fall_distance
Entity.load reads OnGround and fall_distance and saveWithoutId writes the
live values; Horizon wrote false and 0 and read neither. Boats now count
their fall as AbstractBoat.checkFallDamage does (on a host's tick and a
LAN guest's move), reset it in water, and minecarts no longer reset it on
a move up or none, which vanilla's Entity.checkFallDamage never does.

Fixes #12517

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 22:28:01 +03:00
DUzzL 27d7fdd859 Sweep a minecart's rail step for block-inside effects on slopes 2026-11-08 20:25:25 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 34b0265744 /fill loads the block's NBT into the block entities it places
FillCommand.fillBlocks places each block with BlockInput.place, which
loads the tag into the block entity there and counts a block whose tag
changed its block entity even when the state was already in place. A
hollow fill's core is plain air. Horizon placed the state and dropped
the tag, so every block entity /fill made kept its defaults.

Fixes #12814

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 20:22:46 +01:00
MSedgeMC abcbf3739d Breaking a half of an open double chest closes the menu
ChestBlock's menu provider builds a CompoundContainer over both halves, whose
stillValid asks both block entities; breaking either half ends the menu.
Horizon closed the menu only when the clicked half was removed, so the other
half breaking left a shorter menu over the remaining half.
2026-11-08 19:21:02 +00:00
MSedgeMC 45bcdca495 A pack's #minecraft:lectern_books item goes onto the lectern
LecternBlock.useItemOn accepts any stack in ItemTags.LECTERN_BOOKS; Horizon
matched the two vanilla item ids directly, so a data pack adding its own book
to the tag could not put it on a lectern.
2026-11-08 19:20:48 +00:00
MSedgeMC 4e724e319b A lectern's comparator output counts only a book as a book
LecternBlockEntity.getRedstoneSignal adds one only when hasBook(): the stack
carries a written_book_content or writable_book_content component. Horizon
added one for any stack in the Book slot, so a non-book item a command left
in a lectern read 15 at its last page where vanilla reads 14.
2026-11-08 19:20:13 +00:00
MSedgeMC 6b2fadfcd5 A locked container refuses the local player's open
BaseContainerBlockEntity.createMenu asks canOpen, whose LockCode takes the
player's main hand or a spectator; a refusal sends the container.isLocked
action bar naming the provider and one chest-locked sound. Horizon kept a
container's lock on disk and checked it for LAN guests, but the integrated
player's open_menu went straight to the loot roll and the menu, so a locked
chest opened for anyone.

The local open funnel now checks every block entity of the provider - both
halves of a double chest - before it opens, with vanilla's message and sound.
2026-11-08 19:20:04 +00:00
Florian ErnstandClaude Opus 5.5 96f1fb36a2 Format hoglin_hurt's signature
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 20:19:17 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 2e258065d7 Ominous spawner group: read only the motion the issue covers
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 20:18:07 +01:00
martipopsandClaude Opus 5.5 0256b28b72 Merge upstream/main into ui/shell-tabs
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 14:15:57 -05:00
martipopsandClaude Opus 5.5 8200d52bdd Shaders: screens that don't fit the page list collapse, none left out
Fixes #12801. In a short view (427x240 GUI px: an 854x480 window at GUI
scale 2, or 720p at its automatic scale) Shaders' page list has room for
four of a pack's screens, and the rest were dropped with nothing to say
so. Now, when they don't all fit, they collapse into one `‹ Name ›` row
with pips (`tabs::collapsed`). Left of the name opens the screen before,
right of it the one after; while searching, the nearest ones with
matches. Each part is a `Tab` target, so RB and LB still step through
every screen and back to the pack list. When the screens fit, the list
is unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 14:15:53 -05:00
martipopsandClaude Opus 5.5 ce61a0e1de HUD kit: a collapsed page strip
`hud_kit::tabs::collapsed` lays a page list out in one row: the selected
name between `‹ ›` arrows, cut to fit, with a pip per page under it. An
arrow with no page that way is dimmed, and when the pips don't fit across
the row they are a window round the selected one. `draw` paints it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 14:15:52 -05:00
iseaprojectsandopencode 9791592034 Wear the player's armor before the hit reads its armor value
`LivingEntity.getDamageAfterArmorAbsorb` calls `hurtArmor` before it reads
the armor and toughness attributes, so a piece that breaks on a hit takes no
part in the reduction of that same hit. #11696 showed a chestplate at 239 of
240: vanilla saves Health 16.0, Horizon 16.64.

Every source that reaches `hurt_player_from` (mob attacks, projectiles,
`/damage`, fireworks, the wither, pufferfish) and `hurt_player_by` (thorns,
the dragon, lightning, ender pearls, the world border) now takes the
synchronous equipment path that `damages_helmet` already used: it wears the
armor between the hit's selection and its armor read. Explosions, potions and
the library's environmental ticks (`motion.rs`, `landing.rs`, `hunger.rs`)
still queue the wear for the next drain and are left for a follow-up.

The helmet-phase advancement keeps its reported amount, so no hit's
`ENTITY_HURT_PLAYER` value changes. `tests/fixtures/helmet_damage.json` gains
a `mob_attack` hit that breaks a boot beside one that leaves it worn; the
helmet-damage harness compares both against real vanilla.

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-08 15:11:27 -04:00
fbikatandClaude Opus 5.5 afd3e48ccd Snow golems save and load Pumpkin
SnowGolem writes Pumpkin and reads it with getBooleanOr("Pumpkin", true).
Horizon had no snow golem case in entity_nbt, so a golem sheared in game
wore its pumpkin again after a reload and {Pumpkin:0b} was ignored.

Fixes #12778.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 19:08:34 +00:00
RLC248andCodex 56dbf356a0 Canonicalize boolean flags after shared raw save overlays
Co-Authored-By: Codex (GPT-6.1 Sol) <[email protected]>
2026-11-08 20:08:25 +01:00
Nikita Maximilian Grimm 478e7b45bd Merge remote-tracking branch 'origin/main' into entity-ticking-gate 2026-11-08 20:07:44 +01:00
Florian ErnstandClaude Opus 5.5 db32617271 A hoglin's retaliation broadcast keeps a nearer target
HoglinAi.maybeRetaliate broadcasts the attacker to the visible adult
hoglins, and each takes it only when it is not pacified and its current
attack target is not strictly nearer (setAttackTargetIfCloserThanCurrent).
Horizon's hoglin alerted from its hurt-by goal instead: every hoglin in
range without a target got the attacker, overwritten after the tick. A
hoglin hit in the same tick as a neighbour hit its own attacker once,
then took the neighbour's attacker behind the glass and never hit again.

The hoglin's hurt-by goal no longer alerts. hoglin_hurt broadcasts a
retaliation to the sensed visible adult hoglins, and the event applies
the pacified guard and the nearest-target rule. Piglin alerts and the
generic SetTarget are unchanged. With a player on either side the
current target stays, since each mob's player is its own.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 20:02:58 +01:00
Nikita Maximilian Grimm ad7effc349 Merge remote-tracking branch 'origin/main' into fix-dispensed-egg-persistence 2026-11-08 20:01:14 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 0299f2fe2f Parity: the vanilla tick-script client never takes or moves the pointer
Off Linux's virtual screen, the vanilla 26.3 client that VanillaTickScript
drives sits on the person's desktop, and vanilla's MouseHandler took the
person's pointer: the join grabbed and hid it, a mouse move turned the
player, and every screen the script opened warped the pointer to the
window's middle (releaseMouse has no focus check).

- SDL drops the keyboard, text and mouse events once the script is
  installed, and releases what was held; the script's lines never went
  through them.
- MouseHandler is replaced by one whose grab is the flag alone, so screens
  open and close without touching the system pointer.
- The harness opens the window without the focus
  (SDL_WINDOW_ACTIVATE_WHEN_SHOWN=0). Window.focused starts true and only
  SDL focus events change it, so the window asks SDL for its real focus
  while resources load: the join no longer warps the pointer.
- Window.onResize re-grabs whenever the flag is set: SDL drops the window's
  resize events (pixel-size events still resize the frame).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 19:57:10 +01:00
DUzzL a145d469c6 mobs: keep DeathTime on load, tick the impulse grace time, pick the armadillo scute time 2026-11-08 19:56:48 +01:00
baytyoneandZCode 0ec0dbb2f4 horizon-threads: par_chunks_mut, the shared groundwork of #8158
Chunks of a slice run on helper threads under a scope, the calling thread working too; HORIZON_SERIAL_TICK=all keeps every chunk on the caller as the serial reference. Not yet used: the hub's per-player pass and the mob loop's speculate-and-commit, the pieces of #8158 it is for, follow separately.

Co-Authored-By: ZCode (GLM) <[email protected]>
2026-11-08 23:55:16 +05:00
Nikita Maximilian GrimmandClaude Opus 5.5 c5e3ef940b Freeze thrown potions, orbs, rockets and clouds outside entity-ticking chunks
ServerLevel.tick ticks an entity only in an entity-ticking chunk. Thrown
potions, lingering-potion and plain area effect clouds, firework rockets
and experience orbs ticked anywhere loaded, so they kept moving and
ageing while their chunk unloaded or past the simulation distance.

Fixes #12769

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 19:55:11 +01:00
Nikita Maximilian Grimm b83ab5e01d Merge remote-tracking branch 'origin/main' into fix-ominous-spawner-motion 2026-11-08 19:54:44 +01:00
Florian ErnstandClaude Opus 5.5 e8a1e5b997 Test: a hoglin keeps attacking its attacker beside another hit hoglin
Checkpoint before the fix: the Game test and the strict Java harness.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 19:52:02 +01:00
RLC248andCodex acd32b8d31 Preserve projectile entity fields and portal cooldowns
Co-Authored-By: Codex (GPT-6.1 Sol) <[email protected]>
2026-11-08 19:51:48 +01:00
DUzzL 8695ecb3b4 Hurt the ender dragon with firework explosions 2026-11-08 19:48:49 +01:00
RLC248andCodex 08ed0dcb83 Save typed defaults for display and interaction entities
Co-Authored-By: Codex (GPT-6.1 Sol) <[email protected]>
2026-11-08 19:47:21 +01:00
Mayonesu0andClaude Sonnet 5.5 6aa575f7d1 Server's player forgets a fall in a cobweb or sweet berry bush before landing
A server's player copy skipped makeStuckInBlock's fall reset (its client
moves it), and ran its landing check before the block effects, so a
player falling into a cobweb was hurt on landing. handleMovePlayer moves
the player first (checkInsideBlocks) and only then runs doCheckFallDamage:
add an early pass that applies just the fall/impulse reset of a cobweb or
sweet berry bush the moves touched.

Fixes #12382

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-08 19:37:30 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 65d988b030 Shader API 0.2: refuse constructing Frame and the stage inputs
0.2 becomes the only line served at the HDR note's step 5, so the
additions we expect (which pixels are light sources for post, a
full-screen peak, an SDR output's display values) must not break packs.
A pack that builds `PostInput(...)` positionally would stop compiling
when a field is added.

- `horizon-shader check` refuses, for 0.2 packs, a `Compose` of `Frame`,
  any stage input (every `StageKind`'s input and `DisplacementInput`) or
  the post helpers' `SurfaceData` in the pack's files, found in naga's IR
  (function bodies and module constants) and located by span. Reading
  fields and passing a received value along are unchanged; `Surface`
  stays constructible.
- 0.1 is left unchanged: it is retired at step 5 and its structs won't
  grow.
- The 0.2 reference: `[tonemap]` is a pure mapping to the output its
  `frame` describes, once per pixel of each output (Horizon may add
  outputs such as an SDR screenshot); alpha is coverage only, never
  light; `Surface.color` is non-negative unbounded linear light; `Frame`,
  the inputs and `SurfaceData` may gain fields. Changelog entry in
  docs/api/shaders.md.

Closes #12673

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 19:34:02 +01:00
iseaprojectsandopencode a4bf12b2da Italian: the rest of the launcher
Accounts and installs, versions and builds, the updater, the appearance
settings, the AI agent and data collection pages, and the single words the
screens reuse.

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-08 14:32:53 -04:00
iseaprojectsandopencode 42635ff154 Italian: the launcher's mods, packs and porting
What the launcher says about a mod, its jar and what of it came over, the
resource, shader and data packs, and the Modrinth entries.

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-08 14:32:52 -04:00
iseaprojectsandopencode 79a2d9d554 Italian: the launcher's remote servers over SSH
The screen that reaches a server on another computer: what to type, why ssh
is missing, why {host} refused the key, and the horizon-server messages that
come back over the connection.

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-08 14:32:52 -04:00
iseaprojectsandopencode 0d45709554 German: the rest of the launcher
Accounts and installs, versions and builds, the updater, the appearance
settings, the AI agent and data collection pages, and the single words the
screens reuse.

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-08 14:30:34 -04:00
iseaprojectsandopencode 936a48fe4b German: the launcher's mods, packs and porting
What the launcher says about a mod, its jar and what of it came over, the
resource, shader and data packs, and the Modrinth entries.

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-08 14:30:33 -04:00
iseaprojectsandopencode afc1d6a0e0 German: the launcher's remote servers over SSH
The screen that reaches a server on another computer: what to type, why ssh
is missing, why {host} refused the key, and the horizon-server messages that
come back over the connection.

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-08 14:30:33 -04:00
Nikita Maximilian Grimm 226c1ceeef Merge remote-tracking branch 'origin/main' into fix-shulker-empty-items 2026-11-08 19:28:16 +01:00
iseaprojectsandopencode 21a5d735ea Portuguese (Brazil): the rest of the launcher
Accounts and installs, versions and builds, the updater, the appearance
settings, the AI agent and data collection pages, and the single words the
screens reuse.

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-08 14:28:12 -04:00
iseaprojectsandopencode 0e525f3952 Portuguese (Brazil): the launcher's mods, packs and porting
What the launcher says about a mod, its jar and what of it came over, the
resource, shader and data packs, and the Modrinth entries.

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-08 14:28:12 -04:00
iseaprojectsandopencode e5f945dc55 Portuguese (Brazil): the launcher's remote servers over SSH
The screen that reaches a server on another computer: what to type, why ssh
is missing, why {host} refused the key, and the horizon-server messages that
come back over the connection.

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-08 14:28:12 -04:00
Nikita Maximilian GrimmandClaude Opus 5.5 00299919d9 HDR: keep the setting's description, so its translations stay
Every language file keys its translation of the HDR setting's
description by the English text. Changing that text left them stale,
and the language test refuses a key en_us.json no longer has. The old
description is still true with Auto, so keep it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 19:27:03 +01:00
iseaprojectsandopencode 5661b09d71 French: the rest of the launcher
Accounts and installs, versions and builds, the updater, the appearance
settings, the AI agent and data collection pages, and the single words the
screens reuse.

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-08 14:25:53 -04:00
iseaprojectsandopencode 40c12c20e0 French: the launcher's remote servers over SSH
The screen that reaches a server on another computer: what to type, why ssh
is missing, why {host} refused the key, and the horizon-server messages that
come back over the connection.

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-08 14:25:53 -04:00
Florian ErnstandClaude Opus 5.5 a7c004b6ac An experience orb is a score holder for execute store
ScoreHolderArgument resolves @s to the source's entity, and every entity
is a score holder named by its UUID. Horizon gave orbs no holder name
('no UUID here'), so execute as <orb> store ... score @s found no entity
and stored nothing, and a score set by the orb's UUID was reported under
the UUID text instead of the orb's name.

An orb's holder name is now its registered UUID, the identity its saved
UUID reads already use. Projectiles are unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 19:23:40 +01:00
Oloion 4d6f0a1c4a Fix tooltip box pixel alignment and Russian modifier formatting
- Snap tooltip coordinates to whole pixels to prevent border rasterization clipping
- Clamp tooltip x-position to stay within viewport bounds for wide Cyrillic strings
- Route creative tab tooltips through draw_tooltip_lines for consistent rendering
- Add regression tests for Russian modifier placeholder formatting and tooltip geometry

Fixes #12600
2026-11-08 21:23:37 +03:00
MrCloudy2andClaude Opus 5.5 bd13d0f39c A projectile in a cobweb's reach is stuck, and a piston's next push stops it
Entity.makeStuckInBlock: a cobweb or powder snow an entity's box meets sets
its stuckSpeedMultiplier, and its next Entity.move (a piston's push) sets
its movement to zero. Projectiles never took either: their own tick
(AbstractHurtingProjectile/ThrowableProjectile.tick's
applyEffectsFromBlocks) and a piston's push (moveEntityByPiston's
applyEffectsFromBlocks) left a cobweb's projectile flying. They now set
Projectile::stuck_speed, and a block's push spends it.

Harness: test_projectile_piston_cobweb_parity (dedicated servers, frozen
and stepped: a wind charge a piston lifts by a cobweb stops, one in open
air keeps its speed).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 19:20:14 +01:00
iseaprojectsandopencode 468ab204c7 Italian: controller settings, recipe transfer and the Studio timeline
The controller page and the lines it prints while you rebind, the pad notice,
the recipe-transfer messages, and the Studio timeline and top bar.

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-08 14:19:51 -04:00
iseaprojectsandopencode aa208521eb Italian: PvP & HUD
The whole `horizon.pvp` block: the custom crosshair, the block outline, the hit
colour, the HUD widgets with their corners, chat timestamps, compact chat, copy
and search, the preset, the HUD editor, combat feel and the 1.8 rules, and the
visual tweaks.

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-08 14:19:51 -04:00
iseaprojectsandopencode 92c1eb1b76 Italian: emotes, moderation, tempban and page entries
The emote wheel, `/mute`, `/mutelist` and `/tempban` with their durations and
reasons, the remaining options and three page entries.

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-08 14:19:51 -04:00
iseaprojectsandopencode 408d193d01 Italian: the graphics help texts, Realms, Extras, splitscreen and packs
Flat keys, the ones whose English sentence is also the key, so only the value
moves: GPU Culling, Upscaling Sharpness, Opaque Ice, Hidden Faces and the
sentences that say what each setting costs. Plus the Realms sign-in line, the
Extras entries, friends, splitscreen and the pack browser.

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-08 14:19:51 -04:00
iseaprojectsandopencode 9f903d2b3f Italian: Studio, its pages and the mod browser
The `/studio` commands and the Studio screen, the Music page with every
situation, the page titles and the `...` entries, and the whole mod browser.

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-08 14:19:50 -04:00
iseaprojectsandopencode e0615ba966 Italian: the Extras screen, the new options and the controller guide
The Extras entries, the options added since the file was last touched, and all
48 labels of the on-screen controller guide.

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-08 14:19:50 -04:00
DUzzL ad183565d8 Predict the empty-bucket pickup of a sulfur cube on a client 2026-11-08 19:19:47 +01:00
Florian ErnstandClaude Opus 5.5 70c8fe35a2 Test: an experience orb stores into its own score through execute store
Checkpoint before the fix: the Game test and the strict Java harness.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 19:17:18 +01:00
iseaprojectsandopencode 4bcfbe6eb3 German: controller settings and recipe transfer
The controller page and the lines it prints while you rebind, the pad notice and
the recipe-transfer messages.

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-08 14:16:58 -04:00
iseaprojectsandopencode 28722235de German: PvP & HUD
The whole `horizon.pvp` block: the custom crosshair, the block outline, the hit
colour, the HUD widgets with their corners, chat timestamps, compact chat, copy
and search, the preset, the HUD editor, combat feel and the 1.8 rules, and the
visual tweaks.

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-08 14:16:58 -04:00
iseaprojectsandopencode 3f71bd7deb German: packs, options and page entries
The pack browser, the remaining options and three page entries.

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-08 14:16:58 -04:00
iseaprojectsandopencode 3b3ed4b217 German: splitscreen
The splitscreen setup and prompts, which German was missing whole: monitor and
window picking, the per-player profiles, joining and leaving, and the agent and
repro notices that sit between them.

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-08 14:16:58 -04:00
iseaprojectsandopencode a0747ddd25 German: pages, the mod browser, the graphics help texts and Realms
The page titles and `...` entries, the mod browser, the flat graphics keys whose
English sentence is also the key, the Realms sign-in line and the Extras
entries that belong with them.

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-08 14:16:58 -04:00
iseaprojectsandopencode a3770664c2 German: the Extras screen, the new options and the controller guide
The Extras entries, the options added since the file was last touched, and all
48 labels of the on-screen controller guide.

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-08 14:16:57 -04:00
Nikita Maximilian GrimmandClaude Opus 5.5 f03f847d4f Shulker boxes: an empty roll still restores the box's loot table
A box whose loot table rolled nothing now saves no Items, which
RolledLoot::restore must take as the roll, or the unopened box would
lose its LootTable. The group gains a box with LootTable minecraft:empty.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 19:16:13 +01:00
DUzzL 408e4fda1c Slow minecarts in cobwebs like Entity.makeStuckInBlock 2026-11-08 19:05:10 +01:00
baytyoneandZCode 7b92e8a8aa Map searches read each saved chunk's record once, like StructureCheck
A chest with exploration maps resolves each one through findNearestMapStructure with skip_existing_chunks, whose skip check read the saved chunk's record (a region read and an NBT parse) for every candidate of every search, and allocated a String per candidate for the reference set. The cost grew with every map resolved and every chunk saved: on the official SMP, uses grew from 0.27 s to 1.23 s inside use_block (#10255). The session now keeps each saved chunk's answer, as vanilla's StructureCheck cache does, and the reference set is checked by name without allocating. The reference file's format is unchanged.

Co-Authored-By: ZCode (GLM) <[email protected]>
2026-11-08 23:04:23 +05:00
RLC248andCodex bcd3d93200 Keep tracking particles anchored to moving entities
Resolve live position and posed bounds for each emission tick. Retain a removed target at its final body until expiry, and detach before entity-ID reuse.

Co-Authored-By: Codex (GPT-6.1 Sol) <[email protected]>
2026-11-08 19:01:45 +01:00
Vinícius MoreiraandClaude Opus 5.5 2ece11744d Polytone: a particle's emitters see the live particles for limit and exclusion_radius
While the live particles tick they are out of the engine's list, and what their
particle_emitters spawned was made right away: its type's limit and
exclusion_radius counted only the particles spawned so far this tick. A pack's
emitter particle spawning a light ray every tick at one spot (exclusion_radius 1)
stacked a ray per tick where Java keeps one. These spawns now wait until the live
particles are back, still within the tick.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 14:52:10 -03:00
Vinícius MoreiraandClaude Opus 5.5 12b51783dd Polytone: additive custom particles take the fog
A custom particle with render_type additive_translucent takes the fog over its
colour in Java, as vanilla's particle shader gives it. Horizon drew it through the
additive entity pass, which does not, so the freepack's light rays came out bright
yellow where Java's are tinted by the pack's dense fog. Their quads now carry their
own overlay, which the additive pass fogs. Measured in Java on a single light ray
against a black wall: shape, tone and summed light now match within the variation
between shots.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 14:52:10 -03:00
Vinícius MoreiraandClaude Opus 5.5 d9a8fb0667 Polytone: a custom particle's movement_aligned quad turns its texture's top against its motion
Measured in Java with the freepack's light rays and a still copy of them: a custom
particle drawn movement_aligned has its texture's top against its velocity, so a ray
rising toward the noon sun hangs its glow below the leaves it starts at. Horizon
turned it along the velocity, and the rays shone up through the canopy instead of
down under it. A GPU particle keeps its texture's top along its motion, as measured
with the freepack's rain.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 14:51:56 -03:00
Vinícius MoreiraandClaude Opus 5.5 cc1825d017 Polytone: kill_when_still takes a particle a collision stopped
A custom particle with physics and kill_when_still (on by default) was removed on any
tick it did not move, so one that rests on purpose died on its first tick. Measured
in Java with a copy of the freepack's light ray chain (an emitter with physics that
rests beside a leaf and emits every tick of its 20): Java's emitter lives out its
life, Horizon's emitted once or twice, about a fifteenth of the particles. A particle
now goes when a collision stopped it, as a landed one is.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 14:51:56 -03:00
Vinícius MoreiraandClaude Opus 5.5 cadfdbcfb1 Polytone: a custom particle's exclusion_radius
exclusion_radius (wiki "Custom Particle Types") keeps a custom particle from
spawning within that distance of a live one of its type. Horizon read it and spawned
anyway. The freepack's rain splash, which replaces the vanilla one, sets it to 1: its
ripples covered the ground about twice as densely as in Java.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 14:51:29 -03:00
iseaprojectsandopencode bb6e95d5fe lang_coverage: report the keys each language is missing
`lang.rs`'s test checks a bundled file for stale keys, repeated keys, empty
values and changed placeholders, and deliberately not for missing ones: a
language is allowed to lag, and `Language::load` falls back to `en_us`. So
nothing reports the size of the lag, and `fr_fr` missing 510 of 1047 keys is
invisible.

`tools/lang_coverage.py` prints, for the game's `assets/horizon/lang`:
missing, not in the reference, empty, repeated key or wrong indentation,
changed `%s`/`%1$s`/`%.2f`/`%%` placeholders (the same rule as `lang.rs`), and
values identical to English; and for `launcher/lang`: values identical to
English and a `{hole}` a value drops or adds. `--summary` is the line a pull
request pastes, `--json` the same as data, `--show-keys` the keys behind each
count, and `--fail-under` exits 1 so a later CI job can gate it.

It reads files only: no `.local/`, no network, nothing written.

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-08 13:43:33 -04:00
RLC248andCodex 63db5cdd81 Normalize saved boolean flags for boats, minecarts and cushions
Preserve live gravity and grounded state while encoding retained entity
flags with Entity.saveWithoutId's canonical bytes and optional defaults.

Co-Authored-By: Codex (GPT-6.1 Sol) <[email protected]>
2026-11-08 18:25:44 +01:00
MSedgeMC 81caf4c301 A grindstone keeps an explicit zero damage apart
ItemStack.matches compares the component patch, and on a non-damageable item
minecraft:damage:0 is a real component (its prototype has none). Horizon read
the number into the damage field and forgot its presence, so two books with
and without it merged.

Test: a book carrying minecraft:damage:0 and a plain one, both enchanted with
sharpness, give no grindstone result; two identical zero-damage books still
merge to two.
2026-11-08 17:21:07 +00:00
MSedgeMC efbd55185e A smithing trim reads an inline pattern holder
SmithingTrimRecipe's pattern is a TrimPattern holder: a registry name or an
inline asset_id/description/decal definition. Horizon parsed only the string
form, so an inline pattern became an empty name and the trim wore no pattern.

Test: a smithing_trim recipe whose pattern is the inline coast definition
gives a diamond chestplate whose trim holds that direct pattern.
2026-11-08 17:20:55 +00:00
MSedgeMC b68899faad A menu reads a removed max stack size as one
ItemStack.getMaxStackSize is getOrDefault(MAX_STACK_SIZE, 1), so a
!minecraft:max_stack_size patch caps the stack at one. Horizon fell back to
the item's own size, letting two stone stack into a single slot.

Test: in an inventory menu, a stone with the component removed and another
on the cursor stay at one each after a pick-up; on main they merge to two.
2026-11-08 17:20:14 +00:00
MSedgeMC 0618cea36e An anvil refuses an item without its enchantments component
AnvilMenu.createResult first asks EnchantmentHelper.canStoreEnchantments,
which is ItemStack.has of ENCHANTMENTS (stored for a book). A removal patch
leaves no component, so vanilla gives no result, not even a rename. Horizon
assumed every 26.3 item has one and renamed the stack.

Test: a diamond sword with !minecraft:enchantments and an enchanted book
with !minecraft:stored_enchantments both give no result at cost 0; the same
items without the removal rename at cost 1.
2026-11-08 17:20:03 +00:00
CocofireHDandQoder 8beaf4b572 Pickup: a mob's take reaches the clients as a take packet
ClientboundTakeItemEntityPacket is what makes a client add the item
pickup particle and play the pickup sound (ClientPacketListener.
handleTakeItemEntity). Only player pickups were broadcast, so when a
mob picked an item up the item simply vanished for every client
watching, with no flight into the mob (#12493).

add_pickup now sends Hub::take_item for a mob collector from the
server, with the taken entity's id and the stack's size. Player takes
keep their existing broadcast in the guest pickup path.

Co-Authored-By: Qoder <[email protected]>
2026-11-08 18:05:21 +01:00
itarqos5andClaude Sonnet 5.5 14a479c392 Particles: the collision pre-check skips sections that cannot matter
cells_without_collision, which every moving particle asks before resolving its movement,
read the block of each inner cell of its swept box and looked at each cell around it, so a
particle moving 0.4 blocks a tick cost about eight times a nearly still one (poof, smoke and
firework sparks about 205-225 ns, flames and end rods about 25). It now visits a column and
a section at a time and skips every section whose palette holds nothing that collides and
nothing that reaches out of its cell; where a section does hold such blocks and none reaches
out, only the inner cells are read. The answer is unchanged.

On the stress_particles mix (isolated benchmark, particles/tick_bench.rs) the engine tick goes
from 3.21 to 1.77 ms. The section test compares the new scan with the old per-cell one on 4000
random boxes, as well as with the full resolution.

tick_particles gets lap spans (tp_*), so the game's own trace shows where a particle tick goes.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_011paghWkrnyQRBhJRodZpG2
2026-11-08 16:18:46 +00:00
MSedgeMC ded1909d91 An enchanting table reads the stack's enchantable component
EnchantmentMenu's cost and list use ItemStack.get(ENCHANTABLE) and
ItemStack.isEnchantable, so a patch changes the value or removes it. Horizon
read the item's default, so a patched sword offered the default list and a
sword with the component removed still offered enchantments.

Test: a diamond sword patched to value 1 offers costs 5/13/30 with clues
unbreaking 1, sharpness 2 and smite 4; removing the component gives no
offers.
2026-11-08 16:16:41 +00:00
Florian ErnstandClaude Opus 5.5 92a19421d2 A transmute recipe sends one recipe book entry per display
ServerRecipeBook.addRecipes sends a ClientboundRecipeBookAddPacket entry
for each display of a recipe, and TransmuteRecipe.display gives one
shapeless display per material count: map cloning, with 1 to 8 maps, has
eight. The server's book entries skipped every recipe with more than one
display and had no transmute writer, so a learnt transmute recipe never
reached a player's book, although its removal did.

The book now writes a transmute recipe's entries, one per material count
with the recipe's own display ids: the input and that many materials, its
result (the result item, or every item of the input's tag with the
materials added to its count) and the crafting table, then the usual
group, category, placement and flags. A recipe it cannot write in full
gets no entry at all. The ghost recipe still uses only a single-display
recipe, as before.

Part of #12633

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 17:16:41 +01:00
MSedgeMC e17f52b8c2 A smithing trim uses the addition's trim material component
SmithingTrimRecipe.applyTrim reads PROVIDES_TRIM_MATERIAL from the addition
stack, a named material or an inline definition. Horizon read only the item's
default, so a gold ingot patched to provide copper applied a gold trim.

Test: a coast trim with a gold ingot patched to copper gives a diamond
chestplate trimmed copper/coast.
2026-11-08 16:16:33 +00:00
MSedgeMC dc881c302a A grindstone merge reads the stack's max stack size
GrindstoneMenu.mergeItems refuses a non-damageable pair when
ItemStack.getMaxStackSize is below 2. Horizon read the item's default, so a
pair of enchanted books patched to max_stack_size 1 merged to a count of two.

Test: two books with max_stack_size 1 and the same enchantment give no
result; unpatched books still merge to a count of two.
2026-11-08 16:16:27 +00:00
MSedgeMC 0e17b03481 An anvil repairs with the stack's repairable component
AnvilMenu.createResult asks ItemStack.isValidRepairItem, which reads the
REPAIRABLE component of the input stack: a component patch overrides the
item's default list. Horizon read only the default, so a sword patched to
repair with a stick refused it.

Test: a diamond sword patched to max damage 100, damaged 20 and repairable
with a stick takes one stick and gives damage 0 at cost 1.
2026-11-08 16:16:18 +00:00
Florian ErnstandClaude Opus 5.5 7f4a2fe8a0 Measure a multi-display recipe's recipe book entries (test checkpoint)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 17:05:35 +01:00
Stink-OandClaude Opus 5.5 0ed839b8a1 mod API: stamp world_hold (414) and world_warp (415)
`python3 tools/stamp_native_hooks.py --only world_warp world_hold`. The other pending fragments stay pending.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 16:00:32 +00:00
Nikita Maximilian GrimmandGPT 6.1 Sol dd3bac8217 HDR float frame: screenshots tone-mapped for SDR
Map the layered float world's light through a hue-preserving SDR shoulder
for ordinary screenshots and world icons. Reconstruct the HUD at its drawn
brightness over the mapped world without changing HDR presentation.

Co-Authored-By: GPT 6.1 Sol <[email protected]>
2026-11-08 16:56:16 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 e39af976c9 A mob from a dispensed spawn egg is not persistent
SpawnEggItemBehavior.execute spawns the mob with EntitySpawnReason.DISPENSER
and never calls Mob.setPersistenceRequired, so it saves
PersistenceRequired 0b and can despawn. Horizon made it persistent; a
spawn egg used by hand already spawned a non-persistent mob.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 16:46:58 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 eca3a71c8c Ominous item spawners keep the Motion they were loaded with
Entity.load reads Motion and Entity.saveWithoutId writes it back; the
spawner never moves, so the given motion stays. Horizon saved
Motion [0, 0, 0] for every spawner.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 16:45:38 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 ef2142e5a6 Shulker boxes: an empty box saves no Items list
ShulkerBoxBlockEntity.saveAdditional calls ContainerHelper.saveAllItems
with alsoWhenEmpty false, so an empty box writes no Items. Horizon wrote
Items: [] like the other containers, which always write the list.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 16:44:28 +01:00
Florian ErnstandClaude Opus 5.5 bf7ec63e8e Dragon fireballs fly as the projectiles they are
DragonFireball is an AbstractHurtingProjectile: it moves and accelerates
as every hurting projectile does, saves and loads with its chunk, and on
impact bursts into dragon's breath, except on its own owner, which it
flies through. Horizon kept the dragon's fireballs in a list of their
own, ticked only in the End with its own motion, and loaded a summoned
or saved one as a static entity that never moved.

Every dragon fireball is now a projectile of its own kind in the shared
projectile engine. The dragon's attack makes one owned by the dragon
(its direction normalized as vanilla does); a summoned or loaded one
reads its motion, power and owner; it is saved, selected, tracked and
sent to clients like the other fireballs, with its dragon breath trail.
Its impact keeps the old list's cloud and burst unchanged, and the End
still ticks the breath clouds once. The old list and its tick are gone.

Fixes #12584

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 16:24:17 +01:00
MSedgeMC b8e5247b35 fix: set_custom_data merges into the item's custom data
SetCustomDataFunction merges its compound into the item's
minecraft:custom_data component, a nested compound key by key. The loot
engine had no arm, so a datapack table's data changed nothing. The item
layer already reads the component from `other`, so only the loot arm was
missing.
2026-11-08 15:11:59 +00:00
Florian ErnstandClaude Opus 5.5 cd18425b4a Measure the dragon's own fireball constructor and first tick (H127 test checkpoint)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 16:09:10 +01:00
Lyle ColeandClaude Opus 5.5 5f94268324 Data-pack biomes: their own ambient particles, music and ambient sounds
The animate tick, the music and the ambient sounds read the built-in
biomes' files only: in a biome a data pack adds or replaces, the game
showed the built-in biome's particles (or none) and played the built-in
biome's or the dimension's music and ambience, where vanilla reads the
pack's biome (Biome.getAttributes, its visual/ambient_particles,
audio/background_music, audio/music_volume and audio/ambient_sounds).

- BiomeInfo keeps a biome's visual/ambient_particles. The animate tick
  uses a pack biome's own list (the tables keep the built-in ones); a
  particle type Horizon lacks still draws, so the others keep their place
  in the random sequence.
- MusicTables::at and AmbientTables::at read a pack biome's file
  (Biomes::pack_file); an attribute it leaves out comes from the
  dimension type, as vanilla's attribute layers do, not from the built-in
  biome it replaces.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SeonVnpkKZ9a5aRRAWs2Qb
2026-11-08 09:02:21 -06:00
MSedgeMC eb83113dbb An untamed horse bolts with any rider, not only a player
`RunAroundLikeCrazyGoal` asks `isVehicle()`: any player or mob on the
horse makes an untamed one bolt (unless a zombie horse is
`isMobControlled()`). Horizon checked only the controlling player, so a
mob-ridden wild horse stayed calm.
2026-11-08 14:53:00 +00:00
Florian ErnstandClaude Opus 5.5 1522c80b8f Measure a summoned dragon fireball's motion (H127 test checkpoint)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 15:52:27 +01:00
Nikita Maximilian GrimmandGPT 6.1 Sol 9b0a0d56a9 HDR: an Auto setting that follows the system's HDR
Co-Authored-By: GPT 6.1 Sol <[email protected]>
2026-11-08 15:19:41 +01:00
Kacpherek 69193a3635 mob_ai: blazes hovering, rising toward a player above and attacking 2026-11-08 14:43:26 +01:00
Kacpherek fb350ecfc4 A blaze's shot is level event 1018, as vanilla sends it
BlazeAttackGoal fires with levelEvent(1018) at the blaze's block, unless it
is silent: the client plays the shot at volume 2 with its own pitch. Horizon
played the sound itself, at volume 1, drawing its pitch from the blaze's
random, which put every later draw of its AI out of step.
2026-11-08 14:43:26 +01:00
Kacpherek 3b7a7a9ef3 A blaze slows its fall before it moves
Blaze.aiStep multiplies a falling blaze's motion by 0.6 before
super.aiStep(), so before travel adds this tick's gravity. Horizon did it at
the end of the AI step and slowed the new gravity too: a blaze fell at
-0.04704 instead of -0.0784 on its first tick.
2026-11-08 14:43:26 +01:00
Kacpherek b89066f4ac A blaze's burn sound and smoke are its client's, not the server's
Blaze.aiStep plays the burn sound and makes two large smoke particles only
in its isClientSide() branch; the window's game already makes them
(ambient::client_ai_step). The server's copy drew 13 more numbers from the
blaze's random every tick (a nextInt(24) and six nextDoubles), so every
later draw of its AI came out different, and a client following a server
heard the burn twice.
2026-11-08 14:07:26 +01:00
Florian ErnstandClaude Opus 5.5 f9e8af6a94 Save the blending heights an old chunk's new neighbour calculated
When a new chunk generates next to a chunk an older version saved,
BlendingData.getOrUpdateBlendingData calculates the old chunk's border
heights into its own BlendingData, and BlendingData.pack saves them with
the chunk. Horizon calculated them into SavedOldWorld's cache only; the
save kept the old chunk's blending_data as it was loaded, so the heights
were lost.

The Overworld folder now takes the calculated data of a chunk from that
cache when its save is requested, without calculating anything, and keeps
it with the queued record. The encoder replaces blending_data with
BlendingData.pack only when the loaded record has blending_data with the
same section range. The lookup reaches the cache through a weak link, as
the old world holds the folder. Other folders, new chunks and chunks no
calculation reached save as before.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 13:58:55 +01:00
Kacpherek 42cad38648 An extinguished candle's smoke rises at vanilla's widened 0.1F 2026-11-08 13:52:13 +01:00
Kacpherek 86b883ab33 Jukebox notes rise from vanilla's widened 1.2F 2026-11-08 13:52:13 +01:00
Kacpherek 8a0ec928fa A boat under water rises by vanilla's widened 0.01F 2026-11-08 13:52:13 +01:00
Kacpherek dce5e62714 Raiders move through a village at vanilla's widened 1.05F 2026-11-08 13:52:13 +01:00
Kacpherek f102160191 A sweep attack knocks back with vanilla's widened 0.4F 2026-11-08 13:50:16 +01:00
Kacpherek 125b46829c Skeletons and illusioners shoot from and aim with vanilla's widened floats 2026-11-08 13:50:09 +01:00
Kacpherek 5845d88a53 A blaze rises toward its target at vanilla's widened 0.3F 2026-11-08 13:50:00 +01:00
Kacpherek 02d9ee7962 A strafing ender dragon's height offset starts at vanilla's widened 0.4F 2026-11-08 13:49:56 +01:00
Kacpherek 8a197bf691 A dying ender dragon rises by vanilla's widened 0.1F 2026-11-08 13:49:53 +01:00
Kacpherek 34897bdcd3 A snow golem aims and throws from vanilla's widened floats 2026-11-08 13:49:42 +01:00
Kacpherek dc5420e82e A dolphin attacks at vanilla's widened 1.2F 2026-11-08 13:49:31 +01:00
Kacpherek 9850f641b5 A fox attacks and walks to berries at vanilla's widened 1.2F 2026-11-08 13:49:31 +01:00
Kacpherek 652be13517 A villager throws from and at vanilla's widened 0.3F 2026-11-08 13:49:21 +01:00
Kacpherek e1294dbaf6 A sniffer's culling box grows by vanilla's widened 0.6F 2026-11-08 13:49:11 +01:00
Kacpherek 381c191dc1 A rendered minecart samples its rail at vanilla's widened 0.3F 2026-11-08 13:49:07 +01:00
05a9ed3057 /kill on a dropped item, cushion, end crystal or marker makes a vibration
Entity.kill posts entity_die for every entity it removes, so /kill now
does for dropped items (a wool stack dampens it, ItemEntity
.dampensVibrations), cushions, end crystals (after EndCrystal.kill's
onDestroyedBy, even an invulnerable one) and the entities Horizon only
stores (markers, displays, interactions). An armor stand's own death is
a living source no warden can target.

A blast's kill of a painting, item frame, cushion or armor stand is
credited to its causing entity (BlockAttachedEntity.kill /
ArmorStand.kill with DamageSource.getEntity): the mob, the player who
lit the TNT or broke the crystal.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-Authored-By: Codex (gpt-6.1-sol) <[email protected]>
2026-11-08 13:17:05 +01:00
Lyle ColeandClaude Opus 5.5 7a065ec02b Decoration: per-call lookups and re-reads cut (lakes, ores, tags, biome filter)
Per-call costs in decoration, ported from the To The Max fork onto main
(main's own ore-sphere walk, FxHash tick sets and HashMap are kept):
- Biome filter: each biome's feature list is a bitset, not a BTreeSet.
- matching_block_tag: the decoration world keeps the last tag's states
  (environment scans ask one tag up to 32 times per position).
- Block ticks: the region keeps the last 16 default states it resolved
  instead of going through the registry's name map each time.
- Lakes: the spot divisions once per axis coordinate, not per cell; the
  16x16x8 grid as a byte per column, edges tested a column at a time.
- Ores: a bound per region chunk on OCEAN_FLOOR_WG (its heightmap maximum
  on first use, raised by the world's own writes, the only writes that
  move it) answers "no column" without reading up to 121 entries; the
  blob's sin/cos only once a column is found. Target rules that draw
  nothing (all of vanilla's) are memoized per (state, Y ranges held).
- Speleothem Column.scan reads each block once.

Identical output: chunkgen_bench snapshots (32x32, seed 0) and the To The
Max world checksums are unchanged. Interleaved pairs, 4 threads, built
with one codegen unit (the fast profile's 16 units moved unrelated
stages by +-10% between builds):
- vanilla chunkgen_bench --size 32: decorate wall 334-340 -> 317-318 ms
  (-6%); proto unchanged.
- To The Max (seed 0, 16x16): decoration 4.2-4.4 -> 3.9 ms a chunk
  (-9%); finished chunks 152.3-155.7 -> 154.3-155.7/s (terrain-bound
  at 4 threads).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SeonVnpkKZ9a5aRRAWs2Qb
2026-11-07 19:30:09 -06:00
Lyle ColeandClaude Opus 5.5 df7f146ed9 Jigsaw pools: load each element's template on its first use
Decoding a template pool loaded every element's template at once
(gunzip, NBT, palette), where vanilla's pool elements call
StructureTemplateManager.getOrCreate only when the assembler first
touches them. In To The Max's bench area the abandoned camp's pools
loaded 180 templates and its assembly used 18 (villages: 32 loaded, 5
used), holding the start chunk while neighbouring threads waited on it.

Pool elements now hold a LazyTemplate (the id and the shared template
store) that loads through the same cache on first use, so templates stay
comparable by pointer. Data packs' template files still win over the
prepared ones (the store keeps them). When two threads load one file at
once, both get the first copy kept (before, the second insert replaced
the first). Its Debug prints the template as before, so the
structure-start checksum is unchanged.

Identical output: chunkgen_bench snapshots (32x32, seed 0) and the To The
Max world checksums are unchanged. Interleaved pairs, 4 threads, built
with one codegen unit:
- vanilla chunkgen_bench --size 32: structures 0.563-0.568 -> 0.493-0.524
  ms a chunk (-10%); proto wall 1321 -> 1284-1304 ms.
- To The Max (seed 0, 16x16): structures 1.5 -> 1.1 ms a chunk (-27%);
  finished chunks 153.6-155.5 -> 155.1-155.9/s.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SeonVnpkKZ9a5aRRAWs2Qb
2026-11-07 19:30:09 -06:00
1d29778ef7 Let sculk hear cauldron fluid transfers
Post FLUID_PICKUP or FLUID_PLACE after the sound in
CauldronInteractions.fillBucket, emptyBucket and the bottle interactions.
Keep lowerFillLevel's earlier BLOCK_CHANGE and washing-only behavior.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-Authored-By: Codex (gpt-6.1-sol) <[email protected]>
Co-Authored-By: DeepSeek V4.1 Flash <[email protected]>
2026-11-07 21:18:48 +01:00
01b4e8b995 Let sculk hear trampled farmland and broken turtle eggs
Forward landing block events through the player and mob server paths.
FarmlandBlock.turnToBaseBlock posts BLOCK_CHANGE from the landing entity;
TurtleEggBlock.decreaseEggs and Level.destroyBlock post BLOCK_DESTROY
without a source, carrying the affected block state.
Silence a queued egg event when its block write was cancelled.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-Authored-By: Codex (gpt-6.1-sol) <[email protected]>
Co-Authored-By: DeepSeek V4.1 Flash <[email protected]>
2026-11-07 21:18:13 +01:00
627 changed files with 75981 additions and 14306 deletions

No files matched your search

+12 -1
View File
@@ -2,7 +2,8 @@
## What and why
<!-- What this changes, and why. Link the issue it fixes ("Fixes #123"). -->
<!-- What this changes, and why. Link the issue it fixes ("Fixes #123"). Over about 500 lines: link the issue with its
approved spec too ("Spec: #123"), CONTRIBUTING.md "Large pull requests". -->
## Kind
@@ -16,6 +17,16 @@
- [ ] tooling: tools, CI, tests, harnesses
- [ ] docs
## Layer
<!-- engine, platform or Horizon (docs/layers.md, "Where a new feature goes"). Reviewers check:
- the new code sits in a crate or module of that layer;
- no new dependency its layer may not use (CI's layer check): no engine code calling platform or Horizon code,
and Horizon code using the platform's public API only, never the engine directly;
- Horizon features in the game are behind `horizon-extras`, and platform features that change the vanilla
look are off by default;
- no new built-in server address outside the Horizon layer. -->
## Vanilla reference
<!-- The vanilla classes and methods this follows (tools/rea_vanilla.py), and the harness that checks it.
-1
View File
@@ -13,5 +13,4 @@
87152661 rdbtCVS # added by Ludovic, 2026-10-27
84875247 Online5277 # added by Ludovic, 2026-10-27 (his top three by merged pull requests)
86072227 levzzz5154 # added by Ludovic, 2026-10-28 (Horizon Team; author of horizon-mixins)
87478350 Qwerner88880 # added by Ludovic, 2026-10-31 (Discord vladislav_hagen; native-mod hooks)
169198624 itarqos5 # added by Ludovic, 2026-11-08 (Discord Literal; Horizon Team)
+15
View File
@@ -78,6 +78,21 @@ jobs:
rustup toolchain install stable --profile minimal --component rustfmt --component clippy
rustup default stable
echo "WGPU_BACKEND=dx12" >> "$GITHUB_ENV"
# docs/layers.md: a crate depends only on its own layer or a lower one; today's exceptions only shrink.
- name: Crates follow the layers
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
run: python3 tools/check_layers.py ${BASE_SHA:+--base "$BASE_SHA"}
# docs/layers.md#built-in-addresses: every built-in outside address in the Horizon layer's home; the rest only shrinks.
- name: Built-in addresses stay in their home
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
run: python3 tools/check_addresses.py ${BASE_SHA:+--base "$BASE_SHA"}
# docs/layers.md#file-size: a Rust file over 3000 lines may not grow (pull requests; main has no base to compare).
- name: Big files don't grow
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
run: python3 tools/check_file_size.py ${BASE_SHA:+--base "$BASE_SHA"}
- name: Test offline research tooling
run: python -m unittest discover -s tests -v
- name: Install Rust toolchain and the ALSA and udev headers the client links against
+14 -8
View File
@@ -25,6 +25,8 @@ Feature → files → harness for the most-changed areas: [docs/architecture.md]
first line `> 🤖 Opened by Ludovic's AI agent (Claude), not by Ludovic himself.`, commits
`Co-Authored-By: Claude <model> <[email protected]>`, last line
`🤖 Generated with [Claude Code](https://claude.com/claude-code)`.
- **Layers** ([docs/layers.md](docs/layers.md)): engine < platform < Horizon; a crate depends only on its own layer or a
lower one (`tools/check_layers.py`, crate layers in `tools/layers.json`). New Horizon code goes behind `horizon-extras`.
- Everything players run is Rust. Python/Java only for dev tools and harness drivers in `tools/`.
- Horizon-only features follow [docs/design-system.md](docs/design-system.md) and are off by default where
they would change the vanilla look.
@@ -61,6 +63,7 @@ Game workspace (root `Cargo.toml`); `launcher/` and `crates/horizon-telemetry-se
| `horizon-server-files` | `server.properties`, eula, ops/whitelist/bans, usercache |
| `horizon-anticheat` | The dedicated server's anticheat, off by default: `[anticheat]` settings, violation levels, the checks' rules; the hub runs them (`lan/anticheat.rs`) ([docs/anticheat.md](docs/anticheat.md)) |
| `horizon-cosmetics` | Cosmetic items Horizon clients draw on players (off by default): the item format, bones and animations ([docs/cosmetics.md](docs/cosmetics.md)) |
| `horizon-addresses` | Every built-in address of an outside service vanilla doesn't contact (Horizon layer; [docs/layers.md](docs/layers.md#built-in-addresses)) |
| `horizon-friends`, `horizon-relay` | Play with Friends: invite codes, encrypted tunnel, the relay ([docs/play-with-friends.md](docs/play-with-friends.md)) |
| `horizon-mods`, `cargo-horizon-mod` | Content mods (`.hmod`: manifest, content fence, no code), the registry index and `.hpack`, and `cargo horizon-mod` (`new`, `build`, `run`, `check`, `registry`) ([docs/mods.md](docs/mods.md)). Code mods are native, built by the launcher ([docs/native-mods.md](docs/native-mods.md)) |
| `horizon-port-pack` | Porting Java modpacks ([docs/modpacks.md](docs/modpacks.md)) |
@@ -70,6 +73,8 @@ Game workspace (root `Cargo.toml`); `launcher/` and `crates/horizon-telemetry-se
| `horizon-shaders` | Shader packs (the `shaders/` folder format), rebuilt clean-room from a written spec of the format; no wgpu |
| `horizon-theme` | Design tokens and fonts for the launcher, the game's own menus and the website |
| `horizon-telemetry`, `horizon-telemetry-server`, `horizon-bench` | Opt-in data collection, its ingest server, the benchmark report format |
| `horizon-system` | The machine the game runs on (OS, CPU, memory, drive, power, GPU maker and memory), for the crash report, the renderer's GPU info and telemetry |
| `horizon-platform` | The platform's public API: what the Horizon layer may use of the engine ([docs/layers.md](docs/layers.md)); re-exports `horizon-system` today |
| `horizon-dualsense`, `horizon-threads`, `horizon-release-sign`, `horizon-bots`, `xtask` | DualSense output, thread priorities, signed release checksums, load-test bots, repo tasks |
| `launcher/` | GPUI launcher: `main.rs` (UI), `backend.rs` (installs, updates), `cli.rs`, `mcp.rs`, `*_page.rs` |
@@ -101,6 +106,7 @@ python3 tools/parity.py --changed-files paths.txt --list # which harnesses cov
python3 tools/parity.py mob_ai sculk # named harnesses
python3 tools/lan_e2e.py --check chest # two real games over LAN (server-thread host)
python3 tools/agent_control_e2e.py # horizon-ctl / MCP end to end
python3 tools/lang_coverage.py --summary # the keys each language still misses, and its English leftovers
```
- Harnesses and the game need the vanilla data in `.local/` (`minecraft/`, `horizon-client/26.3`, `datagen/`,
@@ -152,17 +158,17 @@ state stays separate from the library tests. This compiles gameplay twice for ex
| Subsystem | Files |
|---|---|
| Game loop, tick, frame | `game.rs`: `fn run`, `fn tick`, `fn tick_world`, `fn render`; G: `window_handler.rs` (`impl ApplicationHandler for Game`: `window_event`, `about_to_wait`); L: `clock.rs`, `spans.rs`; `horizon-client-input/src/window.rs` |
| Rendering | L: `gfx.rs` (`Renderer::new`, `Renderer::render`), `gfx/` (cull, occlusion, translucency, upload, timing, lod, weather, stars, end_sky), `*.wgsl`, `particles.rs`, `block_entity_render.rs`, `item_render.rs`, `fog.rs`, `clouds.rs`, `camera.rs`, `first_person.rs`; G: `render_tick.rs`, `item_draw.rs`, `screen_backgrounds.rs` (panorama, menu blur, border wall) |
| Chunks, streaming, light | L: `streaming.rs` (worker pool: generate, light, mesh), `world.rs`, `chunk.rs`, `chunk_map.rs`, `light.rs`, `mesher.rs` + `mesher/`, `vanillagen.rs` (binds horizon-world's generator), `lod.rs` + `lod/`, `server_terrain.rs` (a server's kept chunks), `bluemap.rs` + `bluemap_detect.rs` (far terrain from its web map), `ticket_levels.rs`; G: `world_loading.rs`, `world_entry.rs`, `postprocess.rs`; game.rs `fn update_world` |
| Game loop, tick, frame | `game.rs`: `fn run`, `fn tick`, `fn tick_world`, `fn render`; G: `window_handler.rs` (`impl ApplicationHandler for Game`: `window_event`, `about_to_wait`), `launch_options.rs` (the command line); L: `clock.rs`, `spans.rs`; `horizon-client-input/src/window.rs` |
| Rendering | L: `gfx.rs` (`Renderer::new`, `Renderer::render`), `gfx/` (cull, occlusion, translucency, upload, timing, lod, weather, stars, end_sky), `*.wgsl`, `particles.rs`, `block_entity_render.rs`, `item_render.rs`, `fog.rs`, `clouds.rs`, `camera.rs`, `first_person.rs`; G: `render_tick.rs`, `frame_geometry.rs` (cracks, sun and moon, first-person hand, rain and snow), `item_draw.rs`, `screen_backgrounds.rs` (panorama, menu blur, border wall) |
| Chunks, streaming, light | L: `streaming.rs` (worker pool: generate, light, mesh), `world.rs`, `chunk.rs`, `chunk_map.rs`, `light.rs`, `mesher.rs` + `mesher/`, `vanillagen.rs` (binds horizon-world's generator), `lod.rs` + `lod/`, `server_terrain.rs` (a server's kept chunks), `bluemap.rs` + `bluemap_detect.rs` (far terrain from its web map), `ticket_levels.rs`; G: `world_loading.rs`, `world_entry.rs`, `postprocess.rs`, `world_update.rs` (`fn update_world`, far terrain) |
| World generation | `crates/horizon-world/src/worldgen/` (`generator*.rs`, `feature_graph.rs`, `structure/`, `carver/`) |
| Block behaviour | `crates/horizon-world/src/level/` (`mod.rs`, `tick.rs`, `hooks.rs`, `redstone.rs`, `place.rs`, `interact.rs`); game.rs `fn tick_blocks`, `fn process_level_events` |
| Block behaviour | `crates/horizon-world/src/level/` (`mod.rs`, `tick.rs`, `hooks.rs`, `redstone.rs`, `place.rs`, `interact.rs`); G: `block_ticks.rs` (`fn tick_blocks`); game.rs `fn process_level_events` |
| Mobs | L: `mobs.rs` (types), `mobs/` (`ai.rs`, `goals.rs`, `brain.rs`, `*_brain.rs`, `nav.rs`, `path.rs`, `species.rs`, `spawning.rs`, `render.rs`, `anim.rs`); G: `creatures.rs` (ticking, spawning, mob events), one file per mob family (`dragon.rs`, `wither.rs`, `villagers.rs`, `piglins.rs`, `golems.rs`...), `natural_spawning.rs` |
| Other entities | L: `entities.rs`, `projectiles.rs`, `arrows.rs`; G: `boats.rs`, `minecarts.rs`, `mounts.rs`, `armor_stands.rs`, `paintings.rs`, `frames.rs`, `fireworks.rs`, `tnt.rs`, `explosion.rs` |
| Player actions | game.rs (`fn use_hand`, `fn tick_mining`, `fn break_block`); G: `item_actions.rs` (`fn use_item`), `melee.rs` (`fn attack`), `hand.rs`, `combat.rs`, `consume.rs`, `using_item.rs` (an item in use, tick by tick), `potions.rs`, `shield.rs`, `crossbow.rs`, `trident.rs`, `fishing.rs`; L: `motion.rs` + `motion/` (movement), `collisions.rs`; crate `horizon-items`: `items.rs`, `item_data.rs`, `enchant.rs`, `blocks.rs` |
| Inventory and containers | L: `menu.rs` (`AbstractContainerMenu.clicked`), `container_screen.rs`, `containers.rs`, `creative.rs`, `crafting.rs`, `recipe_book*.rs`, `stations.rs`, `merchant.rs`; game.rs `fn menu_ops`, `fn click_inventory`; G: `stations.rs`, `trading.rs`, `recipe_book.rs`, `machines.rs`, `inventory_input.rs` (keys and clicks over a container), `inventory_slots.rs` (slot layout and drawing) |
| Other entities | L: `entities.rs`, `projectiles.rs`, `arrows.rs`; G: `item_entities.rs` (dropped items, experience orbs), `boats.rs`, `minecarts.rs`, `mounts.rs`, `armor_stands.rs`, `paintings.rs`, `frames.rs`, `fireworks.rs`, `tnt.rs`, `explosion.rs` |
| Player actions | game.rs (`fn tick_mining`, `fn break_block`); G: `use_hand.rs` (`fn use_hand`), `item_actions.rs` (`fn use_item`), `melee.rs` (`fn attack`), `hand.rs`, `combat.rs`, `consume.rs`, `using_item.rs` (an item in use, tick by tick), `potions.rs`, `shield.rs`, `crossbow.rs`, `trident.rs`, `fishing.rs`; L: `motion.rs` + `motion/` (movement), `collisions.rs`; crate `horizon-items`: `items.rs`, `item_data.rs`, `enchant.rs`, `blocks.rs` |
| Inventory and containers | L: `menu.rs` (`AbstractContainerMenu.clicked`), `container_screen.rs`, `containers.rs`, `creative.rs`, `crafting.rs`, `recipe_book*.rs`, `stations.rs`, `merchant.rs`; G: `menu_clicks.rs` (`fn menu_ops`, `fn click_inventory`), `stations.rs`, `trading.rs`, `recipe_book.rs`, `machines.rs`, `inventory_input.rs` (keys and clicks over a container), `inventory_slots.rs` (slot layout and drawing) |
| Commands | parse: crate `horizon-command` (`args.rs`, `selector.rs`, `tree_*.rs`, `vanilla.rs`); run: G: `commands.rs` (dispatch) + `commands/*.rs` (`admin`, `execute`, `items`, `players`, `world_cmds`...); L: `scoreboard_cmd.rs`, `data_cmd.rs`; `horizon-client-core`: `nbt_path.rs`, `scoreboard.rs`; G: `functions.rs`, `command_blocks.rs` |
| UI screens | `Screen` enum in game.rs; L: `title.rs` + `title/`, `options_ui.rs`, `multiplayer.rs`, `pack_screen.rs`, `settings_shell.rs` (Horizon's settings screens on it: `extras_ui.rs`, `graphics_ui.rs`, `controller_ui.rs`), `screen_draw.rs`, `ui.rs` (widgets), `hud.rs`, `hud_layout.rs`, `hud_overlays.rs`, `hud_kit/` (Horizon overlays' `Room` and `Plate`, [docs/ui-kit.md](docs/ui-kit.md)), `pad_glyph.rs` (controller glyphs, by what the button does), `chat.rs`, `tab_list.rs`; G: `startup_menu.rs`, `loading_screen.rs` (the loading screen, the pre-game `App`), `screens.rs` (pause, death), `options.rs`, `lan_screen.rs`, `chat.rs`, `sign_edit.rs`, `books.rs`, `creative_screen.rs` |
| UI screens | `Screen` enum in game.rs; L: `title.rs` + `title/`, `options_ui.rs`, `multiplayer.rs`, `pack_screen.rs`, `settings_shell.rs` (Horizon's settings screens on it: `extras_ui.rs`, `graphics_ui.rs`, `controller_ui.rs`, `shader_ui.rs`; a page list too long for its column collapses into one row), `screen_draw.rs`, `ui.rs` (widgets), `hud.rs`, `hud_layout.rs`, `hud_overlays.rs`, `hud_kit/` (Horizon overlays' `Room` and `Plate`, the collapsed page strip `tabs` the shell's page list uses, [docs/ui-kit.md](docs/ui-kit.md)), `pad_glyph.rs` (controller glyphs, by what the button does), `chat.rs`, `tab_list.rs`; G: `startup_menu.rs`, `loading_screen.rs` (the loading screen, the pre-game `App`), `resource_loader.rs` (what a start loads, `run_loading`), `screens.rs` (pause, death), `options.rs`, `lan_screen.rs`, `chat.rs`, `sign_edit.rs`, `books.rs`, `creative_screen.rs` |
| Multiplayer client | L: `net.rs` (network thread); G: `remote.rs` (`fn handle_packet`, `fn tick_remote`), `remote_*.rs` (mob data, objects, projectiles, vehicles, stations) |
| LAN host / server side | L: `lan/` (`conn.rs` handshake + login threads, `hub.rs` + `hub/` play state, `chunks.rs`, `entity.rs`, `tracking.rs`, `guest.rs`, `playerdata.rs`); G: `lan.rs` (`LanHost`), `lan_actions.rs` (`fn as_guest`, `fn tick_guests`), `lan_*.rs` per feature, `lan_entities.rs` (tracked entities) |
| Integrated server | G: `integrated.rs` (the server thread), `levels.rs` (one level per dimension, `fn tick_server`), `server_travel.rs` |
+30 -1
View File
@@ -38,10 +38,16 @@ and compares their outputs.
- **Read the vanilla code before writing Horizon's.** `tools/rea_vanilla.py` queries the pinned game bytecode:
`python3 tools/rea_vanilla.py method net.minecraft.world.level.block.DispenserBlock dispenseFrom`.
Say in your pull request which vanilla classes and methods you followed.
- **One version: 26.3.** Parity work targets 26.3 until its parity is good, redstone first. Don't open pull requests
that move Horizon to a newer Minecraft version for now; to join servers on a newer version, put a Via proxy
(ViaVersion, ViaProxy) in front of them.
- **Gameplay changes come with a harness**, new or extended, so the next change can't silently break them.
[The parity workflow](docs/parity-workflow.md) shows how harnesses run; copy a nearby one.
- **Horizon's own features** (the launcher, the new menus) follow [the design system](docs/design-system.md) and are
off by default wherever they would change the vanilla look.
- **Three layers: engine, platform, Horizon.** Vanilla code never depends on the platform (mods, shader packs,
Horizon-only settings) or on Horizon (cosmetics, the shop, official servers, telemetry), and the platform never
depends on Horizon. [docs/layers.md](docs/layers.md) says where a new feature goes; CI checks the dependencies.
- **Everything players run is Rust.** Python and Java are fine for dev tools and harness drivers in `tools/`, never
for player-facing code.
- **No game files in the repo.** Never commit game files, decompiled source or prepared resources (`.local/`):
@@ -80,6 +86,15 @@ license. Shader packs are a clean-room rewrite of the pack format, produced from
source, and the rules above still apply to anything you read while working on them. A shader-pack pull request
names its sources: the sections of that specification, the documentation pages and the packs it ran.
## Vendor libraries and Java mods
- **Upscalers and latency tools.** AMD FSR (MIT) may go in the repository, like any code under an allowed license.
NVIDIA's DLSS, Reflex and any other library under an NVIDIA license may not: they ship only as an optional native mod
that downloads NVIDIA's files itself, on the player's machine. No NVIDIA binaries, headers or SDK files in the
repository.
- **The JVM bridge** (running Java mods) is only ever a separate, optional download, never installed or on by
default. The game may offer to turn it on when it finds a Java mod; the player decides.
## Before asking for review
Run the self-check and paste the block it prints into your pull request:
@@ -116,7 +131,8 @@ it (edited on every push, never repeated): talk to its author in the thread befo
## Pull requests
- **Small.** One change per pull request, under about 500 lines of code where you can; split bigger work into
pieces that each make sense alone. Small pull requests get reviewed and merged the same day.
pieces that each make sense alone. Small pull requests get reviewed and merged the same day. Bigger than that:
[large pull requests](#large-pull-requests).
- **Fill in the template**: what and why, the vanilla reference, the pr_ready block, screenshots for anything you
can see.
- **Tick "Allow edits by maintainers".** When something small is left (a lint, a rebase, a nit) we push the fixup
@@ -139,6 +155,19 @@ generation, the launcher's updates) wait for a local proof first ([merging](docs
Every merge to main is released automatically ([release flow](docs/release-flow.md)), so main always stays playable.
How agents, checks, releases and signed updates keep this safe: [security](docs/security.md).
### Large pull requests
500 lines stays the default. A pull request that can't be split below it starts from a spec:
1. **Open an issue with the spec** before the code: what changes, the files it touches, the behaviour players and
other code will see, and the tests or harnesses that prove it.
2. **A maintainer approves the spec** in the issue. Code written before that may have to change or wait.
3. **The pull request links the approved issue** (`Spec: #123` in the description) and stays within it; a change of
plan goes back to the issue first.
4. **An agent check compares the code with the spec**, run by the maintainers' agents: anything the spec doesn't
cover, or that it promises and the code doesn't do, comes back to you.
5. **Then the normal review**, as for any pull request.
## Parity checks on forks
The `verify` check (formatting, lints, tests) runs on every pull request. The `parity` check runs the affected
Generated
+20 -2
View File
@@ -1374,6 +1374,10 @@ dependencies = [
"digest",
]
[[package]]
name = "horizon-addresses"
version = "0.1.0"
[[package]]
name = "horizon-anticheat"
version = "0.1.0"
@@ -1577,7 +1581,6 @@ dependencies = [
"glam",
"half",
"horizon-assets",
"horizon-bench",
"horizon-client-core",
"horizon-client-input",
"horizon-command",
@@ -1592,7 +1595,7 @@ dependencies = [
"horizon-protocol",
"horizon-shader-api",
"horizon-shaders",
"horizon-telemetry",
"horizon-system",
"horizon-theme",
"horizon-threads",
"horizon-world",
@@ -1641,6 +1644,7 @@ version = "0.1.0"
dependencies = [
"base64 0.22.1",
"glam",
"horizon-addresses",
"png",
"serde",
"serde_json",
@@ -1663,6 +1667,7 @@ dependencies = [
"chacha20poly1305",
"getrandom 0.3.4",
"hkdf",
"horizon-addresses",
"if-addrs",
"serde",
"serde_json",
@@ -1772,6 +1777,13 @@ dependencies = [
"regex",
]
[[package]]
name = "horizon-platform"
version = "0.1.0"
dependencies = [
"horizon-system",
]
[[package]]
name = "horizon-polytone"
version = "0.1.0"
@@ -1909,11 +1921,17 @@ dependencies = [
"zip",
]
[[package]]
name = "horizon-system"
version = "0.1.0"
[[package]]
name = "horizon-telemetry"
version = "0.1.0"
dependencies = [
"getrandom 0.3.4",
"horizon-addresses",
"horizon-platform",
"ruzstd",
"serde",
"serde_json",
+3
View File
@@ -1,6 +1,7 @@
[workspace]
exclude = ["launcher", "crates/horizon-telemetry-server", "crates/horizon-mod-registry", "third_party/wgpu-hal", "third_party/naga", "third_party/libmimalloc-sys"]
members = [
"crates/horizon-addresses",
"crates/horizon-core",
"crates/horizon-dualsense",
"crates/horizon-narrator",
@@ -27,6 +28,8 @@ members = [
"crates/horizon-theme",
"crates/horizon-prepare",
"crates/horizon-telemetry",
"crates/horizon-system",
"crates/horizon-platform",
"crates/horizon-bench",
"crates/horizon-friends",
"crates/horizon-relay",
+4 -5
View File
@@ -12,7 +12,6 @@
</p>
<p align="center">
<img src="https://img.shields.io/badge/vanilla%20parity-100%25-F0795A" alt="Vanilla parity 100%">
<a href="#project-status"><img src="https://img.shields.io/badge/player--visible%20gaps-0%20open-F0795A" alt="Player-visible gaps open"></a>
</p>
@@ -138,13 +137,13 @@ It runs rustfmt, clippy, the touched crates' tests, the Python tests, the public
## Project status
**Player-visible gaps: 0 open** of the 833 the [port ledger](tools/port_ledger.py) found, reading every vanilla 26.3 method against Horizon's code: vanilla behaviour a player would notice that Horizon doesn't have yet. By area: Animals [0](https://github.com/horizon-rust/horizon/issues/3512); Monsters and bosses [0](https://github.com/horizon-rust/horizon/issues/3513); Entities, projectiles, vehicles and decorations [0](https://github.com/horizon-rust/horizon/issues/3514); Items, enchantments and inventories [0](https://github.com/horizon-rust/horizon/issues/3515); Blocks, redstone and fluids [0](https://github.com/horizon-rust/horizon/issues/3516); World, world generation and saves [0](https://github.com/horizon-rust/horizon/issues/3517); Screens, HUD and client packets [0](https://github.com/horizon-rust/horizon/issues/3518); Entity and block rendering, models [0](https://github.com/horizon-rust/horizon/issues/3519); Server and players on the server [0](https://github.com/horizon-rust/horizon/issues/3520); Mob AI, villagers and raids [0](https://github.com/horizon-rust/horizon/issues/3521). This number goes down as gaps close (`tools/parity_gaps.py`); the harness results below say that what is tested matches.
**Player-visible gaps: 0 open** of the 833 the [port ledger](tools/port_ledger.py) found, reading every vanilla 26.3 method against Horizon's code: vanilla behaviour a player would notice that Horizon doesn't have yet. By area: Animals [0](https://github.com/horizon-rust/horizon/issues/3512); Monsters and bosses [0](https://github.com/horizon-rust/horizon/issues/3513); Entities, projectiles, vehicles and decorations [0](https://github.com/horizon-rust/horizon/issues/3514); Items, enchantments and inventories [0](https://github.com/horizon-rust/horizon/issues/3515); Blocks, redstone and fluids [0](https://github.com/horizon-rust/horizon/issues/3516); World, world generation and saves [0](https://github.com/horizon-rust/horizon/issues/3517); Screens, HUD and client packets [0](https://github.com/horizon-rust/horizon/issues/3518); Entity and block rendering, models [0](https://github.com/horizon-rust/horizon/issues/3519); Server and players on the server [0](https://github.com/horizon-rust/horizon/issues/3520); Mob AI, villagers and raids [0](https://github.com/horizon-rust/horizon/issues/3521). This number goes down as gaps close (`tools/parity_gaps.py`); the table below says what the harnesses compare.
The table is the project's own audit: every subsystem matches vanilla in every harness that compares it. 100% means 100% of what the harnesses compare, not that nothing else differs. What players still run into: [known issues](docs/known-issues.md).
The table is the project's own audit of what the harnesses compare, subsystem by subsystem: a score says how much of that subsystem the harnesses cover and pass, not that nothing else differs from vanilla. What players still run into: [known issues](docs/known-issues.md).
### Java parity: 100%
### What the harnesses compare
Scores: 0 (absent), 0.25 (started), 0.5 (playable, major features missing), 0.75 (mostly complete) or 1 (complete and verified by differential tests); the percentage is their average. Last audited 2026-10-13: the harnesses pass on Linux and macOS. The [port ledger](tools/port_ledger.py) adds a method-by-method view of what is ported.
Scores: 0 (absent), 0.25 (started), 0.5 (playable, major features missing), 0.75 (mostly complete) or 1 (its harnesses cover the subsystem and pass). Last audited 2026-10-13: the harnesses pass on Linux and macOS. The [port ledger](tools/port_ledger.py) adds a method-by-method view of what is ported.
| Subsystem | Score | Main gaps |
| --- | ---: | --- |
+1 -1
View File
@@ -76,7 +76,7 @@ Owner: [levzzz5154](https://github.com/levzzz5154) · Tracking: [#3911](https://
<details><summary>Technical</summary>
One item for all modding work ([#3911](https://github.com/horizon-rust/horizon/issues/3911)), led by levzzz5154 with vladislav_hagen; the order between the parts is theirs. Native mods on stable hook points (game/native_hooks.rs, [#3345](https://github.com/horizon-rust/horizon/issues/3345), [#3346](https://github.com/horizon-rust/horizon/issues/3346), [#3434](https://github.com/horizon-rust/horizon/issues/3434), [#3435](https://github.com/horizon-rust/horizon/issues/3435)) built by the launcher ([#3388](https://github.com/horizon-rust/horizon/issues/3388), [#3855](https://github.com/horizon-rust/horizon/issues/3855)); server-side hooks ([#3878](https://github.com/horizon-rust/horizon/issues/3878), [#3706](https://github.com/horizon-rust/horizon/issues/3706)); mod-owned screens, input and drawing; tools for mod makers (templates, `native.py check`); the registry ([#1634](https://github.com/horizon-rust/horizon/issues/1634), [#1642](https://github.com/horizon-rust/horizon/issues/1642), [#1659](https://github.com/horizon-rust/horizon/issues/1659)); machines with inventories and menus ([#1189](https://github.com/horizon-rust/horizon/issues/1189)); mods that read and change the world through the server. [docs/native-mods.md](docs/native-mods.md).
One item for all modding work ([#3911](https://github.com/horizon-rust/horizon/issues/3911)), led by levzzz5154; the order between the parts is theirs. Native mods on stable hook points (game/native_hooks.rs, [#3345](https://github.com/horizon-rust/horizon/issues/3345), [#3346](https://github.com/horizon-rust/horizon/issues/3346), [#3434](https://github.com/horizon-rust/horizon/issues/3434), [#3435](https://github.com/horizon-rust/horizon/issues/3435)) built by the launcher ([#3388](https://github.com/horizon-rust/horizon/issues/3388), [#3855](https://github.com/horizon-rust/horizon/issues/3855)); server-side hooks ([#3878](https://github.com/horizon-rust/horizon/issues/3878), [#3706](https://github.com/horizon-rust/horizon/issues/3706)); mod-owned screens, input and drawing; tools for mod makers (templates, `native.py check`); the registry ([#1634](https://github.com/horizon-rust/horizon/issues/1634), [#1642](https://github.com/horizon-rust/horizon/issues/1642), [#1659](https://github.com/horizon-rust/horizon/issues/1659)); machines with inventories and menus ([#1189](https://github.com/horizon-rust/horizon/issues/1189)); mods that read and change the world through the server. [docs/native-mods.md](docs/native-mods.md).
More: [#3351](https://github.com/horizon-rust/horizon/issues/3351) · [#3353](https://github.com/horizon-rust/horizon/issues/3353) · [#3360](https://github.com/horizon-rust/horizon/issues/3360) · [#1596](https://github.com/horizon-rust/horizon/issues/1596) · [#1635](https://github.com/horizon-rust/horizon/issues/1635)
+6 -1
View File
@@ -338,8 +338,13 @@ is preserved.
## Starling
`crates/horizon-client-render/src/touch/layout.rs` is adapted from the touch layout of
`crates/horizon-client-render/src/touch/layout.rs` and `crates/horizon-client-render/src/touch/aim.rs`
are adapted from the touch layout and touch helpers of
[Starling](https://github.com/starling-client/starling), an Android port of Horizon, at commit
[150a414b09a1](https://github.com/starling-client/starling/tree/150a414b09a18c900489d9462a7fc035dae744b7)
(`android` branch), written mostly by Vasysik. Starling is licensed under the same MIT license as
Horizon (Copyright (c) 2026 Ludovic Marie and the Horizon contributors).
`crates/horizon-client-render/src/android_gamepad.rs` is adapted from Starling's controller decoder
(`crates/horizon-client-render/src/android_gamepad.rs`) and the input-mode part of its
`android_runtime.rs`, at the same commit.
+10
View File
@@ -0,0 +1,10 @@
[package]
name = "horizon-addresses"
version = "0.1.0"
edition = "2024"
rust-version.workspace = true
license = "MIT"
description = "Every built-in address of an outside service vanilla doesn't contact, in one place (docs/layers.md#built-in-addresses)"
[lints]
workspace = true
+36
View File
@@ -0,0 +1,36 @@
//! Every built-in address of an outside service vanilla doesn't contact, in one place
//! (docs/layers.md#built-in-addresses). `tools/check_addresses.py` keeps new ones from going
//! anywhere else.
//!
//! This is a Horizon-layer crate: the engine and the platform never depend on it. Platform code
//! that talks to a service gets the address passed in from a Horizon crate.
/// Horizon's official survival server (SMP), offered once at the top of the server list by the
/// game and the launcher (`launcher/links.json` may name another). Its SRV record leads to the
/// port.
pub const OFFICIAL_SERVER: &str = "play.playhorizon.dev";
/// The official server's earlier addresses: the game and the launcher move a server list's
/// entries for them to [`OFFICIAL_SERVER`], once. A move appends the old address here (and to
/// `official_server_previous` in `launcher/links.json`).
pub const OFFICIAL_SERVER_PREVIOUS: &[&str] = &["horizon.bunchy7s.dev"];
/// [`OFFICIAL_SERVER`] with the port its SRV record gives, for askers that don't look SRV up
/// (the telemetry server's status checks).
pub const OFFICIAL_SERVER_STATUS: &str = "play.playhorizon.dev:24454";
/// The cosmetics service: cosmetics, emotes, "Show me as a Horizon player"
/// (`HORIZON_COSMETICS_API` overrides it).
pub const COSMETICS_API: &str = "https://cosmetics.playhorizon.dev";
/// The telemetry ingest server (`crates/horizon-telemetry-server`): opt-in telemetry, the
/// "I'm playing" ping, benchmark uploads (`HORIZON_TELEMETRY_URL` overrides it).
pub const TELEMETRY: &str = "https://telemetry.playhorizon.dev";
/// Public STUN servers for Play with Friends' NAT discovery: two independent operators, so one
/// outage doesn't break discovery (`HORIZON_STUN_SERVERS` overrides them).
pub const STUN_SERVERS: &[&str] = &["stun.cloudflare.com:3478", "stun.l.google.com:19302"];
/// Play with Friends' troubleshooting page, shown to hosts nobody can reach.
pub const FRIENDS_HELP: &str =
"https://github.com/horizon-rust/horizon/blob/main/docs/play-with-friends.md#troubleshooting";
+3 -2
View File
@@ -652,9 +652,10 @@ mod tests {
assert_eq!(zh.get("%s chunks"), Some("%s 个区块"));
// Not every bundle is complete: the en_us layer is in every map, so a
// missing key falls back to English (`horizon.mods.not_built` is not
// in ru_ru either).
// in la_la).
assert_eq!(zh.get("LOD Distance"), Some("LOD 距离"));
assert_eq!(ru.get("horizon.mods.not_built"), Some(" (Not built)"));
let la = Language::load(&[&empty], "la_la");
assert_eq!(la.get("horizon.mods.not_built"), Some(" (Not built)"));
let pl = Language::load(&[&empty], "pl_pl");
assert_eq!(pl.get("horizon.options.controller"), Some("Kontroler"));
assert_eq!(pl.get("horizon.options.vibration"), Some("Wibracje"));
+1 -2
View File
@@ -11,7 +11,6 @@ horizon-cosmetics = { path = "../horizon-cosmetics" }
# The GUI atlas's pixels, kept compressed once uploaded (hud.rs `pack_pixels`).
lz4_flex = { version = "0.14", default-features = false, features = ["std", "safe-encode", "safe-decode", "checked-decode"] }
horizon-assets = { path = "../horizon-assets" }
horizon-bench = { path = "../horizon-bench" }
horizon-client-core = { path = "../horizon-client-core" }
horizon-client-input = { path = "../horizon-client-input" }
horizon-command = { path = "../horizon-command" }
@@ -27,7 +26,7 @@ horizon-polytone = { path = "../horizon-polytone" }
horizon-protocol = { path = "../horizon-protocol" }
horizon-shader-api = { path = "../horizon-shader-api" }
horizon-shaders = { path = "../horizon-shaders" }
horizon-telemetry = { path = "../horizon-telemetry", default-features = false }
horizon-system = { path = "../horizon-system" }
horizon-theme = { path = "../horizon-theme" }
horizon-threads = { path = "../horizon-threads" }
horizon-world = { path = "../horizon-world" }
@@ -5,6 +5,7 @@
//! `cargo run --profile fast -p horizon-client-render --example pack_load_bench [-- --scale 1]`
//! (`.github/workflows/pack-bench.yml` runs it on a branch and on its base, side by side).
use horizon_client_render::{
gfx::PreparedTextures,
hud::HudAtlas,
model_library::ParsedModels,
packs::{self, Pack},
@@ -403,6 +404,30 @@ fn main() {
);
row("CTM tiles: one by one", sprites(false, false, &tiles, true));
row("CTM tiles: prefetched", sprites(false, true, &tiles, true));
// The mip chains the renderer uploads, from the same pack layers: the HD pack's
// 64 px block sprites fill the array, as they do for a player who has it on.
let mut atlas = Sprites::with_disk_cache(&root);
for name in &blocks {
atlas.insert(&root, name).unwrap();
}
atlas.finish();
let border = vec![255u8; (atlas.size * atlas.size * 4) as usize];
row(
"texture upload: sprite mip chains, both packs",
time(
runs,
|| {},
|| {
std::hint::black_box(PreparedTextures::new(
&atlas.pixels,
atlas.size,
atlas.mips.len() as u32,
&atlas.mips,
(&border, atlas.size),
));
},
),
);
row(
"GUI atlas: decode and stitch, both packs",
time(
@@ -0,0 +1,378 @@
// Adapted from Starling (https://github.com/starling-client/starling, `android` branch at
// 150a414b09a1, `crates/horizon-client-render/src/android_gamepad.rs` and the input-mode part of
// `android_runtime.rs`), written by Vasysik and the Starling contributors. Copyright (c) 2026
// Ludovic Marie and the Horizon contributors, under the same MIT license as Horizon (LICENSE at
// the repository root).
//! Controllers as Android reports them: its standard key codes and motion axes,
//! the same for USB and Bluetooth pads, decoded into `gamepad::PadState`, and
//! which input device (touch, keyboard and mouse, or a controller) is in charge.
//! Pure logic: the Android event backend feeds it, nothing here talks to the OS.
use crate::gamepad::{Button, PadState};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
/// The input device the player picked in the options, or the one in use.
#[derive(Clone, Copy, Default, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum InputMode {
/// Follow whichever device was used last.
#[default]
Auto,
Touch,
KeyboardMouse,
Controller,
}
impl InputMode {
pub const ALL: [Self; 4] = [
Self::Auto,
Self::Touch,
Self::KeyboardMouse,
Self::Controller,
];
pub fn label(self) -> &'static str {
match self {
Self::Auto => "Automatic",
Self::Touch => "Touch & Android keyboard",
Self::KeyboardMouse => "Keyboard & mouse",
Self::Controller => "Controller",
}
}
}
// Android `MotionEvent` axes (AXIS_*).
const AXIS_X: usize = 0;
const AXIS_Y: usize = 1;
const AXIS_Z: usize = 11;
const AXIS_RZ: usize = 14;
const AXIS_HAT_X: usize = 15;
const AXIS_HAT_Y: usize = 16;
const AXIS_LTRIGGER: usize = 17;
const AXIS_RTRIGGER: usize = 18;
const AXIS_GAS: usize = 22;
const AXIS_BRAKE: usize = 23;
const AXES: usize = 24;
/// The Android `KeyEvent` key code (KEYCODE_*) of a controller button. Back
/// (4) is East, as on Android's own screens; volume and other system keys are
/// never game input.
pub fn button(code: u32) -> Option<Button> {
Some(match code {
96 => Button::South,
97 | 4 => Button::East,
99 => Button::West,
100 => Button::North,
102 => Button::LeftBumper,
103 => Button::RightBumper,
104 => Button::LeftTrigger,
105 => Button::RightTrigger,
106 => Button::LeftStick,
107 => Button::RightStick,
108 => Button::Start,
109 => Button::Select,
19 => Button::DPadUp,
20 => Button::DPadDown,
21 => Button::DPadLeft,
22 => Button::DPadRight,
_ => return None,
})
}
/// One controller: its key states, its last axis values, and presses too short
/// to be seen by a poll (released before the next frame).
#[derive(Default)]
struct Device {
keys: PadState,
axes: [f32; AXES],
pending: PadState,
}
impl Device {
fn state(&self) -> PadState {
let a = &self.axes;
let mut s = self.keys;
// Android's Y axes grow downwards; `PadState`'s grow upwards.
s.left = [a[AXIS_X], -a[AXIS_Y]];
s.right = [a[AXIS_Z], -a[AXIS_RZ]];
// Some pads report triggers as LTRIGGER/RTRIGGER, others as BRAKE/GAS,
// others only as L2/R2 keys: `Edges` reads triggers from `triggers`.
let digital = |b| if s.held(b) { 1.0 } else { 0.0 };
let triggers = [
a[AXIS_LTRIGGER]
.max(a[AXIS_BRAKE])
.max(digital(Button::LeftTrigger)),
a[AXIS_RTRIGGER]
.max(a[AXIS_GAS])
.max(digital(Button::RightTrigger)),
];
s.triggers = triggers;
// A hat D-pad adds to a key D-pad; a centred hat doesn't release keys.
for (button, held) in [
(Button::DPadLeft, a[AXIS_HAT_X] < -0.5),
(Button::DPadRight, a[AXIS_HAT_X] > 0.5),
(Button::DPadUp, a[AXIS_HAT_Y] < -0.5),
(Button::DPadDown, a[AXIS_HAT_Y] > 0.5),
] {
s.set(button, held || s.held(button));
}
s
}
}
/// The connected controllers by Android device id, and the one in use.
#[derive(Default)]
pub struct Devices {
devices: BTreeMap<i32, Device>,
active: Option<i32>,
}
impl Devices {
/// A motion event. Returns true only for a meaningful change, so a resting
/// or drifting stick never takes over from a finger or a mouse.
pub fn axis(&mut self, id: i32, axis: u32, value: f64) -> bool {
let index = axis as usize;
if ![
AXIS_X,
AXIS_Y,
AXIS_Z,
AXIS_RZ,
AXIS_HAT_X,
AXIS_HAT_Y,
AXIS_LTRIGGER,
AXIS_RTRIGGER,
AXIS_GAS,
AXIS_BRAKE,
]
.contains(&index)
{
return false;
}
let value = if value.is_finite() {
value.clamp(-1.0, 1.0) as f32
} else {
0.0
};
let d = self.devices.entry(id).or_default();
let old = std::mem::replace(&mut d.axes[index], value);
if value >= 0.5 && old < 0.5 {
let trigger = match index {
AXIS_LTRIGGER | AXIS_BRAKE => Some(0),
AXIS_RTRIGGER | AXIS_GAS => Some(1),
_ => None,
};
if let Some(i) = trigger {
d.pending.triggers[i] = value;
}
}
let activity = value.abs() > 0.25 && (value - old).abs() > 0.01;
if activity {
self.active = Some(id);
}
activity
}
/// A key event. Returns true for a new press of a controller button (key
/// repeats and releases are not activity).
pub fn key(&mut self, id: i32, code: u32, down: bool) -> bool {
let Some(b) = button(code) else { return false };
let d = self.devices.entry(id).or_default();
let activity = down && !d.keys.held(b);
d.keys.set(b, down);
if activity {
d.pending.set(b, true);
match b {
Button::LeftTrigger => d.pending.triggers[0] = 1.0,
Button::RightTrigger => d.pending.triggers[1] = 1.0,
_ => {}
}
self.active = Some(id);
}
activity
}
/// Forget controllers that are no longer connected.
pub fn retain(&mut self, ids: &[i32]) {
self.devices.retain(|id, _| ids.contains(id));
if self.active.is_some_and(|id| !ids.contains(&id)) {
self.active = None;
}
}
pub fn clear(&mut self) {
*self = Self::default();
}
pub fn active_id(&self) -> Option<i32> {
self.active
}
/// The controller in use, without consuming short presses.
pub fn state(&self) -> PadState {
self.active
.and_then(|id| self.devices.get(&id))
.map(Device::state)
.unwrap_or_default()
}
/// The controller in use, once per frame: a press released since the last
/// poll still shows as held this once.
pub fn poll(&mut self) -> PadState {
self.active
.and_then(|id| self.devices.get_mut(&id))
.map(|d| d.state().merge(&std::mem::take(&mut d.pending)))
.unwrap_or_default()
}
}
/// The picked input mode and the device in charge. In `Auto` the last device
/// used takes over; a picked mode ignores the others.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Selection {
mode: InputMode,
active: InputMode,
}
impl Default for Selection {
fn default() -> Self {
Self {
mode: InputMode::Auto,
active: InputMode::Touch,
}
}
}
impl Selection {
/// The mode picked in the options.
pub fn mode(self) -> InputMode {
self.mode
}
/// The device in charge now; never `Auto`.
pub fn active(self) -> InputMode {
self.active
}
/// Pick a mode. Returns true when it changed: the caller drops held
/// controller and keypad input so nothing stays pressed across the switch.
pub fn configure(&mut self, mode: InputMode) -> bool {
if self.mode == mode {
return false;
}
self.mode = mode;
self.active = if mode == InputMode::Auto {
InputMode::Touch
} else {
mode
};
true
}
/// A device of `kind` was used. Returns whether it may act: false when
/// another mode was picked.
pub fn note(&mut self, kind: InputMode) -> bool {
if kind == InputMode::Auto || (self.mode != InputMode::Auto && self.mode != kind) {
return false;
}
self.active = kind;
true
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn android_y_is_inverted_and_hats_do_not_erase_key_dpad() {
let mut d = Devices::default();
d.axis(7, 1, -1.0);
d.axis(7, 14, 0.7);
d.key(7, 19, true);
d.axis(7, 16, 0.0);
assert_eq!(d.state().left, [0.0, 1.0]);
assert_eq!(d.state().right, [0.0, -0.7]);
assert!(d.state().held(Button::DPadUp));
d.key(7, 19, false);
assert!(!d.state().held(Button::DPadUp));
d.axis(7, 15, 1.0);
assert!(d.state().held(Button::DPadRight));
}
#[test]
fn a_short_press_survives_until_one_poll_and_disconnect_clears_it() {
let mut d = Devices::default();
d.key(4, 96, true);
assert_eq!(d.active_id(), Some(4));
d.key(4, 96, false);
assert!(d.poll().held(Button::South));
assert!(!d.poll().held(Button::South));
d.key(4, 96, true);
d.retain(&[]);
assert_eq!(d.active_id(), None);
assert_eq!(d.poll(), PadState::default());
}
#[test]
fn triggers_accept_analog_and_digital_reports() {
let mut d = Devices::default();
d.key(3, 104, true);
d.axis(3, 17, 0.0);
assert!(d.state().held(Button::LeftTrigger));
assert_eq!(d.state().triggers[0], 1.0);
d.key(3, 104, false);
assert_eq!(d.state().triggers[0], 0.0);
d.axis(3, 23, 0.8);
assert_eq!(d.state().triggers[0], 0.8);
d.axis(3, 22, 0.6);
assert_eq!(d.state().triggers[1], 0.6);
assert!(!d.key(3, 24, true)); // Volume up is never a game action.
}
#[test]
fn digital_triggers_reach_edges_even_when_tapped_between_polls() {
let mut d = Devices::default();
let mut edges = crate::gamepad::Edges::default();
d.key(1, 105, true);
d.key(1, 105, false);
assert_eq!(edges.update(&d.poll(), 0.4), [(Button::RightTrigger, true)]);
assert_eq!(
edges.update(&d.poll(), 0.4),
[(Button::RightTrigger, false)]
);
}
#[test]
fn drift_neutral_and_repeats_do_not_select_another_controller() {
let mut d = Devices::default();
assert!(!d.axis(1, 0, 0.12));
assert_eq!(d.state(), PadState::default());
assert!(d.key(2, 96, true));
assert!(!d.key(2, 96, true));
d.axis(1, 0, 0.0);
assert!(d.state().held(Button::South));
d.retain(&[1]);
assert_eq!(d.state(), PadState::default());
d.axis(1, 0, f64::NAN);
assert_eq!(d.state(), PadState::default());
assert!(!d.axis(1, 9, 1.0)); // An axis no controller stick uses.
}
#[test]
fn picked_modes_reject_other_devices_and_auto_follows_activity() {
let mut s = Selection::default();
assert_eq!((s.mode(), s.active()), (InputMode::Auto, InputMode::Touch));
assert!(s.configure(InputMode::Touch));
assert!(!s.configure(InputMode::Touch));
assert!(!s.note(InputMode::Controller));
assert_eq!(s.active(), InputMode::Touch);
assert!(s.configure(InputMode::Controller));
assert_eq!(s.active(), InputMode::Controller);
assert!(!s.note(InputMode::Touch));
assert!(s.configure(InputMode::Auto));
assert_eq!(s.active(), InputMode::Touch);
assert!(s.note(InputMode::KeyboardMouse));
assert_eq!(s.active(), InputMode::KeyboardMouse);
assert!(s.note(InputMode::Controller));
assert_eq!(s.active(), InputMode::Controller);
assert!(!s.note(InputMode::Auto));
assert_eq!(s.active(), InputMode::Controller);
}
#[test]
fn input_modes_save_by_name() {
let names: Vec<_> = InputMode::ALL
.iter()
.map(|m| serde_json::to_string(m).unwrap())
.collect();
assert_eq!(
names,
[
"\"auto\"",
"\"touch\"",
"\"keyboard-mouse\"",
"\"controller\""
]
);
assert!(InputMode::ALL.iter().all(|m| !m.label().is_empty()));
}
}
+384 -8
View File
@@ -112,6 +112,8 @@ struct Mixer {
cameras: Vec<([f64; 3], Basis, u32)>,
/// Per-source gain/pan/step cache for one buffer, reused across callbacks.
voices: Vec<(ChannelId, [f32; 3], f64)>,
/// Mods' audio sources (`push_voice`), each with its queue.
mod_audio: Vec<ModAudioSource>,
/// A mod's muffle of the sounds it does not exempt.
muffle: Muffle,
limiter_peak: f32,
@@ -121,10 +123,158 @@ struct Mixer {
/// The stream reported its device gone (`Library.isCurrentDeviceDisconnected`).
#[cfg(feature = "audio")]
device_lost: bool,
/// The stream's recent backend errors, which tell a dead stream from a hiccup.
#[cfg(feature = "audio")]
stream_errors: StreamErrors,
}
/// A stream's backend errors close together (`StreamErrors::dead`).
#[cfg(any(feature = "audio", test))]
#[derive(Default)]
struct StreamErrors {
/// When the current run of errors began, and how many it holds.
since: Option<std::time::Instant>,
count: u32,
}
#[cfg(any(feature = "audio", test))]
impl StreamErrors {
/// Errors this close together...
const WINDOW: std::time::Duration = std::time::Duration::from_secs(2);
/// ...this many of them: the stream is dead. Under vanilla a device that fails unrecoverably
/// reads as disconnected (`ALC_CONNECTED`, the `ALC_EXT_disconnect` extension), and
/// `SoundEngine` opens the output again; cpal reports a dead ALSA stream (`alsa::poll()`
/// returning POLLERR, over and over) as a backend error like any other, so a run of them
/// stands for it. One alone is a hiccup the stream recovers from.
const DEAD_AFTER: u32 = 3;
/// One more backend error at `now`: whether the stream counts as dead.
fn dead(&mut self, now: std::time::Instant) -> bool {
match self.since {
Some(since) if now.duration_since(since) <= Self::WINDOW => self.count += 1,
_ => {
self.since = Some(now);
self.count = 1;
}
}
self.count >= Self::DEAD_AFTER
}
}
/// Opening the output again after it failed: a few more tries, a couple of seconds apart, so a
/// device that is gone for good isn't asked forever.
#[cfg(any(feature = "audio", test))]
#[derive(Default, Debug, PartialEq)]
struct Reopen {
/// The tries left; 0 while the output plays, or after the last try.
tries: u32,
/// Ticks since the last try.
ticks: u32,
}
#[cfg(any(feature = "audio", test))]
impl Reopen {
const TRIES: u32 = 5;
/// Two seconds of client ticks.
const TICKS: u32 = 40;
/// The output failed to open: whether to keep trying.
fn failed(&mut self, retrying: bool) -> bool {
self.ticks = 0;
self.tries = if retrying {
self.tries.saturating_sub(1)
} else {
Self::TRIES
};
self.tries > 0
}
/// A tick while tries are left: whether to try now.
fn due(&mut self) -> bool {
if self.tries == 0 {
return false;
}
self.ticks += 1;
self.ticks >= Self::TICKS
}
}
/// A camera's forward, up and right vectors.
type Basis = ([f64; 3], [f64; 3], [f64; 3]);
/// How much audio one mod source may have waiting, in seconds (`push_voice`): a burst past it
/// is refused rather than delayed, so a stalled sender cannot pile up latency.
const MOD_AUDIO_SECONDS: f64 = 2.0;
/// How many mod audio sources may play at once, across mods.
const MOD_AUDIO_SOURCES: usize = 64;
/// The distance at which positioned mod audio has faded out, in blocks (linear, as a sound's
/// `AL_LINEAR_DISTANCE` fades).
const MOD_AUDIO_RANGE: f32 = 48.0;
/// One of a mod's audio sources (`push_voice`): a speaker, keyed by the mod and its own id
/// for it. Its chunks play one after another, oldest first, so each speaker's audio stays in
/// order while different speakers play at the same time.
struct ModAudioSource {
mod_id: String,
source: u128,
chunks: std::collections::VecDeque<ModAudioChunk>,
/// How far into the head chunk the callback has got, in its own samples.
played: f64,
}
/// A block of mono samples a mod queued.
struct ModAudioChunk {
/// Mono samples, -1 to 1.
samples: Vec<f32>,
/// The rate they were sampled at.
rate: u32,
/// Where they were spoken, for distance and direction; none plays them centred.
at: Option<[f64; 3]>,
}
impl Mixer {
/// Queues `samples` behind what `source` of `mod_id` already has waiting. False, and
/// nothing queued, when they are empty or the rate is 0, when the source would hold more
/// than [`MOD_AUDIO_SECONDS`], or when a new source would pass [`MOD_AUDIO_SOURCES`].
fn push_mod_audio(
&mut self,
mod_id: &str,
source: u128,
samples: &[f32],
rate: u32,
at: Option<[f64; 3]>,
) -> bool {
if samples.is_empty() || rate == 0 {
return false;
}
let seconds = |len: usize, rate: u32| len as f64 / f64::from(rate);
let added = seconds(samples.len(), rate);
let chunk = ModAudioChunk {
samples: samples.to_vec(),
rate,
at,
};
match self
.mod_audio
.iter()
.position(|s| s.source == source && s.mod_id == mod_id)
{
Some(i) => {
let s = &mut self.mod_audio[i];
let waiting: f64 = s
.chunks
.iter()
.map(|c| seconds(c.samples.len(), c.rate))
.sum();
if waiting + added > MOD_AUDIO_SECONDS {
return false;
}
s.chunks.push_back(chunk);
}
None => {
if added > MOD_AUDIO_SECONDS || self.mod_audio.len() >= MOD_AUDIO_SOURCES {
return false;
}
self.mod_audio.push(ModAudioSource {
mod_id: mod_id.to_owned(),
source,
chunks: std::collections::VecDeque::from([chunk]),
played: 0.0,
});
}
}
true
}
/// The id of world `name` (1 and up), given once per name.
fn world_id(&mut self, name: &str) -> u32 {
let i = match self.world_names.iter().position(|w| w == name) {
@@ -164,6 +314,24 @@ impl MuffleFade {
[self.amount * left, self.lowpass * left]
}
}
impl MixerBackend {
/// Queues a mod's audio for the output callback (`Host::push_voice`). False when nothing
/// was queued: no output device, or what [`Mixer::push_mod_audio`] refuses.
pub fn push_voice(
&self,
mod_id: &str,
source: u128,
samples: &[f32],
rate: u32,
at: Option<[f64; 3]>,
) -> bool {
self.live
&& self
.mixer
.lock()
.is_ok_and(|mut m| m.push_mod_audio(mod_id, source, samples, rate, at))
}
}
/// The mixer side of the engine (`Backend`).
pub struct MixerBackend {
mixer: Arc<Mutex<Mixer>>,
@@ -286,6 +454,21 @@ pub struct CapturedSound {
pub except_entity: Option<u32>,
}
impl Audio {
/// Plays a mod's audio (`Host::push_voice`): mono samples, -1 to 1, at `rate`, behind what
/// `source` of `mod_id` already has waiting; `at` places them for distance and direction.
pub fn push_voice(
&self,
mod_id: &str,
source: u128,
samples: &[f32],
rate: u32,
at: Option<[f64; 3]>,
) -> bool {
self.backend.push_voice(mod_id, source, samples, rate, at)
}
}
pub struct Audio {
_stream: Option<Stream>,
device_name: String,
@@ -327,6 +510,9 @@ pub struct Audio {
/// Ticks since the output devices were last looked at (`DeviceTracker.tick`).
#[cfg(feature = "audio")]
device_ticks: u32,
/// Opening the output again after it failed to (`Reopen`).
#[cfg(feature = "audio")]
reopen: Reopen,
/// The look at the output devices running on its own thread, until a tick
/// takes its answer: listing them can take 100 ms (#11490).
#[cfg(feature = "audio")]
@@ -836,6 +1022,62 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
mixer.muffle.stages = [[0.0; 2]; 2];
}
mixer.muffle.bus = bus;
// A mod's audio (`push_voice`): every source at once, each playing its own chunks in order;
// a chunk that ends mid-buffer hands the rest of the buffer to the next one.
if !mixer.mod_audio.is_empty() {
let (forward, up, right) = basis(mixer.listener_yaw, mixer.listener_pitch);
let listener = mixer.listener;
let frames = data.len() / channels.max(1);
for source in &mut mixer.mod_audio {
let ModAudioSource { chunks, played, .. } = source;
let mut f = 0;
while f < frames {
let Some(chunk) = chunks.front() else {
break;
};
let len = chunk.samples.len();
let [left, right_gain, gain] = match chunk.at {
Some(at) => {
let d = [
at[0] - listener[0],
at[1] - listener[1],
at[2] - listener[2],
];
let gain = distance_gain(Some(MOD_AUDIO_RANGE), dot(d, d).sqrt());
let [l, r] = stereo_gains(dot(d, right), dot(d, up), dot(d, forward));
[l * gain, r * gain, gain]
}
None => [1.0; 3],
};
let step = f64::from(chunk.rate) / f64::from(rate);
while f < frames && (*played as usize) < len {
let i = *played as usize;
let t = (*played - i as f64) as f32;
let next = chunk
.samples
.get(i + 1)
.copied()
.unwrap_or(chunk.samples[i]);
let s = chunk.samples[i] * (1.0 - t) + next * t;
let frame = &mut data[f * channels..(f + 1) * channels];
if channels >= 2 {
frame[0] += s * left;
frame[1] += s * right_gain;
} else {
frame[0] += s * gain;
}
*played += step;
f += 1;
}
if *played as usize >= len {
// The next chunk starts where this one's last step left off.
*played -= len as f64;
chunks.pop_front();
}
}
}
mixer.mod_audio.retain(|s| !s.chunks.is_empty());
}
// Limit the whole buffer together, preserving waveform and stereo balance
// instead of flattening each overloaded sample; release over 200 ms
// (OpenAL Soft's output limiter, `ALC_OUTPUT_LIMITER_SOFT`, releases
@@ -903,6 +1145,7 @@ fn open_device(
if let Ok(mut m) = mixer.lock() {
m.device_name = device.name().unwrap_or_default();
m.device_lost = false;
m.stream_errors = StreamErrors::default();
}
Ok((stream, config))
}
@@ -1052,10 +1295,19 @@ fn build_stream<T: cpal::SizedSample + cpal::FromSample<f32>>(
},
move |e| {
log::warn!("Horizon: audio stream error: {e}");
if matches!(e, cpal::StreamError::DeviceNotAvailable)
&& let Ok(mut m) = on_error.lock()
{
m.device_lost = true;
if let Ok(mut m) = on_error.lock() {
let dead = match e {
cpal::StreamError::DeviceNotAvailable => true,
cpal::StreamError::BackendSpecific { .. } => {
m.stream_errors.dead(std::time::Instant::now())
}
};
if dead && !m.device_lost {
if !matches!(e, cpal::StreamError::DeviceNotAvailable) {
log::warn!("Horizon: the audio stream stopped; opening the output again");
}
m.device_lost = true;
}
}
},
None,
@@ -1177,6 +1429,8 @@ impl Audio {
#[cfg(feature = "audio")]
device_ticks: 0,
#[cfg(feature = "audio")]
reopen: Reopen::default(),
#[cfg(feature = "audio")]
device_probe: None,
}
}
@@ -1340,6 +1594,8 @@ impl Audio {
#[cfg(feature = "audio")]
device_ticks: 0,
#[cfg(feature = "audio")]
reopen: Reopen::default(),
#[cfg(feature = "audio")]
device_probe: None,
}
}
@@ -1447,6 +1703,27 @@ impl Audio {
}
changed
}
/// Opens the output on the chosen device again; `retrying` after an earlier try failed.
#[cfg(feature = "audio")]
fn reopen_output(&mut self, retrying: bool) {
// The old stream lets go of the device first: a dead one may still hold it.
self._stream = None;
let device = self.device_name.clone();
match self.set_device(&device) {
Ok(()) => {
if retrying {
log::info!("Horizon: the audio output is back");
}
self.reopen = Reopen::default();
}
Err(e) if self.reopen.failed(retrying) => {
log::warn!("Horizon: could not reopen the audio output ({e}); trying again soon");
}
Err(e) => {
log::warn!("Horizon: could not reopen the audio output ({e}); it stays off");
}
}
}
/// `SoundManager.tick(paused)`, once per client tick.
pub fn tick(&mut self, paused: bool, ctx: &TickContext) {
#[cfg(feature = "audio")]
@@ -1454,10 +1731,9 @@ impl Audio {
if self.should_change_device() {
// SoundEngine.reload: stop every sound, open the output again.
self.stop_all();
let device = self.device_name.clone();
if let Err(e) = self.set_device(&device) {
log::warn!("Horizon: could not reopen the audio output: {e}");
}
self.reopen_output(false);
} else if self.backend.owner == 0 && self.reopen.due() {
self.reopen_output(true);
}
}
self.sync_volumes();
@@ -1858,6 +2134,46 @@ impl Sounds for Audio {
mod tests {
use super::*;
/// A run of backend errors close together is a dead stream; one or two, or errors far apart,
/// are hiccups.
#[test]
fn a_run_of_stream_errors_is_a_dead_stream() {
use std::time::{Duration, Instant};
let start = Instant::now();
let at = |ms: u64| start + Duration::from_millis(ms);
let mut errors = StreamErrors::default();
assert!(!errors.dead(at(0)));
assert!(!errors.dead(at(500)));
assert!(errors.dead(at(900)));
// Spread out, they never add up.
let mut apart = StreamErrors::default();
for k in 0..10 {
assert!(!apart.dead(at(k * 2500)), "error {k}");
}
}
/// A failed reopening is tried again every two seconds, five more times, then given up.
#[test]
fn a_failed_reopening_retries_a_few_times_then_stops() {
let mut reopen = Reopen::default();
assert!(!reopen.due(), "nothing to retry while the output plays");
assert!(reopen.failed(false));
let mut tries = 0;
for _ in 0..1000 {
if reopen.due() {
tries += 1;
if !reopen.failed(true) {
break;
}
}
}
assert_eq!(tries, Reopen::TRIES);
assert_eq!(reopen, Reopen::default());
// The next lost device starts a fresh run.
assert!(reopen.failed(false));
assert_eq!(reopen.tries, Reopen::TRIES);
}
#[test]
fn level_capture_keeps_order_category_entity_and_actor_exclusion() {
let mut audio = Audio::silent(Path::new("/nonexistent/horizon-entity-sound-test"));
@@ -2517,6 +2833,66 @@ mod tests {
assert_eq!(level(&audio), [0.0; 2]);
}
#[test]
fn each_mod_audio_source_plays_its_own_chunks_in_order() {
let mut m = Mixer::default();
// Two speakers of one mod, each queuing two chunks, interleaved.
for (source, level) in [(1, 0.1), (2, 0.4), (1, 0.2), (2, 0.3)] {
assert!(m.push_mod_audio("voice", source, &[level; 4], 48_000, None));
}
let mut data = vec![0.0f32; 8 * 2];
mix(&mut m, &mut data, 2, 48_000);
// Each speaker's first chunk, then its second: never both of one speaker at once.
let left: Vec<f32> = data.chunks(2).map(|f| f[0]).collect();
let expected = [0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5];
assert!(
left.iter().zip(expected).all(|(a, b)| (a - b).abs() < 1e-6),
"{left:?}"
);
assert!(m.mod_audio.is_empty(), "both sources played out");
// Order within a source: its second chunk follows its first.
assert!(m.push_mod_audio("voice", 1, &[0.25; 2], 48_000, None));
assert!(m.push_mod_audio("voice", 1, &[0.5; 2], 48_000, None));
let mut data = vec![0.0f32; 4 * 2];
mix(&mut m, &mut data, 2, 48_000);
let left: Vec<f32> = data.chunks(2).map(|f| f[0]).collect();
assert_eq!(left, [0.25, 0.25, 0.5, 0.5]);
}
#[test]
fn mod_audio_sources_are_kept_apart_by_mod_and_capped() {
let mut m = Mixer::default();
assert!(m.push_mod_audio("a", 1, &[0.1; 48_000], 48_000, None));
assert!(m.push_mod_audio("b", 1, &[0.1; 48_000], 48_000, None));
assert_eq!(
m.mod_audio.len(),
2,
"the same id in two mods is two sources"
);
// Two seconds wait at most per source; a burst past that is refused, not delayed.
assert!(m.push_mod_audio("a", 1, &[0.1; 48_000], 48_000, None));
assert!(!m.push_mod_audio("a", 1, &[0.1; 1], 48_000, None));
assert!(!m.push_mod_audio("a", 2, &[], 48_000, None));
assert!(!m.push_mod_audio("a", 2, &[0.1], 0, None));
}
#[test]
fn mod_audio_is_panned_and_faded_by_where_it_was_spoken() {
let mut m = Mixer::default();
// Facing south (yaw 0), -X is the listener's right.
assert!(m.push_mod_audio("voice", 1, &[0.5; 16], 48_000, Some([-4.0, 0.0, 0.0])));
let mut data = vec![0.0f32; 16 * 2];
mix(&mut m, &mut data, 2, 48_000);
let left: f32 = data.chunks(2).map(|f| f[0]).sum();
let right: f32 = data.chunks(2).map(|f| f[1]).sum();
assert!(right > left, "right {right} should beat left {left}");
// Past the range it is silent.
assert!(m.push_mod_audio("voice", 1, &[0.5; 16], 48_000, Some([0.0, 0.0, 60.0])));
let mut data = vec![0.0f32; 16 * 2];
mix(&mut m, &mut data, 2, 48_000);
assert!(data.iter().all(|s| *s == 0.0));
}
#[test]
fn output_devices_are_looked_at_again_only_when_the_cards_change() {
// No fingerprint (not Linux, or the cards could not be read): the list
@@ -1459,7 +1459,37 @@ impl BlockEntityRenderer {
registry: &BlockRegistry,
visibility: FaceVisibility,
) -> Result<Self> {
let all: Value = serde_json::from_slice(&std::fs::read(root.join("entity-models.json"))?)?;
Self::load_with_visibility_inner(root, registry, visibility, None, None)
}
/// Load from what the client startup batch already decoded: its entity-model
/// document (none reads `entity-models.json`) and the item defaults, avoiding a
/// second read and decode of either.
pub fn load_with_visibility_models_and_defaults(
root: &Path,
registry: &BlockRegistry,
visibility: FaceVisibility,
models: Option<&Value>,
defaults: &horizon_world::item_defaults::Defaults,
) -> Result<Self> {
Self::load_with_visibility_inner(root, registry, visibility, models, Some(defaults))
}
fn load_with_visibility_inner(
root: &Path,
registry: &BlockRegistry,
visibility: FaceVisibility,
models: Option<&Value>,
defaults: Option<&horizon_world::item_defaults::Defaults>,
) -> Result<Self> {
let owned_models = if models.is_none() {
Some(serde_json::from_slice(&std::fs::read(
root.join("entity-models.json"),
)?)?)
} else {
None
};
let all = models.or(owned_models.as_ref()).expect("entity models");
let models = MODELS
.iter()
.filter_map(|(key, layer)| all.get(*layer).map(|v| (*key, ModelPart::parse(v))))
@@ -1469,12 +1499,17 @@ impl BlockEntityRenderer {
// The packs' custom entity models (`.jem` files), before anything draws.
renderer.set_shapes(renderer.shapes_for(root));
// Sherd items: provides_pottery_pattern -> decorated_pot_pattern asset id.
let items = match horizon_world::item_defaults::read(root) {
Ok(items) => items,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Default::default(),
Err(e) => return Err(e.into()),
let owned_defaults = if defaults.is_none() {
Some(match horizon_world::item_defaults::read(root) {
Ok(items) => items,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Default::default(),
Err(e) => return Err(e.into()),
})
} else {
None
};
for (item, components) in &items {
let items = defaults.or(owned_defaults.as_ref()).unwrap();
for (item, components) in items {
let Some(pattern) = components["minecraft:provides_pottery_pattern"].as_str() else {
continue;
};
+4 -1
View File
@@ -871,7 +871,10 @@ impl RolledLoot {
/// Puts the loot table back in place of `Items` while they are still the
/// roll; false once the container holds anything else.
pub fn restore(&self, map: &mut Compound) -> bool {
if map.get(&"Items".into()) != Some(&self.items) {
// An empty shulker box saves no `Items`, which matches an empty roll.
let saved = map.get(&"Items".into());
let empty = matches!(&self.items, Tag::List(l) if l.is_empty());
if saved != Some(&self.items) && !(saved.is_none() && empty) {
return false;
}
map.remove(&"Items".into());
@@ -80,6 +80,8 @@ pub enum BlockEntity {
Skull(Skull),
/// `TheEndGatewayBlockEntity`: its age and configured destination.
EndGateway(EndGateway),
/// `TheEndPortalBlockEntity`: no data, only identity.
EndPortal,
/// `DaylightDetectorBlockEntity`: no data, only the ticker
/// (`DaylightDetectorBlock.tickEntity`).
DaylightDetector,
@@ -52,6 +52,10 @@ pub struct ChunkMap {
recent_deltas: VecDeque<(u64, ChunkMembershipDelta)>,
/// The resident chunks generation has not placed its animals in yet.
fresh: FxHashSet<Key>,
/// For each column, how many of the 3×3 columns around it (itself
/// included) are resident; absent when none is. `surrounded` asks one
/// map instead of nine every tick, for every ticking chunk.
near: FxHashMap<Key, u8>,
}
impl Default for ChunkMap {
fn default() -> Self {
@@ -62,6 +66,7 @@ impl Default for ChunkMap {
min_valid_revision: revision,
recent_deltas: VecDeque::new(),
fresh: FxHashSet::default(),
near: FxHashMap::default(),
}
}
}
@@ -124,6 +129,11 @@ impl ChunkMap {
}
let previous = self.columns.insert(key, chunk);
if previous.is_none() {
for z in key.1 - 1..=key.1 + 1 {
for x in key.0 - 1..=key.0 + 1 {
*self.near.entry((x, z)).or_insert(0) += 1;
}
}
self.revision = next_revision();
self.record_delta(ChunkMembershipDelta::Added(key));
}
@@ -133,6 +143,16 @@ impl ChunkMap {
self.fresh.remove(key);
let previous = self.columns.remove(key);
if previous.is_some() {
for z in key.1 - 1..=key.1 + 1 {
for x in key.0 - 1..=key.0 + 1 {
if let Some(n) = self.near.get_mut(&(x, z)) {
*n -= 1;
if *n == 0 {
self.near.remove(&(x, z));
}
}
}
}
self.revision = next_revision();
self.record_delta(ChunkMembershipDelta::Removed(*key));
}
@@ -142,6 +162,7 @@ impl ChunkMap {
if !self.columns.is_empty() {
self.columns.clear();
self.fresh.clear();
self.near.clear();
self.revision = next_revision();
self.recent_deltas.clear();
self.recent_deltas
@@ -149,6 +170,10 @@ impl ChunkMap {
self.min_valid_revision = self.revision;
}
}
/// Whether the column at `key` and its eight neighbours are all resident.
pub fn surrounded(&self, key: Key) -> bool {
self.near.get(&key) == Some(&9)
}
pub fn has_fresh(&self) -> bool {
!self.fresh.is_empty()
}
@@ -382,4 +407,41 @@ mod tests {
assert_eq!(ordered, expected);
assert_eq!(ordered.len(), 3);
}
#[test]
fn surrounded_follows_loads_unloads_and_clears() {
// Brute force: the column and its eight neighbours are all resident.
fn expected(map: &ChunkMap, (x, z): Key) -> bool {
(-1..=1).all(|dz| (-1..=1).all(|dx| map.contains_key(&(x + dx, z + dz))))
}
let mut map = ChunkMap::default();
let mut seed = 0x2545_f491_u32;
let mut next = || {
seed ^= seed << 13;
seed ^= seed >> 17;
seed ^= seed << 5;
seed
};
for step in 0..4000 {
let key = ((next() % 9) as i32 - 4, (next() % 9) as i32 - 4);
if next() % 3 == 0 {
map.remove(&key);
} else {
// Replacing a resident column changes nothing.
map.insert(key, Arc::new(Chunk::empty(key.0, key.1)));
}
if step == 2500 {
map.clear();
}
for x in -6..=6 {
for z in -6..=6 {
assert_eq!(
map.surrounded((x, z)),
expected(&map, (x, z)),
"{x},{z} at {step}"
);
}
}
}
}
}
+128 -13
View File
@@ -62,6 +62,16 @@ pub fn radius_cells(cloud_range_chunks: i32) -> i32 {
((cloud_range_chunks * 16) as f32 / 12.0).ceil() as i32
}
/// Cells the mesh's origin snaps to. The mesh is a window on the cloud pattern around the
/// camera's cell, so it used to be rebuilt, and the whole array re-uploaded, on every cell
/// crossed: 6.5 MB, about 11 ms at the default 128-chunk range, the worst frame of a flight.
/// Anchoring the origin to a coarser grid rebuilds it once every `SNAP_CELLS` cells instead,
/// and `Uniform::offset` carries the remaining shift. The ring of cells the shift can leave
/// uncovered at the far side lies past the cloud fog end, where the cloud fade has already
/// taken the alpha to 0 (`entity.wgsl`'s `fs_entity`), so the extra cells cost only the
/// vertices they add.
const SNAP_CELLS: i64 = 4;
#[derive(Clone)]
pub struct CellIter {
radius: i32,
@@ -145,6 +155,10 @@ struct MeshKey {
// The two vertical face tests include both surfaces at the boundaries.
top: bool,
bottom: bool,
// The camera's cell within the snapped origin's, for the 3x3 cells whose inward
// faces are drawn. Only read while the camera is inside the layer.
sub_x: i32,
sub_z: i32,
}
#[repr(C)]
@@ -169,16 +183,30 @@ impl Input<'_> {
let (tw, th) = (f64::from(*w) * 12.0, f64::from(*h) * 12.0);
x -= (x / tw).floor() * tw;
z -= (z / th).floor() * th;
let cell_x = (x / 12.0).floor() as i64;
let cell_z = (z / 12.0).floor() as i64;
let cell_x = (x / 12.0).floor() as i64 / SNAP_CELLS * SNAP_CELLS;
let cell_z = (z / 12.0).floor() as i64 / SNAP_CELLS * SNAP_CELLS;
let bottom = (CLOUD_HEIGHT - self.camera.y) as f32;
// The inward faces of the 3x3 cells around the camera are only seen from inside the
// layer; outside it they are back-facing, so the mesh need not follow the camera there.
let inside_layer = bottom <= 0.0 && bottom + 4.0 >= 0.0;
let (sub_x, sub_z) = if inside_layer {
(
(x / 12.0).floor() as i32 - cell_x as i32,
(z / 12.0).floor() as i32 - cell_z as i32,
)
} else {
(0, 0)
};
let key = MeshKey {
cell_x,
cell_z,
radius: radius_cells(self.range),
// One more ring than the range, for the cells the snapped origin can uncover.
radius: radius_cells(self.range) + SNAP_CELLS as i32,
fast: self.fast,
top: bottom <= 0.0,
bottom: bottom + 4.0 >= 0.0,
sub_x,
sub_z,
};
let colors = [1.0, 0.7, 0.8, 0.9].map(|shade| {
let [r, g, b, a] = self.color;
@@ -200,6 +228,10 @@ impl Input<'_> {
}
}
/// The cloud cells around a snapped origin, rebuilt when the key changes: once every
/// [`SNAP_CELLS`] cells the camera crosses, and every cell while the camera is inside the
/// layer, where the inward faces follow it. The renderer copies the vertices to the GPU on
/// a rebuild, which is why the origin snaps at all.
#[derive(Default)]
pub(crate) struct Mesh {
key: Option<MeshKey>,
@@ -357,8 +389,8 @@ impl Mesh {
}
let mask = face_mask(
dx,
dz,
dx - key.sub_x,
dz - key.sub_z,
bottom,
[
!filled(cx, cz - 1),
@@ -414,13 +446,16 @@ mod tests {
assert_eq!(color[3], 127.0 / 255.0);
}
}
// One block into the second snap cell: every case below moves the snapped origin, or
// another part of the key, so each one rebuilds.
let far_cell = SNAP_CELLS as f64 * 12.0 + 1.0;
for (x, y, z, range, fast) in [
(13.0, 101.0, 1.0, 2, false),
(13.0, 101.0, -5.0, 2, false),
(13.0, 192.33, -5.0, 2, false),
(13.0, 196.34, -5.0, 2, false),
(13.0, 196.34, -5.0, 3, false),
(13.0, 196.34, -5.0, 3, true),
(far_cell, 101.0, 1.0, 2, false),
(far_cell, 101.0, -5.0, 2, false),
(far_cell, 192.33, -5.0, 2, false),
(far_cell, 196.34, -5.0, 2, false),
(far_cell, 196.34, -5.0, 3, false),
(far_cell, 196.34, -5.0, 3, true),
] {
input.camera = glam::DVec3::new(x, y, z);
input.range = range;
@@ -434,8 +469,14 @@ mod tests {
);
assert!(!mesh.prepare(&input).0);
}
// Drift alone crosses a cell, even with a stationary camera.
input.time += 400;
// Drift alone crosses the snapped origin, even with a stationary camera.
input.camera = glam::DVec3::new(1.0, 101.0, 1.0);
input.time = 0;
input.partial_tick = 0.0;
assert!(mesh.prepare(&input).0);
assert!(!mesh.prepare(&input).0);
// 400 ticks of drift move the clouds 12 blocks: one whole snap cell.
input.time = SNAP_CELLS * 400;
assert!(mesh.prepare(&input).0);
// Resource reload constructs a new renderer and cache, including empty packs.
let mut reloaded = Mesh::default();
@@ -446,6 +487,80 @@ mod tests {
assert!(!reloaded.prepare(&input).0);
}
#[test]
fn a_snap_cell_of_motion_keeps_the_mesh_at_the_same_world_cells() {
let texture = (8, 8, (0..64).map(|i| i % 3 != 0).collect());
let mut input = Input {
texture: &texture,
uv: [0.5; 2],
camera: glam::DVec3::new(1.0, 100.0, 1.0),
time: 0,
partial_tick: 0.0,
range: 2,
fast: false,
color: [1.0; 4],
};
// One snap cell, in blocks.
let cell = SNAP_CELLS as f64 * 12.0;
let mut mesh = Mesh::default();
let (rebuilt, uniform) = mesh.prepare(&input);
assert!(rebuilt);
// The shader draws a vertex at `camera + position + offset`, so the world cells the
// cloud covers are `camera` plus this: the same ones anywhere inside a snap cell.
assert_eq!(input.camera.x as f32 + uniform.offset[0], 0.0);
for x in [1.5, cell * 0.15, cell * 0.5, cell * 0.75, cell - 1.5] {
input.camera.x = x;
let (rebuilt, uniform) = mesh.prepare(&input);
assert!(!rebuilt, "rebuilt at {x}");
assert_eq!(input.camera.x as f32 + uniform.offset[0], 0.0);
// The trailing origin still covers the whole range past the camera.
let far = mesh
.vertices
.iter()
.map(|v| v.pos[0] + uniform.offset[0])
.fold(f32::MIN, f32::max);
assert!(far >= 32.0, "clouds end {far} blocks ahead at {x}");
}
// The next snap cell takes the origin with it.
input.camera.x = cell;
let (rebuilt, uniform) = mesh.prepare(&input);
assert!(rebuilt);
assert_eq!(input.camera.x as f32 + uniform.offset[0], cell as f32);
}
#[test]
fn only_inside_the_layer_does_the_mesh_follow_the_camera_between_snap_cells() {
let texture = (8, 8, (0..64).map(|i| i % 3 != 0).collect());
let mut input = Input {
texture: &texture,
uv: [0.5; 2],
camera: glam::DVec3::new(1.0, 101.0, 1.0),
time: 0,
partial_tick: 0.0,
range: 2,
fast: false,
color: [1.0; 4],
};
let mut mesh = Mesh::default();
assert!(mesh.prepare(&input).0);
// Below the layer, cells crossed inside one snap cell change nothing visible.
for (x, z) in [(11.9, 11.9), (24.0, 12.0), (35.5, 23.9)] {
input.camera.x = x;
input.camera.z = z;
assert!(
!mesh.prepare(&input).0,
"rebuilt at {x},{z} below the layer"
);
}
// Inside it, the inward faces around the camera do move with its own cell.
input.camera.y = 194.0;
assert!(mesh.prepare(&input).0);
input.camera.x = 12.0;
assert!(mesh.prepare(&input).0);
input.camera.y = 101.0;
assert!(mesh.prepare(&input).0);
}
#[test]
fn settings_and_packed_alpha_gate_clouds() {
for mode in ["false", "fast", "true"] {
+138 -49
View File
@@ -423,49 +423,57 @@ impl SceneCollisions<'_, '_> {
if lo.x > hi.x || lo.y > hi.y || lo.z > hi.z || cells > 4096 {
return false;
}
let mut column: Option<((i32, i32), Option<&crate::chunk::Chunk>)> = None;
let mut section: Option<((i32, i32), i32, bool)> = None;
for z in lo.z..=hi.z {
for y in lo.y..=hi.y {
for x in lo.x..=hi.x {
let outer = [
x == lo.x || x == hi.x,
y == lo.y || y == hi.y,
z == lo.z || z == hi.z,
];
let kind = outer.into_iter().filter(|&o| o).count();
if kind == 3 {
// A column and section at a time. Most boxes lie in a few sections whose palette says that no
// block in them can matter (nothing that collides, nothing that reaches out of its cell), and
// then none of their cells is read: a particle's box at speed spans dozens of cells.
for cz in (lo.z >> 4)..=(hi.z >> 4) {
for cx in (lo.x >> 4)..=(hi.x >> 4) {
// The scan passes the cells of a column that is not resident.
let Some(chunk) = self.world.chunks.get(&(cx, cz)).map(|c| &**c) else {
continue;
};
let (x0, x1) = (lo.x.max(cx << 4), hi.x.min((cx << 4) + 15));
let (z0, z1) = (lo.z.max(cz << 4), hi.z.min((cz << 4) + 15));
for sy in (lo.y >> 4)..=(hi.y >> 4) {
let reaches = self.section_reaches_out(chunk, sy);
if !reaches && self.section_blocks_all_empty(chunk, sy) {
continue;
}
let at = (x >> 4, z >> 4);
let chunk = match column {
Some((c, chunk)) if c == at => chunk,
_ => {
let chunk = self.world.chunks.get(&at).map(|c| &**c);
column = Some((at, chunk));
chunk
}
};
// The scan passes the cells of a column that is not resident.
let Some(chunk) = chunk else {
continue;
};
if kind != 0 {
let reaches = match section {
Some((c, sy, reaches)) if c == at && sy == y >> 4 => reaches,
_ => {
let reaches = self.section_reaches_out(chunk, y >> 4);
section = Some((at, y >> 4, reaches));
reaches
let (y0, y1) = (lo.y.max(sy << 4), hi.y.min((sy << 4) + 15));
if !reaches {
// Nothing in the section reaches out of its cell: only the inner cells matter.
for z in z0.max(lo.z + 1)..=z1.min(hi.z - 1) {
for y in y0.max(lo.y + 1)..=y1.min(hi.y - 1) {
for x in x0.max(lo.x + 1)..=x1.min(hi.x - 1) {
if self.binding.may_collide(chunk.block(x, y, z)) {
return false;
}
}
}
};
if reaches {
return false;
}
continue;
}
if self.binding.may_collide(chunk.block(x, y, z)) {
return false;
for z in z0..=z1 {
let outer_z = z == lo.z || z == hi.z;
for y in y0..=y1 {
let outer_y = y == lo.y || y == hi.y;
for x in x0..=x1 {
let outer = [x == lo.x || x == hi.x, outer_y, outer_z];
let kind = outer.into_iter().filter(|&o| o).count();
if kind == 3 {
continue;
}
if kind != 0 {
if reaches {
return false;
}
continue;
}
if self.binding.may_collide(chunk.block(x, y, z)) {
return false;
}
}
}
}
}
}
@@ -473,6 +481,23 @@ impl SceneCollisions<'_, '_> {
true
}
/// Whether every block a section's palette holds has an empty collision shape (air, plants);
/// false for a palette of the whole registry or a section outside the column.
fn section_blocks_all_empty(&self, chunk: &crate::chunk::Chunk, section_y: i32) -> bool {
let blocks = usize::try_from(section_y - (chunk.min_y() >> 4))
.ok()
.and_then(|s| chunk.sections.get(s))
.map(|section| &section.blocks);
blocks.is_some_and(|blocks| {
!blocks.is_global()
&& !blocks.maybe_has(|v| {
self.binding
.collision_block(BlockStateId(v))
.map_or(true, |block| !block.empty_shape)
})
})
}
/// `may_reach_out`: whether a section's palette holds a block that reaches out of
/// its cell; a global palette, or a section outside the column, may.
fn section_reaches_out(&self, chunk: &crate::chunk::Chunk, section_y: i32) -> bool {
@@ -503,18 +528,7 @@ impl<'w> BlockCollisionWorld for SceneCollisions<'w, '_> {
/// From the section's palette: every block it can hold has an empty shape (air, plants).
/// A palette of the whole registry, or a section outside the column, is read cell by cell.
fn section_is_empty(&mut self, chunk: Self::Chunk, section_y: i32) -> io::Result<bool> {
let blocks = usize::try_from(section_y - (chunk.min_y() >> 4))
.ok()
.and_then(|s| chunk.sections.get(s))
.map(|section| &section.blocks);
Ok(blocks.is_some_and(|blocks| {
!blocks.is_global()
&& !blocks.maybe_has(|v| {
self.binding
.collision_block(BlockStateId(v))
.map_or(true, |block| !block.empty_shape)
})
}))
Ok(self.section_blocks_all_empty(chunk, section_y))
}
/// The resident columns do not change while a scan runs, and looking one up changes nothing.
fn missing_chunks_stay_missing(&self) -> bool {
@@ -999,6 +1013,76 @@ mod shulker_collision_tests {
mod section_tests {
use super::*;
impl SceneCollisions<'_, '_> {
/// `cells_without_collision` as it was before it skipped sections: every cell of the box, one at a time.
fn cells_without_collision_by_cell(&self, bounds: Aabb) -> bool {
let (min, max) = (bounds.min_position(), bounds.max_position());
if !min.is_finite() || !max.is_finite() {
return false;
}
// The scan's cells: one past the box on every side (`block_collision_scan::cells`).
let lo =
BlockPos::containing(Vec3::new(min.x - 1.0e-7, min.y - 1.0e-7, min.z - 1.0e-7))
.offset(-1, -1, -1);
let hi =
BlockPos::containing(Vec3::new(max.x + 1.0e-7, max.y + 1.0e-7, max.z + 1.0e-7))
.offset(1, 1, 1);
let size = |a: i32, b: i32| i64::from(b) - i64::from(a) + 1;
let cells = size(lo.x, hi.x) * size(lo.y, hi.y) * size(lo.z, hi.z);
if lo.x > hi.x || lo.y > hi.y || lo.z > hi.z || cells > 4096 {
return false;
}
let mut column: Option<((i32, i32), Option<&crate::chunk::Chunk>)> = None;
let mut section: Option<((i32, i32), i32, bool)> = None;
for z in lo.z..=hi.z {
for y in lo.y..=hi.y {
for x in lo.x..=hi.x {
let outer = [
x == lo.x || x == hi.x,
y == lo.y || y == hi.y,
z == lo.z || z == hi.z,
];
let kind = outer.into_iter().filter(|&o| o).count();
if kind == 3 {
continue;
}
let at = (x >> 4, z >> 4);
let chunk = match column {
Some((c, chunk)) if c == at => chunk,
_ => {
let chunk = self.world.chunks.get(&at).map(|c| &**c);
column = Some((at, chunk));
chunk
}
};
// The scan passes the cells of a column that is not resident.
let Some(chunk) = chunk else {
continue;
};
if kind != 0 {
let reaches = match section {
Some((c, sy, reaches)) if c == at && sy == y >> 4 => reaches,
_ => {
let reaches = self.section_reaches_out(chunk, y >> 4);
section = Some((at, y >> 4, reaches));
reaches
}
};
if reaches {
return false;
}
continue;
}
if self.binding.may_collide(chunk.block(x, y, z)) {
return false;
}
}
}
}
true
}
}
/// Boxes swept through air, past torches (no collision shape), a fence (a shape
/// reaching into the cell above) and the scene's stone: wherever the cells say no
/// block can collide, the full resolution returns the movement, to the bit; the rest
@@ -1056,6 +1140,11 @@ mod section_tests {
(next() - 0.5) * reach,
);
let fast = scene.cells_without_collision(bb.expand_towards(d));
assert_eq!(
fast,
scene.cells_without_collision_by_cell(bb.expand_towards(d)),
"{bb:?} by {d:?}"
);
let mut full = SceneCollisions {
world: &world,
binding: &binding,
+47 -1
View File
@@ -316,7 +316,10 @@ impl SkullSkin {
/// The item slots of the container `name`'s block carries, if any.
fn container_size(name: &str) -> Option<usize> {
match name {
"minecraft:chest" | "minecraft:trapped_chest" | "minecraft:barrel" => Some(27),
"minecraft:chest"
| "minecraft:trapped_chest"
| "minecraft:barrel"
| "minecraft:ender_chest" => Some(27),
n if n.ends_with("copper_chest") => Some(27),
"minecraft:hopper" => Some(HOPPER_SLOTS),
"minecraft:dispenser" | "minecraft:dropper" => Some(9),
@@ -453,6 +456,14 @@ impl BlockEntity {
if name == "minecraft:daylight_detector" {
return Some(Self::DaylightDetector);
}
if name == "minecraft:ender_chest" {
// EnderChestBlock.newBlockEntity: 27 slots, player-specific inventory.
return Some(Self::Chest(vec![None; 27]));
}
if name == "minecraft:end_portal" {
// EndPortalBlock.newBlockEntity: no data, only identity.
return Some(Self::EndPortal);
}
if name.ends_with("copper_golem_statue") {
return Some(Self::CopperGolemStatue(Default::default()));
}
@@ -547,6 +558,7 @@ impl BlockEntity {
Self::DaylightDetector => name == "minecraft:daylight_detector",
Self::Bell => name == "minecraft:bell",
Self::Conduit(_) => name == "minecraft:conduit",
Self::EndPortal => name == "minecraft:end_portal",
Self::EnchantingTable => name == "minecraft:enchanting_table",
Self::StructureBlock(_) => name == "minecraft:structure_block",
Self::TestBlock(_) => name == "minecraft:test_block",
@@ -588,6 +600,7 @@ impl BlockEntity {
Self::DaylightDetector => "minecraft:daylight_detector",
Self::Bell => "minecraft:bell",
Self::Conduit(_) => "minecraft:conduit",
Self::EndPortal => "minecraft:end_portal",
Self::EnchantingTable => "minecraft:enchanting_table",
Self::Brushable(_) => "minecraft:brushable_block",
Self::Campfire(_) => "minecraft:campfire",
@@ -625,6 +638,7 @@ impl BlockEntity {
| Self::DaylightDetector
| Self::Bell
| Self::Conduit(_)
| Self::EndPortal
| Self::EnchantingTable
| Self::Brushable(_) => Vec::new(),
Self::Mod(m) => m.items.iter().flatten().cloned().collect(),
@@ -658,6 +672,7 @@ impl BlockEntity {
| Self::DaylightDetector
| Self::Bell
| Self::Conduit(_)
| Self::EndPortal
| Self::EnchantingTable
| Self::Brushable(_) => &[],
Self::Jukebox(j) => &j.items,
@@ -696,6 +711,7 @@ impl BlockEntity {
| Self::DaylightDetector
| Self::Bell
| Self::Conduit(_)
| Self::EndPortal
| Self::EnchantingTable
| Self::Brushable(_) => &mut [],
Self::Jukebox(j) => &mut j.items,
@@ -949,4 +965,34 @@ mod tests {
assert!((250..450).contains(&paid), "{paid}");
assert!((0..100).all(|_| (1..=2).contains(&create_experience(1, 1.7, &mut random))));
}
/// An ender chest placed with /setblock gets its block entity (`EnderChestBlock.newBlockEntity`),
/// so `/data get block` answers with the entity and the chunk saves it.
#[test]
fn an_ender_chest_starts_as_a_block_entity() {
let entity = BlockEntity::for_block("minecraft:ender_chest").expect("an ender chest");
// `Chest` variant with 27 slots; `id()` reports the chest kind, block name comes from NBT.
assert_eq!(entity.id(), "minecraft:chest");
assert_eq!(entity.slots().len(), 27);
assert!(entity.slots()[0].is_none());
assert!(matches!(entity, BlockEntity::Chest(_)));
// `fits`: an ender chest entity fits an ender chest block.
assert!(entity.fits("minecraft:ender_chest"));
// A stone block is not an ender chest.
assert!(BlockEntity::for_block("minecraft:stone").is_none());
}
/// An end portal placed with /setblock gets its block entity (`EndPortalBlock.newBlockEntity`),
/// so `/data get block` answers with the entity and the chunk saves it.
#[test]
fn an_end_portal_starts_as_a_block_entity() {
let entity = BlockEntity::for_block("minecraft:end_portal").expect("an end portal");
assert_eq!(entity.id(), "minecraft:end_portal");
assert_eq!(entity.slots().len(), 0);
assert!(matches!(entity, BlockEntity::EndPortal));
// `fits`: an end portal entity fits an end portal block.
assert!(entity.fits("minecraft:end_portal"));
// A stone block is not an end portal.
assert!(BlockEntity::for_block("minecraft:stone").is_none());
}
}
@@ -479,6 +479,8 @@ impl Screen for ControllerScreen {
.collect(),
})
.collect(),
listed: true,
heading: None,
};
rows().into_iter().map(sheet).collect()
}
@@ -532,8 +534,9 @@ impl Screen for ControllerScreen {
true
}
/// This player's controller settings, bindings included, as the screen found them.
fn undo(&mut self, s: &mut Settings, before: &Settings) {
fn undo(&mut self, s: &mut Settings, before: &Settings) -> Vec<Output> {
s.controller = before.controller.clone();
Vec::new()
}
/// Undo is there exactly while the controller settings differ from what the screen opened
/// with: a binding the game made while the row waited counts, a value stepped back doesn't.
+26 -14
View File
@@ -10,7 +10,7 @@ use std::sync::Mutex;
use std::sync::atomic::{AtomicBool, Ordering};
use chrono::{DateTime, Local};
use horizon_telemetry::system::machine_arch;
use horizon_system::{command_output, machine_arch};
/// What the game is doing, set as it goes: graphics adapter, settings, packs, world.
static DETAILS: Mutex<Vec<(&'static str, String)>> = Mutex::new(Vec::new());
@@ -230,15 +230,6 @@ pub fn release_tag() -> Option<String> {
.then(|| folder.to_owned())
}
fn command_output(program: &str, args: &[&str]) -> Option<String> {
let out = std::process::Command::new(program)
.args(args)
.output()
.ok()?;
let text = String::from_utf8_lossy(&out.stdout).trim().to_owned();
(out.status.success() && !text.is_empty()).then_some(text)
}
fn read(path: &str) -> Option<String> {
std::fs::read_to_string(path).ok()
}
@@ -269,7 +260,7 @@ fn operating_system() -> String {
} else if cfg!(target_os = "macos") {
command_output("sw_vers", &["-productVersion"]).map(|v| format!("macOS {v}"))
} else if cfg!(windows) {
horizon_telemetry::system::windows_version()
horizon_system::windows_version()
} else {
None
};
@@ -284,11 +275,11 @@ fn operating_system() -> String {
pub fn cpu() -> String {
let threads = std::thread::available_parallelism().map_or(0, |n| n.get());
let name = if cfg!(target_os = "linux") {
read("/proc/cpuinfo").and_then(|t| horizon_telemetry::system::linux_cpu_name(&t))
read("/proc/cpuinfo").and_then(|t| horizon_system::linux_cpu_name(&t))
} else if cfg!(target_os = "macos") {
command_output("sysctl", &["-n", "machdep.cpu.brand_string"])
} else if cfg!(windows) {
horizon_telemetry::system::windows_cpu()
horizon_system::windows_cpu()
} else {
std::env::var("PROCESSOR_IDENTIFIER").ok()
};
@@ -387,6 +378,21 @@ fn thread_name(side: &str) -> String {
/// promise to launchers (docs/api/launch.md), so it never changes within a line of that API.
pub const SAVED_LINE: &str = "Horizon: crash report saved to ";
/// What runs on each report before it is saved ([`on_report`]).
pub type ReportHook = fn(&Path, &Report);
static REPORT_HOOKS: std::sync::RwLock<Vec<ReportHook>> = std::sync::RwLock::new(Vec::new());
/// Runs `hook` with every crash report, before it is saved: the game folder and the report. An
/// extension point for what lives above the engine (Horizon's opt-in telemetry keeps the
/// report). A hook runs inside the crash handler: it must not panic or block.
pub fn on_report(hook: ReportHook) {
REPORT_HOOKS
.write()
.unwrap_or_else(|e| e.into_inner())
.push(hook);
}
/// Writes the report and tells where it is; the log gets the error too.
fn deliver(home: &Path, report: Report) -> Option<PathBuf> {
if REPORTED.swap(true, Ordering::SeqCst) {
@@ -407,7 +413,13 @@ fn deliver(home: &Path, report: Report) -> Option<PathBuf> {
),
);
crate::logging::flush();
crate::telemetry::keep_crash(home, &report);
for hook in REPORT_HOOKS
.read()
.unwrap_or_else(|e| e.into_inner())
.iter()
{
hook(home, &report);
}
// A player of a shared game folder (`--player-dir`) finds the report beside its own log.
match report.save(crate::logging::player_dir_override().unwrap_or(home)) {
Ok(path) => {
@@ -661,6 +661,8 @@ pub struct DataStore {
pub predicates: BTreeMap<String, serde_json::Value>,
/// `item_modifier` files, for `/item modify`.
pub item_modifiers: BTreeMap<String, serde_json::Value>,
/// `slot_source` definitions, evaluated against a command's live accessor.
pub slot_sources: BTreeMap<String, serde_json::Value>,
/// The content tables' raw files from the packs after the built-in one:
/// `recipe/`, `recipe_filter/`, `loot_table/`, `advancement/`, `dialog/`, `damage_type/`, `data_maps/`, `enchantment/` and `tags/…/` files, by
/// `<kind-dir>/<ns>:<path>`, each id's files in pack order. The tables
@@ -720,6 +722,7 @@ impl DataStore {
"predicate",
"recipe",
"recipe_filter",
"slot_source",
"tags",
"trade_set",
"villager_trade",
@@ -756,6 +759,11 @@ impl DataStore {
&& let Ok(v) = serde_json::from_slice(&bytes)
{
s.item_modifiers.insert(id, v);
} else if let Some(id) = resource_id(&f, "slot_source", ".json")
&& let Some(bytes) = pack.read(&f)
&& let Ok(v) = serde_json::from_slice(&bytes)
{
s.slot_sources.insert(id, v);
} else if !builtin
&& let Some(id) = content_id(&f)
&& let Some(bytes) = pack.read(&f)
+9 -1
View File
@@ -432,7 +432,15 @@ fn celestial_color(in: Out) -> vec4<f32> {
linear_fog(in.fog_distance.y, globals.fog.z, globals.fog.w));
return in.color * (1.0 - fog);
}
return textureSample(atlas, atlas_sampler, in.uv) * in.color * in.light_color;
let color = textureSample(atlas, atlas_sampler, in.uv) * in.color * in.light_color;
if in.overlay == 45u {
// A Polytone custom particle drawn additively: the fog over its colour, as vanilla's
// particle.fsh, under the additive blend (no alpha cutout: its faint texels add up).
let f = max(linear_fog(in.fog_distance.x, globals.fog.x, globals.fog.y),
linear_fog(in.fog_distance.y, globals.fog.z, globals.fog.w));
return vec4(mix(color.rgb, globals.fog_color.rgb, f * globals.fog_color.a), color.a);
}
return color;
}
@fragment fn fs_celestial(in: Out) -> @location(0) vec4<f32> {
let color = celestial_color(in);
+50 -54
View File
@@ -8,7 +8,7 @@ use horizon_world::environment_attributes::{
use horizon_world::environment_system::{BiomeBlend, Builder, Conditions, System};
use horizon_world::environment_timeline::Timeline;
use serde_json::Value as Json;
use std::collections::{HashMap, HashSet};
use std::collections::HashMap;
use std::path::Path;
use std::sync::{Arc, RwLock};
@@ -169,6 +169,14 @@ pub struct Frame {
pub downfall: f32,
}
impl Frame {
/// `g.rain`: the mean of the rain and thunder levels, so plain rain is 0.5 and a
/// thunderstorm 1 (observed on the real mod: a pack's `g.rain` colormap row).
pub fn global_rain(&self) -> f64 {
(f64::from(self.rain) + f64::from(self.thunder)) / 2.0
}
}
/// The game side of a pack expression in an attribute value: `g`, `p`, `c`, `o` (the camera's
/// position) and `r`, `v` (the value so far) and the pack's `config`.
struct FrameHost<'a> {
@@ -178,6 +186,21 @@ struct FrameHost<'a> {
seed: u64,
}
/// `program`'s value for the frame (a pack's expression outside attribute layers: its `v` is 0).
pub(crate) fn eval_frame(
program: &horizon_polytone::expr::Program,
frame: &Frame,
configs: &horizon_polytone::pack::Configs,
) -> Option<horizon_polytone::expr::Value> {
let mut host = FrameHost {
frame,
configs,
value: 0.0,
seed: frame.game_time as u64,
};
program.eval(&mut host).ok()
}
impl horizon_polytone::expr::Host for FrameHost<'_> {
fn root(&mut self, root: horizon_polytone::expr::Root) -> horizon_polytone::expr::Value {
use horizon_polytone::expr::{Root, Value as V};
@@ -197,7 +220,7 @@ impl horizon_polytone::expr::Host for FrameHost<'_> {
let on = |root: Root| *target == V::Object(root as u32);
let num = |n: f64| Ok(V::Num(n));
match name {
"rain" if on(Root::Global) => num(f64::from(f.rain)),
"rain" if on(Root::Global) => num(f.global_rain()),
"time" if on(Root::Global) => num(f.game_time as f64),
"dayTime" if on(Root::Global) => num(f.day_time as f64),
"skyType" if on(Root::Global) => num(f64::from(f.sky_type)),
@@ -710,43 +733,6 @@ impl Resolver {
}
}
/// The biome ids a `#tag` (`data/<namespace>/tags/worldgen/biome/<path>.json`) holds, nested
/// tags included.
fn biome_tag(
root: &Path,
tag: &str,
cache: &mut HashMap<String, HashSet<String>>,
) -> HashSet<String> {
if let Some(hit) = cache.get(tag) {
return hit.clone();
}
cache.insert(tag.to_owned(), HashSet::new());
let (namespace, path) = tag.split_once(':').unwrap_or(("minecraft", tag));
let file = root.join(format!("data/{namespace}/tags/worldgen/biome/{path}.json"));
let mut out = HashSet::new();
if let Ok(json) = horizon_world::json_file::read::<Json>(&file) {
for value in json["values"].as_array().into_iter().flatten() {
let id = value
.as_str()
.or_else(|| value["id"].as_str())
.unwrap_or_default();
match id.strip_prefix('#') {
Some(inner) => out.extend(biome_tag(root, inner, cache)),
None if !id.is_empty() => {
out.insert(if id.contains(':') {
id.to_owned()
} else {
format!("minecraft:{id}")
});
}
None => {}
}
}
}
cache.insert(tag.to_owned(), out.clone());
out
}
/// The timeline ids a dimension type's `timelines` names: an id, a `#tag` or a list,
/// tags resolved through `tags/timeline/` (their entries may be `{"id", "required"}`).
fn timeline_ids(root: &Path, json: &Json, out: &mut Vec<String>, depth: u32) {
@@ -800,10 +786,14 @@ impl Environment {
&root.join(format!("data/minecraft/dimension_type/{dimension}.json")),
)?;
let id = format!("minecraft:{dimension}");
let tags = crate::pack_tags::Tags::new(root);
let mine: Vec<_> = packs
.dimensions
.iter()
.filter(|m| m.targets.matches(&id, |_| false))
.filter(|m| {
m.targets
.matches(&id, |tag| tags.contains("dimension_type", tag, &id))
})
.collect();
let frame = Arc::new(RwLock::new(Frame::default()));
let resolver = Resolver {
@@ -826,7 +816,6 @@ impl Environment {
// Without biome data (a bare dimension type), there is no biome layer.
let mut biomes = HashMap::new();
let mut post = HashMap::new();
let tags = std::cell::RefCell::new(HashMap::new());
if let Ok(folder) =
horizon_world::json_folder::read(&root.join("data/minecraft/worldgen/biome"))
{
@@ -842,8 +831,7 @@ impl Environment {
};
let mut post_map = AttributeMap::default();
for m in &packs.biomes {
let in_tag =
|tag: &str| biome_tag(root, tag, &mut tags.borrow_mut()).contains(&name);
let in_tag = |tag: &str| tags.contains("worldgen/biome", tag, &name);
if m.targets.matches(&name, in_tag) {
resolver.apply(&mut map, &m.base);
resolver.apply(&mut post_map, &m.post_process);
@@ -1167,20 +1155,25 @@ mod tests {
r#"{"default_value": 7}"#,
),
]);
let at = |rain: f32, chunks: i32| {
let at = |rain: f32, thunder: f32, chunks: i32| {
env.set_frame(Frame {
rain,
thunder,
render_distance: chunks,
..Frame::default()
});
sky_at(&env, "minecraft:plains", 0.0)
};
// `c.viewDistance` is in blocks: 16 chunks are 256.
assert_eq!(at(0.0, 16).fog_end, 224.0);
assert_eq!(at(0.25, 32).fog_end, 498.0);
assert_eq!(at(0.0, 0.0, 16).fog_end, 224.0);
// `g.rain` is the mean of rain and thunder: half rain is 0.25 (448 + 50), plain rain
// 0.5 and a thunderstorm 1, both past the `min` (224 + 100).
assert_eq!(at(0.5, 0.0, 32).fog_end, 498.0);
assert_eq!(at(1.0, 0.0, 16).fog_end, 324.0);
assert_eq!(at(1.0, 1.0, 16).fog_end, 324.0);
// `v` is the value the layers before gave: the default start 0, and 192.33 for clouds.
assert_eq!(at(0.0, 16).fog_start, 7.0);
assert_eq!(at(0.0, 16).cloud_height, 192.33 * 0.5);
assert_eq!(at(0.0, 0.0, 16).fog_start, 7.0);
assert_eq!(at(0.0, 0.0, 16).cloud_height, 192.33 * 0.5);
}
/// A PNG of RGBA pixels in one row.
@@ -1215,18 +1208,21 @@ mod tests {
),
("assets/mypack/polytone/colormaps/sky.png", &png),
]);
let sky = |rain: f32| {
let sky = |rain: f32, thunder: f32| {
env.set_frame(Frame {
rain,
thunder,
..Frame::default()
});
env.sample(&Sample::default()).sky_color
};
assert_eq!(sky(0.0), [1.0, 0.0, 0.0]);
assert_eq!(sky(0.3), [0.0, 1.0, 0.0]);
assert_eq!(sky(1.0), [0.0, 0.0, 1.0]);
// A transparent pixel keeps the colour the layers before gave (the dimension's #808080).
assert_eq!(sky(0.7), [128.0 / 255.0; 3]);
// `g.rain` 0, 0.3 (rain 0.6), 1 (a thunderstorm).
assert_eq!(sky(0.0, 0.0), [1.0, 0.0, 0.0]);
assert_eq!(sky(0.6, 0.0), [0.0, 1.0, 0.0]);
assert_eq!(sky(1.0, 1.0), [0.0, 0.0, 1.0]);
// A transparent pixel keeps the colour the layers before gave (the dimension's #808080):
// `g.rain` 0.7.
assert_eq!(sky(1.0, 0.4), [128.0 / 255.0; 3]);
}
#[test]
@@ -107,12 +107,7 @@ impl Screen for ExtrasScreen {
}
}
}
vec![Sheet {
id: (),
name: lang::get(TITLE),
about: lang::get(ABOUT),
groups,
}]
vec![Sheet::new((), lang::get(TITLE), lang::get(ABOUT), groups)]
}
fn press(&mut self, s: &mut Settings, row: ExtraRow, back: bool) -> Vec<Output> {
match row {
+25 -8
View File
@@ -99,10 +99,11 @@ impl FogState {
self.block_light_flicker += (a - b) * c * d * 0.1;
self.block_light_flicker *= 0.9;
}
/// `LocalPlayer.aiStep`: water vision ramps up while the eye is in water.
pub fn tick(&mut self, eye_in_water: bool) {
/// `LocalPlayer.aiStep`: water vision ramps up while the eye is in water, ten times faster
/// for a spectator.
pub fn tick(&mut self, eye_in_water: bool, spectator: bool) {
self.water_vision_time = if eye_in_water {
(self.water_vision_time + 1).min(600)
(self.water_vision_time + if spectator { 10 } else { 1 }).min(600)
} else {
(self.water_vision_time - 10).max(0)
};
@@ -707,11 +708,11 @@ mod tests {
fn water_vision_ramps() {
let mut s = FogState::default();
for _ in 0..100 {
s.tick(true);
s.tick(true, false);
}
assert!((s.water_vision(true) - 0.6).abs() < 1e-6);
for _ in 0..500 {
s.tick(true);
s.tick(true, false);
}
assert_eq!(s.water_vision(true), 1.0);
assert_eq!(s.water_vision(false), 0.0);
@@ -726,17 +727,17 @@ mod tests {
input.fog_type = FogType::Water;
// Just in: vanilla's water vision is 0, so the fog ends at a quarter of 96 blocks.
let mut state = FogState::default();
state.tick(true);
state.tick(true, false);
let first = setup_fog(&tables, &state, &input);
assert_eq!(first.environmental_end, 24.0);
for _ in 0..600 {
state.tick(true);
state.tick(true, false);
}
let settled = setup_fog(&tables, &state, &input);
assert_eq!(settled.environmental_end, 96.0);
input.full_water_vision = true;
let mut fresh = FogState::default();
fresh.tick(true);
fresh.tick(true, false);
let clear = setup_fog(&tables, &fresh, &input);
assert_eq!(clear.environmental_end, settled.environmental_end);
assert_eq!(clear.color, settled.color);
@@ -782,4 +783,20 @@ mod tests {
assert_eq!(fog.environmental_start, -160.0);
assert_eq!(fog.environmental_end, 768.0);
}
#[test]
fn a_spectator_sees_clearly_under_water_ten_times_sooner() {
let mut swimmer = FogState::default();
let mut spectator = FogState::default();
for _ in 0..60 {
swimmer.tick(true, false);
spectator.tick(true, true);
}
// LocalPlayer.getWaterVision after 60 ticks: 60 and 600 ticks of vision.
assert!((swimmer.water_vision(true) - 0.36).abs() < 1e-6);
assert_eq!(spectator.water_vision(true), 1.0);
// Out of the water both lose it 10 a tick.
spectator.tick(false, true);
assert_eq!(spectator.water_vision_time, 590);
}
}
File diff suppressed because it is too large. Load diff
@@ -20,6 +20,10 @@ const MATERIALS: usize = 16;
pub const TERRAIN_MODEL_SOLID_OVERLAY: u32 = 42;
pub const TERRAIN_MODEL_CUTOUT_OVERLAY: u32 = 43;
pub const TERRAIN_MODEL_CUTOUT_MIPPED_OVERLAY: u32 = 44;
/// The overlay of a resource pack's Polytone custom particle drawn additively
/// (`additive_translucent`): fog tints its colour as vanilla's particle shader does, which the
/// other additive geometry does not take.
pub const ADDITIVE_PARTICLE_OVERLAY: u32 = 45;
/// Native terrain-array UV/payloads must never enter a shared-atlas shader-pack pass.
pub fn is_native_terrain(layer: u32) -> bool {
matches!(
@@ -36,7 +36,7 @@ pub(super) struct EntityOutline {
impl EntityOutline {
/// `entity` draws the silhouettes with the entity shader's vertex stage and
/// `fs_outline`; `format` is the frame's.
/// `fs_outline`; `format` is what it draws onto (`Renderer::overlay_format`).
pub(super) fn new(
device: &wgpu::Device,
entity: (
+2 -1
View File
@@ -19,6 +19,7 @@
//! start, and the run is drawn indirectly with the count the copy reached.
use super::SolidPipeline;
use super::arena::GRAIN;
use smallvec::SmallVec;
/// One section draw for `flatten.wgsl`: index count, first sorted index (or
/// `QUADS` for a solid draw; phase 2: its entry in phase 2's draws), base
@@ -825,7 +826,7 @@ impl Flat {
});
self.phase2_group = Some((group, count as u32));
}
let groups: Vec<(wgpu::BindGroup, u32)> = self
let groups: SmallVec<[(wgpu::BindGroup, u32); 8]> = self
.segments
.iter()
.filter_map(|&(source, first, count)| {
+299 -12
View File
@@ -93,6 +93,8 @@ struct Layered {
tonemap: (wgpu::BindGroupLayout, wgpu::RenderPipeline),
keep: (wgpu::BindGroupLayout, wgpu::RenderPipeline),
composite: (wgpu::BindGroupLayout, wgpu::RenderPipeline),
/// Compiled only when a screenshot needs it, for its readback format and HUD mode.
screenshot: Option<(wgpu::TextureFormat, bool, bool, wgpu::RenderPipeline)>,
/// What `tonemap` and `composite` read, written before they run (`begin`).
params: wgpu::Buffer,
}
@@ -187,8 +189,9 @@ pub struct Hdr {
pub ui: f32,
/// Which pixels of a light source shine (`Masks`).
pub masks: Masks,
/// HDR lighting: off while a shader pack's post or material stage runs, the pack's own, and
/// with `HORIZON_HDR_LIGHTING=0`, to compare.
/// HDR lighting, whether Horizon lifts this frame's world: off under a Horizon shader pack
/// that draws, while a `shaders/` pack's post or material stage runs (the pack's own),
/// with HDR Lighting (`look`) off, and with `HORIZON_HDR_LIGHTING=0`, to compare.
pub lighting: bool,
/// The HDR Lighting setting: off, nothing in the world is lifted, light sources and the
/// sun included, so the picture is SDR's on the HDR canvas.
@@ -328,15 +331,23 @@ impl Hdr {
})
};
let layered = coverage.then(|| {
let with = |label, textures: &[u32], params, entry| {
let with = |label, textures: &[u32], params, entry, format| {
let layout = layout(label, textures, params);
let pipeline = pass(label, &layout, entry, format);
(layout, pipeline)
};
let hud = super::hud_coverage::HUD_FORMAT;
Layered {
tonemap: with("Horizon HDR tone curve", &[1], true, "fs_tonemap"),
keep: with("Horizon HDR HUD backdrop", &[0], false, "fs_keep"),
composite: with("Horizon HDR composite", &[0, 4, 5], true, "fs_composite"),
tonemap: with("Horizon HDR tone curve", &[1], true, "fs_tonemap", format),
keep: with("Horizon HDR HUD backdrop", &[0], false, "fs_keep", hud),
composite: with(
"Horizon HDR composite",
&[0, 4, 5],
true,
"fs_composite",
format,
),
screenshot: None,
params: params(),
}
});
@@ -445,12 +456,11 @@ impl Hdr {
wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
)
});
// In the frame's format, so the HUD's pipelines draw on it as on the frame with HDR
// off; the last pass clips it at white.
// 8-bit, so each HUD draw clips and rounds as on the frame with HDR off.
let hud = layered.then(|| {
texture(
"HUD",
self.format,
super::hud_coverage::HUD_FORMAT,
wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
)
});
@@ -512,17 +522,110 @@ impl Hdr {
self.targets.as_ref().unwrap().frame.clone()
}
/// Lets the targets go, a scaled screenshot's: the next `frame` makes them again.
pub fn release_targets(&mut self) {
self.targets = None;
}
/// The float frame with HUD coverage (`Layered`).
pub fn layered(&self) -> bool {
self.layered.is_some()
}
/// The layered frame's HUD target, which the HUD and menus draw on instead of the frame
/// once `keep_world` made it, and which an ordinary screenshot shows.
/// once `keep_world` made it.
pub fn hud(&self) -> Option<&wgpu::Texture> {
self.targets.as_ref()?.hud.as_ref()
}
/// The layered frame's SDR screenshot, directly in the readback's texture; the icon
/// maps only the world. Where Horizon lifted nothing (`lighting` off: HDR Lighting off,
/// or under a shader pack) nothing is mapped. Neither the display's targets nor its
/// parameters are changed.
pub fn sdr_screenshot(
&mut self,
device: &wgpu::Device,
encoder: &mut wgpu::CommandEncoder,
format: wgpu::TextureFormat,
world_only: bool,
) -> Option<wgpu::Texture> {
let shoulder = self.lighting;
let (l, t) = (self.layered.as_mut()?, self.targets.as_ref()?);
if l.screenshot
.as_ref()
.is_none_or(|(f, w, s, _)| (*f, *w, *s) != (format, world_only, shoulder))
{
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("HDR SDR screenshot"),
source: wgpu::ShaderSource::Wgsl(shader().into()),
});
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("HDR SDR screenshot"),
layout: None,
vertex: wgpu::VertexState {
module: &shader,
entry_point: Some("vs_hdr"),
compilation_options: Default::default(),
buffers: &[],
},
primitive: Default::default(),
depth_stencil: None,
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: Some("fs_sdr_screenshot"),
compilation_options: wgpu::PipelineCompilationOptions {
constants: &[
("screenshot_world_only", f64::from(u8::from(world_only))),
("screenshot_shoulder", f64::from(u8::from(shoulder))),
],
..Default::default()
},
targets: &[Some(format.into())],
}),
multiview_mask: None,
cache: None,
});
l.screenshot = Some((format, world_only, shoulder, pipeline));
}
let pipeline = &l.screenshot.as_ref()?.3;
let views = [
(0, &t.frame),
(4, t.coverage.as_ref()?),
(5, t.hud.as_ref()?),
]
.map(|(binding, texture)| (binding, texture.create_view(&Default::default())));
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("HDR SDR screenshot"),
layout: &pipeline.get_bind_group_layout(0),
entries: &views
.each_ref()
.map(|(binding, view)| wgpu::BindGroupEntry {
binding: *binding,
resource: wgpu::BindingResource::TextureView(view),
}),
});
let copy = device.create_texture(&wgpu::TextureDescriptor {
label: Some("Horizon screenshot copy"),
size: t.frame.size(),
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
Self::draw(
encoder,
pipeline,
&group,
&copy.create_view(&Default::default()),
"HDR SDR screenshot",
None,
);
Some(copy)
}
/// On the layered frame, before `tonemap` and `composite`: what they read, for a frame that
/// drew a world (`world`) or not.
pub fn begin(&self, queue: &wgpu::Queue, world: bool) {
@@ -601,6 +704,20 @@ impl Hdr {
)
}
/// The display as a 0.2 pack's `Frame` describes it, with the values the last pass uses:
/// the headroom (the peak over paper white, at least 1), then paper white, the peak and
/// the black level in nits.
pub fn display(&self) -> [f32; 4] {
let paper = self.paper_white.max(1.0);
let headroom = (self.peak / paper).max(1.0);
[
headroom,
paper,
paper * headroom,
self.black.clamp(0.0, paper),
]
}
/// The peak the HDR metadata gives: while calibration's peak step runs, PQ's own
/// 10 000 nits, so a compositor that clips at the declared peak (KWin) lets the test
/// patch through as bright as the display goes and the logo under it shows.
@@ -1142,8 +1259,8 @@ var<private> params: ReferenceParams;
});
}
/// The first texel of `texture` once `encoder` ran: four half floats, or 10-bit PQ codes
/// for HDR10.
/// The first texel of `texture` once `encoder` ran: four half floats, 10-bit PQ codes
/// for HDR10, or 8-bit values over 255 for the HUD's target.
pub(super) fn texel(
device: &wgpu::Device,
queue: &wgpu::Queue,
@@ -1180,6 +1297,9 @@ var<private> params: ReferenceParams;
let pixel = u32::from_le_bytes(bytes[..4].try_into().unwrap());
return [0, 10, 20, 30].map(|shift| ((pixel >> shift) & 0x3ff) as f32);
}
if texture.format() == super::super::hud_coverage::HUD_FORMAT {
return std::array::from_fn(|i| f32::from(bytes[i]) / 255.0);
}
std::array::from_fn(|i| half::f16::from_le_bytes([bytes[2 * i], bytes[2 * i + 1]]).to_f32())
}
@@ -1412,6 +1532,173 @@ struct ReferenceParams { peak: f32, scene: f32, world: f32, debug: f32, share: v
assert!(r == 1.0 && near(g, 0.5) && near(b, 0.2), "{r} {g} {b}");
}
/// What a 0.2 pack's `Frame` reads of the display: the headroom the tone curve eases
/// into, never below 1, and the black Horizon lifts after it, never above paper white.
#[test]
fn the_display_a_pack_reads_is_the_one_the_curve_maps_to() {
let Some((device, queue)) = gpu() else {
return;
};
let mut hdr = Hdr::with_debug(&device, &queue, F::Rgba16Float, Output::Pq, true, None);
(hdr.paper_white, hdr.peak, hdr.black) = (203.0, 1000.0, 0.05);
let [headroom, paper, peak, black] = hdr.display();
assert_eq!((headroom, paper, black), (1000.0 / 203.0, 203.0, 0.05));
assert!((peak - 1000.0).abs() < 1e-3, "{peak}");
// A peak under paper white has no headroom: the display tops out at white.
(hdr.peak, hdr.black) = (150.0, 400.0);
assert_eq!(hdr.display(), [1.0, 203.0, 203.0, 203.0]);
}
#[test]
fn a_scaled_screenshot_shoots_at_its_size_and_leaves_the_window_its_own_targets() {
let Some((device, queue)) = gpu() else {
eprintln!("scaled screenshot GPU unavailable");
return;
};
let mut hdr = Hdr::with_debug(&device, &queue, F::Rgba16Float, Output::Pq, true, None);
let extent = |t: &wgpu::Texture| [t.width(), t.height()];
hdr.frame(&device, [4, 2]);
// `Renderer::draw_scaled_frame`: the frame at the screenshot's size, its SDR picture
// and HDR10 copy at that size too, then the targets let go.
assert_eq!(extent(&hdr.frame(&device, [8, 4])), [8, 4]);
let mut encoder = device.create_command_encoder(&Default::default());
let sdr = hdr
.sdr_screenshot(&device, &mut encoder, F::Bgra8Unorm, false)
.unwrap();
let pq = hdr.capture(&device, &queue, &mut encoder, true).unwrap();
queue.submit([encoder.finish()]);
assert_eq!((extent(&sdr), extent(&pq)), ([8, 4], [8, 4]));
hdr.release_targets();
assert!(hdr.hud().is_none() && hdr.coverage_view().is_none());
// The window's next frame makes its own again.
assert_eq!(extent(&hdr.frame(&device, [4, 2])), [4, 2]);
assert_eq!(extent(hdr.hud().unwrap()), [4, 2]);
}
#[test]
fn sdr_screenshots_roll_off_the_world_and_keep_the_hud_as_drawn() {
let Some((device, queue)) = gpu() else {
eprintln!("SDR screenshot GPU unavailable");
return;
};
let mut hdr = Hdr::with_debug(&device, &queue, F::Rgba16Float, Output::Pq, true, None);
// These display settings must not scale either the SDR world or the HUD.
(hdr.paper_white, hdr.peak, hdr.black, hdr.ui) = (300.0, 1500.0, 0.2, 0.25);
let shoot = |hdr: &mut Hdr, world: [f64; 3], hud: [f64; 3], c: f64, icon| {
let frame = hdr.frame(&device, [1, 1]);
let t = hdr.targets.as_ref().unwrap();
let mut encoder = device.create_command_encoder(&Default::default());
fill(&mut encoder, &frame, [world[0], world[1], world[2], 1.0]);
fill(
&mut encoder,
t.hud.as_ref().unwrap(),
[hud[0], hud[1], hud[2], 0.0],
);
fill(&mut encoder, t.coverage.as_ref().unwrap(), [c; 4]);
// Read half-floats so 8-bit quantization cannot conceal the curve or RGB ratios.
let copy = hdr
.sdr_screenshot(&device, &mut encoder, F::Rgba16Float, icon)
.unwrap();
texel(&device, &queue, encoder, &copy)
};
// Below the linear knee, through the last half-float below it, the stored
// channels survive exactly. All inputs here are exactly representable.
for gamma in [0.0f32, 0.25, 0.5, 0.75, 0.79248047] {
let w = [gamma, gamma / 2.0, 0.0];
let got = shoot(&mut hdr, w.map(f64::from), w.map(f64::from), 0.0, false);
assert_eq!(got[..3], w.map(|v| half::f16::from_f32(v).to_f32()));
}
// Independent numeric checkpoints in linear light, not a CPU copy of the shader.
for (light, expected) in [
(1.0f32, 0.8),
(2.0, 0.9111111),
(4.0, 0.9578947),
(8.0, 0.9794872),
] {
let g = f64::from(light.powf(1.0 / 2.2));
let got = shoot(&mut hdr, [g; 3], [1.0; 3], 0.0, false);
assert!(
(got[0].powf(2.2) - expected).abs() < 0.002,
"{light}: {got:?}"
);
assert!(got[0] < 1.0);
}
let mut last = 0.0;
for i in 0..65 {
let g = f64::from(i) / 16.0;
let got = shoot(
&mut hdr,
[g, g / 2.0, 0.0],
[g.min(1.0), (g / 2.0).min(1.0), 0.0],
0.0,
false,
);
assert!(got[0] >= last && got[0] <= 1.0, "{g}: {got:?}");
assert!((got[0] - 2.0 * got[1]).abs() < 0.002 && got[2] == 0.0);
last = got[0];
}
// Fully covered is exactly the HUD as its 8-bit target holds it, regardless of the
// world or UI Brightness.
let hud = [64.0, 128.0, 192.0].map(|v: f64| v / 255.0);
assert_eq!(
shoot(&mut hdr, [2.0, 1.0, 0.5], hud, 1.0, false),
[hud[0], hud[1], hud[2], 1.0].map(|v| half::f16::from_f64(v).to_f32())
);
// At zero coverage the mapped world; the icon also ignores a fully opaque HUD.
for (hud, c, icon) in [([1.0, 1.0, 0.5], 0.0, false), ([0.25; 3], 1.0, true)] {
let got = shoot(&mut hdr, [2.0, 1.0, 0.5], hud, c, icon);
for (g, expected) in got.into_iter().zip([0.983284, 0.491642, 0.245821, 1.0]) {
assert!((g - expected).abs() < 0.002, "{got:?}");
}
}
// R8's 128/255 coverage: recover the HUD against the clipped backdrop, then
// composite in gamma. The original HUD target and world's light stay intact.
let got = shoot(
&mut hdr,
[2.0, 1.0, 0.5],
[0.75, 0.875, 0.4375],
128.0 / 255.0,
false,
);
for (g, expected) in got.into_iter().zip([0.741675, 0.62182, 0.31091, 1.0]) {
assert!((g - expected).abs() < 0.002, "{got:?}");
}
let t = hdr.targets.as_ref().unwrap();
let encoder = device.create_command_encoder(&Default::default());
assert_eq!(
texel(&device, &queue, encoder, &t.frame),
[2.0, 1.0, 0.5, 1.0]
);
// The HUD's 8-bit target rounds what is drawn on it.
let encoder = device.create_command_encoder(&Default::default());
let stored = [191.0, 223.0, 112.0, 0.0].map(|v| v / 255.0);
let hud = t.hud.as_ref().unwrap();
assert_eq!(texel(&device, &queue, encoder, hud), stored);
// With HDR Lighting off or under a pack, Horizon lifts nothing: the screenshot is the
// SDR picture, clipped at white.
hdr.lighting = false;
for gamma in [0.5f32, 0.9, 1.0] {
let w = [gamma, gamma / 2.0, 0.0];
// Uncovered, the HUD's target holds the world as drawn; the icon ignores the HUD.
let got = shoot(&mut hdr, w.map(f64::from), w.map(f64::from), 0.0, false);
assert_eq!(got[..3], w.map(|v| half::f16::from_f32(v).to_f32()));
let got = shoot(&mut hdr, w.map(f64::from), [0.25; 3], 1.0, true);
assert_eq!(got[..3], w.map(|v| half::f16::from_f32(v).to_f32()));
}
// Partly covered, the 8-bit screenshot is the HUD's target as drawn.
let got = shoot(
&mut hdr,
[1.0, 0.5, 0.0],
[0.75, 0.875, 0.4375],
128.0 / 255.0,
false,
);
assert_eq!(
got.map(|v| (v * 255.0).round()),
[191.0, 223.0, 112.0, 255.0]
);
}
#[test]
fn the_hud_lays_over_the_worlds_light_by_its_coverage() {
let Some((device, queue)) = gpu() else {
@@ -1,6 +1,10 @@
//! HUD coverage beside its unchanged colour and alpha, only on the float HDR lane.
pub(crate) const FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::R8Unorm;
/// The HUD's own target on the layered frame (`Hdr::hud`): 8-bit, so each HUD draw clips at
/// white and rounds as on vanilla's frame. The coverage pipelines are the only ones that draw
/// on it.
pub(crate) const HUD_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
pub(crate) fn supported(
attachments: u32,
@@ -73,8 +77,9 @@ impl Pipeline {
}
}
/// Keep the primary output and all its blend factors, including destination alpha. Wrap the
/// final shader result, so pack text's alpha, early returns and discard all apply to coverage.
/// Keep the primary output and all its blend factors, including destination alpha, on the
/// HUD's target (`HUD_FORMAT`). Wrap the final shader result, so pack text's alpha, early
/// returns and discard all apply to coverage.
/// `None` for a fragment shader without a colour output at location 0.
pub(super) fn pipeline(
device: &wgpu::Device,
@@ -88,7 +93,8 @@ pub(super) fn pipeline(
wgpu::ShaderSource::Naga(m) => m.into_owned(),
_ => return None,
};
let primary = fragment.targets.first()?.clone()?;
let mut primary = fragment.targets.first()?.clone()?;
primary.format = HUD_FORMAT;
let blend = primary.blend.unwrap_or(wgpu::BlendState::REPLACE).color;
let additive = matches!(
(blend.src_factor, blend.dst_factor),
@@ -164,16 +164,24 @@ fn fullscreen_pass(
}
/// The tonemap stage's entries as the renderer draws them: the check's, with the result
/// encoded into the frame's gamma, where the camera overlays and the HUD draw on it.
fn tonemap_entries() -> String {
/// encoded into the frame's gamma, where the camera overlays and the HUD draw on it. A pack's
/// `[tonemap]` is held to `frame.headroom`. Horizon's own (`default`) is a clip at white in
/// SDR; in HDR it leaves the light as it is for Horizon's tone curve (`Hdr::tonemap`), the
/// one HDR without a pack uses.
fn tonemap_entries(default: bool) -> String {
let held = if default {
"select(min(horizon_sanitize(out), vec3<f32>(frame.headroom)), horizon_sanitize(out), frame.hdr == 1u)"
} else {
"min(horizon_sanitize(out), vec3<f32>(frame.headroom))"
};
StageKind::Tonemap.entries(Api::V0_2).replace(
"return vec4<f32>(min(horizon_sanitize(out), vec3<f32>(frame.headroom)), 1.0);",
"return vec4<f32>(horizon_encode(min(horizon_sanitize(out), vec3<f32>(frame.headroom))), 1.0);",
&format!("return vec4<f32>(horizon_encode({held}), 1.0);"),
)
}
/// Horizon's tonemap for a 0.2 pack without `[tonemap]`: SDR's clip at white, which is the
/// entries' hold at `frame.headroom` (1 until 0.2 packs get HDR output).
/// The tonemap of a 0.2 pack without `[tonemap]`: the picture as it is, held as
/// `tonemap_entries` holds Horizon's.
const DEFAULT_TONEMAP: &str =
"fn tonemap(input: TonemapInput) -> vec3<f32> { return input.color; }\n";
@@ -350,6 +358,9 @@ pub struct NativeFrame {
pub api: Api,
pub decoder: Option<Decoder>,
mirror: Option<wgpu::TextureView>,
/// The HDR display a 0.2 pack's picture goes to this frame (`Hdr::display`: headroom,
/// paper white, peak and black level), set before `write`; `None` is SDR.
pub display: Option<[f32; 4]>,
}
/// A depth texture of `size` the world's depth is copied into.
@@ -559,6 +570,7 @@ impl NativeFrame {
api,
decoder: (api == Api::V0_2).then(|| Decoder::new(device)),
mirror: None,
display: None,
last: None,
eye: [f32::NAN; 4],
history_reset: true,
@@ -799,6 +811,13 @@ impl NativeFrame {
if self.api == Api::V0_2 {
inputs.sky_color = decoded(inputs.sky_color);
inputs.fog_color = decoded(inputs.fog_color);
if let Some([headroom, paper_white, peak, black_level]) = self.display {
inputs.hdr = 1;
inputs.headroom = headroom;
inputs.paper_white = paper_white;
inputs.peak = peak;
inputs.black_level = black_level;
}
}
if !self.reflects {
inputs.has_reflection = 0;
@@ -1054,7 +1073,7 @@ impl NativePost {
let source = tonemap.map_or_else(
|| StageKind::Tonemap.prelude(Api::V0_2) + DEFAULT_TONEMAP,
|s| s.source.clone(),
) + &tonemap_entries();
) + &tonemap_entries(tonemap.is_none());
let file = tonemap.map_or("<tonemap>", |s| s.file.as_str());
let module = naga::front::wgsl::parse_str(&source)
.map_err(|e| format!("{file}: {}", e.emit_to_string(&source)))?;
@@ -1118,6 +1137,11 @@ impl NativePost {
})
}
/// Whether the pack has a `[tonemap]` of its own, which replaces Horizon's tone curve.
pub fn tonemaps(&self) -> bool {
self.display.as_ref().is_some_and(|d| d.label.is_some())
}
/// The passes' labels, in order, then the pack's tonemap's.
pub fn labels(&self) -> Vec<&str> {
let tonemap = self.display.as_ref().and_then(|d| d.label.as_deref());
@@ -1557,8 +1581,8 @@ pub fn frame_inputs(
let k = super::native_shadow::distortion(s.distance, s.resolution);
2.0 * s.distance / s.resolution.max(1) as f32 * (1.0 - k)
}),
// Shader API 0.2's display fields, which 0.1 packs don't declare: SDR's values, also
// with HDR on, until HDR output is served for 0.2 packs.
// Shader API 0.2's display fields, which 0.1 packs don't declare: SDR's values, which
// `NativeFrame::display` replaces on an HDR display.
hdr: 0,
headroom: 1.0,
paper_white: 80.0,
@@ -2484,7 +2508,7 @@ file = \"b.wgsl\"
/// an 8-bit step), at alpha 1.
#[test]
fn a_0_2_world_comes_back_through_decode_tonemap_and_encode() {
assert!(tonemap_entries().contains("horizon_encode(min(horizon_sanitize(out)"));
assert!(tonemap_entries(false).contains("horizon_encode(min(horizon_sanitize(out)"));
let Some((device, queue)) = device() else {
return;
};
@@ -2600,4 +2624,188 @@ file = \"b.wgsl\"
);
}
}
/// A 0.2 pack's `Frame` describes the HDR display its picture goes to (`display`), and SDR
/// without one: `hdr` 0, `headroom` 1, `paper_white` and `peak` 80, `black_level` 0.
#[test]
fn a_0_2_frame_describes_the_display_it_goes_to() {
let Some((device, _)) = device() else {
return;
};
let hud = crate::hud::HudBatch::new([4, 4]);
let frame = crate::gfx::pack_uniforms::initial_history_tests::frame(&hud);
let mut native = NativeFrame::new(
&device,
None,
Vec::new(),
Default::default(),
None,
Api::V0_2,
);
let fields = |i: &FrameInputs| (i.hdr, i.headroom, i.paper_white, i.peak, i.black_level);
assert_eq!(
fields(&native.next_inputs(&frame, [4, 4])),
(0, 1.0, 80.0, 80.0, 0.0)
);
native.display = Some([4.0, 250.0, 1000.0, 0.05]);
assert_eq!(
fields(&native.next_inputs(&frame, [4, 4])),
(1, 4.0, 250.0, 1000.0, 0.05)
);
// Read every frame: a change of display shows on the next one.
native.display = Some([2.0, 300.0, 600.0, 0.0]);
assert_eq!(fields(&native.next_inputs(&frame, [4, 4])).1, 2.0);
native.display = None;
assert_eq!(fields(&native.next_inputs(&frame, [4, 4])).0, 0);
}
/// On the layered HDR frame a 0.2 pack's light is tone mapped once: by Horizon's curve,
/// the one HDR without a pack uses, when the pack has no `[tonemap]`, else by the pack's
/// alone, held to the headroom. In SDR Horizon's is a clip at white.
#[test]
fn a_0_2_pack_is_tone_mapped_once_by_its_own_curve_or_horizons() {
let Some((device, queue)) = device() else {
return;
};
// Light 8 times paper white, as the world's float frame stores it, in gamma.
let light = 8.0f32;
let (paper, peak) = (203.0, 1000.0);
let headroom = peak / paper;
// The picture shown for that light, in linear light: (a pack's [tonemap], HDR).
let shown = |pack_curve: bool, hdr: bool| {
let (stage, file, source) = if pack_curve {
(
"[tonemap]",
"t.wgsl",
"fn tonemap(input: TonemapInput) -> vec3<f32> { return input.color; }\n",
)
} else {
(
"[sky]",
"s.wgsl",
"fn sky(input: SkyInput) -> vec4<f32> { return input.color; }\n",
)
};
let manifest = format!(
"schema = 1\nid = \"t\"\nname = \"T\"\nversion = \"1\"\napi = \"0.2\"\n\
{stage}\nfile = \"{file}\"\n"
);
let files = [(MANIFEST, manifest.as_str()), (file, source)]
.into_iter()
.map(|(n, t)| (n.to_owned(), t.as_bytes().to_vec()))
.collect();
let report = check(&Pack::from_files(files).unwrap()).unwrap();
let tonemap = report.stages.iter().find(|s| s.kind == StageKind::Tonemap);
let format = wgpu::TextureFormat::Rgba16Float;
let frame = NativeFrame::new(
&device,
None,
Vec::new(),
Default::default(),
None,
Api::V0_2,
);
let inputs = FrameInputs {
hdr: u32::from(hdr),
headroom: if hdr { headroom } else { 1.0 },
..Default::default()
};
queue.write_buffer(&frame.buffer, 0, bytemuck::bytes_of(&inputs));
let mut post = NativePost::new(
&device,
Vec::new(),
&report.manifest,
&[],
format,
&frame,
tonemap,
)
.unwrap();
assert_eq!(post.tonemaps(), pack_curve);
let mut display = super::super::hdr::Hdr::new(
&device,
&queue,
format,
super::super::hdr::Output::Pq,
true,
);
(display.paper_white, display.peak) = (paper, peak);
let screen = display.frame(&device, [4, 4]);
let usage =
wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING;
let color = texture(&device, format, usage);
let depth = texture(&device, wgpu::TextureFormat::Depth32Float, usage);
let [color, depth] = [&color, &depth].map(|t| t.create_view(&Default::default()));
let mut encoder = device.create_command_encoder(&Default::default());
let g = f64::from(light.powf(1.0 / 2.2));
drop(encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &color,
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color {
r: g,
g,
b: g,
a: 1.0,
}),
store: wgpu::StoreOp::Store,
},
})],
..Default::default()
}));
let view = screen.create_view(&Default::default());
let world = (&color, &depth, &frame.surface_view);
post.run(&device, &mut encoder, world, &view, [4, 4], true, None);
// The renderer's rule, then its curve, as it runs them after the pack's chain.
if crate::gfx::horizon_tone_curve(hdr, true, post.tonemaps()) {
display.begin(&queue, true);
display.tonemap(&mut encoder, None);
}
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: 256,
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
mapped_at_creation: false,
});
encoder.copy_texture_to_buffer(
screen.as_image_copy(),
wgpu::TexelCopyBufferInfo {
buffer: &buffer,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(256),
rows_per_image: None,
},
},
wgpu::Extent3d {
width: 1,
height: 1,
depth_or_array_layers: 1,
},
);
queue.submit([encoder.finish()]);
buffer.map_async(wgpu::MapMode::Read, .., |r| r.unwrap());
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
let bytes = buffer.get_mapped_range(..).unwrap();
half::f16::from_le_bytes([bytes[0], bytes[1]])
.to_f32()
.powf(2.2)
};
let near = |got: f32, want: f32| (got - want).abs() <= 0.01 * want;
// Horizon's curve once: BT.2390's EETF at 203 and 1000 nits puts light 8 at 4.82
// (worked out apart from the shader, as in the HDR tone curve's own test). Clipped at
// the headroom first, or mapped twice, it would land lower.
let horizon = shown(false, true);
assert!(near(horizon, 4.82043), "{horizon}");
// The pack's identity curve alone: the light held to the headroom, not eased again.
let pack = shown(true, true);
assert!(near(pack, headroom), "{pack}");
// SDR: a clip at white, the pack's or Horizon's.
for pack_curve in [false, true] {
let sdr = shown(pack_curve, false);
assert!(near(sdr, 1.0), "{pack_curve}: {sdr}");
}
}
}
@@ -765,6 +765,8 @@ impl PackAtlas {
item_id_declared: false,
far_depth: false,
paged_maps: false,
water_mask: false,
water_patches: false,
}
}
}
@@ -330,8 +330,30 @@ fn sample(
},
count: None,
};
let samplers: Vec<wgpu::BindGroupLayoutEntry> = (program.interface.samplers.iter())
.flat_map(|s| {
[
wgpu::BindGroupLayoutEntry {
binding: s.binding,
visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
ty: wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Float { filterable: false },
view_dimension: wgpu::TextureViewDimension::D2,
multisampled: false,
},
count: None,
},
wgpu::BindGroupLayoutEntry {
binding: s.binding + 1,
visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::NonFiltering),
count: None,
},
]
})
.collect();
let entries = [
vec![binding(0, false)],
[vec![binding(0, false)], samplers].concat(),
if flat {
vec![binding(0, true), binding(1, true)]
} else {
@@ -367,20 +389,54 @@ fn sample(
vec![]
},
];
// The program's samplers (water's boats' patches, `horizonWaterMask`): a 1x1 stand-in each,
// as the pack binds when there is none, its sampler beside it.
let stand_in = device
.create_texture(&wgpu::TextureDescriptor {
label: None,
size: wgpu::Extent3d {
width: 1,
height: 1,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::R32Float,
usage: wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[],
})
.create_view(&Default::default());
let nearest = device.create_sampler(&Default::default());
let samplers = &program.interface.samplers;
let groups: Vec<_> = layouts
.iter()
.zip(bindings)
.map(|(layout, buffers)| {
.enumerate()
.map(|(group, (layout, buffers))| {
let mut entries: Vec<_> = buffers
.into_iter()
.map(|(binding, buffer)| wgpu::BindGroupEntry {
binding,
resource: buffer.as_entire_binding(),
})
.collect();
if group == 0 {
for s in samplers {
entries.push(wgpu::BindGroupEntry {
binding: s.binding,
resource: wgpu::BindingResource::TextureView(&stand_in),
});
entries.push(wgpu::BindGroupEntry {
binding: s.binding + 1,
resource: wgpu::BindingResource::Sampler(&nearest),
});
}
}
device.create_bind_group(&wgpu::BindGroupDescriptor {
label: None,
layout,
entries: &buffers
.into_iter()
.map(|(binding, buffer)| wgpu::BindGroupEntry {
binding,
resource: buffer.as_entire_binding(),
})
.collect::<Vec<_>>(),
entries: &entries,
})
})
.collect();
@@ -129,6 +129,8 @@ pub fn inputs() -> horizon_shaders::GbuffersInputs {
far_depth: false,
// Entities, items and block entities read the shared atlas: its own maps (paged_maps.rs).
paged_maps: true,
water_mask: false,
water_patches: false,
}
}
@@ -193,6 +195,8 @@ pub fn cloud_inputs() -> horizon_shaders::GbuffersInputs {
item_id_declared: false,
far_depth: false,
paged_maps: false,
water_mask: false,
water_patches: false,
}
}
@@ -227,6 +231,9 @@ pub(super) struct Draw {
pub slot: u32,
/// End portal or gateway faces: drawn with `end_portal.png` bound as the atlas.
pub portal: bool,
/// A boat's water patch (`WATER_MASK_OVERLAY`): depth only, with the program's pipeline that
/// writes no colour.
pub water_mask: bool,
}
/// Which entity runs a pack's shadow pass draws (2.4, 3.2).
@@ -455,12 +462,14 @@ impl PackEntities {
casts,
overlay,
false,
false,
);
}
}
let first = spans.first().map_or(entities.len(), |s| s.start as usize);
extend_without_portals(rest, &entities[..first.min(entities.len())]);
let (mut other, mut portal_faces, mut beam_faces) = (Vec::new(), Vec::new(), Vec::new());
let mut water_patches = Vec::new();
let mut without_fluids = Vec::new();
for (span, range, material) in material_runs(entities, spans, drawn) {
let run_start = range.start as u32;
@@ -490,19 +499,27 @@ impl PackEntities {
// gateway: its cube and its beam): each goes to its own program.
// Beacon beams come in a run of their own, at stage NONE (game.rs: no stage of 5.4
// is theirs), for gbuffers_beaconbeam.
let split = (matches!(material, Some(BLOCK | BLOCK_TRANSLUCENT))
let split = ((matches!(material, Some(BLOCK | BLOCK_TRANSLUCENT))
|| span.stage == render_stage("NONE"))
&& whole
.chunks(4)
.any(|q| matches!(q[0].layer & 0xffff, 6 | 9 | 10));
let parts: [(&[Vertex], Part); 3] = if split {
.any(|q| matches!(q[0].layer & 0xffff, 6 | 9 | 10)))
// A boat's water patch: depth only (`Part::WaterMask`).
|| whole
.chunks(4)
.any(|q| q[0].layer & 0xffff == super::entity_batch::WATER_MASK_OVERLAY);
let parts: [(&[Vertex], Part); 4] = if split {
other.clear();
portal_faces.clear();
beam_faces.clear();
water_patches.clear();
for quad in whole.chunks(4) {
match quad[0].layer & 0xffff {
9 | 10 => portal_faces.extend_from_slice(quad),
6 => beam_faces.extend_from_slice(quad),
super::entity_batch::WATER_MASK_OVERLAY => {
water_patches.extend_from_slice(quad)
}
_ => other.extend_from_slice(quad),
}
}
@@ -510,9 +527,15 @@ impl PackEntities {
(&other, Part::Plain),
(&portal_faces, Part::Portal),
(&beam_faces, Part::Beam),
(&water_patches, Part::WaterMask),
]
} else {
[(whole, Part::Plain), (&[], Part::Plain), (&[], Part::Plain)]
[
(whole, Part::Plain),
(&[], Part::Plain),
(&[], Part::Plain),
(&[], Part::Plain),
]
};
for (run, part) in parts {
if run.is_empty() {
@@ -521,6 +544,8 @@ impl PackEntities {
let program = match part {
Part::Portal => Some(BLOCK),
Part::Beam => Some(BEAM),
// Drawn by the entity program, whose depth-only pipeline it takes.
Part::WaterMask => material.filter(|&p| p == ENTITY),
Part::Plain => material.filter(|&p| {
plain(run)
|| (p == DESTROY && run.iter().all(|v| matches!(v.layer, 11 | 12)))
@@ -529,7 +554,8 @@ impl PackEntities {
let pack = program.is_some_and(|p| drawn[p]) && !span.hidden;
// Portal faces cast too (a pack's shadow pass sees block entities whole, as Iris
// draws them); beams do not.
let casts = part != Part::Beam && program.is_some_and(|p| shadow.cast(&span, p));
let casts = !matches!(part, Part::Beam | Part::WaterMask)
&& program.is_some_and(|p| shadow.cast(&span, p));
if !pack && !span.hidden {
extend_without_portals(rest, run);
}
@@ -541,8 +567,13 @@ impl PackEntities {
Part::Beam => beam_segments(run, &mut self.scratch),
_ => self.scratch.extend_from_slice(run),
}
// A run copied whole: its vertices keep their order, for `at_velocity`.
if !split {
// A run copied whole keeps its vertices' order, for `at_velocity`; so does the
// start of a split one copied as it is (a boat's hull before its water patch).
let in_order = !split
|| (part == Part::Plain
&& bytemuck::cast_slice::<_, u8>(&whole[..run.len().min(whole.len())])
== bytemuck::cast_slice::<_, u8>(run));
if in_order {
copied.push((run_start, posed + start, run.len() as u32));
}
if program == DESTROY {
@@ -563,6 +594,7 @@ impl PackEntities {
casts,
overlay,
portal,
part == Part::WaterMask,
);
}
}
@@ -634,6 +666,7 @@ impl PackEntities {
casts: bool,
overlay: u32,
portal: bool,
water_mask: bool,
) {
let (entity_id, block_id) = match program {
BLOCK | BLOCK_TRANSLUCENT => (0, span.id),
@@ -658,6 +691,7 @@ impl PackEntities {
shadow: casts,
slot: self.draws.len() as u32,
portal,
water_mask,
});
}
}
@@ -692,6 +726,8 @@ enum Part {
Plain,
Portal,
Beam,
/// A boat's water patch: depth only (`Draw::water_mask`).
WaterMask,
}
/// Beam faces (`block_entity_render::beam`: layer 6, v counting the sprite's repeats up the
@@ -1446,6 +1482,79 @@ mod gpu_span_tests {
);
}
/// A boat's water patch (`RenderTypes.waterMask`) is a draw of its own, depth only, that casts
/// no shadow; with no pack program for entities, Horizon draws it with the boat.
#[test]
fn a_boats_water_patch_draws_depth_only() {
let instance = crate::gfx::test_gpu::instance();
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
return;
};
let Ok((device, queue)) = pollster::block_on(adapter.request_device(&Default::default()))
else {
return;
};
let layout = super::super::pack_draw::DrawUniforms::new(&device);
let mut geometry = PackEntities::new(&device, &layout.layout);
let mask = super::super::entity_batch::WATER_MASK_OVERLAY;
// One boat: a hull face, then the water patch.
let cpu: Vec<Vertex> = [0, 0, 0, 0, mask, mask, mask, mask]
.into_iter()
.map(|layer| Vertex {
layer,
..bytemuck::Zeroable::zeroed()
})
.collect();
let spans = [EntitySpan {
start: 0,
id: 7,
stage: render_stage("ENTITIES"),
..Default::default()
}];
let mut rest = Vec::new();
let shadows = Casters {
entities: true,
..Default::default()
};
let mut prepare = |drawn, rest: &mut Vec<Vertex>| {
geometry.prepare(
&device,
&queue,
&layout.layout,
&cpu,
&spans,
drawn,
([0.; 4], [1; 2]),
shadows,
rest,
None,
(&[], &[]),
);
let draws = (geometry.draws.iter())
.map(|d| (d.base, d.program, d.pack, d.shadow, d.water_mask))
.collect::<Vec<_>>();
let heights: Vec<f32> = geometry.scratch.iter().map(|v| v.pos[1]).collect();
(draws, heights)
};
assert_eq!(
prepare([true; PROGRAMS], &mut rest),
(
vec![
(0, ENTITY, true, true, false),
(4, ENTITY, true, false, true)
],
// Where they lie: the patch at the water line, not moved.
vec![0.0; 8]
)
);
assert!(rest.is_empty());
let mut no_entities = [true; PROGRAMS];
no_entities[ENTITY] = false;
let (draws, _) = prepare(no_entities, &mut rest);
assert!(draws.iter().all(|d| !d.2), "{draws:?}");
assert_eq!(rest.len(), 8, "the boat and its patch stay Horizon's");
}
#[test]
fn posed_and_cpu_draws_keep_offsets_uniforms_and_special_fallbacks() {
let instance = crate::gfx::test_gpu::instance();
@@ -53,6 +53,8 @@ pub fn inputs() -> horizon_shaders::GbuffersInputs {
item_id_declared: false,
far_depth: false,
paged_maps: false,
water_mask: false,
water_patches: false,
}
}
@@ -91,6 +91,8 @@ void main() {
terrain: Some(horizon_shaders::GbuffersInputs {
body: "vec2 p = vec2(float((gl_VertexIndex << 1) & 2), float(gl_VertexIndex & 2)); hz_Vertex=vec4(p*2.0-1.0,0.0,1.0);".into(),
paged_maps: true,
water_mask: false,
water_patches: false,
..Default::default()
}),
..Default::default()
+150 -13
View File
@@ -19,6 +19,7 @@ use std::collections::HashMap;
use glam::{DMat4, DVec3};
use horizon_shaders::{Format, Group, Kind, Post, SHADOW_COLORS};
use smallvec::SmallVec;
use super::pack_draw::DrawUniforms;
use super::pack_entities::{PROGRAMS, PackEntities};
@@ -125,6 +126,8 @@ enum Source {
ShadowDepth(usize, bool),
/// `shadowcolor0/1` read outside the shadow programs (in them they are `Color`).
ShadowColor(usize),
/// The boats' water patches' depth (`horizon_shaders::WATER_MASK`), for translucent terrain.
WaterMask,
}
#[derive(Clone)]
@@ -160,9 +163,11 @@ impl SamplerKind {
Source::Color(_) | Source::ShadowColor(_) | Source::Dummy if !filtering => {
Self::Nearest
}
Source::Depth | Source::Depth1 | Source::Depth2 | Source::ShadowDepth(_, false) => {
Self::Nearest
}
Source::Depth
| Source::Depth1
| Source::Depth2
| Source::ShadowDepth(_, false)
| Source::WaterMask => Self::Nearest,
Source::Noise => Self::Repeat,
Source::Shadow | Source::ShadowDepth(_, true) => Self::Comparison,
// Blocks look as the game draws them: nearest texels. Between levels, Iris 1.11.7
@@ -406,6 +411,9 @@ struct Pass {
/// The same program as vanilla blends the sky's parts (`PostProgram::vanilla_sky_blends`):
/// without blending for the disc and the void, then with `STARS_BLEND` for the stars.
sky_parts: Option<[Slot<wgpu::RenderPipeline>; 2]>,
/// The entity program without colour writes, for the boats' water patches
/// (`RenderTypes.waterMask`: depth only, so no water shows inside a hull).
water_mask: Option<Slot<wgpu::RenderPipeline>>,
layout: wgpu::BindGroupLayout,
ubo: wgpu::Buffer,
block: Vec<u8>,
@@ -511,6 +519,8 @@ struct Targets {
depth1: Option<(wgpu::Texture, wgpu::TextureView)>,
/// `depthtex2`, when a program after the hand reads it without the hand.
depth2: Option<(wgpu::Texture, wgpu::TextureView)>,
/// The boats' water patches' depth, when translucent terrain reads it (`Source::WaterMask`).
water_mask: Option<(wgpu::Texture, wgpu::TextureView)>,
/// Both sides' mip chains of the buffers with mipmaps (`color` is their first level).
mips: [Option<[Mips; 2]>; BUFFERS],
/// Where a pass that both samples and stores a buffer samples it from.
@@ -648,6 +658,11 @@ pub struct PackPost {
depth1: bool,
/// A program after the hand reads `depthtex2`: it is copied before the hand.
depth2: bool,
/// Translucent terrain reads the boats' water patches (`Targets::water_mask`).
water_mask: bool,
/// The water patches' depth holds patches (or was never cleared): cleared before the next
/// frame's are drawn.
water_mask_dirty: bool,
/// The shadow buffers, when the shadow pass runs (2.5), and the `shadowcomp` programs.
shadow_targets: Option<super::pack_shadow::ShadowTargets>,
/// What draws terrain from the light (`shadow`), and translucent terrain (`shadow_water`).
@@ -907,6 +922,7 @@ fn source(name: &str, shadow_space: bool, shadow_pass: bool, watershadow: bool)
"lightmap" => Source::Lightmap,
"normals" => Source::Normals,
"specular" => Source::Specular,
horizon_shaders::WATER_MASK => Source::WaterMask,
n => horizon_shaders::buffer_index(n).map_or(Source::Dummy, Source::Color),
}
}
@@ -1647,9 +1663,11 @@ fn bindings(
Source::ShadowColor(n) => shadow
.and_then(|f| f[n].sample_type(None, Some(features)))
.unwrap_or(float),
Source::Depth | Source::Depth1 | Source::Depth2 | Source::ShadowDepth(..) => {
wgpu::TextureSampleType::Float { filterable: false }
}
Source::Depth
| Source::Depth1
| Source::Depth2
| Source::ShadowDepth(..)
| Source::WaterMask => wgpu::TextureSampleType::Float { filterable: false },
// Unfilterable on WebGPU-level devices; the lightmap is sampled at texel centres.
Source::Lightmap => float,
_ => float,
@@ -1933,6 +1951,7 @@ fn build_pass(
custom: &[(String, String)],
draws: &wgpu::BindGroupLayout,
pack: &Resources<'_>,
water_mask: bool,
) -> Option<Pass> {
let features = device.features();
// The frame's uniforms, then the pack's custom ones.
@@ -2147,9 +2166,32 @@ fn build_pass(
)
})
});
(layout, pack.render_pipeline(device, job), sky_parts)
// The water patches' pipeline: the same program, depth only.
let water_mask = water_mask.then(|| {
let targets = (job.targets.iter())
.map(|t| {
t.clone().map(|t| wgpu::ColorTargetState {
write_mask: wgpu::ColorWrites::empty(),
..t
})
})
.collect();
pack.render_pipeline(
device,
RenderJob {
targets,
..job.clone()
},
)
});
(
layout,
pack.render_pipeline(device, job),
sky_parts,
water_mask,
)
});
let (layout, pipeline, sky_parts) = match created {
let (layout, pipeline, sky_parts, water_mask) = match created {
Ok(created) => created,
Err(e) => {
notes.push(format!("{}: skipped: {e}", p.name));
@@ -2189,6 +2231,7 @@ fn build_pass(
group: p.group,
pipeline: Pipeline::Render(pipeline),
sky_parts,
water_mask,
layout,
samplers,
writes,
@@ -2328,6 +2371,7 @@ fn build_compute(
local,
},
sky_parts: None,
water_mask: None,
layout,
samplers,
writes: Vec::new(),
@@ -2610,6 +2654,8 @@ impl PackPost {
})
})
.transpose()?;
// The next pass built also gets a depth-only pipeline for the boats' water patches.
let water_mask_next = std::cell::Cell::new(false);
let mut build = |mut p: horizon_shaders::PostProgram, geometry: Option<&Geometry<'_>>| {
// Not wanted any more: the rest is skipped and the build refused below.
if cancel.load(std::sync::atomic::Ordering::Relaxed) {
@@ -2660,6 +2706,7 @@ impl PackPost {
&custom_names,
&draws.layout,
&pack,
water_mask_next.get(),
)?
};
pass.compute.append(&mut computes);
@@ -2835,7 +2882,11 @@ impl PackPost {
pass
})
};
let mut kind = 0;
let entity_passes = programs.map(|p| {
// The entity program draws the boats, and their water patches (`Draw::water_mask`).
water_mask_next.set(kind == super::pack_entities::ENTITY);
kind += 1;
p.zip(entity_geometry.as_ref()).and_then(|(p, g)| {
if p.name == "gbuffers_damagedblock" {
let decal = Geometry {
@@ -2858,6 +2909,7 @@ impl PackPost {
if entity_passes[super::pack_entities::PARTICLE_TRANSLUCENT].is_some() {
late[super::pack_entities::PARTICLE] = false;
}
water_mask_next.set(false);
let shadow_entities = shadow_programs.map(|p| {
p.zip(entity_geometry.as_ref())
.and_then(|(p, g)| paged(build(p, Some(g))))
@@ -3085,6 +3137,9 @@ impl PackPost {
let depth1 = (water.is_some() || hand.is_some())
&& (reads_depth(Source::Depth1) || (hand.is_none() && reads_depth2));
let depth2 = hand.is_some() && reads_depth2;
let water_mask = (water.iter())
.flat_map(|p| &p.samplers)
.any(|s| s.source == Source::WaterMask);
let every_pass = || {
(passes
.iter()
@@ -3340,6 +3395,8 @@ impl PackPost {
hand_water,
depth1,
depth2,
water_mask,
water_mask_dirty: true,
shadow_targets,
shadow_terrain,
portal_rect: [0.0; 4],
@@ -3671,6 +3728,7 @@ impl PackPost {
}
}
Source::Depth => &targets.depth,
Source::WaterMask => targets.water_mask.as_ref().map_or(&self.dummy, |m| &m.1),
// Before translucent terrain the two are the same.
Source::Depth1 => match &targets.depth1 {
Some(d)
@@ -3864,6 +3922,16 @@ impl PackPost {
&mut self,
encoder: &'e mut wgpu::CommandEncoder,
pass: &Pass,
) -> wgpu::RenderPass<'e> {
self.begin_with(encoder, pass, false)
}
/// `begin`, or with `water_mask` into the water patches' depth instead of the frame's.
fn begin_with<'e>(
&mut self,
encoder: &'e mut wgpu::CommandEncoder,
pass: &Pass,
water_mask: bool,
) -> wgpu::RenderPass<'e> {
let targets = self.targets.as_ref().expect("resized");
let load = wgpu::Operations {
@@ -3877,7 +3945,8 @@ impl PackPost {
_ => (super::pack_shadow::load(), [load; SHADOW_COLORS]),
};
let shadow = self.shadow_targets.as_ref().filter(|_| pass.group.shadow());
let mut attachments: Vec<Option<wgpu::RenderPassColorAttachment>> = Vec::new();
let mut attachments: SmallVec<[Option<wgpu::RenderPassColorAttachment>; BUFFERS]> =
SmallVec::new();
for &(location, buffer) in &pass.writes {
let l = location as usize;
if attachments.len() <= l {
@@ -3898,7 +3967,10 @@ impl PackPost {
color_attachments: &attachments,
timestamp_writes,
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view: shadow.map_or(&targets.depth, |s| &s.depth.1),
view: match &targets.water_mask {
Some((_, view)) if water_mask => view,
_ => shadow.map_or(&targets.depth, |s| &s.depth.1),
},
depth_ops: Some(depth_ops),
stencil_ops: None,
}),
@@ -4081,8 +4153,11 @@ impl PackPost {
let mut draws = geometry
.draws
.iter()
.filter(|d| d.pack && d.program == program)
.filter(|d| d.pack && d.program == program && !d.water_mask)
.peekable();
let masks: Vec<_> = (geometry.draws.iter())
.filter(|d| d.pack && d.program == program && d.water_mask)
.collect();
if draws.peek().is_none() {
continue;
}
@@ -4124,6 +4199,35 @@ impl PackPost {
rp.draw_indexed(0..d.quads * 6, d.base as i32, 0..1);
}
}
// The boats' water patches, depth only, into their own depth: translucent terrain
// discards what lies behind them, and the frame's depth stays the pack's.
if let Some(mask) = pass.water_mask.as_ref().and_then(|m| m.get())
&& !masks.is_empty()
&& self
.targets
.as_ref()
.is_some_and(|t| t.water_mask.is_some())
{
let mut rp = self.begin_with(encoder, pass, true);
rp.set_pipeline(mask);
rp.set_bind_group(0, &group, &[]);
if pass.buffers
&& let Some(g) = &self.buffers.group
{
rp.set_bind_group(4, g, &[]);
}
rp.set_bind_group(1, &geometry.table_group, &[]);
rp.set_vertex_buffer(0, geometry.vertices());
rp.set_index_buffer(geometry.indices.slice(..), wgpu::IndexFormat::Uint32);
for d in &masks {
if pass.draw {
rp.set_bind_group(2, &geometry.group, &[geometry.offset(d.slot)]);
}
rp.draw_indexed(0..d.quads * 6, d.base as i32, 0..1);
}
drop(rp);
self.water_mask_dirty = true;
}
self.mips_valid &= !pass.written;
}
self.entities = Some(entities);
@@ -4905,6 +5009,16 @@ impl PackPost {
let view = t.create_view(&Default::default());
(t, view)
});
let water_mask = self.water_mask.then(|| {
let t = texture(
"Shader pack water patches",
depth32,
wgpu::TextureUsages::RENDER_ATTACHMENT,
);
let view = t.create_view(&Default::default());
(t, view)
});
self.water_mask_dirty = true;
let scratch = std::array::from_fn(|i| {
(self.copied & 1 << i != 0).then(|| {
// A copy also read as an image before a dispatch.
@@ -4939,6 +5053,7 @@ impl PackPost {
depth_texture,
depth1,
depth2,
water_mask,
mips,
});
self.forget_groups();
@@ -5038,7 +5153,8 @@ impl PackPost {
}
let group = self.bind(device, queue, encoder, pass, size);
let t = self.shadow_targets.as_ref().expect("shadow pass");
let mut attachments: Vec<Option<wgpu::RenderPassColorAttachment>> = Vec::new();
let mut attachments: SmallVec<[Option<wgpu::RenderPassColorAttachment>; BUFFERS]> =
SmallVec::new();
for &(location, buffer) in &pass.writes {
let l = location as usize;
if attachments.len() <= l {
@@ -5067,6 +5183,7 @@ impl PackPost {
}
rp.draw(0..6, 0..1);
}
drop(attachments);
if let Some(t) = &mut self.shadow_targets {
t.main ^= pass.flip;
}
@@ -5193,6 +5310,24 @@ impl PackPost {
terrain: &mut TerrainDraw<'_>,
) {
self.resize(device, size);
// Last frame's water patches (or a new texture's zeros) out: depth 1 is no patch.
if self.water_mask_dirty
&& let Some((_, view)) = self.targets.as_ref().and_then(|t| t.water_mask.as_ref())
{
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Shader pack water patches clear"),
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view,
depth_ops: Some(wgpu::Operations {
load: wgpu::LoadOp::Clear(1.0),
store: wgpu::StoreOp::Store,
}),
stencil_ops: None,
}),
..Default::default()
});
self.water_mask_dirty = false;
}
// A resolution scale leaves the picture smaller (or larger) than the window: the pack's
// `final` draws it at the picture's size, and one copy takes it to the window (#9291).
let final_view = if size != window && self.passes.iter().any(|p| p.group == Group::Final) {
@@ -5540,7 +5675,8 @@ impl PackPost {
.as_ref()
.map(|t| &t[usize::from(self.main & 1 << i == 0)])
};
let mut attachments: Vec<Option<wgpu::RenderPassColorAttachment>> = Vec::new();
let mut attachments: SmallVec<[Option<wgpu::RenderPassColorAttachment>; BUFFERS]> =
SmallVec::new();
// Vertex coverage and fragment discard can leave alternate pixels untouched.
// The unused sink can still skip loading its old contents.
let ops = if !pass.sink || pass.viewport.is_some() {
@@ -6212,6 +6348,7 @@ mod tests {
&[],
&draws.layout,
&pack,
false,
)
.unwrap_or_else(|| panic!("the pass builds: {notes:?}"));
// colortex3 holds (0.75, 0.5, 0.25, 0.5) before the pass.
@@ -322,7 +322,7 @@ fn check(sampler: &str, stage: Stage, scene: Scene, expected: [u8; 4]) {
let hand = matches!(scene, Scene::Terrain { hand: true, .. });
let water = matches!(scene, Scene::Terrain { water: true, .. } | Scene::WaterOnly);
let inputs=horizon_shaders::GameInputs {
terrain:terrain.then(|| horizon_shaders::GbuffersInputs { declarations:"layout(location=0)in vec3 fixture_position;\n".into(),body:"hz_Vertex=vec4(fixture_position,1.0);hz_Color=vec4(1.0);hz_MultiTexCoord0=vec4(0.0);hz_MultiTexCoord1=vec4(240.0);hz_MultiTexCoord2=vec4(240.0);hz_Normal=vec3(0.0,0.0,1.0);\n".into(),attributes:vec![],after:String::new(),alpha_test:None,item_id_declared:false,far_depth:false,paged_maps:false }),
terrain:terrain.then(|| horizon_shaders::GbuffersInputs { declarations:"layout(location=0)in vec3 fixture_position;\n".into(),body:"hz_Vertex=vec4(fixture_position,1.0);hz_Color=vec4(1.0);hz_MultiTexCoord0=vec4(0.0);hz_MultiTexCoord1=vec4(240.0);hz_MultiTexCoord2=vec4(240.0);hz_Normal=vec3(0.0,0.0,1.0);\n".into(),attributes:vec![],after:String::new(),alpha_test:None,item_id_declared:false,far_depth:false,paged_maps:false,water_mask:false,water_patches:false }),
hand:hand.then(crate::gfx::pack_hand::inputs),..Default::default()
};
let mut post = gpu.load(
@@ -720,6 +720,8 @@ fn shadow_depth_map_reads(
item_id_declared: false,
far_depth: false,
paged_maps: false,
water_mask: false,
water_patches: false,
}),
..Default::default()
};
@@ -47,6 +47,8 @@ fn pack() -> Post {
item_id_declared: false,
far_depth: false,
paged_maps: false,
water_mask: false,
water_patches: false,
declarations: "layout(location=0)in vec3 fixture_position;\n".into(),
body: "hz_Vertex=vec4(fixture_position,1.0);hz_Color=vec4(1.0);\
hz_MultiTexCoord0=vec4(0.0);hz_MultiTexCoord1=vec4(240.0);\
@@ -4,7 +4,7 @@
//! uniform (set 1) that the vertex inputs read. `gbuffers_skytextured` draws the frame's sun and
//! moon quads, textured from the shared atlas.
use std::ops::Range;
use std::{ops::Range, sync::Mutex};
use wgpu::util::DeviceExt;
@@ -105,6 +105,8 @@ pub fn inputs() -> horizon_shaders::GbuffersInputs {
item_id_declared: false,
far_depth: true,
paged_maps: false,
water_mask: false,
water_patches: false,
}
}
@@ -133,6 +135,8 @@ pub fn celestial_inputs() -> horizon_shaders::GbuffersInputs {
item_id_declared: false,
far_depth: true,
paged_maps: false,
water_mask: false,
water_patches: false,
}
}
@@ -223,6 +227,7 @@ pub(super) struct PackSky {
uniform: wgpu::Buffer,
pub layout: wgpu::BindGroupLayout,
group: wgpu::BindGroup,
celestial_scratch: Mutex<Vec<Celestial>>,
/// Vertex ranges of the sky disc, the sunset fan, the stars, the void and the End's fan.
ranges: [Range<u32>; 5],
}
@@ -302,6 +307,7 @@ impl PackSky {
uniform,
layout,
group,
celestial_scratch: Mutex::new(Vec::new()),
ranges: [sky, sunset, stars, void, end_fan],
}
}
@@ -359,15 +365,21 @@ impl PackSky {
.0
.iter()
.take(CELESTIAL_QUADS);
let triangles: Vec<Celestial> = quads
.clone()
.flat_map(|q| [0, 1, 2, 0, 2, 3].map(|i| q[i]))
.map(|v| Celestial {
pos: turn.transform_vector3(v.pos.into()).into(),
uv: v.uv,
color: v.color,
})
.collect();
let mut triangles = self
.celestial_scratch
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
triangles.clear();
triangles.extend(
quads
.clone()
.flat_map(|q| [0, 1, 2, 0, 2, 3].map(|i| q[i]))
.map(|v| Celestial {
pos: turn.transform_vector3(v.pos.into()).into(),
uv: v.uv,
color: v.color,
}),
);
queue.write_buffer(&self.celestial, 0, bytemuck::cast_slice(&triangles));
let [sky, sunset, stars, void, fan] = self.ranges.clone();
let part = |stage, textured, range| Part {
@@ -374,6 +374,41 @@ pub const NAMES: &[&str] = &[
"horizonDynamicLight13",
"horizonDynamicLight14",
"horizonDynamicLight15",
// Horizon's own: the boats' water patches, for the shadow pass's water (`water_patches`):
// how many, then each one's corner and y range, and its two horizontal edges.
"horizonWaterPatchCount",
"horizonWaterPatch0",
"horizonWaterPatch1",
"horizonWaterPatch2",
"horizonWaterPatch3",
"horizonWaterPatch4",
"horizonWaterPatch5",
"horizonWaterPatch6",
"horizonWaterPatch7",
"horizonWaterPatch8",
"horizonWaterPatch9",
"horizonWaterPatch10",
"horizonWaterPatch11",
"horizonWaterPatch12",
"horizonWaterPatch13",
"horizonWaterPatch14",
"horizonWaterPatch15",
"horizonWaterPatch16",
"horizonWaterPatch17",
"horizonWaterPatch18",
"horizonWaterPatch19",
"horizonWaterPatch20",
"horizonWaterPatch21",
"horizonWaterPatch22",
"horizonWaterPatch23",
"horizonWaterPatch24",
"horizonWaterPatch25",
"horizonWaterPatch26",
"horizonWaterPatch27",
"horizonWaterPatch28",
"horizonWaterPatch29",
"horizonWaterPatch30",
"horizonWaterPatch31",
"isElytraFlying",
"cloudHeight",
];
@@ -870,6 +905,7 @@ impl FrameUniforms {
),
};
let patches = water_patches(frame.entities);
let value = |name: &str| -> Value {
let v3 = |v: Vec3| Value::f(&v.to_array());
let m4 = |m: Mat4| Value::f(&m.to_cols_array());
@@ -960,6 +996,11 @@ impl FrameUniforms {
"gbufferProjectionInverse" => m4(projection_inverse),
"gbufferPreviousModelView" => m4(previous_view),
"gbufferPreviousProjection" => m4(previous_projection),
"horizonWaterPatchCount" => Value::i(&[patches.len() as i32]),
n if water_patch_index(n).is_some() => Value::f(
&(water_patch_index(n).and_then(|k| patches.get(k / 2).map(|p| p[k % 2])))
.unwrap_or([0.0; 4]),
),
"horizonDynamicLightCount" => Value::i(&[frame.dynamic_lights.len() as i32]),
n if dynamic_light_index(n).is_some() => Value::f(
&(dynamic_light_index(n).and_then(|k| frame.dynamic_lights.get(k)))
@@ -1847,6 +1888,37 @@ pub(super) mod initial_history_tests {
assert_eq!(float(&u, "horizonDynamicLight0")[..4], [0.0; 4]);
}
#[test]
fn a_boats_water_patch_becomes_its_box() {
let vertex = |pos: [f32; 3], layer: u32| super::super::Vertex {
pos,
layer,
..bytemuck::Zeroable::zeroed()
};
// A patch 1.75 by 1 block, 0.2 thick, turned 30 degrees, at the hull's water line;
// a hull quad on each side of it.
let (c, s) = (30f32.to_radians().cos(), 30f32.to_radians().sin());
let at = |x: f32, y: f32, z: f32| [2.0 + x * c - z * s, y, 3.0 + x * s + z * c];
let mask = super::super::WATER_MASK_OVERLAY;
let mut quads = vec![vertex([0.0; 3], 0); 4];
for y in [-1.2, -1.0] {
quads.extend(
[(0.0, 0.0), (1.75, 0.0), (1.75, 1.0), (0.0, 1.0)]
.map(|(x, z)| vertex(at(x, y, z), mask)),
);
}
quads.extend(vec![vertex([5.0; 3], 0); 4]);
let boxes = water_patches(&quads);
assert_eq!(boxes.len(), 1);
let [[x, z, low, high], [ax, az, bx, bz]] = boxes[0];
let close = |a: f32, b: f32| (a - b).abs() < 1e-4;
assert!(close(x, 2.0) && close(z, 3.0) && close(low, -1.2) && close(high, -1.0));
// The edges: the 1-block one first (nearer), then the 1.75-block one.
assert!(close(ax, -s) && close(az, c), "{boxes:?}");
assert!(close(bx, 1.75 * c) && close(bz, 1.75 * s), "{boxes:?}");
assert!(water_patches(&quads[..4]).is_empty());
}
#[test]
fn environmental_smoothing_starts_at_the_first_inputs_without_resetting_each_frame() {
let hud = HudBatch::new([800, 450]);
@@ -2642,6 +2714,67 @@ pub(super) mod initial_history_tests {
}
}
/// `horizonWaterPatch<k>`'s `k`: patch `k / 2`, its corner and y range (even) or edges (odd).
fn water_patch_index(name: &str) -> Option<usize> {
let k: usize = name.strip_prefix("horizonWaterPatch")?.parse().ok()?;
(k < 2 * horizon_shaders::WATER_PATCHES).then_some(k)
}
/// The boats' water patches among the frame's entity quads (`WATER_MASK_OVERLAY`), the first
/// `WATER_PATCHES`, each as a camera-relative box: a corner's x and z and the patch's lowest and
/// highest y, then its two horizontal edges from that corner. A boat rocks a little: its patch's
/// corners are told apart in x and z within 2 cm.
pub(super) fn water_patches(entities: &[super::Vertex]) -> Vec<[[f32; 4]; 2]> {
let mut out = Vec::new();
let mut points: Vec<[f32; 3]> = Vec::new();
let flush = |points: &mut Vec<[f32; 3]>, out: &mut Vec<[[f32; 4]; 2]>| {
if let Some(b) = patch_box(points)
&& out.len() < horizon_shaders::WATER_PATCHES
{
out.push(b);
}
points.clear();
};
for quad in entities.as_chunks::<4>().0 {
if quad[0].layer & 0xffff == super::WATER_MASK_OVERLAY {
points.extend(quad.iter().map(|v| v.pos));
} else if !points.is_empty() {
flush(&mut points, &mut out);
}
}
flush(&mut points, &mut out);
out
}
/// A water patch's box (`water_patches`) from its corners.
fn patch_box(points: &[[f32; 3]]) -> Option<[[f32; 4]; 2]> {
let first = points.first()?;
let (low, high) = points
.iter()
.fold((f32::MAX, f32::MIN), |(l, h), p| (l.min(p[1]), h.max(p[1])));
let mut corners: Vec<[f32; 2]> = Vec::new();
for p in points {
let c = [p[0], p[2]];
if !corners
.iter()
.any(|q| (q[0] - c[0]).abs() < 0.02 && (q[1] - c[1]).abs() < 0.02)
{
corners.push(c);
}
}
let origin = [first[0], first[2]];
let mut others: Vec<[f32; 2]> = corners.into_iter().filter(|c| *c != origin).collect();
if others.len() < 2 {
return None;
}
let distance = |c: &[f32; 2]| (c[0] - origin[0]).powi(2) + (c[1] - origin[1]).powi(2);
others.sort_by(|a, b| distance(a).total_cmp(&distance(b)));
// The two nearest corners are the edges' ends; the farthest is across.
let edge = |c: [f32; 2]| [c[0] - origin[0], c[1] - origin[1]];
let (a, b) = (edge(others[0]), edge(others[1]));
Some([[origin[0], origin[1], low, high], [a[0], a[1], b[0], b[1]]])
}
/// `horizonDynamicLight<k>`'s `k`, for the lights `Frame::dynamic_lights` holds.
fn dynamic_light_index(name: &str) -> Option<usize> {
let k: usize = name.strip_prefix("horizonDynamicLight")?.parse().ok()?;
@@ -47,6 +47,8 @@ pub fn inputs() -> horizon_shaders::GbuffersInputs {
item_id_declared: false,
far_depth: false,
paged_maps: false,
water_mask: false,
water_patches: false,
}
}
@@ -9,8 +9,11 @@
#[derive(Default)]
pub struct Readback {
texture: Option<wgpu::Texture>,
/// The 8-bit texture `finish` put the frame on, where the swapchain image can't be read
/// back; `present` then draws it instead of `texture`.
finished: Option<wgpu::Texture>,
blitter: Option<(wgpu::TextureFormat, wgpu::util::TextureBlitter)>,
/// A frame drew into `texture` and still has to reach the swapchain.
/// A frame drew into `texture` (or `finished`) and still has to reach the swapchain.
drawn: bool,
}
@@ -53,7 +56,47 @@ impl Readback {
self.texture.clone()
}
/// Draws the frame `target` gave onto the swapchain image `surface`; nothing
/// The frame `target` gave, finished onto 8 bits of the swapchain image `surface`'s format,
/// clipped and rounded as an 8-bit frame holds it, for what draws over it from here on
/// (`Renderer::overlay_format`): `surface` itself, or, for a screenshot it can't be copied
/// from, a texture of its own that `present` then draws there.
pub fn finish(
&mut self,
device: &wgpu::Device,
encoder: &mut wgpu::CommandEncoder,
surface: &wgpu::Texture,
screenshot: bool,
) -> Option<wgpu::Texture> {
let frame = self.texture.clone().filter(|_| self.drawn)?;
let format = surface.format().remove_srgb_suffix();
let target = if !screenshot || surface.usage().contains(wgpu::TextureUsages::COPY_SRC) {
self.drawn = false;
surface.clone()
} else {
let texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some("Horizon finished frame"),
size: frame.size(),
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT
| wgpu::TextureUsages::COPY_SRC
| wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[],
});
self.finished = Some(texture.clone());
texture
};
let view = target.create_view(&wgpu::TextureViewDescriptor {
format: Some(format),
..Default::default()
});
self.blit(device, encoder, &frame, &view, format);
Some(target)
}
/// Draws the frame `target` (or `finish`) gave onto the swapchain image `surface`; nothing
/// when this frame drew straight into it.
pub fn present(
&mut self,
@@ -61,10 +104,11 @@ impl Readback {
encoder: &mut wgpu::CommandEncoder,
surface: &wgpu::Texture,
) {
let finished = self.finished.take();
if !std::mem::take(&mut self.drawn) {
return;
}
let Some(texture) = self.texture.clone() else {
let Some(texture) = finished.or_else(|| self.texture.clone()) else {
return;
};
self.draw_onto(device, encoder, &texture, surface);
@@ -346,4 +390,68 @@ mod tests {
let float = wgpu::TextureFormat::Rgba16Float;
reaches_an_8_bit_swapchain(&device, &queue, float, color, [0, 51, 255, 255]);
}
/// `finish` puts a float frame on 8 bits before the overlays draw: on the swapchain image
/// itself, which `present` then leaves alone, or, for a screenshot of an image that can't
/// be copied from, on a texture of its own that the screenshot reads and `present` draws.
#[test]
fn a_float_frame_finishes_on_8_bits_before_the_overlays() {
let Some((device, queue)) = device() else {
return;
};
let float = wgpu::TextureFormat::Rgba16Float;
let color = wgpu::Color {
r: 1.5,
g: 0.2,
b: -0.5,
a: 1.0,
};
let check = texture(
&device,
wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
);
let blitter = wgpu::util::TextureBlitter::new(&device, F);
let view = |t: &wgpu::Texture| t.create_view(&Default::default());
for (usage, screenshot) in [
(wgpu::TextureUsages::COPY_SRC, false),
(wgpu::TextureUsages::COPY_SRC, true),
(wgpu::TextureUsages::empty(), false),
(wgpu::TextureUsages::empty(), true),
] {
let surface = texture(
&device,
wgpu::TextureUsages::RENDER_ATTACHMENT
| wgpu::TextureUsages::TEXTURE_BINDING
| usage,
);
let mut readback = Readback::default();
let scope = device.push_error_scope(wgpu::ErrorFilter::Validation);
let mut encoder = device.create_command_encoder(&Default::default());
let frame = readback
.target(&device, &surface, float, screenshot)
.unwrap();
clear(&mut encoder, &frame, color);
let finished = readback
.finish(&device, &mut encoder, &surface, screenshot)
.unwrap();
assert_eq!(finished.format(), F);
let own = screenshot && usage.is_empty();
assert_eq!(finished == surface, !own, "{usage:?} {screenshot}");
assert_eq!(readback.drawn, own);
let shot = screenshot
.then(|| crate::gfx::copy_frame(&device, &mut encoder, &finished, [70, 3]));
readback.present(&device, &mut encoder, &surface);
blitter.copy(&device, &mut encoder, &view(&surface), &view(&check));
let shown = crate::gfx::copy_frame(&device, &mut encoder, &check, [70, 3]);
queue.submit([encoder.finish()]);
if let Some(error) = pollster::block_on(scope.pop()) {
panic!("{usage:?} {screenshot}: {error}");
}
assert_eq!(last_pixel(&device, &shown), [0, 51, 255, 255]);
if let Some(shot) = shot {
assert_eq!(last_pixel(&device, &shot), [0, 51, 255, 255]);
}
assert!(!readback.drawn && readback.finished.is_none());
}
}
}
+192 -34
View File
@@ -19,28 +19,41 @@ pub const HDR_MARKER: &str = "horizon-hdr-pending";
pub struct Reported {
pub paper_white: u32,
pub peak: u32,
/// The display's darkest black, in hundredths of a nit.
pub black: u32,
}
static REPORTED: [AtomicU32; 3] = [AtomicU32::new(0), AtomicU32::new(0), AtomicU32::new(0)];
static REPORTED: [AtomicU32; 2] = [AtomicU32::new(0), AtomicU32::new(0)];
/// Sets what the system reports (the renderer, when it opens its surface).
pub fn report(reported: Option<Reported>) {
let (paper_white, peak, black) =
reported.map_or((0, 0, 0), |r| (r.paper_white, r.peak, r.black));
let (paper_white, peak) = reported.map_or((0, 0), |r| (r.paper_white, r.peak));
REPORTED[0].store(paper_white, Ordering::Relaxed);
REPORTED[1].store(peak, Ordering::Relaxed);
REPORTED[2].store(black, Ordering::Relaxed);
}
/// What the system reports, if it does.
pub fn reported() -> Option<Reported> {
let [paper_white, peak, black] = REPORTED.each_ref().map(|n| n.load(Ordering::Relaxed));
(peak > 0).then_some(Reported {
paper_white,
peak,
black,
})
let [paper_white, peak] = REPORTED.each_ref().map(|n| n.load(Ordering::Relaxed));
(peak > 0).then_some(Reported { paper_white, peak })
}
/// The top of Peak Brightness's scale: `peak_nits_at(PEAK_STEPS)` is PQ's 10 000 nits.
pub const PEAK_STEPS: u32 = 52;
/// Peak Brightness's value at `step` of its scale, the slider's and calibration's: 200 to
/// 2000 nits by 50, where most displays' peaks are, then up to PQ's 10 000 by 500.
pub fn peak_nits_at(step: u32) -> u32 {
match step.min(PEAK_STEPS) {
s @ 0..=36 => 200 + s * 50,
s => 2000 + (s - 36) * 500,
}
}
/// Where `nits` sits on Peak Brightness's scale ([`peak_nits_at`]), between steps for a value
/// off it.
pub fn peak_step_of(nits: f32) -> f32 {
let step = if nits <= 2000.0 {
(nits - 200.0) / 50.0
} else {
36.0 + (nits - 2000.0) / 500.0
};
step.clamp(0.0, PEAK_STEPS as f32)
}
/// Simple-level presets. `Custom` leaves every option as the player set it.
@@ -508,6 +521,61 @@ pub fn hdr_offered() -> bool {
HDR_OFFERED.load(std::sync::atomic::Ordering::Relaxed)
}
static SYSTEM_HDR: AtomicU32 = AtomicU32::new(0);
/// The window's current system HDR state, independent of swapchain availability.
pub fn report_system_hdr(on: Option<bool>) {
SYSTEM_HDR.store(on.map_or(0, |on| if on { 2 } else { 1 }), Ordering::Relaxed);
}
pub fn system_hdr() -> Option<bool> {
match SYSTEM_HDR.load(Ordering::Relaxed) {
1 => Some(false),
2 => Some(true),
_ => None,
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum HdrMode {
#[default]
Off,
On,
Auto,
}
impl HdrMode {
pub const ALL: [Self; 3] = [Self::Off, Self::On, Self::Auto];
pub fn key(self) -> &'static str {
match self {
Self::Off => "false",
Self::On => "true",
Self::Auto => "auto",
}
}
pub fn label(self) -> &'static str {
match self {
Self::Off => "Off",
Self::On => "On",
Self::Auto => "Auto",
}
}
fn parse(value: &str, default: Self) -> Self {
match value {
"false" | "off" => Self::Off,
"true" | "on" => Self::On,
"auto" => Self::Auto,
_ => default,
}
}
/// On retains the old switch's intent even before a surface is opened; the renderer
/// still gates output on availability. Auto requires both availability and a live report.
fn wanted(self, offered: bool, system: Option<bool>) -> bool {
match self {
Self::Off => false,
Self::On => true,
Self::Auto => offered && system == Some(true),
}
}
}
static TRANSLUCENT_BATCHING_OFFERED: std::sync::atomic::AtomicBool =
std::sync::atomic::AtomicBool::new(false);
/// Whether the device can batch translucent quads (`Graphics::translucent_batching`): set when
@@ -783,16 +851,15 @@ pub struct Graphics {
/// 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,
pub hdr: HdrMode,
/// HDR paper white (where SDR white sits), in nits; 0 is Auto: the desktop's SDR
/// white, as the system reports it ([`Graphics::paper_white_nits`]).
pub hdr_paper_white: u32,
/// HDR peak brightness the world's highlights reach, in nits; 0 is Auto:
/// HDR peak brightness the world's highlights reach, in nits (200..=10 000); 0 is Auto:
/// what the system reports for the display ([`Graphics::peak_nits`]).
pub hdr_peak: u32,
/// HDR black level: the display's darkest black, in hundredths of a nit; 0 is Auto:
/// what the system reports, else true black. Shadows are lifted onto it, and the HDR
/// metadata says it.
/// HDR black level: the display's darkest black, in hundredths of a nit; 0, the default,
/// lifts nothing. Shadows are lifted onto it, and the HDR metadata says it.
pub hdr_black_level: u32,
/// HDR brightness of the HUD and menus, in percent of paper white (25..=100); the
/// first-person hand stays at the world's.
@@ -870,7 +937,7 @@ impl Default for Graphics {
upscaling_sharpness: 85,
screenshot_quality: 1,
frame_generation: FrameGeneration::Off,
hdr: false,
hdr: HdrMode::Off,
hdr_paper_white: 0,
hdr_peak: 0,
hdr_black_level: 0,
@@ -902,8 +969,8 @@ impl Graphics {
Vec::new()
}
};
if fs::remove_file(dir.join(HDR_MARKER)).is_ok() && g.hdr {
g.hdr = false;
if fs::remove_file(dir.join(HDR_MARKER)).is_ok() && g.hdr != HdrMode::Off {
g.hdr = HdrMode::Off;
g.save(dir);
log::warn!(
"the last start ended before HDR output showed a frame; HDR is off now (graphics option hdr, in {FILE})"
@@ -1000,7 +1067,7 @@ impl Graphics {
.unwrap_or(1)
}
"frameGeneration" => g.frame_generation = FrameGeneration::parse(value),
"hdr" => g.hdr = flag(g.hdr),
"hdr" => g.hdr = HdrMode::parse(value, g.hdr),
"hdrLighting" => g.hdr_lighting = flag(g.hdr_lighting),
"hdrPaperWhite" => {
g.hdr_paper_white = match number(g.hdr_paper_white) {
@@ -1011,7 +1078,7 @@ impl Graphics {
"hdrPeak" => {
g.hdr_peak = match number(g.hdr_peak) {
0 => 0,
n => n.clamp(200, 2000),
n => n.clamp(200, 10_000),
}
}
"hdrBlackLevel" => g.hdr_black_level = number(g.hdr_black_level).min(100),
@@ -1082,7 +1149,7 @@ impl Graphics {
self.upscaling_sharpness,
self.screenshot_quality,
self.frame_generation.name(),
bool_text(self.hdr),
self.hdr.key(),
self.hdr_paper_white,
self.hdr_peak,
self.hdr_black_level,
@@ -1111,10 +1178,14 @@ impl Graphics {
pub fn save(&self, dir: &Path) {
let _ = fs::write(dir.join(FILE), self.serialize());
}
/// The single decision shared by rendering and the HDR settings page.
pub fn hdr_wanted(&self) -> bool {
self.hdr.wanted(hdr_offered(), system_hdr())
}
/// Whether a renderer made now draws its frame in 16 bits (`Renderer::new`'s `wide_frame`):
/// HDR on, with Smooth Gradients.
pub fn wide_frame(&self) -> bool {
self.hdr && self.hdr_smooth_gradients
self.hdr_wanted() && self.hdr_smooth_gradients
}
/// Paper white, in nits: the player's, or on Auto the desktop's SDR white as the
/// system reports it, else 203 (BT.2408's reference white).
@@ -1132,13 +1203,10 @@ impl Graphics {
n => n,
}
}
/// The black level, in nits: the player's, or on Auto what the system reports, else 0.
/// The black level, in nits. Not the system's: files from before saved 0 for that
/// (Auto), and a reported black would lift every display's shadows by default.
pub fn black_level_nits(&self) -> f32 {
let hundredths = match self.hdr_black_level {
0 => reported().map_or(0, |r| r.black),
n => n,
};
hundredths as f32 / 100.0
self.hdr_black_level as f32 / 100.0
}
/// Worker threads for chunk generation and meshing.
pub fn worker_threads(&self) -> usize {
@@ -1207,6 +1275,53 @@ pub fn apply_preset(preset: Preset, s: &mut crate::settings::Settings) {
mod tests {
use super::*;
#[test]
fn hdr_modes_keep_boolean_files_and_round_trip_auto() {
assert_eq!(Graphics::parse("").hdr, HdrMode::Off);
for (value, expected) in [
("true", HdrMode::On),
("false", HdrMode::Off),
("on", HdrMode::On),
("off", HdrMode::Off),
("auto", HdrMode::Auto),
("unknown", HdrMode::Off),
] {
let g = Graphics::parse(&format!("hdr:{value}\n"));
assert_eq!(g.hdr, expected, "{value}");
assert_eq!(Graphics::parse(&g.serialize()).hdr, expected);
let saved = g.serialize();
let value = saved.lines().find_map(|l| l.strip_prefix("hdr:")).unwrap();
// The old boolean parser keeps its Off default for an unknown value.
assert_eq!(value == "true", expected == HdrMode::On);
}
assert_eq!(Graphics::parse("hdr:true\nhdr:unknown\n").hdr, HdrMode::On);
}
#[test]
fn hdr_auto_needs_a_positive_system_report_and_an_output() {
for (offered, system, expected) in [
(false, None, false),
(false, Some(false), false),
(false, Some(true), false),
(true, None, false),
(true, Some(false), false),
(true, Some(true), true),
] {
assert!(!HdrMode::Off.wanted(offered, system));
assert!(HdrMode::On.wanted(offered, system));
assert_eq!(
HdrMode::Auto.wanted(offered, system),
expected,
"{offered} {system:?}"
);
}
let readings = [Some(false), Some(true), Some(false), None, Some(true)];
assert_eq!(
readings.map(|r| HdrMode::Auto.wanted(true, r)),
[false, true, false, false, true]
);
}
#[test]
fn the_api_choice_matches_the_system() {
let choices = GraphicsApi::choices();
@@ -1267,7 +1382,7 @@ mod tests {
upscaling_sharpness: 40,
screenshot_quality: 4,
frame_generation: FrameGeneration::Fsr31,
hdr: true,
hdr: HdrMode::On,
hdr_paper_white: 240,
hdr_peak: 1400,
hdr_black_level: 12,
@@ -1352,9 +1467,36 @@ mod tests {
};
assert_eq!(Graphics::parse(&g.serialize()).gpu_culling, mode);
}
// 0 is Auto; anything else is a peak the slider reaches.
assert_eq!(Graphics::parse("hdrPeak:0\n").hdr_peak, 0);
assert_eq!(Graphics::parse("hdrPeak:50\n").hdr_peak, 200);
// 0 is Auto; anything else is a peak, up to PQ's 10 000 nits. Older files' values
// (up to 2000) read as they were saved.
for (file, peak) in [
("hdrPeak:0\n", 0),
("hdrPeak:50\n", 200),
("hdrPeak:1234\n", 1234),
("hdrPeak:2000\n", 2000),
("hdrPeak:3000\n", 3000),
("hdrPeak:10000\n", 10_000),
("hdrPeak:25000\n", 10_000),
] {
let g = Graphics::parse(file);
assert_eq!(g.hdr_peak, peak, "{file}");
assert_eq!(Graphics::parse(&g.serialize()).hdr_peak, peak, "{file}");
}
assert_eq!(Graphics::parse("hdrPeak:3000\n").peak_nits(), 3000);
// The black level is what's saved, in hundredths of a nit: 0 (Auto in older files)
// lifts nothing, and the most is a nit.
for (file, hundredths, nits) in [
("", 0, 0.0),
("hdrBlackLevel:0\n", 0, 0.0),
("hdrBlackLevel:5\n", 5, 0.05),
("hdrBlackLevel:100\n", 100, 1.0),
("hdrBlackLevel:250\n", 100, 1.0),
] {
let g = Graphics::parse(file);
assert_eq!(g.hdr_black_level, hundredths, "{file}");
assert_eq!(g.black_level_nits(), nits, "{file}");
assert_eq!(Graphics::parse(&g.serialize()), g, "{file}");
}
assert_eq!(Graphics::parse("hdrPaperWhite:0\n").hdr_paper_white, 0);
assert_eq!(Graphics::parse("hdrPaperWhite:5\n").hdr_paper_white, 10);
// Only the button's choices.
@@ -1385,6 +1527,22 @@ mod tests {
);
}
#[test]
fn peak_scale_steps_by_50_to_2000_then_by_500_to_10000() {
let scale: Vec<u32> = (0..=PEAK_STEPS).map(peak_nits_at).collect();
let fine: Vec<u32> = (200..=2000).step_by(50).collect();
let coarse: Vec<u32> = (2500..=10_000).step_by(500).collect();
assert_eq!(scale, [fine, coarse].concat());
for (step, &nits) in scale.iter().enumerate() {
assert_eq!(peak_step_of(nits as f32), step as f32);
}
// Off the scale, between its neighbours; beyond it, at its ends.
assert_eq!(peak_step_of(1225.0), 20.5);
assert_eq!(peak_step_of(3250.0), 38.5);
assert_eq!(peak_step_of(100.0), 0.0);
assert_eq!(peak_step_of(20_000.0), PEAK_STEPS as f32);
}
#[test]
fn leaf_culling_round_trips_its_stable_names() {
assert_eq!(Graphics::default().leaf_culling, LeafCullingMode::Off);
@@ -189,11 +189,23 @@ fn tick(batch: &mut HudBatch, atlas: &HudAtlas, x: f32, y: f32, color: u32) {
/// A flat button with its label centred: Undo, Calibrate..., and Done in the
/// violet (`primary`). Pointed at or focused, vanilla's white edge.
pub fn button(
batch: &mut HudBatch,
atlas: &HudAtlas,
rect: [f32; 4],
label: &Text,
flags: (bool, bool, bool),
) {
button_with_room(batch, atlas, rect, label, flags, 0.0);
}
/// A button whose label leaves `room` at its left, for a controller's glyph drawn there
/// (`widgets::action_glyph_room`).
pub fn button_with_room(
batch: &mut HudBatch,
atlas: &HudAtlas,
rect: [f32; 4],
label: &Text,
(hover, live, primary): (bool, bool, bool),
room: f32,
) {
let [x, y, w, h] = rect;
let face = match (live, primary, hover) {
@@ -209,7 +221,15 @@ pub fn button(
}
let color = if live { WHITE } else { MUTED };
let ty = y + ((h - 8.0) / 2.0).floor();
scrolling_label(batch, atlas, label, [x + 2.0, ty, w - 4.0], (y, h), color);
let left = x + 2.0 + room;
scrolling_label(
batch,
atlas,
label,
[left, ty, w - 4.0 - room],
(y, h),
color,
);
}
/// A widget label of `[left, y, width]` inside the widget's `(top, height)`: centred when it fits,
/// else slid (vanilla `AbstractWidget.extractScrollingStringOverContents`) and clipped to the box.
+158 -51
View File
@@ -6,7 +6,7 @@
//! and what they do live here.
use crate::{
graphics::{
EntityCullingMode, FarDetail, FrameGeneration, GpuCullingMode, GraphicsApi,
EntityCullingMode, FarDetail, FrameGeneration, GpuCullingMode, GraphicsApi, HdrMode,
LeafCullingMode, Overlay, Pacing, ParticleCullingMode, PresentMode, Preset, Upscaling,
},
graphics_style::Cost,
@@ -598,7 +598,7 @@ pub fn rows() -> Vec<(Page, Vec<(&'static str, Vec<Setting>)>)> {
h(
H::HdrBlackLevel,
None,
"Your display's darkest black. 0 for OLED; LCDs glow a little.",
"Lifts shadows onto your display's darkest black. 0: true black, no lift.",
),
h(
H::HdrPeak,
@@ -608,7 +608,7 @@ pub fn rows() -> Vec<(Page, Vec<(&'static str, Vec<Setting>)>)> {
h(
H::HdrPaperWhite,
None,
"How bright white paper and menus look. Auto: your desktop's white.",
"How bright white looks in the world. Auto: your desktop's white.",
),
h(
H::HdrUiBrightness,
@@ -780,6 +780,9 @@ fn labelled_percent(caption: &str, percent: u32) -> Text {
)
}
/// Peak Brightness's slider: Auto, then each step of the peak's scale.
const PEAK_POSITIONS: u32 = crate::graphics::PEAK_STEPS + 1;
impl H {
fn slider(self, s: &Settings) -> Option<f32> {
let g = &s.horizon;
@@ -805,10 +808,10 @@ impl H {
H::UpscalingSharpness => Some(g.upscaling_sharpness as f32 / 100.0),
// Auto, then 10..480 by 10.
H::HdrPaperWhite => Some(g.hdr_paper_white as f32 / 480.0),
// Auto, then 200..2000 by 50.
// Auto, then the peak's scale.
H::HdrPeak => Some(match g.hdr_peak {
0 => 0.0,
n => (n as f32 - 150.0) / 1850.0,
n => (1.0 + crate::graphics::peak_step_of(n as f32)) / PEAK_POSITIONS as f32,
}),
H::HdrBlackLevel => Some(g.hdr_black_level as f32 / 100.0),
H::HdrUiBrightness => Some((g.hdr_ui_brightness as f32 - 25.0) / 75.0),
@@ -823,8 +826,8 @@ impl H {
H::Workers => Some(max_workers()),
H::ResolutionScale => Some(30),
H::UpscalingSharpness => Some(20),
H::HdrPaperWhite => Some(41),
H::HdrPeak => Some(37),
H::HdrPaperWhite => Some(48),
H::HdrPeak => Some(PEAK_POSITIONS),
H::HdrBlackLevel => Some(100),
H::HdrUiBrightness => Some(15),
_ => None,
@@ -924,7 +927,7 @@ impl H {
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::Hdr => labelled("HDR", g.hdr.label()),
H::HdrCalibrate => Text::literal(lang::get(match setup(s) {
Setup::Unknown => "Calibrate...",
Setup::Automatic => "Calibrate Anyway...",
@@ -941,13 +944,7 @@ impl H {
0 => labelled("Peak Brightness", lang::tr("Auto (%s)", &[&g.peak_nits()])),
n => labelled("Peak Brightness", n.to_string()),
},
H::HdrBlackLevel => match g.hdr_black_level {
0 => labelled(
"Black Level",
lang::tr("Auto (%s)", &[&format!("{:.2}", g.black_level_nits())]),
),
n => labelled("Black Level", format!("{:.2}", n as f32 / 100.0)),
},
H::HdrBlackLevel => labelled("Black Level", format!("{:.2}", g.black_level_nits())),
H::HdrUiBrightness => labelled_percent("UI Brightness", g.hdr_ui_brightness),
H::HdrLighting => labelled("HDR Lighting", on_off(g.hdr_lighting)),
H::HdrKey => {
@@ -995,9 +992,9 @@ impl H {
H::UpscalingSharpness => g.upscaling_sharpness = (v * 20.0).round() as u32 * 5,
H::HdrPaperWhite => g.hdr_paper_white = (v * 48.0).round() as u32 * 10,
H::HdrPeak => {
g.hdr_peak = match (v * 37.0).round() as u32 {
g.hdr_peak = match (v * PEAK_POSITIONS as f32).round() as u32 {
0 => 0,
step => 150 + step * 50,
step => crate::graphics::peak_nits_at(step - 1),
}
}
H::HdrBlackLevel => g.hdr_black_level = (v * 100.0).round() as u32,
@@ -1079,7 +1076,12 @@ impl H {
H::FrameGeneration => {
g.frame_generation = step(&FrameGeneration::ALL, g.frame_generation, back)
}
H::Hdr => g.hdr = !g.hdr,
H::Hdr => {
g.hdr = step(&HdrMode::ALL, g.hdr, back);
if g.hdr == HdrMode::On && !crate::graphics::hdr_offered() {
g.hdr = step(&HdrMode::ALL, g.hdr, back);
}
}
H::HdrLighting => g.hdr_lighting = !g.hdr_lighting,
H::BatteryMode => g.battery = !g.battery,
H::ShadowCache => g.shadow_cache = !g.shadow_cache,
@@ -1112,11 +1114,9 @@ impl Item {
fn available(self) -> bool {
!matches!(self, Item::Vanilla(o) if !o.available())
}
/// Why a control can't be used now, when it can't: HDR's page works only where
/// the display offers HDR output, whatever `horizon-graphics.txt` says (a copied game
/// folder, a display swapped for an SDR one: the renderer leaves HDR off there, and
/// the saved values wait for an HDR display), and its sliders do something only
/// while it is on; Translucent Batching switches only where the device can batch
/// Why a control can't be used now: HDR's On requires an offered output, while Auto
/// can also be chosen on a reporting SDR display. Brightness controls need HDR output
/// active; saved values wait for it. Translucent Batching switches where the device can batch
/// (`graphics::translucent_batching_offered`), its saved value waiting for one that can,
/// Smooth Gradients only where it can draw the 16-bit frame, the same way, and GPU Culling
/// only where it is offered (`graphics::gpu_culling_offer`), with the reason it isn't.
@@ -1126,8 +1126,7 @@ impl Item {
}
match self {
Item::Horizon(
H::Hdr
| H::HdrCalibrate
H::HdrCalibrate
| H::HdrBlackLevel
| H::HdrPeak
| H::HdrPaperWhite
@@ -1150,7 +1149,7 @@ impl Item {
| H::HdrUiBrightness
| H::HdrLighting
| H::HdrSmoothGradients,
) if !s.horizon.hdr => Some("Turn HDR on to use it."),
) if !s.horizon.hdr_wanted() => Some("Turn HDR on to use it."),
Item::Horizon(H::TranslucentBatching)
if !crate::graphics::translucent_batching_offered() =>
{
@@ -1197,7 +1196,6 @@ impl Item {
Item::Horizon(H::Weather) => g.weather,
Item::Horizon(H::AnimatedTextures) => g.animated_textures,
Item::Horizon(H::DitherCutoutMips) => g.dither_cutout_mips,
Item::Horizon(H::Hdr) => g.hdr,
Item::Horizon(H::HdrLighting) => g.hdr_lighting,
Item::Horizon(H::BatteryMode) => g.battery,
Item::Horizon(H::ShadowCache) => g.shadow_cache,
@@ -1443,6 +1441,7 @@ impl Screen for GraphicsScreen {
use crate::graphics as g;
let offers = (
g::hdr_offered(),
g::system_hdr(),
g::smooth_gradients_offered(),
g::translucent_batching_offered(),
g::gpu_culling_offer(),
@@ -1467,6 +1466,8 @@ impl Screen for GraphicsScreen {
.collect(),
})
.collect(),
listed: true,
heading: None,
};
rows().into_iter().map(sheet).collect()
}
@@ -1514,7 +1515,7 @@ impl Screen for GraphicsScreen {
/// settings and the vanilla options on its rows. Not the other options (the narrator, or
/// one `horizon-ctl options-set` changed meanwhile), nor fullscreen, which F11 and the
/// window manager change.
fn undo(&mut self, s: &mut Settings, b: &Settings) {
fn undo(&mut self, s: &mut Settings, b: &Settings) -> Vec<Output> {
s.horizon = b.horizon.clone();
s.render_distance = b.render_distance;
s.simulation_distance = b.simulation_distance;
@@ -1551,6 +1552,7 @@ impl Screen for GraphicsScreen {
}
}
s.file.fullscreen_resolution = b.file.fullscreen_resolution.clone();
Vec::new()
}
/// Calibration can change what Undo puts back, as a row does.
fn changes(&self, outputs: &[Output]) -> bool {
@@ -1767,14 +1769,14 @@ mod tests {
// Calibration counts as a change too.
let _offer = HDR_OFFER.lock().unwrap_or_else(|e| e.into_inner());
crate::graphics::offer_hdr(true);
s.horizon.hdr = true;
s.horizon.hdr = HdrMode::On;
let calibrate = row(&mut ui, &s, Item::Horizon(H::HdrCalibrate));
press(&mut ui, &mut s, at(calibrate));
assert!(ui.changed());
// From the keyboard: Undo puts it back and goes away.
ui.set_focus(Some(Target::Undo));
assert_eq!(key(&mut ui, &mut s, K::Enter), [CLICK, CHANGED]);
assert!(!s.horizon.hdr && !ui.changed());
assert!(!s.horizon.hdr_wanted() && !ui.changed());
assert_eq!(ui.focused(), Some(Target::Done));
}
@@ -1789,7 +1791,7 @@ mod tests {
assert!(press(&mut ui, &mut s, (r[0] + 4.0, r[1] + 4.0)).is_empty());
assert_eq!(s.horizon.chunk_updates, 16);
assert!(ui.changes_value());
assert!(!ui.release());
assert!(ui.release().is_none());
// At the track's end: the most, then dragged to its start.
assert_eq!(press(&mut ui, &mut s, (tx + tw, r[1] + 4.0)), [DRAGGED]);
assert_eq!(H::ChunkUpdates.slider(&s), Some(1.0));
@@ -1798,8 +1800,8 @@ mod tests {
[DRAGGED]
);
assert_eq!(H::ChunkUpdates.slider(&s), Some(0.0));
assert!(ui.release());
assert!(!ui.release());
assert!(ui.release().is_some());
assert!(ui.release().is_none());
}
#[test]
@@ -1807,7 +1809,7 @@ mod tests {
let _offer = HDR_OFFER.lock().unwrap_or_else(|e| e.into_inner());
crate::graphics::offer_hdr(true);
let mut s = Settings::default();
s.horizon.hdr = true;
s.horizon.hdr = HdrMode::On;
let item = Item::Horizon(H::HdrSmoothGradients);
// Where the device can't, the saved On reads Off.
crate::graphics::offer_smooth_gradients(false);
@@ -1823,39 +1825,42 @@ mod tests {
assert_eq!(item.blocked(&s), None);
assert_eq!(item.label(&s).plain(), "Smooth Gradients: On");
assert_eq!(item.switch(&s), Some(true));
s.horizon.hdr = false;
s.horizon.hdr = HdrMode::Off;
assert_eq!(item.blocked(&s), Some("Turn HDR on to use it."));
}
#[test]
fn hdr_waits_for_a_display_that_offers_it_and_its_sliders_for_the_switch() {
fn hdr_auto_can_be_selected_before_querying_and_brightness_waits_for_output() {
let _offer = HDR_OFFER.lock().unwrap_or_else(|e| e.into_inner());
let mut s = Settings::default();
let mut ui = ui();
let at = |[x, y, ..]: [f32; 4]| (x + 8.0, y + 4.0);
crate::graphics::offer_hdr(false);
// A 720p window opened at HDR shows only its greyed rows: Down still reaches the shortcut.
// Off has not asked the system yet: Auto must still be reachable to start the watch.
let short = (427.0, 240.0);
let mut at_hdr = Ui::default();
at_hdr.show(Page::Hdr);
at_hdr.key(&mut s, K::ArrowDown, None, false, short);
assert_eq!(at_hdr.focused_row(), Some(Item::Horizon(H::HdrKey)));
assert_eq!(at_hdr.focused_row(), Some(Item::Horizon(H::Hdr)));
let hdr = row(&mut ui, &s, Item::Horizon(H::Hdr));
assert!(press(&mut ui, &mut s, at(hdr)).is_empty());
// Not even with HDR saved on, from another computer: nor its sliders and calibration.
s.horizon.hdr = true;
for h in [H::Hdr, H::HdrCalibrate, H::HdrPeak, H::HdrPaperWhite] {
assert_eq!(press(&mut ui, &mut s, at(hdr)), [CLICK, CHANGED]);
assert_eq!(s.horizon.hdr, HdrMode::Auto);
assert_eq!(press(&mut ui, &mut s, at(hdr)), [CLICK, CHANGED]);
assert_eq!(s.horizon.hdr, HdrMode::Off);
// Brightness still needs an output, even with On saved from another computer.
s.horizon.hdr = HdrMode::On;
for h in [H::HdrCalibrate, H::HdrPeak, H::HdrPaperWhite] {
assert!(Item::Horizon(h).blocked(&s).is_some(), "{h:?}");
}
s.horizon.hdr = false;
s.horizon.hdr = HdrMode::Off;
crate::graphics::offer_hdr(true);
let (tx, _) = Columns::of(SIZE.0, SIZE.1).track();
let peak = row(&mut ui, &s, Item::Horizon(H::HdrPeak));
assert!(press(&mut ui, &mut s, (tx + 2.0, peak[1] + 4.0)).is_empty());
// The switch turns it on, and the sliders below it work.
// The choice turns it on, and the sliders below it work.
let hdr = row(&mut ui, &s, Item::Horizon(H::Hdr));
assert_eq!(press(&mut ui, &mut s, at(hdr)), [CLICK, CHANGED]);
assert!(s.horizon.hdr);
assert!(s.horizon.hdr_wanted());
let peak = row(&mut ui, &s, Item::Horizon(H::HdrPeak));
assert_eq!(press(&mut ui, &mut s, (tx + 2.0, peak[1] + 4.0)), [DRAGGED]);
ui.release();
@@ -1873,6 +1878,33 @@ mod tests {
assert!(ui.changed());
}
#[test]
fn hdr_auto_is_a_choice_on_a_reporting_sdr_display() {
let _offer = HDR_OFFER.lock().unwrap_or_else(|e| e.into_inner());
crate::graphics::offer_hdr(false);
crate::graphics::report_system_hdr(Some(false));
let mut s = Settings::default();
let item = Item::Horizon(H::Hdr);
assert_eq!(item.switch(&s), None);
assert_eq!(item.blocked(&s), None);
item.cycle(&mut s, false, FB);
assert_eq!(s.horizon.hdr, HdrMode::Auto); // On requires an offered output.
assert!(!s.horizon.hdr_wanted());
assert!(Item::Horizon(H::HdrPeak).blocked(&s).is_some());
item.cycle(&mut s, true, FB);
assert_eq!(s.horizon.hdr, HdrMode::Off);
crate::graphics::offer_hdr(true);
for expected in [HdrMode::On, HdrMode::Auto, HdrMode::Off] {
item.cycle(&mut s, false, FB);
assert_eq!(s.horizon.hdr, expected);
}
s.horizon.hdr = HdrMode::Auto;
crate::graphics::report_system_hdr(Some(true));
assert!(s.horizon.hdr_wanted());
assert_eq!(Item::Horizon(H::HdrPeak).blocked(&s), None);
crate::graphics::report_system_hdr(None);
}
#[test]
fn the_scrollbar_can_be_held_and_dragged() {
// #3552: AbstractScrollArea.updateScrolling holds the scrollbar on a press over its
@@ -1891,7 +1923,7 @@ mod tests {
assert_eq!(ui.scroll_amount(), content - (cols.bottom - cols.top));
drag(&mut ui, &mut s, cols.top - 50.0, true);
assert_eq!(ui.scroll_amount(), 0.0);
assert!(!ui.release());
assert!(ui.release().is_none());
drag(&mut ui, &mut s, cols.bottom + 50.0, true);
assert_eq!(ui.scroll_amount(), 0.0);
}
@@ -2730,8 +2762,8 @@ mod tests {
let pos = i as f32 / steps as f32;
let mut s1 = Settings::default();
let mut s2 = Settings::default();
s1.horizon.hdr = true;
s2.horizon.hdr = true;
s1.horizon.hdr = HdrMode::On;
s2.horizon.hdr = HdrMode::On;
h.set_slider(&mut s1, pos);
item.step_slider(&mut s1, true, 200.0);
@@ -2747,8 +2779,8 @@ mod tests {
let pos = i as f32 / steps as f32;
let mut s1 = Settings::default();
let mut s2 = Settings::default();
s1.horizon.hdr = true;
s2.horizon.hdr = true;
s1.horizon.hdr = HdrMode::On;
s2.horizon.hdr = HdrMode::On;
h.set_slider(&mut s1, pos);
item.step_slider(&mut s1, false, 200.0);
@@ -2762,6 +2794,81 @@ mod tests {
}
}
}
#[test]
fn hdr_sliders_step_through_every_value_they_can_be_set_to() {
let _offer = HDR_OFFER.lock().unwrap_or_else(|e| e.into_inner());
crate::graphics::offer_hdr(true);
let value = |h: H, s: &Settings| match h {
H::HdrPaperWhite => s.horizon.hdr_paper_white,
H::HdrPeak => s.horizon.hdr_peak,
H::HdrBlackLevel => s.horizon.hdr_black_level,
H::HdrUiBrightness => s.horizon.hdr_ui_brightness,
_ => unreachable!(),
};
let fresh = || {
let mut s = Settings::default();
s.horizon.hdr = HdrMode::On;
s
};
for h in [
H::HdrPaperWhite,
H::HdrPeak,
H::HdrBlackLevel,
H::HdrUiBrightness,
] {
// Everything a drag can land on, each read back where the slider puts it.
let mut set = std::collections::BTreeSet::new();
for i in 0..=10_000 {
let mut s = fresh();
h.set_slider(&mut s, i as f32 / 10_000.0);
let v = value(h, &s);
set.insert(v);
let mut again = fresh();
h.set_slider(&mut again, h.slider(&s).unwrap());
assert_eq!(value(h, &again), v, "{h:?} round trip");
}
// Everything a key or the controller reaches, from the bottom to the top.
let mut s = fresh();
h.set_slider(&mut s, 0.0);
let mut stepped = std::collections::BTreeSet::from([value(h, &s)]);
for _ in 0..h.slider_steps().unwrap() {
Item::Horizon(h).step_slider(&mut s, true, 200.0);
stepped.insert(value(h, &s));
}
assert_eq!(stepped, set, "{h:?}");
}
// Auto, then 10..480 by 10; Auto, then 200..2000 by 50 and 2500..10 000 by 500;
// 0..1 nit by 0.01 with 0 no lift.
let mut s = fresh();
let reach = |h: H, s: &mut Settings| {
h.set_slider(s, 0.0);
let mut all = vec![value(h, s)];
for _ in 0..h.slider_steps().unwrap() {
Item::Horizon(h).step_slider(s, true, 200.0);
all.push(value(h, s));
}
all
};
let paper: Vec<u32> = std::iter::once(0).chain((10..=480).step_by(10)).collect();
assert_eq!(reach(H::HdrPaperWhite, &mut s), paper);
let peak: Vec<u32> = std::iter::once(0)
.chain((200..=2000).step_by(50))
.chain((2500..=10_000).step_by(500))
.collect();
assert_eq!(reach(H::HdrPeak, &mut s), peak);
assert_eq!(
reach(H::HdrBlackLevel, &mut s),
(0..=100).collect::<Vec<u32>>()
);
}
#[test]
fn black_level_reads_as_its_lift() {
let mut s = Settings::default();
let label = |s: &Settings| Item::Horizon(H::HdrBlackLevel).label(s).plain();
assert_eq!(label(&s), "Black Level: 0.00");
s.horizon.hdr_black_level = 5;
assert_eq!(label(&s), "Black Level: 0.05");
}
/// Horizon's graphics captions are English sentences that the language table keys by
/// themselves, so a caption, description or value that is not in `en_us.json` shows in
/// English in every language. Every text the screen shows, with each setting cycled
@@ -2769,7 +2876,7 @@ mod tests {
#[test]
fn every_graphics_text_is_in_the_language_table() {
let (_, missed) = lang::scoped(lang::Language::load(&[], "de_de"), || {
for hdr in [false, true] {
for hdr in HdrMode::ALL {
let mut s = Settings::default();
s.horizon.hdr = hdr;
s.horizon.taa = true;
+42 -3
View File
@@ -261,21 +261,27 @@ fn tone_scale(top: f32, headroom: f32, paper: f32) -> f32 {
}
// Hdr::keep_world: the HUD's backdrop, the world clipped to white with alpha 0, the frame as
// the HUD found it before.
// the HUD found it before. The HUD's target is 8-bit, as that frame was.
@fragment fn fs_keep(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> {
let w = textureLoad(frame, vec2<i32>(pos.xy), 0).rgb;
return vec4(clamp(w, vec3(0.0), vec3(1.0)), 0.0);
}
// The backdrop `fs_keep` left in the HUD's 8-bit target for world `w`: where no HUD draws, the
// target holds exactly this, so the HUD's own part below is exactly 0 there.
fn backdrop(w: vec3<f32>) -> vec3<f32> {
return round(clamp(w, vec3(0.0), vec3(1.0)) * 255.0) / 255.0;
}
// The HUD over the world in gamma, the space the HUD blends in: its own part, what its backdrop
// B doesn't explain (H = P - (1 - c) B, exact for the layering blends), at UI Brightness, over
// (1 - c) of the world, light above white included. Where the world is at or below white, at
// 100%, that is the HUD's picture as drawn.
fn layered(at: vec2<i32>) -> vec3<f32> {
let w = max(textureLoad(frame, at, 0).rgb, vec3(0.0));
let p = clamp(textureLoad(hud, at, 0).rgb, vec3(0.0), vec3(1.0));
let p = textureLoad(hud, at, 0).rgb;
let c = textureLoad(coverage, at, 0).r;
let h = max(p - (1.0 - c) * min(w, vec3(1.0)), vec3(0.0));
let h = max(p - (1.0 - c) * backdrop(w), vec3(0.0));
return h * pow(params.ui, 1.0 / 2.2) + (1.0 - c) * w;
}
@@ -284,6 +290,39 @@ fn layered(at: vec2<i32>) -> vec3<f32> {
return vec4(layered(vec2<i32>(pos.xy)), 1.0);
}
// Screenshots keep midtones at their drawn brightness, with a shoulder starting at 0.6
// paper white in linear light. Unlike the display curve (whose knee is at least white),
// this has an unbounded source range: k + (1-k)*(top-k)/(top+1-2*k) approaches SDR
// white smoothly, with slope 1 at k. One scale keeps the world's linear RGB ratios.
fn screenshot_world(w: vec3<f32>) -> vec3<f32> {
let top = pow(max(w.r, max(w.g, w.b)), 2.2);
if top <= 0.6 {
return w;
}
let mapped = 0.6 + 0.4 * (top - 0.6) / (top - 0.2);
return w * pow(mapped / top, 1.0 / 2.2);
}
override screenshot_world_only: bool = false;
// Off where Horizon lifts nothing (HDR Lighting off, or a shader pack's picture): the world is
// clipped at white as SDR draws it.
override screenshot_shoulder: bool = true;
// The ordinary PNG, or the world's icon before the HUD: H as drawn, at 100%, over
// the SDR world's light. This never changes the targets used by HDR output or its copy.
@fragment fn fs_sdr_screenshot(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> {
let at = vec2<i32>(pos.xy);
let w = max(textureLoad(frame, at, 0).rgb, vec3(0.0));
let mapped = select(min(w, vec3(1.0)), screenshot_world(w), screenshot_shoulder);
if screenshot_world_only {
return vec4(mapped, 1.0);
}
let p = textureLoad(hud, at, 0).rgb;
let c = textureLoad(coverage, at, 0).r;
let h = max(p - (1.0 - c) * backdrop(w), vec3(0.0));
return vec4(h + (1.0 - c) * mapped, 1.0);
}
@fragment fn fs_hdr_layered(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> {
if calibrating(pos) {
return calibration_picture(pos);
@@ -47,8 +47,8 @@ pub struct Calibration {
step: usize,
/// Nits, in the order of `STEPS`.
values: [f32; 3],
/// Which values were Auto (peak, paper white): saved as Auto again unless the
/// player moved them.
/// Which values were Auto (peak, paper white; black has none): saved as Auto again
/// unless the player moved them.
auto: [Option<f32>; 3],
dragging: bool,
}
@@ -57,12 +57,29 @@ pub struct Calibration {
const BAND: f32 = 84.0;
impl Step {
/// Range and step of the slider, in nits: the settings' own.
fn range(self) -> (f32, f32, f32) {
/// The slider's steps past its first: the settings' own.
fn steps(self) -> u32 {
match self {
Step::Black => (0.0, 1.0, 0.01),
Step::Peak => (200.0, 2000.0, 50.0),
Step::PaperWhite => (10.0, 480.0, 10.0),
Step::Black => 100,
Step::Peak => crate::graphics::PEAK_STEPS,
Step::PaperWhite => 47,
}
}
/// The nits at `step` of the slider: black 0..1 by 0.01, the peak's scale, paper
/// white 10..480 by 10.
fn nits_at(self, step: u32) -> f32 {
match self {
Step::Black => step as f32 / 100.0,
Step::Peak => crate::graphics::peak_nits_at(step) as f32,
Step::PaperWhite => 10.0 + step as f32 * 10.0,
}
}
/// Where `nits` sits on the slider, in steps.
fn step_of(self, nits: f32) -> f32 {
match self {
Step::Black => nits * 100.0,
Step::Peak => crate::graphics::peak_step_of(nits),
Step::PaperWhite => (nits - 10.0) / 10.0,
}
}
/// The English name, which is also its key in the language table.
@@ -130,7 +147,7 @@ impl Calibration {
g.paper_white_nits() as f32,
],
auto: [
(g.hdr_black_level == 0).then_some(g.black_level_nits()),
None,
(g.hdr_peak == 0).then_some(g.peak_nits() as f32),
(g.hdr_paper_white == 0).then_some(g.paper_white_nits() as f32),
],
@@ -140,9 +157,21 @@ impl Calibration {
fn current(&self) -> Step {
STEPS[self.step]
}
fn set(&mut self, v: f32) {
let (min, max, step) = self.current().range();
self.values[self.step] = (min + ((v - min) / step).round() * step).clamp(min, max);
/// The slider's position, 0..=1.
fn position(&self) -> f32 {
let step = self.current();
step.step_of(self.values[self.step]) / step.steps() as f32
}
/// Puts the slider on its step nearest `position`.
fn set(&mut self, position: f32) {
let step = self.current();
let steps = step.steps() as f32;
self.values[self.step] = step.nits_at((position * steps).round().clamp(0.0, steps) as u32);
}
/// Moves the slider `by` steps from the one nearest its value.
fn step_by(&mut self, by: f32) {
let steps = self.current().steps() as f32;
self.set(((self.position() * steps).round() + by) / steps);
}
fn label(&self) -> Text {
let v = self.values[self.step];
@@ -184,7 +213,6 @@ impl Calibration {
w,
rgba(160, 160, 160, 255),
);
let (min, max, _) = step.range();
let (x, y, sw) = l.slider;
ui::slider(
batch,
@@ -193,7 +221,7 @@ impl Calibration {
y,
sw,
&self.label(),
(self.values[self.step] - min) / (max - min),
self.position(),
over(cursor, l.slider) || self.dragging,
true,
self.dragging,
@@ -238,14 +266,12 @@ impl Calibration {
if !self.dragging {
return Outcome::Nothing;
}
let (min, max, _) = self.current().range();
self.set(min + ui::slider_value(x, sw, cursor.0) * (max - min));
self.set(ui::slider_value(x, sw, cursor.0));
return Outcome::Changed;
}
if over(cursor, l.slider) {
self.dragging = true;
let (min, max, _) = self.current().range();
self.set(min + ui::slider_value(x, sw, cursor.0) * (max - min));
self.set(ui::slider_value(x, sw, cursor.0));
return Outcome::Changed;
}
if over(cursor, l.cancel) {
@@ -273,14 +299,13 @@ impl Calibration {
}
/// Left and Right step the slider, Enter goes on, Escape cancels.
pub fn key(&mut self, key: KeyCode) -> Outcome {
let (_, _, step) = self.current().range();
match key {
KeyCode::ArrowLeft | KeyCode::ArrowDown => {
self.set(self.values[self.step] - step);
self.step_by(-1.0);
Outcome::Changed
}
KeyCode::ArrowRight | KeyCode::ArrowUp => {
self.set(self.values[self.step] + step);
self.step_by(1.0);
Outcome::Changed
}
KeyCode::Enter | KeyCode::NumpadEnter => self.next(),
@@ -328,4 +353,73 @@ mod tests {
assert_eq!(s.horizon.hdr_peak, before.peak_nits() + 50);
assert_eq!(s.horizon.hdr_paper_white, before.hdr_paper_white);
}
#[test]
fn each_step_reaches_every_value_of_its_setting() {
// Black Level 0..1 nit by 0.01; Peak Brightness 200..2000 by 50, then by 500 to PQ's
// 10 000; Paper White 10..480 by 10.
let expected: [Vec<f32>; 3] = [
(0..=100).map(|n| n as f32 / 100.0).collect(),
(200..=2000)
.step_by(50)
.chain((2500..=10_000).step_by(500))
.map(|n| n as f32)
.collect(),
(10..=480).step_by(10).map(|n| n as f32).collect(),
];
let mut c = Calibration::new(&Settings::default());
for (i, expected) in expected.iter().enumerate() {
assert_eq!(c.picture(1.0).0, STEPS[i]);
for _ in 0..200 {
c.key(KeyCode::ArrowLeft);
}
let mut seen = vec![c.picture(1.0).1];
for _ in 0..200 {
c.key(KeyCode::ArrowRight);
let v = c.picture(1.0).1;
if seen.last() != Some(&v) {
seen.push(v);
}
}
assert_eq!(&seen, expected, "{:?}", STEPS[i]);
c.key(KeyCode::Enter);
}
}
#[test]
fn black_level_saves_the_lift_it_shows_and_peak_reaches_10000() {
let mut s = Settings::default();
let mut c = Calibration::new(&s);
// Untouched, no lift stays no lift.
c.save(&mut s);
assert_eq!(s.horizon.hdr_black_level, 0);
c.key(KeyCode::ArrowRight);
c.key(KeyCode::ArrowRight);
c.save(&mut s);
assert_eq!(s.horizon.hdr_black_level, 2);
// Calibrated back down, 0 is no lift: what the step showed.
let mut c = Calibration::new(&s);
assert!((c.picture(1.0).1 - 0.02).abs() < 1e-6);
c.key(KeyCode::ArrowLeft);
c.key(KeyCode::ArrowLeft);
c.key(KeyCode::Enter);
for _ in 0..60 {
c.key(KeyCode::ArrowRight);
}
c.save(&mut s);
assert_eq!(s.horizon.hdr_black_level, 0);
assert_eq!(s.horizon.black_level_nits(), 0.0);
assert_eq!(s.horizon.hdr_peak, 10_000);
let mut c = Calibration::new(&s);
c.key(KeyCode::Enter);
assert_eq!(c.picture(1.0).1, 10_000.0);
// One step down from the top is 500 nits; from 2000, 50.
c.key(KeyCode::ArrowLeft);
assert_eq!(c.picture(1.0).1, 9500.0);
s.horizon.hdr_peak = 2000;
let mut c = Calibration::new(&s);
c.key(KeyCode::Enter);
c.key(KeyCode::ArrowLeft);
assert_eq!(c.picture(1.0).1, 1950.0);
}
}
+182 -54
View File
@@ -3,6 +3,7 @@
//! GUI scale is applied when drawing).
use crate::Result;
use horizon_assets::animation::AnimationMetadata;
use smallvec::SmallVec;
use std::{
collections::{HashMap, HashSet},
fs,
@@ -902,18 +903,20 @@ impl HudAtlas {
}
/// Empty slots written at run time: map textures (game/maps.rs), the
/// minimap, a native mod's own image, downloaded skins, world and pack
/// icons and the on-screen keyboard's text.
/// icons and the on-screen keyboard's text. Their pixels are already zero
/// in the destination atlas, so keep the source buffers empty and avoid
/// duplicating the atlas-sized allocation during packing.
fn slots(images: &mut Vec<PackedImage>, trim: (u32, u32), mod_image: bool) {
for i in 0..MAP_SLOTS {
let name = map_slot_sprite(i).unwrap_or_default();
images.push((name.into(), 128, 128, vec![0; 128 * 128 * 4]));
images.push((name.into(), 128, 128, Vec::new()));
}
// The minimap extra's image (game/extras.rs).
images.push((
MINIMAP_SPRITE.into(),
crate::minimap::SIZE as u32,
crate::minimap::SIZE as u32,
vec![0; crate::minimap::SIZE * crate::minimap::SIZE * 4],
Vec::new(),
));
// A mod's own image (native `mod_image`), only when a mod is loaded.
if mod_image {
@@ -921,45 +924,29 @@ impl HudAtlas {
MOD_IMAGE_SPRITE.into(),
MOD_IMAGE_SIZE,
MOD_IMAGE_SIZE,
vec![0; (MOD_IMAGE_SIZE * MOD_IMAGE_SIZE * 4) as usize],
Vec::new(),
));
}
// Downloaded skins (game/scoreboard.rs).
for i in 0..SKIN_SLOTS {
images.push((format!("horizon/skin{i}"), 64, 64, vec![0; 64 * 64 * 4]));
images.push((format!("horizon/skin{i}"), 64, 64, Vec::new()));
}
for i in 0..TRIM_SLOTS {
images.push((
format!("horizon/trim{i}"),
trim.0,
trim.1,
vec![0; (trim.0 * trim.1 * 4) as usize],
));
images.push((format!("horizon/trim{i}"), trim.0, trim.1, Vec::new()));
}
for i in 0..WORLD_ICON_SLOTS {
images.push((
format!("horizon/world-icon{i}"),
64,
64,
vec![0; 64 * 64 * 4],
));
images.push((format!("horizon/world-icon{i}"), 64, 64, Vec::new()));
}
let side = PACK_ICON_SIZE as usize;
for i in 0..PACK_ICON_SLOTS {
images.push((
format!("horizon/pack-icon{i}"),
PACK_ICON_SIZE,
PACK_ICON_SIZE,
vec![0; side * side * 4],
Vec::new(),
));
}
// The on-screen keyboard's text and corner masks (ui_theme), rasterized at run time.
images.push((
"horizon/ui-text".into(),
2048,
1024,
vec![0; 2048 * 1024 * 4],
));
images.push(("horizon/ui-text".into(), 2048, 1024, Vec::new()));
}
/// Only the font's metrics, for a game that draws nothing (the
/// integrated or dedicated server): text measures as with the full atlas,
@@ -1528,10 +1515,12 @@ impl HudAtlas {
{
translucent.insert(name.clone());
}
for row in 0..*h {
let src = &data[(row * w * 4) as usize..((row + 1) * w * 4) as usize];
let dst = (((py + row) * width + px) * 4) as usize;
pixels[dst..dst + (w * 4) as usize].copy_from_slice(src);
if !data.is_empty() {
for row in 0..*h {
let src = &data[(row * w * 4) as usize..((row + 1) * w * 4) as usize];
let dst = (((py + row) * width + px) * 4) as usize;
pixels[dst..dst + (w * 4) as usize].copy_from_slice(src);
}
}
let b = u32::from(bordered.contains(name));
let (px, py, w, h) = (px + b, py + b, w - 2 * b, h - 2 * b);
@@ -1745,6 +1734,10 @@ pub enum Blend {
Additive,
/// Alpha blending with an isolated depth buffer and a pixel scissor.
Depth([u32; 4]),
/// The glint over the `Depth` run before it with the same scissor, in its
/// pass: `RenderPipelines.GLINT`'s additive blend, depth tested against the
/// model and never written.
DepthGlint([u32; 4]),
/// `RenderPipelines.VIGNETTE`: the destination darkened by the texture
/// (`ZERO, ONE_MINUS_SRC_COLOR`).
Vignette,
@@ -1842,12 +1835,31 @@ impl HudBatch {
}
out
}
fn runs_in(&self, vertices: std::ops::Range<u32>) -> Vec<(std::ops::Range<u32>, Blend)> {
let mut runs = self.runs();
for (range, _) in &mut runs {
*range = range.start.max(vertices.start)..range.end.min(vertices.end);
fn runs_in(
&self,
vertices: std::ops::Range<u32>,
) -> SmallVec<[(std::ops::Range<u32>, Blend); 16]> {
let mut runs = SmallVec::new();
let mut at = vertices.start;
for (range, blend) in &self.blends {
if range.start > at {
let start = at.max(vertices.start);
let end = range.start.min(vertices.end);
if start < end {
runs.push((start..end, Blend::Alpha));
}
}
let start = range.start.max(vertices.start);
let end = range.end.min(vertices.end);
if start < end {
runs.push((start..end, *blend));
}
at = at.max(range.end);
}
let end = (self.vertices.len() as u32).min(vertices.end);
if at < end {
runs.push((at.max(vertices.start)..end, Blend::Alpha));
}
runs.retain(|(range, _)| !range.is_empty());
runs
}
/// `AbstractContainerScreen.init`: center in integer GUI coordinates.
@@ -1901,8 +1913,39 @@ impl HudBatch {
});
}
}
/// `RenderTypes.armorCutoutNoCullGlint` (and the items' glint types) over a
/// [`Self::quad_3d`] face: `uv` is the face's atlas UV,
/// which the shader masks by, and `glint_uv` the transformed glint UV.
/// `armor` samples `enchanted_glint_armor` (`ARMOR_ENTITY_GLINT`), otherwise
/// `enchanted_glint_item`. Drawn by [`Self::depth_glint_since`].
pub fn quad_3d_glint(
&mut self,
p: [[f32; 3]; 4],
uv: [[f32; 2]; 4],
glint_uv: [[f32; 2]; 4],
armor: bool,
) {
let marker = if armor { 4.0 } else { 2.0 };
for i in [0, 1, 2, 0, 2, 3] {
self.vertices.push(HudVertex {
pos: [p[i][0], p[i][1]],
uv: [uv[i][0] + marker, uv[i][1]],
color: u32::MAX,
depth: p[i][2],
glint_uv: glint_uv[i],
text: 0,
});
}
}
/// Keep per-vertex depth for an orthographic GUI model, with +z toward the viewer.
pub fn depth_since(&mut self, start: usize, clip: [f32; 4]) {
self.depth_glint_since(start, self.vertices.len(), clip);
}
/// `RenderPipelines.GLINT` after a GUI model: [`Self::depth_since`] for a model whose glint faces
/// ([`Self::quad_3d_glint`]) follow its own from `glint`: both share the
/// depth buffer, and the glint is drawn over the model in the same pass.
pub fn depth_glint_since(&mut self, start: usize, glint: usize, clip: [f32; 4]) {
let glint = glint.clamp(start, self.vertices.len());
let vertices = &mut self.vertices[start..];
if vertices.is_empty() {
return;
@@ -1919,11 +1962,17 @@ impl HudBatch {
v.depth = (max - v.depth) / (max - min).max(f32::EPSILON);
}
let [x0, y0, x1, y1] = clip.map(|v| (v * self.scale).max(0.0) as u32);
let clip = [x0, y0, x1.saturating_sub(x0), y1.saturating_sub(y0)];
// Separate previews clear depth independently, even if their scissors match.
self.blends.push((
start as u32..self.vertices.len() as u32,
Blend::Depth([x0, y0, x1.saturating_sub(x0), y1.saturating_sub(y0)]),
));
if glint > start {
self.blends
.push((start as u32..glint as u32, Blend::Depth(clip)));
}
let end = self.vertices.len();
if end > glint {
self.blends
.push((glint as u32..end as u32, Blend::DepthGlint(clip)));
}
}
pub fn rect_uv(&mut self, x: f32, y: f32, w: f32, h: f32, r: Rect, color: u32) {
self.quad(
@@ -2537,6 +2586,8 @@ pub struct HudRenderer {
invert_pipeline: Pipeline,
additive_pipeline: Pipeline,
depth_pipeline: Pipeline,
/// `RenderPipelines.GLINT` over a GUI model (`Blend::DepthGlint`).
depth_glint_pipeline: Pipeline,
vignette_pipeline: Pipeline,
end_portal_pipeline: Pipeline,
/// The layout of `group`, which the CIT glint pipelines share.
@@ -2562,6 +2613,7 @@ impl HudRenderer {
format: wgpu::TextureFormat,
view: &wgpu::TextureView,
glint_view: &wgpu::TextureView,
glint_armor_view: &wgpu::TextureView,
glint_sampler: &wgpu::Sampler,
coverage: bool,
) -> Result<Self> {
@@ -2636,6 +2688,16 @@ impl HudRenderer {
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
count: None,
},
wgpu::BindGroupLayoutEntry {
binding: 6,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Float { filterable: true },
view_dimension: wgpu::TextureViewDimension::D2,
multisampled: false,
},
count: None,
},
],
});
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
@@ -2666,6 +2728,10 @@ impl HudRenderer {
binding: 5,
resource: wgpu::BindingResource::Sampler(&linear_sampler),
},
wgpu::BindGroupEntry {
binding: 6,
resource: wgpu::BindingResource::TextureView(glint_armor_view),
},
],
});
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
@@ -2675,7 +2741,8 @@ impl HudRenderer {
});
let attributes =
wgpu::vertex_attr_array![0=>Float32x2,1=>Float32x2,2=>Unorm8x4,3=>Float32,4=>Float32x2];
let create = |label, blend, fragment_entry, depth: bool| {
// `depth`: the model's depth test, and whether the run writes depth.
let create = |label, blend, fragment_entry, depth: Option<bool>| {
let desc = wgpu::RenderPipelineDescriptor {
label: Some(label),
layout: Some(&pipeline_layout),
@@ -2690,9 +2757,9 @@ impl HudRenderer {
})],
},
primitive: Default::default(),
depth_stencil: depth.then_some(wgpu::DepthStencilState {
depth_stencil: depth.map(|write| wgpu::DepthStencilState {
format: wgpu::TextureFormat::Depth32Float,
depth_write_enabled: Some(true),
depth_write_enabled: Some(write),
depth_compare: Some(wgpu::CompareFunction::LessEqual),
stencil: Default::default(),
bias: Default::default(),
@@ -2714,12 +2781,12 @@ impl HudRenderer {
let source = wgpu::ShaderSource::Wgsl(include_str!("hud.wgsl").into());
Pipeline::new(device, &desc, source, coverage).expect("the HUD's coverage")
};
let pipeline = create("HUD", wgpu::BlendState::ALPHA_BLENDING, "fs_main", false);
let pipeline = create("HUD", wgpu::BlendState::ALPHA_BLENDING, "fs_main", None);
let depth_pipeline = create(
"HUD model",
wgpu::BlendState::ALPHA_BLENDING,
"fs_main",
true,
Some(true),
);
// BlendFunction.INVERT: ONE_MINUS_DST_COLOR, ONE_MINUS_SRC_COLOR,
// ONE, ZERO.
@@ -2738,7 +2805,7 @@ impl HudRenderer {
},
},
"fs_crosshair",
false,
None,
);
// BlendFunction.ADDITIVE: ONE, ONE.
let add = wgpu::BlendComponent {
@@ -2753,7 +2820,18 @@ impl HudRenderer {
alpha: add,
},
"fs_main",
false,
None,
);
// RenderPipelines.GLINT tests depth EQUAL and writes none; the faces are
// the model's own, so LEQUAL passes the same pixels.
let depth_glint_pipeline = create(
"HUD model glint",
wgpu::BlendState {
color: add,
alpha: add,
},
"fs_main",
Some(false),
);
// RenderPipelines.VIGNETTE: ZERO, ONE_MINUS_SRC_COLOR, ZERO, ONE.
let vignette_pipeline = create(
@@ -2771,14 +2849,14 @@ impl HudRenderer {
},
},
"fs_vignette",
false,
None,
);
// RenderPipelines.END_PORTAL: opaque.
let end_portal_pipeline = create(
"HUD end portal",
wgpu::BlendState::REPLACE,
"fs_end_portal",
false,
None,
);
let capacity = 1 << 16;
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
@@ -2794,6 +2872,7 @@ impl HudRenderer {
invert_pipeline,
additive_pipeline,
depth_pipeline,
depth_glint_pipeline,
vignette_pipeline,
end_portal_pipeline,
layout,
@@ -3001,7 +3080,8 @@ impl HudRenderer {
if vertices.is_empty() || size.contains(&0) {
return;
}
let mut attachments = vec![Some(wgpu::RenderPassColorAttachment {
let mut attachments = [None, None];
attachments[0] = Some(wgpu::RenderPassColorAttachment {
view: target,
resolve_target: None,
depth_slice: None,
@@ -3009,29 +3089,38 @@ impl HudRenderer {
load: wgpu::LoadOp::Load,
store: wgpu::StoreOp::Store,
},
})];
});
let attachment_count;
if let Some(view) = coverage {
attachments.push(Some(hud_coverage::attachment(view)));
attachments[1] = Some(hud_coverage::attachment(view));
attachment_count = 2;
} else {
attachment_count = 1;
}
let runs = batch.runs_in(vertices);
let mut at = 0;
while at < runs.len() {
let clip = match runs[at].1 {
Blend::Depth(clip) => Some(clip),
Blend::Depth(clip) | Blend::DepthGlint(clip) => Some(clip),
_ => None,
};
let end = if clip.is_some() {
let end = if let Some(clip) = clip {
// The model's glint shares its pass and depth buffer.
at + 1
+ runs[at + 1..]
.iter()
.take_while(|(_, b)| *b == Blend::DepthGlint(clip))
.count()
} else {
runs[at..]
.iter()
.position(|(_, b)| matches!(b, Blend::Depth(_)))
.position(|(_, b)| matches!(b, Blend::Depth(_) | Blend::DepthGlint(_)))
.map_or(runs.len(), |n| at + n)
};
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("HUD"),
timestamp_writes: timing.as_deref_mut().and_then(|t| t.writes("HUD")),
color_attachments: &attachments,
color_attachments: &attachments[..attachment_count],
depth_stencil_attachment: clip.map(|_| wgpu::RenderPassDepthStencilAttachment {
view: depth,
depth_ops: Some(wgpu::Operations {
@@ -3060,6 +3149,7 @@ impl HudRenderer {
Blend::Invert => &self.invert_pipeline,
Blend::Additive => &self.additive_pipeline,
Blend::Depth(_) => &self.depth_pipeline,
Blend::DepthGlint(_) => &self.depth_glint_pipeline,
Blend::Vignette => &self.vignette_pipeline,
Blend::EndPortal => &self.end_portal_pipeline,
Blend::CitGlint(blend) => {
@@ -4601,6 +4691,44 @@ texture=wide",
);
}
#[test]
fn a_gui_models_glint_follows_it_in_its_depth_pass() {
let mut batch = HudBatch::new([640, 480]);
batch.scale = 2.0;
let face = |z: f32| [[0.0, 0.0, z], [0.0, 1.0, z], [1.0, 1.0, z], [1.0, 0.0, z]];
let uv = [[0.25, 0.5]; 4];
let start = batch.vertices.len();
batch.quad_3d(face(1.0), uv, u32::MAX);
batch.quad_3d(face(3.0), uv, u32::MAX);
let glint = batch.vertices.len();
batch.quad_3d_glint(face(3.0), uv, [[7.0, 8.0]; 4], true);
batch.quad_3d_glint(face(1.0), uv, [[1.0, 2.0]; 4], false);
batch.depth_glint_since(start, glint, [1.0, 2.0, 4.0, 6.0]);
assert_eq!(
batch.runs(),
vec![
(0..12, Blend::Depth([2, 4, 6, 8])),
(12..24, Blend::DepthGlint([2, 4, 6, 8])),
]
);
// The glint shares its face's depth, normalised with the model's.
assert_eq!(batch.vertices[12].depth, batch.vertices[6].depth);
assert_eq!(batch.vertices[18].depth, batch.vertices[0].depth);
// The shader picks the armour glint by u >= 4, the item's by u >= 2,
// and masks by the atlas UV under the marker.
assert_eq!(batch.vertices[12].uv, [4.25, 0.5]);
assert_eq!(batch.vertices[12].glint_uv, [7.0, 8.0]);
assert_eq!(batch.vertices[18].uv, [2.25, 0.5]);
// A model with no glint keeps a single depth run.
let start = batch.vertices.len();
batch.quad_3d(face(1.0), uv, u32::MAX);
batch.depth_glint_since(start, batch.vertices.len(), [1.0, 2.0, 4.0, 6.0]);
assert_eq!(
batch.runs().last(),
Some(&(24..30, Blend::Depth([2, 4, 6, 8])))
);
}
#[test]
fn hud_shader_validates_both_fragment_entries() {
let module = naga::front::wgsl::parse_str(include_str!("hud.wgsl")).unwrap();
+10 -3
View File
@@ -12,6 +12,8 @@ struct Hud {
@group(0) @binding(3) var glint_tex: texture_2d<f32>;
@group(0) @binding(4) var glint_sampler: sampler;
@group(0) @binding(5) var linear_sampler: sampler;
// enchanted_glint_armor, the armour layers' GlintSampler (entity.wgsl's glint_armor_tex).
@group(0) @binding(6) var glint_armor_tex: texture_2d<f32>;
struct Out {
@builtin(position) clip: vec4<f32>,
@location(0) uv: vec2<f32>,
@@ -32,11 +34,16 @@ struct Out {
}
@fragment fn fs_main(in: Out) -> @location(0) vec4<f32> {
// u >= 2: the GLINT_TEXTURING sample over the item, masked by the item's
// texture alpha (item.fsh's ALPHA_CUTOUT and GLINT path).
// texture alpha (item.fsh's ALPHA_CUTOUT and GLINT path). u >= 4: the same
// over a GUI model's armour, from enchanted_glint_armor (ARMOR_ENTITY_GLINT).
if in.uv.x >= 2.0 {
let mask = textureSample(atlas, atlas_sampler, in.uv - vec2(2.0, 0.0)).a;
let armor = in.uv.x >= 4.0;
let offset = select(2.0, 4.0, armor);
let mask = textureSample(atlas, atlas_sampler, in.uv - vec2(offset, 0.0)).a;
let item_sample = textureSample(glint_tex, glint_sampler, in.glint_uv);
let armor_sample = textureSample(glint_armor_tex, glint_sampler, in.glint_uv);
if mask < 0.1 { discard; }
var glint = hud.size.w * textureSample(glint_tex, glint_sampler, in.glint_uv);
var glint = hud.size.w * select(item_sample, armor_sample, armor);
let tint = hud.end_portal[2].yzw;
if tint.x >= 0.0 {
glint = vec4(max(glint.r, max(glint.g, glint.b)) * tint, glint.a);
@@ -43,7 +43,8 @@ impl Gpu {
let view = atlas.create_view(&Default::default());
let hud = HudRenderer::new(
&device,
wgpu::TextureFormat::Rgba16Float,
hud_coverage::HUD_FORMAT,
&view,
&view,
&view,
&device.create_sampler(&Default::default()),
@@ -70,8 +71,16 @@ impl Gpu {
})
}
fn draw(&self, batch: &HudBatch, initial: f64, coverage: bool) -> (Vec<u8>, Vec<u8>) {
let frame = self.texture(wgpu::TextureFormat::Rgba16Float);
/// `batch` drawn on the HUD's target cleared to `backdrop`, with coverage cleared to
/// `initial`: the target's and the coverage's 8-bit texels.
fn draw(
&self,
batch: &HudBatch,
backdrop: f64,
initial: f64,
coverage: bool,
) -> (Vec<u8>, Vec<u8>) {
let frame = self.texture(hud_coverage::HUD_FORMAT);
let mask = self.texture(hud_coverage::FORMAT);
let depth = self.texture(wgpu::TextureFormat::Depth32Float);
let [frame_view, mask_view, depth_view] =
@@ -84,7 +93,7 @@ impl Gpu {
bytemuck::bytes_of(&HudUniform::new([4.0, 2.0, 1.0, 0.75], batch.end_portal, 0)),
);
let mut encoder = self.device.create_command_encoder(&Default::default());
for (view, value) in [(&frame_view, 0.3), (&mask_view, initial)] {
for (view, value) in [(&frame_view, backdrop), (&mask_view, initial)] {
let mut attachment = hud_coverage::attachment(view);
attachment.ops.load = wgpu::LoadOp::Clear(wgpu::Color {
r: value,
@@ -139,7 +148,7 @@ impl Gpu {
(
color
.chunks(256)
.flat_map(|r| r[..32].iter().copied())
.flat_map(|r| r[..16].iter().copied())
.collect(),
mask.chunks(256)
.flat_map(|r| r[..4].iter().copied())
@@ -148,8 +157,8 @@ impl Gpu {
}
fn check(&self, batch: &HudBatch, initial: f64, expected: [u8; 4]) {
let (plain, _) = self.draw(batch, initial, false);
let (color, mask) = self.draw(batch, initial, true);
let (plain, _) = self.draw(batch, 0.3, initial, false);
let (color, mask) = self.draw(batch, 0.3, initial, true);
assert_eq!(color, plain, "coverage changed the primary colour or alpha");
for (got, want) in mask.iter().zip(expected.repeat(2)) {
assert!(
@@ -219,6 +228,46 @@ fn coverage_follows_hud_blends_discard_scissor_and_depth_without_changing_colour
gpu.check(&batch, 0.0, [0, 128, 0, 0]);
}
/// The HUD's target is 8-bit as vanilla's frame is, so each draw clips at white before the
/// next blends over it: a text highlight added over a light backdrop, then a 50% black panel.
#[test]
fn each_hud_draw_clips_at_white_as_on_an_8_bit_frame() {
let Some(gpu) = Gpu::new() else {
return;
};
let mut batch = HudBatch::new([4, 2]);
quad(
&mut batch,
Blend::Additive,
rgba(128, 128, 128, 255),
0.0,
0.25,
);
quad(&mut batch, Blend::Alpha, rgba(0, 0, 0, 128), 0.0, 0.25);
// Vanilla's frame in integers: 204 + 128 clips at 255, then the panel keeps 127/255 of it.
let (backdrop, highlight, panel) = (204u32, 128u32, 128u32);
let clipped = (backdrop + highlight).min(255);
let want = ((clipped * (255 - panel)) as f64 / 255.0).round() as u8;
assert_eq!(want, 127);
// The HUD layer's target, and the 8-bit frame a 0.2 pack finishes on in SDR, where the
// HUD draws without coverage.
for coverage in [true, false] {
let (color, mask) = gpu.draw(&batch, f64::from(backdrop) / 255.0, 0.0, coverage);
// Unclipped, the panel would have kept 127/255 of 332: 165.
for pixel in [0, 1, 4, 5] {
assert_eq!(
color[pixel * 4..pixel * 4 + 3],
[want; 3],
"{coverage}: {color:?}"
);
}
// The highlight hides nothing; the panel hides half of what is under it.
if coverage {
assert_eq!(mask[0], 128, "{mask:?}");
}
}
}
#[test]
fn coverage_preserves_each_cit_blend_and_its_destination_alpha() {
let Some(mut gpu) = Gpu::new() else {
@@ -4,8 +4,26 @@
//!
//! - `room`: what of a view an overlay may use, clear of the boss bars.
//! - `plate`: the box behind a notice's text.
//! - `tabs`: a collapsed page strip, the name between arrows with a pip per page.
//!
//! Vanilla's own display lists (`hud_layout`, `screen_draw`) never use the kit: they stay
//! exactly vanilla's.
pub mod plate;
pub mod room;
pub mod tabs;
/// `0xRRGGBB` and an alpha as the batch's colour (`hud::rgba`), for named colour constants.
pub const fn colour(rgb: u32, alpha: u8) -> u32 {
(alpha as u32) << 24 | (rgb & 0xFF) << 16 | (rgb >> 8 & 0xFF) << 8 | (rgb >> 16 & 0xFF)
}
#[cfg(test)]
mod tests {
#[test]
fn a_named_colour_is_the_batch_s_rgba() {
assert_eq!(
super::colour(0x7656FA, 140),
crate::hud::rgba(0x76, 0x56, 0xFA, 140)
);
}
}
@@ -0,0 +1,217 @@
//! A collapsed page strip: the selected page's name between `‹ ›` arrows, with a pip per page
//! under it. A page list with more pages than room (the settings shell's, `settings_shell`)
//! shows them this way instead of leaving some out.
//!
//! [`collapsed`] only lays out (pure, testable); [`draw`] paints a laid-out strip. It knows
//! nothing of clicks or bumpers: the screen using it decides what each part does.
//!
//! Sizes are GUI px. The name is cut to the room between the arrows, and when the pips don't all
//! fit across the area, the ones shown are a window around the selected page's.
//!
//! ```
//! use horizon_client_render::hud::HudAtlas;
//! use horizon_client_render::hud_kit::tabs;
//!
//! let atlas = HudAtlas::build(std::path::Path::new("/nonexistent")).unwrap();
//! let names = ["General", "Camera", "Build"].map(String::from);
//! let strip = tabs::collapsed(&atlas, &names, 0, [8.0, 100.0, 112.0, 16.0]);
//! assert_eq!(strip.label, "General");
//! assert!(!strip.has_prev && strip.has_next);
//! assert_eq!(strip.pips.len(), 3);
//! ```
use crate::hud::{HudAtlas, HudBatch};
use crate::hud_kit::colour;
/// A pip under the strip: its size and the step from one to the next, in GUI px.
pub const PIP: [f32; 2] = [3.0, 2.0];
pub const PIP_STEP: f32 = 5.0;
/// How far, in GUI px, the arrows sit from the name.
pub const ARROW_GAP: f32 = 4.0;
/// An arrow with a page that way, and one at an end, with none.
pub const ARROW: u32 = colour(0x9FA3AE, 255);
pub const ARROW_OFF: u32 = colour(0x9FA3AE, 77);
/// The pips (the selected one is white).
pub const PIP_OFF: u32 = colour(0xFFFFFF, 89);
pub const PIP_ON: u32 = colour(0xFFFFFF, 255);
/// The name's colour.
pub const NAME: u32 = colour(0xFFFFFF, 255);
/// The line of text's height in GUI px (vanilla's font, without its shadow).
const LINE: f32 = 8.0;
/// A laid-out strip.
#[derive(Clone, Debug, PartialEq)]
pub struct Strip {
/// The area it was laid out in, `[x, y, w, h]` GUI px.
pub area: [f32; 4],
/// The selected page's index in the names, and its name, cut to fit.
pub selected: usize,
pub label: String,
/// Where the name is drawn, `[x, y, w, h]`.
pub label_area: [f32; 4],
/// The arrows' text positions `[x, y]` (before, after).
pub arrows: [[f32; 2]; 2],
/// Whether there is a page before and after the selected one: an arrow without is dimmed.
/// A screen may change these (a search skipping pages without matches).
pub has_prev: bool,
pub has_next: bool,
/// The pips `[x, y, w, h]` under the area, and whether each is the selected page's. At most
/// as many as fit across the area.
pub pips: Vec<([f32; 4], bool)>,
}
/// The strip for `names` with `selected` in `area` (`[x, y, w, h]`, GUI px): the selected name
/// between `‹ ›` arrows, cut to fit, with pips under the area. Past the end, the last page.
pub fn collapsed(atlas: &HudAtlas, names: &[String], selected: usize, area: [f32; 4]) -> Strip {
let [x, y, w, h] = area;
let n = names.len();
let selected = selected.min(n.saturating_sub(1));
let text_y = y + ((h - LINE) / 2.0).floor();
let centre = x + w / 2.0;
let arrow_w = atlas.font.width("‹").max(atlas.font.width("›"));
let room = (w - 2.0 * (arrow_w + ARROW_GAP)).max(0.0);
let name = names.get(selected).map_or("", String::as_str);
let label = crate::graphics_style::fit(atlas, name, room);
let label_w = atlas.font.width(&label).ceil();
let left = (centre - label_w / 2.0).floor();
let arrows = [
[left - ARROW_GAP - arrow_w, text_y],
[left + label_w + ARROW_GAP, text_y],
];
// As many pips as fit across the area, a window round the selected one.
let fit = (((w - PIP[0]) / PIP_STEP).floor() as usize + 1).min(n);
let first = selected.saturating_sub(fit / 2).min(n - fit.min(n));
let pips_x = (centre - (fit as f32 * PIP_STEP - (PIP_STEP - PIP[0])) / 2.0).floor();
let pips = (0..fit)
.map(|i| {
let px = pips_x + i as f32 * PIP_STEP;
([px, y + h + 1.0, PIP[0], PIP[1]], first + i == selected)
})
.collect();
Strip {
area,
selected,
label,
label_area: [left, y, label_w, h],
arrows,
has_prev: selected > 0,
has_next: selected + 1 < n,
pips,
}
}
/// Paints `strip`: the name, its arrows (dimmed at an end) and the pips.
pub fn draw(batch: &mut HudBatch, atlas: &HudAtlas, strip: &Strip) {
let [lx, ly, ..] = strip.label_area;
let text_y = ly + ((strip.area[3] - LINE) / 2.0).floor();
batch.text(atlas, &strip.label, lx, text_y, NAME, true);
let lit = [strip.has_prev, strip.has_next];
for (([ax, ay], arrow), on) in strip.arrows.into_iter().zip(["‹", "›"]).zip(lit) {
batch.text(
atlas,
arrow,
ax,
ay,
if on { ARROW } else { ARROW_OFF },
true,
);
}
for (pip, on) in &strip.pips {
let [px, py, pw, ph] = *pip;
batch.fill(atlas, px, py, pw, ph, if *on { PIP_ON } else { PIP_OFF });
}
}
#[cfg(test)]
mod tests {
use super::*;
fn atlas() -> HudAtlas {
HudAtlas::build(std::path::Path::new("/nonexistent/horizon-test-assets")).unwrap()
}
fn names(list: &[&str]) -> Vec<String> {
list.iter().map(|s| s.to_string()).collect()
}
const AREA: [f32; 4] = [8.0, 100.0, 112.0, 16.0];
#[test]
fn the_name_sits_centred_between_its_arrows_with_a_pip_per_page() {
let atlas = atlas();
let pages = names(&["General", "Sky", "Water", "Fog"]);
let strip = collapsed(&atlas, &pages, 1, AREA);
assert_eq!(strip.label, "Sky");
let [nx, _, nw, _] = strip.label_area;
assert!((nx + nw / 2.0 - 64.0).abs() <= 1.0, "{nx} {nw}");
let [[before, _], [after, _]] = strip.arrows;
assert!(before < nx && after >= nx + nw);
let on: Vec<bool> = strip.pips.iter().map(|(_, on)| *on).collect();
assert_eq!(on, [false, true, false, false]);
// The pips sit under the row, centred.
let (first, last) = (strip.pips[0].0, strip.pips[3].0);
assert!(first[1] >= AREA[1] + AREA[3]);
assert!(((first[0] + last[0] + last[2]) / 2.0 - 64.0).abs() <= 1.0);
// Past the end, the last page; no pages, nothing to the arrows' sides.
assert_eq!(collapsed(&atlas, &pages, 9, AREA).selected, 3);
let none = collapsed(&atlas, &[], 0, AREA);
assert!(none.label.is_empty() && none.pips.is_empty());
assert!(!none.has_prev && !none.has_next);
}
#[test]
fn a_long_name_is_cut_to_stay_between_its_arrows() {
let atlas = atlas();
let long = names(&["A page with a very long name", "B"]);
let strip = collapsed(&atlas, &long, 0, [0.0, 0.0, 80.0, 12.0]);
let [_, [after, _]] = strip.arrows;
assert!(after + atlas.font.width("›") <= 80.0, "{after}");
assert!(strip.label.ends_with("..."));
}
#[test]
fn the_ends_have_no_page_beyond_and_draw_their_arrow_dimmed() {
let atlas = atlas();
let pages = names(&["A", "B", "C"]);
let ends = [(0, false, true), (1, true, true), (2, true, false)];
for (selected, prev, next) in ends {
let strip = collapsed(&atlas, &pages, selected, AREA);
assert_eq!((strip.has_prev, strip.has_next), (prev, next));
}
// The first page: its `‹` is drawn in the dimmed colour, its `›` in the arrow's.
let alpha = |strip: &Strip, i: usize| {
let mut batch = HudBatch::new([854, 480]);
draw(&mut batch, &atlas, strip);
let [ax, _] = strip.arrows[i];
(batch.vertices.iter())
.filter(|v| {
(v.pos[0] - ax).abs() < 4.0 && v.color & 0x00FF_FFFF == ARROW & 0x00FF_FFFF
})
.map(|v| v.color >> 24)
.max()
.unwrap_or(0)
};
let first = collapsed(&atlas, &pages, 0, AREA);
assert_eq!(alpha(&first, 0), ARROW_OFF >> 24);
assert_eq!(alpha(&first, 1), ARROW >> 24);
}
#[test]
fn many_pages_show_as_many_pips_as_fit_round_the_selected_one() {
let atlas = atlas();
let pages: Vec<String> = (0..60).map(|i| format!("Page {i}")).collect();
let [x, _, w, _] = AREA;
for selected in [0, 1, 30, 58, 59] {
let strip = collapsed(&atlas, &pages, selected, AREA);
assert!(strip.pips.len() < pages.len());
for ([px, _, pw, _], _) in &strip.pips {
assert!(*px >= x && px + pw <= x + w, "{selected}: {px}");
}
assert_eq!(strip.pips.iter().filter(|p| p.1).count(), 1, "{selected}");
}
// At the ends, the lit pip is at the window's end too.
let first = collapsed(&atlas, &pages, 0, AREA);
assert!(first.pips[0].1);
let last = collapsed(&atlas, &pages, 59, AREA);
assert!(last.pips.last().unwrap().1);
}
}
+4 -1
View File
@@ -1,6 +1,7 @@
//! The client's world data, rendering and screens: chunks, meshing, light, streaming, the renderer,
//! the HUD, menus and settings. Gameplay (horizon-client) builds on it; nothing here reaches gameplay.
#![forbid(unsafe_code)]
pub mod android_gamepad;
pub mod assets;
pub mod atlas_sources;
pub mod audio;
@@ -82,7 +83,9 @@ pub mod options_fixes;
pub mod options_ui;
pub mod overlay;
pub mod pack_browser;
pub mod pack_options_ui;
pub mod pack_screen;
pub mod pack_tags;
pub mod packs;
pub mod pad_cursor;
pub mod pad_glyph;
@@ -90,6 +93,7 @@ pub mod paged_maps;
pub mod par;
pub mod particles;
pub mod poi_sections;
pub mod polytone_post;
pub mod post_chain;
pub mod post_glsl;
pub mod potent_sulfur;
@@ -125,7 +129,6 @@ pub mod streaming;
pub mod structure_block;
pub mod studio_ui;
pub mod tab_list;
pub mod telemetry;
pub mod test_block;
pub mod test_instance_block;
pub mod testgen;
+238 -1
View File
@@ -9,6 +9,7 @@ use crate::{
};
pub mod bake;
pub mod cache;
mod kept;
mod mesh;
pub mod packed;
mod samples;
@@ -202,6 +203,15 @@ pub const MAX_CHUNKS: u32 = 1 << 22;
/// structures) rather than sampled: cells of up to 4 blocks, out to about
/// 1024 blocks at Full far detail.
const DETAIL_LEVEL: u8 = 2;
/// Kept tiles come back a level at a time (`Lod::bring_back`) while at most
/// this many drawn tiles wait for their meshes.
const RETURN_BACKLOG: usize = 32;
/// Kept tiles of cells a chunk wide or less (levels up to this) come back once
/// the near terrain is in and the seam with it sampled, or this long after the
/// return: their thousands of meshes held up the near chunks' and the seam's,
/// showing caves through unmeshed ground meanwhile.
const RETURN_FINE_LEVEL: u8 = 4;
const RETURN_SEAM_WAIT: std::time::Duration = std::time::Duration::from_secs(10);
/// The most chunks one detail request asks for, one request each 250 ms:
/// generated ones arrive at the decoration threads' pace, cached ones at once.
const DETAIL_REQUEST: usize = 512;
@@ -1321,6 +1331,12 @@ pub struct Lod {
saved: Saved,
reduced: Reduced,
saved_order: VecDeque<(i32, i32)>,
/// Tiles the plan let go, taken back on a return instead of sampled again.
kept: kept::KeptTiles,
/// Kept tiles the plan wants again, not taken back yet (`bring_back`).
returning: Vec<TileKey>,
/// When the plan last asked kept tiles back (`RETURN_SEAM_WAIT`).
returned_at: std::time::Instant,
/// `LodSource::keeps_observed`: tiles sampled without an observed chunk
/// are sampled again (`resample_over`).
keeps_observed: bool,
@@ -1688,6 +1704,9 @@ impl Lod {
saved: FxHashMap::default(),
reduced: FxHashMap::default(),
saved_order: VecDeque::new(),
kept: kept::KeptTiles::new(kept::KEPT_FOR, kept::KEPT_BYTES),
returning: Vec::new(),
returned_at: std::time::Instant::now(),
keeps_observed,
}
}
@@ -1713,6 +1732,10 @@ impl Lod {
pub fn stats(&self) -> (usize, usize) {
(self.active.len(), self.ready.len())
}
/// The tiles kept for a return, and about how many bytes they hold.
pub fn kept(&self) -> (usize, usize) {
(self.kept.len(), self.kept.bytes())
}
pub fn progress(&self) -> (usize, usize) {
let mut desired = 0;
let mut sampled = 0;
@@ -2030,6 +2053,8 @@ impl Lod {
if self.retry.len() != retrying {
self.wanted_stale = true;
}
// Kept tiles there are as old: sampled again if the plan wants them.
self.kept.forget(|key, _| key.refreshed_by(area, widths));
// A first sample in flight read the old data too.
let keys: Vec<_> = self
.ready
@@ -2137,6 +2162,21 @@ impl Lod {
for key in resampled_over(&self.ready, &self.pending, chunk) {
self.mark_stale(key);
}
// Kept tiles with the chunk's hole: sampled again if the plan wants them.
let (cx, cz) = chunk;
self.kept.forget(|key, samples| {
let size = key.size();
let stride = size / CELLS as i32;
let [ox, _, oz] = key.origin();
stride > 16
&& (ox..ox + size).contains(&(cx * 16))
&& (oz..oz + size).contains(&(cz * 16))
&& samples
.get(
((cz * 16 - oz) / stride * CELLS as i32 + (cx * 16 - ox) / stride) as usize,
)
.is_some_and(|c| *c == Column::default())
});
}
/// A chunk's own columns as its level-0 tile; the same columns again
/// (asked again after a `refresh`) mesh nothing.
@@ -2464,6 +2504,8 @@ impl Lod {
self.area = Some(area);
self.replan(camera, detail, distance, elevation, aim);
});
} else if self.kept.due() {
self.kept.trim();
}
let mut meshes = Vec::new();
crate::spans::time("lod_results", || self.receive(&mut meshes));
@@ -2473,6 +2515,7 @@ impl Lod {
// and the nearest first, where far terrain meets the near chunks.
meshes.sort_by_key(|(key, _)| (key.level, key.distance(camera)));
crate::spans::time("lod_covered", || self.cover(covered));
crate::spans::time("lod_return", || self.bring_back());
if std::mem::take(&mut self.reselect) {
self.rebuild_selection();
}
@@ -2513,7 +2556,41 @@ impl Lod {
.copied()
.collect();
self.plan = plan;
self.ready.retain(|key, _| self.plan.nodes.contains(key));
self.kept.fit_to_view(self.plan.nodes.len());
// Tiles leaving the plan are kept for a return, with their detail. Not
// stale ones (their source has more), nor ones of cells a chunk wide or
// less over the full-detail columns: never drawn there, they came back
// as flat grey ground around the near terrain until it loaded.
let leaving: Vec<_> = self
.ready
.keys()
.filter(|key| !self.plan.nodes.contains(key))
.copied()
.collect();
for key in leaving {
let columns = self.ready.remove(&key).unwrap();
if !self.stale.contains(&key) && !self.over_covered(key) {
let mask = self.detail_masks.get(&key).copied();
self.kept.keep(key, columns, mask);
}
}
self.kept.trim();
// Those the plan wants again come back (`bring_back`) instead of being
// sampled: a level at a time, coarsest first. Not at the seam with the
// near chunks (`Plan::stands_in`), sampled first and fresh there: kept
// ones drew grey ground and the near chunks' caves until they loaded.
let (returning, seam): (Vec<_>, Vec<_>) = self
.plan
.nodes
.iter()
.filter(|key| self.kept.contains(key) && !self.ready.contains_key(key))
.copied()
.partition(|key| self.plan.stands_in(*key));
for key in seam {
self.kept.take(&key);
}
self.returning = returning;
self.returned_at = std::time::Instant::now();
let nodes = &self.plan.nodes;
self.mesh_samples.retain(|key| nodes.contains(key));
// Queued tiles the view left are not sampled (a running one is
@@ -2542,6 +2619,81 @@ impl Lod {
self.mark_side_neighbors_of(key);
}
}
/// Whether a tile of cells a chunk wide or less lies over a covered column.
fn over_covered(&self, key: TileKey) -> bool {
if key.size() > 16 * CELLS as i32 {
return false;
}
let [ox, _, oz] = key.origin();
let size = key.size();
if let Some([x0, z0, x1, z1]) = self.covered_bounds
&& (ox >= x1 || oz >= z1 || ox + size <= x0 || oz + size <= z0)
{
return false;
}
let chunks = (size / 16).max(1);
(0..chunks * chunks).any(|i| {
self.covered.contains(&(
ox.div_euclid(16) + i % chunks,
oz.div_euclid(16) + i / chunks,
))
})
}
/// Whether a tile at the seam with the near chunks (`Plan::stands_in`)
/// still waits for its samples.
fn seam_open(&self) -> bool {
self.plan.nodes.iter().any(|key| {
!self.plan.stands_in(*key)
&& !self.plan.split.contains(key)
&& !self.ready.contains_key(key)
})
}
/// Takes kept tiles back into the plan, the coarsest level first, the next
/// once the meshes are in: all of them at once left the sky showing
/// through every tile not meshed yet, where sampling fills a view coarse
/// first and refines it.
fn bring_back(&mut self) {
if self.returning.is_empty() || self.unmeshed.len() > RETURN_BACKLOG {
return;
}
let level = self
.returning
.iter()
.map(|key| key.level)
.max()
.unwrap_or(0);
if level <= RETURN_FINE_LEVEL
&& self.returned_at.elapsed() < RETURN_SEAM_WAIT
&& (self.near_loading || self.seam_open())
{
return;
}
let (now, later): (Vec<_>, Vec<_>) =
self.returning.drain(..).partition(|key| key.level == level);
self.returning = later;
// Tiles forgotten meanwhile (`refresh`) are sampled: the queue skipped them.
self.wanted_stale = true;
let mut returned = Vec::new();
for key in now {
if self.ready.contains_key(&key) || self.pending.contains(&key) {
continue;
}
if let Some((columns, mask)) = self.kept.take(&key) {
self.ready.insert(key, columns);
if let Some(mask) = mask {
self.detail_masks.insert(key, mask);
}
returned.push(key);
}
}
if returned.is_empty() {
return;
}
self.rebuild_selection();
for key in returned {
self.refresh_path(key);
}
}
/// Recomputes every active tile after a plan change, then marks only the
/// tiles whose selection changed (plus their neighbours).
fn rebuild_selection(&mut self) {
@@ -3020,6 +3172,7 @@ impl Lod {
|| (self.plan.split.contains(&key) && wanted.class != BACKDROP)
|| !self.plan.nodes.contains(&key)
|| self.retry.contains_key(&key)
|| self.kept.contains(&key)
{
continue;
}
@@ -3947,6 +4100,90 @@ mod tests {
}
/// Detail read before a refresh over its chunk is dropped (asked
/// again); elsewhere, or read after, it is kept.
/// Out and home again: the tiles the trip let go come back, the coarsest
/// level first and a level once the meshes are in, without a sample job.
#[test]
fn a_return_takes_kept_tiles_back_coarse_first_instead_of_sampling() {
let mut lod = Lod::scheduler_for_test();
let filled = Arc::new(vec![
Column::from_surface(Some(Surface {
height: 70,
state: BlockStateId(1),
water: None,
biome: 0,
}));
CELLS * CELLS
]);
lod.replan([0, 0], 2, 64, 0, None);
let home: Vec<_> = lod.plan.nodes.iter().copied().collect();
for &key in &home {
lod.ready.insert(key, filled.clone());
}
lod.replan([200_000, 0], 2, 64, 0, None);
let left: Vec<_> = home
.iter()
.copied()
.filter(|key| !lod.plan.nodes.contains(key))
.collect();
assert!(!left.is_empty() && left.iter().all(|key| !lod.ready.contains_key(key)));
lod.replan([0, 0], 2, 64, 0, None);
// Those at the seam with the near chunks are sampled fresh instead.
let (wanted, seam): (Vec<_>, Vec<_>) = left
.iter()
.copied()
.filter(|key| lod.plan.nodes.contains(key))
.partition(|key| lod.plan.stands_in(*key));
assert!(!wanted.is_empty() && !seam.is_empty());
assert!(seam.iter().all(|key| !lod.kept.contains(key)));
// Nothing else to sample: they are on their way back.
lod.request_samples([0, 0]);
assert!(wanted.iter().all(|key| !lod.pending.contains(key)));
assert!(seam.iter().any(|key| lod.pending.contains(key)));
let coarsest = wanted.iter().map(|key| key.level).max().unwrap();
// Meshes outstanding: nothing comes back yet.
lod.unmeshed
.extend((0..=RETURN_BACKLOG as i32).map(|x| TileKey {
x,
z: 9_999,
level: 0,
}));
lod.bring_back();
assert!(wanted.iter().all(|key| !lod.ready.contains_key(key)));
lod.unmeshed.clear();
lod.bring_back();
for key in &wanted {
assert_eq!(
lod.ready.contains_key(key),
key.level == coarsest,
"{key:?}"
);
}
// Finer ones wait for the seam's samples.
for _ in 0..64 {
lod.bring_back();
}
assert!(
!lod.returning.is_empty()
&& lod
.returning
.iter()
.all(|key| key.level <= RETURN_FINE_LEVEL)
);
for key in &seam {
lod.ready.insert(*key, filled.clone());
}
assert!(!lod.seam_open());
while !lod.returning.is_empty() {
lod.unmeshed.clear();
lod.bring_back();
}
assert!(
wanted
.iter()
.all(|key| lod.ready.get(key).is_some_and(|s| Arc::ptr_eq(s, &filled)))
);
assert!(lod.pending.iter().all(|key| seam.contains(key)));
}
#[test]
fn only_detail_a_later_refresh_covers_is_dropped() {
let mut lod = Lod::scheduler_for_test();
@@ -0,0 +1,218 @@
//! Far tiles the plan let go, kept a while for a return: a teleport back, a
//! trip out and home again, the far distance lowered and raised. Taken back
//! (`Lod::replan`) instead of sampled again, which costs every far tile its
//! noise, biomes and surface rules a second time.
//!
//! Bounded in time (`KEPT_FOR`) and in memory (`KEPT_BYTES`); past the bytes
//! the finest go first, so the coarse shape of every place nearby stays
//! longest. Nothing is written to disk: the tiles leave with the `Lod`, that
//! is with the world.
use super::{Column, Span, TileKey};
use crate::fast_hash::FxHashMap;
use std::{
sync::Arc,
time::{Duration, Instant},
};
/// How long a tile is kept after the plan let it go.
pub(super) const KEPT_FOR: Duration = Duration::from_secs(600);
/// About as many bytes as a 512-chunk view's tiles hold, so one whole place
/// is kept; a far tile is some 20 to 60 KB. The most kept at any far distance.
pub(super) const KEPT_BYTES: usize = 512 << 20;
/// The tiles a 512-chunk view plans: a smaller view keeps a share of
/// `KEPT_BYTES` as large as its share of these (`KeptTiles::fit_to_view`), so
/// travelling with a short far distance doesn't fill the whole budget.
const VIEW_512_TILES: usize = 4700;
struct Kept {
columns: Arc<Vec<Column>>,
/// Its detail (`Lod::detail_masks`), so refined cells are not asked again.
mask: Option<[u64; 4]>,
at: Instant,
bytes: usize,
}
pub(super) struct KeptTiles {
tiles: FxHashMap<TileKey, Kept>,
bytes: usize,
checked: Instant,
time: Duration,
budget: usize,
}
impl KeptTiles {
pub(super) fn new(time: Duration, budget: usize) -> Self {
Self {
tiles: FxHashMap::default(),
bytes: 0,
checked: Instant::now(),
time,
budget,
}
}
/// The budget for a view that plans `tiles`: `KEPT_BYTES` for a 512-chunk
/// view, a share of it for a smaller one.
pub(super) fn fit_to_view(&mut self, tiles: usize) {
self.budget = (KEPT_BYTES / VIEW_512_TILES)
.saturating_mul(tiles)
.min(KEPT_BYTES);
}
pub(super) fn len(&self) -> usize {
self.tiles.len()
}
pub(super) fn bytes(&self) -> usize {
self.bytes
}
pub(super) fn contains(&self, key: &TileKey) -> bool {
self.tiles.contains_key(key)
}
pub(super) fn keep(&mut self, key: TileKey, columns: Arc<Vec<Column>>, mask: Option<[u64; 4]>) {
// Spans past the inline four live on the heap.
let bytes = std::mem::size_of_val(columns.as_slice())
+ columns
.iter()
.filter(|column| column.spans.len() > 4)
.map(|column| column.spans.len() * std::mem::size_of::<Span>())
.sum::<usize>();
let kept = Kept {
columns,
mask,
at: Instant::now(),
bytes,
};
self.bytes += bytes;
if let Some(old) = self.tiles.insert(key, kept) {
self.bytes -= old.bytes;
}
}
pub(super) fn take(&mut self, key: &TileKey) -> Option<(Arc<Vec<Column>>, Option<[u64; 4]>)> {
let kept = self.tiles.remove(key)?;
self.bytes -= kept.bytes;
Some((kept.columns, kept.mask))
}
/// Forgets the tiles `stale` picks: their source has more than they hold.
pub(super) fn forget(&mut self, mut stale: impl FnMut(&TileKey, &[Column]) -> bool) {
let bytes = &mut self.bytes;
self.tiles.retain(|key, kept| {
let keep = !stale(key, &kept.columns);
if !keep {
*bytes -= kept.bytes;
}
keep
});
}
/// Whether a minute went by since the last `trim`.
pub(super) fn due(&self) -> bool {
!self.tiles.is_empty() && self.checked.elapsed() >= Duration::from_secs(60)
}
/// Drops the tiles kept past their time, then, past the budget, the
/// finest and the oldest first.
pub(super) fn trim(&mut self) {
self.checked = Instant::now();
let (bytes, time) = (&mut self.bytes, self.time);
self.tiles.retain(|_, kept| {
let keep = kept.at.elapsed() < time;
if !keep {
*bytes -= kept.bytes;
}
keep
});
if self.bytes <= self.budget {
return;
}
let mut order: Vec<_> = self
.tiles
.iter()
.map(|(key, kept)| (key.level, kept.at, *key))
.collect();
order.sort_unstable();
for (_, _, key) in order {
if self.bytes <= self.budget {
break;
}
self.take(&key);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::lod::{CELLS, Surface};
use horizon_world::registry::BlockStateId;
fn tile(height: i32) -> Arc<Vec<Column>> {
Arc::new(vec![
Column::from_surface(Some(Surface {
height,
state: BlockStateId(1),
water: None,
biome: 0,
}));
CELLS * CELLS
])
}
fn key(level: u8) -> TileKey {
TileKey { x: 3, z: -2, level }
}
#[test]
fn a_kept_tile_comes_back_with_its_detail_once() {
let mut kept = KeptTiles::new(KEPT_FOR, usize::MAX);
kept.keep(key(0), tile(64), Some([u64::MAX; 4]));
let (columns, mask) = kept.take(&key(0)).unwrap();
assert_eq!(columns[0].surface.unwrap().height, 64);
assert_eq!(mask, Some([u64::MAX; 4]));
assert!(kept.take(&key(0)).is_none());
assert_eq!(kept.bytes(), 0);
}
#[test]
fn a_smaller_view_keeps_a_share_of_the_budget() {
let mut kept = KeptTiles::new(KEPT_FOR, KEPT_BYTES);
kept.fit_to_view(VIEW_512_TILES);
assert_eq!(kept.budget, KEPT_BYTES / VIEW_512_TILES * VIEW_512_TILES);
// A 128-chunk view plans about a sixteenth of the tiles.
kept.fit_to_view(VIEW_512_TILES / 16);
assert!(kept.budget <= KEPT_BYTES / 16);
kept.fit_to_view(VIEW_512_TILES * 4);
assert_eq!(kept.budget, KEPT_BYTES);
}
#[test]
fn past_the_budget_the_finest_go_first_and_past_the_time_all() {
let mut kept = KeptTiles::new(KEPT_FOR, usize::MAX);
kept.keep(key(4), tile(80), None);
let one = kept.bytes();
kept.keep(key(0), tile(64), None);
kept.keep(key(1), tile(70), None);
kept.budget = one * 2;
kept.trim();
assert!(kept.contains(&key(4)) && kept.contains(&key(1)) && !kept.contains(&key(0)));
assert_eq!(kept.bytes(), one * 2);
kept.time = Duration::ZERO;
kept.trim();
assert_eq!((kept.len(), kept.bytes()), (0, 0));
}
#[test]
fn forgotten_tiles_are_not_taken_back() {
let mut kept = KeptTiles::new(KEPT_FOR, usize::MAX);
kept.keep(key(2), tile(64), None);
kept.keep(key(3), tile(64), None);
kept.forget(|key, _| key.level == 2);
assert!(kept.take(&key(2)).is_none());
assert!(kept.take(&key(3)).is_some());
assert_eq!(kept.bytes(), 0);
}
}
+103 -71
View File
@@ -1334,6 +1334,8 @@ pub enum Page {
ShaderPacks,
/// The picked pack's options: the shader screen (`shader_ui`) at the pack's main screen.
ShaderOptions,
/// The selected resource packs' Polytone options (`pack_options_ui`), from the pack screen.
PackOptions,
/// Horizon's graphics settings (`graphics_ui`).
Graphics,
GraphicsAdvanced,
@@ -1486,6 +1488,12 @@ fn matches_focus(id: &FocusId, i: usize, wd: &Widget) -> bool {
/// What the game must do after an interaction.
#[derive(Clone, Debug, PartialEq)]
pub enum Event {
/// The Pack Options page closed with changes: the player's choices of its entries, the
/// ones that differ from their defaults (the others went back to theirs).
PackOptions {
entries: Vec<String>,
chosen: std::collections::BTreeMap<String, horizon_polytone::pack::ConfigValue>,
},
/// An accepted button press or the release of a slider.
ButtonClick,
/// Horizon's own: download the Modrinth hit at this index of the pack
@@ -1662,7 +1670,12 @@ pub struct OptionsUi {
controller: shell::Shell<crate::controller_ui::ControllerScreen>,
/// Horizon Extras, on the settings shell.
extras: shell::Shell<crate::extras_ui::ExtrasScreen>,
pub shader: crate::shader_ui::ShaderUi,
/// Shaders: the shader packs and the picked one's options, on the settings shell.
shader: shell::Shell<crate::shader_ui::ShaderScreen>,
/// The Pack Options page, and the packs' Polytone config entries by pack id (the game
/// fills them beside `pack_infos`).
pub pack_options: crate::pack_options_ui::PackOptionsUi,
pub pack_configs: std::collections::BTreeMap<String, horizon_polytone::pack::Configs>,
pub split_profiles: crate::split_profiles::SplitProfiles,
/// HDR calibration while its page is open.
pub calibration: Option<crate::hdr_calibration::Calibration>,
@@ -1741,6 +1754,8 @@ impl Default for OptionsUi {
controller: Default::default(),
extras: Default::default(),
shader: Default::default(),
pack_options: Default::default(),
pack_configs: Default::default(),
split_profiles: Default::default(),
calibration: None,
calibration_fullscreen: None,
@@ -2098,7 +2113,6 @@ impl OptionsUi {
.pack_screen
.as_ref()
.is_some_and(|p| p.search_focused()),
_ if Self::shader_page(self.page()).is_some() => self.shader.typing(),
_ => match self.shell() {
Some(shell) => shell.typing(),
None => self.settings_search_active(),
@@ -2209,12 +2223,12 @@ impl OptionsUi {
if page == Page::Packs {
self.pack_screen = None;
}
if let Some(at) = Self::shader_page(page) {
self.shader.open(at);
}
if let Some(shell) = self.shell_mut() {
shell.open();
}
if let Some(at) = Self::shader_page(page) {
self.shader.show(at);
}
if let Some(at) = Self::graphics_page(page) {
self.graphics.show(at);
}
@@ -2239,6 +2253,7 @@ impl OptionsUi {
Page::Extras => Some(&self.extras),
page if Self::graphics_page(page).is_some() => Some(&self.graphics),
page if Self::controller_page(page).is_some() => Some(&self.controller),
page if Self::shader_page(page).is_some() => Some(&self.shader),
_ => None,
}
}
@@ -2247,6 +2262,7 @@ impl OptionsUi {
Page::Extras => Some(&mut self.extras),
page if Self::graphics_page(page).is_some() => Some(&mut self.graphics),
page if Self::controller_page(page).is_some() => Some(&mut self.controller),
page if Self::shader_page(page).is_some() => Some(&mut self.shader),
_ => None,
}
}
@@ -2291,38 +2307,6 @@ impl OptionsUi {
}
events
}
/// What the shader screen's actions do to these options.
fn shader_events(
&mut self,
s: &mut Settings,
actions: Vec<crate::shader_ui::Action>,
) -> Vec<Event> {
use crate::shader_ui::Action;
let mut events = Vec::new();
for action in actions {
match action {
Action::ButtonClick => events.push(Event::ButtonClick),
Action::Changed => events.push(Event::Changed),
Action::SliderDrag => events.push(Event::SliderDrag),
Action::Picked => {
// The last pack's changed options are kept before the next one's are read.
if s.shader_options.as_ref().is_some_and(|o| o.changed) {
events.push(Event::ShaderOptions);
}
events.push(Event::ShaderPack);
events.push(Event::OpenShaderOptions);
}
Action::OpenFolder => events.push(Event::OpenShaderPackFolder),
Action::Apply => {
events.push(Event::ShaderOptions);
events.push(Event::OpenShaderOptions);
}
Action::NativeChanged(pack) => events.push(Event::NativePackOptions(pack)),
Action::Done => events.extend(self.back(s)),
}
}
events
}
/// Where the shader screen opens for `page`, if it is one of its pages.
fn shader_page(page: Page) -> Option<crate::shader_ui::Tab> {
match page {
@@ -2378,6 +2362,7 @@ impl OptionsUi {
Page::NativeMod(_) => "Mod Settings",
Page::ShaderPacks => "horizon.pages.shader_packs",
Page::ShaderOptions => "horizon.pages.shader_options",
Page::PackOptions => "horizon.pack.options.title",
Page::Graphics | Page::GraphicsAdvanced | Page::GraphicsHdr | Page::HdrCalibration => {
"options.videoTitle"
}
@@ -3680,8 +3665,8 @@ impl OptionsUi {
c.draw(batch, atlas, cursor);
return;
}
if Self::shader_page(self.page()).is_some() {
self.shader.draw(s, batch, atlas, cursor, self.in_world);
if self.page() == Page::PackOptions {
self.pack_options.draw(batch, atlas, cursor, self.in_world);
return;
}
let waiting = self.waiting();
@@ -4059,9 +4044,19 @@ impl OptionsUi {
});
return self.shell_events(s, outputs);
}
if Self::shader_page(self.page()).is_some() {
let actions = self.shader.click(s, (x, y), size, drag, right, self.shift);
return self.shader_events(s, actions);
if self.page() == Page::PackOptions {
let mut events = Vec::new();
for action in self
.pack_options
.click((x, y), size, drag, right || self.shift)
{
match action {
crate::pack_options_ui::Action::ButtonClick => events.push(Event::ButtonClick),
crate::pack_options_ui::Action::SliderDrag => {}
crate::pack_options_ui::Action::Done => events.extend(self.back(s)),
}
}
return events;
}
if self.page() == Page::Packs {
let mut events = Vec::new();
@@ -4091,6 +4086,14 @@ impl OptionsUi {
crate::pack_screen::Action::BrowsePick(i) => {
events.push(Event::DownloadPack(i));
}
crate::pack_screen::Action::Options(id) => {
let mut configs = horizon_polytone::pack::Configs::default();
if let Some(own) = self.pack_configs.get(&id) {
configs.merge(own);
}
self.pack_options = crate::pack_options_ui::PackOptionsUi::open(configs);
self.push(Page::PackOptions);
}
crate::pack_screen::Action::None => {}
}
return events;
@@ -4329,15 +4332,19 @@ impl OptionsUi {
// AbstractSliderButton suppresses the press sound and plays once on
// release, even if the pointer has left the widget or its value stayed.
let slider = self.dragging.take().is_some();
let (shader_slider, shader_pack) = self.shader.release();
let shell_slider = self.shell_mut().is_some_and(|shell| shell.release());
let graphics_slider = shader_slider | shell_slider;
let shell_slider = self.shell_mut().and_then(|shell| shell.release());
self.pack_options.release();
let calibration_slider = self.calibration.as_mut().is_some_and(|c| c.release());
let split_drop = self.split_setup.release();
if slider || graphics_slider || calibration_slider {
if slider || shell_slider.is_some() || calibration_slider {
let mut events = vec![Event::ButtonClick, Event::Changed];
// A pack's option is saved, and the pack rebuilt, once per drag.
events.extend(shader_pack.map(Event::NativePackOptions));
// What the screen does once a drag ends: a shader pack's option is saved, and the
// pack rebuilt, once per drag.
let done = shell_slider.into_iter().flatten();
events.extend(done.filter_map(|output| match output {
shell::Output::Event(event) => Some(event),
_ => None,
}));
events
} else if split_drop {
vec![Event::ButtonClick]
@@ -4420,8 +4427,8 @@ impl OptionsUi {
self.studio.wheel(amount);
return Vec::new();
}
if Self::shader_page(self.page()).is_some() {
self.shader.scroll(s, size, amount);
if self.page() == Page::PackOptions {
self.pack_options.scroll(size, amount);
return Vec::new();
}
let plain = self.resource_error.is_none()
@@ -4507,8 +4514,8 @@ impl OptionsUi {
self.studio.wheel(amount);
return;
}
if Self::shader_page(self.page()).is_some() {
self.shader.scroll(s, size, amount);
if self.page() == Page::PackOptions {
self.pack_options.scroll(size, amount);
return;
}
match &mut self.pack_screen {
@@ -4576,7 +4583,19 @@ impl OptionsUi {
self.pack_screen = Some(screen);
}
}
self.pack_screen.as_mut().expect("made above")
// The cards of packs with Polytone options carry their button (data packs have none).
let with_options = if self.data_pack_dir.is_none() {
self.pack_configs
.iter()
.filter(|(_, c)| !c.entries().is_empty())
.map(|(id, _)| id.clone())
.collect()
} else {
Default::default()
};
let screen = self.pack_screen.as_mut().expect("made above");
screen.with_options = with_options;
screen
}
/// Escape or Done: leave the current page, applying page results.
/// After an input on HDR calibration: Save keeps what it landed on, Cancel what was
@@ -4633,6 +4652,16 @@ impl OptionsUi {
self.language_search.focused = false;
self.can_change_value = false;
match page {
Page::PackOptions => {
let ui = std::mem::take(&mut self.pack_options);
if ui.changed {
let entries = ui.configs.entries().keys().cloned().collect();
events.push(Event::PackOptions {
entries,
chosen: ui.configs.chosen(),
});
}
}
Page::ShaderOptions | Page::ShaderPacks => {
if s.shader_options.as_ref().is_some_and(|o| o.changed) {
events.push(Event::ShaderOptions);
@@ -4695,8 +4724,6 @@ impl OptionsUi {
if shell.typing() {
shell.type_text(text);
}
} else if Self::shader_page(self.page()).is_some() && self.shader.typing() {
self.shader.type_text(text);
} else if self.settings_search_active() {
self.settings_search.insert_text(text);
self.scroll = 0.0;
@@ -4731,9 +4758,6 @@ impl OptionsUi {
if let Some(shell) = self.shell_mut() {
return shell.shortcut(shortcut, clipboard);
}
if Self::shader_page(self.page()).is_some() {
return self.shader.shortcut(shortcut, clipboard);
}
if self.settings_search_active() {
if self.settings_search.shortcut(shortcut, clipboard) {
self.scroll = 0.0;
@@ -4855,16 +4879,6 @@ impl OptionsUi {
let outputs = shell.key(s, key, text, shift, gui);
return self.shell_events(s, outputs);
}
if Self::shader_page(self.page()).is_some() {
let size = (self.size.0.max(480.0), self.size.1.max(240.0));
let (actions, handled) = self.shader.key_modifiers(s, key, text, shift, size);
let mut events = self.shader_events(s, actions);
// Escape leaves a screen a link opened first, then the shader screen.
if key == K::Escape && !handled {
events.extend(self.back(s));
}
return events;
}
if key == K::Escape {
return self.back(s);
@@ -5525,8 +5539,20 @@ mod tests {
/// read, and leaving the shader screen applies them.
#[test]
fn the_shader_screen_saves_changed_options_before_reading_the_next_packs() {
use crate::shader_ui::Action;
let mut s = Settings::default();
use crate::shader_pack_list::{Entry, Kind};
let entry = |file: &str| Entry {
file: file.into(),
key: file.into(),
title: file.into(),
kind: Kind::Classic,
options: Vec::new(),
values: Vec::new(),
pages: Vec::new(),
};
let mut s = Settings {
shader_packs: vec![entry("A.zip"), entry("B.zip")],
..Settings::default()
};
s.horizon.shader_pack = "A.zip".into();
let mut options = crate::shader_options::ShaderOptions::new(
"A.zip".into(),
@@ -5540,15 +5566,21 @@ mod tests {
let mut ui = OptionsUi::default();
ui.open(false);
ui.push(Page::ShaderPacks);
let events = ui.shader_events(&mut s, vec![Action::Picked]);
let size = (854.0, 480.0);
let (x, y) = ui
.shader
.point_at(&s, size, crate::shader_ui::Item::Pack(1));
let events = ui.click(&mut s, &atlas(), x, y, size, [1708, 960], false, false);
assert!(matches!(
events[..],
[
Event::ButtonClick,
Event::ShaderOptions,
Event::ShaderPack,
Event::OpenShaderOptions
]
));
assert_eq!(s.horizon.shader_pack, "B.zip");
let events = ui.back(&mut s);
assert!(matches!(events[0], Event::ShaderOptions));
}
@@ -0,0 +1,558 @@
//! The Pack Options page (#11486): the config entries of the selected resource packs' Polytone
//! `config_entries` (wiki "Polytone Configs"), in the frame of Horizon's settings screens
//! (`settings_shell`). Numbers are sliders, texts cycle, booleans switch; the entries sit in
//! their `section`s, named by the packs' own translations. Leaving with Done keeps what changed,
//! and the packs load again with it.
use crate::widgets::Action as Declared;
use crate::{
graphics_style as style,
hud::{HudAtlas, HudBatch},
lang,
settings_shell::{self as shell, Columns, Line, Lines},
text::Text,
ui,
};
use horizon_polytone::pack::{ConfigKind, ConfigValue, Configs};
/// What a click asks of the options screen.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Action {
ButtonClick,
SliderDrag,
/// Done: leave, keeping what changed.
Done,
}
/// The page's state: the entries being edited, how they are listed, and the pointer's hold.
#[derive(Default)]
pub struct PackOptionsUi {
/// The selected packs' entries and the values being picked.
pub configs: Configs,
/// Entry ids, by the row index the list uses.
ids: Vec<String>,
/// The sections in order: the `section` id (none for entries without one) and its rows.
sections: Vec<(Option<String>, Vec<u16>)>,
scroll: ui::Scroll,
dragging: Option<u16>,
/// A value changed since the page opened.
pub changed: bool,
}
/// The presets' row (and its section): past every entry's index.
const PRESETS: u16 = u16::MAX;
/// A translation, or `fallback` where no language has the key.
fn translated(key: &str, fallback: impl FnOnce() -> String) -> String {
if lang::has(key) {
lang::get(key)
} else {
fallback()
}
}
/// `namespace:path` split, `minecraft` when there is no namespace.
fn split(id: &str) -> (&str, &str) {
id.split_once(':').unwrap_or(("minecraft", id))
}
/// A name for an id without a translation: `fog_density` is "Fog density".
fn readable(path: &str) -> String {
let words = path.rsplit('/').next().unwrap_or(path).replace('_', " ");
let mut chars = words.chars();
chars
.next()
.map(|c| c.to_uppercase().chain(chars).collect())
.unwrap_or_default()
}
impl PackOptionsUi {
/// The page opens on `configs`, the selected packs' entries with the player's values.
pub fn open(configs: Configs) -> Self {
let mut ids: Vec<(String, Option<String>, i64)> = configs
.entries()
.iter()
.map(|(id, e)| (id.clone(), e.section.clone(), e.display_order))
.collect();
ids.sort_by(|a, b| a.2.cmp(&b.2).then_with(|| a.0.cmp(&b.0)));
// A section's place: the smallest `section_order` among its entries, else its name.
let mut sections: Vec<(Option<String>, Option<i64>, Vec<u16>)> = Vec::new();
for (row, (id, section, _)) in ids.iter().enumerate() {
let order = configs.entries()[id].section_order;
match sections.iter_mut().find(|s| &s.0 == section) {
Some(s) => {
s.1 = match (s.1, order) {
(Some(a), Some(b)) => Some(a.min(b)),
(a, b) => a.or(b),
};
s.2.push(row as u16);
}
None => sections.push((section.clone(), order, vec![row as u16])),
}
}
sections.sort_by(|a, b| match (a.1, b.1) {
(Some(x), Some(y)) => x.cmp(&y),
(Some(_), None) => std::cmp::Ordering::Less,
(None, Some(_)) => std::cmp::Ordering::Greater,
(None, None) => a.0.cmp(&b.0),
});
Self {
configs,
ids: ids.into_iter().map(|(id, ..)| id).collect(),
sections: sections.into_iter().map(|(s, _, rows)| (s, rows)).collect(),
..Self::default()
}
}
fn layout(&self, w: f32, h: f32) -> (Columns, Vec<Line<u16, u16>>, f32) {
let cols = Columns::of(w, h);
let mut lines = Lines::new();
// The pack-wide presets first, when the entries have some.
if !self.configs.presets().is_empty() {
lines.titled(PRESETS, vec![PRESETS]);
}
for (i, (_, rows)) in self.sections.iter().enumerate() {
lines.titled(i as u16, rows.clone());
}
let (lines, content) = lines.finish();
(cols, lines, content)
}
/// The namespace the presets' translations are under: of the first entry with presets.
fn presets_namespace(&self) -> &str {
self.configs
.entries()
.iter()
.find(|(_, e)| !e.presets.is_empty())
.map_or("minecraft", |(id, _)| split(id).0)
}
/// The pack-wide preset every entry's value matches, if one does.
fn current_preset(&self) -> Option<String> {
self.configs.presets().into_iter().find(|preset| {
self.configs
.entries()
.iter()
.all(|(id, e)| match e.presets.get(preset) {
Some(value) => e.accept(value).as_ref() == self.configs.get(id),
None => true,
})
})
}
/// A preset's label (`<namespace>.presets.<name>`), or the label of none (`.none`).
fn preset_label(&self, preset: Option<&str>) -> String {
let namespace = self.presets_namespace();
match preset {
Some(name) => translated(&format!("{namespace}.presets.{name}"), || readable(name)),
None => translated(&format!("{namespace}.presets.none"), || {
lang::get("horizon.pack.options.presets.custom")
}),
}
}
/// The next pack-wide preset after the current one (`back`: before it), applied.
fn cycle_preset(&mut self, back: bool) {
let presets = self.configs.presets();
if presets.is_empty() {
return;
}
let n = presets.len();
let next = match self
.current_preset()
.and_then(|c| presets.iter().position(|p| *p == c))
{
Some(i) if back => (i + n - 1) % n,
Some(i) => (i + 1) % n,
None if back => n - 1,
None => 0,
};
let before = self.configs.clone();
self.configs.apply_preset(&presets[next]);
if self.configs != before {
self.changed = true;
}
}
/// A section's heading: `config.<namespace>.section.<id>`, by its first entry's namespace.
fn section_name(&self, section: u16) -> String {
if section == PRESETS {
let key = format!("{}.presets", self.presets_namespace());
return translated(&key, || lang::get("horizon.pack.options.presets"));
}
let Some((id, rows)) = self.sections.get(usize::from(section)) else {
return String::new();
};
let Some(id) = id else {
return lang::get("horizon.pack.options.general");
};
let namespace = rows
.first()
.and_then(|&r| self.ids.get(usize::from(r)))
.map_or("minecraft", |entry| split(entry).0);
translated(&format!("config.{namespace}.section.{id}"), || readable(id))
}
/// An entry's name (`config.<namespace>.<name>`) and its value's label.
fn parts(&self, row: u16) -> (String, Option<String>) {
if row == PRESETS {
let label = self.preset_label(self.current_preset().as_deref());
return (self.section_name(PRESETS), Some(label));
}
let Some(id) = self.ids.get(usize::from(row)) else {
return (String::new(), None);
};
let (namespace, path) = split(id);
let name = translated(&format!("config.{namespace}.{path}"), || readable(path));
let entry = &self.configs.entries()[id];
let value = match self.configs.get(id) {
Some(ConfigValue::Number(n)) => {
let step = match entry.kind {
ConfigKind::Number { step, .. } => step,
_ => 1.0,
};
let text = if step >= 1.0 && step.fract() == 0.0 {
format!("{n:.0}")
} else {
format!("{n:.2}")
.trim_end_matches('0')
.trim_end_matches('.')
.to_owned()
};
Some(text)
}
Some(ConfigValue::Text(t)) => Some(t.clone()),
Some(ConfigValue::Bool(_)) | None => None,
};
let value = value.map(|v| match &entry.value_translation {
Some(key) if lang::has(key) => lang::tr(key, &[&v]),
_ => v,
});
(name, value)
}
/// The tooltip (`config.<namespace>.<name>.tooltip`) and the performance hint.
fn description(&self, row: u16) -> String {
let Some(id) = self.ids.get(usize::from(row)) else {
return String::new();
};
let (namespace, path) = split(id);
let key = format!("config.{namespace}.{path}.tooltip");
let mut text = if lang::has(&key) {
lang::get(&key)
} else {
String::new()
};
if let Some(impact) = &self.configs.entries()[id].performance_impact {
let level = lang::get(&format!("horizon.pack.options.impact.{impact}"));
let line = lang::tr("horizon.pack.options.impact", &[&level]);
text = if text.is_empty() {
line
} else {
format!("{text} {line}")
};
}
text
}
/// A slider's place (0 to 1) for a number entry.
fn slider(&self, row: u16) -> Option<f32> {
let id = self.ids.get(usize::from(row))?;
let ConfigKind::Number { min, max, .. } = self.configs.entries()[id].kind else {
return None;
};
let Some(ConfigValue::Number(n)) = self.configs.get(id) else {
return None;
};
Some(if max > min {
((n - min) / (max - min)) as f32
} else {
0.0
})
}
fn switch(&self, row: u16) -> Option<bool> {
match self.configs.get(self.ids.get(usize::from(row))?)? {
ConfigValue::Bool(b) => Some(*b),
_ => None,
}
}
pub fn draw(
&mut self,
batch: &mut HudBatch,
atlas: &HudAtlas,
cursor: (f32, f32),
in_world: bool,
) {
let (w, h) = (batch.width, batch.height);
let (cols, lines, content) = self.layout(w, h);
self.scroll.clamp(cols.top, cols.bottom, content);
shell::backdrop(batch, atlas, in_world);
shell::mark(
batch,
atlas,
&cols,
&lang::get("horizon.pack.options.title"),
);
shell::plate(
batch,
atlas,
&cols,
&lang::get("horizon.pack.options.title"),
);
let over_list = cursor.1 >= cols.top && cursor.1 < cols.bottom;
let mut described = None;
shell::list(
batch,
atlas,
&cols,
&lines,
self.scroll.amount,
|section| self.section_name(section),
|batch, row: u16, y| {
let [rx, ry, rw, rh] = cols.row(y);
let hover = over_list && ui::hovered(cursor, rx, ry, rw, rh);
if hover || self.dragging == Some(row) {
described = Some(row);
}
let (name, value) = self.parts(row);
let label = match &value {
Some(v) => format!("{name}: {v}"),
None => name.clone(),
};
let view = shell::RowView {
label: &label,
name: &name,
value: value.as_deref(),
switch: self.switch(row),
slider: self.slider(row),
live: true,
hover,
focused: false,
held: self.dragging == Some(row),
key: None,
};
shell::row(batch, atlas, &cols, y, &view);
},
);
shell::scroller(batch, atlas, &cols, content, self.scroll.amount);
// The strip: the pointed-at entry's tooltip.
let [x, y, ..] = cols.dock;
style::panel(batch, atlas, cols.dock);
if let Some(row) = described {
let name = Text::literal(self.parts(row).0);
batch.text_styled(atlas, &name, x + 12.0, y + 8.0, style::WHITE, true);
shell::dock_note(batch, atlas, &cols, self.description(row), style::GREY);
}
let mut foot = vec![(cols.button(0), "gui.done", Some(Declared::Done))];
if self
.configs
.chosen()
.iter()
.any(|(id, _)| self.ids.contains(id))
{
foot.push((cols.button(1), "controls.reset", None));
}
for (rect, key, declared) in foot {
if let Some(declared) = declared {
crate::widgets::declare_action(declared, rect, true);
}
let label = Text::literal(lang::get(key));
shell::foot_button(batch, atlas, rect, &label, cursor, (false, true));
}
}
/// A press (or a drag with `drag`) at `(x, y)`; `back` turns a cycle backwards.
pub fn click(
&mut self,
(x, y): (f32, f32),
(w, h): (f32, f32),
drag: bool,
back: bool,
) -> Vec<Action> {
let (cols, lines, content) = self.layout(w, h);
let at = |[rx, ry, rw, rh]: [f32; 4]| ui::hovered((x, y), rx, ry, rw, rh);
if drag {
if self.scroll.held() {
self.scroll.drag(y, cols.top, cols.bottom, content);
} else if let Some(row) = self.dragging {
self.set_slider(row, cols.slider_at(x));
return vec![Action::SliderDrag];
}
return Vec::new();
}
if cols.scrolls()
&& self
.scroll
.press((x, y), cols.bar_x(), cols.top, cols.bottom, content)
{
return Vec::new();
}
if at(cols.button(0)) {
return vec![Action::ButtonClick, Action::Done];
}
if at(cols.button(1)) {
self.reset();
return vec![Action::ButtonClick];
}
if y < cols.top || y >= cols.bottom {
return Vec::new();
}
let Some(row) = lines.iter().find_map(|line| {
let row = line.setting?;
at(cols.row(cols.top + line.y - self.scroll.amount)).then_some(row)
}) else {
return Vec::new();
};
if row == PRESETS {
self.cycle_preset(back);
return vec![Action::ButtonClick];
}
let Some(id) = self.ids.get(usize::from(row)).cloned() else {
return Vec::new();
};
let entry = self.configs.entries()[&id].clone();
match (&entry.kind, self.configs.get(&id).cloned()) {
(ConfigKind::Number { .. }, _) => {
self.dragging = Some(row);
self.set_slider(row, cols.slider_at(x));
return vec![Action::SliderDrag];
}
(ConfigKind::Bool, Some(ConfigValue::Bool(b))) => {
self.set(&id, ConfigValue::Bool(!b));
}
(ConfigKind::Text { allowed }, Some(ConfigValue::Text(t))) if !allowed.is_empty() => {
let i = allowed.iter().position(|a| *a == t).unwrap_or(0);
let n = allowed.len();
let next = if back { (i + n - 1) % n } else { (i + 1) % n };
self.set(&id, ConfigValue::Text(allowed[next].clone()));
}
_ => return Vec::new(),
}
vec![Action::ButtonClick]
}
fn set(&mut self, id: &str, value: ConfigValue) {
let before = self.configs.get(id).cloned();
self.configs.set(id, value);
if self.configs.get(id).cloned() != before {
self.changed = true;
}
}
/// Number entry `row` at slider place `at` (0 to 1).
fn set_slider(&mut self, row: u16, at: f32) {
let Some(id) = self.ids.get(usize::from(row)).cloned() else {
return;
};
if let ConfigKind::Number { min, max, .. } = self.configs.entries()[&id].kind {
self.set(&id, ConfigValue::Number(min + f64::from(at) * (max - min)));
}
}
/// Every entry back to its default.
fn reset(&mut self) {
let defaults: Vec<_> = self
.configs
.entries()
.iter()
.map(|(id, e)| (id.clone(), e.default.clone()))
.collect();
for (id, value) in defaults {
self.set(&id, value);
}
}
/// The pointer lets go.
pub fn release(&mut self) {
self.scroll.release();
self.dragging = None;
}
/// The mouse wheel scrolls the list.
pub fn scroll(&mut self, (w, h): (f32, f32), amount: f32) {
let (cols, _, content) = self.layout(w, h);
self.scroll
.wheel(amount, 18.0, cols.top, cols.bottom, content);
}
}
#[cfg(test)]
mod tests {
use super::*;
fn configs() -> Configs {
let mut c = Configs::default();
c.add_entry(
"p:fog".into(),
&serde_json::json!({"default_value": 0.5, "section": "sky", "section_order": 2}),
);
c.add_entry("p:rain".into(), &serde_json::json!({"default_value": "drops", "allowed_values": ["drops", "streaks"], "section": "weather", "section_order": 1}));
c.add_entry(
"p:birds".into(),
&serde_json::json!({"default_value": true}),
);
c
}
#[test]
fn sections_sort_by_their_order_and_the_unsectioned_last() {
let ui = PackOptionsUi::open(configs());
let order: Vec<_> = ui.sections.iter().map(|(s, _)| s.as_deref()).collect();
assert_eq!(order, [Some("weather"), Some("sky"), None]);
}
#[test]
fn rows_switch_cycle_and_slide() {
let mut ui = PackOptionsUi::open(configs());
let size = (480.0, 270.0);
let (cols, lines, _) = ui.layout(size.0, size.1);
let ids = ui.ids.clone();
let point = |id: &str| {
let row = ids.iter().position(|i| i == id).unwrap() as u16;
let line = lines.iter().find(|l| l.setting == Some(row)).unwrap();
let [x, y, w, h] = cols.row(cols.top + line.y);
(x + w - 4.0, y + h / 2.0)
};
let (birds, rain) = (point("p:birds"), point("p:rain"));
ui.click(birds, size, false, false);
assert_eq!(ui.configs.get("p:birds"), Some(&ConfigValue::Bool(false)));
ui.click(rain, size, false, false);
assert_eq!(
ui.configs.get("p:rain"),
Some(&ConfigValue::Text("streaks".into()))
);
// The slider's far right end is its maximum.
let fog = point("p:fog");
let (tx, tw) = cols.track();
ui.click((tx + tw, fog.1), size, false, false);
ui.release();
assert_eq!(ui.configs.get("p:fog"), Some(&ConfigValue::Number(1.0)));
assert!(ui.changed);
assert_eq!(ui.configs.chosen().len(), 3);
ui.reset();
assert!(ui.configs.chosen().is_empty());
}
#[test]
fn the_presets_row_cycles_the_pack_wide_presets_and_shows_custom_otherwise() {
let mut c = Configs::default();
let entry = |default: f64, fast: f64, pretty: f64| serde_json::json!({"default_value": default, "presets": {"fast": fast, "pretty": pretty}});
c.add_entry("p:fog".into(), &entry(0.5, 0.0, 1.0));
c.add_entry("p:dust".into(), &entry(0.5, 0.0, 0.8));
let mut ui = PackOptionsUi::open(c);
// The defaults are no preset.
assert_eq!(ui.current_preset(), None);
ui.cycle_preset(false);
assert_eq!(ui.current_preset().as_deref(), Some("fast"));
assert_eq!(ui.configs.get("p:fog"), Some(&ConfigValue::Number(0.0)));
ui.cycle_preset(false);
assert_eq!(ui.current_preset().as_deref(), Some("pretty"));
ui.cycle_preset(true);
assert_eq!(ui.current_preset().as_deref(), Some("fast"));
// One value off the preset: custom again.
ui.set("p:dust", ConfigValue::Number(0.3));
assert_eq!(ui.current_preset(), None);
assert!(ui.changed);
}
}
+105 -1
View File
@@ -320,6 +320,8 @@ pub enum Action {
OpenFolder,
/// The Done button (`onClose`: commit).
Done,
/// Horizon's own: the options button on the card of this pack, which has Polytone options.
Options(String),
/// Horizon's own: a Modrinth hit the player picked, to download into the
/// packs folder. The screen does not know where that is.
BrowsePick(usize),
@@ -359,6 +361,8 @@ pub struct PackScreen {
browser_icons: Vec<String>,
/// Whether that button is drawn at all.
pub browsing_allowed: bool,
/// The packs with Polytone options (#11486): their cards carry an options button.
pub with_options: std::collections::BTreeSet<String>,
/// The browser's own search box, separate from the one that filters the
/// player's installed packs.
browser_search: TextField,
@@ -374,6 +378,8 @@ fn tr(key: &str) -> Text {
/// height of 20; drawn shorter, neither it nor its label sits right, so the
/// button keeps that height and is centred on the shorter search box.
const ADD_SIZE: i32 = 20;
/// The options button on a pack's card.
const OPTIONS_SIZE: i32 = 16;
/// A card in the grid: its icon beside its name, author and description.
const ROW_H: i32 = 74;
/// The air around a card's contents.
@@ -440,6 +446,7 @@ impl PackScreen {
confirm: None,
browser: None,
browsing_allowed: false,
with_options: Default::default(),
backdrop: Backdrop::None,
browser_icons: Vec::new(),
browser_search: {
@@ -632,6 +639,12 @@ impl PackScreen {
}
let l = &self.lists[side];
let hit = l.entry_at(x as i32, y as i32);
if let Some(i) = hit
&& let Some((id, bx, by)) = self.options_button(side, i)
&& d::inside((x as i32, y as i32), bx, by, OPTIONS_SIZE, OPTIONS_SIZE)
{
return Action::Options(id);
}
let mut took = false;
if let Some(i) = hit {
took = true;
@@ -1362,6 +1375,45 @@ impl PackScreen {
);
}
}
/// The options button on the card of row `i` of list `side`, when its pack has
/// Polytone options: the pack and the button's corner, at the card's right edge.
fn options_button(&self, side: usize, i: usize) -> Option<(String, i32, i32)> {
let l = &self.lists[side];
let e = l.entries.get(i)?;
let Row::Pack(id) = &e.row else { return None };
if !self.with_options.contains(id) {
return None;
}
let x = l.row_left() + l.entry_width() - OPTIONS_SIZE - 2;
let y = e.y + (e.height - OPTIONS_SIZE) / 2;
Some((id.clone(), x, y))
}
/// The options button of a card: a small button with a sliders glyph.
fn draw_options_button(out: &mut Vec<Draw>, x: i32, y: i32, hover: bool) {
crate::widgets::narratable(|| {
crate::widgets::Narratable::button(
tr("horizon.pack.options").plain(),
[x as f32, y as f32, OPTIONS_SIZE as f32, OPTIONS_SIZE as f32],
true,
)
});
let sprite = if hover {
"widget/button_highlighted"
} else {
"widget/button"
};
d::sprite(out, sprite, x, y, OPTIONS_SIZE, OPTIONS_SIZE);
let (line, knob) = if hover {
(0xFF_C6_C6_C6u32 as i32, -1)
} else {
(0xFF_A0_A0_A0u32 as i32, 0xFF_E0_E0_E0u32 as i32)
};
// Three sliders, their knobs at different places.
for (row, at) in [(4, 8), (8, 4), (12, 9)] {
d::fill(out, x + 3, y + row, x + 13, y + row + 1, line);
d::fill(out, x + at, y + row - 1, x + at + 2, y + row + 2, knob);
}
}
/// The Add button, just right of the search box.
fn add_button(&self, m: &Measure) -> (i32, i32) {
let (sx, sy) = self.header(m)[2];
@@ -1470,6 +1522,7 @@ impl PackScreen {
});
let (left, w) = (l.row_left(), l.entry_width());
let narrow = if l.scrollable() { 6 } else { 0 };
let mut tooltip = None;
for (i, e) in l.entries.iter().enumerate() {
if e.y + e.height < l.y || e.y > l.bottom() {
continue;
@@ -1560,7 +1613,16 @@ impl PackScreen {
}
}
}
let max = 157 - narrow;
let mut max = 157 - narrow;
if let Some((_, bx, by)) = self.options_button(side, i) {
let hover = hovered == Some(i)
&& d::inside(mouse, bx, by, OPTIONS_SIZE, OPTIONS_SIZE);
Self::draw_options_button(out, bx, by, hover);
if hover {
tooltip = Some(tr("horizon.pack.optionsInfo"));
}
max -= OPTIONS_SIZE + 2;
}
d::string_widget(out, m, &name, cx + 34, cy + 1, max);
let lines = d::label_lines(m, &desc, max, 2, &desc_style);
d::label(out, &lines, cx + 34, cy + 12, max, false);
@@ -1568,6 +1630,9 @@ impl PackScreen {
}
}
out.push(Draw::NoScissor);
if let Some(t) = tooltip {
d::tooltip(out, m, &t, mouse);
}
d::blit(
out,
&format!("textures/gui/{prefix}header_separator.png"),
@@ -1652,6 +1717,45 @@ mod icon_tests {
assert!(load_icon(b"not a png").is_none());
}
/// A pack with Polytone options carries an options button on its card, which opens
/// that pack's options and nothing else; a pack without options has none.
#[test]
fn a_card_with_options_opens_its_own_pack_options() {
let atlas =
crate::hud::HudAtlas::build(std::path::Path::new("/nonexistent/horizon-test-assets"))
.unwrap();
let m = Measure(&atlas.font);
let packs = vec![pack("file/Configurable", None), pack("file/Plain", None)];
let mut screen = PackScreen::new(&m, tr("resourcePack.title"), 480, 320, false, packs, &[]);
screen.with_options.insert("file/Configurable".into());
let row = |screen: &PackScreen, id: &str| {
screen.lists[0]
.entries
.iter()
.position(|e| e.row == Row::Pack(id.into()))
.unwrap()
};
assert!(
screen
.options_button(0, row(&screen, "file/Plain"))
.is_none()
);
let (id, bx, by) = screen
.options_button(0, row(&screen, "file/Configurable"))
.unwrap();
assert_eq!(id, "file/Configurable");
let (cx, cy) = (bx + OPTIONS_SIZE / 2, by + OPTIONS_SIZE / 2);
// Hovered, it explains itself.
let draws = screen.draw(&m, (cx, cy), 0, 0);
assert!(draws.iter().any(|d| matches!(d, Draw::Tooltip { .. })));
assert_eq!(
screen.click(&m, cx as f64, cy as f64),
Action::Options("file/Configurable".into())
);
// The press opened the options, it did not move the pack.
assert!(screen.commit().is_empty());
}
/// Programmer Art and High Contrast draw their own `pack.png`; a pack
/// without one keeps `unknown_pack`.
#[test]
@@ -0,0 +1,132 @@
//! Tags as resource packs' files name them (`#minecraft:logs`): the ids
//! `data/<namespace>/tags/<registry>/<path>.json` holds, nested tags included, read from a pack
//! overlay root (which links the vanilla data) and kept once read.
use serde_json::Value as Json;
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
/// `minecraft:` for an id without a namespace.
fn full_id(id: &str) -> String {
if id.contains(':') {
id.to_owned()
} else {
format!("minecraft:{id}")
}
}
/// The tags under one root, read on first use.
#[derive(Debug)]
pub struct Tags {
root: PathBuf,
cache: Mutex<HashMap<(String, String), Arc<HashSet<String>>>>,
}
impl Tags {
pub fn new(root: &Path) -> Self {
Self {
root: root.to_path_buf(),
cache: Mutex::default(),
}
}
/// The ids in `tag` (`minecraft:logs`, without the `#`) of `registry` (`block`,
/// `entity_type`, `worldgen/biome`...); empty for a tag no file defines.
pub fn members(&self, registry: &str, tag: &str) -> Arc<HashSet<String>> {
let key = (registry.to_owned(), full_id(tag));
if let Some(hit) = self
.cache
.lock()
.unwrap_or_else(|e| e.into_inner())
.get(&key)
{
return hit.clone();
}
let mut out = HashSet::new();
self.collect(registry, &key.1, &mut out, &mut HashSet::new());
let out = Arc::new(out);
self.cache
.lock()
.unwrap_or_else(|e| e.into_inner())
.insert(key, out.clone());
out
}
/// Whether `id` is in `tag` of `registry`.
pub fn contains(&self, registry: &str, tag: &str, id: &str) -> bool {
self.members(registry, tag).contains(id)
}
/// The tag's ids into `out`; `seen` stops a tag that includes itself.
fn collect(
&self,
registry: &str,
tag: &str,
out: &mut HashSet<String>,
seen: &mut HashSet<String>,
) {
if !seen.insert(tag.to_owned()) {
return;
}
let (namespace, path) = tag.split_once(':').unwrap_or(("minecraft", tag));
let file = self
.root
.join(format!("data/{namespace}/tags/{registry}/{path}.json"));
let Ok(json) = horizon_world::json_file::read::<Json>(&file) else {
return;
};
for value in json["values"].as_array().into_iter().flatten() {
// An entry is an id or `{"id": ..., "required": false}`.
let id = value
.as_str()
.or_else(|| value["id"].as_str())
.unwrap_or_default();
match id.strip_prefix('#') {
Some(inner) => self.collect(registry, &full_id(inner), out, seen),
None if !id.is_empty() => {
out.insert(full_id(id));
}
None => {}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn nested_tags_optional_entries_and_cycles_resolve() {
static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
let n = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let root =
std::env::temp_dir().join(format!("horizon-pack-tags-{}-{n}", std::process::id()));
let write = |path: &str, json: serde_json::Value| {
let file = root.join(path);
std::fs::create_dir_all(file.parent().unwrap()).unwrap();
std::fs::write(file, json.to_string()).unwrap();
};
write(
"data/minecraft/tags/block/logs.json",
serde_json::json!({"values": ["oak_log", "#pack:fancy", {"id": "mod:log", "required": false}]}),
);
// A tag that includes the one including it.
write(
"data/pack/tags/block/fancy.json",
serde_json::json!({"values": ["minecraft:cherry_log", "#minecraft:logs"]}),
);
let tags = Tags::new(&root);
let mut logs: Vec<String> = tags.members("block", "logs").iter().cloned().collect();
logs.sort();
assert_eq!(
logs,
["minecraft:cherry_log", "minecraft:oak_log", "mod:log"]
);
assert!(tags.contains("block", "minecraft:logs", "minecraft:oak_log"));
// Another registry's tag of the same name, and a tag no file defines, are empty.
assert!(tags.members("entity_type", "logs").is_empty());
assert!(tags.members("block", "pack:none").is_empty());
std::fs::remove_dir_all(root).unwrap();
}
}
+77
View File
@@ -522,8 +522,85 @@ pub fn polytone_configs(source: &Source) -> horizon_polytone::pack::Configs {
}
}
}
for (id, value) in polytone_choices() {
if configs.entries().contains_key(&id) {
configs.set(&id, value);
}
}
configs
}
/// A pack's Polytone config entries and one's value, for the game's Pack Options.
pub use horizon_polytone::pack::{ConfigValue, Configs};
/// The player's Polytone config choices (#11486): what they changed from the packs' defaults,
/// by entry id, for every pack. `polytone_configs` lays them over each pack's defaults.
static POLYTONE_CHOICES: std::sync::RwLock<BTreeMap<String, horizon_polytone::pack::ConfigValue>> =
std::sync::RwLock::new(BTreeMap::new());
pub fn polytone_choices() -> BTreeMap<String, horizon_polytone::pack::ConfigValue> {
POLYTONE_CHOICES
.read()
.map(|c| c.clone())
.unwrap_or_default()
}
/// Replaces the player's Polytone config choices (packs read them when they load again).
pub fn set_polytone_choices(choices: BTreeMap<String, horizon_polytone::pack::ConfigValue>) {
if let Ok(mut c) = POLYTONE_CHOICES.write() {
*c = choices;
}
}
/// Where the choices are kept: `polytone-configs.json` beside `options.txt`.
pub fn polytone_choices_file() -> std::path::PathBuf {
crate::logging::player_dir().join("polytone-configs.json")
}
/// Reads the choices from `path` (none when it is missing or broken).
pub fn load_polytone_choices(path: &Path) -> BTreeMap<String, horizon_polytone::pack::ConfigValue> {
use horizon_polytone::pack::ConfigValue;
let Ok(bytes) = fs::read(path) else {
return BTreeMap::new();
};
let Ok(serde_json::Value::Object(map)) = serde_json::from_slice(&bytes) else {
log::warn!("{}: not a JSON object", path.display());
return BTreeMap::new();
};
map.into_iter()
.filter_map(|(id, v)| {
let value = match v {
serde_json::Value::Number(n) => ConfigValue::Number(n.as_f64()?),
serde_json::Value::String(s) => ConfigValue::Text(s),
serde_json::Value::Bool(b) => ConfigValue::Bool(b),
_ => return None,
};
Some((id, value))
})
.collect()
}
/// Writes the choices to `path`.
pub fn save_polytone_choices(
path: &Path,
choices: &BTreeMap<String, horizon_polytone::pack::ConfigValue>,
) -> std::io::Result<()> {
use horizon_polytone::pack::ConfigValue;
let map: serde_json::Map<String, serde_json::Value> = choices
.iter()
.map(|(id, v)| {
let json = match v {
ConfigValue::Number(n) => serde_json::json!(n),
ConfigValue::Text(s) => serde_json::json!(s),
ConfigValue::Bool(b) => serde_json::json!(b),
};
(id.clone(), json)
})
.collect();
let text = serde_json::to_string_pretty(&serde_json::Value::Object(map))
.map_err(std::io::Error::other)?;
fs::write(path, text)
}
fn pack(id: String, title: Text, source: Source) -> Option<Pack> {
pack_for(id, title, source, RESOURCE_FORMAT)
}
+173 -40
View File
@@ -30,6 +30,7 @@ pub use horizon_items::firework::{DYE_FIREWORK_COLORS, FireworkExplosion, Firewo
mod native;
pub mod polytone;
mod polytone_gpu;
mod polytone_model;
pub use native::{ParticleController, ParticleUpdate};
/// `ParticleOptions` with the parameters of the option classes used here.
@@ -66,6 +67,9 @@ pub struct ParticleOptions {
/// `nextInt(40) + 10` after constructing the particle. Set by the client for an older
/// server only, never read from the wire.
pub legacy_trail_lifetime: bool,
/// The block or item a `BlockParticleOption` or `ItemParticleOption` carries
/// (`minecraft:stone`), for resource packs' particle modifier filters.
pub source: Option<std::sync::Arc<str>>,
/// What the Polytone emitter that spawned this custom particle sets on it.
pub polytone_seed: Option<std::sync::Arc<polytone::Seed>>,
}
@@ -85,14 +89,7 @@ impl ParticleOptions {
legacy_entity_effect: None,
legacy_trail_lifetime: false,
polytone_seed: None,
}
}
/// A native mod's `SimpleParticleType`: the sprite set of
/// `assets/<namespace>/particles/<path>.json`, drawn as a plain billboard.
pub fn named(name: impl Into<String>) -> Self {
Self {
mod_particle: Some(name.into()),
..Self::simple(ParticleType::Smoke)
source: None,
}
}
/// `GeyserParticleOptions(type, waterBlocks)`.
@@ -226,6 +223,8 @@ enum Light {
AgeEmission4,
/// `FireflyParticle.getLightCoords`: block light fading in and out, no sky.
Firefly,
/// A Polytone particle's `light_level`: block light at least this level.
MinBlock(u8),
}
#[derive(Clone, Copy, Debug, PartialEq)]
enum Behaviour {
@@ -527,8 +526,10 @@ pub struct Particle {
/// `Particle.random`: the particle's own generator (`RandomSource.create()`).
random: LegacyRandom,
controller: Option<Box<dyn native::Controller>>,
/// A resource pack's Polytone custom particle type, for its `limit`.
/// A resource pack's Polytone custom particle type, for its `limit` and `exclusion_radius`.
polytone_type: Option<std::sync::Arc<str>>,
/// A Polytone model particle's faces, drawn instead of its sprite quad.
model: Option<std::sync::Arc<polytone_model::ParticleModel>>,
/// How a Polytone custom particle's quad turns (`rotation_mode`).
orientation: polytone_gpu::Orientation,
/// Blended additively (a Polytone `additive_translucent` particle).
@@ -895,6 +896,7 @@ impl Particle {
random: LegacyRandom::new(0),
controller: None,
polytone_type: None,
model: None,
orientation: polytone_gpu::Orientation::Camera,
additive: false,
}
@@ -1098,6 +1100,7 @@ impl Particle {
let b = (b * b) * (b * b);
(sky, (block + (b.clamp(0.0, 1.0) * 240.0) as u32).min(240))
}
Light::MinBlock(level) => (sky, block.max(u32::from(level) * 16)),
Light::Firefly => (
0,
(255.0 * fade_amount(self.lifetime_progress(self.age as f32 + partial), 0.1, 0.3))
@@ -1191,6 +1194,27 @@ pub struct QuadSnapshot {
pub light: u32,
}
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum TrackingTarget {
LocalPlayer,
Entity(i32),
}
/// The entity position and current dimensions that `getX/Y/Z(double)` use.
#[derive(Clone, Copy)]
pub struct TrackingBody {
pub feet: [f64; 3],
pub width: f64,
pub height: f64,
}
struct TrackingEmitter {
options: ParticleOptions,
target: Option<TrackingTarget>,
body: TrackingBody,
remaining: u32,
}
/// World access for particle physics.
pub struct ParticleLevel<'a> {
pub world: &'a World,
@@ -1265,6 +1289,9 @@ impl ParticleLevel<'_> {
pub struct ParticleEngine {
particles: Vec<Particle>,
pending: Vec<Particle>,
/// While the live particles tick (out of `particles`), what their emitters spawn waits
/// here, so a spawn's `limit` and `exclusion_radius` see every live particle.
spawned_in_tick: Option<Vec<(ParticleOptions, [f64; 3], [f64; 3])>>,
/// `ParticleEngine.random`, handed to every `ParticleProvider`.
random: LegacyRandom,
/// `ClientLevel.random` as particle constructors use it (`level.getRandom()`).
@@ -1293,13 +1320,15 @@ pub struct ParticleEngine {
set_sprites: Vec<Vec<u32>>,
/// Resource packs' Polytone custom particle types (#11486).
polytone: Option<std::sync::Arc<polytone::Registry>>,
/// Vanilla particle types a pack replaces with a custom one of the same id
/// (`assets/minecraft/polytone/custom_particles/rain.json` is `minecraft:rain`).
polytone_overrides: HashMap<ParticleType, String>,
/// The packs' GPU particles' records, one store per type, and the ticks they count in.
polytone_gpu: Vec<polytone_gpu::Store>,
polytone_ticks: u64,
/// The last frame's additively blended quads (`take_additive`).
additive: std::sync::Mutex<Vec<Vertex>>,
/// `TrackingEmitter`s: particle type, entity feet position, width, height, ticks left.
emitters: Vec<(ParticleOptions, [f64; 3], f64, f64, u32)>,
emitters: Vec<TrackingEmitter>,
/// Camera position for the 32-block spawn limiter.
pub camera: [f64; 3],
/// The Particles option (`ParticleStatus`): 0 all, 1 decreased, 2 minimal.
@@ -1477,6 +1506,7 @@ impl ParticleEngine {
let mut engine = Self {
particles: Vec::new(),
pending: Vec::new(),
spawned_in_tick: None,
random: LegacyRandom::new(0x7061_7274),
level_random: LegacyRandom::new(0x6c65_7665),
spell_random: LegacyRandom::new(0x7370_656c),
@@ -1491,6 +1521,7 @@ impl ParticleEngine {
mod_sets: HashMap::new(),
set_sprites: Vec::new(),
polytone: None,
polytone_overrides: HashMap::new(),
polytone_gpu: Vec::new(),
polytone_ticks: 0,
additive: std::sync::Mutex::default(),
@@ -1724,6 +1755,15 @@ impl ParticleEngine {
};
if !same {
self.polytone_gpu.clear();
self.polytone_overrides = registry
.iter()
.flat_map(|r| {
ParticleType::ALL.iter().filter_map(|&ty| {
let id = format!("minecraft:{}", ty.name());
r.get(&id).is_some().then_some((ty, id))
})
})
.collect();
}
self.polytone = registry;
}
@@ -1910,27 +1950,51 @@ impl ParticleEngine {
pub fn tracking_emitter(
&mut self,
options: ParticleOptions,
feet: [f64; 3],
width: f64,
height: f64,
target: TrackingTarget,
body: TrackingBody,
) {
self.tracking_emitter_for(options, feet, width, height, 3);
self.tracking_emitter_for(options, target, body, 3);
}
/// `createTrackingEmitter(entity, type, lifetime)`.
pub fn tracking_emitter_for(
&mut self,
options: ParticleOptions,
feet: [f64; 3],
width: f64,
height: f64,
target: TrackingTarget,
body: TrackingBody,
life: u32,
) {
self.emit(&options, feet, width, height);
self.emit(&options, body);
if life > 1 {
self.emitters.push((options, feet, width, height, life - 1));
self.emitters.push(TrackingEmitter {
options,
target: Some(target),
body,
remaining: life - 1,
});
}
}
fn emit(&mut self, options: &ParticleOptions, feet: [f64; 3], width: f64, height: f64) {
/// Vanilla keeps the old entity reference until the emitter expires. Keep
/// its final body if it leaves the level, even when the id is paired again.
pub fn detach_tracking_target(
&mut self,
target: TrackingTarget,
mut last_body: impl FnMut() -> Option<TrackingBody>,
) {
for emitter in &mut self.emitters {
if emitter.target == Some(target) {
emitter.body = last_body().unwrap_or(emitter.body);
emitter.target = None;
}
}
}
/// `TrackingEmitter.tick`: the spawn attempts use the tracked entity's
/// current feet and dimensions.
fn emit(&mut self, options: &ParticleOptions, body: TrackingBody) {
let TrackingBody {
feet,
width,
height,
} = body;
for _ in 0..16 {
let r = &mut self.random;
let xa = f64::from(r.next_float() * 2.0 - 1.0);
@@ -1938,9 +2002,9 @@ impl ParticleEngine {
let za = f64::from(r.next_float() * 2.0 - 1.0);
if xa * xa + ya * ya + za * za <= 1.0 {
let p = [
feet[0] + width * xa / 4.0,
feet[0] + width * (xa / 4.0),
feet[1] + height * (0.5 + ya / 4.0),
feet[2] + width * za / 4.0,
feet[2] + width * (za / 4.0),
];
self.add(options, p, [xa, ya + 0.2, za]);
}
@@ -1993,8 +2057,18 @@ impl ParticleEngine {
return;
}
let seed = self.seeds.next_long();
if let Some(particle) = self.make(options, p, v, seed) {
if let Some(mut particle) = self.make(options, p, v, seed) {
*self.made.entry(options.ty.name()).or_default() += 1;
// Resource packs' Polytone particle modifiers (#11486), once as it spawns.
if let Some(registry) = &self.polytone
&& registry.modifies_particles()
{
let id = match &options.mod_particle {
Some(name) => name.clone(),
None => format!("minecraft:{}", options.ty.name()),
};
polytone::modify(registry, &id, options.source.as_deref(), &mut particle);
}
// The provider has drawn, so rejecting here keeps vanilla's RNG order.
// shortcut: counts live plus queued particles by scanning instead of
// vanilla's counted map; use a counter if spore spawning shows in profiles.
@@ -2139,12 +2213,17 @@ impl ParticleEngine {
let ty = o.ty;
// A native mod's SimpleParticleType: vanilla's TextureSheetParticle with
// the sprite set its particles/<name>.json names, no tint, 20..40 ticks.
if let Some(name) = &o.mod_particle
// A pack's custom particle: a new type, or one replacing the vanilla type of its id.
let custom = match &o.mod_particle {
Some(name) => Some(name.as_str()),
None => self.polytone_overrides.get(&ty).map(String::as_str),
};
if let Some(name) = custom
&& let Some(registry) = &self.polytone
&& let Some(def) = registry.get(name)
{
let (registry, def) = (registry.clone(), def.clone());
let particle = polytone::make(self, &registry, name, &def, o, p, v, seed)?;
let (registry, def, name) = (registry.clone(), def.clone(), name.to_owned());
let particle = polytone::make(self, &registry, &name, &def, o, p, v, seed)?;
if def.gpu.is_some() {
// A GPU particle is only a record: it never becomes an engine particle.
let now = self.polytone_ticks;
@@ -3793,7 +3872,16 @@ impl ParticleEngine {
}
}
}
/// A tick where no emitter follows an entity's live body (tests, fixtures, benches).
pub fn tick(&mut self, level: &ParticleLevel) {
self.tick_tracking(level, |_| None);
}
/// A tick whose tracking emitters read each followed entity's live body.
pub fn tick_tracking(
&mut self,
level: &ParticleLevel,
mut tracking_body: impl FnMut(TrackingTarget) -> Option<TrackingBody>,
) {
self.polytone_ticks += 1;
for store in &mut self.polytone_gpu {
store.expire(self.polytone_ticks);
@@ -3804,18 +3892,29 @@ impl ParticleEngine {
}
self.tick_starters();
self.tick_explosions(level);
for (options, feet, width, height, life) in std::mem::take(&mut self.emitters) {
self.emit(&options, feet, width, height);
if life > 1 {
self.emitters.push((options, feet, width, height, life - 1));
let mut emitters = std::mem::take(&mut self.emitters);
emitters.retain_mut(|emitter| {
if let Some(target) = emitter.target {
match tracking_body(target) {
Some(body) => emitter.body = body,
None => emitter.target = None,
}
}
}
self.emit(&emitter.options, emitter.body);
emitter.remaining -= 1;
emitter.remaining > 0
});
self.emitters = emitters;
let mut particles = std::mem::take(&mut self.particles);
self.spawned_in_tick = Some(Vec::new());
for p in &mut particles {
self.tick_particle(p, level);
}
particles.retain(|p| !p.removed);
self.particles = particles;
for (options, pos, velocity) in self.spawned_in_tick.take().unwrap_or_default() {
self.add(&options, pos, velocity);
}
let pending = std::mem::take(&mut self.pending);
for mut p in pending {
if p.pre_tick {
@@ -3923,8 +4022,12 @@ impl ParticleEngine {
if let Some(frame) = update.frame {
self.set_sprite_frame(p, frame);
}
for (options, pos, velocity) in update.spawned {
self.add(&options, pos, velocity);
if let Some(later) = &mut self.spawned_in_tick {
later.extend(update.spawned);
} else {
for (options, pos, velocity) in update.spawned {
self.add(&options, pos, velocity);
}
}
} else {
p.remove();
@@ -4868,7 +4971,10 @@ impl ParticleEngine {
(pos.as_vec3(), p.quad_size(partial))
});
let rotation = if p.orientation != polytone_gpu::Orientation::Camera {
let velocity = FVec3::new(
// A custom particle's `movement_aligned` quad has its texture's top against
// its motion (measured in Java: a light ray rising toward the noon sun hangs
// below the leaves it starts at); a GPU particle's has it along its motion.
let velocity = -FVec3::new(
p.velocity[0] as f32,
p.velocity[1] as f32,
p.velocity[2] as f32,
@@ -4958,6 +5064,35 @@ impl ParticleEngine {
r.v0 + dv * p.uv[3],
);
let target: &mut Vec<Vertex> = if p.additive { &mut additive } else { &mut *out };
let overlay = if p.additive {
crate::gfx::ADDITIVE_PARTICLE_OVERLAY
} else if layer {
crate::gfx::TRANSLUCENT_PARTICLE_OVERLAY
} else {
0
};
// A Polytone model particle: its model's faces, scaled by its size and turned as
// its quad would be.
if let Some(model) = &p.model {
for quad in &model.quads {
let Some(&(r, _)) = self.atlas.get(&quad.sprite) else {
continue;
};
let (du, dv) = (r.u1 - r.u0, r.v1 - r.v0);
for (corner, uv) in quad.corners.iter().zip(quad.uv) {
target.push(Vertex {
normal: [0; 4],
pos: (center + rotation * (*corner * size)).to_array(),
uv: [r.u0 + du * uv[0], r.v0 + dv * uv[1]],
layer: overlay | u32::from(hdr_tier(p.ty)) << 16,
color,
light,
glint_uv: [0.0, 0.0],
});
}
}
continue;
}
for rotation in rotations.into_iter().flatten() {
for (n, uv) in [
((1.0, -1.0), [u1, v1]),
@@ -4970,11 +5105,7 @@ impl ParticleEngine {
normal: [0; 4],
pos: v.to_array(),
uv,
layer: if layer {
crate::gfx::TRANSLUCENT_PARTICLE_OVERLAY
} else {
0
} | u32::from(hdr_tier(p.ty)) << 16,
layer: overlay | u32::from(hdr_tier(p.ty)) << 16,
color,
light,
@@ -5062,6 +5193,8 @@ const CREEPER: [[f64; 2]; 12] = [
[0.0, -0.4],
];
#[cfg(test)]
mod tick_bench;
#[cfg(test)]
mod tests {
use super::*;
@@ -37,6 +37,17 @@ impl Clone for Box<dyn Controller> {
}
}
impl super::ParticleOptions {
/// A native mod's `SimpleParticleType`: the sprite set of
/// `assets/<namespace>/particles/<path>.json`, drawn as a plain billboard.
pub fn named(name: impl Into<String>) -> Self {
Self {
mod_particle: Some(name.into()),
..Self::simple(super::ParticleType::Smoke)
}
}
}
#[cfg(test)]
mod tests {
use super::super::*;
@@ -11,8 +11,8 @@ use crate::environment::Frame;
use horizon_core::random::{LegacyRandom, RandomSource};
use horizon_polytone::expr::{Host, Program, Root, Value as V};
use horizon_polytone::particles::{
BlockEmitters, CustomParticle, Emitter, EntityEmitters, LiquidAffinity, RenderType,
SoundEmitter,
BlockEmitters, CustomParticle, Emitter, EntityEmitters, LiquidAffinity, ParticleModifier,
RenderType, SoundEmitter,
};
use horizon_world::registry::BlockRegistry;
use serde_json::Value as Json;
@@ -37,10 +37,17 @@ pub struct Registry {
blocks: &'static BlockRegistry,
/// Each custom particle type's colormap (`colormap`), by type id.
tints: HashMap<String, Arc<Tint>>,
/// The tags modifiers' `targets` and emitters' `biomes` name.
tags: crate::pack_tags::Tags,
/// Each model particle type's faces (`model`), by type id.
models: HashMap<String, Arc<super::polytone_model::ParticleModel>>,
/// Sound ids as the engine's sound queue holds them, interned once.
sound_names: Mutex<HashMap<String, &'static str>>,
/// Sounds the custom particles played this tick (`ParticleEngine::tick` plays them).
sounds: Mutex<Vec<Sound>>,
/// The particle modifiers with their colormaps, and by particle type the ones aimed at it.
particle_modifiers: Vec<(ParticleModifier, Option<Tint>, Option<Filter>)>,
modifiers_by_type: Mutex<HashMap<String, Arc<[usize]>>>,
}
/// A sound for the engine's queue: id, position, volume, pitch, delayed by the distance.
@@ -140,6 +147,15 @@ fn json_files(root: &Path, dir: &Path) -> Vec<(String, Json)> {
out
}
impl super::ParticleOptions {
/// These options with the block or item they carry (`source`), for particle modifier
/// filters.
pub fn with_source(mut self, id: &str) -> Self {
self.source = Some(Arc::from(id));
self
}
}
impl Registry {
/// Every `assets/<namespace>/polytone/{custom_particles,block_modifiers,entity_modifiers}/**.json` under a
/// pack overlay root.
@@ -151,6 +167,7 @@ impl Registry {
let mut types = HashMap::new();
let mut modifiers = Vec::new();
let mut entity_modifiers = Vec::new();
let mut particle_modifiers = Vec::new();
let namespaces = std::fs::read_dir(pack_root.join("assets"))
.into_iter()
.flatten()
@@ -176,6 +193,24 @@ impl Registry {
Err(e) => log::warn!("{relative}: {e}"),
}
}
for (relative, json) in json_files(pack_root, &polytone.join("particle_modifiers")) {
let implicit = horizon_polytone::implicit_id(&relative, "particle_modifiers");
match ParticleModifier::parse(&json, implicit.as_deref()) {
Ok(Some(m)) => {
let tint = m.colormap.as_ref().and_then(|json| {
let tint = Tint::read(pack_root, json);
if tint.is_none() {
log::warn!("{relative}: colormap {json} not found");
}
tint
});
let filter = m.filter.as_ref().map(Filter::read);
particle_modifiers.push((m, tint, filter));
}
Ok(None) => {}
Err(e) => log::warn!("{relative}: {e}"),
}
}
for (relative, json) in json_files(pack_root, &polytone.join("block_modifiers")) {
let implicit = horizon_polytone::implicit_id(&relative, "block_modifiers");
match BlockEmitters::parse(&json, implicit.as_deref()) {
@@ -185,7 +220,8 @@ impl Registry {
}
}
}
// Each block's modifiers: the highest priority ones (block tags aren't resolved here).
// Each block's modifiers: the highest priority ones.
let tags = crate::pack_tags::Tags::new(pack_root);
let mut by_name: HashMap<&str, Option<Arc<[Arc<BlockEmitters>]>>> = HashMap::new();
let block_emitters = (0..blocks.len() as u32)
.map(|i| {
@@ -198,7 +234,10 @@ impl Registry {
.or_insert_with(|| {
let aimed: Vec<_> = modifiers
.iter()
.filter(|m| m.targets.matches(name, |_| false))
.filter(|m| {
m.targets
.matches(name, |tag| tags.contains("block", tag, name))
})
.collect();
let top = aimed.iter().map(|m| m.priority).max()?;
Some(
@@ -212,6 +251,12 @@ impl Registry {
.clone()
})
.collect();
let models = super::polytone_model::read_models(
pack_root,
types
.iter()
.filter_map(|(id, def)| Some((id, def.render.get("model")?))),
);
let tints = types
.iter()
.filter_map(|(id, def)| {
@@ -235,8 +280,12 @@ impl Registry {
configs,
blocks,
tints,
models,
tags,
sound_names: Mutex::default(),
sounds: Mutex::default(),
particle_modifiers,
modifiers_by_type: Mutex::default(),
}
}
@@ -244,6 +293,34 @@ impl Registry {
self.types.is_empty()
&& self.block_emitters.iter().all(Option::is_none)
&& self.entity_modifiers.is_empty()
&& self.particle_modifiers.is_empty()
}
/// Whether any pack modifies vanilla or mod particles (`particle_modifiers`).
pub(super) fn modifies_particles(&self) -> bool {
!self.particle_modifiers.is_empty()
}
/// The particle modifiers aimed at a particle type (`minecraft:flame`), in the order read;
/// `#tags` in `targets` are particle type tags.
fn modifiers_for(&self, id: &str) -> Arc<[usize]> {
let mut cache = self
.modifiers_by_type
.lock()
.unwrap_or_else(|e| e.into_inner());
if let Some(found) = cache.get(id) {
return found.clone();
}
let found: Arc<[usize]> = (0..self.particle_modifiers.len())
.filter(|&i| {
self.particle_modifiers[i]
.0
.targets
.matches(id, |tag| self.tags.contains("particle_type", tag, id))
})
.collect();
cache.insert(id.to_owned(), found.clone());
found
}
/// A sound id as the engine's sound queue holds it.
@@ -277,8 +354,8 @@ impl Registry {
}
}
/// The entity modifiers aimed at an entity type (`minecraft:player`); entity tags aren't
/// resolved here.
/// The entity modifiers aimed at an entity type (`minecraft:player`); `#tags` in `targets`
/// are entity type tags.
fn entity_emitters(&self, entity: &str) -> Arc<[Arc<EntityEmitters>]> {
let mut cache = self
.entity_emitters
@@ -289,7 +366,10 @@ impl Registry {
.or_insert_with(|| {
self.entity_modifiers
.iter()
.filter(|m| m.targets.matches(entity, |_| false))
.filter(|m| {
m.targets
.matches(entity, |tag| self.tags.contains("entity_type", tag, entity))
})
.cloned()
.collect()
})
@@ -661,7 +741,7 @@ impl Host for Ctx<'_> {
(CAMERA, "x" | "y" | "z") => num(frame.camera[axis(name)]),
(CAMERA, "viewDistance") => num(f64::from(frame.render_distance * 16)),
(CAMERA, _) => self.positional(ParticleLevel::block_at(frame.camera), name, args),
(GLOBAL, "rain") => num(f64::from(frame.rain)),
(GLOBAL, "rain") => num(frame.global_rain()),
(GLOBAL, "time") => num(frame.game_time as f64),
(GLOBAL, "dayTime") => num(frame.day_time as f64),
(GLOBAL, "skyType") => num(f64::from(frame.sky_type)),
@@ -760,7 +840,7 @@ fn state_matches(predicate: &Json, ctx: &Ctx, at: [i32; 3]) -> bool {
}
}
/// Whether `biomes` (an id, a list of ids, or a tag, which isn't resolved here) holds the biome
/// Whether `biomes` (an id, a `#tag`, or a list of them) holds the biome
/// at `at`.
fn biome_matches(biomes: &Json, ctx: &Ctx, at: [i32; 3]) -> bool {
let Some(biome) = ctx.biome(at) else {
@@ -773,9 +853,14 @@ fn biome_matches(biomes: &Json, ctx: &Ctx, at: [i32; 3]) -> bool {
format!("minecraft:{s}")
}
};
// An id, or a `#tag` of biomes.
let holds = |entry: &str| match entry.strip_prefix('#') {
Some(tag) => ctx.registry.tags.contains("worldgen/biome", tag, &biome),
None => id(entry) == biome,
};
match biomes {
Json::String(s) => id(s) == biome,
Json::Array(list) => list.iter().filter_map(Json::as_str).any(|s| id(s) == biome),
Json::String(s) => holds(s),
Json::Array(list) => list.iter().filter_map(Json::as_str).any(holds),
_ => true,
}
}
@@ -1044,11 +1129,11 @@ impl super::ParticleController for Controller {
}
// Moves with collision when it has physics, else freely; then the friction.
if def.has_physics {
let before = p.position;
p.move_by(level, p.velocity);
let still = (0..3).all(|i| (p.position[i] - before[i]).abs() < 1.0e-7);
// `kill_when_still`: a particle a collision stopped (one that landed), not one that
// was never moving: in Java a resting emitter with physics lives out its life.
if (def.kill_on_contact && (p.on_ground || p.stopped_by_collision))
|| (def.kill_when_still && still)
|| (def.kill_when_still && p.stopped_by_collision)
{
p.remove();
}
@@ -1105,19 +1190,41 @@ pub(super) fn make(
return None;
}
}
// `exclusion_radius`: none spawns within that distance of a live one of its type (rain
// splashes, one per patch of ground).
if def.exclusion_radius > 0.0 && def.gpu.is_none() {
let r2 = def.exclusion_radius * def.exclusion_radius;
let crowded = engine.particles.iter().chain(&engine.pending).any(|p| {
!p.removed
&& p.polytone_type.as_deref() == Some(name)
&& (0..3)
.map(|i| (p.position[i] - position[i]).powi(2))
.sum::<f64>()
< r2
});
if crowded {
return None;
}
}
let mut random = LegacyRandom::new(seed);
let mut particle = Particle::at(&mut random, ParticleType::Smoke, position);
particle.velocity = velocity;
particle.gravity = 0.0;
particle.has_physics = def.has_physics;
let mut frames = 0;
// A model particle (`model`) isn't drawn yet: it still ticks and emits. A GPU particle is
// drawn from its record instead (`polytone_gpu`).
// A model particle draws its model's faces; one whose model is missing still ticks and
// emits. A GPU particle is drawn from its record instead (`polytone_gpu`).
let model = registry.models.get(name).cloned();
let hidden = def.render_type == RenderType::Invisible
|| def.render.contains_key("model")
|| (def.render.contains_key("model") && model.is_none())
|| def.gpu.is_some();
match engine.mod_sets.get(name) {
Some(&(set, _)) => {
// A mod's sprite set, or for a type replacing a vanilla one, that type's.
let set = engine.mod_sets.get(name).map(|&(set, _)| set).or_else(|| {
let vanilla = name.strip_prefix("minecraft:")?;
engine.sets.get(&ParticleType::from_name(vanilla)?).copied()
});
match set {
Some(set) => {
let sprites = engine.set_sprites.get(set as usize)?;
frames = sprites.len();
particle.set = Some(set);
@@ -1129,11 +1236,22 @@ pub(super) fn make(
particle.sprite = Sprite::Index(*sprites.get(pick).or(sprites.first())?);
}
None if hidden => {}
None if model.is_some() => {}
None => return None,
}
particle.no_render = hidden;
if let Some(model) = model {
// The sprite lookup that admits a particle to drawing: its first face's.
particle.sprite = Sprite::Named(model.quads[0].sprite.clone());
particle.model = Some(model);
}
particle.orientation = super::polytone_gpu::Orientation::parse(&def.rotation_mode);
particle.additive = def.render_type == RenderType::AdditiveTranslucent;
// `light_level`: the block light the particle gives itself (wiki "Custom Particle Types"),
// a floor under the light where it is, as GPU particles have.
if def.light_level > 0 {
particle.light = super::Light::MinBlock(def.light_level);
}
particle.behaviour = Behaviour::Controlled {
translucent: def.render_type != RenderType::Opaque,
};
@@ -1170,7 +1288,8 @@ pub(super) fn make(
customs.extend(seed.customs.iter().map(|(k, v)| (k.clone(), *v)));
}
particle.random = random;
particle.polytone_type = def.limit.map(|_| Arc::from(name));
particle.polytone_type =
(def.limit.is_some() || def.exclusion_radius > 0.0).then(|| Arc::from(name));
particle.bb = super::aabb_at(position, particle.bb_width, particle.bb_height);
particle.controller = Some(Box::new(Controller {
def: def.clone(),
@@ -1185,6 +1304,108 @@ pub(super) fn make(
Some(particle)
}
/// A particle modifier's `filter`: the blocks and items (an id or a list each) whose particles
/// it changes.
struct Filter {
blocks: Vec<String>,
items: Vec<String>,
}
impl Filter {
fn read(json: &Json) -> Self {
let ids = |key: &str| -> Vec<String> {
let id = |s: &str| {
if s.contains(':') {
s.to_owned()
} else {
format!("minecraft:{s}")
}
};
match &json[key] {
Json::String(s) => vec![id(s)],
Json::Array(list) => list.iter().filter_map(Json::as_str).map(id).collect(),
_ => Vec::new(),
}
};
Self {
blocks: ids("block"),
items: ids("item"),
}
}
/// Whether a particle carrying `source` (a block's or an item's id) passes.
fn passes(&self, source: Option<&str>) -> bool {
source.is_some_and(|s| self.blocks.iter().chain(&self.items).any(|id| id == s))
}
}
/// Runs the packs' particle modifiers aimed at `id` on a particle that was just made: each
/// expression once, against the particle as its constructor left it (`size` scales it, `speed`
/// sets its power, as vanilla's `Particle.scale` and `setPower`).
pub(super) fn modify(registry: &Registry, id: &str, source: Option<&str>, p: &mut Particle) {
let modifiers = registry.modifiers_for(id);
if modifiers.is_empty() {
return;
}
let Ok(frame) = registry.frame.read() else {
return;
};
let mut random = std::mem::replace(&mut p.random, LegacyRandom::new(0));
let (mut custom, mut customs) = (0.0, BTreeMap::new());
for &i in modifiers.iter() {
let (modifier, tint, filter) = &registry.particle_modifiers[i];
if filter.as_ref().is_some_and(|f| !f.passes(source)) {
continue;
}
let mut ctx = Ctx {
subject: Subject::Particle(p),
random: &mut random,
custom: &mut custom,
customs: &mut customs,
frame: &frame,
registry,
level: None,
blocks: Vec::new(),
};
// Every expression reads the particle before this modifier changes it.
let value =
|ctx: &mut Ctx, key: &str| modifier.fields.get(key).and_then(|prog| eval(prog, ctx));
let values = MODIFIED.map(|key| value(&mut ctx, key));
let tinted = tint.as_ref().and_then(|t| t.sample(&mut ctx));
drop(ctx);
let [color, red, green, blue, alpha, life, size, speed] = values;
if let Some(rgb) = tinted {
p.rgba[..3].copy_from_slice(&rgb);
}
if let Some(c) = color {
let c = c as i64;
for (channel, shift) in [16, 8, 0].into_iter().enumerate() {
p.rgba[channel] = ((c >> shift) & 0xff) as f32 / 255.0;
}
}
for (channel, v) in [red, green, blue, alpha].into_iter().enumerate() {
if let Some(v) = v {
p.rgba[channel] = v as f32;
}
}
if let Some(life) = life {
p.lifetime = life as i32;
}
if let Some(size) = size {
p.scale(size as f32);
}
if let Some(speed) = speed {
p.set_power(speed as f32);
}
}
p.random = random;
}
/// The fields a particle modifier sets, in the order `modify` applies them.
const MODIFIED: [&str; 8] = [
"color", "red", "green", "blue", "alpha", "life", "size", "speed",
];
impl ParticleEngine {
/// The emitters of the packs' entity modifiers aimed at `entity` (its type, as
/// `minecraft:player`) standing at `position`, run once a client tick.
@@ -1228,6 +1449,19 @@ impl ParticleEngine {
}
}
/// Whether the packs' block modifiers stop `state`'s own particles (`disable_particles`): its
/// `Block.animateTick` doesn't run.
pub fn polytone_disables_particles(
&self,
state: horizon_world::registry::BlockStateId,
) -> bool {
self.polytone
.as_ref()
.and_then(|r| r.block_emitters.get(state.0 as usize))
.and_then(Option::as_ref)
.is_some_and(|modifiers| modifiers.iter().any(|m| m.disable_particles))
}
/// The block emitters of the packs' block modifiers aimed at the block at `at`, run from a
/// block animate tick (`Block.animateTick`, as torches spawn their flames).
pub fn polytone_animate(&mut self, level: &ParticleLevel<'_>, at: [i32; 3]) {
@@ -1393,6 +1627,22 @@ mod tests {
])
}
#[test]
fn disable_particles_stops_only_its_blocks_own_particles() {
let blocks = blocks();
let root = pack(&[(
"assets/minecraft/polytone/block_modifiers/torch.json",
serde_json::json!({"disable_particles": true}),
)]);
let engine = engine(Registry::read(&root, Default::default(), blocks));
let state = |name| blocks.default_state(name).unwrap();
assert!(engine.polytone_disables_particles(state("minecraft:torch")));
assert!(!engine.polytone_disables_particles(state("minecraft:air")));
// Without a Polytone pack nothing is stopped.
let plain = ParticleEngine::new(Path::new(""), &HashMap::new(), &HashSet::new());
assert!(!plain.polytone_disables_particles(state("minecraft:torch")));
}
#[test]
fn a_block_emitter_spawns_a_custom_particle_its_values_seed() {
let blocks = blocks();
@@ -1420,6 +1670,39 @@ mod tests {
std::fs::remove_dir_all(root).unwrap();
}
/// `light_level` is a floor under the block light a custom particle is drawn with (as the
/// real mod draws a lit `light_level` 8 particle at night, measured beside it): the world's
/// light where it is brighter.
#[test]
fn light_level_is_a_floor_under_the_block_light() {
let blocks = blocks();
let root = pack(&[
(
"assets/test/polytone/custom_particles/spark.json",
serde_json::json!({"has_physics": false, "light_level": 8, "initializer": {"lifetime": "10"}}),
),
(
"assets/test/polytone/block_modifiers/torch.json",
serde_json::json!({"targets": ["minecraft:torch"], "particle_emitters": [{"particle": "test:spark", "chance": "1"}]}),
),
]);
let scene = scene(blocks);
let level = ParticleLevel {
world: &scene.world,
facts: &scene.facts,
collision: &scene.collision,
player: None,
};
let mut engine = engine(Registry::read(&root, Default::default(), blocks));
engine.polytone_animate(&level, [1, 64, 1]);
let p = engine.pending.pop().unwrap();
// Dark: the floor, 8 (light coordinates count 16 a level). The sky light is the world's.
assert_eq!(p.light_coords(0.0, 240, 0), (240, 128));
// A brighter block light is kept.
assert_eq!(p.light_coords(0.0, 0, 208), (0, 208));
std::fs::remove_dir_all(root).unwrap();
}
#[test]
fn the_ticker_moves_the_particle_and_removes_it() {
let blocks = blocks();
@@ -1497,6 +1780,199 @@ mod tests {
std::fs::remove_dir_all(root).unwrap();
}
#[test]
fn no_particle_spawns_within_the_exclusion_radius_of_its_type() {
let blocks = blocks();
let root = pack(&[
(
"assets/test/polytone/custom_particles/splash.json",
serde_json::json!({"has_physics": false, "exclusion_radius": 1, "render_type": "invisible"}),
),
(
"assets/test/polytone/custom_particles/drop.json",
serde_json::json!({"has_physics": false, "render_type": "invisible"}),
),
(
"assets/minecraft/polytone/entity_modifiers/player.json",
serde_json::json!({"emitters": [
{"particle": "test:splash", "x": "0", "chance": "1"},
// 0.5 from the first: excluded.
{"particle": "test:splash", "x": "0.5", "chance": "1"},
// 3 away: spawns.
{"particle": "test:splash", "x": "3", "chance": "1"},
// Another type is not excluded by it.
{"particle": "test:drop", "x": "0.5", "chance": "1"}
]}),
),
]);
let scene = scene(blocks);
let level = ParticleLevel {
world: &scene.world,
facts: &scene.facts,
collision: &scene.collision,
player: None,
};
let registry = Registry::read(&root, Default::default(), blocks);
let mut engine = engine(registry);
engine.polytone_entity(&level, "minecraft:player", [1.0, 64.0, 1.0]);
let mut xs: Vec<f64> = engine.pending.iter().map(|p| p.position[0]).collect();
xs.sort_by(f64::total_cmp);
assert_eq!(xs, [1.0, 1.5, 4.0]);
std::fs::remove_dir_all(root).unwrap();
}
/// Measured in Java: a custom particle's `movement_aligned` quad has its texture's top
/// against its motion, so the freepack's light rays, rising toward the noon sun, hang
/// below the leaves they start at instead of over them.
#[test]
fn a_rising_movement_aligned_particle_hangs_its_texture_below() {
let blocks = blocks();
let root = pack(&[
(
"assets/test/polytone/custom_particles/spark.json",
serde_json::json!({"has_physics": false, "rotation_mode": "movement_aligned",
"initializer": {"lifetime": "100", "size": "1"}}),
),
(
"assets/minecraft/polytone/entity_modifiers/player.json",
serde_json::json!({"emitters": [{"particle": "test:spark", "chance": "1", "dy": "0.01"}]}),
),
]);
let scene = scene(blocks);
let level = ParticleLevel {
world: &scene.world,
facts: &scene.facts,
collision: &scene.collision,
player: None,
};
let mut engine = engine(Registry::read(&root, Default::default(), blocks));
let rect = crate::hud::Rect {
u0: 0.0,
v0: 0.0,
u1: 1.0,
v1: 1.0,
w: 16,
h: 16,
};
engine.rects = vec![Some(rect); 13];
engine.polytone_entity(&level, "minecraft:player", [1.0, 64.0, 1.0]);
engine.tick(&level);
let p = engine.particles.last().unwrap();
// The camera 5 blocks south, at the particle's height.
let camera = DVec3::new(p.position[0], p.position[1], p.position[2] + 5.0);
let mut out = Vec::new();
engine.render(camera, 0.0, 0.0, 0.0, &|_| 0xF0, &mut out);
assert_eq!(out.len(), 4);
for v in &out {
// The texture's top row (v 0) under the centre, its bottom row over it.
if v.uv[1] == 0.0 {
assert!(v.pos[1] < 0.0, "top row at {:?}", v.pos);
} else {
assert!(v.pos[1] > 0.0, "bottom row at {:?}", v.pos);
}
}
std::fs::remove_dir_all(root).unwrap();
}
/// An `additive_translucent` custom particle takes the fog in Java, as vanilla's particle
/// shader gives it: its quads say so to the additive pass.
#[test]
fn an_additive_custom_particle_takes_the_particle_shader_overlay() {
let blocks = blocks();
let root = pack(&[
(
"assets/test/polytone/custom_particles/spark.json",
serde_json::json!({"has_physics": false, "render_type": "additive_translucent",
"initializer": {"lifetime": "100", "size": "1"}}),
),
(
"assets/minecraft/polytone/entity_modifiers/player.json",
serde_json::json!({"emitters": [{"particle": "test:spark", "chance": "1"}]}),
),
]);
let scene = scene(blocks);
let level = ParticleLevel {
world: &scene.world,
facts: &scene.facts,
collision: &scene.collision,
player: None,
};
let mut engine = engine(Registry::read(&root, Default::default(), blocks));
let rect = crate::hud::Rect {
u0: 0.0,
v0: 0.0,
u1: 1.0,
v1: 1.0,
w: 16,
h: 16,
};
engine.rects = vec![Some(rect); 13];
engine.polytone_entity(&level, "minecraft:player", [1.0, 64.0, 1.0]);
engine.tick(&level);
let mut out = Vec::new();
engine.render(
DVec3::new(1.0, 64.0, 6.0),
0.0,
0.0,
0.0,
&|_| 0xF0,
&mut out,
);
assert!(out.is_empty());
let additive = engine.take_additive();
assert_eq!(additive.len(), 4);
assert!(
additive
.iter()
.all(|v| v.layer & 0xffff == crate::gfx::ADDITIVE_PARTICLE_OVERLAY)
);
std::fs::remove_dir_all(root).unwrap();
}
/// A particle's emitter spawns while the live particles tick: its children's
/// `exclusion_radius` sees the ones alive, so a ray emitted every tick at one spot stays one.
#[test]
fn a_particles_emitter_respects_the_exclusion_radius_of_its_children() {
let blocks = blocks();
let root = pack(&[
(
"assets/test/polytone/custom_particles/holder.json",
serde_json::json!({"has_physics": false, "render_type": "invisible",
"initializer": {"lifetime": "100"},
"particle_emitters": [{"particle": "test:ray", "chance": "1"}]}),
),
(
"assets/test/polytone/custom_particles/ray.json",
serde_json::json!({"has_physics": false, "render_type": "invisible",
"exclusion_radius": 1, "initializer": {"lifetime": "100"}}),
),
(
"assets/minecraft/polytone/entity_modifiers/player.json",
serde_json::json!({"emitters": [{"particle": "test:holder", "chance": "1"}]}),
),
]);
let scene = scene(blocks);
let level = ParticleLevel {
world: &scene.world,
facts: &scene.facts,
collision: &scene.collision,
player: None,
};
let mut engine = engine(Registry::read(&root, Default::default(), blocks));
engine.polytone_entity(&level, "minecraft:player", [1.0, 70.0, 1.0]);
for _ in 0..10 {
engine.tick(&level);
}
let rays = engine
.particles
.iter()
.chain(&engine.pending)
.filter(|p| p.polytone_type.as_deref() == Some("test:ray"))
.count();
assert_eq!(rays, 1);
std::fs::remove_dir_all(root).unwrap();
}
#[test]
fn a_gpu_particle_is_a_record_drawn_until_its_life_ends() {
let blocks = blocks();
@@ -1627,4 +2103,201 @@ mod tests {
);
std::fs::remove_dir_all(root).unwrap();
}
#[test]
fn particle_modifiers_scale_power_and_recolour_their_particles_once() {
let root = pack(&[
(
"assets/minecraft/polytone/particle_modifiers/flame.json",
serde_json::json!({"size": "2", "life": "LIFETIME + 5", "red": "0.5", "speed": "2"}),
),
(
"assets/pack/polytone/particle_modifiers/smoky.json",
serde_json::json!({"targets": ["minecraft:smoke"], "color": "0x336699"}),
),
]);
let registry = Registry::read(&root, Default::default(), blocks());
let flame = || {
let mut p = Particle::blank(ParticleType::Flame, [0.0; 3]);
p.size = 0.1;
p.lifetime = 10;
p.velocity = [0.25, f64::from(0.1f32), -0.5];
p
};
let mut p = flame();
modify(&registry, "minecraft:flame", None, &mut p);
// `size` multiplies, `speed` is vanilla's power: x and z doubled, a y of 0.1 kept.
assert_eq!((p.size, p.lifetime, p.rgba[0]), (0.2, 15, 0.5));
assert_eq!(p.velocity, [0.5, f64::from(0.1f32), -1.0]);
// Another type's modifier, by its targets: the packed colour.
let mut p = flame();
modify(&registry, "minecraft:smoke", None, &mut p);
assert_eq!(
&p.rgba[..3],
&[
0x33 as f32 / 255.0,
0x66 as f32 / 255.0,
0x99 as f32 / 255.0
]
);
assert_eq!((p.size, p.lifetime), (0.1, 10));
// No modifier aimed at it: untouched.
let mut p = flame();
modify(&registry, "minecraft:soul_fire_flame", None, &mut p);
assert_eq!(
(p.size, p.lifetime, p.velocity),
(0.1, 10, [0.25, f64::from(0.1f32), -0.5])
);
}
/// The freepack's light rays come from an emitter with physics that rests beside a leaf and
/// emits every tick of its life: resting is not being still, only a stopped fall is.
#[test]
fn a_resting_particle_with_physics_lives_out_its_life() {
let blocks = blocks();
let root = pack(&[
(
"assets/test/polytone/custom_particles/rest.json",
serde_json::json!({"has_physics": true, "render_type": "invisible",
"initializer": {"lifetime": "20"}}),
),
(
"assets/minecraft/polytone/entity_modifiers/player.json",
serde_json::json!({"emitters": [{"particle": "test:rest", "chance": "1"}]}),
),
]);
let scene = scene(blocks);
let level = ParticleLevel {
world: &scene.world,
facts: &scene.facts,
collision: &scene.collision,
player: None,
};
let mut engine = engine(Registry::read(&root, Default::default(), blocks));
engine.polytone_entity(&level, "minecraft:player", [1.5, 70.0, 1.5]);
let mut p = engine.pending.pop().unwrap();
for _ in 0..10 {
engine.tick_particle(&mut p, &level);
assert!(!p.removed);
}
// Stopped by a collision, it goes.
p.stopped_by_collision = true;
engine.tick_particle(&mut p, &level);
assert!(p.removed);
std::fs::remove_dir_all(root).unwrap();
}
#[test]
fn a_pack_custom_particle_replaces_the_vanilla_type_of_its_id() {
let root = pack(&[(
"assets/minecraft/polytone/custom_particles/flame.json",
serde_json::json!({"has_physics": false, "initializer": {"lifetime": "33", "size": "0.75"}}),
)]);
let mut engine = engine(Registry::read(&root, Default::default(), blocks()));
// The vanilla flame's sprite set (what the pack's particles/flame.json lists).
engine.sets.insert(ParticleType::Flame, 1);
engine.set_sprites.push(vec![20, 21]);
engine.create(
&ParticleOptions::simple(ParticleType::Flame),
[1.0, 65.0, 1.0],
[0.0; 3],
);
assert_eq!(engine.pending.len(), 1);
let p = &engine.pending[0];
assert!(
p.controller.is_some(),
"the pack's particle, not vanilla's flame"
);
assert_eq!((p.lifetime, p.size), (33, 0.75));
assert!(matches!(p.sprite, Sprite::Index(20)));
std::fs::remove_dir_all(root).unwrap();
}
#[test]
fn block_and_biome_tags_pick_where_an_emitter_runs() {
let blocks = blocks();
let root = pack(&[
(
"assets/test/polytone/custom_particles/spark.json",
serde_json::json!({"has_physics": false}),
),
(
"assets/test/polytone/block_modifiers/shiny.json",
serde_json::json!({
"targets": ["#test:shiny"],
"particle_emitters": [{
"particle": "test:spark", "chance": "1", "biomes": "#test:warm",
"spawn_location": "lower_corner", "y": "1"
}]
}),
),
(
"data/test/tags/block/shiny.json",
serde_json::json!({"values": ["minecraft:torch"]}),
),
(
"data/test/tags/worldgen/biome/warm.json",
serde_json::json!({"values": ["#test:hot", "minecraft:plains"]}),
),
(
"data/test/tags/worldgen/biome/hot.json",
serde_json::json!({"values": ["minecraft:desert"]}),
),
]);
let scene = scene(blocks);
let level = ParticleLevel {
world: &scene.world,
facts: &scene.facts,
collision: &scene.collision,
player: None,
};
let mut engine = engine(Registry::read(&root, Default::default(), blocks));
// The test chunk's biome is the table's first: a cold one first, then a warm one.
let first = |name: &str| {
Arc::new(crate::biome::Biomes::from_jsons(&[(
name,
serde_json::json!({}),
)]))
};
let registry = engine.polytone.clone().unwrap();
registry.set_biomes(&first("minecraft:snowy_plains"));
engine.polytone_animate(&level, [1, 64, 1]);
assert!(engine.pending.is_empty(), "outside #test:warm");
// `#test:warm` holds the desert through the nested `#test:hot`.
registry.set_biomes(&first("minecraft:desert"));
engine.polytone_animate(&level, [1, 64, 1]);
assert_eq!(engine.pending.len(), 1);
assert_eq!(engine.pending[0].position, [1.0, 65.0, 1.0]);
std::fs::remove_dir_all(root).unwrap();
}
#[test]
fn a_filtered_modifier_changes_only_the_blocks_and_items_it_names() {
let root = pack(&[
(
"assets/minecraft/polytone/particle_modifiers/block.json",
serde_json::json!({"size": "3", "filter": {"block": ["stone", "minecraft:granite"]}}),
),
(
"assets/minecraft/polytone/particle_modifiers/item.json",
serde_json::json!({"life": "50", "filter": {"item": "minecraft:apple"}}),
),
]);
let registry = Registry::read(&root, Default::default(), blocks());
let run = |id: &str, source: Option<&str>| {
let mut p = Particle::blank(ParticleType::Block, [0.0; 3]);
p.size = 0.1;
p.lifetime = 10;
modify(&registry, id, source, &mut p);
(p.size, p.lifetime)
};
assert_eq!(run("minecraft:block", Some("minecraft:stone")), (0.3, 10));
assert_eq!(run("minecraft:block", Some("minecraft:granite")), (0.3, 10));
assert_eq!(run("minecraft:block", Some("minecraft:dirt")), (0.1, 10));
// A particle that carries no block (the filter can't pass).
assert_eq!(run("minecraft:block", None), (0.1, 10));
assert_eq!(run("minecraft:item", Some("minecraft:apple")), (0.1, 50));
assert_eq!(run("minecraft:item", Some("minecraft:bread")), (0.1, 10));
std::fs::remove_dir_all(root).unwrap();
}
}
@@ -23,6 +23,8 @@ pub(super) enum Orientation {
Flat,
/// `movement_aligned`: stands along its velocity, facing the camera around it.
Movement,
/// `none`: fixed in the world's axes (its +Z facing south).
Fixed,
}
impl Orientation {
@@ -31,6 +33,7 @@ impl Orientation {
"look_at_y" => Self::AroundY,
"look_up" => Self::Flat,
"movement_aligned" => Self::Movement,
"none" => Self::Fixed,
_ => Self::Camera,
}
}
@@ -50,6 +53,7 @@ pub(super) fn orient(
Orientation::Camera => rolled(camera),
Orientation::AroundY => rolled(Quat::from_rotation_y((-center.x).atan2(-center.z))),
Orientation::Flat => rolled(Quat::from_rotation_x(-std::f32::consts::FRAC_PI_2)),
Orientation::Fixed => rolled(Quat::IDENTITY),
Orientation::Movement => {
let up = velocity.normalize_or_zero();
let right = up.cross((-center).normalize_or_zero()).normalize_or_zero();
@@ -61,6 +65,24 @@ pub(super) fn orient(
}
}
/// The alpha a particle keeps at `life` (0 to 1 of its lifetime) with `fade_in` and `fade_out`
/// fractions of it spent fading: each ramp eases in and out (smoothstep), as Polytone's are
/// measured to (a linear ramp leaves young rain drops twice as bright).
fn fade(life: f32, fade_in: f32, fade_out: f32) -> f32 {
let ease = |x: f32| {
let x = x.clamp(0.0, 1.0);
x * x * (3.0 - 2.0 * x)
};
let mut alpha = 1.0;
if fade_in > 0.0 {
alpha *= ease(life / fade_in);
}
if fade_out > 0.0 {
alpha *= ease((1.0 - life) / fade_out);
}
alpha
}
/// One GPU particle spawn.
#[derive(Clone, Copy, Debug)]
struct Record {
@@ -239,12 +261,7 @@ impl Store {
// Fading in and out, and the colour and size toward their ends.
let [fade_in, fade_out] = gpu.fade.map(|f| f as f32);
let mut alpha = r.rgba[3];
if fade_in > 0.0 {
alpha *= (life / fade_in).min(1.0);
}
if fade_out > 0.0 {
alpha *= ((1.0 - life) / fade_out).min(1.0);
}
alpha *= fade(life, fade_in, fade_out);
let mut rgb = [r.rgba[0], r.rgba[1], r.rgba[2]];
if let Some(end) = gpu.color_end {
let channel = |shift: u32| ((end >> shift) & 0xff) as f32 / 255.0;
@@ -368,6 +385,20 @@ mod tests {
assert!((v[1] + 0.1 / -f64::ln(friction)).abs() < 1e-9);
}
/// Measured in Java on a line of particles whose age grows with x: a quarter of the way
/// through its fade-in a particle has about a sixth of its alpha, not a quarter; half at half.
#[test]
fn fades_ease_in_and_out() {
let close = |a: f32, b: f32| (a - b).abs() < 1e-4;
assert!(close(fade(0.0, 0.4, 0.2), 0.0));
assert!(close(fade(0.1, 0.4, 0.2), 0.15625));
assert!(close(fade(0.2, 0.4, 0.2), 0.5));
assert!(close(fade(0.5, 0.4, 0.2), 1.0));
assert!(close(fade(0.9, 0.4, 0.2), 0.5));
assert!(close(fade(1.0, 0.4, 0.2), 0.0));
assert!(close(fade(0.3, 0.0, 0.0), 1.0));
}
#[test]
fn orientations_turn_the_quad_normal() {
let normal = |q: Quat| q * FVec3::Z;
@@ -0,0 +1,331 @@
//! Polytone model particles (`"model"` on a custom particle type, #11486): a particle drawn as
//! the faces of a block model (`assets/<ns>/models/<path>.json`) instead of one sprite quad.
//! Visual Effects+ draws its block glows this way: a glow texture on a plane in front of the
//! block, turned to the camera and blended additively.
//!
//! The model is read the vanilla way (elements, faces, `#texture` references, parents, element
//! rotations) into quads around the model's centre, in blocks; the particle's size scales them
//! and its rotation mode turns them as it turns a sprite quad (`+Z` facing the camera for
//! `look_at_xyz`, world axes for `none`).
use glam::{Quat, Vec3};
use serde_json::{Map, Value as Json};
use std::collections::HashMap;
use std::path::Path;
/// One face of a model particle: corners in blocks around the model's centre, in drawing order,
/// with their texture coordinates inside the sprite (0 to 1).
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct ModelQuad {
pub(crate) corners: [Vec3; 4],
pub(crate) uv: [[f32; 2]; 4],
/// The sprite's atlas name (`davidparticles:block/glow_test_1`, `block/stone`).
pub(crate) sprite: String,
}
/// A model particle's faces.
#[derive(Clone, Debug, Default, PartialEq)]
pub(crate) struct ParticleModel {
pub(crate) quads: Vec<ModelQuad>,
}
/// `ns:path` with `minecraft` when the namespace is left out.
fn split_id(id: &str) -> (&str, &str) {
id.split_once(':').unwrap_or(("minecraft", id))
}
/// The atlas name of a texture id: `minecraft:block/stone` is `block/stone`.
fn sprite_name(texture: &str) -> String {
match split_id(texture) {
("minecraft", path) => path.to_owned(),
(namespace, path) => format!("{namespace}:{path}"),
}
}
/// A model JSON and its parents' `textures` and `elements`, the child's winning.
fn read_model(pack_root: &Path, id: &str, depth: u32) -> Option<(Map<String, Json>, Vec<Json>)> {
if depth > 8 {
return None;
}
let (namespace, path) = split_id(id);
let file = pack_root
.join("assets")
.join(namespace)
.join("models")
.join(format!("{path}.json"));
let json: Json = horizon_world::json_file::read(&file).ok()?;
let (mut textures, mut elements) = match json.get("parent").and_then(Json::as_str) {
Some(parent) => read_model(pack_root, parent, depth + 1).unwrap_or_default(),
None => Default::default(),
};
if let Some(own) = json.get("textures").and_then(Json::as_object) {
textures.extend(own.iter().map(|(k, v)| (k.clone(), v.clone())));
}
if let Some(own) = json.get("elements").and_then(Json::as_array) {
elements.clone_from(own);
}
Some((textures, elements))
}
/// A `#name` texture reference followed through the model's textures to an id.
fn resolve_texture(textures: &Map<String, Json>, reference: &str) -> Option<String> {
let mut name = reference;
for _ in 0..8 {
let Some(key) = name.strip_prefix('#') else {
return Some(name.to_owned());
};
name = textures.get(key)?.as_str()?;
}
None
}
fn vec3(v: &Json) -> Option<Vec3> {
let a = v.as_array()?;
Some(Vec3::new(
a.first()?.as_f64()? as f32,
a.get(1)?.as_f64()? as f32,
a.get(2)?.as_f64()? as f32,
))
}
/// A face's corners (in model pixels) in vanilla's order: top left, bottom left, bottom right,
/// top right, as seen from outside the face.
fn face_corners(face: &str, f: Vec3, t: Vec3) -> Option<[Vec3; 4]> {
let v = Vec3::new;
Some(match face {
"south" => [
v(f.x, t.y, t.z),
v(f.x, f.y, t.z),
v(t.x, f.y, t.z),
v(t.x, t.y, t.z),
],
"north" => [
v(t.x, t.y, f.z),
v(t.x, f.y, f.z),
v(f.x, f.y, f.z),
v(f.x, t.y, f.z),
],
"east" => [
v(t.x, t.y, t.z),
v(t.x, f.y, t.z),
v(t.x, f.y, f.z),
v(t.x, t.y, f.z),
],
"west" => [
v(f.x, t.y, f.z),
v(f.x, f.y, f.z),
v(f.x, f.y, t.z),
v(f.x, t.y, t.z),
],
"up" => [
v(f.x, t.y, f.z),
v(f.x, t.y, t.z),
v(t.x, t.y, t.z),
v(t.x, t.y, f.z),
],
"down" => [
v(f.x, f.y, t.z),
v(f.x, f.y, f.z),
v(t.x, f.y, f.z),
v(t.x, f.y, t.z),
],
_ => return None,
})
}
/// The `uv` a face has when its JSON gives none: the element's extent on that face.
fn default_uv(face: &str, f: Vec3, t: Vec3) -> [f32; 4] {
match face {
"north" => [16.0 - t.x, 16.0 - t.y, 16.0 - f.x, 16.0 - f.y],
"south" => [f.x, 16.0 - t.y, t.x, 16.0 - f.y],
"east" => [16.0 - t.z, 16.0 - t.y, 16.0 - f.z, 16.0 - f.y],
"west" => [f.z, 16.0 - t.y, t.z, 16.0 - f.y],
"up" => [f.x, f.z, t.x, t.z],
_ => [f.x, 16.0 - t.z, t.x, 16.0 - f.z],
}
}
/// An element's `rotation` (`angle` degrees around `axis` through `origin`, in pixels).
fn element_rotation(element: &Json) -> Option<(Quat, Vec3)> {
let r = element.get("rotation")?;
let angle = (r.get("angle")?.as_f64()? as f32).to_radians();
if angle == 0.0 {
return None;
}
let axis = match r.get("axis")?.as_str()? {
"x" => Vec3::X,
"y" => Vec3::Y,
_ => Vec3::Z,
};
let origin = r.get("origin").and_then(vec3).unwrap_or(Vec3::splat(8.0));
Some((Quat::from_axis_angle(axis, angle), origin))
}
impl ParticleModel {
/// The model `id` (`davidparticles:block/fullblock_glow`) under a pack overlay root; `None`
/// when it's missing or has no face to draw.
pub(crate) fn read(pack_root: &Path, id: &str) -> Option<Self> {
let (textures, elements) = read_model(pack_root, id, 0)?;
let mut quads = Vec::new();
for element in &elements {
let (Some(f), Some(t)) = (
element.get("from").and_then(vec3),
element.get("to").and_then(vec3),
) else {
continue;
};
let rotation = element_rotation(element);
let faces = element.get("faces").and_then(Json::as_object);
for (face, spec) in faces.into_iter().flatten() {
let Some(corners) = face_corners(face, f, t) else {
continue;
};
// A face with no area (a plane's edges) draws nothing.
let span = (corners[0] - corners[2]).abs();
if [span.x, span.y, span.z]
.iter()
.filter(|&&s| s > 1.0e-4)
.count()
< 2
{
continue;
}
let Some(sprite) = spec
.get("texture")
.and_then(Json::as_str)
.and_then(|r| resolve_texture(&textures, r))
else {
continue;
};
let uv = match spec.get("uv").and_then(Json::as_array) {
Some(a) if a.len() == 4 => {
let n = |i: usize| a[i].as_f64().unwrap_or(0.0) as f32;
[n(0), n(1), n(2), n(3)]
}
_ => default_uv(face, f, t),
};
if uv[0] == uv[2] || uv[1] == uv[3] {
continue;
}
let corners = corners.map(|c| {
let c = match rotation {
Some((q, origin)) => q * (c - origin) + origin,
None => c,
};
c / 16.0 - Vec3::splat(0.5)
});
let [u0, v0, u1, v1] = uv.map(|x| x / 16.0);
quads.push(ModelQuad {
corners,
uv: [[u0, v0], [u0, v1], [u1, v1], [u1, v0]],
sprite: sprite_name(&sprite),
});
}
}
(!quads.is_empty()).then_some(Self { quads })
}
}
/// The packs' model particles: each custom particle type with a `model`, its faces.
pub(crate) fn read_models<'a>(
pack_root: &Path,
types: impl Iterator<Item = (&'a String, &'a Json)>,
) -> HashMap<String, std::sync::Arc<ParticleModel>> {
types
.filter_map(|(id, model)| {
let model_id = model.as_str()?;
match ParticleModel::read(pack_root, model_id) {
Some(m) => Some((id.clone(), std::sync::Arc::new(m))),
None => {
log::warn!("custom particle {id}: model {model_id} not found or empty");
None
}
}
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn write(root: &Path, path: &str, json: serde_json::Value) {
let file = root.join(path);
std::fs::create_dir_all(file.parent().unwrap()).unwrap();
std::fs::write(file, json.to_string()).unwrap();
}
fn root() -> std::path::PathBuf {
static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
let n = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
std::env::temp_dir().join(format!("horizon-particle-model-{}-{n}", std::process::id()))
}
#[test]
fn a_glow_plane_keeps_its_one_face_in_front_of_the_centre() {
let root = root();
// Visual Effects+'s glow, as Blockbench writes it: a plane at z 18.25 whose edges have
// no area, and faces without a texture.
write(
&root,
"assets/fx/models/block/glow.json",
serde_json::json!({
"textures": {"2": "fx:block/glow", "particle": "fx:block/glow"},
"elements": [{
"from": [1.25, 1.25, 18.25], "to": [14.75, 14.75, 18.25],
"faces": {
"north": {"uv": [0, 0, 0, 0], "texture": "#2"},
"east": {"uv": [0, 0, 0, 4], "texture": "#missing"},
"south": {"uv": [0, 0, 16, 16], "texture": "#2"}
}
}]
}),
);
let model = ParticleModel::read(&root, "fx:block/glow").unwrap();
assert_eq!(model.quads.len(), 1);
let quad = &model.quads[0];
assert_eq!(quad.sprite, "fx:block/glow");
// 18.25 px is 0.640625 blocks in front of the centre; the plane spans ±0.421875.
let z = 18.25 / 16.0 - 0.5;
let e = 6.75 / 16.0;
assert_eq!(
quad.corners,
[
Vec3::new(-e, e, z),
Vec3::new(-e, -e, z),
Vec3::new(e, -e, z),
Vec3::new(e, e, z)
]
);
assert_eq!(quad.uv, [[0.0, 0.0], [0.0, 1.0], [1.0, 1.0], [1.0, 0.0]]);
std::fs::remove_dir_all(root).unwrap();
}
#[test]
fn parents_textures_and_default_uvs_resolve_as_vanilla() {
let root = root();
write(
&root,
"assets/minecraft/models/block/base.json",
serde_json::json!({
"textures": {"all": "block/stone"},
"elements": [{"from": [0, 0, 0], "to": [16, 8, 16], "faces": {"up": {"texture": "#all"}}}]
}),
);
write(
&root,
"assets/fx/models/slab.json",
serde_json::json!({
"parent": "block/base", "textures": {"all": "fx:block/sand"}
}),
);
let model = ParticleModel::read(&root, "fx:slab").unwrap();
assert_eq!(model.quads.len(), 1);
// The child's texture wins; `up` at y 8 px, seen from above with north on top.
assert_eq!(model.quads[0].sprite, "fx:block/sand");
assert_eq!(model.quads[0].corners[0], Vec3::new(-0.5, 0.0, -0.5));
assert_eq!(model.quads[0].corners[2], Vec3::new(0.5, 0.0, 0.5));
assert_eq!(model.quads[0].uv[2], [1.0, 1.0]);
assert!(ParticleModel::read(&root, "fx:missing").is_none());
std::fs::remove_dir_all(root).unwrap();
}
}
@@ -0,0 +1,155 @@
//! The particle stress scene's load on `ParticleEngine`, outside the game: `cargo test --profile fast
//! -p horizon-client-render --lib particle_tick_cost -- --ignored --nocapture` (prepared resources;
//! `PARTICLE_BENCH_SECS` sets the run, default 5). It spawns what tools/stress_scenes.py's `particles`
//! scene makes each tick (flames, cosy smoke, end rods, TNT debris) over its stone floor and times the
//! spawns and the engine tick apart, once the engine holds as many particles as it will.
use super::*;
use crate::{blocks::BlockFacts, chunk::Chunk, collisions, world::World};
use std::sync::Arc;
use std::time::{Duration, Instant};
const FLOOR_Y: i32 = 289;
/// A cheap, repeatable stream of numbers in [0, 1).
struct Lcg(u64);
impl Lcg {
fn next(&mut self) -> f64 {
self.0 = self
.0
.wrapping_mul(6_364_136_223_846_793_005)
.wrapping_add(1_442_695_040_888_963_407);
((self.0 >> 11) as f64) / ((1u64 << 53) as f64)
}
fn signed(&mut self) -> f64 {
self.next() * 2.0 - 1.0
}
}
type Emitter = (ParticleType, usize, [f64; 3], [f64; 3], f64);
fn spawn_tick(engine: &mut ParticleEngine, rng: &mut Lcg, types: &[Emitter], rockets: usize) {
for _ in 0..rockets {
// `summon firework_rocket` with a large ball of three colours and a trail, bursting 18 ticks up.
let explosion = FireworkExplosion {
shape: FireworkShape::LargeBall,
colors: vec![0xFF0000, 0x00FF00, 0x0000FF],
fade: Vec::new(),
trail: true,
twinkle: false,
};
let at = [-5.5 + rng.next() * 12.0, f64::from(FLOOR_Y + 20), 20.5];
engine.fireworks(at, [0.0, 0.1, 0.0], vec![explosion], engine.camera);
}
for &(ty, count, center, delta, speed) in types {
let options = ParticleOptions::simple(ty);
for _ in 0..count {
let p = [
center[0] + rng.signed() * delta[0],
center[1] + rng.signed() * delta[1],
center[2] + rng.signed() * delta[2],
];
let v = [
rng.signed() * speed,
rng.signed() * speed,
rng.signed() * speed,
];
engine.add(&options, p, v);
}
}
}
#[test]
#[ignore = "a measurement, not a check"]
fn particle_tick_cost() {
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client")
.join(horizon_core::VANILLA_VERSION);
if !root.join("cached-collision.nbt").exists() {
eprintln!("no prepared resources: skipped");
return;
}
let assets = crate::assets::Assets::load(&root).unwrap();
let facts = BlockFacts::load(&root, assets.registry).unwrap();
let collision = collisions::load(&root, assets.registry).unwrap();
let mut world = World::new(facts.air, 0);
let floor = assets
.registry
.default_state("minecraft:smooth_stone")
.unwrap();
for cz in -1..=3 {
for cx in -2..=2 {
world
.chunks
.insert((cx, cz), Arc::new(Chunk::empty(cx, cz)));
}
}
for z in 2..=40 {
for x in -24..=24 {
world.set(x, FLOOR_Y, z, floor).unwrap();
}
}
let level = ParticleLevel {
world: &world,
facts: &facts,
collision: &collision,
player: None,
};
let ty = |name: &str| ParticleType::from_name(name).unwrap();
let (fx, fy, fz) = (0.5, f64::from(FLOOR_Y + 5), 20.5);
let tnt = [0.5, f64::from(FLOOR_Y + 24), 26.5];
// The scene's emitters, per tick, and TNT's debris (poof and smoke).
let flame: Emitter = (ty("flame"), 320, [fx, fy, fz], [8.0, 4.0, 8.0], 0.01);
let cosy: Emitter = (
ty("campfire_cosy_smoke"),
8,
[fx, fy, fz],
[8.0, 4.0, 8.0],
0.0,
);
let rod: Emitter = (ty("end_rod"), 80, [fx, fy, fz], [8.0, 4.0, 8.0], 0.05);
let poof: Emitter = (ty("poof"), 258, tnt, [3.0, 3.0, 3.0], 0.4);
let smoke: Emitter = (ty("smoke"), 238, tnt, [3.0, 3.0, 3.0], 0.4);
let cases: [(&str, Vec<Emitter>, usize); 7] = [
("flame 320/tick", vec![flame], 0),
("cosy smoke 8/tick", vec![cosy], 0),
("end rod 80/tick", vec![rod], 0),
("poof 258/tick", vec![poof], 0),
("smoke 238/tick", vec![smoke], 0),
("3 rockets/tick", vec![], 3),
("the whole scene", vec![flame, cosy, rod, poof, smoke], 3),
];
let secs: f64 = std::env::var("PARTICLE_BENCH_SECS")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(2.0);
for (name, types, rockets) in cases {
let mut engine = ParticleEngine::new(Path::new(""), &HashMap::new(), &HashSet::new());
engine.camera = [0.5, 298.0, 0.5];
let mut rng = Lcg(1234);
for _ in 0..600 {
spawn_tick(&mut engine, &mut rng, &types, rockets);
engine.tick(&level);
}
let (mut spawn, mut tick, mut ticks, mut alive) =
(Duration::ZERO, Duration::ZERO, 0u64, 0usize);
let end = Instant::now() + Duration::from_secs_f64(secs);
while Instant::now() < end {
let t = Instant::now();
spawn_tick(&mut engine, &mut rng, &types, rockets);
spawn += t.elapsed();
let t = Instant::now();
engine.tick(&level);
tick += t.elapsed();
alive += engine.len();
ticks += 1;
}
let per = |d: Duration| d.as_secs_f64() * 1000.0 / ticks as f64;
let alive = alive / ticks as usize;
eprintln!(
"{name:20} alive {alive:6} | per tick: spawn {:6.3} ms, engine tick {:6.3} ms | {:5.0} ns per particle",
per(spawn),
per(tick),
per(tick) * 1.0e6 / alive.max(1) as f64
);
}
}
@@ -0,0 +1,260 @@
//! Resource packs' Polytone post chains (#11486, wiki "Shaders"): vanilla `post_effect` chains a
//! pack runs over the world while their `activation_condition` holds, with uniform blocks from
//! expressions (`expression_uniforms`), the `PolyGlobals` block, and targets of their own
//! (`post_targets`) kept between frames. `post_chain` runs them; this reads what they add.
use crate::environment::Frame;
use crate::post_chain::{Extras, InternalTarget};
use horizon_polytone::expr::Program;
use serde_json::Value as Json;
use std::path::Path;
/// A `polytone/post_chains/<path>.json`.
struct Chain {
/// The `post_effect` chain it runs.
effect: String,
activation: Option<Program>,
/// Each expression uniform: its block's name and expression.
uniforms: Vec<(String, Program)>,
}
/// The packs' post chains and targets.
pub struct PolytonePost {
chains: Vec<Chain>,
targets: std::collections::BTreeMap<String, InternalTarget>,
configs: horizon_polytone::pack::Configs,
}
/// What `PolyGlobals` holds (wiki "Extra Shader Uniforms").
#[derive(Clone, Copy, Debug, Default)]
pub struct PolyGlobals {
/// The world's projection (reverse-Z, as 26.3's and Horizon's are), and its model-view (the
/// camera's turn).
pub projection: glam::Mat4,
pub model_view: glam::Mat4,
/// The sun's angle in radians, 0 at the horizon.
pub sun_angle: f32,
/// The time of day in ticks (0 to 24000), and the game ticks this frame advanced.
pub day_time: f32,
pub delta_time: f32,
/// The player's feet: `PolyPlayerBlockPos`, and `PolyPlayerOffset` as vanilla's
/// `CameraOffset` is (the block position less the position, so `polyPlayerPos()` is the
/// block position less the offset).
pub player: [f64; 3],
}
impl PolyGlobals {
/// The std140 block: `mat4, mat4, float, float, float, ivec3, vec3`.
pub fn bytes(&self) -> Vec<u8> {
let mut out = Vec::with_capacity(176);
for m in [self.projection, self.model_view] {
out.extend(m.to_cols_array().iter().flat_map(|v| v.to_le_bytes()));
}
for v in [self.sun_angle, self.day_time, self.delta_time, 0.0] {
out.extend(v.to_le_bytes());
}
let block = self.player.map(f64::floor);
for v in block {
out.extend((v as i32).to_le_bytes());
}
out.extend(0i32.to_le_bytes());
for i in 0..3 {
out.extend(((block[i] - self.player[i]) as f32).to_le_bytes());
}
out.extend(0f32.to_le_bytes());
out
}
}
/// Every `.json` under `assets/<namespace>/polytone/<kind>`, by its implicit id.
fn files(root: &Path, kind: &str) -> Vec<(String, Json)> {
let mut out = Vec::new();
let namespaces = std::fs::read_dir(root.join("assets"))
.into_iter()
.flatten()
.flatten();
for namespace in namespaces {
let mut stack = vec![namespace.path().join("polytone").join(kind)];
while let Some(dir) = stack.pop() {
for entry in std::fs::read_dir(&dir).into_iter().flatten().flatten() {
let path = entry.path();
if path.is_dir() {
stack.push(path);
continue;
}
let relative = path
.strip_prefix(root)
.unwrap_or(&path)
.to_string_lossy()
.into_owned();
let Some(id) = horizon_polytone::implicit_id(&relative, kind) else {
continue;
};
match horizon_world::json_file::read::<Json>(&path) {
Ok(json) => out.push((id, json)),
Err(e) => log::warn!("{relative}: {e}"),
}
}
}
}
out
}
fn compile(where_: &str, src: &Json) -> Option<Program> {
let text = match src {
Json::String(s) => s.clone(),
Json::Number(n) => n.to_string(),
Json::Bool(b) => b.to_string(),
_ => return None,
};
match Program::compile(&text) {
Ok(p) => Some(p),
Err(e) => {
log::warn!("{where_}: {e}");
None
}
}
}
impl PolytonePost {
/// The post chains and targets of the packs merged at `root`; `None` without chains.
pub fn read(root: &Path, configs: horizon_polytone::pack::Configs) -> Option<Self> {
let chains: Vec<Chain> = files(root, "post_chains")
.into_iter()
.filter_map(|(id, json)| {
let effect = json["post_chain"].as_str()?;
let effect = if effect.contains(':') {
effect.to_owned()
} else {
format!("minecraft:{effect}")
};
let activation = json
.get("activation_condition")
.and_then(|c| compile(&id, c));
let uniforms = json["expression_uniforms"]
.as_object()
.into_iter()
.flatten()
.filter_map(|(name, src)| Some((name.clone(), compile(&id, src)?)))
.collect();
Some(Chain {
effect,
activation,
uniforms,
})
})
.collect();
if chains.is_empty() {
return None;
}
let targets = files(root, "post_targets")
.into_iter()
.map(|(id, json)| {
let size = |key: &str| json[key].as_u64().filter(|n| *n > 0).map(|n| n as u32);
let target = InternalTarget {
width: size("width"),
height: size("height"),
persistent: true,
clear: [0.0; 4],
};
(id, target)
})
.collect();
Some(Self {
chains,
targets,
configs,
})
}
/// The chains to run this frame (those whose condition holds), and what they add.
pub fn frame(&self, frame: &Frame, globals: &PolyGlobals) -> (Vec<String>, Extras) {
let mut effects = Vec::new();
let mut extras = Extras {
targets: self.targets.clone(),
blocks: Default::default(),
};
extras
.blocks
.insert("PolyGlobals".to_owned(), globals.bytes());
for chain in &self.chains {
let on = chain.activation.as_ref().is_none_or(|c| {
crate::environment::eval_frame(c, frame, &self.configs).is_some_and(|v| v.truthy())
});
if !on {
continue;
}
for (name, program) in &chain.uniforms {
let value = crate::environment::eval_frame(program, frame, &self.configs)
.map_or(0.0, |v| v.num()) as f32;
let mut block = value.to_le_bytes().to_vec();
block.resize(16, 0);
extras.blocks.insert(name.clone(), block);
}
effects.push(chain.effect.clone());
}
(effects, extras)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn poly_globals_follow_std140() {
let g = PolyGlobals {
projection: glam::Mat4::from_diagonal(glam::Vec4::splat(2.0)),
sun_angle: 1.5,
day_time: 6000.0,
delta_time: 0.5,
player: [10.25, -3.5, 7.0],
..Default::default()
};
let b = g.bytes();
let f = |at: usize| f32::from_le_bytes(b[at..at + 4].try_into().unwrap());
let i = |at: usize| i32::from_le_bytes(b[at..at + 4].try_into().unwrap());
assert_eq!(b.len(), 176);
assert_eq!(f(0), 2.0);
assert_eq!((f(128), f(132), f(136)), (1.5, 6000.0, 0.5));
// ivec3 at 144, vec3 at 160 (std140 aligns both to 16). The offset is the block position
// less the position, as vanilla's CameraOffset: the block position less it is the position.
assert_eq!((i(144), i(148), i(152)), (10, -4, 7));
assert_eq!((f(160), f(164), f(168)), (-0.25, -0.5, 0.0));
}
#[test]
fn chains_run_while_their_condition_holds_with_their_uniforms() {
let root =
std::env::temp_dir().join(format!("horizon-polytone-post-{}", std::process::id()));
let write = |path: &str, text: &str| {
let path = root.join(path);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, text).unwrap();
};
write(
"assets/p/polytone/post_chains/glare.json",
r#"{"activation_condition": "g.rain() < 0.5", "post_chain": "p:glare",
"expression_uniforms": {"Weather": "1 - g.rain()"}}"#,
);
write(
"assets/p/polytone/post_targets/history.json",
r#"{"width": 1, "height": 1}"#,
);
let post = PolytonePost::read(&root, Default::default()).unwrap();
let mut frame = Frame {
rain: 0.25,
..Frame::default()
};
let (effects, extras) = post.frame(&frame, &PolyGlobals::default());
assert_eq!(effects, ["p:glare"]);
// `g.rain` is the mean of rain (0.25) and thunder (0): 0.125.
assert_eq!(
f32::from_le_bytes(extras.blocks["Weather"][..4].try_into().unwrap()),
0.875
);
assert!(extras.targets["p:history"].persistent);
frame.rain = 1.0;
assert!(post.frame(&frame, &PolyGlobals::default()).0.is_empty());
std::fs::remove_dir_all(root).unwrap();
}
}
+309 -17
View File
@@ -385,6 +385,8 @@ enum Block {
Globals,
/// One of the pass's `uniforms` groups.
Custom(wgpu::Buffer),
/// A block the request sets every frame (`Extras::blocks`), by name.
Dynamic(String, wgpu::Buffer),
}
/// A pass running the resources' own GLSL, translated by `post_glsl`.
@@ -474,6 +476,17 @@ pub struct Request<'a> {
pub menu_blur_radius: f32,
/// What the `Globals` block holds besides the screen size and the blur radius.
pub globals: Globals,
/// What resource packs' Polytone post chains add, when a pack has some.
pub extras: Option<&'a Extras>,
}
/// What resource packs' Polytone post chains add to every chain (#11486, wiki "Shaders"): their
/// declared targets (`post_targets`), kept between frames, and uniform blocks set every frame.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Extras {
pub targets: BTreeMap<String, InternalTarget>,
/// Each block's std140 bytes, by its name (`PolyGlobals`, an expression uniform's).
pub blocks: BTreeMap<String, Vec<u8>>,
}
/// `GlobalSettingsUniform.update`'s other values.
@@ -516,6 +529,7 @@ impl<'a> Request<'a> {
root: Path::new(""),
menu_blur_radius: 0.0,
globals: Globals::default(),
extras: None,
}
}
}
@@ -544,8 +558,31 @@ pub struct PostEffects {
/// root they load from; `pending` until they arrive.
preloading: Option<(PathBuf, mpsc::Receiver<Preloaded>)>,
pending: HashSet<String>,
/// This frame's `Extras::blocks`.
extra_blocks: BTreeMap<String, Vec<u8>>,
/// The frame's depth with GL's rows, for the passes that read `minecraft:main`'s: the
/// pipeline, and the texture at the frame's size.
gl_depth: Option<(wgpu::BindGroupLayout, wgpu::RenderPipeline)>,
gl_depth_target: Option<(wgpu::Texture, wgpu::TextureView)>,
}
/// The frame's depth with its rows flipped as the chains' colour is (row 0 at the bottom). Its
/// values stay: 26.3 keeps reverse-Z depth as Horizon does (the sky is 0), and packs' post
/// shaders read it so.
const GL_DEPTH_WGSL: &str = r#"
@group(0) @binding(0) var depth: texture_depth_2d;
@vertex fn vs(@builtin(vertex_index) i: u32) -> @builtin(position) vec4<f32> {
let p = vec2<f32>(f32((i << 1u) & 2u), f32(i & 2u));
return vec4<f32>(p * 2.0 - 1.0, 0.0, 1.0);
}
@fragment fn fs(@builtin(position) at: vec4<f32>) -> @location(0) vec4<f32> {
let size = textureDimensions(depth);
let x = min(u32(at.x), size.x - 1u);
let y = size.y - 1u - min(u32(at.y), size.y - 1u);
return vec4<f32>(textureLoad(depth, vec2<u32>(x, y), 0), 0.0, 0.0, 1.0);
}
"#;
/// A chain [`PostEffects::preload`] loaded, by id.
type Preloaded = (String, Result<Option<Chain>, String>);
@@ -628,6 +665,9 @@ impl PostEffects {
failed: HashSet::new(),
preloading: None,
pending: HashSet::new(),
extra_blocks: BTreeMap::new(),
gl_depth: None,
gl_depth_target: None,
root: PathBuf::new(),
work: None,
staging: None,
@@ -685,7 +725,7 @@ impl PostEffects {
let ids = ids.clone();
move || {
for id in ids {
let mut chain = Self::load(&root, &device, &queue, &id);
let mut chain = Self::load(&root, &device, &queue, &id, &Extras::default());
if let Ok(Some(chain)) = &mut chain {
for pass in &mut chain.passes {
if let Some(translated) = &mut pass.translated {
@@ -784,11 +824,18 @@ impl PostEffects {
device: &wgpu::Device,
queue: &wgpu::Queue,
id: &str,
extras: &Extras,
) -> Result<Option<Chain>, String> {
let Ok(text) = std::fs::read_to_string(resource_path(root, id)) else {
return Ok(None);
};
let config = Config::parse(&text)?;
let mut config = Config::parse(&text)?;
for (target, t) in &extras.targets {
config
.targets
.entry(target.clone())
.or_insert_with(|| t.clone());
}
// isPostEffectValid / LevelTargetBundle.MAIN_TARGETS: the frame is the one
// external target Horizon has.
if let Some(bad) = config.external_targets().into_iter().find(|t| *t != MAIN) {
@@ -812,18 +859,26 @@ impl PostEffects {
let mut passes = Vec::new();
for (n, p) in config.passes.iter().enumerate() {
let output = index(&p.output).ok_or("a target is missing")?;
let pass =
match Self::translated_pass(root, device, queue, n, p, output, &index, &mut images)
{
Ok(pass) => pass,
Err(e) => match Self::standin_pass(device, queue, n, p, output, &index) {
Ok(pass) => {
log::debug!("post effect {id} pass {n} uses the built-in shaders: {e}");
pass
}
Err(_) => return Err(e),
},
};
let pass = match Self::translated_pass(
root,
device,
queue,
n,
p,
output,
&index,
&mut images,
extras,
) {
Ok(pass) => pass,
Err(e) => match Self::standin_pass(device, queue, n, p, output, &index) {
Ok(pass) => {
log::debug!("post effect {id} pass {n} uses the built-in shaders: {e}");
pass
}
Err(_) => return Err(e),
},
};
passes.push(pass);
}
Ok(Some(Chain {
@@ -847,6 +902,7 @@ impl PostEffects {
output: usize,
index: &dyn Fn(&str) -> Option<usize>,
images: &mut Vec<(wgpu::Texture, wgpu::TextureView, [u32; 2])>,
extras: &Extras,
) -> Result<Pass, String> {
let program = crate::post_glsl::translate(root, &p.vertex, &p.fragment)?;
let mut refs = Vec::new();
@@ -879,6 +935,15 @@ impl PostEffects {
let block = match name.as_str() {
"SamplerInfo" => Block::Info,
"Globals" => Block::Globals,
other if !p.uniforms.contains_key(other) && extras.blocks.contains_key(other) => {
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Post effect pack uniforms"),
size: UNIFORM_BYTES,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
Block::Dynamic(other.to_owned(), buffer)
}
other => {
let values = p
.uniforms
@@ -1130,6 +1195,7 @@ impl PostEffects {
) -> bool {
self.use_root(req.root);
self.collect_preloaded();
self.extra_blocks = req.extras.map(|e| e.blocks.clone()).unwrap_or_default();
let mut applied = Vec::new();
for effect in req.effects {
let id = normalise(effect);
@@ -1138,7 +1204,9 @@ impl PostEffects {
continue;
}
if !self.chains.contains_key(&id) {
let loaded = match Self::load(&self.root, device, queue, &id) {
let empty = Extras::default();
let extras = req.extras.unwrap_or(&empty);
let loaded = match Self::load(&self.root, device, queue, &id, extras) {
Ok(chain) => chain,
Err(e) => {
log::warn!("Failed to load post effect {id}: {e}");
@@ -1207,6 +1275,11 @@ impl PostEffects {
timing.as_deref_mut(),
);
self.staging = Some(staging);
if let Some(view) = depth {
self.gl_depth(device, encoder, view, size);
}
let gl_depth = self.gl_depth_target.take();
let depth = depth.and(gl_depth.as_ref().map(|(_, v)| v));
for id in &applied {
let mut chain = self.chains.remove(id).flatten().expect("loaded above");
self.run_chain(
@@ -1237,9 +1310,117 @@ impl PostEffects {
);
self.blit = Some(blit);
self.work = Some(work);
self.gl_depth_target = gl_depth;
true
}
/// Copies `depth` (the frame's) into `gl_depth_target` with GL's rows, at `size`.
fn gl_depth(
&mut self,
device: &wgpu::Device,
encoder: &mut wgpu::CommandEncoder,
depth: &wgpu::TextureView,
size: [u32; 2],
) {
let (layout, pipeline) = self.gl_depth.get_or_insert_with(|| {
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Post effect GL depth"),
source: wgpu::ShaderSource::Wgsl(GL_DEPTH_WGSL.into()),
});
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("Post effect GL depth"),
entries: &[wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Depth,
view_dimension: wgpu::TextureViewDimension::D2,
multisampled: false,
},
count: None,
}],
});
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("Post effect GL depth"),
bind_group_layouts: &[Some(&layout)],
immediate_size: 0,
});
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("Post effect GL depth"),
layout: Some(&pipeline_layout),
vertex: wgpu::VertexState {
module: &module,
entry_point: Some("vs"),
compilation_options: Default::default(),
buffers: &[],
},
fragment: Some(wgpu::FragmentState {
module: &module,
entry_point: Some("fs"),
compilation_options: Default::default(),
targets: &[Some(wgpu::TextureFormat::R32Float.into())],
}),
primitive: Default::default(),
depth_stencil: None,
multisample: Default::default(),
multiview_mask: None,
cache: None,
});
(layout, pipeline)
});
if self
.gl_depth_target
.as_ref()
.is_none_or(|(t, _)| t.size().width != size[0] || t.size().height != size[1])
{
let texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some("Post effect GL depth"),
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: wgpu::TextureFormat::R32Float,
usage: wgpu::TextureUsages::TEXTURE_BINDING
| wgpu::TextureUsages::RENDER_ATTACHMENT,
view_formats: &[],
});
let view = texture.create_view(&Default::default());
self.gl_depth_target = Some((texture, view));
}
let (_, target) = self.gl_depth_target.as_ref().expect("made above");
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Post effect GL depth"),
layout,
entries: &[wgpu::BindGroupEntry {
binding: 0,
resource: wgpu::BindingResource::TextureView(depth),
}],
});
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Post effect GL depth"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: target,
depth_slice: None,
resolve_target: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
store: wgpu::StoreOp::Store,
},
})],
depth_stencil_attachment: None,
timestamp_writes: None,
occlusion_query_set: None,
multiview_mask: None,
});
pass.set_pipeline(pipeline);
pass.set_bind_group(0, &group, &[]);
pass.draw(0..3, 0..1);
}
#[allow(clippy::too_many_arguments)]
fn run_chain(
&mut self,
@@ -1438,6 +1619,13 @@ impl PostEffects {
Block::Info => &pass.info,
Block::Globals => &self.globals,
Block::Custom(buffer) => buffer,
Block::Dynamic(name, buffer) => {
if let Some(bytes) = self.extra_blocks.get(name) {
let n = bytes.len().min(UNIFORM_BYTES as usize) & !3;
queue.write_buffer(buffer, 0, &bytes[..n]);
}
buffer
}
};
entries.push(wgpu::BindGroupEntry {
binding: *binding,
@@ -1831,6 +2019,7 @@ mod tests {
root,
menu_blur_radius: 5.0,
globals: Globals::default(),
extras: None,
},
None,
);
@@ -1962,6 +2151,7 @@ mod tests {
root,
menu_blur_radius: 0.0,
globals: Globals::default(),
extras: None,
},
None,
));
@@ -2050,6 +2240,99 @@ mod tests {
std::fs::remove_dir_all(root).unwrap();
}
/// The frame's depth reaches a pass as 26.3 keeps it, reverse-Z (the sky 0, a near pixel
/// close to 1), not as GL's window depth.
#[test]
fn a_pass_reads_the_frame_depth_as_it_is() {
let Some((device, queue)) = device() else {
return;
};
let root = scratch(
"depth",
r#"{"passes":[{"vertex_shader":"minecraft:core/quad","fragment_shader":"minecraft:post/depth",
"inputs":[{"sampler_name":"Depth","target":"minecraft:main","use_depth_buffer":true}],
"output":"minecraft:main"}]}"#,
);
own_glsl(&root);
std::fs::write(
root.join("assets/minecraft/shaders/post/depth.fsh"),
"#version 330\nuniform sampler2D DepthSampler;\nlayout(location = 0) in vec2 texCoord;\n\
layout(location = 0) out vec4 fragColor;\n\
void main() { fragColor = vec4(texture(DepthSampler, texCoord).r, 0.0, 0.0, 1.0); }\n",
)
.unwrap();
let size = wgpu::Extent3d {
width: 8,
height: 4,
depth_or_array_layers: 1,
};
let texture = |format, usage| {
device.create_texture(&wgpu::TextureDescriptor {
label: None,
size,
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format,
usage,
view_formats: &[],
})
};
let frame = texture(
wgpu::TextureFormat::Rgba8Unorm,
wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
);
let depth = texture(
wgpu::TextureFormat::Depth32Float,
wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
);
let (view, depth_view) = (
frame.create_view(&Default::default()),
depth.create_view(&Default::default()),
);
let mut encoder = device.create_command_encoder(&Default::default());
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: None,
color_attachments: &[],
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view: &depth_view,
depth_ops: Some(wgpu::Operations {
load: wgpu::LoadOp::Clear(0.25),
store: wgpu::StoreOp::Store,
}),
stencil_ops: None,
}),
..Default::default()
});
let mut effects = PostEffects::new(&device);
let ran = effects.apply(
&device,
&queue,
&mut encoder,
&frame,
&view,
[8, 4],
Some(&depth_view),
&Request {
effects: &["minecraft:depth".to_owned()],
root: &root,
menu_blur_radius: 0.0,
globals: Globals::default(),
extras: None,
},
None,
);
let buffer = crate::gfx::copy_frame(&device, &mut encoder, &frame, [8, 4]);
queue.submit([encoder.finish()]);
buffer.map_async(wgpu::MapMode::Read, .., |r| r.unwrap());
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
let red = buffer.get_mapped_range(..).unwrap()[0];
assert!(ran);
// 0.25 of 255, not 0.75.
assert!(red.abs_diff(64) <= 1, "{red}");
std::fs::remove_dir_all(root).unwrap();
}
/// `core/quad.vsh`, `post/mine.fsh` and the `tint` image of `a_chain_runs_its_own_glsl`.
fn own_glsl(root: &Path) {
let shaders = root.join("assets/minecraft/shaders");
@@ -2189,13 +2472,22 @@ mod tests {
for name in ["blur", "creeper", "invert", "spider"] {
let id = format!("minecraft:{name}");
assert!(
PostEffects::load(&root, &device, &queue, &id)
PostEffects::load(&root, &device, &queue, &id, &Extras::default())
.unwrap()
.is_some(),
"{id}"
);
}
// entity_outline needs targets and shaders the world pass has of its own.
assert!(PostEffects::load(&root, &device, &queue, "minecraft:entity_outline").is_err());
assert!(
PostEffects::load(
&root,
&device,
&queue,
"minecraft:entity_outline",
&Extras::default()
)
.is_err()
);
}
}
+4 -5
View File
@@ -165,7 +165,7 @@ pub struct Context {
pub spectator: bool,
/// `isSteppingCarefully` (sneaking).
pub stepping_carefully: bool,
/// `dampensVibrations` (a warden).
/// `dampensVibrations` (a warden or a wool item).
pub dampens: bool,
/// The source is a living entity wardens may target.
pub targetable: bool,
@@ -187,11 +187,10 @@ pub fn valid_vibration(listens: bool, event: &str, ctx: &Context) -> bool {
if ctx.stepping_carefully && ignored_when_sneaking(event) {
return false;
}
if ctx.dampens {
return false;
}
}
!ctx.dampening_state
// Nonliving sources have no Who handle, but ItemEntity still carries its
// stack's dampensVibrations flag (including after Entity.kill removes it).
!ctx.dampens && !ctx.dampening_state
}
impl Selector {
File diff suppressed because it is too large. Load diff
@@ -19,8 +19,11 @@ pub struct ShaderOptions {
pub chosen: BTreeMap<String, String>,
/// The screens opened from the main one, innermost last.
pub path: Vec<String>,
/// Something changed since the page opened.
/// The values differ from those the pack's file holds (`saved`): saving them loads the pack
/// again.
pub changed: bool,
/// The values the pack's file holds, as read or as last saved.
saved: BTreeMap<String, String>,
}
impl ShaderOptions {
@@ -39,14 +42,28 @@ impl ShaderOptions {
chosen: BTreeMap::new(),
path: Vec::new(),
changed: false,
saved: BTreeMap::new(),
};
for (name, value) in chosen {
out.set(&name, value);
}
out.saved = out.chosen.clone();
out.changed = false;
out
}
/// The values back to `chosen` (Undo): changed while they differ from the file's.
pub fn restore(&mut self, chosen: BTreeMap<String, String>) {
self.chosen = chosen;
self.changed = self.chosen != self.saved;
}
/// The pack's file holds `saved` now, saved by another way than this page's.
pub fn saved_as(&mut self, saved: BTreeMap<String, String>) {
self.saved = saved;
self.changed = self.chosen != self.saved;
}
/// The screen shown: the innermost opened, else the main one.
pub fn screen(&self) -> &str {
self.path.last().map_or("", String::as_str)
@@ -74,7 +91,7 @@ impl ShaderOptions {
} else {
self.chosen.insert(name.to_owned(), value);
}
self.changed = true;
self.changed = self.chosen != self.saved;
}
/// The option's next value (the previous one with `back`), wrapping around.
@@ -123,9 +140,8 @@ impl ShaderOptions {
/// Option `name` back at the pack's default.
pub fn reset_option(&mut self, name: &str) {
if self.chosen.remove(name).is_some() {
self.changed = true;
}
self.chosen.remove(name);
self.changed = self.chosen != self.saved;
}
/// The profile after the current one (the first when none matches) with its values.
@@ -150,10 +166,8 @@ impl ShaderOptions {
}
pub fn reset(&mut self) {
if !self.chosen.is_empty() {
self.chosen.clear();
self.changed = true;
}
self.chosen.clear();
self.changed = self.chosen != self.saved;
}
fn named(&self, key: &str, or: &str) -> Text {
@@ -73,29 +73,6 @@ impl Entry {
changed
}
/// Option `i`'s slider one step on (`back`: one before), stopping at its ends. Whether its
/// value changed.
pub fn nudge_option(&mut self, i: usize, back: bool) -> bool {
let (Some(o), Some(v)) = (self.options.get(i), self.values.get_mut(i)) else {
return false;
};
let dir = if back { -1.0 } else { 1.0 };
let new = match &o.kind {
OptionKind::Float { min, max, step, .. } => {
let (min, max, step) = (f64::from(*min), f64::from(*max), f64::from(*step));
let n = ((*v - min) / step).round() + dir;
on_f32((min + n * step).clamp(min, max))
}
OptionKind::Int { min, max, .. } => {
(v.round() + dir).clamp(f64::from(*min), f64::from(*max))
}
_ => return false,
};
let changed = new != *v;
*v = new;
changed
}
/// Every option back to its default.
pub fn reset_options(&mut self) {
let defaults = self.options.iter().map(|o| o.kind.default_constant());
@@ -353,15 +330,36 @@ fn step_value(kind: &OptionKind, v: f64, back: bool) -> f64 {
}
}
/// Where `v` sits between the option's minimum and maximum, 0 to 1; `None` for the options
/// that are buttons.
/// A float option's stops past its minimum: its steps, the last one cut short at the maximum
/// when the range isn't a whole number of them.
pub fn float_steps(min: f32, max: f32, step: f32) -> f64 {
let (min, max, step) = (f64::from(min), f64::from(max), f64::from(step));
((max - min) / step - STEP_SLACK).ceil()
}
/// What a range a hair over a whole number of steps loses to rounding, short of another stop.
const STEP_SLACK: f64 = 1e-6;
/// Where `v` sits between the option's minimum and maximum, 0 to 1, as `slider_value` reads
/// it back: a float at its stop (the maximum the last one), an int along its range. `None` for
/// the options that are buttons.
fn slider_position(kind: &OptionKind, v: f64) -> Option<f32> {
let (min, max) = match kind {
OptionKind::Float { min, max, .. } => (f64::from(*min), f64::from(*max)),
OptionKind::Int { min, max, .. } => (f64::from(*min), f64::from(*max)),
let position = match kind {
OptionKind::Float { min, max, step, .. } => {
let steps = float_steps(*min, *max, *step);
let stop = ((v - f64::from(*min)) / f64::from(*step)).round();
if v >= f64::from(*max) || steps <= 0.0 {
1.0
} else {
stop / steps
}
}
OptionKind::Int { min, max, .. } => {
let (min, max) = (f64::from(*min), f64::from(*max));
(v - min) / (max - min)
}
_ => return None,
};
Some(((v - min) / (max - min)).clamp(0.0, 1.0) as f32)
Some(position.clamp(0.0, 1.0) as f32)
}
/// The option's value nearest the slider position `at`: a float on its steps from the
@@ -371,8 +369,8 @@ fn slider_value(kind: &OptionKind, at: f32) -> Option<f64> {
let at = f64::from(at.clamp(0.0, 1.0));
match kind {
OptionKind::Float { min, max, step, .. } => {
let steps = float_steps(*min, *max, *step);
let (min, max, step) = (f64::from(*min), f64::from(*max), f64::from(*step));
let steps = ((max - min) / step - 1e-6).ceil();
Some(on_f32((min + (at * steps).round() * step).min(max)))
}
OptionKind::Int { min, max, .. } => {
@@ -649,17 +647,47 @@ mod tests {
assert_eq!(entry.values[3], 1.0);
entry.set_slider_position(3, 0.6);
assert_eq!(entry.values[3], f64::from(0.6_f32));
}
// The arrow keys: a step at a time, not round past the ends.
entry.values[0] = 2.0;
assert!(!entry.nudge_option(0, false));
assert_eq!(entry.values[0], 2.0);
assert!(entry.nudge_option(0, true));
assert_eq!(entry.values[0], f64::from(1.95_f32));
entry.values[1] = 0.0;
assert!(!entry.nudge_option(1, true));
assert!(entry.nudge_option(1, false));
assert_eq!(entry.values[1], 1.0);
assert!(!entry.nudge_option(2, false));
/// The arrow keys step a slider through its stops (the shell's `slider_step`, by the stops
/// `float_steps` counts): a float whose range isn't a whole number of steps walks every one
/// both ways, its maximum included, and stops at the ends.
#[test]
fn the_arrow_keys_walk_a_floats_stops_both_ways() {
let kind = OptionKind::Float {
default: 0.0,
min: 0.0,
max: 1.0,
step: 0.3,
};
let mut entry = Entry {
file: "p".into(),
key: "horizon:p".into(),
title: "P".into(),
kind: Kind::Horizon,
options: vec![horizon_shader_api::PackOption {
id: "d".into(),
name: "D".into(),
description: String::new(),
kind,
}],
values: vec![0.0],
pages: Vec::new(),
};
let steps = float_steps(0.0, 1.0, 0.3) as u32;
let mut walk = |forward: bool| {
let mut seen = Vec::new();
for _ in 0..steps + 2 {
let at = entry.slider_position(0).unwrap();
let next = crate::settings_shell::slider_step(at, Some(steps), 100.0, forward);
entry.set_slider_position(0, next);
seen.push(entry.values[0]);
}
seen
};
// The `k`th stop, as the pack's f32 `override` gets it.
let stop = |k: u8| f64::from((f64::from(k) * f64::from(0.3_f32)) as f32);
assert_eq!(walk(true), [stop(1), stop(2), stop(3), 1.0, 1.0, 1.0]);
assert_eq!(walk(false), [stop(3), stop(2), stop(1), 0.0, 0.0, 0.0]);
}
}
File diff suppressed because it is too large. Load diff
+22 -2
View File
@@ -5,6 +5,13 @@ pub use crate::chunk::block_entity::{SavedLine, SignData, SignText};
use crate::{scoreboard, text::Text};
use horizon_world::nbt::Tag;
/// `getBooleanOr(key, false)` and `Codec.BOOL`: any numeric tag's nonzero byte.
fn nbt_flag(tag: &Tag, key: &str) -> bool {
tag.get(key)
.and_then(horizon_client_core::nbt_path::int_value)
.is_some_and(|value| value as i8 != 0)
}
/// `DyeColor` names and `getTextColor` values by id.
pub const DYE_TEXT: [(&str, u32); 16] = [
("white", 0xFFFFFF),
@@ -201,12 +208,12 @@ impl SignData {
out.color = i as u8;
}
}
out.glowing = matches!(t.get("has_glowing_text"), Some(Tag::Byte(b)) if *b != 0);
out.glowing = nbt_flag(t, "has_glowing_text");
out
};
self.front = side(tag.get("front_text"));
self.back = side(tag.get("back_text"));
self.waxed = matches!(tag.get("is_waxed"), Some(Tag::Byte(b)) if *b != 0);
self.waxed = nbt_flag(tag, "is_waxed");
self.allow_op_features =
matches!(tag.get("allow_op_features"), Some(Tag::Byte(b)) if *b != 0);
}
@@ -301,6 +308,19 @@ pub fn editor_sprite(folder: &str, wood: &str) -> String {
mod tests {
use super::*;
#[test]
fn waxed_and_glowing_text_take_any_number() {
let tag = Tag::compound([
("is_waxed", Tag::Int(1)),
(
"front_text",
Tag::compound([("has_glowing_text", Tag::Short(1))]),
),
]);
let mut decoded = SignData::default();
decoded.decode(&tag);
assert!(decoded.waxed && decoded.front.glowing);
}
#[test]
fn round_trips_nbt() {
let mut data = SignData {
hanging: false,
@@ -978,6 +978,10 @@ pub struct SoundEngine {
instance_by_source: Vec<(usize, InstanceId)>,
gain_by_source: [f32; 11],
ticking: Vec<InstanceId>,
live_scratch: Vec<(u64, ChannelId)>,
mapped_scratch: Vec<u64>,
removed_scratch: Vec<InstanceId>,
ticking_scratch: Vec<InstanceId>,
/// `queuedSounds` (insertion order).
queued: Vec<(InstanceId, i32)>,
delete_time: HashMap<InstanceId, i32>,
@@ -1015,6 +1019,10 @@ impl SoundEngine {
instance_by_source: Vec::new(),
gain_by_source: [1.0; 11],
ticking: Vec::new(),
live_scratch: Vec::new(),
mapped_scratch: Vec::new(),
removed_scratch: Vec::new(),
ticking_scratch: Vec::new(),
queued: Vec::new(),
delete_time: HashMap::new(),
queued_tickable: Vec::new(),
@@ -1479,20 +1487,26 @@ impl SoundEngine {
self.schedule_tick(backend);
}
fn schedule_tick(&mut self, backend: &mut impl Backend) {
let live: Vec<(u64, ChannelId)> = self
.handles
.iter()
.filter_map(|(h, handle)| handle.channel.map(|c| (*h, c)))
.collect();
for (h, c) in live {
let mut live = std::mem::take(&mut self.live_scratch);
live.clear();
live.extend(
self.handles
.iter()
.filter_map(|(h, handle)| handle.channel.map(|c| (*h, c))),
);
for (h, c) in live.drain(..) {
if backend.stopped(c) {
self.release_handle(h, backend);
}
}
// Released handles nobody maps any more.
let mapped: Vec<u64> = self.instance_to_channel.iter().map(|(_, h)| *h).collect();
let mut mapped = std::mem::take(&mut self.mapped_scratch);
mapped.clear();
mapped.extend(self.instance_to_channel.iter().map(|(_, h)| *h));
self.handles
.retain(|h, handle| handle.channel.is_some() || mapped.contains(h));
self.live_scratch = live;
self.mapped_scratch = mapped;
}
fn handle_stopped(&self, handle: u64) -> bool {
self.handles.get(&handle).is_none_or(|h| h.stopped)
@@ -1502,7 +1516,10 @@ impl SoundEngine {
for id in std::mem::take(&mut self.queued_tickable) {
self.play(id, backend);
}
for id in self.ticking.clone() {
let mut ticking = std::mem::take(&mut self.ticking_scratch);
ticking.clear();
ticking.extend(self.ticking.iter().copied());
for id in ticking.drain(..) {
let Some(instance) = self.instances.get(&id) else {
continue;
};
@@ -1549,7 +1566,8 @@ impl SoundEngine {
self.notify(id, subtitle, distance);
}
}
let mut removed = Vec::new();
let mut removed = std::mem::take(&mut self.removed_scratch);
removed.clear();
let mut index = 0;
while index < self.instance_to_channel.len() {
let (id, handle) = self.instance_to_channel[index];
@@ -1596,9 +1614,11 @@ impl SoundEngine {
index += 1;
}
}
for id in removed {
for id in removed.drain(..) {
self.collect(id);
}
self.ticking_scratch = ticking;
self.removed_scratch = removed;
}
fn tick_music_when_paused(&mut self) {
let mut index = 0;
+3 -5
View File
@@ -1654,11 +1654,9 @@ mod tests {
"L'écran partagé est désactivé."
);
assert!(labels(&ui).contains(&"Écran partagé : Désactivé".to_owned()));
// German translates it now; a key a language lacks still falls back to the bundled
// English (lang's `layers_selected_language_over_english`).
lang::set(lang::Language::load(&[], "de_de"));
assert_eq!(
ui.status_lines().unwrap()[0],
"Splitscreen is off.",
"untranslated new keys use bundled English"
);
assert_eq!(ui.status_lines().unwrap()[0], "Splitscreen ist aus.");
}
}
+34 -11
View File
@@ -530,6 +530,12 @@ pub struct Streaming {
saves: Arc<dyn Saves>,
/// Results received while `save` waited, for the next `update`.
deferred: Vec<Done>,
/// Per-update collection scratch, retained to avoid allocator churn while
/// the view is moving or many columns are dirty.
light_scratch: Vec<(i64, i32, i32)>,
gone_scratch: Vec<(i32, i32)>,
mesh_candidates_scratch: Vec<(i64, i32, i32, SectionMask)>,
mesh_columns_scratch: Vec<((i32, i32), SectionMask)>,
sequence: u64,
pub center: (i32, i32),
/// The view distance around `center`; below 0, no area there (a dedicated
@@ -1130,6 +1136,10 @@ impl Streaming {
written: Default::default(),
saves,
deferred: Vec::new(),
light_scratch: Vec::new(),
gone_scratch: Vec::new(),
mesh_candidates_scratch: Vec::new(),
mesh_columns_scratch: Vec::new(),
sequence: 0,
center: (0, 0),
radius: 8,
@@ -1664,6 +1674,15 @@ impl Streaming {
if self.paused {
return (uploads, Vec::new());
}
let mut stale = Vec::new();
let mut light = std::mem::take(&mut self.light_scratch);
light.clear();
let mut gone = std::mem::take(&mut self.gone_scratch);
gone.clear();
let mut candidates = std::mem::take(&mut self.mesh_candidates_scratch);
candidates.clear();
let mut columns = std::mem::take(&mut self.mesh_columns_scratch);
columns.clear();
ExtraCover::refresh(&mut self.cover, &self.extra);
self.cover_current = true;
// The sweeps below walk every loaded column and proto chunk. Run them
@@ -1678,7 +1697,6 @@ impl Streaming {
let deferred = std::mem::take(&mut self.deferred);
let received = self.shared.done.take();
// Meshes made stale meanwhile, freed with the uploaded ones (`free`).
let mut stale = Vec::new();
for done in deferred.into_iter().chain(received) {
match done {
Done::Proto(x, z, proto, decorated) => {
@@ -1831,8 +1849,8 @@ impl Streaming {
protos.contains_key(k) || finishing.contains(k) || world.chunks.contains_key(k)
});
// Finalize chunks whose neighbourhood is decorated.
let candidates: Vec<(i32, i32)> = self.protos.keys().copied().collect();
for (x, z) in candidates {
let finalize_candidates: Vec<(i32, i32)> = self.protos.keys().copied().collect();
for (x, z) in finalize_candidates {
if world.chunks.contains_key(&(x, z))
|| self.finishing.contains(&(x, z))
|| !self.in_range(x, z, 1)
@@ -1990,8 +2008,6 @@ impl Streaming {
// grew with the render distance and outlived the columns they
// were queued for (#3629). What stays in `dirty_light` is asked
// for again, from the current centre, as capacity frees.
let mut light: Vec<(i64, i32, i32)> = Vec::new();
let mut gone: Vec<(i32, i32)> = Vec::new();
for &(x, z) in world.dirty_light.iter() {
if !world.chunks.contains_key(&(x, z)) {
gone.push((x, z));
@@ -1999,12 +2015,12 @@ impl Streaming {
light.push((self.distance(x, z), x, z));
}
}
for key in gone {
for key in gone.drain(..) {
world.dirty_light.remove(&key);
}
let room = (self.workers * 4).saturating_sub(self.lighting.len());
nearest_first(&mut light, room);
for (_, x, z) in light {
for (_, x, z) in light.drain(..) {
let Some(region) = self.light_region(world, x, z) else {
continue;
};
@@ -2023,6 +2039,10 @@ impl Streaming {
if !self.mesh {
world.dirty_sections.clear();
self.cover_current = false;
self.light_scratch = light;
self.gone_scratch = gone;
self.mesh_candidates_scratch = candidates;
self.mesh_columns_scratch = columns;
return (uploads, removed);
}
// Meshing once the column and its neighbours are lit, and the column
@@ -2056,9 +2076,8 @@ impl Streaming {
}
// Column by column: the checks below hold for all of a column's
// sections, and columns waiting for light cost one look per frame.
let mut candidates: Vec<(i64, i32, i32, SectionMask)> = Vec::new();
let columns: Vec<_> = world.dirty_sections.columns().collect();
for ((x, z), sections) in columns {
columns.extend(world.dirty_sections.columns());
for ((x, z), sections) in columns.iter().copied() {
if !world.chunks.contains_key(&(x, z)) {
world.dirty_sections.take_column(x, z);
continue;
@@ -2096,7 +2115,7 @@ impl Streaming {
// capacity frees; `requeue` then has a small queue to keep close.
let room = (self.workers * 32).saturating_sub(self.meshing.len());
nearest_first(&mut candidates, room);
for (_, x, z, sections) in candidates {
for (_, x, z, sections) in candidates.drain(..) {
let Some(region) = Self::region(world, x, z) else {
continue;
};
@@ -2132,6 +2151,10 @@ impl Streaming {
}
}
self.cover_current = false;
self.light_scratch = light;
self.gone_scratch = gone;
self.mesh_candidates_scratch = candidates;
self.mesh_columns_scratch = columns;
(uploads, removed)
}
fn schedule_generation(&mut self, world: &World, workers: usize) {
+20 -10
View File
@@ -344,10 +344,16 @@ pub struct TabInputs<'a> {
/// The player is in the level with an upside-down name
/// (`AvatarRenderer.isPlayerUpsideDown`).
pub upside_down: &'a dyn Fn(u128) -> bool,
/// The player plays Horizon: Horizon's mark after their head (`horizon_cosmetics::mark`).
pub horizon: &'a dyn Fn(u128) -> bool,
/// A mark after the player's head, or none: an extension point for what lives above the
/// engine (Horizon's mark, `horizon_cosmetics::mark`). Vanilla draws none.
pub mark: &'a dyn Fn(u128) -> Option<NameMark>,
}
/// A mark drawn after a player's head in the tab list: filled rectangles `(x, y, width, height,
/// ARGB)` in an 8x8 box. Once any listed player has one, every row gets the column, so the names
/// stay aligned.
pub type NameMark = &'static [(u8, u8, u8, u8, u32)];
/// `PlayerTabOverlay`: the header, footer, visibility and heart states.
#[derive(Clone, Debug, Default)]
pub struct TabList {
@@ -367,7 +373,7 @@ struct LayoutCache {
footer: Option<Text>,
language: Option<std::sync::Arc<crate::lang::Language>>,
dimensions: (i32, bool, u32, u64),
flags: Vec<(bool, bool)>,
flags: Vec<(bool, Option<NameMark>)>,
draws: Vec<GuiDraw>,
hearts: Vec<(usize, i32, i32, i32, u128, i32)>,
}
@@ -426,7 +432,7 @@ impl TabList {
let flags: Vec<_> = i
.players
.iter()
.map(|p| ((i.upside_down)(p.uuid), (i.horizon)(p.uuid)))
.map(|p| ((i.upside_down)(p.uuid), (i.mark)(p.uuid)))
.collect();
let valid = self.cache.as_ref().is_some_and(|c| {
c.players == i.players
@@ -540,9 +546,13 @@ impl TabList {
};
let sw = i.screen_width;
let head = if show_head { 9 } else { 0 };
// Horizon: a column for the mark once anyone listed has it, so the names stay aligned.
let marked: Vec<bool> = players.iter().map(|p| (i.horizon)(p.uuid)).collect();
let mark = if marked.contains(&true) { 9 } else { 0 };
// A column for the marks once anyone listed has one, so the names stay aligned.
let marked: Vec<Option<NameMark>> = players.iter().map(|p| (i.mark)(p.uuid)).collect();
let mark = if marked.iter().any(Option::is_some) {
9
} else {
0
};
let head = head + mark;
let slot_width = (cols * (head + max_name + width_for_score + 13)).min(sw - 50) / cols;
let xxo = sw / 2 - (slot_width * cols + (cols - 1) * 5) / 2;
@@ -610,8 +620,8 @@ impl TabList {
);
xo += 9;
}
if marked[n as usize] {
for (mx, my, w, h, color) in horizon_cosmetics::mark::MARK {
if let Some(rects) = marked[n as usize] {
for &(mx, my, w, h, color) in rects {
let (x, y) = (xo + i32::from(mx), yo + i32::from(my));
let (x1, y1) = (x + i32::from(w), y + i32::from(h));
out.push(GuiDraw::Fill {
@@ -830,7 +840,7 @@ mod tests {
gui_ticks: frame,
background: 0x20ffffff,
upside_down: &|_| false,
horizon: &|_| false,
mark: &|_| None,
};
fresh.cache = None;
assert_eq!(
+8 -16
View File
@@ -151,31 +151,23 @@ fn rotated_grid(uv: vec2<f32>, layer: u32, pixel: vec2<f32>, du: vec2<f32>,
let dv_len = length(dv);
let effective = sqrt(min(du_len, dv_len) * max(du_len, dv_len));
let exact = max(0.0, log2(effective / min_pixel));
let low = floor(exact);
let mip_blend = fract(exact);
// From this level on the sprite is a single texel (0: the views stop before one is), and
// the sampler clamps any level past it to it: there every tap reads the same value, so
// one tap gives the four's mean. Distant tiles sit there.
let single = globals.filtering.z;
if single > 0.0 && low >= single {
return sprite_level(uv, layer, low);
if single > 0.0 && floor(exact) >= single {
return sprite_level(uv, layer, floor(exact));
}
let high_single = single > 0.0 && low + 1.0 >= single;
// sampleRGSS takes each of the four taps at the two mip levels around `exact` and mixes them
// by fract(exact). The sprite sampler's mip filter is linear, so a tap at the fractional
// level is that mix: four texture instructions where the shader mix took eight.
var offsets = array<vec2<f32>, 4>(
vec2(0.125, 0.375), vec2(-0.125, -0.375), vec2(0.375, -0.125), vec2(-0.375, 0.125));
var sum_low = vec4(0.0);
var sum_high = vec4(0.0);
var sum = vec4(0.0);
for (var i = 0; i < 4; i++) {
let p = uv + offsets[i] * pixel;
sum_low += sprite_level(p, layer, low);
if !high_single {
sum_high += sprite_level(p, layer, low + 1.0);
}
sum += sprite_level(uv + offsets[i] * pixel, layer, exact);
}
if high_single {
sum_high = 4.0 * sprite_level(uv, layer, low + 1.0);
}
return mix(sum_low * 0.25, sum_high * 0.25, mip_blend);
return sum * 0.25;
}
// Distant terrain tiles (lod/mesh.rs) count their UVs in blocks past 1: each block takes the
// sprite again, as separate faces would under the clamped sampler. Sprite UVs in [0, 1] stay.
@@ -1,4 +1,9 @@
//! On-screen touch controls for touch-screen devices (Android first). Nothing
//! here runs unless a touch surface is in use.
pub mod aim;
pub mod layout;
pub use aim::{
attack_indicator, camera_delta, hotbar_slot, item_places_entity_on_tap, item_uses_on_tap,
projectile_crosshair, screen_direction,
};
pub use layout::{Control, Mode, Placement, Settings};
@@ -0,0 +1,255 @@
// Adapted from Starling (https://github.com/starling-client/starling, `android` branch at
// 150a414b09a1, `crates/horizon-client-render/src/touch.rs`), written by Vasysik and the
// Starling contributors. Copyright (c) 2026 Ludovic Marie and the Horizon contributors, under the
// same MIT license as Horizon (LICENSE at the repository root).
//! What a touch aims at and what it does: camera turns from a drag, the ray
//! through a tapped point, the hotbar slot under a finger, and which held
//! items act on a short tap. Pure functions; the touch controls call them.
use super::layout::Settings;
/// Items that place an entity where the player taps, like a block: a short tap
/// places them instead of waiting for a long press.
pub fn item_places_entity_on_tap(item: &str) -> bool {
let Some(id) = item.strip_prefix("minecraft:") else {
return false;
};
id.ends_with("_boat")
|| id.ends_with("_raft")
|| id.ends_with("_spawn_egg")
|| id == "minecart"
|| id.ends_with("_minecart")
|| matches!(
id,
"armor_stand" | "item_frame" | "glow_item_frame" | "painting" | "end_crystal"
)
}
/// Items used on the tapped surface (buckets, bone meal, flint and steel...):
/// a short tap uses them instead of waiting for a long press.
pub fn item_uses_on_tap(item: &str) -> bool {
item_places_entity_on_tap(item)
|| item
.strip_prefix("minecraft:")
.is_some_and(|id| id.ends_with("_bucket") && id != "milk_bucket")
|| matches!(
item,
"minecraft:bone_meal"
| "minecraft:flint_and_steel"
| "minecraft:fire_charge"
| "minecraft:honeycomb"
| "minecraft:glass_bottle"
| "minecraft:bucket"
)
}
/// Items that are aimed rather than pointed at a block: while one is held, the
/// tap schemes show the crosshair and shoot along it.
pub fn projectile_crosshair(item: &str) -> bool {
matches!(
item,
"minecraft:bow"
| "minecraft:crossbow"
| "minecraft:trident"
| "minecraft:snowball"
| "minecraft:egg"
| "minecraft:ender_pearl"
| "minecraft:experience_bottle"
| "minecraft:splash_potion"
| "minecraft:lingering_potion"
| "minecraft:wind_charge"
| "minecraft:fishing_rod"
)
}
/// The camera turn in degrees (yaw, pitch) for a drag of `delta`, normalized to
/// a `width` x `height` surface. Equal physical drags turn equally on either
/// axis, in landscape and in portrait; a drag across the long side is 180°.
pub fn camera_delta(delta: [f32; 2], width: u32, height: u32, s: &Settings) -> [f32; 2] {
let scale = width.max(height).max(1) as f32;
[
delta[0] * width as f32 / scale * 180.0 * s.sensitivity * s.sensitivity_horizontal,
delta[1] * height as f32 / scale * 180.0 * s.sensitivity * s.sensitivity_vertical,
]
}
/// The attack indicator to draw (the `attackIndicator` option: 0 off,
/// 1 crosshair, 2 hotbar). Without a crosshair, the crosshair gauge moves to
/// the hotbar, as in vanilla's hotbar setting.
pub fn attack_indicator(setting: u8, crosshair: bool) -> u8 {
if setting == 1 && !crosshair {
2
} else {
setting
}
}
/// The unit ray from the eye through `point` (normalized surface coordinates,
/// y down) for a perspective view with vertical `fov` in degrees. The view
/// itself does not turn: a tap acts where it lands.
pub fn screen_direction(
forward: [f64; 3],
up: [f64; 3],
point: [f32; 2],
aspect: f64,
fov: f64,
) -> [f64; 3] {
// forward x up: screen right in the game's coordinates.
let right = [
forward[1] * up[2] - forward[2] * up[1],
forward[2] * up[0] - forward[0] * up[2],
forward[0] * up[1] - forward[1] * up[0],
];
let t = (fov.to_radians() / 2.0).tan();
let x = (f64::from(point[0]) * 2.0 - 1.0) * aspect * t;
let y = (1.0 - f64::from(point[1]) * 2.0) * t;
let ray = std::array::from_fn::<_, 3, _>(|i| forward[i] + right[i] * x + up[i] * y);
let length = ray.iter().map(|v| v * v).sum::<f64>().sqrt();
ray.map(|v| v / length)
}
/// The hotbar slot under `point`, in GUI pixels on a `width` x `height` GUI:
/// the nine 20-pixel slots of the 22-pixel bar centred at the bottom.
pub fn hotbar_slot(width: f32, height: f32, point: [f32; 2]) -> Option<usize> {
let x = point[0] - (width / 2.0 - 90.0);
(point[1] >= height - 22.0 && point[1] < height && (0.0..180.0).contains(&x))
.then(|| (x / 20.0) as usize)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn camera_turns_by_physical_distance_on_both_axes() {
let mut settings = Settings::default();
for (w, h) in [(2400, 1080), (1080, 2400)] {
// 100 pixels right and 100 pixels down.
let drag = [100.0 / w as f32, 100.0 / h as f32];
let turn = camera_delta(drag, w, h, &settings);
assert!((turn[0] - turn[1]).abs() < 0.0001);
settings.sensitivity_vertical = 0.5;
let tuned = camera_delta(drag, w, h, &settings);
assert!((tuned[0] - tuned[1] * 2.0).abs() < 0.0001);
settings.sensitivity_vertical = 1.0;
}
let full = camera_delta([1.0, 0.0], 2400, 1080, &settings);
assert!((full[0] - 180.0).abs() < 0.0001);
settings.sensitivity = 2.0;
let fast = camera_delta([0.5, 0.0], 2400, 1080, &settings);
assert!((fast[0] - 180.0).abs() < 0.0001);
// A zero-sized surface turns nothing instead of dividing by zero.
assert_eq!(camera_delta([0.5, 0.5], 0, 0, &settings), [0.0, 0.0]);
}
#[test]
fn surface_tools_act_on_tap_but_projectiles_aim() {
for id in [
"bone_meal",
"flint_and_steel",
"fire_charge",
"water_bucket",
"lava_bucket",
"powder_snow_bucket",
"bucket",
"honeycomb",
"glass_bottle",
] {
assert!(item_uses_on_tap(&format!("minecraft:{id}")), "{id}");
}
assert!(!item_uses_on_tap("minecraft:milk_bucket"));
assert!(!item_uses_on_tap("minecraft:apple"));
for id in [
"bow",
"crossbow",
"snowball",
"egg",
"wind_charge",
"trident",
] {
let id = format!("minecraft:{id}");
assert!(!item_uses_on_tap(&id), "{id}");
assert!(projectile_crosshair(&id), "{id}");
}
assert!(!projectile_crosshair("minecraft:firework_rocket"));
assert!(!projectile_crosshair("minecraft:stone"));
}
#[test]
fn entity_placers_act_on_tap() {
for id in [
"oak_boat",
"bamboo_chest_raft",
"villager_spawn_egg",
"minecart",
"chest_minecart",
"armor_stand",
"item_frame",
"glow_item_frame",
"painting",
"end_crystal",
] {
let id = format!("minecraft:{id}");
assert!(item_places_entity_on_tap(&id), "{id}");
assert!(item_uses_on_tap(&id), "{id}");
}
for id in [
"egg",
"snowball",
"wind_charge",
"firework_rocket",
"fishing_rod",
"apple",
"wheat_seeds",
"diamond_helmet",
"bow",
] {
assert!(
!item_places_entity_on_tap(&format!("minecraft:{id}")),
"{id}"
);
}
// Only vanilla ids: a modded item that happens to end in `_boat` is left alone.
assert!(!item_places_entity_on_tap("example:spell_boat"));
}
#[test]
fn attack_gauge_moves_to_the_hotbar_without_a_crosshair() {
assert_eq!(attack_indicator(0, false), 0);
assert_eq!(attack_indicator(1, false), 2);
assert_eq!(attack_indicator(1, true), 1);
assert_eq!(attack_indicator(2, false), 2);
assert_eq!(attack_indicator(2, true), 2);
}
#[test]
fn tap_ray_goes_through_the_point_without_turning_the_view() {
let forward = [0.0, 0.0, 1.0];
let up = [0.0, 1.0, 0.0];
assert_eq!(
screen_direction(forward, up, [0.5, 0.5], 2.0, 90.0),
forward
);
let ray = screen_direction(forward, up, [0.75, 0.25], 2.0, 90.0);
// Right of centre is -x for this view; above centre is +y.
assert!(ray[0] < 0.0 && ray[1] > 0.0 && ray[2] > 0.0);
assert!((ray.iter().map(|x| x * x).sum::<f64>() - 1.0).abs() < 1e-10);
// The top edge at a 90° vertical fov is 45° up.
let top = screen_direction(forward, up, [0.5, 0.0], 2.0, 90.0);
assert!((top[1] - top[2]).abs() < 1e-10);
}
#[test]
fn hotbar_has_nine_slots_and_nothing_beside_it() {
for i in 0..9 {
assert_eq!(
hotbar_slot(427.0, 240.0, [133.5 + i as f32 * 20.0, 230.0]),
Some(i)
);
}
assert_eq!(hotbar_slot(427.0, 240.0, [320.0, 230.0]), None);
assert_eq!(hotbar_slot(427.0, 240.0, [100.0, 230.0]), None);
// Above the bar is the world, not a slot.
assert_eq!(hotbar_slot(427.0, 240.0, [213.5, 210.0]), None);
}
}
@@ -168,6 +168,12 @@ pub struct Settings {
pub button_visibility: [u8; CONTROLS],
/// One row of placements per [`Mode`], indexed by [`Control`].
pub layouts: [[Placement; CONTROLS]; 3],
/// Camera turn speed for look drags; scales both axes.
pub sensitivity: f32,
/// Extra scale on the yaw (left and right) turn.
pub sensitivity_horizontal: f32,
/// Extra scale on the pitch (up and down) turn.
pub sensitivity_vertical: f32,
}
impl Default for Settings {
@@ -203,6 +209,9 @@ impl Default for Settings {
shield_button: true,
button_visibility: [FOLLOW_SCHEME; CONTROLS],
layouts,
sensitivity: 1.0,
sensitivity_horizontal: 1.0,
sensitivity_vertical: 1.0,
}
}
}
@@ -238,6 +247,13 @@ impl Settings {
for p in self.layouts.iter_mut().flatten() {
p.sanitize();
}
for v in [
&mut self.sensitivity,
&mut self.sensitivity_horizontal,
&mut self.sensitivity_vertical,
] {
*v = finite_or(*v, 1.0).clamp(0.1, 3.0);
}
}
/// Whether attack and use act on the crosshair rather than on a tap.
@@ -385,6 +401,28 @@ mod tests {
assert_eq!(p.opacity, 0.65);
}
#[test]
fn sensitivity_stays_finite_and_in_range() {
let mut s = Settings {
sensitivity: f32::INFINITY,
sensitivity_horizontal: 9.0,
sensitivity_vertical: 0.0,
..Settings::default()
};
s.sanitize();
assert_eq!(
[
s.sensitivity,
s.sensitivity_horizontal,
s.sensitivity_vertical
],
[1.0, 3.0, 0.1]
);
// Files saved before these options existed keep the defaults.
let read: Settings = serde_json::from_str(r#"{"mode":"tap"}"#).unwrap();
assert_eq!(read.sensitivity, 1.0);
}
#[test]
fn left_hands_mirror_and_dpad_can_swap_jump_and_sneak() {
let mut s = Settings::default();
@@ -108,6 +108,9 @@ pub struct World {
pub inhabited: FxHashMap<(i32, i32), i64>,
pub game_time: i64,
pub day_time: i64,
/// The End's world clock (`WorldClocks.THE_END`, the End sky flash's), saved apart from
/// the game time and, like every clock, still while `advance_time` is off.
pub end_clock: i64,
pub weather: Weather,
/// Every block position written since the log was last drained, kept
/// only while the world is open to the network (see `lan`).
@@ -238,6 +241,7 @@ impl World {
inhabited: FxHashMap::default(),
game_time: 0,
day_time: 1000,
end_clock: 0,
weather: Weather::default(),
block_log: None,
block_log_held: Vec::new(),
@@ -1,29 +1,137 @@
{
"horizon.extras.header.world": "Wêreld",
"horizon.extras.pregen": "Chunk voorafgenerering",
"horizon.extras.pregen.tooltip": "Aktiveer /pregen in jou enkelspeler- en LAN-wêrelde. Met cheat aktief, gebruik /pregen start om 'n area te genereer voordat jy dit verken. Aktiveer nie die opdrag op bedieners waarby jy aansluit nie.",
"horizon.options.controller": "Beheerder",
"horizon.options.three_d_skin_layers": "3D-velae",
"horizon.options.vibration": "Vibrasie",
"horizon.options.play_in_background": "Speel in agtergrond",
"horizon.options.adaptive_triggers": "Aanpasbare snellers",
"horizon.options.gameplay_guide": "Spelgids",
"horizon.options.gameplay_guide.basic": "Basies",
"horizon.options.gameplay_guide.full": "Vol",
"horizon.options.gameplay_guide.off": "Af",
"horizon.controller.guide.use": "Gebruik",
"horizon.controller.guide.open": "Maak oop",
"horizon.controller.guide.ride": "Ry",
"horizon.controller.guide.trade": "Handel",
"horizon.controller.guide.feed": "Voer",
"horizon.controller.guide.place": "Plaas",
"horizon.controller.guide.eat": "Eet",
"horizon.controller.guide.drink": "Drink",
"horizon.controller.guide.shoot": "Skiet",
"horizon.controller.guide.load": "Laai",
"horizon.controller.guide.throw": "Gooi",
"horizon.controller.guide.raise": "Lig",
"horizon.controller.guide.equip": "Toerus",
"horizon.controller.guide.fish": "Visvang",
"horizon.controller.guide.zoom": "Zoem",
"horizon.controller.guide.boost": "Versnel",
"horizon.controller.guide.break": "Breek",
"horizon.controller.guide.attack": "Aanval",
"horizon.controller.guide.spectate": "Kyk",
"horizon.controller.guide.spectator_menu": "Toeskouermenu",
"horizon.controller.guide.release_shoot": "Los om te skiet",
"horizon.controller.guide.release_throw": "Los om te gooi",
"horizon.controller.guide.release_stop": "Los om te stop",
"horizon.controller.guide.fly": "Vlieg",
"horizon.controller.guide.fly_up": "Vlieg op",
"horizon.controller.guide.fly_down": "Vlieg af",
"horizon.controller.guide.swim_up": "Swem op",
"horizon.controller.guide.swim_down": "Swem af",
"horizon.controller.guide.glide": "Sweef",
"horizon.controller.guide.dismount": "Klim af",
"horizon.controller.guide.stand": "Staan op",
"horizon.controller.guide.drop": "Laat item val",
"horizon.controller.guide.swap": "Ruil hande",
"horizon.controller.guide.take": "Neem",
"horizon.controller.guide.take_half": "Neem helfte",
"horizon.controller.guide.put": "Sit neer",
"horizon.controller.guide.put_one": "Sit een",
"horizon.controller.guide.swap_stack": "Ruil",
"horizon.controller.guide.quick_move": "Vinnige skuif",
"horizon.controller.guide.drop_stack": "Laat stapel val",
"horizon.controller.guide.place_recipe": "Plaas resep",
"horizon.controller.guide.place_recipe_all": "Plaas alles",
"horizon.controller.guide.alternatives": "Alternatiewe",
"horizon.controller.guide.select": "Kies",
"horizon.controller.guide.close": "Maak toe",
"horizon.controller.guide.pages": "Bladsye",
"horizon.controller.guide.categories": "Kategorieë",
"horizon.controller.guide.tabs": "Oortjies",
"horizon.options.lightbar": "Ligbalk",
"horizon.options.look_sensitivity": "Kyksensitiwiteit",
"horizon.options.invert_x": "Omgekeerde kyk X",
"horizon.options.invert_y": "Omgekeerde kyk Y",
"horizon.options.craft_on_servers": "Vinnige vervaardiging op bedieners",
"horizon.controller.direct_craft": "Vinnige vervaardiging",
"horizon.options.sneak_button": "Sluipknoppie",
"horizon.options.move_deadzone": "Bewegingsdooiesone",
"horizon.options.look_deadzone": "Kykdooiesone",
"horizon.options.trigger_threshold": "Snellerdrempel",
"horizon.options.button_glyphs": "Knoppie Glyphs",
"horizon.options.on_screen_keyboard": "Sleutelbord op die skerm",
"horizon.options.chat_delay": "Chat Vertraging",
"horizon.options.lightbar.health": "Gesondheid",
"horizon.options.lightbar.dimension": "Dimensie",
"horizon.options.glyphs.auto": "Outo",
"horizon.options.glyphs.xbox": "Xbox",
"horizon.options.glyphs.playstation": "PlayStation",
"horizon.options.glyphs.nintendo": "Nintendo",
"horizon.options.nintendo_buttons": "Nintendo-knoppies",
"horizon.options.nintendo_buttons.position": "Volgens posisie",
"horizon.options.nintendo_buttons.letter": "Volgens letter",
"horizon.options.keyboard.auto": "Outo",
"horizon.options.keyboard.horizon": "Horizon",
"horizon.options.keyboard.steam": "Steam",
"horizon.options.unavailable": "Hierdie kenmerk is nog nie in Horizon beskikbaar nie.",
"horizon.options.search": "Soek instellings...",
"horizon.studio.record.usage": "Gebruik: /studio record <start [name]|stop|status>",
"horizon.studio.record.started": "Studio-opname begin: %s",
"horizon.studio.record.saved": "Studio-opname gestoor: %s",
"horizon.studio.record.error": "Studio-opnamefout: %s",
"horizon.studio.record.status.active": "Studio-opname aktief — %s s, %s pakkette, %s KB (%s)",
"horizon.studio.record.status.inactive": "Studio-opname onaktief",
"horizon.studio.replay.usage": "Gebruik: /studio replay <open PATH|pause|resume|speed MULTIPLIER|seek MS|status|close>",
"horizon.studio.replay.open.usage": "Gebruik: /studio replay open <PATH>",
"horizon.studio.replay.opened": "Studio-herhaling oopgemaak: %s (%s s, %s pakkette)",
"horizon.studio.replay.open_error": "Kon nie herhaling oopmaak nie: %s",
"horizon.studio.replay.paused": "Studio-herhaling gepouseer",
"horizon.studio.replay.resumed": "Studio-herhaling hervat",
"horizon.studio.replay.inactive": "Geen aktiewe Studio-herhaling nie",
"horizon.studio.replay.speed.usage": "Gebruik: /studio replay speed <MULTIPLIER>",
"horizon.studio.replay.speed.set": "Studio-herhalingspoed op %s× gestel",
"horizon.studio.replay.speed.invalid": "Ongeldige herhalingspoed",
"horizon.studio.replay.seek.usage": "Gebruik: /studio replay seek <MILLISECONDS>",
"horizon.studio.replay.seeked": "Studio-herhaling verskuif na %s ms (%s s)",
"horizon.studio.replay.seek.error": "Kon nie in die herhaling spring nie: %s",
"horizon.studio.replay.seek.backward_requires_standalone": "Terugspoel is slegs vir 'n selfstandige herhaling beskikbaar.",
"horizon.studio.replay.seek.invalid": "Ongeldige herhalingsposisie",
"horizon.studio.replay.status": "Studio-herhaling: %s / %s ms (%s%%), spoed %s×, gepouseer %s, klaar %s",
"horizon.studio.replay.closed": "Studio-herhaling toegemaak",
"horizon.studio.freecam.enabled": "Studio-vrye kamera aktief. Gebruik WASD om te beweeg, Space en Shift om op of af te gaan, en Control om vinniger te beweeg.",
"horizon.studio.freecam.disabled": "Studio-vrye kamera afgeskakel",
"horizon.studio.freecam.unavailable": "Studio-vrye kamera kon nie 'n spelerkamera vind nie",
"horizon.studio.freecam.replay_required": "Studio-vrye kamera is beskikbaar tydens herhalingsweergawe",
"horizon.studio.freecam.usage": "Gebruik: /studio freecam <on|off>",
"horizon.studio.keyframe.added": "Kamera-sleutelraam by %s ms bygevoeg. Die pad word gestoor wanneer jy die herhaling toemaak.",
"horizon.studio.keyframe.cleared": "Kamerapad skoongemaak. Die verandering word gestoor wanneer jy die herhaling toemaak.",
"horizon.studio.keyframe.removed": "Kamera-sleutelraam %s verwyder. Die verandering word gestoor wanneer jy die herhaling toemaak.",
"horizon.studio.keyframe.save_error": "Kon nie die kamerapad stoor nie: %s",
"horizon.studio.keyframe.remove_usage": "Gebruik: /studio keyframe remove <INDEX>",
"horizon.studio.keyframe.index_invalid": "Kamera-sleutelraamindeks is buite bereik",
"horizon.studio.keyframe.usage": "Gebruik: /studio keyframe <add|remove INDEX|clear>",
"horizon.studio.path.locked": "Studio-kamerapad-weergawe aktief",
"horizon.studio.path.unlocked": "Studio-kamerapad-weergawe gestop",
"horizon.studio.path.usage": "Gebruik: /studio path <lock|unlock> (voeg eers ten minste een sleutelraam by)",
"horizon.pages.mods": "Mods",
"horizon.pages.shader_packs": "Shader Pakke",
"horizon.pages.shader_options": "Shader Opsies",
"horizon.pages.controller": "Beheerderinstellings",
"horizon.pages.controller_binds": "Kontroleerder bind",
"horizon.pages.extras": "Horizon-ekstras",
"horizon.pages.settings": "Horizon-instellings",
"horizon.pages.studio": "Herhalingstudio",
"horizon.pages.agent": "KI Agent",
"horizon.split.leave": "Los",
"horizon.pages.split_settings": "Verdeelskerm-instellings",
@@ -44,6 +152,7 @@
"horizon.pages.music.empty": "Geen musieksnitte nie: die speletjie se klanke is nie gelaai nie.",
"horizon.pages.music.tooltip": "Die ingeboude snitte, volgens situasie: speel nou een, of skakel enige af sodat die speletjie ophou om dit te kies.",
"horizon.pages.settings.tooltip": "Horizon se eie instellings: grafika, beheerders, ekstras en mods.",
"horizon.pages.studio.tooltip": "Blaai deur gestoorde opnames en hul metadata.",
"horizon.pages.graphics.tooltip": "Grafika, eenvoudig: elke instelling sê wat dit kos.",
"horizon.pages.graphics_advanced.tooltip": "Elke grafiese instelling, Horizon s'n ingesluit, met wat elkeen kos.",
"horizon.pages.controller.tooltip": "Beheerders: hul knoppies, stokke, vibrasie en spyskaartnavigasie.",
@@ -64,6 +173,14 @@
"horizon.pages.open.horizon_graphics": "Horizon-grafika...",
"horizon.pages.open.controller_binds": "Beheerder bind...",
"horizon.pages.open.horizon_settings": "Horizon-instellings...",
"horizon.pages.open.studio": "Herhalingstudio...",
"horizon.studio.saved_recordings": "Gestoorde opnames",
"horizon.studio.empty": "Geen opnames in die opnames-lêergids gevind nie.",
"horizon.studio.empty_hint": "Gebruik /studio record start <name> in 'n wêreld, of sleep .mcpr-lêers daar in.",
"horizon.studio.open_folder": "Maak opnames oop",
"horizon.studio.open_replay": "Speel herhaling",
"horizon.studio.refresh": "Verfris",
"horizon.studio.unknown_server": "Onbekend",
"horizon.pages.open.music": "Musiek...",
"horizon.mods.empty": "Geen mods: plaas .hmod-lêers in die horizon-mods-lêergids",
"horizon.mods.stopped": " (Gestop)",
@@ -72,6 +189,28 @@
"horizon.mods.settings": "Instellings",
"horizon.mods.in_world": " (los die wêreld om te verander)",
"horizon.mods.open_folder": "Maak Mod-lêergids oop",
"horizon.mods.browse": "Blaai deur mods...",
"horizon.mods.browse.title": "Blaai deur mods",
"horizon.mods.browse.search": "Soek mods...",
"horizon.mods.browse.project": "Mod",
"horizon.mods.browse.loading": "Lees Horizon se modregister...",
"horizon.mods.browse.failed": "Kon nie die modregister lees nie: %s",
"horizon.mods.browse.none": "Geen mod gevind nie",
"horizon.mods.browse.has_update": " (opdatering)",
"horizon.mods.browse.is_installed": " (geïnstalleer)",
"horizon.mods.browse.by": "deur %s",
"horizon.mods.browse.installing": "Installeer tans...",
"horizon.mods.browse.install": "Installeer",
"horizon.mods.browse.update": "Dateer op",
"horizon.mods.browse.installed": "Geïnstalleer",
"horizon.mods.browse.open_page": "Maak bladsy oop",
"horizon.mods.browse.enabled": "Mod",
"horizon.mods.browse.changelog": "Veranderingslys",
"horizon.mods.browse.done": "%s %s is geïnstalleer en aan.",
"horizon.mods.browse.install_failed": "Kon dit nie installeer nie: %s",
"horizon.mods.switch.worldgen": "verander wêreldgenerering",
"horizon.mods.switch.registries": "voeg blokke, items of ander registerinskrywings by",
"horizon.mods.switch.restart": "Herbegin die speletjie om %s toe te pas: hulle voeg blokke, items of wêreldgenerering by.",
"horizon.shader_options.profile": "Profiel",
"horizon.agent.copy_details": "Kopieer verbindingbesonderhede",
"horizon.multiplayer.join": "Sluit aan",
@@ -83,7 +222,9 @@
"LOD Distance": "LOD Afstand",
"Far Detail": "Verre Detail",
"Cave Culling": "Grotuitdunning",
"GPU Culling": "GPU-uitdunning",
"Entity Culling": "Entiteit Uitdunning",
"Chunk Updates": "Chunk-opdaterings",
"Upload Budget": "Laai begroting op",
"Sky": "Hemel",
"Fog": "Mis",
@@ -93,11 +234,17 @@
"Frame Pacing": "Raamtempo",
"Worker Threads": "Werkerdrade",
"Resolution Scale": "Resolusieskaal",
"Anti-aliasing": "Teen-aliassing",
"Upscaling": "Opskaling",
"Upscaling Sharpness": "Opskalingskerpte",
"Paper White": "Papier Wit",
"Peak Brightness": "Piek helderheid",
"Black Level": "Swart vlak",
"UI Brightness": "UI Helderheid",
"HDR Lighting": "HDR-beligting",
"Light sources, the sun and sunlight shine past white. Off: just as SDR.": "Ligbronne, die son en sonlig skyn verby wit. Af: net soos SDR.",
"Smooth Gradients": "Gladde gradiënte",
"16-bit frame: dark skies and shadows don't band. A bit more VRAM. Next start.": "16-bis raam: donker lug en skaduwees band nie. 'n Bietjie meer VRAM. Volgende begin.",
"Battery Mode": "Batterymodus",
"Graphics API": "Grafiese API",
"Translucent Batching": "Deurskynende Batching",
@@ -111,6 +258,7 @@
"Performance": "Prestasie",
"Balanced": "Gebalanseerd",
"Quality": "Kwaliteit",
"Ultra": "Ultra",
"Ultra Performance": "Ultra prestasie",
"Native": "Inheems",
"Full": "Vol",
@@ -121,18 +269,29 @@
"FIFO": "EIEU",
"Mailbox": "Posbus",
"Immediate": "Onmiddellik",
"Vulkan": "Vulkan",
"DirectX 12": "DirectX 12",
"OpenGL": "OpenGL",
"FPS and Graph": "FPS en grafiek",
"FPS": "FPS",
"TAA": "TAA",
"FXAA": "FXAA",
"No cost": "Geen koste nie",
"Low cost": "Lae koste",
"Medium cost": "Medium koste",
"High cost": "Hoë koste",
"Saves time": "Spaar tyd",
"HDR": "HDR",
"Graphics": "Grafika",
"Done": "Klaar",
"This feature is not yet available in Horizon.": "Hierdie kenmerk is nog nie in Horizon beskikbaar nie.",
"Your display or system doesn't offer HDR output.": "Jou skerm of stelsel bied nie HDR-uitvoer nie.",
"Turn HDR on to use it.": "Skakel HDR aan om dit te gebruik.",
"Your GPU or graphics API doesn't support it.": "Jou GPU of grafiese API ondersteun dit nie.",
"Not with software rendering, which runs it on the CPU.": "Nie met sagteware-weergawe nie, wat dit op die CPU laat loop.",
"Out of video memory: off until the next start.": "Nie genoeg videogeheue nie: af tot die volgende begin.",
"The graphics device was lost: off until the next start.": "Die grafiese toestel is verloor: af tot die volgende begin.",
"Not on DirectX 12 yet: try the Vulkan graphics API.": "Nog nie op DirectX 12 nie: probeer die Vulkan-grafiese API.",
"%s chunks": "%s stukke",
"%s per frame": "%s per raam",
"%s MiB/frame": "%s MiB/raam",
@@ -148,13 +307,15 @@
"Size of menus and the HUD.": "Grootte van spyskaarte en die HUD.",
"Sets the options below together. Changing one makes it Custom.": "Stel die opsies hieronder saam. Om een te verander, maak dit Pasgemaak.",
"Lava, fire and sunlight shine brighter than white. Needs an HDR display.": "Lava, vuur en sonlig skyn helderder as wit. Benodig 'n HDR-skerm.",
"Your display's darkest black. 0 for OLED; LCDs glow a little.": "Jou skerm is die donkerste swart. 0 vir OLED; LCD's gloei 'n bietjie.",
"Lifts shadows onto your display's darkest black. 0: true black, no lift.": "Lig skaduwees na jou skerm se donkerste swart. 0: suiwer swart, geen ligting.",
"How bright highlights get. Auto: the peak your system reports.": "Hoe helder hoogtepunte word. Outo: die hoogtepunt wat jou stelsel rapporteer.",
"How bright white paper and menus look. Auto: your desktop's white.": "Hoe helder wit papier en spyskaarte lyk. Outo: jou lessenaar is wit.",
"How bright white looks in the world. Auto: your desktop's white.": "Hoe helder wit in die wêreld lyk. Outo: jou lessenaar se wit.",
"How bright the HUD and menus are, against paper white.": "Hoe helder die HUD en spyskaarte is, teenoor papierwit.",
"How far mobs, crops and machines keep working. CPU time.": "Hoe ver skare, gewasse en masjiene aanhou werk. SVE tyd.",
"How far away mobs and items are drawn, as a share of normal.": "Hoe ver weg skares en items getrek word, as 'n deel van normaal.",
"Skips terrain hidden behind closed sections. Off costs a lot.": "Slaan terrein oor wat agter geslote gedeeltes versteek is. Af kos baie.",
"Culls nearby terrain on the GPU: less CPU at high render distance, more GPU.": "Dun nabygeleë terrein op die GPU uit: minder CPU by hoë tekenafstand, meer GPU.",
"Auto: tries it where your CPU limits the frame, keeps it if frames get faster.": "Outo: probeer dit waar jou CPU die raam beperk, hou dit as rame vinniger word.",
"Hides entities behind opaque blocks.": "Versteek entiteite agter ondeursigtige blokke.",
"No entity occlusion culling.": "Geen entiteit-okklusion uitskot nie.",
"CPU occlusion test runs this frame.": "SVE-okklusietoets loop hierdie raam.",
@@ -178,6 +339,7 @@
"World pixels per screen pixel. Below 100% is faster on the GPU.": "Wêreldpixels per skermpiksel. Onder 100% is vinniger op die GPU.",
"Smooths jagged edges. FXAA: softer. TAA: smoothest, moving things may trail.": "Maak gekartelde rande glad. FXAA: sagter. TAA: gladste, bewegende dinge kan loop.",
"Renders the world smaller and rebuilds it with TAA. Faster, softer.": "Maak die wêreld kleiner en herbou dit met TAA. Vinniger, sagter.",
"Sharpens the upscaled picture. Higher: crisper edges, more shimmer.": "Verskerp die opgeskaalde beeld. Hoër: skerper rande, meer flikkering.",
"HDR output on an HDR display: lava, fire and sunlight shine brighter.": "HDR-uitset op 'n HDR-skerm: lawa, vuur en sonlig skyn helderder.",
"How frames reach the screen while VSync is off.": "Hoe rame die skerm bereik terwyl VSync af is.",
"Efficient sleeps until the next frame; Precise spins, like vanilla.": "Doeltreffende slaap tot die volgende raam; Presiese draaie, soos vanielje.",
@@ -197,6 +359,7 @@
"Frame rate and frame times over the game, with a graph.": "Raamtempo en raamtye oor die spel, met 'n grafiek.",
"horizon.multiplayer.finding_friend": "Soek jou vriend se wêreld...",
"horizon.multiplayer.not_signed_in": "Hierdie bediener is in aanlynmodus. Meld aan met jou Microsoft-rekening in die Horizon-lanseerder om daarby aan te sluit.",
"horizon.realms.not_signed_in": "Meld aan met jou Microsoft-rekening in die Horizon-lanseerder om op Realms te speel.",
"horizon.multiplayer.invalid_address": "Ongeldige bedieneradres: %s",
"horizon.multiplayer.connected_friend_direct": "Reguit gekoppel aan jou vriend...",
"horizon.multiplayer.connected_friend_relay": "Gekoppel aan jou vriend deur die aflos...",
@@ -226,6 +389,7 @@
"Brighter-than-white output on an HDR display.": "Helderder-as-wit uitset op 'n HDR-skerm.",
"Results for \"%s\"": "Resultate vir \"%s\"",
"No settings match.": "Geen instellings pas nie.",
"horizon.plain.official": "Amptelike Horizon met lanseerder, mods en opdaterings: playhorizon.dev",
"horizon.title.not_affiliated": "Nie geaffilieer met Mojang of Microsoft nie",
"horizon.extras.header.lighting": "Beligting",
"horizon.extras.header.third_person": "Derde persoon",
@@ -249,9 +413,12 @@
"horizon.extras.no_ai": "Geen KI",
"horizon.extras.agent_control": "Agent beheer",
"horizon.extras.discord_presence": "Ryk teenwoordigheid op Discord",
"horizon.extras.zoom": "Zoem",
"horizon.extras.zoom_mod_install": "Zoem: installeer uit Mods",
"horizon.extras.splitscreen": "Verdeelskerm",
"horizon.extras.chat_animation": "Glad gesels",
"horizon.extras.chat_indicator": "Boodskap aanwyser",
"horizon.extras.wheel_cycles_list_settings.tooltip": "Die rolwiel oor 'n knoppie in 'n instellingslys verander sy waarde. Af, soos in vanielje, rol die wiel deur die lys.",
"horizon.extras.wheel_cycles_settings": "Rolwiel verander instellings",
"horizon.extras.pack_browser": "Hulpbronpakketblaaier",
"horizon.extras.fullbright.tooltip": "Wys blokke en entiteite in volle lig, selfs in donker areas.",
@@ -312,6 +479,7 @@
"horizon.friends.stopped": "Het opgehou om die uitnodigingkode te deel.",
"horizon.friends.short_code_changed": "Iemand het aanhou om 'n verkeerde kort kode te probeer, so dit het verander.",
"horizon.friends.wrong_code": "Iemand het 'n verkeerde kode probeer.",
"horizon.friends.relay_stopped": "Te veel verkeerde kort kodes, so die aflos is af totdat jy weer deel. Vriende kan steeds met die volle kode aansluit.",
"horizon.friends.code_ready": "Jou uitnodigingskode is gereed: Speletjie-kieslys, Speel met vriende. Kopieer dit en stuur dit aan jou vriende.",
"horizon.friends.share_failed": "Kon nie die wêreld deel nie: %s",
"horizon.friends.finding_route": "Vind tans hoe vriende jou kan bereik …",
@@ -325,11 +493,13 @@
"horizon.extras.chat_indicator.tooltip": "Die gekleurde balk aan die linkerkant van 'n kletslyn.",
"horizon.extras.pack_browser.tooltip": "Select Resource Packs kry 'n Voeg by-knoppie wat Modrinth deursoek en 'n pakket in jou pakke-lêergids installeer. Af, die speletjie kontak nooit Modrinth nie.",
"horizon.extras.splitscreen.tooltip": "In 'n wêreld kan 'n ander beheerder as 'n nuwe speler aansluit: druk A wanneer dit vra, kies dan 'n profiel en waar om te speel.",
"horizon.splitscreen.setup.monitor": "Monitor %s",
"horizon.splitscreen.setup.monitor_unknown": "'n monitor",
"horizon.splitscreen.setup.window": "Venster %s",
"horizon.splitscreen.setup.new_window": "Nuwe venster",
"horizon.splitscreen.setup.fill_monitor": "Vul %s",
"horizon.splitscreen.setup.done": "Klaar",
"horizon.splitscreen.setup.switch": "%s: %s",
"horizon.splitscreen.setup.on": "Aan",
"horizon.splitscreen.setup.off": "Af",
"horizon.splitscreen.setup.player": "Speler %s",
@@ -387,8 +557,12 @@
"horizon.splitscreen.setup.help.give_up_pad": "Jou beheerder hou op om jou te bestuur en word aangebied om aan te sluit (A).",
"horizon.splitscreen.setup.note.held_at": "%s tot %s: A laat val dit hier, B sit dit terug.",
"horizon.splitscreen.setup.note.held": "%s opgetel: links/regs kies waar, A laat val, B sit dit terug.",
"horizon.splitscreen.online_only": "Hierdie bediener is in aanlynmodus: vanlyn profiele kan nie daarby aansluit nie; elke speler het sy eie Minecraft-rekening nodig.",
"horizon.splitscreen.own_account": "Hierdie bediener vereis elke speler se eie Minecraft-rekening",
"horizon.splitscreen.local_only": "Ander spelers kan slegs met hul eie Minecraft-rekening by hierdie bediener aansluit: vanlyn profiele speel in jou wêreld, op LAN of op 'n bediener op jou plaaslike netwerk.",
"Hides models behind opaque blocks. Async checks off-thread; entities tick.": "Versteek modelle agter ondeursigtige blokke. Async kontroleer af-draad; entiteite merk.",
"Block Entities": "Blok Entiteite",
"Shulker Boxes": "Shulkerbokse",
"Reuses settled boxes; lid movement stays animated.": "Hergebruik afgewikkelde bokse; dekselbeweging bly geanimeerd.",
"Signs": "Tekens",
"Reuses sign text until text, light, or font changes.": "Hergebruik tekenteks totdat teks, lig of lettertipe verander.",
@@ -404,6 +578,8 @@
"Reuses statue geometry until its block state changes.": "Hergebruik standbeeldgeometrie totdat die bloktoestand daarvan verander.",
"Leaf Culling": "Blaaruitsny",
"Every leaf face. Vanilla's fancy leaves.": "Elke blaar gesig. Vanielje se spoggerige blare.",
"Opaque Ice": "Ondeursigtige ys",
"Draws ice in the solid pass, without its see-through texture.": "Teken ys in die soliede fase, sonder sy deurskynende tekstuur.",
"Distant Plant Dither": "Verre Plant Dither",
"Keeps fine plants in high-res packs from fading far away.": "Hou fyn plante in hoë-resolusie-pakke om ver weg te vervaag.",
"Screenshot Quality": "Skermkiekie Kwaliteit",
@@ -411,7 +587,18 @@
"Frame Generation": "Raam Generasie",
"Shows a made-up frame between each two real ones: smoother, a little more lag.": "Toon 'n opgemaakte raam tussen elke twee regte: gladder, 'n bietjie meer vertraging.",
"Shadow Cache": "Skadu-kas",
"Shaders": "Skadus",
"Shader packs, Horizon's and shaders/ folder ones, and their options.": "Skadupakke, Horizon s'n en dié in die shaders/-lêergids, en hul opsies.",
"Shader packs": "Skadupakke",
"Vanilla's own preset: sets only vanilla's options, as Minecraft does.": "Vanielje se eie voorafinstelling: stel net vanielje se opsies, soos Minecraft dit doen.",
"Fullscreen takes the display over, at the mode below.": "Volskerm neem die skerm oor, in die modus hieronder.",
"The display mode exclusive fullscreen uses. Applies when you leave.": "Die skermmodus wat eksklusiewe volskerm gebruik. Word toegepas wanneer jy verlaat.",
"Blurs the world behind menus.": "Vervaag die wêreld agter spyskaarte.",
"How textures are sampled: sharper at a slant with Anisotropic.": "Hoe teksture gesampled word: skerper skuins met Anisotropies.",
"How far Anisotropic filtering goes. Higher is sharper at a slant.": "Hoe ver Anisotropiese filtering gaan. Hoër is skerper skuins.",
"Only with Anisotropic texture filtering.": "Slegs met Anisotropiese tekstuurfiltrering.",
"Shader Packs...": "Shader-pakke...",
"Picks a shader pack, Horizon's or a shaders/ folder one, and sets its options.": "Kies 'n skadupak, Horizon s'n of een in die shaders/-lêergids, en stel sy opsies.",
"Reuses the pack shadow map when sun, view and world match. Off by default.": "Hergebruik die pakskadukaart wanneer son, uitsig en wêreld ooreenstem. Af by verstek.",
"Find BlueMap": "Vind BlueMap",
"On servers, finds their BlueMap web map for far terrain. Next join.": "Op bedieners, vind hul BlueMap-webkaart vir ver terrein. Sluit volgende aan.",
@@ -419,6 +606,8 @@
"How far around you BlueMap tiles are downloaded. Next join.": "Hoe ver om jou is BlueMap-teëls afgelaai. Sluit volgende aan.",
"Synchronous": "Sinchronies",
"Asynchronous": "Asinchronies",
"Hidden Faces": "Versteekte vlakke",
"Drops leaf faces behind two leaf layers: faster, same crowns.": "Laat blaaroppervlakke agter twee blaarlae val: vinniger, dieselfde kroon.",
"Solid": "Solied",
"Solid Aggressive": "Stewige aggressiewe",
"Hollow": "Hol",
@@ -440,6 +629,10 @@
"Auto (%s nits)": "Outo (%s nete)",
"%.2f nits": "%.2f nete",
"Hides models behind opaque blocks. Entities update. Off with custom packs.": "Versteek modelle agter ondeursigtige blokke. Entiteite-opdatering. Af met pasgemaakte pakke.",
"Entity Culling Precheck": "Entiteit-uitdunning-voorafkontrole",
"Runs the mob AABB precheck after this many loaded mobs. Zero disables it.": "Voer die skare-AABB-voorafkontrole uit ná hierdie aantal gelaaide skare. Nul skakel dit af.",
"%s mobs": "%s skares",
"Only applies to asynchronous entity culling.": "Geld slegs vir asinchroniese entiteit-uitdunning.",
"horizon.extras.repro_keys": "Opname kortpaaie",
"horizon.extras.repro_keys.tooltip": "F3+R begin of stop 'n repro-opname en F3+E skryf sy bundel. Af by verstek: 'n opname kopieer die wêreld.",
"horizon.agent.record_session": "Begin opnamesessie",
@@ -448,8 +641,20 @@
"horizon.repro.started": "Opname het begin; F3+R stop dit en skryf die bundel.",
"horizon.repro.stopped": "Opname gestop: %s",
"horizon.repro.bundle": "Repro-bundel: %s",
"horizon.repro.error": "Repro: %s",
"horizon.repro.no_recording": "Daar is nog geen opname om uit te voer nie.",
"horizon.repro.replay_started": "Herhaling het begin: %s",
"horizon.splitscreen.profiles.offline": " (Vanlyn)",
"horizon.splitscreen.profiles.microsoft": " (Microsoft)",
"horizon.splitscreen.profiles.sign_in": "Meld weer aan in die lanseerder",
"horizon.splitscreen.profiles.online": "Microsoft aanlyn",
"horizon.splitscreen.account_unavailable": "%s is vanlyn. Meld weer aan in die lanseerder en herbegin dan die speletjie.",
"horizon.splitscreen.account_expired": "%s se Microsoft-aanmelding het verval. Meld weer aan in die lanseerder en herbegin dan die speletjie.",
"Particle Culling": "Deeltjie-uitdunning",
"Draws every particle.": "Teken elke deeltjie.",
"Skips offscreen, subpixel and terrain-hidden particles.": "Slaan van-skerm, subpiksel- en terreinversteekte deeltjies oor.",
"Skips offscreen, subpixel and terrain-hidden particles with less render-thread work.": "Slaan van-skerm, subpiksel- en terreinversteekte deeltjies oor met minder werk op die tekenraad.",
"horizon.pack.add": "+",
"horizon.pack.addInfo": "Voeg 'n hulpbronpakket van Modrinth by",
"horizon.pack.addWorking": "Vra vir Modrinth...",
"horizon.pack.addNone": "Geen hulpbronpakke gevind nie",
@@ -459,13 +664,32 @@
"horizon.pack.installed": "Geïnstalleer",
"horizon.pack.search": "Soek",
"horizon.pack.searchHint": "Soek Modrinth...",
"horizon.pack.filters": "Filters",
"horizon.pack.filter.realistic": "Realisties",
"horizon.pack.filter.simplistic": "Simplistiese",
"horizon.pack.filter.vanillaLike": "Vanielje-agtig",
"horizon.pack.filter.x16": "16x",
"horizon.pack.filter.x32": "32x",
"horizon.pack.filter.x64": "64x",
"horizon.pack.addFailed": "Die pakkie kon nie bygevoeg word nie: %s",
"horizon.pack.options": "Pakketopsies",
"horizon.pack.optionsInfo": "Opsies van hierdie pakket",
"horizon.pack.options.title": "Pakketopsies",
"horizon.pack.options.general": "Algemeen",
"horizon.pack.options.presets": "Voorafinstellings",
"horizon.pack.options.presets.custom": "Pasgemaak",
"horizon.pack.options.impact": "Prestasie-impak: %s",
"horizon.pack.options.impact.low": "laag",
"horizon.pack.options.impact.medium": "medium",
"horizon.pack.options.impact.high": "hoog",
"horizon.pack.options.impact.varies": "wissel",
"horizon.extras.header.cosmetics": "Skoonheidsmiddels",
"horizon.extras.cosmetics_mine": "My skoonheidsmiddels",
"horizon.extras.cosmetics_mine.tooltip": "Versteek: elke Horizon-speler sien jou sonder jou skoonheidsmiddels, jy ingesluit. Dieselfde skakelaar as die lanseerder se klerekas.",
"key.emote": "Emotewiel",
"horizon.emote_wheel.title": "Emotes",
"horizon.emote_wheel.empty": "Nog geen emotes nie",
"horizon.emote_wheel.help": "Los of klik om te speel · 1–8 · Esc maak toe",
"horizon.extras.cosmetics_others": "Ander spelers se skoonheidsmiddels",
"horizon.extras.cosmetics_others.tooltip": "Wat ander Horizon-spelers dra: alles, net hul naamplaatjiestyle, of niks. Versteek, die speletjie vra nooit wat hulle dra nie.",
"horizon.extras.cosmetics_hide_fx": "Versteek ander spelers se spore en auras",
@@ -489,5 +713,349 @@
"Pack settings": "Pakketopsies",
"Built-in settings": "Ingeboude opsies",
"%s settings": "%s-opsies",
"Settings made by the pack's author: they change only this pack.": "Opsies deur die pakket se skepper: hulle verander net hierdie pakket."
"Settings made by the pack's author: they change only this pack.": "Opsies deur die pakket se skepper: hulle verander net hierdie pakket.",
"horizon.moderation.duration.minute": "%s minuut",
"horizon.moderation.duration.minutes": "%s minute",
"horizon.moderation.duration.hour": "%s uur",
"horizon.moderation.duration.hours": "%s uur",
"horizon.moderation.duration.day": "%s dag",
"horizon.moderation.duration.days": "%s dae",
"horizon.moderation.reason": "%1$s: %2$s",
"horizon.moderation.mutelist.empty": "Daar is geen dempings nie",
"horizon.moderation.mutelist.count": "Daar is %s demping(s):",
"horizon.moderation.chat_filter": "die geselsfilter",
"horizon.moderation.until_unmuted": "tot ontdemp",
"horizon.moderation.remaining": "vir nog %s",
"horizon.moderation.mutelist.entry": "%1$s is deur %2$s gedemp %3$s",
"horizon.moderation.invalid_duration": "%s is nie 'n duur nie: 10m, 2h, 1d",
"horizon.moderation.usage.mute": "/mute <player> [duration] [reason]",
"horizon.moderation.usage.tempmute": "/tempmute <player> <duration> [reason]",
"horizon.moderation.usage.unmute": "/unmute <player>",
"horizon.moderation.usage.mutelist": "/mutelist",
"horizon.moderation.for_duration": "vir %s",
"horizon.moderation.muted": "%1$s gedemp %2$s",
"horizon.moderation.unmuted": "%s ontdemp",
"horizon.moderation.not_muted": "%s is nie gedemp nie",
"horizon.tempban.usage": "/tempban <player> <duration> [reason]",
"horizon.tempban.invalid_duration": "%s is nie 'n duur nie: 30m, 12h, 7d",
"horizon.tempban.dedicated_required": "Tydelike verbannings vereis 'n toegewyde bediener",
"horizon.tempban.success": "%1$s vir %2$s, tot %3$s",
"horizon.tempban.list.empty": "Daar is geen tydelike verbannings nie",
"horizon.tempban.list.count": "Daar is %s tydelike banning(s):",
"horizon.tempban.list.entry": "%1$s is deur %2$s verban %3$s (tot %4$s)",
"horizon.options.pointer_speed": "Spyskaartwyser-spoed",
"horizon.options.scroll_speed": "Spyskaartrolspoed",
"horizon.options.repeat_delay": "Navigasie-herhalingsvertraging",
"horizon.options.repeat_interval": "Navigasie-herhalingsinterval",
"horizon.options.milliseconds": "%s ms",
"horizon.extras.cosmetics_solo": "In enkelspeler",
"horizon.extras.cosmetics_solo.tooltip": "Wys jou skoonheidsmiddels en jou naamplaatjie se kenteken ook in jou enkelspeler-wêrelde (jou naamplaatjie met Eie naamplaatjie). Af: net op bedieners.",
"horizon.extras.horizon_marks": "Horizon-logo's",
"horizon.extras.horizon_marks.tooltip": "Wys die Horizon-logo langs Horizon-spelers: 'n klein logo voor die name van ander spelers wat Horizon gebruik, bo hul koppe en in die spelerlys. Net Horizon-spelers sien dit: bedieners ontvang niks.",
"horizon.extras.horizon_me": "Wys my as Horizon",
"horizon.extras.horizon_me.tooltip": "Wys my as 'n Horizon-speler: ander Horizon-spelers sien die logo by jou naam. Aan, antwoord die diens enigeen wat oor jou vra dat jy die afgelope 3 uur Horizon gespeel het; af, rapporteer dit jou nooit nie.",
"horizon.pages.pvp": "PvP & HUD",
"horizon.pages.pvp.tooltip": "Visier, HUD en PvP-instellings, alles by verstek af. Niks hier wys wat vanielje nie wys nie.",
"horizon.pages.open.pvp": "PvP & HUD...",
"horizon.pvp.header.crosshair": "Visier",
"horizon.pvp.crosshair": "Pasgemaakte visier",
"horizon.pvp.crosshair_shape": "Vorm",
"horizon.pvp.crosshair_size": "Grootte",
"horizon.pvp.crosshair_gap": "Gaping",
"horizon.pvp.crosshair_thickness": "Dikte",
"horizon.pvp.crosshair_colour": "Kleur",
"horizon.pvp.crosshair_outline": "Omlyn",
"horizon.pvp.crosshair_dot": "Middelpunt",
"horizon.pvp.crosshair_hover": "Kleur op entiteite",
"horizon.pvp.crosshair.tooltip": "Teken jou eie visier in plaas van vanielje s'n. Die eerste instellings daarvan lyk soos vanielje s'n.",
"horizon.pvp.crosshair_shape.tooltip": "'n Kruis, 'n T, 'n X, 'n sirkel, 'n vierkant of net 'n punt.",
"horizon.pvp.crosshair_size.tooltip": "Die lengte van elke arm, in GUI-piksels.",
"horizon.pvp.crosshair_gap.tooltip": "Die leë ruimte in die middel.",
"horizon.pvp.crosshair_thickness.tooltip": "Hoe wyd elke lyn is.",
"horizon.pvp.crosshair_colour.tooltip": "Omgekeerd wys die kleur daaronder omgekeerd, soos vanielje se visier doen.",
"horizon.pvp.crosshair_outline.tooltip": "'n Dun swart rand, sodat dit op enige agtergrond wys.",
"horizon.pvp.crosshair_dot.tooltip": "'n Punt in die middel van die visier.",
"horizon.pvp.crosshair_hover.tooltip": "'n Ander kleur terwyl die visier oor 'n skare of 'n speler is wat jy kan slaan, soos vanielje se aanvalaanwyser dit weet.",
"horizon.pvp.colour.inverted": "Omgekeerd",
"horizon.pvp.colour.white": "Wit",
"horizon.pvp.colour.red": "Rooi",
"horizon.pvp.colour.orange": "Oranje",
"horizon.pvp.colour.yellow": "Geel",
"horizon.pvp.colour.green": "Groen",
"horizon.pvp.colour.cyan": "Siaan",
"horizon.pvp.colour.blue": "Blou",
"horizon.pvp.colour.magenta": "Magenta",
"horizon.pvp.colour.black": "Swart",
"horizon.pvp.shape.cross": "Kruis",
"horizon.pvp.shape.t": "T",
"horizon.pvp.shape.x": "X",
"horizon.pvp.shape.circle": "Sirkel",
"horizon.pvp.shape.square": "Vierkant",
"horizon.pvp.shape.dot": "Punt",
"horizon.pvp.outline": "Pasgemaakte blokomlyn",
"horizon.pvp.outline.tooltip": "Verander die omlyn rondom die blok waarna jy kyk.",
"horizon.pvp.outline_colour": "Kleur",
"horizon.pvp.outline_colour.tooltip": "Die omlyn se kleur. Swart teen 40% is vanielje s'n.",
"horizon.pvp.outline_opacity": "Ondeursigtigheid",
"horizon.pvp.outline_opacity.tooltip": "Hoe solied die omlyn is.",
"horizon.pvp.outline_thickness": "Dikte",
"horizon.pvp.outline_thickness.tooltip": "Die omlyn se wydte, in keer van vanielje s'n.",
"horizon.pvp.outline_fill": "Vulling",
"horizon.pvp.outline_fill.tooltip": "Kleur die vlakke van die blok waarna jy kyk, liggies.",
"horizon.pvp.hit": "Pasgemaakte trefferkleur",
"horizon.pvp.hit.tooltip": "Verander die kleur waarin skares en spelers flits wanneer hulle seerkry. Rooi teen 30% is vanielje s'n.",
"horizon.pvp.hit_colour": "Kleur",
"horizon.pvp.hit_colour.tooltip": "Die kleur waarin gewonde skares en spelers flits.",
"horizon.pvp.hit_opacity": "Ondeursigtigheid",
"horizon.pvp.hit_opacity.tooltip": "Hoeveel van die kleur hulle bedek.",
"horizon.pvp.header.outline": "Blokomlyn",
"horizon.pvp.header.hit": "Trefferkleur",
"horizon.pvp.hud_armour": "Wapenrusting",
"horizon.pvp.hud_armour.tooltip": "Die wapenrusting wat jy dra, kop eerste, met hoeveel gebruike elke stuk oor het.",
"horizon.pvp.hud_armour_corner": "Wapenrustinghoek",
"horizon.pvp.hud_armour_corner.tooltip": "Waar die wapenrusting wys.",
"horizon.pvp.hud_armour_percent": "Duursaamheid in %",
"horizon.pvp.hud_armour_percent.tooltip": "Gebruike oor as 'n persentasie van die stuk se maksimum in plaas van 'n getal.",
"horizon.pvp.hud_effects": "Effekte",
"horizon.pvp.hud_effects.tooltip": "Jou effekte met hul name, vlakke en tyd oor, in die plek van die ikone regs bo.",
"horizon.pvp.hud_effects_corner": "Effektehoek",
"horizon.pvp.hud_effects_corner.tooltip": "Waar die effekte wys.",
"horizon.pvp.hud_held": "Getal van item in hand",
"horizon.pvp.hud_held.tooltip": "Hoeveel van die item in jou hand jou voorraad het; pyle met 'n boog of kruisboog.",
"horizon.pvp.hud_held_corner": "Hoek vir getal in hand",
"horizon.pvp.hud_held_corner.tooltip": "Waar die getal wys.",
"horizon.pvp.hud_background": "Widget-agtergronde",
"horizon.pvp.hud_background.tooltip": "'n Donker blokkie agter elke widget.",
"horizon.pvp.hud_opacity": "Agtergrondondeursigtigheid",
"horizon.pvp.hud_opacity.tooltip": "Hoe donker die blokkies agter die widgets is.",
"horizon.pvp.hud_text_colour": "Tekskleur",
"horizon.pvp.hud_text_colour.tooltip": "Die widgets se tekskleur. Duursaamheid behou sy groen tot rooi.",
"horizon.pvp.hud_shadow": "Teksskaduwee",
"horizon.pvp.hud_shadow.tooltip": "'n Skaduwee onder die widgets se teks, soos vanielje se HUD-teks het.",
"horizon.pvp.header.hud": "HUD-widgets",
"horizon.pvp.corner.top_left": "Links bo",
"horizon.pvp.corner.top_right": "Regs bo",
"horizon.pvp.corner.bottom_left": "Links onder",
"horizon.pvp.corner.bottom_right": "Regs onder",
"horizon.pvp.hud_ping": "Ping",
"horizon.pvp.hud_ping.tooltip": "Die latensie wat die bediener vir jou rapporteer, in millisekondes. Slegs multispeler.",
"horizon.pvp.hud_ping_corner": "Ping-hoek",
"horizon.pvp.hud_ping_corner.tooltip": "Waar die ping wys.",
"horizon.pvp.hud_coords": "Koördinate",
"horizon.pvp.hud_coords.tooltip": "Jou blokposisie en die rigting waarin jy kyk, soos F3 dit wys. Versteek wanneer die wêreld of bediener F3 verminder.",
"horizon.pvp.hud_coords_corner": "Koördinatehoek",
"horizon.pvp.hud_coords_corner.tooltip": "Waar die koördinate wys.",
"horizon.pvp.hud_server": "Bedieneradres",
"horizon.pvp.hud_server.tooltip": "Die adres van die bediener waarop jy speel. Slegs multispeler.",
"horizon.pvp.hud_server_corner": "Bedienerhoek",
"horizon.pvp.hud_server_corner.tooltip": "Waar die adres wys.",
"horizon.pvp.hud_combo": "Komboteller",
"horizon.pvp.hud_combo.tooltip": "Treffers in 'n ry wat die bediener bevestig het, totdat jy seerkry of 3 sekondes sonder een verbygaan.",
"horizon.pvp.hud_combo_corner": "Kombohoek",
"horizon.pvp.hud_combo_corner.tooltip": "Waar die combo wys.",
"horizon.pvp.hud.ping": "Ping: %s ms",
"horizon.pvp.hud.coords": "XYZ: %s %s %s",
"horizon.pvp.hud.facing": "Rigting: %s",
"horizon.pvp.hud.combo": "Combo: %s",
"horizon.pvp.hud.direction.north": "Noord",
"horizon.pvp.hud.direction.south": "Suid",
"horizon.pvp.hud.direction.east": "Oos",
"horizon.pvp.hud.direction.west": "Wes",
"horizon.pvp.hud_fps": "FPS",
"horizon.pvp.hud_fps.tooltip": "Rame wat in die laaste sekonde geteken is.",
"horizon.pvp.hud_fps_corner": "FPS-hoek",
"horizon.pvp.hud_fps_corner.tooltip": "Waar die FPS wys.",
"horizon.pvp.hud_cps": "Klikke per sekonde",
"horizon.pvp.hud_cps.tooltip": "Jou linker- en regterklikke in die laaste sekonde.",
"horizon.pvp.hud_cps_corner": "CPS-hoek",
"horizon.pvp.hud_cps_corner.tooltip": "Waar die klikke per sekonde wys.",
"horizon.pvp.hud_keys": "Toetsaanslae",
"horizon.pvp.hud_keys.tooltip": "Jou bewegingsleutels, muisknoppies en spring, verlig terwyl jy hulle hou, met klikke per sekonde.",
"horizon.pvp.hud_keys_corner": "Toetsaanslaghoek",
"horizon.pvp.hud_keys_corner.tooltip": "Waar die toetsaanslae wys.",
"horizon.pvp.hud_clock": "Horlosie",
"horizon.pvp.hud_clock.tooltip": "Jou rekenaar se tyd.",
"horizon.pvp.hud_clock_corner": "Horlosiehoek",
"horizon.pvp.hud_clock_corner.tooltip": "Waar die horlosie wys.",
"horizon.pvp.hud_reach": "Bereik",
"horizon.pvp.hud_reach.tooltip": "Hoe ver van jou oë jou laaste treffer geland het, vir 3 sekondes. Dit meet net: jou bereik bly vanielje s'n.",
"horizon.pvp.hud_reach_corner": "Bereikhoek",
"horizon.pvp.hud_reach_corner.tooltip": "Waar die bereik wys.",
"horizon.pvp.hud_speed": "Spoed",
"horizon.pvp.hud_speed.tooltip": "Hoe vinnig jy oor die grond beweeg, in blokke per sekonde.",
"horizon.pvp.hud_speed_corner": "Spoedhoek",
"horizon.pvp.hud_speed_corner.tooltip": "Waar die spoed wys.",
"horizon.pvp.hud.fps": "%s FPS",
"horizon.pvp.hud.cps": "CPS: %s | %s",
"horizon.pvp.hud.reach": "Bereik: %s",
"horizon.pvp.hud.speed": "Spoed: %s b/s",
"horizon.pvp.hud.keys.attack": "LMB",
"horizon.pvp.hud.keys.use": "RMB",
"horizon.pvp.hud.keys.cps": "%s CPS",
"horizon.pvp.numeric_enchants": "Betoweringsvlakke as getalle",
"horizon.pvp.numeric_enchants.tooltip": "Nutswenke en die betoweringstafel wys Sharpness 5 in plaas van Sharpness V.",
"horizon.pvp.header.items": "Items",
"horizon.pvp.fire_lower": "Laer vuur",
"horizon.pvp.fire_lower.tooltip": "Skuif die vlamme wat jy sien terwyl jy brand, af sodat hulle minder van die skerm bedek.",
"horizon.pvp.shield_size": "Skildgrootte",
"horizon.pvp.shield_size.tooltip": "Die grootte van die skild in jou hand, in eerstepersoonsaansig.",
"horizon.pvp.totem_size": "Totem-animasiegrootte",
"horizon.pvp.totem_size.tooltip": "Die grootte van die totem wat die skerm vul wanneer dit jou red.",
"horizon.pvp.glint_colour": "Betoweringsglans-kleur",
"horizon.pvp.glint_colour.tooltip": "Die kleur van die glans op betowerde items en wapenrusting. Af is vanielje se pers.",
"horizon.pvp.clear_water": "Heldere water",
"horizon.pvp.clear_water.tooltip": "Onder water, sien so ver soos vanielje jou toelaat ná 30 sekondes daar, van die begin af.",
"horizon.pvp.clear_lava": "Heldere lawa",
"horizon.pvp.clear_lava.tooltip": "In lawa, sien so ver soos vanielje se Fire Resistance jou toelaat, sonder die effek.",
"horizon.pvp.header.visual": "Visuele aanpassings",
"horizon.pvp.view_time": "Tyd van die dag",
"horizon.pvp.view_time.tooltip": "Die tyd van die dag wat jy sien: net die lug en sy lig. Die wêreld se tyd gaan aan en niks word na die bediener gestuur nie.",
"horizon.pvp.view_weather": "Weer",
"horizon.pvp.view_weather.tooltip": "Die weer wat jy sien: net reën, sy lig en mis. Die wêreld se weer gaan aan en niks word na die bediener gestuur nie.",
"horizon.pvp.header.view": "Jou uitsig",
"horizon.pvp.view.world": "Wêreld s'n",
"horizon.pvp.time.morning": "Oggend",
"horizon.pvp.time.noon": "Middag",
"horizon.pvp.time.sunset": "Sonsondergang",
"horizon.pvp.time.midnight": "Middernag",
"horizon.pvp.weather.clear": "Helder",
"horizon.pvp.weather.rain": "Reën",
"horizon.pvp.weather.thunder": "Donderweer",
"horizon.chat.timestamp": "[%s] ",
"horizon.chat.repeats": " (x%s)",
"horizon.pvp.header.chat": "Gesels",
"horizon.chat.copied": "Boodskap gekopieer",
"horizon.chat.search": "Soek in gesels:",
"horizon.pvp.chat_timestamps": "Tydstempels",
"horizon.pvp.chat_timestamps.tooltip": "Die tyd toe elke boodskap ingekom het, voor dit.",
"horizon.pvp.chat_compact": "Kompakte gesels",
"horizon.pvp.chat_compact.tooltip": "'n Boodskap wat in 'n ry herhaal word, wys een keer, met hoe gereeld dit gekom het: (x3).",
"horizon.pvp.chat_copy": "Kopieer op klik",
"horizon.pvp.chat_copy.tooltip": "Om op 'n boodskap in die oop gesels te klik, kopieer sy teks, tensy die klik iets anders doen.",
"horizon.pvp.chat_search": "Soek in gesels",
"horizon.pvp.chat_search.tooltip": "Ctrl+F in die oop gesels: wat jy tik wys net die boodskappe wat dit bevat. Enter of Ctrl+F weer stop.",
"horizon.pvp.preset": "PvP-voorafinstelling",
"horizon.pvp.preset.tooltip": "Skakel die pasgemaakte visier, die wapenrusting- en effekte-widgets, wissel-hardloop en -sluip, laer vuur, en die FPS-teller en toetsaanslae-mods aan as dit geïnstalleer is. Jou instellings vir elkeen bly.",
"horizon.pvp.hud_editor_key": "HUD-redigeerder-sleutel",
"horizon.pvp.hud_editor_key.tooltip": "Maak die HUD-redigeerder vanuit die speletjie oop. Klik, druk dan 'n sleutel; Escape ontbind dit.",
"horizon.pvp.editor": "Redigeer HUD",
"horizon.pvp.editor.tooltip": "Skuif die widgets op die speletjie: sleep hulle enige plek, verander hul grootte met die wiel, regs-klik een om dit terug in sy hoek te sit.",
"horizon.pvp.editor.hint": "Sleep widgets · Wiel: grootte · Regs-klik: terug na sy hoek",
"horizon.pvp.combat_feel": "Strydgevoel",
"horizon.pvp.combat_feel.tooltip": "Legacy teken gevegte soos 1.8.9 dit gedoen het: die item in jou hand, sy swaai, eet, die boog en 'n swaard wat opgehou word terwyl jy gebruik hou, sonder die dip ná elke treffer of die aanvalaanwyser. Net die voorkoms: wat jy na 'n bediener stuur bly dieselfde.",
"horizon.pvp.combat_indicator": "Aanvalaanwyser in Legacy",
"horizon.pvp.combat_indicator.tooltip": "Hou die aanvalaanwyser onder Legacy, wat 1.8.9 nie gehad het nie.",
"horizon.pvp.combat_feel.modern": "Modern",
"horizon.pvp.combat_feel.legacy": "Legacy (1.8.9)",
"horizon.pvp.header.combat": "Strydgevoel",
"horizon.pvp.combat_sounds": "1.8-trefferklanke",
"horizon.pvp.combat_sounds.tooltip": "Onder Legacy, laat die aanvalklanke uit wat 1.9 bygevoeg het (sterk, swak, sweep, krities...). Seerklanke bly.",
"horizon.pvp.combat_rules": "1.8-stryd in wêrelde wat jy aanbied",
"horizon.pvp.combat_rules.tooltip": "Enkelspeler en die wêrelde wat jy op LAN oopmaak, veg volgens 1.8.9 se reëls: geen aanvalafkoeling of sweep nie, 1.8-wapenskade, Strength, Sharpness en kritiese treffers. Bedieners waarby jy aansluit, hou hul eie reëls.",
"Bindings": "Bindings",
"Play": "Speel",
"Device": "Toestel",
"Look": "Kyk",
"Movement": "Beweging",
"Menus": "Spyskaarte",
"Actions": "Aksies",
"Reset": "Stel terug",
"Detected": "Bespeur",
"Glyphs": "Glyphs",
"Feedback": "Terugvoer",
"Controller": "Beheerder",
"How the sticks turn and move you, and how menus repeat and scroll.": "Hoe die stokke jou draai en beweeg, en hoe spyskaarte herhaal en rol.",
"Which button does what. Each player has their own.": "Watter knoppie wat doen. Elke speler het sy eie.",
"The controller the game sees, its button glyphs and its feedback.": "Die beheerder wat die speletjie sien, sy knoppie-glyphs en sy terugvoer.",
"Press this row, then the button that should do it.": "Druk hierdie ry, dan die knoppie wat dit moet doen.",
"Off, the game ignores every controller for this player.": "Af, die speletjie ignoreer elke beheerder vir hierdie speler.",
"How fast the right stick turns the view.": "Hoe vinnig die regterstok die uitsig draai.",
"Turn the view the other way, left and right.": "Draai die uitsig die ander kant toe, links en regs.",
"Turn the view the other way, up and down.": "Draai die uitsig die ander kant toe, op en af.",
"How far the right stick moves before the view turns. Raise it if the view drifts.": "Hoe ver die regterstok beweeg voordat die uitsig draai. Verhoog dit as die uitsig dryf.",
"Hold the button to sneak, or press it once to start and again to stop.": "Hou die knoppie om te sluip, of druk dit een keer om te begin en weer om te stop.",
"How far the left stick moves before you walk. Raise it if you drift.": "Hoe ver die linkerstok beweeg voordat jy stap. Verhoog dit as jy dryf.",
"How far a trigger is pulled before it counts as pressed.": "Hoe ver 'n sneller getrek word voordat dit as ingedruk tel.",
"Shows what your buttons do right now, in play and in inventories.": "Wys wat jou knoppies nou doen, in die spel en in voorraadskerms.",
"Shows only what you can do with what you're looking at, and the main inventory buttons.": "Wys net wat jy met dit waarna jy kyk kan doen, en die hoof-voorraadsknoppies.",
"Doesn't show what your buttons do.": "Wys nie wat jou knoppies doen nie.",
"Shows the buttons of the controller you're using.": "Wys die knoppies van die beheerder wat jy gebruik.",
"Always shows Xbox buttons.": "Wys altyd Xbox-knoppies.",
"Always shows PlayStation buttons.": "Wys altyd PlayStation-knoppies.",
"Always shows Nintendo buttons.": "Wys altyd Nintendo-knoppies.",
"A, on the right, confirms and jumps and B goes back, as on a Switch.": "A, regs, bevestig en spring en B gaan terug, soos op 'n Switch.",
"B, at the bottom, confirms and jumps and A goes back, as on other controllers.": "B, onder, bevestig en spring en A gaan terug, soos op ander beheerders.",
"Opens Steam's keyboard on a Steam Deck under Steam, and Horizon's otherwise.": "Maak Steam se sleutelbord oop op 'n Steam Deck onder Steam, en anders Horizon s'n.",
"Always opens Horizon's keyboard.": "Maak altyd Horizon se sleutelbord oop.",
"Always opens Steam's keyboard.": "Maak altyd Steam se sleutelbord oop.",
"A DualSense's light shows your health, from green through amber to red.": "'n DualSense se lig wys jou gesondheid, van groen deur amber na rooi.",
"A DualSense's light changes with the dimension you're in.": "'n DualSense se lig verander met die dimensie waarin jy is.",
"A DualSense keeps its own light.": "'n DualSense hou sy eie lig.",
"Lets LT + A craft on servers other than your own worlds. Some servers count it as a macro.": "Laat LT + A op bedieners anders as jou eie wêrelde vervaardig. Sommige bedieners reken dit as 'n makro.",
"Show the buttons for what you can do, while you play with a controller.": "Wys die knoppies vir wat jy kan doen, terwyl jy met 'n beheerder speel.",
"How fast the stick moves the crosshair in inventories.": "Hoe vinnig die stok die visier in voorraadskerms beweeg.",
"How fast the right stick scrolls a list.": "Hoe vinnig die regterstok deur 'n lys rol.",
"How long a held direction waits before it repeats.": "Hoe lank 'n gehoue rigting wag voordat dit herhaal.",
"How long a held direction takes between repeats.": "Hoe lank 'n gehoue rigting tussen herhalings neem.",
"Which on-screen keyboard opens when a text field is pressed.": "Watter sleutelbord op die skerm oopmaak wanneer 'n teksveld gedruk word.",
"Every action back on its default button.": "Elke aksie terug op sy verstekknoppie.",
"These settings belong to this player's profile.": "Hierdie instellings behoort aan hierdie speler se profiel.",
"The controller driving this player, or else the first one connected.": "Die beheerder wat hierdie speler bestuur, of anders die eerste een wat gekoppel is.",
"The USB vendor and product the system reports. Useful when reporting a controller that misbehaves.": "Die USB-vervaardiger en -produk wat die stelsel rapporteer. Nuttig wanneer jy 'n beheerder rapporteer wat hom misdra.",
"The glyph family the game picked from the controller's maker.": "Die glyph-familie wat die speletjie uit die beheerder se maker gekies het.",
"Follow the controller, or always show one family's buttons.": "Volg die beheerder, of wys altyd een familie se knoppies.",
"Whether a Nintendo controller's A and B work by where they sit or by their letters.": "Of 'n Nintendo-beheerder se A en B volgens waar hulle sit of volgens hul letters werk.",
"The buttons menus show right now.": "Die knoppies wat spyskaarte nou wys.",
"Rumble when something happens to you.": "Vibreer wanneer iets met jou gebeur.",
"The controller keeps playing while this window isn't focused, and losing focus won't pause.": "Die beheerder hou aan speel terwyl hierdie venster nie gefokus is nie, en fokusverlies sal nie pouseer nie.",
"A DualSense's triggers push back on bows and tools.": "'n DualSense se snellers druk terug op boë en gereedskap.",
"What a DualSense's light shows, over USB or Bluetooth.": "Wat 'n DualSense se lig wys, oor USB of Bluetooth.",
"Player profile": "Spelerprofiel",
"Vendor and product": "Vervaardiger en produk",
"Detected glyphs": "Bespeurde glyphs",
"Glyphs in use": "Glyphs in gebruik",
"Unknown": "Onbekend",
"No controller": "Geen beheerder",
"Editing: %s": "Redigeer: %s",
"Press the button for this. %s cancels.": "Druk die knoppie hiervoor. %s kanselleer.",
"This button also does: %s": "Hierdie knoppie doen ook: %s",
"Not bound: press this row, then the button that should do it.": "Nie gebonde nie: druk hierdie ry, dan die knoppie wat dit moet doen.",
"horizon.pad.notice.connected.press": "Druk om dit te gebruik",
"horizon.recipe_transfer.missing": "Ontbrekende items",
"horizon.recipe_transfer.no_room": "Voorraad vol",
"horizon.recipe_transfer.unavailable": "Resep pas nie in hierdie spyskaart nie",
"horizon.recipe_transfer.unsupported": "Bediener vereis 'n bekende resep",
"horizon.studio.timeline.freecam_on": "Vrye kamera: AAN",
"horizon.studio.timeline.freecam_off": "Vrye kamera: AF",
"horizon.studio.timeline.freecam_short": "Kam",
"horizon.studio.timeline.freecam_active_short": "Kam ✓",
"horizon.studio.timeline.add_keyframe_short": "+ Sleutel",
"horizon.studio.timeline.delete_keyframe_short": "− Verwyder",
"horizon.studio.timeline.split": "Splits",
"horizon.studio.timeline.marker": "Merkpunt %s",
"horizon.studio.timeline.seconds": "%s s",
"horizon.studio.timeline.scene_count": "%s tonele",
"horizon.studio.timeline.scene_title": "Toneel %s",
"horizon.studio.timeline.sequence": "Sekwens",
"horizon.studio.topbar.title": "✦ Studio",
"horizon.studio.topbar.file": "Lêer",
"horizon.studio.topbar.camera": "Kamera",
"horizon.studio.topbar.view": "Uitsig",
"horizon.studio.topbar.file_short": "Lêer",
"horizon.studio.topbar.camera_short": "Kam",
"horizon.studio.topbar.view_short": "Uitsig",
"horizon.studio.topbar.game_pause_menu": "⏸ Pousemenu",
"horizon.studio.topbar.pause_short": "Pouse",
"horizon.studio.topbar.open_recordings": "📁 Maak opnames-lêergids oop",
"horizon.studio.topbar.close_replay": "✕ Maak herhaling toe",
"horizon.studio.topbar.exit": "Verlaat",
"horizon.studio.topbar.toggle_freecam": "Wissel vrye kamera",
"horizon.studio.topbar.reset_to_player": "👤 Herstel na speler",
"horizon.studio.topbar.unlock_freecam": "🔓 Ontsluit vrye kamera",
"horizon.studio.topbar.lock_to_spline": "🔒 Vast aan spline",
"horizon.studio.topbar.add_keyframe": "➕ Voeg sleutelraam by",
"horizon.studio.topbar.add_marker": "Voeg merkpunt by",
"horizon.studio.topbar.clear_keyframes": "🗑 Maak sleutelrame skoon",
"horizon.studio.topbar.timeline_hud": "⏱ Tydlyn",
"horizon.studio.topbar.split_scene": "Splits toneel"
}
@@ -1,14 +1,74 @@
{
"horizon.extras.header.world": "العالم",
"horizon.extras.pregen": "التوليد المسبق للقطع",
"horizon.extras.pregen.tooltip": "يفعّل /pregen في عوالم اللاعب الفردي وشبكة LAN. مع تفعيل أوامر الغش، استخدم /pregen start لتوليد منطقة قبل استكشافها. لا يفعّل الأمر في الخوادم التي تنضم إليها.",
"horizon.options.controller": "المراقب المالي",
"horizon.options.three_d_skin_layers": "طبقات الجلد ثلاثية الأبعاد",
"horizon.options.vibration": "الاهتزاز",
"horizon.options.play_in_background": "اللعب في الخلفية",
"horizon.options.adaptive_triggers": "المشغلات التكيفية",
"horizon.options.gameplay_guide": "دليل اللعب",
"horizon.options.gameplay_guide.basic": "أساسي",
"horizon.options.gameplay_guide.full": "كامل",
"horizon.options.gameplay_guide.off": "إيقاف",
"horizon.controller.guide.use": "استخدام",
"horizon.controller.guide.open": "فتح",
"horizon.controller.guide.ride": "الركوب",
"horizon.controller.guide.trade": "المتاجرة",
"horizon.controller.guide.feed": "إطعام",
"horizon.controller.guide.place": "وضع",
"horizon.controller.guide.eat": "أكل",
"horizon.controller.guide.drink": "شرب",
"horizon.controller.guide.shoot": "إطلاق",
"horizon.controller.guide.load": "تحميل",
"horizon.controller.guide.throw": "رمي",
"horizon.controller.guide.raise": "رفع",
"horizon.controller.guide.equip": "تجهيز",
"horizon.controller.guide.fish": "صيد السمك",
"horizon.controller.guide.zoom": "تكبير",
"horizon.controller.guide.boost": "تعزيز",
"horizon.controller.guide.break": "كسر",
"horizon.controller.guide.attack": "هجوم",
"horizon.controller.guide.spectate": "مشاهدة",
"horizon.controller.guide.spectator_menu": "قائمة المشاهدة",
"horizon.controller.guide.release_shoot": "أفلت للإطلاق",
"horizon.controller.guide.release_throw": "أفلت للرمي",
"horizon.controller.guide.release_stop": "أفلت للتوقف",
"horizon.controller.guide.fly": "طيران",
"horizon.controller.guide.fly_up": "الطيران لأعلى",
"horizon.controller.guide.fly_down": "الطيران لأسفل",
"horizon.controller.guide.swim_up": "السباحة لأعلى",
"horizon.controller.guide.swim_down": "السباحة لأسفل",
"horizon.controller.guide.glide": "انزلاق",
"horizon.controller.guide.dismount": "النزول",
"horizon.controller.guide.stand": "الوقوف",
"horizon.controller.guide.drop": "إسقاط العنصر",
"horizon.controller.guide.swap": "تبديل اليدين",
"horizon.controller.guide.take": "أخذ",
"horizon.controller.guide.take_half": "أخذ النصف",
"horizon.controller.guide.put": "إنزال",
"horizon.controller.guide.put_one": "وضع واحد",
"horizon.controller.guide.swap_stack": "تبديل",
"horizon.controller.guide.quick_move": "نقل سريع",
"horizon.controller.guide.drop_stack": "إسقاط الكومة",
"horizon.controller.guide.place_recipe": "وضع الوصفة",
"horizon.controller.guide.place_recipe_all": "وضع الكل",
"horizon.controller.guide.alternatives": "بدائل",
"horizon.controller.guide.select": "اختيار",
"horizon.controller.guide.close": "إغلاق",
"horizon.controller.guide.pages": "الصفحات",
"horizon.controller.guide.categories": "الفئات",
"horizon.controller.guide.tabs": "علامات التبويب",
"horizon.options.lightbar": "الشريط الضوئي",
"horizon.options.look_sensitivity": "حساسية النظر",
"horizon.options.invert_x": "عكس محور النظر X",
"horizon.options.invert_y": "عكس محور النظر Y",
"horizon.options.craft_on_servers": "الصناعة السريعة على الخوادم",
"horizon.controller.direct_craft": "صناعة سريعة",
"horizon.options.sneak_button": "زر التسلل",
"horizon.options.move_deadzone": "المنطقة الميتة للحركة",
"horizon.options.look_deadzone": "المنطقة الميتة للنظر",
"horizon.options.trigger_threshold": "حد المشغّل",
"horizon.options.button_glyphs": "الحروف الرسومية للأزرار",
"horizon.options.on_screen_keyboard": "لوحة المفاتيح على الشاشة",
"horizon.options.chat_delay": "تأخير الدردشة",
@@ -18,10 +78,52 @@
"horizon.options.glyphs.xbox": "اكس بوكس",
"horizon.options.glyphs.playstation": "بلاي ستيشن",
"horizon.options.glyphs.nintendo": "نينتندو",
"horizon.options.nintendo_buttons": "أزرار نينتندو",
"horizon.options.nintendo_buttons.position": "حسب الموضع",
"horizon.options.nintendo_buttons.letter": "حسب الحرف",
"horizon.options.keyboard.auto": "تلقائي",
"horizon.options.keyboard.horizon": "Horizon",
"horizon.options.keyboard.steam": "البخار",
"horizon.options.unavailable": "هذه الميزة غير متوفرة بعد في Horizon.",
"horizon.options.search": "إعدادات البحث...",
"horizon.studio.record.usage": "الاستخدام: /studio record <start [name]|stop|status>",
"horizon.studio.record.started": "بدأ تسجيل الاستوديو: %s",
"horizon.studio.record.saved": "تم حفظ تسجيل الاستوديو: %s",
"horizon.studio.record.error": "خطأ في تسجيل الاستوديو: %s",
"horizon.studio.record.status.active": "تسجيل الاستوديو نشط — %s ث، %s حزمة، %s KB (%s)",
"horizon.studio.record.status.inactive": "تسجيل الاستوديو غير نشط",
"horizon.studio.replay.usage": "الاستخدام: /studio replay <open PATH|pause|resume|speed MULTIPLIER|seek MS|status|close>",
"horizon.studio.replay.open.usage": "الاستخدام: /studio replay open <PATH>",
"horizon.studio.replay.opened": "تم فتح إعادة تشغيل الاستوديو: %s (%s ث، %s حزمة)",
"horizon.studio.replay.open_error": "تعذّر فتح إعادة التشغيل: %s",
"horizon.studio.replay.paused": "تم إيقاف إعادة تشغيل الاستوديو مؤقتًا",
"horizon.studio.replay.resumed": "تم استئناف إعادة تشغيل الاستوديو",
"horizon.studio.replay.inactive": "لا توجد إعادة تشغيل استوديو نشطة",
"horizon.studio.replay.speed.usage": "الاستخدام: /studio replay speed <MULTIPLIER>",
"horizon.studio.replay.speed.set": "ضُبطت سرعة إعادة تشغيل الاستوديو على %s×",
"horizon.studio.replay.speed.invalid": "سرعة إعادة تشغيل غير صالحة",
"horizon.studio.replay.seek.usage": "الاستخدام: /studio replay seek <MILLISECONDS>",
"horizon.studio.replay.seeked": "تم الانتقال في إعادة تشغيل الاستوديو إلى %s مللي ثانية (%s ث)",
"horizon.studio.replay.seek.error": "تعذّر الانتقال في إعادة التشغيل: %s",
"horizon.studio.replay.seek.backward_requires_standalone": "الرجوع للخلف متاح فقط لإعادة تشغيل مستقلة.",
"horizon.studio.replay.seek.invalid": "موضع إعادة تشغيل غير صالح",
"horizon.studio.replay.status": "إعادة تشغيل الاستوديو: %s / %s مللي ثانية (%s%%)، السرعة %s×، متوقفة مؤقتًا %s، منتهية %s",
"horizon.studio.replay.closed": "تم إغلاق إعادة تشغيل الاستوديو",
"horizon.studio.freecam.enabled": "تم تفعيل الكاميرا الحرة للاستوديو. استخدم WASD للحركة، والمسافة وShift للصعود أو الهبوط، وControl للحركة أسرع.",
"horizon.studio.freecam.disabled": "تم إيقاف الكاميرا الحرة للاستوديو",
"horizon.studio.freecam.unavailable": "تعذّر على الكاميرا الحرة للاستوديو العثور على كاميرا اللاعب",
"horizon.studio.freecam.replay_required": "الكاميرا الحرة للاستوديو متاحة أثناء تشغيل إعادة التشغيل",
"horizon.studio.freecam.usage": "الاستخدام: /studio freecam <on|off>",
"horizon.studio.keyframe.added": "أُضيف مفتاح إطار كاميرا عند %s مللي ثانية. يُحفظ المسار عند إغلاق إعادة التشغيل.",
"horizon.studio.keyframe.cleared": "تم مسح مسار الكاميرا. يُحفظ التغيير عند إغلاق إعادة التشغيل.",
"horizon.studio.keyframe.removed": "أُزيل مفتاح إطار الكاميرا %s. يُحفظ التغيير عند إغلاق إعادة التشغيل.",
"horizon.studio.keyframe.save_error": "تعذّر حفظ مسار الكاميرا: %s",
"horizon.studio.keyframe.remove_usage": "الاستخدام: /studio keyframe remove <INDEX>",
"horizon.studio.keyframe.index_invalid": "فهرس مفتاح إطار الكاميرا خارج النطاق",
"horizon.studio.keyframe.usage": "الاستخدام: /studio keyframe <add|remove INDEX|clear>",
"horizon.studio.path.locked": "تم تفعيل تشغيل مسار كاميرا الاستوديو",
"horizon.studio.path.unlocked": "تم إيقاف تشغيل مسار كاميرا الاستوديو",
"horizon.studio.path.usage": "الاستخدام: /studio path <lock|unlock> (أضف مفتاح إطار واحدًا على الأقل أولًا)",
"horizon.pages.mods": "تعديل",
"horizon.pages.shader_packs": "حزم شادر",
"horizon.pages.shader_options": "خيارات التظليل",
@@ -29,6 +131,7 @@
"horizon.pages.controller_binds": "ربط وحدة التحكم",
"horizon.pages.extras": "إضافات Horizon",
"horizon.pages.settings": "إعدادات Horizon",
"horizon.pages.studio": "استوديو إعادة التشغيل",
"horizon.pages.agent": "وكيل منظمة العفو الدولية",
"horizon.split.leave": "غادر",
"horizon.pages.split_settings": "إعدادات تقسيم الشاشة",
@@ -49,6 +152,7 @@
"horizon.pages.music.empty": "لا توجد مقطوعات موسيقية: لا يتم تحميل أصوات اللعبة.",
"horizon.pages.music.tooltip": "المسارات المضمنة، حسب الموقف: العب واحدة الآن، أو قم بإيقاف تشغيل أي منها حتى تتوقف اللعبة عن اختيارها.",
"horizon.pages.settings.tooltip": "إعدادات Horizon الخاصة: الرسومات ووحدات التحكم والإضافات والتعديلات.",
"horizon.pages.studio.tooltip": "تصفّح التسجيلات المحفوظة وبياناتها الوصفية.",
"horizon.pages.graphics.tooltip": "الرسومات ببساطة: كل إعداد يوضح تكلفته.",
"horizon.pages.graphics_advanced.tooltip": "كل إعدادات الرسومات، بما في ذلك Horizon، وتكلفة كل منها.",
"horizon.pages.controller.tooltip": "وحدات التحكم: الأزرار والعصي والاهتزاز والتنقل في القائمة.",
@@ -69,6 +173,14 @@
"horizon.pages.open.horizon_graphics": "رسومات Horizon...",
"horizon.pages.open.controller_binds": "ربط وحدة التحكم...",
"horizon.pages.open.horizon_settings": "إعدادات Horizon...",
"horizon.pages.open.studio": "استوديو إعادة التشغيل...",
"horizon.studio.saved_recordings": "التسجيلات المحفوظة",
"horizon.studio.empty": "لم يتم العثور على تسجيلات في مجلد التسجيلات.",
"horizon.studio.empty_hint": "استخدم /studio record start <name> في عالم، أو ألقِ ملفات .mcpr هناك.",
"horizon.studio.open_folder": "فتح التسجيلات",
"horizon.studio.open_replay": "تشغيل إعادة التشغيل",
"horizon.studio.refresh": "تحديث",
"horizon.studio.unknown_server": "غير معروف",
"horizon.pages.open.music": "موسيقى...",
"horizon.mods.empty": "لا توجد تعديلات: ضع ملفات .hmod في مجلد Horizon-mods",
"horizon.mods.stopped": " (متوقف)",
@@ -77,6 +189,28 @@
"horizon.mods.settings": "الإعدادات",
"horizon.mods.in_world": " (اترك العالم ليتغير)",
"horizon.mods.open_folder": "افتح مجلد التعديل",
"horizon.mods.browse": "تصفح التعديلات...",
"horizon.mods.browse.title": "تصفح التعديلات",
"horizon.mods.browse.search": "بحث عن التعديلات...",
"horizon.mods.browse.project": "تعديل",
"horizon.mods.browse.loading": "قراءة سجل تعديلات Horizon...",
"horizon.mods.browse.failed": "تعذّرت قراءة سجل التعديلات: %s",
"horizon.mods.browse.none": "لم يُعثر على أي تعديل",
"horizon.mods.browse.has_update": " (تحديث)",
"horizon.mods.browse.is_installed": " (مثبّت)",
"horizon.mods.browse.by": "بواسطة %s",
"horizon.mods.browse.installing": "جارٍ التثبيت...",
"horizon.mods.browse.install": "تثبيت",
"horizon.mods.browse.update": "تحديث",
"horizon.mods.browse.installed": "مثبّت",
"horizon.mods.browse.open_page": "فتح الصفحة",
"horizon.mods.browse.enabled": "تعديل",
"horizon.mods.browse.changelog": "سجل التغييرات",
"horizon.mods.browse.done": "تم تثبيت %s %s وهو مفعّل.",
"horizon.mods.browse.install_failed": "تعذّر تثبيته: %s",
"horizon.mods.switch.worldgen": "يغيّر توليد العالم",
"horizon.mods.switch.registries": "يضيف كتلًا أو عناصر أو مدخلات سجل أخرى",
"horizon.mods.switch.restart": "أعد تشغيل اللعبة لتطبيق %s: فهي تضيف كتلًا أو عناصر أو توليد عالم.",
"horizon.shader_options.profile": "الملف الشخصي",
"horizon.agent.copy_details": "نسخ تفاصيل الاتصال",
"horizon.multiplayer.join": "انضم",
@@ -88,6 +222,7 @@
"LOD Distance": "مسافة اللد",
"Far Detail": "تفاصيل بعيدة",
"Cave Culling": "إعدام الكهف",
"GPU Culling": "استبعاد GPU",
"Entity Culling": "إعدام الكيان",
"Chunk Updates": "تحديثات القطعة",
"Upload Budget": "تحميل الميزانية",
@@ -101,10 +236,15 @@
"Resolution Scale": "مقياس القرار",
"Anti-aliasing": "مكافحة التعرج",
"Upscaling": "الارتقاء",
"Upscaling Sharpness": "حدة الارتقاء",
"Paper White": "ورقة بيضاء",
"Peak Brightness": "ذروة السطوع",
"Black Level": "المستوى الأسود",
"UI Brightness": "سطوع واجهة المستخدم",
"HDR Lighting": "إضاءة HDR",
"Light sources, the sun and sunlight shine past white. Off: just as SDR.": "تتجاوز مصادر الضوء والشمس وضوء الشمس اللون الأبيض. إيقاف: كما في SDR فقط.",
"Smooth Gradients": "تدرجات ناعمة",
"16-bit frame: dark skies and shadows don't band. A bit more VRAM. Next start.": "إطار 16 بت: لا تظهر نطاقات في السماوات والظلال الداكنة. ذاكرة VRAM أكثر قليلًا. عند البدء التالي.",
"Battery Mode": "وضع البطارية",
"Graphics API": "واجهة برمجة تطبيقات الرسومات",
"Translucent Batching": "الخلط الشفاف",
@@ -148,6 +288,10 @@
"Your display or system doesn't offer HDR output.": "لا توفر شاشتك أو نظامك مخرجات HDR.",
"Turn HDR on to use it.": "قم بتشغيل HDR لاستخدامه.",
"Your GPU or graphics API doesn't support it.": "وحدة معالجة الرسومات (GPU) أو واجهة برمجة تطبيقات الرسومات الخاصة بك لا تدعمها.",
"Not with software rendering, which runs it on the CPU.": "غير متاح مع العرض البرمجي، الذي ينفّذه على CPU.",
"Out of video memory: off until the next start.": "نفدت ذاكرة الفيديو: إيقاف حتى البدء التالي.",
"The graphics device was lost: off until the next start.": "فُقد جهاز الرسومات: إيقاف حتى البدء التالي.",
"Not on DirectX 12 yet: try the Vulkan graphics API.": "غير متاح على DirectX 12 بعد: جرّب واجهة برمجة تطبيقات الرسومات Vulkan.",
"%s chunks": "%s قطع",
"%s per frame": "%s لكل إطار",
"%s MiB/frame": "%s MiB/الإطار",
@@ -163,13 +307,15 @@
"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 قليلاً.",
"Lifts shadows onto your display's darkest black. 0: true black, no lift.": "يرفع الظلال إلى أغمق أسود في شاشتك. 0: أسود حقيقي، دون رفع.",
"How bright highlights get. Auto: the peak your system reports.": "كيف تحصل على النقاط البارزة الساطعة. تلقائي: ذروة تقارير النظام الخاص بك.",
"How bright white paper and menus look. Auto: your desktop's white.": "كيف تبدو الورق والقوائم البيضاء الزاهية. تلقائي: سطح المكتب الخاص بك باللون الأبيض.",
"How bright white looks in the world. 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.": "إلى أي مدى يستمر الغوغاء والمحاصيل والآلات في العمل. وقت وحدة المعالجة المركزية.",
"How far away mobs and items are drawn, as a share of normal.": "إلى أي مدى يتم رسم الغوغاء والعناصر، كنسبة من المعدل الطبيعي.",
"Skips terrain hidden behind closed sections. Off costs a lot.": "يتخطى التضاريس المخفية خلف الأقسام المغلقة. قبالة تكاليف الكثير.",
"Culls nearby terrain on the GPU: less CPU at high render distance, more GPU.": "يستبعد التضاريس القريبة على GPU: وقت CPU أقل عند مسافة عرض كبيرة، وGPU أكثر.",
"Auto: tries it where your CPU limits the frame, keeps it if frames get faster.": "تلقائي: يجرّبه حيث يحدّ CPU من الإطار، ويبقيه إذا أصبحت الإطارات أسرع.",
"Hides entities behind opaque blocks.": "يخفي الكيانات خلف الكتل غير الشفافة.",
"No entity occlusion culling.": "لا يوجد إعدام انسداد الكيان.",
"CPU occlusion test runs this frame.": "يقوم اختبار انسداد وحدة المعالجة المركزية بتشغيل هذا الإطار.",
@@ -193,6 +339,7 @@
"World pixels per screen pixel. Below 100% is faster on the GPU.": "بكسل العالم لكل بكسل الشاشة. أقل من 100% أسرع على وحدة معالجة الرسومات.",
"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. أسرع وأكثر ليونة.",
"Sharpens the upscaled picture. Higher: crisper edges, more shimmer.": "يزيد حدة الصورة المرتقاة. أعلى: حواف أوضح ووميض أكثر.",
"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.": "ينام بكفاءة حتى الإطار التالي؛ يدور بدقة، مثل الفانيليا.",
@@ -212,6 +359,7 @@
"Frame rate and frame times over the game, with a graph.": "معدل الإطارات وأوقات الإطار خلال اللعبة، مع رسم بياني.",
"horizon.multiplayer.finding_friend": "البحث عن عالم صديقك...",
"horizon.multiplayer.not_signed_in": "هذا الخادم في وضع الاتصال بالإنترنت. قم بتسجيل الدخول باستخدام حساب Microsoft الخاص بك في Horizon Launcher للانضمام إليه.",
"horizon.realms.not_signed_in": "سجّل الدخول بحساب Microsoft في مشغّل Horizon للعب على Realms.",
"horizon.multiplayer.invalid_address": "عنوان الخادم غير صالح: %s",
"horizon.multiplayer.connected_friend_direct": "تواصل مباشرة مع صديقك..",
"horizon.multiplayer.connected_friend_relay": "متصل بصديقك من خلال التتابع...",
@@ -241,6 +389,7 @@
"Brighter-than-white output on an HDR display.": "إخراج أكثر سطوعًا من اللون الأبيض على شاشة HDR.",
"Results for \"%s\"": "نتائج ل \"%s\"",
"No settings match.": "لا توجد إعدادات متطابقة.",
"horizon.plain.official": "Horizon الرسمي مع المشغّل والتعديلات والتحديثات: playhorizon.dev",
"horizon.title.not_affiliated": "لا ينتمي إلى Mojang أو Microsoft",
"horizon.extras.header.lighting": "الإضاءة",
"horizon.extras.header.third_person": "شخص ثالث",
@@ -264,9 +413,12 @@
"horizon.extras.no_ai": "لا يوجد ذكاء اصطناعي",
"horizon.extras.agent_control": "مراقبة الوكيل",
"horizon.extras.discord_presence": "وجود الخلاف الغني",
"horizon.extras.zoom": "تكبير",
"horizon.extras.zoom_mod_install": "التكبير: ثبّته من التعديلات",
"horizon.extras.splitscreen": "تقسيم الشاشة",
"horizon.extras.chat_animation": "دردشة سلسة",
"horizon.extras.chat_indicator": "مؤشر الرسالة",
"horizon.extras.wheel_cycles_list_settings.tooltip": "تغيّر عجلة التمرير فوق زر في قائمة الإعدادات قيمته. عند الإيقاف، كما في الفانيليا، تمرّر العجلة القائمة.",
"horizon.extras.wheel_cycles_settings": "عجلة التمرير تغير الإعدادات",
"horizon.extras.pack_browser": "متصفح حزمة الموارد",
"horizon.extras.fullbright.tooltip": "إظهار الكتل والكيانات في الضوء الكامل، حتى في المناطق المظلمة.",
@@ -327,6 +479,7 @@
"horizon.friends.stopped": "تم إيقاف مشاركة رمز الدعوة.",
"horizon.friends.short_code_changed": "استمر شخص ما في تجربة رمز قصير خاطئ، لذلك تغير.",
"horizon.friends.wrong_code": "حاول شخص ما رمزا خاطئا.",
"horizon.friends.relay_stopped": "رموز قصيرة خاطئة كثيرة، لذا أُوقف التتابع حتى تشارك مجددًا. يمكن للأصدقاء الانضمام بالرمز الكامل.",
"horizon.friends.code_ready": "رمز الدعوة الخاص بك جاهز: قائمة الألعاب، العب مع الأصدقاء. انسخها وأرسلها إلى أصدقائك.",
"horizon.friends.share_failed": "لا يمكن مشاركة العالم: %s",
"horizon.friends.finding_route": "التعرف على كيفية وصول الأصدقاء إليك...",
@@ -346,6 +499,7 @@
"horizon.splitscreen.setup.new_window": "نافذة جديدة",
"horizon.splitscreen.setup.fill_monitor": "املأ %s",
"horizon.splitscreen.setup.done": "تم",
"horizon.splitscreen.setup.switch": "%s: %s",
"horizon.splitscreen.setup.on": "على",
"horizon.splitscreen.setup.off": "إيقاف",
"horizon.splitscreen.setup.player": "اللاعب %s",
@@ -403,6 +557,9 @@
"horizon.splitscreen.setup.help.give_up_pad": "تتوقف وحدة التحكم الخاصة بك عن قيادتك ويُعرض عليك الانضمام (أ).",
"horizon.splitscreen.setup.note.held_at": "%s إلى %s: يسقطها A هنا، ويعيدها B.",
"horizon.splitscreen.setup.note.held": "%s تم الالتقاط: يسار/يمين يختار المكان، يسقط A، ويعيده B.",
"horizon.splitscreen.online_only": "هذا الخادم في وضع الاتصال: لا يمكن للملفات الشخصية غير المتصلة الانضمام إليه، وكل لاعب يحتاج حساب Minecraft خاصًا به.",
"horizon.splitscreen.own_account": "يحتاج هذا الخادم حساب Minecraft خاصًا بكل لاعب",
"horizon.splitscreen.local_only": "يمكن للاعبين الآخرين الانضمام إلى هذا الخادم فقط بحساب Minecraft خاص بهم: أما الملفات الشخصية غير المتصلة فتلعب في عالمك، على LAN أو على خادم في شبكتك المحلية.",
"Hides models behind opaque blocks. Async checks off-thread; entities tick.": "يخفي النماذج خلف الكتل المعتمة. غير متزامن يتحقق من خارج الموضوع؛ علامة الكيانات.",
"Block Entities": "حظر الكيانات",
"Shulker Boxes": "صناديق شوكر",
@@ -421,6 +578,8 @@
"Reuses statue geometry until its block state changes.": "يعيد استخدام هندسة التمثال حتى تتغير حالة الكتلة الخاصة به.",
"Leaf Culling": "إعدام الأوراق",
"Every leaf face. Vanilla's fancy leaves.": "وجه كل ورقة. أوراق الفانيليا الفاخرة.",
"Opaque Ice": "جليد معتم",
"Draws ice in the solid pass, without its see-through texture.": "يرسم الجليد في مرور الصلب، دون قوامه الشفاف.",
"Distant Plant Dither": "ثبات النبات البعيد",
"Keeps fine plants in high-res packs from fading far away.": "يحافظ على النباتات الجميلة في عبوات عالية الدقة من البهتان بعيدًا.",
"Screenshot Quality": "جودة لقطة الشاشة",
@@ -428,7 +587,18 @@
"Frame Generation": "جيل الإطار",
"Shows a made-up frame between each two real ones: smoother, a little more lag.": "يُظهر إطارًا مكوّنًا بين كل إطارين حقيقيين: أكثر سلاسة وتأخرًا قليلًا.",
"Shadow Cache": "ذاكرة التخزين المؤقت الظل",
"Shaders": "الشيدرات",
"Shader packs, Horizon's and shaders/ folder ones, and their options.": "حزم الشيدر، من Horizon ومن مجلد shaders/، وخياراتها.",
"Shader packs": "حزم الشيدر",
"Vanilla's own preset: sets only vanilla's options, as Minecraft does.": "الإعداد المسبق للفانيليا نفسها: يضبط خيارات الفانيليا فقط، كما يفعل Minecraft.",
"Fullscreen takes the display over, at the mode below.": "ملء الشاشة يستحوذ على الشاشة، بالوضع أدناه.",
"The display mode exclusive fullscreen uses. Applies when you leave.": "وضع الشاشة الذي يستخدمه ملء الشاشة الحصري. يُطبّق عند المغادرة.",
"Blurs the world behind menus.": "يضبّب العالم خلف القوائم.",
"How textures are sampled: sharper at a slant with Anisotropic.": "كيفية أخذ عينات القوام: أكثر حدة عند الميل مع Anisotropic.",
"How far Anisotropic filtering goes. Higher is sharper at a slant.": "مدى وصول تصفية Anisotropic. الأعلى أكثر حدة عند الميل.",
"Only with Anisotropic texture filtering.": "فقط مع تصفية القوام Anisotropic.",
"Shader Packs...": "حزم التظليل...",
"Picks a shader pack, Horizon's or a shaders/ folder one, and sets its options.": "يختار حزمة شيدر، من Horizon أو من مجلد shaders/، ويضبط خياراتها.",
"Reuses the pack shadow map when sun, view and world match. Off by default.": "يعيد استخدام خريطة ظل الحزمة عندما تتطابق الشمس والعرض والعالم. إيقاف بشكل افتراضي.",
"Find BlueMap": "ابحث عن بلو ماب",
"On servers, finds their BlueMap web map for far terrain. Next join.": "على الخوادم، يتم العثور على خريطة الويب BlueMap الخاصة بهم للتضاريس البعيدة. الانضمام التالي.",
@@ -436,6 +606,8 @@
"How far around you BlueMap tiles are downloaded. Next join.": "إلى أي مدى يتم تنزيل بلاط BlueMap من حولك. الانضمام التالي.",
"Synchronous": "متزامن",
"Asynchronous": "غير متزامن",
"Hidden Faces": "الأوجه المخفية",
"Drops leaf faces behind two leaf layers: faster, same crowns.": "يسقط أوجه الأوراق خلف طبقتَي أوراق: أسرع، والتِيجان نفسها.",
"Solid": "صلب",
"Solid Aggressive": "العدوانية الصلبة",
"Hollow": "جوفاء",
@@ -457,6 +629,10 @@
"Auto (%s nits)": "تلقائي (%s شمعة في المتر المربع)",
"%.2f nits": "%.2f القمل",
"Hides models behind opaque blocks. Entities update. Off with custom packs.": "يخفي النماذج خلف الكتل المعتمة. تحديث الكيانات مع حزم مخصصة.",
"Entity Culling Precheck": "فحص استبعاد الكيانات المسبق",
"Runs the mob AABB precheck after this many loaded mobs. Zero disables it.": "يشغّل فحص AABB للغوغاء بعد هذا العدد من الغوغاء المحمّلين. صفر يعطّله.",
"%s mobs": "%s غوغاء",
"Only applies to asynchronous entity culling.": "ينطبق فقط على استبعاد الكيانات غير المتزامن.",
"horizon.extras.repro_keys": "تسجيل الاختصارات",
"horizon.extras.repro_keys.tooltip": "يقوم F3+R ببدء التسجيل النسخي أو إيقافه ويقوم F3+E بكتابة الحزمة الخاصة به. متوقف بشكل افتراضي: تسجيل ينسخ العالم.",
"horizon.agent.record_session": "ابدأ جلسة التسجيل",
@@ -468,6 +644,17 @@
"horizon.repro.error": "استنساخ: %s",
"horizon.repro.no_recording": "لا يوجد تسجيل للتصدير حتى الآن.",
"horizon.repro.replay_started": "بدأت إعادة التشغيل: %s",
"horizon.splitscreen.profiles.offline": " (غير متصل)",
"horizon.splitscreen.profiles.microsoft": " (Microsoft)",
"horizon.splitscreen.profiles.sign_in": "سجّل الدخول مجددًا في المشغّل",
"horizon.splitscreen.profiles.online": "Microsoft متصل",
"horizon.splitscreen.account_unavailable": "%s غير متصل. سجّل الدخول مجددًا في المشغّل، ثم أعد تشغيل اللعبة.",
"horizon.splitscreen.account_expired": "انتهت صلاحية تسجيل دخول %s إلى Microsoft. سجّل الدخول مجددًا في المشغّل، ثم أعد تشغيل اللعبة.",
"Particle Culling": "استبعاد الجسيمات",
"Draws every particle.": "يرسم كل جسيم.",
"Skips offscreen, subpixel and terrain-hidden particles.": "يتخطى الجسيمات خارج الشاشة ودون البكسل والمخفية بالتضاريس.",
"Skips offscreen, subpixel and terrain-hidden particles with less render-thread work.": "يتخطى الجسيمات خارج الشاشة ودون البكسل والمخفية بالتضاريس بعمل أقل في خيط العرض.",
"horizon.pack.add": "+",
"horizon.pack.addInfo": "إضافة حزمة موارد من Modrinth",
"horizon.pack.addWorking": "يسأل مودرنث...",
"horizon.pack.addNone": "لم يتم العثور على حزم الموارد",
@@ -481,10 +668,28 @@
"horizon.pack.filter.realistic": "واقعية",
"horizon.pack.filter.simplistic": "تبسيط",
"horizon.pack.filter.vanillaLike": "تشبه الفانيليا",
"horizon.pack.filter.x16": "16x",
"horizon.pack.filter.x32": "32x",
"horizon.pack.filter.x64": "64x",
"horizon.pack.addFailed": "لا يمكن إضافة الحزمة: %s",
"horizon.pack.options": "خيارات الحزمة",
"horizon.pack.optionsInfo": "خيارات هذه الحزمة",
"horizon.pack.options.title": "خيارات الحزمة",
"horizon.pack.options.general": "عام",
"horizon.pack.options.presets": "إعدادات مسبقة",
"horizon.pack.options.presets.custom": "مخصص",
"horizon.pack.options.impact": "تأثير الأداء: %s",
"horizon.pack.options.impact.low": "منخفض",
"horizon.pack.options.impact.medium": "متوسط",
"horizon.pack.options.impact.high": "مرتفع",
"horizon.pack.options.impact.varies": "يتفاوت",
"horizon.extras.header.cosmetics": "مستحضرات التجميل",
"horizon.extras.cosmetics_mine": "مستحضرات التجميل الخاصة بي",
"horizon.extras.cosmetics_mine.tooltip": "مخفي: يراك كل لاعب في Horizon بدون مستحضرات التجميل الخاصة بك، بما في ذلك أنت. نفس مفتاح خزانة المشغل.",
"key.emote": "عجلة التعبيرات",
"horizon.emote_wheel.title": "التعبيرات",
"horizon.emote_wheel.empty": "لا توجد تعبيرات بعد",
"horizon.emote_wheel.help": "أفلت أو انقر للتشغيل · 1–8 · Esc يغلق",
"horizon.extras.cosmetics_others": "مستحضرات التجميل للاعبين الآخرين",
"horizon.extras.cosmetics_others.tooltip": "ما يرتديه لاعبو Horizon الآخرون: كل شيء، فقط أنماط بطاقة الاسم الخاصة بهم، أو لا شيء. مخفيًا، اللعبة لا تسأل أبدًا عما يرتدونه.",
"horizon.extras.cosmetics_hide_fx": "إخفاء آثار وهالات اللاعبين الآخرين",
@@ -508,5 +713,349 @@
"Pack settings": "إعدادات الحزمة",
"Built-in settings": "الإعدادات المدمجة",
"%s settings": "إعدادات %s",
"Settings made by the pack's author: they change only this pack.": "إعدادات وضعها مؤلف الحزمة: تؤثر على هذه الحزمة فقط."
"Settings made by the pack's author: they change only this pack.": "إعدادات وضعها مؤلف الحزمة: تؤثر على هذه الحزمة فقط.",
"horizon.moderation.duration.minute": "%s دقيقة",
"horizon.moderation.duration.minutes": "%s دقائق",
"horizon.moderation.duration.hour": "%s ساعة",
"horizon.moderation.duration.hours": "%s ساعات",
"horizon.moderation.duration.day": "%s يوم",
"horizon.moderation.duration.days": "%s أيام",
"horizon.moderation.reason": "%1$s: %2$s",
"horizon.moderation.mutelist.empty": "لا توجد حالات كتم",
"horizon.moderation.mutelist.count": "هناك %s حالة كتم:",
"horizon.moderation.chat_filter": "مرشح الدردشة",
"horizon.moderation.until_unmuted": "حتى رفع الكتم",
"horizon.moderation.remaining": "لمدة %s أخرى",
"horizon.moderation.mutelist.entry": "تم كتم %1$s بواسطة %2$s %3$s",
"horizon.moderation.invalid_duration": "%s ليس مدة: 10m، 2h، 1d",
"horizon.moderation.usage.mute": "/mute <player> [duration] [reason]",
"horizon.moderation.usage.tempmute": "/tempmute <player> <duration> [reason]",
"horizon.moderation.usage.unmute": "/unmute <player>",
"horizon.moderation.usage.mutelist": "/mutelist",
"horizon.moderation.for_duration": "لمدة %s",
"horizon.moderation.muted": "تم كتم %1$s %2$s",
"horizon.moderation.unmuted": "رُفع الكتم عن %s",
"horizon.moderation.not_muted": "%s غير مكتوم",
"horizon.tempban.usage": "/tempban <player> <duration> [reason]",
"horizon.tempban.invalid_duration": "%s ليس مدة: 30m، 12h، 7d",
"horizon.tempban.dedicated_required": "تتطلب الحظور المؤقتة خادمًا مخصصًا",
"horizon.tempban.success": "%1$s لمدة %2$s، حتى %3$s",
"horizon.tempban.list.empty": "لا توجد حظور مؤقتة",
"horizon.tempban.list.count": "هناك %s حظر مؤقت:",
"horizon.tempban.list.entry": "تم حظر %1$s بواسطة %2$s %3$s (حتى %4$s)",
"horizon.options.pointer_speed": "سرعة مؤشر القوائم",
"horizon.options.scroll_speed": "سرعة تمرير القوائم",
"horizon.options.repeat_delay": "تأخير تكرار التنقل",
"horizon.options.repeat_interval": "فاصل تكرار التنقل",
"horizon.options.milliseconds": "%s مللي ثانية",
"horizon.extras.cosmetics_solo": "في اللاعب الفردي",
"horizon.extras.cosmetics_solo.tooltip": "إظهار مستحضرات التجميل وشارة علامة اسمك في عوالم اللاعب الفردي أيضًا (علامة اسمك مع علامة الاسم الخاصة). إيقاف: على الخوادم فقط.",
"horizon.extras.horizon_marks": "شعارات Horizon",
"horizon.extras.horizon_marks.tooltip": "إظهار شعار Horizon بجانب لاعبي Horizon: شعار صغير قبل أسماء اللاعبين الآخرين الذين يستخدمون Horizon، فوق رؤوسهم وفي قائمة اللاعبين. يراه لاعبو Horizon فقط: لا تتلقى الخوادم شيئًا.",
"horizon.extras.horizon_me": "إظهاري كـ Horizon",
"horizon.extras.horizon_me.tooltip": "إظهاري كلاعب Horizon: يرى لاعبو Horizon الآخرون الشعار بجانب اسمك. عند التشغيل، تجيب الخدمة أي شخص يسأل عنك أنك لعبت Horizon خلال الساعات الثلاث الماضية؛ وعند الإيقاف، لا تفيد عنك أبدًا.",
"horizon.pages.pvp": "PvP وHUD",
"horizon.pages.pvp.tooltip": "إعدادات التصويب وHUD وPvP، كلها متوقفة افتراضيًا. لا شيء هنا يُظهر ما لا تُظهره الفانيليا.",
"horizon.pages.open.pvp": "PvP وHUD...",
"horizon.pvp.header.crosshair": "التصويب",
"horizon.pvp.crosshair": "تصويب مخصص",
"horizon.pvp.crosshair_shape": "الشكل",
"horizon.pvp.crosshair_size": "الحجم",
"horizon.pvp.crosshair_gap": "الفجوة",
"horizon.pvp.crosshair_thickness": "السماكة",
"horizon.pvp.crosshair_colour": "اللون",
"horizon.pvp.crosshair_outline": "الحدود",
"horizon.pvp.crosshair_dot": "نقطة المركز",
"horizon.pvp.crosshair_hover": "اللون على الكيانات",
"horizon.pvp.crosshair.tooltip": "يرسم تصويبك الخاص بدلًا من تصويب الفانيليا. إعداداته الأولى تشبه تصويب الفانيليا.",
"horizon.pvp.crosshair_shape.tooltip": "صليب أو T أو X أو دائرة أو مربع أو نقطة فقط.",
"horizon.pvp.crosshair_size.tooltip": "طول كل ذراع، بكسلات واجهة المستخدم.",
"horizon.pvp.crosshair_gap.tooltip": "المسافة الفارغة في الوسط.",
"horizon.pvp.crosshair_thickness.tooltip": "عرض كل خط.",
"horizon.pvp.crosshair_colour.tooltip": "يعرض Inverted اللون تحته معكوسًا، كما يفعل تصويب الفانيليا.",
"horizon.pvp.crosshair_outline.tooltip": "حد أسود رفيع، ليظهر على أي خلفية.",
"horizon.pvp.crosshair_dot.tooltip": "نقطة في وسط التصويب.",
"horizon.pvp.crosshair_hover.tooltip": "لون آخر حين يكون التصويب فوق غوغاء أو لاعب يمكنك إصابته، كما يعرفه مؤشر هجوم الفانيليا.",
"horizon.pvp.colour.inverted": "معكوس",
"horizon.pvp.colour.white": "أبيض",
"horizon.pvp.colour.red": "أحمر",
"horizon.pvp.colour.orange": "برتقالي",
"horizon.pvp.colour.yellow": "أصفر",
"horizon.pvp.colour.green": "أخضر",
"horizon.pvp.colour.cyan": "سماوي",
"horizon.pvp.colour.blue": "أزرق",
"horizon.pvp.colour.magenta": "أرجواني",
"horizon.pvp.colour.black": "أسود",
"horizon.pvp.shape.cross": "صليب",
"horizon.pvp.shape.t": "T",
"horizon.pvp.shape.x": "X",
"horizon.pvp.shape.circle": "دائرة",
"horizon.pvp.shape.square": "مربع",
"horizon.pvp.shape.dot": "نقطة",
"horizon.pvp.outline": "حدود كتلة مخصصة",
"horizon.pvp.outline.tooltip": "يغيّر الحدود حول الكتلة التي تنظر إليها.",
"horizon.pvp.outline_colour": "اللون",
"horizon.pvp.outline_colour.tooltip": "لون الحدود. الأسود بنسبة 40% هو لون الفانيليا.",
"horizon.pvp.outline_opacity": "العتامة",
"horizon.pvp.outline_opacity.tooltip": "مدى صلابة الحدود.",
"horizon.pvp.outline_thickness": "السماكة",
"horizon.pvp.outline_thickness.tooltip": "عرض الحدود، بمضاعفات عرض الفانيليا.",
"horizon.pvp.outline_fill": "التعبئة",
"horizon.pvp.outline_fill.tooltip": "يلوّن أوجه الكتلة التي تنظر إليها بلطف.",
"horizon.pvp.hit": "لون إصابة مخصص",
"horizon.pvp.hit.tooltip": "يغيّر لون وميض الغوغاء واللاعبين عند إصابتهم. الأحمر بنسبة 30% هو لون الفانيليا.",
"horizon.pvp.hit_colour": "اللون",
"horizon.pvp.hit_colour.tooltip": "اللون الذي يومض به الغوغاء واللاعبون المصابون.",
"horizon.pvp.hit_opacity": "العتامة",
"horizon.pvp.hit_opacity.tooltip": "مقدار اللون الذي يغطيهم.",
"horizon.pvp.header.outline": "حدود الكتلة",
"horizon.pvp.header.hit": "لون الإصابة",
"horizon.pvp.hud_armour": "العتاد",
"horizon.pvp.hud_armour.tooltip": "العتاد الذي ترتديه، من الرأس أولًا، مع عدد الاستخدامات المتبقية لكل قطعة.",
"horizon.pvp.hud_armour_corner": "زاوية العتاد",
"horizon.pvp.hud_armour_corner.tooltip": "أين يظهر العتاد.",
"horizon.pvp.hud_armour_percent": "المتانة بالنسبة المئوية",
"horizon.pvp.hud_armour_percent.tooltip": "الاستخدامات المتبقية كنسبة من الحد الأقصى للقطعة بدلًا من رقم.",
"horizon.pvp.hud_effects": "التأثيرات",
"horizon.pvp.hud_effects.tooltip": "تأثيراتك بأسمائها ومستوياتها ووقتها المتبقي، مكان الأيقونات في أعلى اليمين.",
"horizon.pvp.hud_effects_corner": "زاوية التأثيرات",
"horizon.pvp.hud_effects_corner.tooltip": "أين تظهر التأثيرات.",
"horizon.pvp.hud_held": "عدد العنصر المحمول",
"horizon.pvp.hud_held.tooltip": "كم من العنصر في يدك يحمله مخزونك؛ الأسهم مع قوس أو قوس نشاب.",
"horizon.pvp.hud_held_corner": "زاوية العدد المحمول",
"horizon.pvp.hud_held_corner.tooltip": "أين يظهر العدد.",
"horizon.pvp.hud_background": "خلفيات الأدوات",
"horizon.pvp.hud_background.tooltip": "صندوق داكن خلف كل أداة.",
"horizon.pvp.hud_opacity": "عتامة الخلفية",
"horizon.pvp.hud_opacity.tooltip": "مدى قتامة الصناديق خلف الأدوات.",
"horizon.pvp.hud_text_colour": "لون النص",
"horizon.pvp.hud_text_colour.tooltip": "لون نص الأدوات. تحتفظ المتانة بتدرجها من الأخضر إلى الأحمر.",
"horizon.pvp.hud_shadow": "ظل النص",
"horizon.pvp.hud_shadow.tooltip": "ظل تحت نص الأدوات، كما لنص HUD في الفانيليا.",
"horizon.pvp.header.hud": "أدوات HUD",
"horizon.pvp.corner.top_left": "أعلى اليسار",
"horizon.pvp.corner.top_right": "أعلى اليمين",
"horizon.pvp.corner.bottom_left": "أسفل اليسار",
"horizon.pvp.corner.bottom_right": "أسفل اليمين",
"horizon.pvp.hud_ping": "البينغ",
"horizon.pvp.hud_ping.tooltip": "زمن الاستجابة الذي يبلغ عنه الخادم لك، بالمللي ثانية. متعدد اللاعبين فقط.",
"horizon.pvp.hud_ping_corner": "زاوية البينغ",
"horizon.pvp.hud_ping_corner.tooltip": "أين يظهر البينغ.",
"horizon.pvp.hud_coords": "الإحداثيات",
"horizon.pvp.hud_coords.tooltip": "موضعك بالكتل والاتجاه الذي تواجهه، كما يعرضهما F3. مخفي عندما يقلّل العالم أو الخادم من F3.",
"horizon.pvp.hud_coords_corner": "زاوية الإحداثيات",
"horizon.pvp.hud_coords_corner.tooltip": "أين تظهر الإحداثيات.",
"horizon.pvp.hud_server": "عنوان الخادم",
"horizon.pvp.hud_server.tooltip": "عنوان الخادم الذي تلعب عليه. متعدد اللاعبين فقط.",
"horizon.pvp.hud_server_corner": "زاوية الخادم",
"horizon.pvp.hud_server_corner.tooltip": "أين يظهر العنوان.",
"horizon.pvp.hud_combo": "عداد الضربات المتتالية",
"horizon.pvp.hud_combo.tooltip": "الضربات المتتالية التي أكدها الخادم، حتى تُصاب أو تمرّ 3 ثوانٍ دون ضربة.",
"horizon.pvp.hud_combo_corner": "زاوية الضربات المتتالية",
"horizon.pvp.hud_combo_corner.tooltip": "أين تظهر الضربات المتتالية.",
"horizon.pvp.hud.ping": "البينغ: %s مللي ثانية",
"horizon.pvp.hud.coords": "XYZ: %s %s %s",
"horizon.pvp.hud.facing": "الاتجاه: %s",
"horizon.pvp.hud.combo": "الضربات المتتالية: %s",
"horizon.pvp.hud.direction.north": "شمال",
"horizon.pvp.hud.direction.south": "جنوب",
"horizon.pvp.hud.direction.east": "شرق",
"horizon.pvp.hud.direction.west": "غرب",
"horizon.pvp.hud_fps": "FPS",
"horizon.pvp.hud_fps.tooltip": "الإطارات المرسومة في الثانية الأخيرة.",
"horizon.pvp.hud_fps_corner": "زاوية FPS",
"horizon.pvp.hud_fps_corner.tooltip": "أين يظهر FPS.",
"horizon.pvp.hud_cps": "النقرات في الثانية",
"horizon.pvp.hud_cps.tooltip": "نقراتك اليمنى واليسرى في الثانية الأخيرة.",
"horizon.pvp.hud_cps_corner": "زاوية CPS",
"horizon.pvp.hud_cps_corner.tooltip": "أين تظهر النقرات في الثانية.",
"horizon.pvp.hud_keys": "ضغطات المفاتيح",
"horizon.pvp.hud_keys.tooltip": "مفاتيح حركتك وأزرار الفأرة والقفز، مضاءة أثناء إمساكها، مع النقرات في الثانية.",
"horizon.pvp.hud_keys_corner": "زاوية ضغطات المفاتيح",
"horizon.pvp.hud_keys_corner.tooltip": "أين تظهر ضغطات المفاتيح.",
"horizon.pvp.hud_clock": "الساعة",
"horizon.pvp.hud_clock.tooltip": "وقت حاسوبك.",
"horizon.pvp.hud_clock_corner": "زاوية الساعة",
"horizon.pvp.hud_clock_corner.tooltip": "أين تظهر الساعة.",
"horizon.pvp.hud_reach": "مدى الوصول",
"horizon.pvp.hud_reach.tooltip": "إلى أي مدى من عينيك وصلت آخر ضربة، لـ3 ثوانٍ. إنه يقيس فقط: يبقى مدى وصولك كما في الفانيليا.",
"horizon.pvp.hud_reach_corner": "زاوية مدى الوصول",
"horizon.pvp.hud_reach_corner.tooltip": "أين يظهر مدى الوصول.",
"horizon.pvp.hud_speed": "السرعة",
"horizon.pvp.hud_speed.tooltip": "سرعة تحركك على الأرض، بالكتل في الثانية.",
"horizon.pvp.hud_speed_corner": "زاوية السرعة",
"horizon.pvp.hud_speed_corner.tooltip": "أين تظهر السرعة.",
"horizon.pvp.hud.fps": "%s FPS",
"horizon.pvp.hud.cps": "CPS: %s | %s",
"horizon.pvp.hud.reach": "مدى الوصول: %s",
"horizon.pvp.hud.speed": "السرعة: %s كتلة/ث",
"horizon.pvp.hud.keys.attack": "LMB",
"horizon.pvp.hud.keys.use": "RMB",
"horizon.pvp.hud.keys.cps": "%s CPS",
"horizon.pvp.numeric_enchants": "مستويات السحر كأرقام",
"horizon.pvp.numeric_enchants.tooltip": "تُظهر التلميحات وطاولة السحر Sharpness 5 بدلًا من Sharpness V.",
"horizon.pvp.header.items": "العناصر",
"horizon.pvp.fire_lower": "خفض النار",
"horizon.pvp.fire_lower.tooltip": "ينزل النيران التي تراها أثناء الاحتراق، لتغطي مساحة أقل من الشاشة.",
"horizon.pvp.shield_size": "حجم الدرع",
"horizon.pvp.shield_size.tooltip": "حجم الدرع في يدك، بمنظور الشخص الأول.",
"horizon.pvp.totem_size": "حجم حركة التميمة",
"horizon.pvp.totem_size.tooltip": "حجم التميمة التي تملأ الشاشة عندما تنقذك.",
"horizon.pvp.glint_colour": "لون لمعان السحر",
"horizon.pvp.glint_colour.tooltip": "لون اللمعان على العناصر والعتاد المسحور. إيقاف هو أرجواني الفانيليا.",
"horizon.pvp.clear_water": "ماء صافٍ",
"horizon.pvp.clear_water.tooltip": "تحت الماء، أرِ إلى المدى الذي تسمح به الفانيليا بعد 30 ثانية هناك، من البداية.",
"horizon.pvp.clear_lava": "حمم صافية",
"horizon.pvp.clear_lava.tooltip": "في الحمم، أرِ إلى المدى الذي تسمح به مقاومة النار في الفانيليا، دون التأثير.",
"horizon.pvp.header.visual": "تعديلات بصرية",
"horizon.pvp.view_time": "وقت اليوم",
"horizon.pvp.view_time.tooltip": "وقت اليوم الذي تراه: السماء وضوؤها فقط. يستمر وقت العالم ولا يُرسل شيء إلى الخادم.",
"horizon.pvp.view_weather": "الطقس",
"horizon.pvp.view_weather.tooltip": "الطقس الذي تراه: المطر وضوؤه والضباب فقط. يستمر طقس العالم ولا يُرسل شيء إلى الخادم.",
"horizon.pvp.header.view": "عرضك",
"horizon.pvp.view.world": "عرض العالم",
"horizon.pvp.time.morning": "الصباح",
"horizon.pvp.time.noon": "الظهر",
"horizon.pvp.time.sunset": "الغروب",
"horizon.pvp.time.midnight": "منتصف الليل",
"horizon.pvp.weather.clear": "صافٍ",
"horizon.pvp.weather.rain": "مطر",
"horizon.pvp.weather.thunder": "رعد",
"horizon.chat.timestamp": "[%s] ",
"horizon.chat.repeats": " (x%s)",
"horizon.pvp.header.chat": "الدردشة",
"horizon.chat.copied": "تم نسخ الرسالة",
"horizon.chat.search": "بحث في الدردشة:",
"horizon.pvp.chat_timestamps": "الطوابع الزمنية",
"horizon.pvp.chat_timestamps.tooltip": "وقت وصول كل رسالة، قبلها.",
"horizon.pvp.chat_compact": "دردشة مدمجة",
"horizon.pvp.chat_compact.tooltip": "الرسالة المتكررة على التوالي تظهر مرة واحدة، مع عدد مرات وصولها: (x3).",
"horizon.pvp.chat_copy": "نسخ بالنقر",
"horizon.pvp.chat_copy.tooltip": "النقر على رسالة في الدردشة المفتوحة ينسخ نصها، ما لم يفعل النقر شيئًا آخر.",
"horizon.pvp.chat_search": "بحث الدردشة",
"horizon.pvp.chat_search.tooltip": "Ctrl+F في الدردشة المفتوحة: ما تكتبه يُظهر فقط الرسائل التي تحتويه. Enter أو Ctrl+F مجددًا يوقفه.",
"horizon.pvp.preset": "إعداد PvP المسبق",
"horizon.pvp.preset.tooltip": "يفعّل التصويب المخصص وأداة العتاد والتأثيرات وتبديل الركض والتسلل وخفض النار وعداد FPS وتعديلات ضغطات المفاتيح إن كانت مثبتة. تبقى إعداداتك لكل منها.",
"horizon.pvp.hud_editor_key": "مفتاح محرر HUD",
"horizon.pvp.hud_editor_key.tooltip": "يفتح محرر HUD من اللعبة. انقر، ثم اضغط مفتاحًا؛ Esc يلغي الربط.",
"horizon.pvp.editor": "تحرير HUD",
"horizon.pvp.editor.tooltip": "حرّك الأدوات في اللعبة: اسحبها إلى أي مكان، وغيّر حجمها بالعجلة، وانقر بزر الفأرة الأيمن على أداة لإعادتها إلى زاويتها.",
"horizon.pvp.editor.hint": "اسحب الأدوات · العجلة: الحجم · الزر الأيمن: العودة إلى الزاوية",
"horizon.pvp.combat_feel": "إحساس القتال",
"horizon.pvp.combat_feel.tooltip": "يرسم Legacy القتال كما في 1.8.9: العنصر في يدك، وضربته، والأكل، والقوس، والسيف المرفوع أثناء إمساك الاستخدام، دون الانخفاض بعد كل ضربة أو مؤشر الهجوم. المظهر فقط: ما ترسله إلى الخادم يبقى كما هو.",
"horizon.pvp.combat_indicator": "مؤشر الهجوم في Legacy",
"horizon.pvp.combat_indicator.tooltip": "يحتفظ بمؤشر الهجوم في Legacy، الذي لم يكن في 1.8.9.",
"horizon.pvp.combat_feel.modern": "حديث",
"horizon.pvp.combat_feel.legacy": "Legacy (1.8.9)",
"horizon.pvp.header.combat": "إحساس القتال",
"horizon.pvp.combat_sounds": "أصوات ضرب 1.8",
"horizon.pvp.combat_sounds.tooltip": "في Legacy، يستبعد أصوات الهجوم التي أضافها 1.9 (القوية، الضعيفة، الكنس، الحرجة...). تبقى أصوات الإصابة.",
"horizon.pvp.combat_rules": "قتال 1.8 في العوالم التي تستضيفها",
"horizon.pvp.combat_rules.tooltip": "اللاعب الفردي والعوالم التي تفتحها على LAN تقاتل بقواعد 1.8.9: لا تبريد هجوم أو كنس، وضرر أسلحة 1.8، والقوة، والحدّة، والضربات الحرجة. الخوادم التي تنضم إليها تحتفظ بقواعدها.",
"Bindings": "الربط",
"Play": "اللعب",
"Device": "الجهاز",
"Look": "النظر",
"Movement": "الحركة",
"Menus": "القوائم",
"Actions": "الإجراءات",
"Reset": "إعادة الضبط",
"Detected": "المكتشف",
"Glyphs": "الحروف الرسومية",
"Feedback": "التغذية الراجعة",
"Controller": "وحدة التحكم",
"How the sticks turn and move you, and how menus repeat and scroll.": "كيف تديرك العصي وتحركك، وكيف تتكرر القوائم وتمرّ.",
"Which button does what. Each player has their own.": "أي زر يفعل ماذا. لكل لاعب ربطه الخاص.",
"The controller the game sees, its button glyphs and its feedback.": "وحدة التحكم التي تراها اللعبة، وحروف أزرارها وتغذيتها الراجعة.",
"Press this row, then the button that should do it.": "اضغط هذا الصف، ثم الزر الذي يجب أن يفعل ذلك.",
"Off, the game ignores every controller for this player.": "عند الإيقاف، تتجاهل اللعبة كل وحدات التحكم لهذا اللاعب.",
"How fast the right stick turns the view.": "مدى سرعة إدارة العصا اليمنى للعرض.",
"Turn the view the other way, left and right.": "إدارة العرض في الاتجاه الآخر، يمينًا ويسارًا.",
"Turn the view the other way, up and down.": "إدارة العرض في الاتجاه الآخر، أعلى وأسفل.",
"How far the right stick moves before the view turns. Raise it if the view drifts.": "إلى أي مدى تتحرك العصا اليمنى قبل أن يدور العرض. ارفعه إذا كان العرض ينزلق.",
"Hold the button to sneak, or press it once to start and again to stop.": "أمسك الزر للتسلل، أو اضغطه مرة للبدء ومرة أخرى للتوقف.",
"How far the left stick moves before you walk. Raise it if you drift.": "إلى أي مدى تتحرك العصا اليسرى قبل أن تمشي. ارفعه إذا كنت تنزلق.",
"How far a trigger is pulled before it counts as pressed.": "إلى أي مدى يُسحب المشغّل قبل أن يُحتسب مضغوطًا.",
"Shows what your buttons do right now, in play and in inventories.": "يعرض ما تفعله أزرارك الآن، في اللعب وفي المخزون.",
"Shows only what you can do with what you're looking at, and the main inventory buttons.": "يعرض فقط ما يمكنك فعله بما تنظر إليه، وأزرار المخزون الرئيسية.",
"Doesn't show what your buttons do.": "لا يعرض ما تفعله أزرارك.",
"Shows the buttons of the controller you're using.": "يعرض أزرار وحدة التحكم التي تستخدمها.",
"Always shows Xbox buttons.": "يعرض أزرار Xbox دائمًا.",
"Always shows PlayStation buttons.": "يعرض أزرار PlayStation دائمًا.",
"Always shows Nintendo buttons.": "يعرض أزرار Nintendo دائمًا.",
"A, on the right, confirms and jumps and B goes back, as on a Switch.": "A، على اليمين، يؤكد ويقفز وB يعود، كما في Switch.",
"B, at the bottom, confirms and jumps and A goes back, as on other controllers.": "B، في الأسفل، يؤكد ويقفز وA يعود، كما في وحدات التحكم الأخرى.",
"Opens Steam's keyboard on a Steam Deck under Steam, and Horizon's otherwise.": "يفتح لوحة مفاتيح Steam على Steam Deck تحت Steam، ولوحة Horizon فيما عدا ذلك.",
"Always opens Horizon's keyboard.": "يفتح لوحة مفاتيح Horizon دائمًا.",
"Always opens Steam's keyboard.": "يفتح لوحة مفاتيح Steam دائمًا.",
"A DualSense's light shows your health, from green through amber to red.": "يعرض ضوء DualSense صحتك، من الأخضر عبر الكهرماني إلى الأحمر.",
"A DualSense's light changes with the dimension you're in.": "يتغير ضوء DualSense مع البعد الذي أنت فيه.",
"A DualSense keeps its own light.": "يحتفظ DualSense بضوئه الخاص.",
"Lets LT + A craft on servers other than your own worlds. Some servers count it as a macro.": "يتيح LT + A للصناعة على خوادم غير عوالمك. بعض الخوادم تعتبره ماكرو.",
"Show the buttons for what you can do, while you play with a controller.": "إظهار أزرار ما يمكنك فعله أثناء اللعب بوحدة تحكم.",
"How fast the stick moves the crosshair in inventories.": "مدى سرعة تحريك العصا للتصويب في المخزون.",
"How fast the right stick scrolls a list.": "مدى سرعة تمرير العصا اليمنى للقائمة.",
"How long a held direction waits before it repeats.": "كم ينتظر اتجاه ممسوك قبل أن يتكرر.",
"How long a held direction takes between repeats.": "كم يستغرق اتجاه ممسوك بين التكرارات.",
"Which on-screen keyboard opens when a text field is pressed.": "أي لوحة مفاتيح على الشاشة تُفتح عند الضغط على حقل نص.",
"Every action back on its default button.": "إعادة كل إجراء إلى زرّه الافتراضي.",
"These settings belong to this player's profile.": "تنتمي هذه الإعدادات إلى الملف الشخصي لهذا اللاعب.",
"The controller driving this player, or else the first one connected.": "وحدة التحكم التي تقود هذا اللاعب، أو الأولى المتصلة إن لم توجد.",
"The USB vendor and product the system reports. Useful when reporting a controller that misbehaves.": "مورّد ومنتج USB كما يبلّغ عنه النظام. مفيد عند الإبلاغ عن وحدة تحكم تسيء التصرف.",
"The glyph family the game picked from the controller's maker.": "عائلة الحروف الرسومية التي اختارتها اللعبة من صانع وحدة التحكم.",
"Follow the controller, or always show one family's buttons.": "اتبع وحدة التحكم، أو اعرض أزرار عائلة واحدة دائمًا.",
"Whether a Nintendo controller's A and B work by where they sit or by their letters.": "هل يعمل A وB في وحدة تحكم Nintendo حسب موضعهما أم حسب حرفيهما.",
"The buttons menus show right now.": "الأزرار التي تعرضها القوائم الآن.",
"Rumble when something happens to you.": "اهتزاز عند حدوث شيء لك.",
"The controller keeps playing while this window isn't focused, and losing focus won't pause.": "تستمر وحدة التحكم في اللعب بينما هذه النافذة غير مركّزة، وفقدان التركيز لن يوقف اللعب.",
"A DualSense's triggers push back on bows and tools.": "تدفع مشغلات DualSense للخلف على الأقواس والأدوات.",
"What a DualSense's light shows, over USB or Bluetooth.": "ما يعرضه ضوء DualSense، عبر USB أو Bluetooth.",
"Player profile": "ملف اللاعب الشخصي",
"Vendor and product": "المورّد والمنتج",
"Detected glyphs": "الحروف الرسومية المكتشفة",
"Glyphs in use": "الحروف الرسومية المستخدمة",
"Unknown": "غير معروف",
"No controller": "لا توجد وحدة تحكم",
"Editing: %s": "التحرير: %s",
"Press the button for this. %s cancels.": "اضغط الزر المخصص لهذا. %s يلغي.",
"This button also does: %s": "هذا الزر يفعل أيضًا: %s",
"Not bound: press this row, then the button that should do it.": "غير مربوط: اضغط هذا الصف، ثم الزر الذي يجب أن يفعل ذلك.",
"horizon.pad.notice.connected.press": "اضغط للاستخدام",
"horizon.recipe_transfer.missing": "عناصر ناقصة",
"horizon.recipe_transfer.no_room": "المخزون ممتلئ",
"horizon.recipe_transfer.unavailable": "الوصفة لا تناسب هذه القائمة",
"horizon.recipe_transfer.unsupported": "يتطلب الخادم وصفة معروفة",
"horizon.studio.timeline.freecam_on": "الكاميرا الحرة: تشغيل",
"horizon.studio.timeline.freecam_off": "الكاميرا الحرة: إيقاف",
"horizon.studio.timeline.freecam_short": "كام",
"horizon.studio.timeline.freecam_active_short": "كام ✓",
"horizon.studio.timeline.add_keyframe_short": "+ مفتاح",
"horizon.studio.timeline.delete_keyframe_short": "− حذف",
"horizon.studio.timeline.split": "تقسيم",
"horizon.studio.timeline.marker": "علامة %s",
"horizon.studio.timeline.seconds": "%s ث",
"horizon.studio.timeline.scene_count": "%s مشاهد",
"horizon.studio.timeline.scene_title": "مشهد %s",
"horizon.studio.timeline.sequence": "تسلسل",
"horizon.studio.topbar.title": "✦ الاستوديو",
"horizon.studio.topbar.file": "ملف",
"horizon.studio.topbar.camera": "الكاميرا",
"horizon.studio.topbar.view": "عرض",
"horizon.studio.topbar.file_short": "ملف",
"horizon.studio.topbar.camera_short": "كام",
"horizon.studio.topbar.view_short": "عرض",
"horizon.studio.topbar.game_pause_menu": "⏸ قائمة الإيقاف المؤقت",
"horizon.studio.topbar.pause_short": "إيقاف مؤقت",
"horizon.studio.topbar.open_recordings": "📁 فتح مجلد التسجيلات",
"horizon.studio.topbar.close_replay": "✕ إغلاق إعادة التشغيل",
"horizon.studio.topbar.exit": "خروج",
"horizon.studio.topbar.toggle_freecam": "تبديل الكاميرا الحرة",
"horizon.studio.topbar.reset_to_player": "👤 العودة إلى اللاعب",
"horizon.studio.topbar.unlock_freecam": "🔓 فك قفل الكاميرا الحرة",
"horizon.studio.topbar.lock_to_spline": "🔒 القفل على المنحنى",
"horizon.studio.topbar.add_keyframe": "➕ إضافة مفتاح إطار",
"horizon.studio.topbar.add_marker": "إضافة علامة",
"horizon.studio.topbar.clear_keyframes": "🗑 مسح مفاتيح الإطارات",
"horizon.studio.topbar.timeline_hud": "⏱ الخط الزمني",
"horizon.studio.topbar.split_scene": "تقسيم المشهد"
}
@@ -1,23 +1,129 @@
{
"horizon.extras.header.world": "Dünya",
"horizon.extras.pregen": "Parçaların əvvəlcədən yaradılması",
"horizon.extras.pregen.tooltip": "Tək oyunçu və LAN dünyalarınızda /pregen əmrini aktivləşdirir. Hiylələr aktiv olduqda, ərazini kəşf etməzdən əvvəl yaratmaq üçün /pregen start istifadə edin. Qoşulduğunuz serverlərdə əmri aktivləşdirmir.",
"horizon.options.controller": "Nəzarətçi",
"horizon.options.three_d_skin_layers": "3D dəri qatları",
"horizon.options.vibration": "Vibrasiya",
"horizon.options.play_in_background": "Arxa planda oynat",
"horizon.options.adaptive_triggers": "Adaptiv tətiklər",
"horizon.options.gameplay_guide": "Oyun bələdçisi",
"horizon.options.gameplay_guide.basic": "Əsas",
"horizon.options.gameplay_guide.full": "Tam",
"horizon.options.gameplay_guide.off": "Söndür",
"horizon.controller.guide.use": "İstifadə et",
"horizon.controller.guide.open": "Aç",
"horizon.controller.guide.ride": "Min",
"horizon.controller.guide.trade": "Ticarət et",
"horizon.controller.guide.feed": "Yedizdir",
"horizon.controller.guide.place": "Yerləşdir",
"horizon.controller.guide.eat": "Ye",
"horizon.controller.guide.drink": "İç",
"horizon.controller.guide.shoot": "Atəş et",
"horizon.controller.guide.load": "Yüklə",
"horizon.controller.guide.throw": "At",
"horizon.controller.guide.raise": "Qaldır",
"horizon.controller.guide.equip": "Geyindir",
"horizon.controller.guide.fish": "Balıq tut",
"horizon.controller.guide.zoom": "Yaxınlaşdır",
"horizon.controller.guide.boost": "Sürətləndir",
"horizon.controller.guide.break": "Qır",
"horizon.controller.guide.attack": "Hücum et",
"horizon.controller.guide.spectate": "İzlə",
"horizon.controller.guide.spectator_menu": "İzləyici menyusu",
"horizon.controller.guide.release_shoot": "Atəş üçün burax",
"horizon.controller.guide.release_throw": "Atmaq üçün burax",
"horizon.controller.guide.release_stop": "Dayandırmaq üçün burax",
"horizon.controller.guide.fly": "Uç",
"horizon.controller.guide.fly_up": "Yuxarı uç",
"horizon.controller.guide.fly_down": "Aşağı uç",
"horizon.controller.guide.swim_up": "Yuxarı üz",
"horizon.controller.guide.swim_down": "Aşağı üz",
"horizon.controller.guide.glide": "Süzül",
"horizon.controller.guide.dismount": "Düş",
"horizon.controller.guide.stand": "Ayağa qalx",
"horizon.controller.guide.drop": "Əşyanı at",
"horizon.controller.guide.swap": "Əlləri dəyiş",
"horizon.controller.guide.take": "Götür",
"horizon.controller.guide.take_half": "Yarısını götür",
"horizon.controller.guide.put": "Qoy",
"horizon.controller.guide.put_one": "Birini qoy",
"horizon.controller.guide.swap_stack": "Dəyiş",
"horizon.controller.guide.quick_move": "Sürətli köçür",
"horizon.controller.guide.drop_stack": "Yığını at",
"horizon.controller.guide.place_recipe": "Resepti yerləşdir",
"horizon.controller.guide.place_recipe_all": "Hamısını yerləşdir",
"horizon.controller.guide.alternatives": "Alternativlər",
"horizon.controller.guide.select": "Seç",
"horizon.controller.guide.close": "Bağla",
"horizon.controller.guide.pages": "Səhifələr",
"horizon.controller.guide.categories": "Kateqoriyalar",
"horizon.controller.guide.tabs": "Nişanlar",
"horizon.options.lightbar": "İşıq çubuğu",
"horizon.options.look_sensitivity": "Baxış həssaslığı",
"horizon.options.invert_x": "Tərs baxış X",
"horizon.options.invert_y": "Tərs baxış Y",
"horizon.options.craft_on_servers": "Serverlərdə sürətli hazırlama",
"horizon.controller.direct_craft": "Sürətli hazırlama",
"horizon.options.sneak_button": "Gizlənmə düyməsi",
"horizon.options.move_deadzone": "Hərəkət ölü zonası",
"horizon.options.look_deadzone": "Baxış ölü zonası",
"horizon.options.trigger_threshold": "Tətik həddi",
"horizon.options.button_glyphs": "Düymə Qlifləri",
"horizon.options.on_screen_keyboard": "Ekran Klaviaturası",
"horizon.options.chat_delay": "Söhbət Gecikməsi",
"horizon.options.lightbar.health": "Sağlamlıq",
"horizon.options.lightbar.dimension": "Ölçü",
"horizon.options.glyphs.auto": "Avtomatik",
"horizon.options.glyphs.xbox": "Xbox",
"horizon.options.glyphs.playstation": "PlayStation",
"horizon.options.glyphs.nintendo": "Nintendo",
"horizon.options.nintendo_buttons": "Nintendo düymələri",
"horizon.options.nintendo_buttons.position": "Mövqeyə görə",
"horizon.options.nintendo_buttons.letter": "Hərfə görə",
"horizon.options.keyboard.auto": "Avtomatik",
"horizon.options.keyboard.horizon": "Horizon",
"horizon.options.keyboard.steam": "Steam",
"horizon.options.unavailable": "Bu funksiya hələ Horizon ərazisində mövcud deyil.",
"horizon.options.search": "Axtarış parametrləri...",
"horizon.studio.record.usage": "İstifadə: /studio record <start [name]|stop|status>",
"horizon.studio.record.started": "Studio qeydi başladı: %s",
"horizon.studio.record.saved": "Studio qeydi saxlanıldı: %s",
"horizon.studio.record.error": "Studio qeyd xətası: %s",
"horizon.studio.record.status.active": "Studio qeydi aktivdir — %s san, %s paket, %s KB (%s)",
"horizon.studio.record.status.inactive": "Studio qeydi aktiv deyil",
"horizon.studio.replay.usage": "İstifadə: /studio replay <open PATH|pause|resume|speed MULTIPLIER|seek MS|status|close>",
"horizon.studio.replay.open.usage": "İstifadə: /studio replay open <PATH>",
"horizon.studio.replay.opened": "Studio təkrarı açıldı: %s (%s san, %s paket)",
"horizon.studio.replay.open_error": "Təkrarı açmaq mümkün olmadı: %s",
"horizon.studio.replay.paused": "Studio təkrarı pauzaya salındı",
"horizon.studio.replay.resumed": "Studio təkrarı davam etdirildi",
"horizon.studio.replay.inactive": "Aktiv Studio təkrarı yoxdur",
"horizon.studio.replay.speed.usage": "İstifadə: /studio replay speed <MULTIPLIER>",
"horizon.studio.replay.speed.set": "Studio təkrar sürəti %s× təyin edildi",
"horizon.studio.replay.speed.invalid": "Yanlış təkrar sürəti",
"horizon.studio.replay.seek.usage": "İstifadə: /studio replay seek <MILLISECONDS>",
"horizon.studio.replay.seeked": "Studio təkrarı %s ms mövqeyinə keçirildi (%s san)",
"horizon.studio.replay.seek.error": "Təkrarı həmin mövqeyə keçirmək mümkün olmadı: %s",
"horizon.studio.replay.seek.backward_requires_standalone": "Geri sarma yalnız müstəqil təkrar üçün mövcuddur.",
"horizon.studio.replay.seek.invalid": "Yanlış təkrar mövqeyi",
"horizon.studio.replay.status": "Studio təkrarı: %s / %s ms (%s%%), sürət %s×, pauza %s, bitib %s",
"horizon.studio.replay.closed": "Studio təkrarı bağlandı",
"horizon.studio.freecam.enabled": "Studio sərbəst kamerası aktivdir. Hərəkət üçün WASD, qalxmaq və ya enmək üçün Space və Shift, daha sürətli hərəkət üçün Control istifadə edin.",
"horizon.studio.freecam.disabled": "Studio sərbəst kamerası söndürüldü",
"horizon.studio.freecam.unavailable": "Studio sərbəst kamerası oyunçu kamerası tapa bilmədi",
"horizon.studio.freecam.replay_required": "Studio sərbəst kamerası təkrarın oxudulması zamanı mövcuddur",
"horizon.studio.freecam.usage": "İstifadə: /studio freecam <on|off>",
"horizon.studio.keyframe.added": "%s ms-də kamera açar kadrı əlavə edildi. Yol təkrarı bağladığınızda saxlanılır.",
"horizon.studio.keyframe.cleared": "Kamera yolu təmizləndi. Dəyişiklik təkrarı bağladığınızda saxlanılır.",
"horizon.studio.keyframe.removed": "Kamera açar kadrı %s silindi. Dəyişiklik təkrarı bağladığınızda saxlanılır.",
"horizon.studio.keyframe.save_error": "Kamera yolunu saxlamaq mümkün olmadı: %s",
"horizon.studio.keyframe.remove_usage": "İstifadə: /studio keyframe remove <INDEX>",
"horizon.studio.keyframe.index_invalid": "Kamera açar kadrının indeksi aralıqdan kənardır",
"horizon.studio.keyframe.usage": "İstifadə: /studio keyframe <add|remove INDEX|clear>",
"horizon.studio.path.locked": "Studio kamera yolu oxudulması aktivdir",
"horizon.studio.path.unlocked": "Studio kamera yolu oxudulması dayandırıldı",
"horizon.studio.path.usage": "İstifadə: /studio path <lock|unlock> (əvvəlcə ən azı bir açar kadr əlavə edin)",
"horizon.pages.mods": "Modlar",
"horizon.pages.shader_packs": "Shader Paketləri",
"horizon.pages.shader_options": "Shader Seçimləri",
@@ -25,6 +131,7 @@
"horizon.pages.controller_binds": "Nəzarətçi Bağlayır",
"horizon.pages.extras": "Horizon Əlavələr",
"horizon.pages.settings": "Horizon Parametrlər",
"horizon.pages.studio": "Təkrar Studio",
"horizon.pages.agent": "AI agenti",
"horizon.split.leave": "tərk et",
"horizon.pages.split_settings": "Splitscreen Parametrləri",
@@ -45,6 +152,7 @@
"horizon.pages.music.empty": "Musiqi parçaları yoxdur: oyunun səsləri yüklənmir.",
"horizon.pages.music.tooltip": "Vəziyyətə görə quraşdırılmış treklər: birini indi oynayın və ya hər hansı birini söndürün ki, oyun onu seçməyi dayandırsın.",
"horizon.pages.settings.tooltip": "Horizon öz parametrləri: qrafika, nəzarətçilər, əlavələr və modlar.",
"horizon.pages.studio.tooltip": "Saxlanılmış qeydlərə və onların metadatasına baxın.",
"horizon.pages.graphics.tooltip": "Qrafika, sadəcə olaraq: hər parametr nəyə başa gəldiyini bildirir.",
"horizon.pages.graphics_advanced.tooltip": "Hər bir qrafik parametri, Horizon daxil edilir, hər birinin dəyəri ilə.",
"horizon.pages.controller.tooltip": "Nəzarətçilər: onların düymələri, çubuqları, vibrasiya və menyu naviqasiyası.",
@@ -61,16 +169,48 @@
"horizon.pages.open.split_setup": "Ayrılmış ekran...",
"horizon.pages.open.extras": "Əlavələr...",
"horizon.pages.open.mods": "Modlar...",
"horizon.pages.open.agent": "AI agenti...",
"horizon.pages.open.horizon_graphics": "Horizon Qrafika...",
"horizon.pages.open.controller_binds": "Nəzarətçi Bağlayır...",
"horizon.pages.open.horizon_settings": "Horizon Parametrlər...",
"horizon.pages.open.studio": "Təkrar Studio...",
"horizon.studio.saved_recordings": "Saxlanılmış qeydlər",
"horizon.studio.empty": "Qeydlər qovluğunda heç bir qeyd tapılmadı.",
"horizon.studio.empty_hint": "Dünyada /studio record start <name> istifadə edin və ya ora .mcpr faylları atın.",
"horizon.studio.open_folder": "Qeydləri aç",
"horizon.studio.open_replay": "Təkrarı oynat",
"horizon.studio.refresh": "Yenilə",
"horizon.studio.unknown_server": "Naməlum",
"horizon.pages.open.music": "Musiqi...",
"horizon.mods.empty": "Mod yoxdur: .hmod fayllarını horizon-mods qovluğuna qoyun",
"horizon.mods.stopped": " (Dayandı)",
"horizon.mods.not_built": " (tikilməyib)",
"horizon.mods.dev": " (Dev)",
"horizon.mods.settings": "Parametrlər",
"horizon.mods.in_world": " (Dəyişmək üçün dünyanı tərk et)",
"horizon.mods.open_folder": "Mod Qovluğunu açın",
"horizon.mods.browse": "Modlara bax...",
"horizon.mods.browse.title": "Modlara bax",
"horizon.mods.browse.search": "Modlarda axtar...",
"horizon.mods.browse.project": "Mod",
"horizon.mods.browse.loading": "Horizon-un mod reyestri oxunur...",
"horizon.mods.browse.failed": "Mod reyestrini oxumaq mümkün olmadı: %s",
"horizon.mods.browse.none": "Heç bir mod tapılmadı",
"horizon.mods.browse.has_update": " (yeniləmə)",
"horizon.mods.browse.is_installed": " (quraşdırılıb)",
"horizon.mods.browse.by": "%s tərəfindən",
"horizon.mods.browse.installing": "Quraşdırılır...",
"horizon.mods.browse.install": "Quraşdır",
"horizon.mods.browse.update": "Yenilə",
"horizon.mods.browse.installed": "Quraşdırılıb",
"horizon.mods.browse.open_page": "Səhifəni aç",
"horizon.mods.browse.enabled": "Mod",
"horizon.mods.browse.changelog": "Dəyişikliklər",
"horizon.mods.browse.done": "%s %s quraşdırıldı və aktivdir.",
"horizon.mods.browse.install_failed": "Onu quraşdırmaq mümkün olmadı: %s",
"horizon.mods.switch.worldgen": "dünya yaradılmasını dəyişir",
"horizon.mods.switch.registries": "bloklar, əşyalar və ya digər reyestr qeydləri əlavə edir",
"horizon.mods.switch.restart": "%s tətbiq etmək üçün oyunu yenidən başladın: onlar bloklar, əşyalar və ya dünya yaradılması əlavə edir.",
"horizon.shader_options.profile": "Profil",
"horizon.agent.copy_details": "Bağlantı təfərrüatlarını kopyalayın",
"horizon.multiplayer.join": "Qoşulun",
@@ -82,6 +222,7 @@
"LOD Distance": "LOD məsafəsi",
"Far Detail": "Uzaq Detal",
"Cave Culling": "Mağaraların kəsilməsi",
"GPU Culling": "GPU kəsməsi",
"Entity Culling": "Müəssisə Culling",
"Chunk Updates": "Parça Yeniləmələri",
"Upload Budget": "Büdcə yükləyin",
@@ -90,13 +231,20 @@
"Weather": "Hava",
"Animated Textures": "Animasiyalı Dokular",
"Present Mode": "İndiki rejim",
"Frame Pacing": "Kadr tempi",
"Worker Threads": "İşçi ipləri",
"Resolution Scale": "Qətnamə Ölçüsü",
"Anti-aliasing": "Antialiasing",
"Upscaling": "Təkmilləşdirmə",
"Upscaling Sharpness": "Böyütmə kəskinliyi",
"Paper White": "Kağız Ağ",
"Peak Brightness": "Pik Parlaqlıq",
"Black Level": "Qara Səviyyə",
"UI Brightness": "UI Parlaqlığı",
"HDR Lighting": "HDR işıqlandırma",
"Light sources, the sun and sunlight shine past white. Off: just as SDR.": "İşıq mənbələri, günəş və günəş işığı ağdan kənara parlayır. Söndür: yalnız SDR kimi.",
"Smooth Gradients": "Hamar keçidlər",
"16-bit frame: dark skies and shadows don't band. A bit more VRAM. Next start.": "16-bit kadr: tünd səmalar və kölgələr zolaqlanmır. Bir az daha çox VRAM. Növbəti başlatmada.",
"Battery Mode": "Batareya rejimi",
"Graphics API": "Qrafik API",
"Translucent Batching": "Şəffaf Batching",
@@ -110,6 +258,7 @@
"Performance": "Performans",
"Balanced": "Balanslı",
"Quality": "Keyfiyyət",
"Ultra": "Ultra",
"Ultra Performance": "Ultra Performans",
"Native": "Yerli",
"Full": "Tam",
@@ -120,18 +269,29 @@
"FIFO": "FİFO",
"Mailbox": "Poçt qutusu",
"Immediate": "Dərhal",
"Vulkan": "Vulkan",
"DirectX 12": "DirectX 12",
"OpenGL": "OpenGL",
"FPS and Graph": "FPS və Qrafik",
"FPS": "FPS",
"TAA": "TAA",
"FXAA": "FXAA",
"No cost": "Xərc yoxdur",
"Low cost": "Aşağı qiymət",
"Medium cost": "Orta qiymət",
"High cost": "Yüksək qiymət",
"Saves time": "Vaxta qənaət edir",
"HDR": "HDR",
"Graphics": "Qrafika",
"Done": "Bitdi",
"This feature is not yet available in Horizon.": "Bu funksiya hələ Horizon ərazisində mövcud deyil.",
"Your display or system doesn't offer HDR output.": "Ekranınız və ya sisteminiz HDR çıxışı təklif etmir.",
"Turn HDR on to use it.": "İstifadə etmək üçün HDR-ni yandırın.",
"Your GPU or graphics API doesn't support it.": "GPU və ya qrafik API onu dəstəkləmir.",
"Not with software rendering, which runs it on the CPU.": "Proqram təminatı ilə renderləmədə mümkün deyil, o bunu CPU-da işlədir.",
"Out of video memory: off until the next start.": "Video yaddaşı bitdi: növbəti başlatmaya qədər söndürülüb.",
"The graphics device was lost: off until the next start.": "Qrafik qurğu itirildi: növbəti başlatmaya qədər söndürülüb.",
"Not on DirectX 12 yet: try the Vulkan graphics API.": "Hələ DirectX 12-də deyil: Vulkan qrafik API-ni sınayın.",
"%s chunks": "%s parçalar",
"%s per frame": "%s hər çərçivə üçün",
"%s MiB/frame": "%s MiB/çərçivə",
@@ -147,13 +307,15 @@
"Size of menus and the HUD.": "Menyuların ölçüsü və HUD.",
"Sets the options below together. Changing one makes it Custom.": "Aşağıdakı seçimləri birlikdə təyin edir. Birinin dəyişdirilməsi onu Xüsusi edir.",
"Lava, fire and sunlight shine brighter than white. Needs an HDR display.": "Lava, od və günəş işığı ağdan daha parlaq parlayır. HDR displey lazımdır.",
"Your display's darkest black. 0 for OLED; LCDs glow a little.": "Ekranınız ən tünd qaradır. OLED üçün 0; LCD-lər bir az parlayır.",
"Lifts shadows onto your display's darkest black. 0: true black, no lift.": "Kölgələri ekranınızın ən tünd qarasına qaldırır. 0: həqiqi qara, qaldırma yoxdur.",
"How bright highlights get. Auto: the peak your system reports.": "Parlaq məqamlar necə əldə edilir. Avtomatik: sisteminizin ən yüksək hesabatı.",
"How bright white paper and menus look. Auto: your desktop's white.": "Ağ kağız və menyular necə parlaq görünür. Avtomatik: masaüstünüz ağ rəngdədir.",
"How bright white looks in the world. Auto: your desktop's white.": "Dünyada ağ rəngin nə qədər parlaq göründüyü. Avtomatik: iş masanızın ağı.",
"How bright the HUD and menus are, against paper white.": "HUD və menyular ağ kağıza qarşı nə qədər parlaqdır.",
"How far mobs, crops and machines keep working. CPU time.": "Kütlələr, məhsullar və maşınlar nə qədər işləməyə davam edir. CPU vaxtı.",
"How far away mobs and items are drawn, as a share of normal.": "Nə qədər uzağa mob və əşyalar çəkilir, normal bir pay olaraq.",
"Skips terrain hidden behind closed sections. Off costs a lot.": "Qapalı hissələrin arxasında gizlənmiş ərazini ötür. Sıxılma çox baha başa gəlir.",
"Culls nearby terrain on the GPU: less CPU at high render distance, more GPU.": "Yaxınlıqdakı ərazini GPU-da kəsir: böyük render məsafəsində daha az CPU, daha çox GPU.",
"Auto: tries it where your CPU limits the frame, keeps it if frames get faster.": "Avtomatik: CPU-nun kadrı məhdudlaşdırdığı yerdə sınayır, kadrlar sürətlənirsə saxlayır.",
"Hides entities behind opaque blocks.": "Obyektləri qeyri-şəffaf blokların arxasında gizlədir.",
"No entity occlusion culling.": "Heç bir obyektin okklyuziyası məhv edilmir.",
"CPU occlusion test runs this frame.": "CPU tıxanma testi bu çərçivəni idarə edir.",
@@ -174,8 +336,10 @@
"Sorts water and glass more exactly behind each other.": "Suyu və şüşəni bir-birinin arxasında daha dəqiq şəkildə sıralayır.",
"Which chunk rebuilds wait for the frame: none, nearby or yours.": "Hansı hissənin yenidən qurulması çərçivəni gözləyir: heç biri, yaxınlıqda və ya sizin.",
"Animates water, lava, fire and other moving textures.": "Su, lava, od və digər hərəkətli dokuları canlandırır.",
"World pixels per screen pixel. Below 100% is faster on the GPU.": "Ekran pikselinə düşən dünya pikseli. 100%-dən aşağı GPU-da daha sürətlidir.",
"Smooths jagged edges. FXAA: softer. TAA: smoothest, moving things may trail.": "Kəsik kənarları hamarlayır. FXAA: daha yumşaq. TAA: ən hamar, hərəkətli şeylər cığır çəkə bilər.",
"Renders the world smaller and rebuilds it with TAA. Faster, softer.": "Dünyanı daha kiçik edir və TAA ilə yenidən qurur. Daha sürətli, daha yumşaq.",
"Sharpens the upscaled picture. Higher: crisper edges, more shimmer.": "Böyüdülmüş təsviri kəskinləşdirir. Daha yüksək: daha aydın kənarlar, daha çox parıltı.",
"HDR output on an HDR display: lava, fire and sunlight shine brighter.": "HDR displeydə HDR çıxışı: lava, yanğın və günəş işığı daha parlaq parlayır.",
"How frames reach the screen while VSync is off.": "VSync söndürüldükdə çərçivələr ekrana necə çatır.",
"Efficient sleeps until the next frame; Precise spins, like vanilla.": "Növbəti kadra qədər səmərəli yuxular; Vanil kimi dəqiq spinlər.",
@@ -195,9 +359,11 @@
"Frame rate and frame times over the game, with a graph.": "Qrafik ilə oyun üzrə çərçivə sürəti və kadr vaxtları.",
"horizon.multiplayer.finding_friend": "Dostunuzun dünyasını tapmaq...",
"horizon.multiplayer.not_signed_in": "Bu server onlayn rejimdədir. Qoşulmaq üçün Horizon başlatma cihazında Microsoft hesabınızla daxil olun.",
"horizon.realms.not_signed_in": "Realms-də oynamaq üçün Horizon başlatıcısında Microsoft hesabınızla daxil olun.",
"horizon.multiplayer.invalid_address": "Yanlış server ünvanı: %s",
"horizon.multiplayer.connected_friend_direct": "Dostunuza birbaşa qoşulun...",
"horizon.multiplayer.connected_friend_relay": "Rele vasitəsilə dostunuza qoşulun...",
"General": "Ümumi",
"Advanced": "Qabaqcıl",
"Undo": "Geri alın",
"Search options": "Axtarış seçimləri",
@@ -210,8 +376,10 @@
"Textures": "Dokular",
"Image": "Şəkil",
"Chunk loading": "Parça yükləmə",
"Culling": "Kəsmə",
"Power": "Güc",
"Frames": "Çərçivələr",
"Overlay": "Örtük",
"Output": "Çıxış",
"Brightness": "Parlaqlıq",
"How far you see, the window and the HUD.": "Nə qədər görürsən, pəncərə və HUD.",
@@ -221,12 +389,14 @@
"Brighter-than-white output on an HDR display.": "HDR displeydə ağdan daha parlaq çıxış.",
"Results for \"%s\"": "\"%s\" üçün nəticələr",
"No settings match.": "Heç bir parametr uyğun gəlmir.",
"horizon.plain.official": "Rəsmi Horizon, başlatıcı, modlar və yeniləmələrlə: playhorizon.dev",
"horizon.title.not_affiliated": "Mojang və ya Microsoft ilə əlaqəli deyil",
"horizon.extras.header.lighting": "İşıqlandırma",
"horizon.extras.header.third_person": "Üçüncü şəxs",
"horizon.extras.header.particles": "hissəciklər",
"horizon.extras.header.inventory": "İnventar",
"horizon.extras.header.chat": "Söhbət",
"horizon.extras.header.multiplayer": "Çoxoyunçulu",
"horizon.extras.header.sound": "Səs",
"horizon.extras.header.menus": "Menyular",
"horizon.extras.fullbright": "Tam parlaq",
@@ -242,8 +412,13 @@
"horizon.extras.sort_key": "Sıralama açarı",
"horizon.extras.no_ai": "AI yoxdur",
"horizon.extras.agent_control": "Agent Nəzarəti",
"horizon.extras.discord_presence": "Discord Rich Presence",
"horizon.extras.zoom": "Yaxınlaşdırma",
"horizon.extras.zoom_mod_install": "Yaxınlaşdırma: Modlardan quraşdırın",
"horizon.extras.splitscreen": "Bölünmüş ekran",
"horizon.extras.chat_animation": "Rahat söhbət",
"horizon.extras.chat_indicator": "Mesaj göstəricisi",
"horizon.extras.wheel_cycles_list_settings.tooltip": "Parametrlər siyahısındakı düymənin üzərində sürüşdürmə çarxı onun dəyərini dəyişir. Söndür, vanildə olduğu kimi, çarx siyahını sürüşdürür.",
"horizon.extras.wheel_cycles_settings": "Sürüşdürmə çarxı parametrləri dəyişir",
"horizon.extras.pack_browser": "Resurs paketi brauzeri",
"horizon.extras.fullbright.tooltip": "Blokları və obyektləri hətta qaranlıq yerlərdə də tam işıqda göstərin.",
@@ -304,9 +479,11 @@
"horizon.friends.stopped": "Dəvət kodunu paylaşmağı dayandırdı.",
"horizon.friends.short_code_changed": "Kimsə səhv qısa kodu sınamağa davam etdi, ona görə də dəyişdi.",
"horizon.friends.wrong_code": "Kimsə səhv kodu sınadı.",
"horizon.friends.relay_stopped": "Çox sayda yanlış qısa kod daxil edildi, ona görə yenidən paylaşana qədər rele söndürülüb. Dostlar hələ də tam kodla qoşula bilər.",
"horizon.friends.code_ready": "Dəvət kodunuz hazırdır: Oyun Menyu, Dostlarla Oyna. Kopyalayın və dostlarınıza göndərin.",
"horizon.friends.share_failed": "Dünyanı paylaşmaq mümkün olmadı: %s",
"horizon.friends.finding_route": "Dostların sizinlə necə əlaqə saxlaya biləcəyi tapılır...",
"horizon.osk.shift": "Shift",
"horizon.osk.space": "Kosmos",
"horizon.osk.delete": "Sil",
"horizon.osk.type": "Növ",
@@ -316,11 +493,13 @@
"horizon.extras.chat_indicator.tooltip": "Söhbət xəttinin solunda olan rəngli bar.",
"horizon.extras.pack_browser.tooltip": "Resurs Paketlərini seçin Modrinth-də axtarış aparan və paketlər qovluğuna paket quraşdıran Əlavə et düyməsini alır. Off, oyun Modrinthlə heç vaxt əlaqə saxlamır.",
"horizon.extras.splitscreen.tooltip": "Dünyada başqa bir nəzarətçi yeni oyunçu kimi qoşula bilər: soruşduqda A düyməsini basın, sonra profil və harada oynayacağınızı seçin.",
"horizon.splitscreen.setup.monitor": "Monitor %s",
"horizon.splitscreen.setup.monitor_unknown": "monitor",
"horizon.splitscreen.setup.window": "Pəncərə %s",
"horizon.splitscreen.setup.new_window": "Yeni pəncərə",
"horizon.splitscreen.setup.fill_monitor": "%s doldurun",
"horizon.splitscreen.setup.done": "Bitdi",
"horizon.splitscreen.setup.switch": "%s: %s",
"horizon.splitscreen.setup.on": "Aktiv",
"horizon.splitscreen.setup.off": "Söndür",
"horizon.splitscreen.setup.player": "Oyunçu %s",
@@ -378,6 +557,9 @@
"horizon.splitscreen.setup.help.give_up_pad": "Nəzarətçiniz sizi idarə etməyi dayandırır və ona qoşulmaq təklif olunur (A).",
"horizon.splitscreen.setup.note.held_at": "%s to %s: A onu bura düşür, B onu geri qoyur.",
"horizon.splitscreen.setup.note.held": "%s götürdü: sol/sağ yeri seçir, A düşür, B onu geri qoyur.",
"horizon.splitscreen.online_only": "Bu server onlayn rejimdədir: oflayn profillər ona qoşula bilməz, hər oyunçunun öz Minecraft hesabı lazımdır.",
"horizon.splitscreen.own_account": "Bu server hər oyunçunun öz Minecraft hesabını tələb edir",
"horizon.splitscreen.local_only": "Digər oyunçular bu serverə yalnız öz Minecraft hesabları ilə qoşula bilər: oflayn profillər sizin dünyanızda, LAN-da və ya yerli şəbəkənizdəki serverdə oynayır.",
"Hides models behind opaque blocks. Async checks off-thread; entities tick.": "Modelləri qeyri-şəffaf blokların arxasında gizlədir. Async-dən kənar yoxlayır; qurumlar işarələyir.",
"Block Entities": "Blok obyektləri",
"Shulker Boxes": "Shulker qutuları",
@@ -396,6 +578,8 @@
"Reuses statue geometry until its block state changes.": "Blok vəziyyəti dəyişənə qədər heykəlin həndəsəsindən təkrar istifadə edir.",
"Leaf Culling": "Yarpaqların kəsilməsi",
"Every leaf face. Vanilla's fancy leaves.": "Hər yarpaq üzü. Vanilin gözəl yarpaqları.",
"Opaque Ice": "Qeyri-şəffaf buz",
"Draws ice in the solid pass, without its see-through texture.": "Buzu şəffaf teksturası olmadan, bərk keçiddə çəkir.",
"Distant Plant Dither": "Uzaq Bitki Dither",
"Keeps fine plants in high-res packs from fading far away.": "Yüksək dəqiqlikli paketlərdəki incə bitkilərin uzaqdan solmasını qarşısını alır.",
"Screenshot Quality": "Ekran Şotunun Keyfiyyəti",
@@ -403,13 +587,27 @@
"Frame Generation": "Çərçivə Yaradılması",
"Shows a made-up frame between each two real ones: smoother, a little more lag.": "Hər iki real arasında düzəldilmiş çərçivəni göstərir: daha hamar, bir az daha gecikmə.",
"Shadow Cache": "Kölgə önbelleği",
"Shaders": "Şeyderlər",
"Shader packs, Horizon's and shaders/ folder ones, and their options.": "Şeyder paketləri, Horizon-un və shaders/ qovluğundakılar, və onların seçimləri.",
"Shader packs": "Şeyder paketləri",
"Vanilla's own preset: sets only vanilla's options, as Minecraft does.": "Vanilin öz əvvəlcədən təyini: yalnız vanil seçimlərini təyin edir, Minecraft kimi.",
"Fullscreen takes the display over, at the mode below.": "Tam ekran ekranı ələ keçirir, aşağıdakı rejimdə.",
"The display mode exclusive fullscreen uses. Applies when you leave.": "Eksklüziv tam ekranın istifadə etdiyi ekran rejimi. Çıxdığınızda tətbiq olunur.",
"Blurs the world behind menus.": "Menyuların arxasındakı dünyanı bulanıqlaşdırır.",
"How textures are sampled: sharper at a slant with Anisotropic.": "Teksturaların necə nümunələndiyi: Anizotrop ilə maili baxışda daha kəskin.",
"How far Anisotropic filtering goes. Higher is sharper at a slant.": "Anizotrop filtrləmənin nə qədər getdiyi. Daha yüksək maili baxışda daha kəskindir.",
"Only with Anisotropic texture filtering.": "Yalnız anizotrop tekstura filtrləməsi ilə.",
"Shader Packs...": "Shader Paketləri...",
"Picks a shader pack, Horizon's or a shaders/ folder one, and sets its options.": "Şeyder paketi seçir, Horizon-un və ya shaders/ qovluğundan, və onun seçimlərini təyin edir.",
"Reuses the pack shadow map when sun, view and world match. Off by default.": "Günəş, görünüş və dünya uyğunlaşdıqda paket kölgə xəritəsindən təkrar istifadə edir. Defolt olaraq söndürülür.",
"Find BlueMap": "BlueMap tapın",
"On servers, finds their BlueMap web map for far terrain. Next join.": "Serverlərdə uzaq ərazilər üçün BlueMap veb xəritəsini tapır. Növbəti qoşulun.",
"BlueMap Radius": "BlueMap radiusu",
"How far around you BlueMap tiles are downloaded. Next join.": "Ətrafınızda BlueMap plitələri nə qədər yüklənir. Növbəti qoşulun.",
"Synchronous": "Sinxron",
"Asynchronous": "Asinxron",
"Hidden Faces": "Gizli üzlər",
"Drops leaf faces behind two leaf layers: faster, same crowns.": "İki yarpaq qatının arxasındakı yarpaq üzlərini atır: daha sürətli, eyni taclar.",
"Solid": "Möhkəm",
"Solid Aggressive": "Möhkəm Aqressiv",
"Hollow": "Boş",
@@ -427,8 +625,14 @@
"horizon.agent.session_opened": "Oyuna başlayan proqram tərəfindən bu sessiya üçün açıldı.",
"horizon.agent.socket": "Soket: %s (tam yolu üçün təfərrüatları kopyalayın)",
"horizon.agent.local_only": "Yalnız yerli: bu kompüterdəki agentlər, heç vaxt şəbəkə.",
"%s nits": "%s nit",
"Auto (%s nits)": "Avtomatik (%s nits)",
"%.2f nits": "%.2f nit",
"Hides models behind opaque blocks. Entities update. Off with custom packs.": "Modelləri qeyri-şəffaf blokların arxasında gizlədir. Müəssisələrin yenilənməsi. Fərdi paketlərlə endirim.",
"Entity Culling Precheck": "Obyekt kəsmə ön yoxlaması",
"Runs the mob AABB precheck after this many loaded mobs. Zero disables it.": "Yüklənmiş mob sayı bu həddə çatanda mob AABB ön yoxlamasını işlədir. Sıfır onu söndürür.",
"%s mobs": "%s mob",
"Only applies to asynchronous entity culling.": "Yalnız asinxron obyekt kəsməsinə aiddir.",
"horizon.extras.repro_keys": "Qısayolların qeyd edilməsi",
"horizon.extras.repro_keys.tooltip": "F3+R repro qeydini başlayır və ya dayandırır və F3+E öz paketini yazır. Defolt olaraq söndürülür: yazı dünyanı kopyalayır.",
"horizon.agent.record_session": "Səs yazmağa başlayın",
@@ -440,6 +644,17 @@
"horizon.repro.error": "Reproduksiya: %s",
"horizon.repro.no_recording": "İxrac etmək üçün hələ heç bir qeyd yoxdur.",
"horizon.repro.replay_started": "Təkrar başladı: %s",
"horizon.splitscreen.profiles.offline": " (Oflayn)",
"horizon.splitscreen.profiles.microsoft": " (Microsoft)",
"horizon.splitscreen.profiles.sign_in": "Başlatıcıda yenidən daxil olun",
"horizon.splitscreen.profiles.online": "Microsoft onlayn",
"horizon.splitscreen.account_unavailable": "%s oflayndır. Başlatıcıda yenidən daxil olun, sonra oyunu yenidən başladın.",
"horizon.splitscreen.account_expired": "%s istifadəçisinin Microsoft girişinin vaxtı bitib. Başlatıcıda yenidən daxil olun, sonra oyunu yenidən başladın.",
"Particle Culling": "Hissəcik kəsməsi",
"Draws every particle.": "Hər hissəciyi çəkir.",
"Skips offscreen, subpixel and terrain-hidden particles.": "Ekrandan kənar, subpiksel və ərazi ilə gizlənmiş hissəcikləri atlayır.",
"Skips offscreen, subpixel and terrain-hidden particles with less render-thread work.": "Ekrandan kənar, subpiksel və ərazi ilə gizlənmiş hissəcikləri daha az render ipi işi ilə atlayır.",
"horizon.pack.add": "+",
"horizon.pack.addInfo": "Modrinth-dən resurs paketi əlavə edin",
"horizon.pack.addWorking": "Modrinthdən soruşur...",
"horizon.pack.addNone": "Heç bir resurs paketi tapılmadı",
@@ -453,10 +668,28 @@
"horizon.pack.filter.realistic": "Realist",
"horizon.pack.filter.simplistic": "Sadə",
"horizon.pack.filter.vanillaLike": "Vanil kimi",
"horizon.pack.filter.x16": "16x",
"horizon.pack.filter.x32": "32x",
"horizon.pack.filter.x64": "64x",
"horizon.pack.addFailed": "Paketi əlavə etmək mümkün olmadı: %s",
"horizon.pack.options": "Paket seçimləri",
"horizon.pack.optionsInfo": "Bu paketin seçimləri",
"horizon.pack.options.title": "Paket seçimləri",
"horizon.pack.options.general": "Ümumi",
"horizon.pack.options.presets": "Əvvəlcədən təyinlər",
"horizon.pack.options.presets.custom": "Xüsusi",
"horizon.pack.options.impact": "Performans təsiri: %s",
"horizon.pack.options.impact.low": "aşağı",
"horizon.pack.options.impact.medium": "orta",
"horizon.pack.options.impact.high": "yüksək",
"horizon.pack.options.impact.varies": "dəyişir",
"horizon.extras.header.cosmetics": "Kosmetika",
"horizon.extras.cosmetics_mine": "Kosmetikam",
"horizon.extras.cosmetics_mine.tooltip": "Gizli: hər Horizon oyunçu sizi kosmetikanız olmadan görür. Başlatma şkafı ilə eyni açar.",
"key.emote": "Emosiya çarxı",
"horizon.emote_wheel.title": "Emosiyalar",
"horizon.emote_wheel.empty": "Hələ emosiya yoxdur",
"horizon.emote_wheel.help": "Oynatmaq üçün buraxın və ya klikləyin · 1–8 · Esc bağlayır",
"horizon.extras.cosmetics_others": "Digər oyunçuların kosmetikası",
"horizon.extras.cosmetics_others.tooltip": "Digər Horizon oyunçular nə geyinirlər: hər şey, yalnız ad etiketi üslubları və ya heç nə. Gizli, oyun heç vaxt nə geyindiklərini soruşmur.",
"horizon.extras.cosmetics_hide_fx": "Digər oyunçuların izlərini və auralarını gizlət",
@@ -480,5 +713,349 @@
"Pack settings": "Paket seçimləri",
"Built-in settings": "Daxili seçimlər",
"%s settings": "%s seçimləri",
"Settings made by the pack's author: they change only this pack.": "Paketin müəllifinin yaratdığı seçimlər: yalnız bu paketə təsir edir."
"Settings made by the pack's author: they change only this pack.": "Paketin müəllifinin yaratdığı seçimlər: yalnız bu paketə təsir edir.",
"horizon.moderation.duration.minute": "%s dəqiqə",
"horizon.moderation.duration.minutes": "%s dəqiqə",
"horizon.moderation.duration.hour": "%s saat",
"horizon.moderation.duration.hours": "%s saat",
"horizon.moderation.duration.day": "%s gün",
"horizon.moderation.duration.days": "%s gün",
"horizon.moderation.reason": "%1$s: %2$s",
"horizon.moderation.mutelist.empty": "Heç bir susdurma yoxdur",
"horizon.moderation.mutelist.count": "%s susdurma var:",
"horizon.moderation.chat_filter": "söhbət filtri",
"horizon.moderation.until_unmuted": "səsi açılana qədər",
"horizon.moderation.remaining": "daha %s müddətinə",
"horizon.moderation.mutelist.entry": "%1$s, %2$s tərəfindən %3$s susduruldu",
"horizon.moderation.invalid_duration": "%s müddət deyil: 10m, 2h, 1d",
"horizon.moderation.usage.mute": "/mute <player> [duration] [reason]",
"horizon.moderation.usage.tempmute": "/tempmute <player> <duration> [reason]",
"horizon.moderation.usage.unmute": "/unmute <player>",
"horizon.moderation.usage.mutelist": "/mutelist",
"horizon.moderation.for_duration": "%s müddətinə",
"horizon.moderation.muted": "%1$s susduruldu %2$s",
"horizon.moderation.unmuted": "%s üçün susdurma götürüldü",
"horizon.moderation.not_muted": "%s susdurulmayıb",
"horizon.tempban.usage": "/tempban <player> <duration> [reason]",
"horizon.tempban.invalid_duration": "%s müddət deyil: 30m, 12h, 7d",
"horizon.tempban.dedicated_required": "Müvəqqəti qadağalar üçün ayrılmış server lazımdır",
"horizon.tempban.success": "%1$s, %2$s müddətinə, %3$s tarixinə qədər",
"horizon.tempban.list.empty": "Heç bir müvəqqəti qadağa yoxdur",
"horizon.tempban.list.count": "%s müvəqqəti qadağa var:",
"horizon.tempban.list.entry": "%1$s, %2$s tərəfindən %3$s qadağan edildi (%4$s tarixinə qədər)",
"horizon.options.pointer_speed": "Menyu göstərici sürəti",
"horizon.options.scroll_speed": "Menyu sürüşdürmə sürəti",
"horizon.options.repeat_delay": "Naviqasiya təkrar gecikməsi",
"horizon.options.repeat_interval": "Naviqasiya təkrar intervalı",
"horizon.options.milliseconds": "%s ms",
"horizon.extras.cosmetics_solo": "Tək oyunçuda",
"horizon.extras.cosmetics_solo.tooltip": "Kosmetikanızı və ad etiketinizin nişanını tək oyunçu dünyalarınızda da göstərin (Öz ad etiketi ilə ad etiketiniz). Söndür: yalnız serverlərdə.",
"horizon.extras.horizon_marks": "Horizon loqoları",
"horizon.extras.horizon_marks.tooltip": "Horizon oyunçularının yanında Horizon loqosunu göstərin: Horizon istifadə edən digər oyunçuların adlarının önündə kiçik loqo, başlarının üstündə və oyunçu siyahısında. Yalnız Horizon oyunçuları görür: serverlərə heç nə göndərilmir.",
"horizon.extras.horizon_me": "Məni Horizon kimi göstər",
"horizon.extras.horizon_me.tooltip": "Məni Horizon oyunçusu kimi göstər: digər Horizon oyunçuları adınızın yanında loqonu görür. Aktiv olduqda xidmət sizin haqqınızda soruşan hər kəsə son 3 saatda Horizon oynadığınızı bildirir; söndürüldükdə heç vaxt sizi bildirmir.",
"horizon.pages.pvp": "PvP və HUD",
"horizon.pages.pvp.tooltip": "Nişangah, HUD və PvP parametrləri, hamısı defolt olaraq söndürülüb. Burada heç nə vanilin göstərmədiyindən artıq göstərmir.",
"horizon.pages.open.pvp": "PvP və HUD...",
"horizon.pvp.header.crosshair": "Nişangah",
"horizon.pvp.crosshair": "Xüsusi nişangah",
"horizon.pvp.crosshair_shape": "Forma",
"horizon.pvp.crosshair_size": "Ölçü",
"horizon.pvp.crosshair_gap": "Aralıq",
"horizon.pvp.crosshair_thickness": "Qalınlıq",
"horizon.pvp.crosshair_colour": "Rəng",
"horizon.pvp.crosshair_outline": "Kontur",
"horizon.pvp.crosshair_dot": "Mərkəz nöqtəsi",
"horizon.pvp.crosshair_hover": "Obyektlər üzərində rəng",
"horizon.pvp.crosshair.tooltip": "Vanilin nişangahı yerinə öz nişangahınızı çəkir. İlk parametrləri vanil kimi görünür.",
"horizon.pvp.crosshair_shape.tooltip": "Xaç, T, X, dairə, kvadrat və ya yalnız nöqtə.",
"horizon.pvp.crosshair_size.tooltip": "Hər qolun uzunluğu, GUI pikseli ilə.",
"horizon.pvp.crosshair_gap.tooltip": "Ortadakı boş sahə.",
"horizon.pvp.crosshair_thickness.tooltip": "Hər xəttin nə qədər enli olduğu.",
"horizon.pvp.crosshair_colour.tooltip": "Tərs, altındakı rəngi tərs göstərir, vanilin nişangahı kimi.",
"horizon.pvp.crosshair_outline.tooltip": "Nazik qara kənar, hər fonda görünməsi üçün.",
"horizon.pvp.crosshair_dot.tooltip": "Nişangahın ortasında nöqtə.",
"horizon.pvp.crosshair_hover.tooltip": "Nişangah vura biləcəyiniz bir mob və ya oyunçunun üzərində olduqda başqa rəng, vanilin hücum göstəricisinin bildiyi kimi.",
"horizon.pvp.colour.inverted": "Tərs",
"horizon.pvp.colour.white": "Ağ",
"horizon.pvp.colour.red": "Qırmızı",
"horizon.pvp.colour.orange": "Narıncı",
"horizon.pvp.colour.yellow": "Sarı",
"horizon.pvp.colour.green": "Yaşıl",
"horizon.pvp.colour.cyan": "Firuzəyi",
"horizon.pvp.colour.blue": "Mavi",
"horizon.pvp.colour.magenta": "Magenta",
"horizon.pvp.colour.black": "Qara",
"horizon.pvp.shape.cross": "Xaç",
"horizon.pvp.shape.t": "T",
"horizon.pvp.shape.x": "X",
"horizon.pvp.shape.circle": "Dairə",
"horizon.pvp.shape.square": "Kvadrat",
"horizon.pvp.shape.dot": "Nöqtə",
"horizon.pvp.outline": "Xüsusi blok konturu",
"horizon.pvp.outline.tooltip": "Baxdığınız blokun ətrafındakı konturu dəyişir.",
"horizon.pvp.outline_colour": "Rəng",
"horizon.pvp.outline_colour.tooltip": "Konturun rəngi. 40%-də qara vanilinkidir.",
"horizon.pvp.outline_opacity": "Qeyri-şəffaflıq",
"horizon.pvp.outline_opacity.tooltip": "Konturun nə qədər bərk olduğu.",
"horizon.pvp.outline_thickness": "Qalınlıq",
"horizon.pvp.outline_thickness.tooltip": "Konturun eni, vanilin qatları ilə.",
"horizon.pvp.outline_fill": "Doldurma",
"horizon.pvp.outline_fill.tooltip": "Baxdığınız blokun üzlərini yüngülcə rəngləyir.",
"horizon.pvp.hit": "Xüsusi vurma rəngi",
"horizon.pvp.hit.tooltip": "Mob və oyunçuların yaralandıqda yanıb-sönən rəngini dəyişir. 30%-də qırmızı vanilinkidir.",
"horizon.pvp.hit_colour": "Rəng",
"horizon.pvp.hit_colour.tooltip": "Yaralı mob və oyunçuların yanıb-söndüyü rəng.",
"horizon.pvp.hit_opacity": "Qeyri-şəffaflıq",
"horizon.pvp.hit_opacity.tooltip": "Rəngin onları nə qədər örtdüyü.",
"horizon.pvp.header.outline": "Blok konturu",
"horizon.pvp.header.hit": "Vurma rəngi",
"horizon.pvp.hud_armour": "Zireh",
"horizon.pvp.hud_armour.tooltip": "Geyindiyiniz zireh, başdan başlayaraq, hər parçanın qalan istifadə sayı ilə.",
"horizon.pvp.hud_armour_corner": "Zireh küncü",
"horizon.pvp.hud_armour_corner.tooltip": "Zirehin göstərildiyi yer.",
"horizon.pvp.hud_armour_percent": "Davamlılıq % ilə",
"horizon.pvp.hud_armour_percent.tooltip": "Qalan istifadələr rəqəm yerinə parçanın maksimumunun faizi kimi.",
"horizon.pvp.hud_effects": "Effektlər",
"horizon.pvp.hud_effects.tooltip": "Yuxarı sağdakı ikonların yerinə, adları, səviyyələri və qalan vaxtı ilə effektləriniz.",
"horizon.pvp.hud_effects_corner": "Effektlər küncü",
"horizon.pvp.hud_effects_corner.tooltip": "Effektlərin göstərildiyi yer.",
"horizon.pvp.hud_held": "Əldəki əşya sayı",
"horizon.pvp.hud_held.tooltip": "Əlinizdəki əşyadan inventarınızda nə qədər var; yay və ya arbalet ilə oxlar.",
"horizon.pvp.hud_held_corner": "Əldəki say küncü",
"horizon.pvp.hud_held_corner.tooltip": "Sayın göstərildiyi yer.",
"horizon.pvp.hud_background": "Vidcet fonları",
"horizon.pvp.hud_background.tooltip": "Hər vidcetin arxasında tünd qutu.",
"horizon.pvp.hud_opacity": "Fon qeyri-şəffaflığı",
"horizon.pvp.hud_opacity.tooltip": "Vidcetlərin arxasındakı qutuların nə qədər tünd olduğu.",
"horizon.pvp.hud_text_colour": "Mətn rəngi",
"horizon.pvp.hud_text_colour.tooltip": "Vidcetlərin mətn rəngi. Davamlılıq yaşıldan qırmızıya keçidini saxlayır.",
"horizon.pvp.hud_shadow": "Mətn kölgəsi",
"horizon.pvp.hud_shadow.tooltip": "Vidcet mətninin altında kölgə, vanil HUD mətnində olduğu kimi.",
"horizon.pvp.header.hud": "HUD vidcetləri",
"horizon.pvp.corner.top_left": "Yuxarı sol",
"horizon.pvp.corner.top_right": "Yuxarı sağ",
"horizon.pvp.corner.bottom_left": "Aşağı sol",
"horizon.pvp.corner.bottom_right": "Aşağı sağ",
"horizon.pvp.hud_ping": "Ping",
"horizon.pvp.hud_ping.tooltip": "Serverin sizin üçün bildirdiyi gecikmə, millisaniyə ilə. Yalnız çoxoyunçulu rejimdə.",
"horizon.pvp.hud_ping_corner": "Ping küncü",
"horizon.pvp.hud_ping_corner.tooltip": "Pingin göstərildiyi yer.",
"horizon.pvp.hud_coords": "Koordinatlar",
"horizon.pvp.hud_coords.tooltip": "Blok mövqeyiniz və baxdığınız istiqamət, F3 göstərdiyi kimi. Dünya və ya server F3-ü məhdudlaşdırdıqda gizlədilir.",
"horizon.pvp.hud_coords_corner": "Koordinatlar küncü",
"horizon.pvp.hud_coords_corner.tooltip": "Koordinatların göstərildiyi yer.",
"horizon.pvp.hud_server": "Server ünvanı",
"horizon.pvp.hud_server.tooltip": "Oynadığınız serverin ünvanı. Yalnız çoxoyunçulu rejimdə.",
"horizon.pvp.hud_server_corner": "Server küncü",
"horizon.pvp.hud_server_corner.tooltip": "Ünvanın göstərildiyi yer.",
"horizon.pvp.hud_combo": "Kombo sayğacı",
"horizon.pvp.hud_combo.tooltip": "Serverin təsdiqlədiyi ardıcıl vuruşlar, yaralanana və ya 3 saniyə vuruş olmadan keçənə qədər.",
"horizon.pvp.hud_combo_corner": "Kombo küncü",
"horizon.pvp.hud_combo_corner.tooltip": "Kombonun göstərildiyi yer.",
"horizon.pvp.hud.ping": "Ping: %s ms",
"horizon.pvp.hud.coords": "XYZ: %s %s %s",
"horizon.pvp.hud.facing": "İstiqamət: %s",
"horizon.pvp.hud.combo": "Kombo: %s",
"horizon.pvp.hud.direction.north": "Şimal",
"horizon.pvp.hud.direction.south": "Cənub",
"horizon.pvp.hud.direction.east": "Şərq",
"horizon.pvp.hud.direction.west": "Qərb",
"horizon.pvp.hud_fps": "FPS",
"horizon.pvp.hud_fps.tooltip": "Son saniyədə çəkilmiş kadrlar.",
"horizon.pvp.hud_fps_corner": "FPS küncü",
"horizon.pvp.hud_fps_corner.tooltip": "FPS-in göstərildiyi yer.",
"horizon.pvp.hud_cps": "Saniyədə kliklər",
"horizon.pvp.hud_cps.tooltip": "Son saniyədə sol və sağ klikləriniz.",
"horizon.pvp.hud_cps_corner": "CPS küncü",
"horizon.pvp.hud_cps_corner.tooltip": "Saniyədə kliklərin göstərildiyi yer.",
"horizon.pvp.hud_keys": "Klaviş vuruşları",
"horizon.pvp.hud_keys.tooltip": "Hərəkət düymələriniz, siçan düymələri və tullanma, basılı saxladığınızda işıqlanır, saniyədə kliklərlə.",
"horizon.pvp.hud_keys_corner": "Klaviş vuruşları küncü",
"horizon.pvp.hud_keys_corner.tooltip": "Klaviş vuruşlarının göstərildiyi yer.",
"horizon.pvp.hud_clock": "Saat",
"horizon.pvp.hud_clock.tooltip": "Kompüterinizin vaxtı.",
"horizon.pvp.hud_clock_corner": "Saat küncü",
"horizon.pvp.hud_clock_corner.tooltip": "Saatın göstərildiyi yer.",
"horizon.pvp.hud_reach": "Çatma məsafəsi",
"horizon.pvp.hud_reach.tooltip": "Son vuruşunuzun gözlərinizdən nə qədər uzaqda yerə düşdüyü, 3 saniyəlik. Yalnız ölçür: çatma məsafəniz vanil olaraq qalır.",
"horizon.pvp.hud_reach_corner": "Çatma küncü",
"horizon.pvp.hud_reach_corner.tooltip": "Çatma məsafəsinin göstərildiyi yer.",
"horizon.pvp.hud_speed": "Sürət",
"horizon.pvp.hud_speed.tooltip": "Yer üzərində nə qədər sürətli hərəkət etdiyiniz, saniyədə blokla.",
"horizon.pvp.hud_speed_corner": "Sürət küncü",
"horizon.pvp.hud_speed_corner.tooltip": "Sürətin göstərildiyi yer.",
"horizon.pvp.hud.fps": "%s FPS",
"horizon.pvp.hud.cps": "CPS: %s | %s",
"horizon.pvp.hud.reach": "Çatma: %s",
"horizon.pvp.hud.speed": "Sürət: %s b/s",
"horizon.pvp.hud.keys.attack": "LMB",
"horizon.pvp.hud.keys.use": "RMB",
"horizon.pvp.hud.keys.cps": "%s CPS",
"horizon.pvp.numeric_enchants": "Sehr səviyyələri rəqəmlə",
"horizon.pvp.numeric_enchants.tooltip": "Alət ipucları və sehr masası Kəskinlik V yerinə Kəskinlik 5 göstərir.",
"horizon.pvp.header.items": "Əşyalar",
"horizon.pvp.fire_lower": "Alçaq od",
"horizon.pvp.fire_lower.tooltip": "Yanarkən gördüyünüz alovları aşağı salır, belə ki ekranı daha az örtür.",
"horizon.pvp.shield_size": "Qalxan ölçüsü",
"horizon.pvp.shield_size.tooltip": "Birinci şəxsdə əlinizdəki qalxanın ölçüsü.",
"horizon.pvp.totem_size": "Totem animasiyasının ölçüsü",
"horizon.pvp.totem_size.tooltip": "Sizi xilas edəndə ekranı dolduran totemin ölçüsü.",
"horizon.pvp.glint_colour": "Sehr parıltısının rəngi",
"horizon.pvp.glint_colour.tooltip": "Sehrlənmiş əşyalar və zirehlər üzərindəki parıltının rəngi. Söndür vanilin bənövşəyisidir.",
"horizon.pvp.clear_water": "Şəffaf su",
"horizon.pvp.clear_water.tooltip": "Suyun altında, vanilin 30 saniyədən sonra icazə verdiyi qədər uzağı əvvəldən görün.",
"horizon.pvp.clear_lava": "Şəffaf lava",
"horizon.pvp.clear_lava.tooltip": "Lavada, effekt olmadan vanilin Odadavamlılıq effektinin icazə verdiyi qədər uzağı görün.",
"horizon.pvp.header.visual": "Vizual düzəlişlər",
"horizon.pvp.view_time": "Günün vaxtı",
"horizon.pvp.view_time.tooltip": "Gördüyünüz günün vaxtı: yalnız səma və onun işığı. Dünyanın vaxtı davam edir və serverə heç nə göndərilmir.",
"horizon.pvp.view_weather": "Hava",
"horizon.pvp.view_weather.tooltip": "Gördüyünüz hava: yalnız yağış, onun işığı və duman. Dünyanın havası davam edir və serverə heç nə göndərilmir.",
"horizon.pvp.header.view": "Sizin görünüşünüz",
"horizon.pvp.view.world": "Dünyanın",
"horizon.pvp.time.morning": "Səhər",
"horizon.pvp.time.noon": "Günorta",
"horizon.pvp.time.sunset": "Gün batımı",
"horizon.pvp.time.midnight": "Gecə yarısı",
"horizon.pvp.weather.clear": "Aydın",
"horizon.pvp.weather.rain": "Yağış",
"horizon.pvp.weather.thunder": "İldırım",
"horizon.chat.timestamp": "[%s] ",
"horizon.chat.repeats": " (x%s)",
"horizon.pvp.header.chat": "Söhbət",
"horizon.chat.copied": "Mesaj kopyalandı",
"horizon.chat.search": "Söhbətdə axtar:",
"horizon.pvp.chat_timestamps": "Vaxt nişanları",
"horizon.pvp.chat_timestamps.tooltip": "Hər mesajın gəldiyi vaxt, ondan əvvəl.",
"horizon.pvp.chat_compact": "Yığcam söhbət",
"horizon.pvp.chat_compact.tooltip": "Ardıcıl təkrarlanan mesaj bir dəfə göstərilir, neçə dəfə gəldiyi ilə: (x3).",
"horizon.pvp.chat_copy": "Klikləyəndə kopyala",
"horizon.pvp.chat_copy.tooltip": "Açıq söhbətdə mesaja klikləmək onun mətnini kopyalayır, əgər klik başqa bir iş görmürsə.",
"horizon.pvp.chat_search": "Söhbətdə axtarış",
"horizon.pvp.chat_search.tooltip": "Açıq söhbətdə Ctrl+F: yazdığınız yalnız onu ehtiva edən mesajları göstərir. Enter və ya yenidən Ctrl+F dayandırır.",
"horizon.pvp.preset": "PvP əvvəlcədən təyini",
"horizon.pvp.preset.tooltip": "Xüsusi nişangahı, zireh və effekt vidcetlərini, qaçış və gizlənmə keçidini, alçaq odu, həmçinin quraşdırılıbsa FPS sayğacı və klaviş vuruşları modlarını yandırır. Hər biri üçün parametrləriniz qalır.",
"horizon.pvp.hud_editor_key": "HUD redaktoru düyməsi",
"horizon.pvp.hud_editor_key.tooltip": "Oyundan HUD redaktorunu açır. Klikləyin, sonra düyməyə basın; Escape onu ayırır.",
"horizon.pvp.editor": "HUD-u redaktə et",
"horizon.pvp.editor.tooltip": "Vidcetləri oyun üzərində hərəkət etdirin: istənilən yerə dartın, çarxla ölçüsünü dəyişin, birinə sağ klikləyərək onu küncünə qaytarın.",
"horizon.pvp.editor.hint": "Vidcetləri dartın · Çarx: ölçü · Sağ klik: küncünə qaytar",
"horizon.pvp.combat_feel": "Döyüş hissi",
"horizon.pvp.combat_feel.tooltip": "Klassik döyüşləri 1.8.9 kimi çəkir: əlinizdəki əşya, onun yellənməsi, yemək, yay və istifadə düyməsini basılı saxlayanda qaldırılmış qılınc, hər vuruşdan sonra enmə və hücum göstəricisi olmadan. Yalnız görünüş: serverə göndərdikləriniz eyni qalır.",
"horizon.pvp.combat_indicator": "Klassikdə hücum göstəricisi",
"horizon.pvp.combat_indicator.tooltip": "1.8.9-da olmayan hücum göstəricisini Klassik altında saxlayır.",
"horizon.pvp.combat_feel.modern": "Müasir",
"horizon.pvp.combat_feel.legacy": "Klassik (1.8.9)",
"horizon.pvp.header.combat": "Döyüş hissi",
"horizon.pvp.combat_sounds": "1.8 vuruş səsləri",
"horizon.pvp.combat_sounds.tooltip": "Klassik altında 1.9-un əlavə etdiyi hücum səslərini (güclü, zəif, süpürmə, kritik...) daxil etmir. Yaralanma səsləri qalır.",
"horizon.pvp.combat_rules": "Ev sahibliyi etdiyiniz dünyalarda 1.8 döyüşü",
"horizon.pvp.combat_rules.tooltip": "Tək oyunçu və LAN-a açdığınız dünyalar 1.8.9 qaydaları ilə döyüşür: hücum soyuması və süpürmə yoxdur, 1.8 silah zərəri, Güc, Kəskinlik və kritik vuruşlar. Qoşulduğunuz serverlər öz qaydalarını saxlayır.",
"Bindings": "Təyinatlar",
"Play": "Oyna",
"Device": "Qurğu",
"Look": "Baxış",
"Movement": "Hərəkət",
"Menus": "Menyular",
"Actions": "Hərəkətlər",
"Reset": "Sıfırla",
"Detected": "Aşkarlandı",
"Glyphs": "Qliflər",
"Feedback": "Əks əlaqə",
"Controller": "Nəzarətçi",
"How the sticks turn and move you, and how menus repeat and scroll.": "Çubuqların sizi necə çevirdiyi və hərəkət etdirdiyi, menyuların necə təkrarladığı və sürüşdüyü.",
"Which button does what. Each player has their own.": "Hansı düymənin nə etdiyi. Hər oyunçunun özü var.",
"The controller the game sees, its button glyphs and its feedback.": "Oyunun gördüyü nəzarətçi, onun düymə qlifləri və əks əlaqəsi.",
"Press this row, then the button that should do it.": "Bu sətrə basın, sonra bunu etməli olan düyməyə.",
"Off, the game ignores every controller for this player.": "Söndür, oyun bu oyunçu üçün bütün nəzarətçiləri nəzərə almır.",
"How fast the right stick turns the view.": "Sağ çubuğun görünüşü nə qədər sürətli çevirdiyi.",
"Turn the view the other way, left and right.": "Görünüşü əks istiqamətə çevir, sola və sağa.",
"Turn the view the other way, up and down.": "Görünüşü əks istiqamətə çevir, yuxarı və aşağı.",
"How far the right stick moves before the view turns. Raise it if the view drifts.": "Görünüş dönməzdən əvvəl sağ çubuğun nə qədər hərəkət etdiyi. Görünüş sürüşürsə, artırın.",
"Hold the button to sneak, or press it once to start and again to stop.": "Gizlənmək üçün düyməni basılı saxlayın, və ya bir dəfə basın başlasın, yenidən basın dayansın.",
"How far the left stick moves before you walk. Raise it if you drift.": "Yeriməzdən əvvəl sol çubuğun nə qədər hərəkət etdiyi. Sürüşürsünüzsə, artırın.",
"How far a trigger is pulled before it counts as pressed.": "Tətikin basılmış sayılması üçün nə qədər çəkilməli olduğu.",
"Shows what your buttons do right now, in play and in inventories.": "Düymələrinizin hazırda nə etdiyini göstərir, oyunda və inventarlarda.",
"Shows only what you can do with what you're looking at, and the main inventory buttons.": "Yalnız baxdığınız şeylə nə edə biləcəyinizi və əsas inventar düymələrini göstərir.",
"Doesn't show what your buttons do.": "Düymələrinizin nə etdiyini göstərmir.",
"Shows the buttons of the controller you're using.": "İstifadə etdiyiniz nəzarətçinin düymələrini göstərir.",
"Always shows Xbox buttons.": "Həmişə Xbox düymələrini göstərir.",
"Always shows PlayStation buttons.": "Həmişə PlayStation düymələrini göstərir.",
"Always shows Nintendo buttons.": "Həmişə Nintendo düymələrini göstərir.",
"A, on the right, confirms and jumps and B goes back, as on a Switch.": "Sağdakı A təsdiqləyir və tullanır, B geri qayıdır, Switch-də olduğu kimi.",
"B, at the bottom, confirms and jumps and A goes back, as on other controllers.": "Aşağıdakı B təsdiqləyir və tullanır, A geri qayıdır, digər nəzarətçilərdə olduğu kimi.",
"Opens Steam's keyboard on a Steam Deck under Steam, and Horizon's otherwise.": "Steam Deck-də Steam altında Steam-in klaviaturasını, əks halda Horizon-un klaviaturasını açır.",
"Always opens Horizon's keyboard.": "Həmişə Horizon-un klaviaturasını açır.",
"Always opens Steam's keyboard.": "Həmişə Steam-in klaviaturasını açır.",
"A DualSense's light shows your health, from green through amber to red.": "DualSense-in işığı sağlamlığınızı göstərir, yaşıldan kəhrəba vasitəsilə qırmızıya.",
"A DualSense's light changes with the dimension you're in.": "DualSense-in işığı olduğunuz ölçüdən asılı olaraq dəyişir.",
"A DualSense keeps its own light.": "DualSense öz işığını saxlayır.",
"Lets LT + A craft on servers other than your own worlds. Some servers count it as a macro.": "LT + A-nın öz dünyalarınızdan başqa serverlərdə hazırlamağa imkan verir. Bəzi serverlər bunu makro sayır.",
"Show the buttons for what you can do, while you play with a controller.": "Nəzarətçi ilə oynayarkən nə edə biləcəyinizin düymələrini göstərin.",
"How fast the stick moves the crosshair in inventories.": "İnventarlarda çubuğun nişangahı nə qədər sürətli hərəkət etdirdiyi.",
"How fast the right stick scrolls a list.": "Sağ çubuğun siyahını nə qədər sürətli sürüşdürdüyü.",
"How long a held direction waits before it repeats.": "Basılı saxlanılan istiqamətin təkrarlanmazdan əvvəl nə qədər gözlədiyi.",
"How long a held direction takes between repeats.": "Basılı saxlanılan istiqamətin təkrarlar arasında nə qədər vaxt aldığı.",
"Which on-screen keyboard opens when a text field is pressed.": "Mətn sahəsinə basıldıqda hansı ekran klaviaturasının açıldığı.",
"Every action back on its default button.": "Hər fəaliyyət öz defolt düyməsinə qayıdır.",
"These settings belong to this player's profile.": "Bu parametrlər bu oyunçunun profilinə aiddir.",
"The controller driving this player, or else the first one connected.": "Bu oyunçunu idarə edən nəzarətçi, əks halda ilk qoşulan.",
"The USB vendor and product the system reports. Useful when reporting a controller that misbehaves.": "Sistemin bildirdiyi USB istehsalçısı və məhsulu. Pis davranan nəzarətçini bildirərkən faydalıdır.",
"The glyph family the game picked from the controller's maker.": "Oyunun nəzarətçinin istehsalçısından seçdiyi qlif ailəsi.",
"Follow the controller, or always show one family's buttons.": "Nəzarətçini izlə, və ya həmişə bir ailənin düymələrini göstər.",
"Whether a Nintendo controller's A and B work by where they sit or by their letters.": "Nintendo nəzarətçisinin A və B düymələrinin mövqeyə görə, yoxsa hərfə görə işlədiyi.",
"The buttons menus show right now.": "Menyuların hazırda göstərdiyi düymələr.",
"Rumble when something happens to you.": "Sizə bir şey olanda titrəsin.",
"The controller keeps playing while this window isn't focused, and losing focus won't pause.": "Bu pəncərə fokusda deyilkən nəzarətçi işləməyə davam edir və fokus itkisi pauza etməyəcək.",
"A DualSense's triggers push back on bows and tools.": "DualSense-in tətikləri yaylarda və alətlərdə geri itələyir.",
"What a DualSense's light shows, over USB or Bluetooth.": "DualSense-in işığının nə göstərdiyi, USB və ya Bluetooth üzərindən.",
"Player profile": "Oyunçu profili",
"Vendor and product": "İstehsalçı və məhsul",
"Detected glyphs": "Aşkarlanan qliflər",
"Glyphs in use": "İstifadədə olan qliflər",
"Unknown": "Naməlum",
"No controller": "Nəzarətçi yoxdur",
"Editing: %s": "Redaktə edilir: %s",
"Press the button for this. %s cancels.": "Bunun üçün düyməyə basın. %s ləğv edir.",
"This button also does: %s": "Bu düymə həmçinin bunu edir: %s",
"Not bound: press this row, then the button that should do it.": "Təyin edilməyib: bu sətrə basın, sonra bunu etməli olan düyməyə.",
"horizon.pad.notice.connected.press": "İstifadə etmək üçün basın",
"horizon.recipe_transfer.missing": "Çatışmayan əşyalar",
"horizon.recipe_transfer.no_room": "İnventar doludur",
"horizon.recipe_transfer.unavailable": "Resept bu menyuya sığmır",
"horizon.recipe_transfer.unsupported": "Server tanınan resept tələb edir",
"horizon.studio.timeline.freecam_on": "Sərbəst kamera: AKTİV",
"horizon.studio.timeline.freecam_off": "Sərbəst kamera: SÖNDÜR",
"horizon.studio.timeline.freecam_short": "Kam",
"horizon.studio.timeline.freecam_active_short": "Kam ✓",
"horizon.studio.timeline.add_keyframe_short": "+ Kadr",
"horizon.studio.timeline.delete_keyframe_short": "− Sil",
"horizon.studio.timeline.split": "Böl",
"horizon.studio.timeline.marker": "Nişan %s",
"horizon.studio.timeline.seconds": "%s san",
"horizon.studio.timeline.scene_count": "%s səhnə",
"horizon.studio.timeline.scene_title": "Səhnə %s",
"horizon.studio.timeline.sequence": "Ardıcıllıq",
"horizon.studio.topbar.title": "✦ Studio",
"horizon.studio.topbar.file": "Fayl",
"horizon.studio.topbar.camera": "Kamera",
"horizon.studio.topbar.view": "Görünüş",
"horizon.studio.topbar.file_short": "Fayl",
"horizon.studio.topbar.camera_short": "Kam",
"horizon.studio.topbar.view_short": "Görünüş",
"horizon.studio.topbar.game_pause_menu": "⏸ Pauza menyusu",
"horizon.studio.topbar.pause_short": "Pauza",
"horizon.studio.topbar.open_recordings": "📁 Qeydlər qovluğunu aç",
"horizon.studio.topbar.close_replay": "✕ Təkrarı bağla",
"horizon.studio.topbar.exit": "Çıxış",
"horizon.studio.topbar.toggle_freecam": "Sərbəst kameranı dəyiş",
"horizon.studio.topbar.reset_to_player": "👤 Oyunçuya qaytar",
"horizon.studio.topbar.unlock_freecam": "🔓 Sərbəst kameranı aç",
"horizon.studio.topbar.lock_to_spline": "🔒 Spline-a bağla",
"horizon.studio.topbar.add_keyframe": "➕ Açar kadr əlavə et",
"horizon.studio.topbar.add_marker": "Nişan əlavə et",
"horizon.studio.topbar.clear_keyframes": "🗑 Açar kadrları təmizlə",
"horizon.studio.topbar.timeline_hud": "⏱ Vaxt xətti",
"horizon.studio.topbar.split_scene": "Səhnəni böl"
}
@@ -155,9 +155,7 @@
"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.": "Һеҙҙең дисплей&#8217;ы иң ҡара ҡара. 0 өсөн OLED; ЖК-дисплейҙар бер аҙ балҡый.",
"How bright highlights get. Auto: the peak your system reports.": "Нисек сағыу өҫтөнлөктәр ала. Авто: пик һеҙҙең система отчеттар.",
"How bright white paper and menus look. Auto: your desktop's white.": "Нисек сағыу аҡ ҡағыҙ, менюлар күренә. Авто: һеҙҙең өҫтәл&#8217;ы аҡ.",
"How bright the HUD and menus are, against paper white.": "Нисек сағыу HUD һәм менюлар, ҡағыҙ аҡ ҡаршы.",
"How far mobs, crops and machines keep working. CPU time.": "Күпме алыҫҡа мобтар, культуралар һәм машиналар эшләп тора. Процессор ваҡыты.",
"How far away mobs and items are drawn, as a share of normal.": "Нисек алыҫҡа мобтар һәм әйберҙәр тартыла, өлөшө булараҡ нормаль.",
@@ -150,9 +150,7 @@
"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; ВК-дысплеі трохі свецяцца.",
"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.": "Як далёка працягваюць працаваць мобы, ураджай і машыны. працэсарны час.",
"How far away mobs and items are drawn, as a share of normal.": "Наколькі далёка намаляваны мобы і прадметы ў долі звычайнага.",
@@ -1,29 +1,137 @@
{
"horizon.extras.header.world": "Свят",
"horizon.extras.pregen": "Предварително генериране на парчета",
"horizon.extras.pregen.tooltip": "Включва /pregen във вашите светове за един играч и в LAN. С включени чийтове използвайте /pregen start, за да генерирате район, преди да го изследвате. Не включва командата на сървъри, към които се присъединявате.",
"horizon.options.controller": "Контролер",
"horizon.options.three_d_skin_layers": "3D слоеве на кожата",
"horizon.options.vibration": "Вибрация",
"horizon.options.play_in_background": "Игра във фон",
"horizon.options.adaptive_triggers": "Адаптивни тригери",
"horizon.options.gameplay_guide": "Ръководство за играта",
"horizon.options.gameplay_guide.basic": "Основно",
"horizon.options.gameplay_guide.full": "Пълно",
"horizon.options.gameplay_guide.off": "Изкл.",
"horizon.controller.guide.use": "Използване",
"horizon.controller.guide.open": "Отваряне",
"horizon.controller.guide.ride": "Яздене",
"horizon.controller.guide.trade": "Търговия",
"horizon.controller.guide.feed": "Хранене",
"horizon.controller.guide.place": "Поставяне",
"horizon.controller.guide.eat": "Ядене",
"horizon.controller.guide.drink": "Пиене",
"horizon.controller.guide.shoot": "Стрелба",
"horizon.controller.guide.load": "Зареждане",
"horizon.controller.guide.throw": "Хвърляне",
"horizon.controller.guide.raise": "Вдигане",
"horizon.controller.guide.equip": "Екипиране",
"horizon.controller.guide.fish": "Риболов",
"horizon.controller.guide.zoom": "Приближаване",
"horizon.controller.guide.boost": "Ускорение",
"horizon.controller.guide.break": "Разбиване",
"horizon.controller.guide.attack": "Атака",
"horizon.controller.guide.spectate": "Наблюдение",
"horizon.controller.guide.spectator_menu": "Меню на наблюдателя",
"horizon.controller.guide.release_shoot": "Пуснете, за да стреляте",
"horizon.controller.guide.release_throw": "Пуснете, за да хвърлите",
"horizon.controller.guide.release_stop": "Пуснете, за да спрете",
"horizon.controller.guide.fly": "Летене",
"horizon.controller.guide.fly_up": "Летене нагоре",
"horizon.controller.guide.fly_down": "Летене надолу",
"horizon.controller.guide.swim_up": "Плуване нагоре",
"horizon.controller.guide.swim_down": "Плуване надолу",
"horizon.controller.guide.glide": "Планиране",
"horizon.controller.guide.dismount": "Слизане",
"horizon.controller.guide.stand": "Изправяне",
"horizon.controller.guide.drop": "Изпускане на предмет",
"horizon.controller.guide.swap": "Разменяне на ръце",
"horizon.controller.guide.take": "Вземане",
"horizon.controller.guide.take_half": "Вземане на половина",
"horizon.controller.guide.put": "Оставяне",
"horizon.controller.guide.put_one": "Оставяне на един",
"horizon.controller.guide.swap_stack": "Разменяне",
"horizon.controller.guide.quick_move": "Бързо преместване",
"horizon.controller.guide.drop_stack": "Изпускане на стека",
"horizon.controller.guide.place_recipe": "Поставяне на рецепта",
"horizon.controller.guide.place_recipe_all": "Поставяне на всички",
"horizon.controller.guide.alternatives": "Алтернативи",
"horizon.controller.guide.select": "Избор",
"horizon.controller.guide.close": "Затваряне",
"horizon.controller.guide.pages": "Страници",
"horizon.controller.guide.categories": "Категории",
"horizon.controller.guide.tabs": "Раздели",
"horizon.options.lightbar": "Светлинна лента",
"horizon.options.look_sensitivity": "Чувствителност на погледа",
"horizon.options.invert_x": "Обръщане на оста X на погледа",
"horizon.options.invert_y": "Обръщане на оста Y на погледа",
"horizon.options.craft_on_servers": "Бързо изработване на сървъри",
"horizon.controller.direct_craft": "Бързо изработване",
"horizon.options.sneak_button": "Бутон за промъкване",
"horizon.options.move_deadzone": "Мъртва зона на движението",
"horizon.options.look_deadzone": "Мъртва зона на погледа",
"horizon.options.trigger_threshold": "Праг на тригера",
"horizon.options.button_glyphs": "Глифове на бутони",
"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.nintendo_buttons": "Бутони на Nintendo",
"horizon.options.nintendo_buttons.position": "По позиция",
"horizon.options.nintendo_buttons.letter": "По буква",
"horizon.options.keyboard.auto": "Авто",
"horizon.options.keyboard.horizon": "Horizon",
"horizon.options.keyboard.steam": "Пара",
"horizon.options.unavailable": "Тази функция все още не е налична в Horizon.",
"horizon.options.search": "Настройки за търсене...",
"horizon.studio.record.usage": "Употреба: /studio record <start [name]|stop|status>",
"horizon.studio.record.started": "Записът в Studio започна: %s",
"horizon.studio.record.saved": "Записът в Studio е запазен: %s",
"horizon.studio.record.error": "Грешка при запис в Studio: %s",
"horizon.studio.record.status.active": "Записът в Studio е активен — %s с, %s пакета, %s KB (%s)",
"horizon.studio.record.status.inactive": "Записът в Studio е неактивен",
"horizon.studio.replay.usage": "Употреба: /studio replay <open PATH|pause|resume|speed MULTIPLIER|seek MS|status|close>",
"horizon.studio.replay.open.usage": "Употреба: /studio replay open <PATH>",
"horizon.studio.replay.opened": "Възпроизвеждането в Studio е отворено: %s (%s с, %s пакета)",
"horizon.studio.replay.open_error": "Възпроизвеждането не можа да се отвори: %s",
"horizon.studio.replay.paused": "Възпроизвеждането в Studio е на пауза",
"horizon.studio.replay.resumed": "Възпроизвеждането в Studio продължи",
"horizon.studio.replay.inactive": "Няма активно възпроизвеждане в Studio",
"horizon.studio.replay.speed.usage": "Употреба: /studio replay speed <MULTIPLIER>",
"horizon.studio.replay.speed.set": "Скоростта на възпроизвеждане в Studio е зададена на %s×",
"horizon.studio.replay.speed.invalid": "Невалидна скорост на възпроизвеждане",
"horizon.studio.replay.seek.usage": "Употреба: /studio replay seek <MILLISECONDS>",
"horizon.studio.replay.seeked": "Възпроизвеждането в Studio прескочи до %s ms (%s с)",
"horizon.studio.replay.seek.error": "Възпроизвеждането не можа да прескочи: %s",
"horizon.studio.replay.seek.backward_requires_standalone": "Превъртането назад е налично само при самостоятелно възпроизвеждане.",
"horizon.studio.replay.seek.invalid": "Невалидна позиция на възпроизвеждане",
"horizon.studio.replay.status": "Възпроизвеждане в Studio: %s / %s ms (%s%%), скорост %s×, на пауза %s, завършено %s",
"horizon.studio.replay.closed": "Възпроизвеждането в Studio е затворено",
"horizon.studio.freecam.enabled": "Свободната камера в Studio е включена. Използвайте WASD за движение, Space и Shift за издигане или спускане и Control за по-бързо движение.",
"horizon.studio.freecam.disabled": "Свободната камера в Studio е изключена",
"horizon.studio.freecam.unavailable": "Свободната камера в Studio не намери камера на играч",
"horizon.studio.freecam.replay_required": "Свободната камера в Studio е налична при възпроизвеждане на запис",
"horizon.studio.freecam.usage": "Употреба: /studio freecam <on|off>",
"horizon.studio.keyframe.added": "Добавен е ключов кадър на камерата на %s ms. Пътят се запазва, когато затворите възпроизвеждането.",
"horizon.studio.keyframe.cleared": "Пътят на камерата е изчистен. Промяната се запазва, когато затворите възпроизвеждането.",
"horizon.studio.keyframe.removed": "Премахнат е ключов кадър на камерата %s. Промяната се запазва, когато затворите възпроизвеждането.",
"horizon.studio.keyframe.save_error": "Пътят на камерата не можа да се запази: %s",
"horizon.studio.keyframe.remove_usage": "Употреба: /studio keyframe remove <INDEX>",
"horizon.studio.keyframe.index_invalid": "Индексът на ключовия кадър е извън диапазона",
"horizon.studio.keyframe.usage": "Употреба: /studio keyframe <add|remove INDEX|clear>",
"horizon.studio.path.locked": "Възпроизвеждането на пътя на камерата в Studio е включено",
"horizon.studio.path.unlocked": "Възпроизвеждането на пътя на камерата в Studio е спряно",
"horizon.studio.path.usage": "Употреба: /studio path <lock|unlock> (първо добавете поне един ключов кадър)",
"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.studio": "Replay Studio",
"horizon.pages.agent": "AI агент",
"horizon.split.leave": "тръгвай",
"horizon.pages.split_settings": "Настройки за разделен екран",
@@ -44,6 +152,7 @@
"horizon.pages.music.empty": "Няма музикални записи: звуците на играта не се зареждат.",
"horizon.pages.music.tooltip": "Вградените песни според ситуацията: пуснете една сега или изключете някоя, така че играта да спре да я избира.",
"horizon.pages.settings.tooltip": "Собствени настройки на Horizon: графики, контролери, екстри и модове.",
"horizon.pages.studio.tooltip": "Разгледайте запазените записи и тяхната информация.",
"horizon.pages.graphics.tooltip": "Графика просто: всяка настройка казва колко струва.",
"horizon.pages.graphics_advanced.tooltip": "Всяка графична настройка, включително Horizon, с цената на всяка.",
"horizon.pages.controller.tooltip": "Контролери: техните бутони, стикове, вибрация и навигация в менюто.",
@@ -61,15 +170,47 @@
"horizon.pages.open.extras": "Екстри...",
"horizon.pages.open.mods": "Модове...",
"horizon.pages.open.agent": "AI агент...",
"horizon.pages.open.horizon_graphics": "Графика на Horizon...",
"horizon.pages.open.controller_binds": "Контролерът се свързва...",
"horizon.pages.open.horizon_settings": "Настройки на Horizon...",
"horizon.pages.open.studio": "Replay Studio...",
"horizon.studio.saved_recordings": "Запазени записи",
"horizon.studio.empty": "В папката със записи не са намерени записи.",
"horizon.studio.empty_hint": "Използвайте /studio record start <name> в свят или сложете там .mcpr файлове.",
"horizon.studio.open_folder": "Отворете записите",
"horizon.studio.open_replay": "Пуснете записа",
"horizon.studio.refresh": "Опреснете",
"horizon.studio.unknown_server": "Неизвестно",
"horizon.pages.open.music": "музика...",
"horizon.mods.empty": "Без модификации: поставете .hmod файлове в папката horizon-mods",
"horizon.mods.stopped": " (спряно)",
"horizon.mods.not_built": " (Не е построен)",
"horizon.mods.dev": " (Разраб.)",
"horizon.mods.settings": "Настройки",
"horizon.mods.in_world": " (оставете света да се промени)",
"horizon.mods.open_folder": "Отворете папката на Mod",
"horizon.mods.browse": "Разглеждане на модове...",
"horizon.mods.browse.title": "Разглеждане на модове",
"horizon.mods.browse.search": "Търсене на модове...",
"horizon.mods.browse.project": "Мод",
"horizon.mods.browse.loading": "Четене на регистъра с модове на Horizon...",
"horizon.mods.browse.failed": "Регистърът с модове не можа да се прочете: %s",
"horizon.mods.browse.none": "Няма намерен мод",
"horizon.mods.browse.has_update": " (актуализация)",
"horizon.mods.browse.is_installed": " (инсталиран)",
"horizon.mods.browse.by": "от %s",
"horizon.mods.browse.installing": "Инсталиране...",
"horizon.mods.browse.install": "Инсталирайте",
"horizon.mods.browse.update": "Актуализирайте",
"horizon.mods.browse.installed": "Инсталиран",
"horizon.mods.browse.open_page": "Отворете страницата",
"horizon.mods.browse.enabled": "Мод",
"horizon.mods.browse.changelog": "Списък с промени",
"horizon.mods.browse.done": "%s %s е инсталиран и включен.",
"horizon.mods.browse.install_failed": "Не можа да се инсталира: %s",
"horizon.mods.switch.worldgen": "променя генерирането на света",
"horizon.mods.switch.registries": "добавя блокове, предмети или други записи в регистъра",
"horizon.mods.switch.restart": "Рестартирайте играта, за да приложите %s: те добавят блокове, предмети или генериране на света.",
"horizon.shader_options.profile": "Профил",
"horizon.agent.copy_details": "Копиране на подробности за връзката",
"horizon.multiplayer.join": "Присъединете се",
@@ -81,6 +222,7 @@
"LOD Distance": "LOD разстояние",
"Far Detail": "Далечен детайл",
"Cave Culling": "Пещерно избиване",
"GPU Culling": "Отстраняване чрез GPU",
"Entity Culling": "Отстраняване на субекти",
"Chunk Updates": "Актуализации на парчета",
"Upload Budget": "Бюджет за качване",
@@ -92,11 +234,17 @@
"Frame Pacing": "Темпо на рамката",
"Worker Threads": "Работни нишки",
"Resolution Scale": "Скала на резолюцията",
"Anti-aliasing": "Антиалиасинг",
"Upscaling": "Премащабиране",
"Upscaling Sharpness": "Рязкост на премащабирането",
"Paper White": "Бяла хартия",
"Peak Brightness": "Пикова яркост",
"Black Level": "Черно ниво",
"UI Brightness": "Яркост на потребителския интерфейс",
"HDR Lighting": "HDR осветление",
"Light sources, the sun and sunlight shine past white. Off: just as SDR.": "Източниците на светлина, слънцето и слънчевата светлина блестят отвъд бялото. Изкл.: точно като SDR.",
"Smooth Gradients": "Плавни градиенти",
"16-bit frame: dark skies and shadows don't band. A bit more VRAM. Next start.": "16-битов кадър: тъмните небеса и сенки не се разслояват. Малко повече VRAM. Следващ старт.",
"Battery Mode": "Режим на батерия",
"Graphics API": "Графичен API",
"Translucent Batching": "Полупрозрачно пакетиране",
@@ -118,20 +266,32 @@
"Efficient": "Ефективно",
"Precise": "Прецизен",
"Auto (VSync)": "Автоматично (VSync)",
"FIFO": "FIFO",
"Mailbox": "Пощенска кутия",
"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": "Спестява време",
"HDR": "HDR",
"Graphics": "Графика",
"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, за да го използвате.",
"Your GPU or graphics API doesn't support it.": "Вашият GPU или графичен API не го поддържат.",
"Not with software rendering, which runs it on the CPU.": "Не при софтуерно изобразяване, което го изпълнява на CPU.",
"Out of video memory: off until the next start.": "Изчерпана видеопамет: изключено до следващия старт.",
"The graphics device was lost: off until the next start.": "Графичното устройство беше загубено: изключено до следващия старт.",
"Not on DirectX 12 yet: try the Vulkan graphics API.": "Още не при DirectX 12: опитайте с графичния API Vulkan.",
"%s chunks": "%s парчета",
"%s per frame": "%s на кадър",
"%s MiB/frame": "%s MiB/кадър",
@@ -147,13 +307,15 @@
"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 дисплеите светят малко.",
"Lifts shadows onto your display's darkest black. 0: true black, no lift.": "Повдига сенките до най-тъмното черно на вашия дисплей. 0: истинско черно, без повдигане.",
"How bright highlights get. Auto: the peak your system reports.": "Колко ярки стават акцентите. Автоматично: пикът, който вашата система отчита.",
"How bright white paper and menus look. Auto: your desktop's white.": "Колко ярки изглеждат бялата хартия и менютата. Автоматично: вашият работен плот е бял.",
"How bright white looks in the world. 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.": "Докъде продължават да работят тълпите, културите и машините. процесорно време.",
"How far away mobs and items are drawn, as a share of normal.": "Колко далеч са изтеглени тълпите и предметите, като дял от нормалното.",
"Skips terrain hidden behind closed sections. Off costs a lot.": "Пропуска терен, скрит зад затворени участъци. Off струва много.",
"Culls nearby terrain on the GPU: less CPU at high render distance, more GPU.": "Отстранява близкия терен на GPU: по-малко CPU при голямо разстояние на изобразяване, повече GPU.",
"Auto: tries it where your CPU limits the frame, keeps it if frames get faster.": "Авто: опитва го там, където CPU ограничава кадъра, и го запазва, ако кадрите станат по-бързи.",
"Hides entities behind opaque blocks.": "Скрива обекти зад непрозрачни блокове.",
"No entity occlusion culling.": "Без отстраняване на оклузия на обект.",
"CPU occlusion test runs this frame.": "Тестът за запушване на процесора изпълнява този кадър.",
@@ -177,6 +339,7 @@
"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. По-бързо, по-меко.",
"Sharpens the upscaled picture. Higher: crisper edges, more shimmer.": "Изостря премащабираната картина. По-високо: по-ясни ръбове, повече трептене.",
"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.": "Ефективно заспиване до следващия кадър; Прецизни завъртания, като ванилия.",
@@ -196,6 +359,7 @@
"Frame rate and frame times over the game, with a graph.": "Скорост на кадрите и времена на кадрите в играта, с графика.",
"horizon.multiplayer.finding_friend": "Намиране на света на вашия приятел...",
"horizon.multiplayer.not_signed_in": "Този сървър е в онлайн режим. Влезте с вашия акаунт в Microsoft в стартовия панел на Horizon, за да се присъедините към него.",
"horizon.realms.not_signed_in": "Влезте с вашия акаунт в Microsoft в стартовия панел на Horizon, за да играете в Realms.",
"horizon.multiplayer.invalid_address": "Невалиден адрес на сървъра: %s",
"horizon.multiplayer.connected_friend_direct": "Свързан директно с вашия приятел...",
"horizon.multiplayer.connected_friend_relay": "Свързан с вашия приятел чрез релето...",
@@ -225,6 +389,7 @@
"Brighter-than-white output on an HDR display.": "По-ярък от бялото изход на HDR дисплей.",
"Results for \"%s\"": "Резултати за \"%s\"",
"No settings match.": "Няма съответстващи настройки.",
"horizon.plain.official": "Официалният Horizon със стартов панел, модове и актуализации: playhorizon.dev",
"horizon.title.not_affiliated": "Не е свързан с Mojang или Microsoft",
"horizon.extras.header.lighting": "Осветление",
"horizon.extras.header.third_person": "Трето лице",
@@ -248,9 +413,12 @@
"horizon.extras.no_ai": "Без AI",
"horizon.extras.agent_control": "Агентски контрол",
"horizon.extras.discord_presence": "Богато присъствие на Discord",
"horizon.extras.zoom": "Приближаване",
"horizon.extras.zoom_mod_install": "Приближаване: инсталирайте от Модове",
"horizon.extras.splitscreen": "Разделен екран",
"horizon.extras.chat_animation": "Плавен чат",
"horizon.extras.chat_indicator": "Индикатор за съобщение",
"horizon.extras.wheel_cycles_list_settings.tooltip": "Колелцето за превъртане върху бутон в списък с настройки променя стойността му. Изключено, както във ванилия, колелцето превърта списъка.",
"horizon.extras.wheel_cycles_settings": "Колелото за превъртане променя настройките",
"horizon.extras.pack_browser": "Браузър за пакети с ресурси",
"horizon.extras.fullbright.tooltip": "Показвайте блокове и обекти при пълна светлина, дори в тъмни зони.",
@@ -311,9 +479,11 @@
"horizon.friends.stopped": "Спря споделянето на кода за покана.",
"horizon.friends.short_code_changed": "Някой продължаваше да опитва грешен кратък код, така че той се промени.",
"horizon.friends.wrong_code": "Някой е опитал грешен код.",
"horizon.friends.relay_stopped": "Твърде много грешни кратки кодове, затова релето е изключено, докато не споделите отново. Приятелите все още могат да се присъединят с пълния код.",
"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": "Тип",
@@ -329,6 +499,7 @@
"horizon.splitscreen.setup.new_window": "Нов прозорец",
"horizon.splitscreen.setup.fill_monitor": "Попълнете %s",
"horizon.splitscreen.setup.done": "Готово",
"horizon.splitscreen.setup.switch": "%s: %s",
"horizon.splitscreen.setup.on": "включено",
"horizon.splitscreen.setup.off": "Изкл",
"horizon.splitscreen.setup.player": "Играч %s",
@@ -386,6 +557,9 @@
"horizon.splitscreen.setup.help.give_up_pad": "Вашият контролер спира да ви кара и получава предложение да се присъедини (A).",
"horizon.splitscreen.setup.note.held_at": "%s до %s: A го пуска тук, B го връща обратно.",
"horizon.splitscreen.setup.note.held": "%s взето: ляв/десен избира къде, A пуска, B го връща обратно.",
"horizon.splitscreen.online_only": "Този сървър е в онлайн режим: офлайн профилите не могат да се присъединят, всеки играч се нуждае от собствен акаунт в Minecraft.",
"horizon.splitscreen.own_account": "Този сървър изисква собствен акаунт в Minecraft за всеки играч",
"horizon.splitscreen.local_only": "Другите играчи могат да се присъединят към този сървър само със свой акаунт в Minecraft: офлайн профилите играят във вашия свят, в LAN или на сървър в локалната ви мрежа.",
"Hides models behind opaque blocks. Async checks off-thread; entities tick.": "Скрива модели зад непрозрачни блокове. Async проверява извън нишката; субекти отметка.",
"Block Entities": "Блокиране на обекти",
"Shulker Boxes": "Кутии Шулкер",
@@ -404,11 +578,26 @@
"Reuses statue geometry until its block state changes.": "Използва повторно геометрията на статуята, докато блоковото й състояние се промени.",
"Leaf Culling": "Изрязване на листа",
"Every leaf face. Vanilla's fancy leaves.": "Всеки лист лице. Елегантните листа на ванилия.",
"Opaque Ice": "Непрозрачен лед",
"Draws ice in the solid pass, without its see-through texture.": "Рисува леда в плътния проход, без прозрачната му текстура.",
"Distant Plant Dither": "Дитеринг на далечни растения",
"Keeps fine plants in high-res packs from fading far away.": "Предпазва фините растения в пакети с висока разделителна способност от избледняване.",
"Screenshot Quality": "Качество на екранната снимка",
"F2 screenshots at this many times the game's resolution. Higher takes longer.": "Екранни снимки на F2 при толкова многократна разделителна способност на играта. По-високото отнема повече време.",
"Frame Generation": "Генериране на рамка",
"Shows a made-up frame between each two real ones: smoother, a little more lag.": "Показва измислен кадър между всеки два реални: по-гладък, с малко повече забавяне.",
"Shadow Cache": "Кеш на сенките",
"Shaders": "Шейдъри",
"Shader packs, Horizon's and shaders/ folder ones, and their options.": "Шейдърни пакети — на Horizon и от папката shaders/ — и техните опции.",
"Shader packs": "Шейдърни пакети",
"Vanilla's own preset: sets only vanilla's options, as Minecraft does.": "Собствената предварителна настройка на ванилия: задава само опциите на ванилия, както прави Minecraft.",
"Fullscreen takes the display over, at the mode below.": "Целият екран поема дисплея, в режима по-долу.",
"The display mode exclusive fullscreen uses. Applies when you leave.": "Режимът на дисплея, който използва ексклузивният цял екран. Прилага се при излизане.",
"Blurs the world behind menus.": "Замъглява света зад менютата.",
"How textures are sampled: sharper at a slant with Anisotropic.": "Как се семплират текстурите: по-ясно под наклон с анизотропно.",
"How far Anisotropic filtering goes. Higher is sharper at a slant.": "Докъде стига анизотропното филтриране. По-високо е по-ясно под наклон.",
"Only with Anisotropic texture filtering.": "Само с анизотропно филтриране на текстурите.",
"Picks a shader pack, Horizon's or a shaders/ folder one, and sets its options.": "Избира шейдърен пакет — на Horizon или от папката shaders/ — и задава опциите му.",
"Reuses the pack shadow map when sun, view and world match. Off by default.": "Използва повторно картата на сенките на глутницата, когато слънцето, гледката и света съвпадат. Изключено по подразбиране.",
"Find BlueMap": "Намерете BlueMap",
"On servers, finds their BlueMap web map for far terrain. Next join.": "На сървъри намира тяхната уеб карта BlueMap за далечен терен. Следващо присъединяване.",
@@ -416,6 +605,8 @@
"How far around you BlueMap tiles are downloaded. Next join.": "На какво разстояние около вас се изтеглят плочките BlueMap. Следващо присъединяване.",
"Synchronous": "Синхронен",
"Asynchronous": "Асинхронен",
"Hidden Faces": "Скрити лица",
"Drops leaf faces behind two leaf layers: faster, same crowns.": "Изпуска листните лица зад два слоя листа: по-бързо, същите корони.",
"Solid": "Твърди",
"Solid Aggressive": "Твърди агресивни",
"Hollow": "Кух",
@@ -437,6 +628,10 @@
"Auto (%s nits)": "Автоматично (%s нита)",
"%.2f nits": "%.2f нита",
"Hides models behind opaque blocks. Entities update. Off with custom packs.": "Скрива модели зад непрозрачни блокове. Актуализация на обекти. Изключване с персонализирани пакети.",
"Entity Culling Precheck": "Предварителна проверка на отстраняването",
"Runs the mob AABB precheck after this many loaded mobs. Zero disables it.": "Изпълнява предварителната проверка на AABB за тълпите след толкова заредени тълпи. Нула я изключва.",
"%s mobs": "%s тълпи",
"Only applies to asynchronous entity culling.": "Прилага се само при асинхронно отстраняване на обекти.",
"horizon.extras.repro_keys": "Преки пътища за запис",
"horizon.extras.repro_keys.tooltip": "F3+R стартира или спира възпроизвеждане на запис и F3+E записва неговия пакет. Изключено по подразбиране: записът копира света.",
"horizon.agent.record_session": "Започнете запис на сесия",
@@ -448,7 +643,19 @@
"horizon.repro.error": "Репро: %s",
"horizon.repro.no_recording": "Все още няма запис за експортиране.",
"horizon.repro.replay_started": "Стартирало повторение: %s",
"horizon.splitscreen.profiles.offline": " (Офлайн)",
"horizon.splitscreen.profiles.microsoft": " (Microsoft)",
"horizon.splitscreen.profiles.sign_in": "Влезте отново в стартовия панел",
"horizon.splitscreen.profiles.online": "Microsoft онлайн",
"horizon.splitscreen.account_unavailable": "%s е офлайн. Влезте отново в стартовия панел, след което рестартирайте играта.",
"horizon.splitscreen.account_expired": "Влизането на %s в Microsoft е изтекло. Влезте отново в стартовия панел, след което рестартирайте играта.",
"Particle Culling": "Отстраняване на частици",
"Draws every particle.": "Рисува всяка частица.",
"Skips offscreen, subpixel and terrain-hidden particles.": "Пропуска частици извън екрана, подпикселни и скрити от терена.",
"Skips offscreen, subpixel and terrain-hidden particles with less render-thread work.": "Пропуска частици извън екрана, подпикселни и скрити от терена с по-малко натоварване на нишката за изобразяване.",
"horizon.pack.add": "+",
"horizon.pack.addInfo": "Добавете ресурсен пакет от Modrinth",
"horizon.pack.addWorking": "Питаме Modrinth...",
"horizon.pack.addNone": "Няма намерени пакети с ресурси",
"horizon.pack.addTitle": "Добавете пакет с ресурси",
"horizon.pack.install": "Инсталирайте",
@@ -460,10 +667,28 @@
"horizon.pack.filter.realistic": "Реалистичен",
"horizon.pack.filter.simplistic": "Опростено",
"horizon.pack.filter.vanillaLike": "Подобно на ванилия",
"horizon.pack.filter.x16": "16x",
"horizon.pack.filter.x32": "32x",
"horizon.pack.filter.x64": "64x",
"horizon.pack.addFailed": "Пакетът не можа да бъде добавен: %s",
"horizon.pack.options": "Опции на пакета",
"horizon.pack.optionsInfo": "Опции на този пакет",
"horizon.pack.options.title": "Опции на пакета",
"horizon.pack.options.general": "Общи",
"horizon.pack.options.presets": "Предварителни настройки",
"horizon.pack.options.presets.custom": "Персонализирани",
"horizon.pack.options.impact": "Влияние върху производителността: %s",
"horizon.pack.options.impact.low": "ниско",
"horizon.pack.options.impact.medium": "средно",
"horizon.pack.options.impact.high": "високо",
"horizon.pack.options.impact.varies": "различно",
"horizon.extras.header.cosmetics": "Козметика",
"horizon.extras.cosmetics_mine": "Моята козметика",
"horizon.extras.cosmetics_mine.tooltip": "Скрит: всеки играч на Horizon ви вижда без вашата козметика, включително и вие. Същият превключвател като гардероба на стартера.",
"key.emote": "Колело за емоции",
"horizon.emote_wheel.title": "Емоции",
"horizon.emote_wheel.empty": "Още няма емоции",
"horizon.emote_wheel.help": "Пуснете или щракнете за възпроизвеждане · 1–8 · Esc затваря",
"horizon.extras.cosmetics_others": "Козметика за други играчи",
"horizon.extras.cosmetics_others.tooltip": "Какво носят другите играчи на Horizon: всичко, само техните стилове на имената или нищо. Скрито, играта никога не пита какво носят.",
"horizon.extras.cosmetics_hide_fx": "Скриване на следите и аурите на другите играчи",
@@ -488,5 +713,349 @@
"Pack settings": "Настройки на пакета",
"Built-in settings": "Вградени настройки",
"%s settings": "Настройки на %s",
"Settings made by the pack's author: they change only this pack.": "Настройки, направени от автора на пакета: променят само този пакет."
"Settings made by the pack's author: they change only this pack.": "Настройки, направени от автора на пакета: променят само този пакет.",
"horizon.moderation.duration.minute": "%s минута",
"horizon.moderation.duration.minutes": "%s минути",
"horizon.moderation.duration.hour": "%s час",
"horizon.moderation.duration.hours": "%s часа",
"horizon.moderation.duration.day": "%s ден",
"horizon.moderation.duration.days": "%s дни",
"horizon.moderation.reason": "%1$s: %2$s",
"horizon.moderation.mutelist.empty": "Няма заглушени",
"horizon.moderation.mutelist.count": "Има %s заглушени:",
"horizon.moderation.chat_filter": "филтърът за чат",
"horizon.moderation.until_unmuted": "до отмяна на заглушаването",
"horizon.moderation.remaining": "още %s",
"horizon.moderation.mutelist.entry": "%1$s беше заглушен от %2$s %3$s",
"horizon.moderation.invalid_duration": "%s не е продължителност: 10m, 2h, 1d",
"horizon.moderation.usage.mute": "/mute <player> [duration] [reason]",
"horizon.moderation.usage.tempmute": "/tempmute <player> <duration> [reason]",
"horizon.moderation.usage.unmute": "/unmute <player>",
"horizon.moderation.usage.mutelist": "/mutelist",
"horizon.moderation.for_duration": "за %s",
"horizon.moderation.muted": "Заглушен %1$s %2$s",
"horizon.moderation.unmuted": "Заглушаването на %s е отменено",
"horizon.moderation.not_muted": "%s не е заглушен",
"horizon.tempban.usage": "/tempban <player> <duration> [reason]",
"horizon.tempban.invalid_duration": "%s не е продължителност: 30m, 12h, 7d",
"horizon.tempban.dedicated_required": "Временните банове изискват отделен сървър",
"horizon.tempban.success": "%1$s за %2$s, до %3$s",
"horizon.tempban.list.empty": "Няма временни банове",
"horizon.tempban.list.count": "Има %s временни бана:",
"horizon.tempban.list.entry": "%1$s беше баннат от %2$s %3$s (до %4$s)",
"horizon.options.pointer_speed": "Скорост на показалеца в менютата",
"horizon.options.scroll_speed": "Скорост на превъртане в менютата",
"horizon.options.repeat_delay": "Забавяне на повторението при навигация",
"horizon.options.repeat_interval": "Интервал на повторението при навигация",
"horizon.options.milliseconds": "%s ms",
"horizon.extras.cosmetics_solo": "В режим един играч",
"horizon.extras.cosmetics_solo.tooltip": "Показвайте козметиката си и значката на етикета с име и в световете си за един играч (етикета с име чрез Собствен етикет с име). Изкл.: само на сървъри.",
"horizon.extras.horizon_marks": "Лога на Horizon",
"horizon.extras.horizon_marks.tooltip": "Показва логото на Horizon до играчите на Horizon: малко лого преди имената на другите играчи, които използват Horizon, над главите им и в списъка с играчи. Само играчите на Horizon го виждат: сървърите не получават нищо.",
"horizon.extras.horizon_me": "Показвай ме като Horizon",
"horizon.extras.horizon_me.tooltip": "Показва ме като играч на Horizon: другите играчи на Horizon виждат логото до името ви. Включено, услугата отговаря на всеки, който пита за вас, че сте играли Horizon през последните 3 часа; изключено, никога не ви докладва.",
"horizon.pages.pvp": "PvP и HUD",
"horizon.pages.pvp.tooltip": "Настройки за мерника, HUD и PvP, всички изключени по подразбиране. Нищо тук не показва онова, което ванилия не показва.",
"horizon.pages.open.pvp": "PvP и HUD...",
"horizon.pvp.header.crosshair": "Мерник",
"horizon.pvp.crosshair": "Собствен мерник",
"horizon.pvp.crosshair_shape": "Форма",
"horizon.pvp.crosshair_size": "Размер",
"horizon.pvp.crosshair_gap": "Отстояние",
"horizon.pvp.crosshair_thickness": "Дебелина",
"horizon.pvp.crosshair_colour": "Цвят",
"horizon.pvp.crosshair_outline": "Контур",
"horizon.pvp.crosshair_dot": "Точка в центъра",
"horizon.pvp.crosshair_hover": "Цвят върху обекти",
"horizon.pvp.crosshair.tooltip": "Рисува ваш собствен мерник вместо този на ванилия. Първите му настройки изглеждат като тези на ванилия.",
"horizon.pvp.crosshair_shape.tooltip": "Кръст, T, X, кръг, квадрат или само точка.",
"horizon.pvp.crosshair_size.tooltip": "Дължината на всяко рамо, в пиксели на интерфейса.",
"horizon.pvp.crosshair_gap.tooltip": "Празното пространство в средата.",
"horizon.pvp.crosshair_thickness.tooltip": "Колко широка е всяка линия.",
"horizon.pvp.crosshair_colour.tooltip": "Инвертиран показва цвета под него обърнат, както прави мерникът на ванилия.",
"horizon.pvp.crosshair_outline.tooltip": "Тънък черен ръб, за да се вижда на всякакъв фон.",
"horizon.pvp.crosshair_dot.tooltip": "Точка в средата на мерника.",
"horizon.pvp.crosshair_hover.tooltip": "Друг цвят, докато мерникът е върху тълпа или играч, когото можете да уцелите, както го разпознава индикаторът за атака на ванилия.",
"horizon.pvp.colour.inverted": "Инвертиран",
"horizon.pvp.colour.white": "Бял",
"horizon.pvp.colour.red": "Червен",
"horizon.pvp.colour.orange": "Оранжев",
"horizon.pvp.colour.yellow": "Жълт",
"horizon.pvp.colour.green": "Зелен",
"horizon.pvp.colour.cyan": "Циан",
"horizon.pvp.colour.blue": "Син",
"horizon.pvp.colour.magenta": "Магента",
"horizon.pvp.colour.black": "Черен",
"horizon.pvp.shape.cross": "Кръст",
"horizon.pvp.shape.t": "T",
"horizon.pvp.shape.x": "X",
"horizon.pvp.shape.circle": "Кръг",
"horizon.pvp.shape.square": "Квадрат",
"horizon.pvp.shape.dot": "Точка",
"horizon.pvp.outline": "Собствен контур на блок",
"horizon.pvp.outline.tooltip": "Променя контура около блока, който гледате.",
"horizon.pvp.outline_colour": "Цвят",
"horizon.pvp.outline_colour.tooltip": "Цветът на контура. Черно при 40% е на ванилия.",
"horizon.pvp.outline_opacity": "Непрозрачност",
"horizon.pvp.outline_opacity.tooltip": "Колко плътен е контурът.",
"horizon.pvp.outline_thickness": "Дебелина",
"horizon.pvp.outline_thickness.tooltip": "Ширината на контура, в пъти тази на ванилия.",
"horizon.pvp.outline_fill": "Запълване",
"horizon.pvp.outline_fill.tooltip": "Оцветява леко лицата на блока, който гледате.",
"horizon.pvp.hit": "Собствен цвят при удар",
"horizon.pvp.hit.tooltip": "Променя цвета, с който тълпите и играчите премигват, когато бъдат наранени. Червено при 30% е на ванилия.",
"horizon.pvp.hit_colour": "Цвят",
"horizon.pvp.hit_colour.tooltip": "Цветът, с който премигват наранените тълпи и играчи.",
"horizon.pvp.hit_opacity": "Непрозрачност",
"horizon.pvp.hit_opacity.tooltip": "Колко от цвета ги покрива.",
"horizon.pvp.header.outline": "Контур на блок",
"horizon.pvp.header.hit": "Цвят при удар",
"horizon.pvp.hud_armour": "Броня",
"horizon.pvp.hud_armour.tooltip": "Бронята, която носите, започвайки от главата, с оставащите използвания на всяка част.",
"horizon.pvp.hud_armour_corner": "Ъгъл на бронята",
"horizon.pvp.hud_armour_corner.tooltip": "Къде се показва бронята.",
"horizon.pvp.hud_armour_percent": "Издръжливост в %",
"horizon.pvp.hud_armour_percent.tooltip": "Оставащи използвания като процент от максимума на частта вместо като число.",
"horizon.pvp.hud_effects": "Ефекти",
"horizon.pvp.hud_effects.tooltip": "Вашите ефекти с имената, нивата и оставащото време, вместо иконите в горния десен ъгъл.",
"horizon.pvp.hud_effects_corner": "Ъгъл на ефектите",
"horizon.pvp.hud_effects_corner.tooltip": "Къде се показват ефектите.",
"horizon.pvp.hud_held": "Брой на държания предмет",
"horizon.pvp.hud_held.tooltip": "Колко от предмета в ръката ви има в инвентара ви; стрели с лък или арбалет.",
"horizon.pvp.hud_held_corner": "Ъгъл на брояча",
"horizon.pvp.hud_held_corner.tooltip": "Къде се показва броячът.",
"horizon.pvp.hud_background": "Фонове на джаджите",
"horizon.pvp.hud_background.tooltip": "Тъмна кутия зад всяка джаджа.",
"horizon.pvp.hud_opacity": "Непрозрачност на фона",
"horizon.pvp.hud_opacity.tooltip": "Колко тъмни са кутиите зад джаджите.",
"horizon.pvp.hud_text_colour": "Цвят на текста",
"horizon.pvp.hud_text_colour.tooltip": "Цветът на текста на джаджите. Издръжливостта запазва прехода си от зелено към червено.",
"horizon.pvp.hud_shadow": "Сянка на текста",
"horizon.pvp.hud_shadow.tooltip": "Сянка под текста на джаджите, каквато има текстът на HUD във ванилия.",
"horizon.pvp.header.hud": "Джаджи на HUD",
"horizon.pvp.corner.top_left": "Горе вляво",
"horizon.pvp.corner.top_right": "Горе вдясно",
"horizon.pvp.corner.bottom_left": "Долу вляво",
"horizon.pvp.corner.bottom_right": "Долу вдясно",
"horizon.pvp.hud_ping": "Ping",
"horizon.pvp.hud_ping.tooltip": "Закъснението, което сървърът отчита за вас, в милисекунди. Само в мултиплейър.",
"horizon.pvp.hud_ping_corner": "Ъгъл на ping",
"horizon.pvp.hud_ping_corner.tooltip": "Къде се показва ping.",
"horizon.pvp.hud_coords": "Координати",
"horizon.pvp.hud_coords.tooltip": "Позицията ви в блокове и посоката, в която гледате, както ги показва F3. Скрити, когато светът или сървърът ограничава F3.",
"horizon.pvp.hud_coords_corner": "Ъгъл на координатите",
"horizon.pvp.hud_coords_corner.tooltip": "Къде се показват координатите.",
"horizon.pvp.hud_server": "Адрес на сървъра",
"horizon.pvp.hud_server.tooltip": "Адресът на сървъра, в който играете. Само в мултиплейър.",
"horizon.pvp.hud_server_corner": "Ъгъл на сървъра",
"horizon.pvp.hud_server_corner.tooltip": "Къде се показва адресът.",
"horizon.pvp.hud_combo": "Брояч на комботата",
"horizon.pvp.hud_combo.tooltip": "Последователни удари, потвърдени от сървъра, докато не бъдете наранени или не минат 3 секунди без удар.",
"horizon.pvp.hud_combo_corner": "Ъгъл на комботата",
"horizon.pvp.hud_combo_corner.tooltip": "Къде се показва комботата.",
"horizon.pvp.hud.ping": "Ping: %s ms",
"horizon.pvp.hud.coords": "XYZ: %s %s %s",
"horizon.pvp.hud.facing": "Посока: %s",
"horizon.pvp.hud.combo": "Комбо: %s",
"horizon.pvp.hud.direction.north": "Север",
"horizon.pvp.hud.direction.south": "Юг",
"horizon.pvp.hud.direction.east": "Изток",
"horizon.pvp.hud.direction.west": "Запад",
"horizon.pvp.hud_fps": "FPS",
"horizon.pvp.hud_fps.tooltip": "Кадри, нарисувани през последната секунда.",
"horizon.pvp.hud_fps_corner": "Ъгъл на FPS",
"horizon.pvp.hud_fps_corner.tooltip": "Къде се показва FPS.",
"horizon.pvp.hud_cps": "Щраквания в секунда",
"horizon.pvp.hud_cps.tooltip": "Вашите леви и десни щраквания през последната секунда.",
"horizon.pvp.hud_cps_corner": "Ъгъл на CPS",
"horizon.pvp.hud_cps_corner.tooltip": "Къде се показват щракванията в секунда.",
"horizon.pvp.hud_keys": "Натискания на клавиши",
"horizon.pvp.hud_keys.tooltip": "Клавишите ви за движение, бутоните на мишката и скокът, осветени, докато ги задържате, с щраквания в секунда.",
"horizon.pvp.hud_keys_corner": "Ъгъл на натисканията",
"horizon.pvp.hud_keys_corner.tooltip": "Къде се показват натисканията.",
"horizon.pvp.hud_clock": "Часовник",
"horizon.pvp.hud_clock.tooltip": "Времето на вашия компютър.",
"horizon.pvp.hud_clock_corner": "Ъгъл на часовника",
"horizon.pvp.hud_clock_corner.tooltip": "Къде се показва часовникът.",
"horizon.pvp.hud_reach": "Обсег",
"horizon.pvp.hud_reach.tooltip": "Колко далеч от очите ви е попаднал последният удар, за 3 секунди. Само измерва: обсегът ви остава този на ванилия.",
"horizon.pvp.hud_reach_corner": "Ъгъл на обсега",
"horizon.pvp.hud_reach_corner.tooltip": "Къде се показва обсегът.",
"horizon.pvp.hud_speed": "Скорост",
"horizon.pvp.hud_speed.tooltip": "Колко бързо се движите по земята, в блокове в секунда.",
"horizon.pvp.hud_speed_corner": "Ъгъл на скоростта",
"horizon.pvp.hud_speed_corner.tooltip": "Къде се показва скоростта.",
"horizon.pvp.hud.fps": "%s FPS",
"horizon.pvp.hud.cps": "CPS: %s | %s",
"horizon.pvp.hud.reach": "Обсег: %s",
"horizon.pvp.hud.speed": "Скорост: %s б/с",
"horizon.pvp.hud.keys.attack": "ЛКМ",
"horizon.pvp.hud.keys.use": "ДКМ",
"horizon.pvp.hud.keys.cps": "%s CPS",
"horizon.pvp.numeric_enchants": "Нива на омагьосване с числа",
"horizon.pvp.numeric_enchants.tooltip": "Подсказките и масата за омагьосване показват Острота 5 вместо Острота V.",
"horizon.pvp.header.items": "Предмети",
"horizon.pvp.fire_lower": "По-нисък огън",
"horizon.pvp.fire_lower.tooltip": "Премества пламъците, които виждате, докато горите, надолу, за да закриват по-малко от екрана.",
"horizon.pvp.shield_size": "Размер на щита",
"horizon.pvp.shield_size.tooltip": "Размерът на щита в ръката ви, от първо лице.",
"horizon.pvp.totem_size": "Размер на анимацията на тотема",
"horizon.pvp.totem_size.tooltip": "Размерът на тотема, който изпълва екрана, когато ви спаси.",
"horizon.pvp.glint_colour": "Цвят на блясъка на омагьосването",
"horizon.pvp.glint_colour.tooltip": "Цветът на блясъка по омагьосаните предмети и броня. Изкл. е лилавото на ванилия.",
"horizon.pvp.clear_water": "Бистра вода",
"horizon.pvp.clear_water.tooltip": "Под вода виждайте толкова далеч, колкото ванилия ви позволява след 30 секунди там, още от началото.",
"horizon.pvp.clear_lava": "Бистра лава",
"horizon.pvp.clear_lava.tooltip": "В лава виждайте толкова далеч, колкото позволява Устойчивост на огън във ванилия, без ефекта.",
"horizon.pvp.header.visual": "Визуални промени",
"horizon.pvp.view_time": "Час от деня",
"horizon.pvp.view_time.tooltip": "Часът от деня, който виждате: само небето и светлината му. Времето на света продължава и нищо не се изпраща към сървъра.",
"horizon.pvp.view_weather": "Време",
"horizon.pvp.view_weather.tooltip": "Времето, което виждате: само дъждът, светлината му и мъглата. Времето на света продължава и нищо не се изпраща към сървъра.",
"horizon.pvp.header.view": "Вашата гледка",
"horizon.pvp.view.world": "На света",
"horizon.pvp.time.morning": "Сутрин",
"horizon.pvp.time.noon": "Обед",
"horizon.pvp.time.sunset": "Залез",
"horizon.pvp.time.midnight": "Полунощ",
"horizon.pvp.weather.clear": "Ясно",
"horizon.pvp.weather.rain": "Дъжд",
"horizon.pvp.weather.thunder": "Гръмотевица",
"horizon.chat.timestamp": "[%s] ",
"horizon.chat.repeats": " (x%s)",
"horizon.pvp.header.chat": "Чат",
"horizon.chat.copied": "Съобщението е копирано",
"horizon.chat.search": "Търсене в чата:",
"horizon.pvp.chat_timestamps": "Времеви маркери",
"horizon.pvp.chat_timestamps.tooltip": "Времето, в което е пристигнало всяко съобщение, пред него.",
"horizon.pvp.chat_compact": "Компактен чат",
"horizon.pvp.chat_compact.tooltip": "Съобщение, повторено последователно, се показва веднъж, с броя на повторенията: (x3).",
"horizon.pvp.chat_copy": "Копиране при щракване",
"horizon.pvp.chat_copy.tooltip": "Щракването върху съобщение в отворения чат копира текста му, освен ако щракването не прави друго.",
"horizon.pvp.chat_search": "Търсене в чата",
"horizon.pvp.chat_search.tooltip": "Ctrl+F в отворения чат: написаното показва само съобщенията, които го съдържат. Enter или Ctrl+F отново спира.",
"horizon.pvp.preset": "PvP предварителна настройка",
"horizon.pvp.preset.tooltip": "Включва собствения мерник, джаджите за броня и ефекти, превключването на спринт и промъкване, по-ниския огън, както и модовете за брояч на FPS и натискания на клавиши, ако са инсталирани. Настройките ви за всяко от тях остават.",
"horizon.pvp.hud_editor_key": "Клавиш за редактора на HUD",
"horizon.pvp.hud_editor_key.tooltip": "Отваря редактора на HUD от играта. Щракнете, след това натиснете клавиш; Escape го развързва.",
"horizon.pvp.editor": "Редактиране на HUD",
"horizon.pvp.editor.tooltip": "Местете джаджите върху играта: плъзгайте ги навсякъде, променяйте размера им с колелцето, щракнете с десен бутон върху някоя, за да я върнете в ъгъла ѝ.",
"horizon.pvp.editor.hint": "Плъзгайте джаджи · Колелце: размер · Десен бутон: обратно в ъгъла",
"horizon.pvp.combat_feel": "Усещане за бой",
"horizon.pvp.combat_feel.tooltip": "Legacy рисува боевете както 1.8.9: предметът в ръката ви, замахът му, храненето, лъкът и меч, вдигнат, докато задържате използване, без снижаването след всеки удар и без индикатора за атака. Само изгледът: това, което изпращате на сървър, остава същото.",
"horizon.pvp.combat_indicator": "Индикатор за атака в Legacy",
"horizon.pvp.combat_indicator.tooltip": "Запазва индикатора за атака в Legacy, който 1.8.9 нямаше.",
"horizon.pvp.combat_feel.modern": "Модерно",
"horizon.pvp.combat_feel.legacy": "Legacy (1.8.9)",
"horizon.pvp.header.combat": "Усещане за бой",
"horizon.pvp.combat_sounds": "Звуци при удар от 1.8",
"horizon.pvp.combat_sounds.tooltip": "В Legacy пропуска звуците при атака, добавени в 1.9 (силна, слаба, замах, критична...). Звуците при нараняване остават.",
"horizon.pvp.combat_rules": "Бой по 1.8 в светове, които домакинствате",
"horizon.pvp.combat_rules.tooltip": "Играта за един играч и световете, които отваряте в LAN, се бият по правилата на 1.8.9: без изчакване на атаката и без замах, щети от оръжия по 1.8, Сила, Острота и критични удари. Сървърите, към които се присъединявате, запазват своите правила.",
"Bindings": "Обвързвания",
"Play": "Игра",
"Device": "Устройство",
"Look": "Поглед",
"Movement": "Движение",
"Menus": "Менюта",
"Actions": "Действия",
"Reset": "Нулиране",
"Detected": "Открито",
"Glyphs": "Глифове",
"Feedback": "Обратна връзка",
"Controller": "Контролер",
"How the sticks turn and move you, and how menus repeat and scroll.": "Как стиковете ви обръщат и движат и как менютата се повтарят и превъртат.",
"Which button does what. Each player has their own.": "Кой бутон какво прави. Всеки играч има свои.",
"The controller the game sees, its button glyphs and its feedback.": "Контролерът, който играта вижда, глифовете на бутоните му и обратната му връзка.",
"Press this row, then the button that should do it.": "Натиснете този ред, след това бутона, който трябва да го прави.",
"Off, the game ignores every controller for this player.": "Изключено, играта пренебрегва всеки контролер за този играч.",
"How fast the right stick turns the view.": "Колко бързо десният стик обръща гледката.",
"Turn the view the other way, left and right.": "Обръща гледката в другата посока, наляво и надясно.",
"Turn the view the other way, up and down.": "Обръща гледката в другата посока, нагоре и надолу.",
"How far the right stick moves before the view turns. Raise it if the view drifts.": "Колко се движи десният стик, преди гледката да се обърне. Увеличете го, ако гледката се движи сама.",
"Hold the button to sneak, or press it once to start and again to stop.": "Задръжте бутона, за да се промъкнете, или го натиснете веднъж, за да започнете, и отново, за да спрете.",
"How far the left stick moves before you walk. Raise it if you drift.": "Колко се движи левият стик, преди да тръгнете. Увеличете го, ако се движите сами.",
"How far a trigger is pulled before it counts as pressed.": "Колко се натиска тригерът, преди да се брои за натиснат.",
"Shows what your buttons do right now, in play and in inventories.": "Показва какво правят бутоните ви в момента, в игра и в инвентарите.",
"Shows only what you can do with what you're looking at, and the main inventory buttons.": "Показва само онова, което можете да направите с това, което гледате, и основните бутони на инвентара.",
"Doesn't show what your buttons do.": "Не показва какво правят бутоните ви.",
"Shows the buttons of the controller you're using.": "Показва бутоните на контролера, който използвате.",
"Always shows Xbox buttons.": "Винаги показва бутоните на Xbox.",
"Always shows PlayStation buttons.": "Винаги показва бутоните на PlayStation.",
"Always shows Nintendo buttons.": "Винаги показва бутоните на Nintendo.",
"A, on the right, confirms and jumps and B goes back, as on a Switch.": "A, отдясно, потвърждава и скача, а B се връща назад, както на Switch.",
"B, at the bottom, confirms and jumps and A goes back, as on other controllers.": "B, отдолу, потвърждава и скача, а A се връща назад, както на други контролери.",
"Opens Steam's keyboard on a Steam Deck under Steam, and Horizon's otherwise.": "Отваря клавиатурата на Steam на Steam Deck под Steam, а иначе — тази на Horizon.",
"Always opens Horizon's keyboard.": "Винаги отваря клавиатурата на Horizon.",
"Always opens Steam's keyboard.": "Винаги отваря клавиатурата на Steam.",
"A DualSense's light shows your health, from green through amber to red.": "Светлината на DualSense показва здравето ви — от зелено през кехлибарено до червено.",
"A DualSense's light changes with the dimension you're in.": "Светлината на DualSense се променя според измерението, в което сте.",
"A DualSense keeps its own light.": "DualSense запазва собствената си светлина.",
"Lets LT + A craft on servers other than your own worlds. Some servers count it as a macro.": "Позволява LT + A да изработва на сървъри, различни от вашите светове. Някои сървъри го смятат за макрос.",
"Show the buttons for what you can do, while you play with a controller.": "Показва бутоните за онова, което можете да направите, докато играете с контролер.",
"How fast the stick moves the crosshair in inventories.": "Колко бързо стикът движи мерника в инвентарите.",
"How fast the right stick scrolls a list.": "Колко бързо десният стик превърта списък.",
"How long a held direction waits before it repeats.": "Колко дълго задържана посока чака, преди да се повтори.",
"How long a held direction takes between repeats.": "Колко дълго задържана посока отнема между повторенията.",
"Which on-screen keyboard opens when a text field is pressed.": "Коя екранна клавиатура се отваря при натискане на текстово поле.",
"Every action back on its default button.": "Всяко действие обратно на бутона си по подразбиране.",
"These settings belong to this player's profile.": "Тези настройки принадлежат на профила на този играч.",
"The controller driving this player, or else the first one connected.": "Контролерът, който управлява този играч, или иначе първият свързан.",
"The USB vendor and product the system reports. Useful when reporting a controller that misbehaves.": "USB производителят и продуктът, които системата отчита. Полезно при докладване на контролер, който се държи неправилно.",
"The glyph family the game picked from the controller's maker.": "Семейството глифове, което играта избра от производителя на контролера.",
"Follow the controller, or always show one family's buttons.": "Следва контролера или винаги показва бутоните на едно семейство.",
"Whether a Nintendo controller's A and B work by where they sit or by their letters.": "Дали A и B на контролер на Nintendo работят според позицията си или според буквите си.",
"The buttons menus show right now.": "Бутоните, които менютата показват в момента.",
"Rumble when something happens to you.": "Вибрация, когато ви се случи нещо.",
"The controller keeps playing while this window isn't focused, and losing focus won't pause.": "Контролерът продължава да играе, докато този прозорец не е на фокус, и загубата на фокус няма да постави на пауза.",
"A DualSense's triggers push back on bows and tools.": "Тригерите на DualSense оказват натиск при лъкове и инструменти.",
"What a DualSense's light shows, over USB or Bluetooth.": "Какво показва светлината на DualSense, по USB или Bluetooth.",
"Player profile": "Профил на играча",
"Vendor and product": "Производител и продукт",
"Detected glyphs": "Открити глифове",
"Glyphs in use": "Глифове в употреба",
"Unknown": "Неизвестно",
"No controller": "Няма контролер",
"Editing: %s": "Редактиране: %s",
"Press the button for this. %s cancels.": "Натиснете бутона за това. %s отказва.",
"This button also does: %s": "Този бутон също прави: %s",
"Not bound: press this row, then the button that should do it.": "Не е обвързан: натиснете този ред, след това бутона, който трябва да го прави.",
"horizon.pad.notice.connected.press": "Натиснете, за да го използвате",
"horizon.recipe_transfer.missing": "Липсващи предмети",
"horizon.recipe_transfer.no_room": "Инвентарът е пълен",
"horizon.recipe_transfer.unavailable": "Рецептата не се побира в това меню",
"horizon.recipe_transfer.unsupported": "Сървърът изисква позната рецепта",
"horizon.studio.timeline.freecam_on": "Свободна камера: ВКЛ.",
"horizon.studio.timeline.freecam_off": "Свободна камера: ИЗКЛ.",
"horizon.studio.timeline.freecam_short": "Кам",
"horizon.studio.timeline.freecam_active_short": "Кам ✓",
"horizon.studio.timeline.add_keyframe_short": "+ Кл. кадър",
"horizon.studio.timeline.delete_keyframe_short": "− Изтр.",
"horizon.studio.timeline.split": "Разделяне",
"horizon.studio.timeline.marker": "Маркер %s",
"horizon.studio.timeline.seconds": "%s с",
"horizon.studio.timeline.scene_count": "%s сцени",
"horizon.studio.timeline.scene_title": "Сцена %s",
"horizon.studio.timeline.sequence": "Последователност",
"horizon.studio.topbar.title": "✦ Studio",
"horizon.studio.topbar.file": "Файл",
"horizon.studio.topbar.camera": "Камера",
"horizon.studio.topbar.view": "Изглед",
"horizon.studio.topbar.file_short": "Файл",
"horizon.studio.topbar.camera_short": "Кам",
"horizon.studio.topbar.view_short": "Изглед",
"horizon.studio.topbar.game_pause_menu": "⏸ Меню за пауза",
"horizon.studio.topbar.pause_short": "Пауза",
"horizon.studio.topbar.open_recordings": "📁 Отвори папката със записи",
"horizon.studio.topbar.close_replay": "✕ Затвори записа",
"horizon.studio.topbar.exit": "Изход",
"horizon.studio.topbar.toggle_freecam": "Превключи свободната камера",
"horizon.studio.topbar.reset_to_player": "👤 Върни към играча",
"horizon.studio.topbar.unlock_freecam": "🔓 Отключи свободната камера",
"horizon.studio.topbar.lock_to_spline": "🔒 Заключи към сплайн",
"horizon.studio.topbar.add_keyframe": "➕ Добави ключов кадър",
"horizon.studio.topbar.add_marker": "Добави маркер",
"horizon.studio.topbar.clear_keyframes": "🗑 Изчисти ключовите кадри",
"horizon.studio.topbar.timeline_hud": "⏱ Времева линия",
"horizon.studio.topbar.split_scene": "Раздели сцената"
}
@@ -148,9 +148,7 @@
"Size of menus and the HUD.": "Ment ar mennozhioù hag an HUD.",
"Sets the options below together. Changing one makes it Custom.": "Lakaat a ra an dibarzhioù amañ dindan asambles. Cheñch unan a ra anezhañ Personelaet.",
"Lava, fire and sunlight shine brighter than white. Needs an HDR display.": "Lava, tan ha gouloù an heol a lugern muioc'h eget ar gwenn. Ezhomm zo eus ur skramm HDR.",
"Your display's darkest black. 0 for OLED; LCDs glow a little.": "Du teñvalañ eo ho skramm. 0 evit OLED; LCDoù a lugern un tamm.",
"How bright highlights get. Auto: the peak your system reports.": "Pegen skedus e teu ar sklêrijennoù. Emgefre : ar pik a vez embannet gant ho reizhiad.",
"How bright white paper and menus look. Auto: your desktop's white.": "Pegen skedus e seblant ar paper gwenn hag ar mennozhioù. Emgefre : gwenn eo ho burev.",
"How bright the HUD and menus are, against paper white.": "Pegen skedus eo an HUD hag ar mennozhioù, a-enep gwenn ar paper.",
"How far mobs, crops and machines keep working. CPU time.": "Pegen pell e kendalc'h ar mobs, ar c'hoadoù hag ar mekanikoù da labourat. Amzer ar CPU.",
"How far away mobs and items are drawn, as a share of normal.": "Pegen pell e vez tennet ar mobs hag an traoù, evel ul lodenn eus ar pezh a vez graet normal.",
@@ -1,37 +1,158 @@
{
"horizon.extras.header.world": "Svijet",
"horizon.extras.pregen": "Prethodno generisanje komada",
"horizon.extras.pregen.tooltip": "Omogućava /pregen u vašim svjetovima za jednog igrača i na LAN-u. Uz uključene prevare koristite /pregen start da generišete područje prije nego ga istražite. Ne omogućava komandu na serverima kojima se pridružite.",
"horizon.options.controller": "Kontroler",
"horizon.options.three_d_skin_layers": "3D slojevi skina",
"horizon.options.vibration": "Vibracije",
"horizon.options.play_in_background": "Igra u pozadini",
"horizon.options.adaptive_triggers": "Adaptivni okidači",
"horizon.options.gameplay_guide": "Vodič kroz igru",
"horizon.options.gameplay_guide.basic": "Osnovno",
"horizon.options.gameplay_guide.full": "Pun",
"horizon.options.gameplay_guide.off": "Isključeno",
"horizon.controller.guide.use": "Koristi",
"horizon.controller.guide.open": "Otvori",
"horizon.controller.guide.ride": "Jahaj",
"horizon.controller.guide.trade": "Trguj",
"horizon.controller.guide.feed": "Nahrani",
"horizon.controller.guide.place": "Postavi",
"horizon.controller.guide.eat": "Jedi",
"horizon.controller.guide.drink": "Pij",
"horizon.controller.guide.shoot": "Pucaj",
"horizon.controller.guide.load": "Napuni",
"horizon.controller.guide.throw": "Baci",
"horizon.controller.guide.raise": "Podigni",
"horizon.controller.guide.equip": "Opremi",
"horizon.controller.guide.fish": "Pecaj",
"horizon.controller.guide.zoom": "Zum",
"horizon.controller.guide.boost": "Ubrzaj",
"horizon.controller.guide.break": "Razbij",
"horizon.controller.guide.attack": "Napadni",
"horizon.controller.guide.spectate": "Posmatraj",
"horizon.controller.guide.spectator_menu": "Meni posmatrača",
"horizon.controller.guide.release_shoot": "Otpusti za pucanje",
"horizon.controller.guide.release_throw": "Otpusti za bacanje",
"horizon.controller.guide.release_stop": "Otpusti za zaustavljanje",
"horizon.controller.guide.fly": "Leti",
"horizon.controller.guide.fly_up": "Leti gore",
"horizon.controller.guide.fly_down": "Leti dolje",
"horizon.controller.guide.swim_up": "Plivaj gore",
"horizon.controller.guide.swim_down": "Plivaj dolje",
"horizon.controller.guide.glide": "Jedri",
"horizon.controller.guide.dismount": "Siđi",
"horizon.controller.guide.stand": "Ustani",
"horizon.controller.guide.drop": "Ispusti predmet",
"horizon.controller.guide.swap": "Zamijeni ruke",
"horizon.controller.guide.take": "Uzmi",
"horizon.controller.guide.take_half": "Uzmi pola",
"horizon.controller.guide.put": "Spusti",
"horizon.controller.guide.put_one": "Stavi jedan",
"horizon.controller.guide.swap_stack": "Zamijeni",
"horizon.controller.guide.quick_move": "Brzo premjesti",
"horizon.controller.guide.drop_stack": "Ispusti hrpu",
"horizon.controller.guide.place_recipe": "Postavi recept",
"horizon.controller.guide.place_recipe_all": "Postavi sve",
"horizon.controller.guide.alternatives": "Alternative",
"horizon.controller.guide.select": "Odaberi",
"horizon.controller.guide.close": "Zatvori",
"horizon.controller.guide.pages": "Stranice",
"horizon.controller.guide.categories": "Kategorije",
"horizon.controller.guide.tabs": "Kartice",
"horizon.options.lightbar": "Svjetlosna traka",
"horizon.options.look_sensitivity": "Osjetljivost pogleda",
"horizon.options.invert_x": "Obrnuti pogled po X",
"horizon.options.invert_y": "Obrnuti pogled po Y",
"horizon.options.craft_on_servers": "Brza izrada na serverima",
"horizon.controller.direct_craft": "Brza izrada",
"horizon.options.sneak_button": "Dugme za šunjanje",
"horizon.options.move_deadzone": "Mrtva zona kretanja",
"horizon.options.look_deadzone": "Mrtva zona pogleda",
"horizon.options.trigger_threshold": "Prag okidača",
"horizon.options.button_glyphs": "Dugmad Glyphs",
"horizon.options.on_screen_keyboard": "Tastatura na ekranu",
"horizon.options.chat_delay": "Kašnjenje ćaskanja",
"horizon.options.lightbar.health": "Zdravlje",
"horizon.options.lightbar.dimension": "Dimenzija",
"horizon.options.glyphs.auto": "Automatski",
"horizon.options.glyphs.xbox": "Xbox",
"horizon.options.glyphs.playstation": "PlayStation",
"horizon.options.glyphs.nintendo": "Nintendo",
"horizon.options.nintendo_buttons": "Nintendo dugmad",
"horizon.options.nintendo_buttons.position": "Po položaju",
"horizon.options.nintendo_buttons.letter": "Po slovu",
"horizon.options.keyboard.auto": "Automatski",
"horizon.options.keyboard.horizon": "Horizon",
"horizon.options.keyboard.steam": "Steam",
"horizon.options.unavailable": "Ova funkcija još nije dostupna u Horizon.",
"horizon.options.search": "Postavke pretrage...",
"horizon.studio.record.usage": "Upotreba: /studio record <start [name]|stop|status>",
"horizon.studio.record.started": "Snimanje u Studiju je počelo: %s",
"horizon.studio.record.saved": "Snimanje u Studiju je sačuvano: %s",
"horizon.studio.record.error": "Greška pri snimanju u Studiju: %s",
"horizon.studio.record.status.active": "Snimanje u Studiju je aktivno — %s s, %s paketa, %s KB (%s)",
"horizon.studio.record.status.inactive": "Snimanje u Studiju nije aktivno",
"horizon.studio.replay.usage": "Upotreba: /studio replay <open PATH|pause|resume|speed MULTIPLIER|seek MS|status|close>",
"horizon.studio.replay.open.usage": "Upotreba: /studio replay open <PATH>",
"horizon.studio.replay.opened": "Repriza u Studiju je otvorena: %s (%s s, %s paketa)",
"horizon.studio.replay.open_error": "Nije moguće otvoriti reprizu: %s",
"horizon.studio.replay.paused": "Repriza u Studiju je pauzirana",
"horizon.studio.replay.resumed": "Repriza u Studiju je nastavljena",
"horizon.studio.replay.inactive": "Nema aktivne reprize u Studiju",
"horizon.studio.replay.speed.usage": "Upotreba: /studio replay speed <MULTIPLIER>",
"horizon.studio.replay.speed.set": "Brzina reprize u Studiju je postavljena na %s×",
"horizon.studio.replay.speed.invalid": "Nevažeća brzina reprize",
"horizon.studio.replay.seek.usage": "Upotreba: /studio replay seek <MILLISECONDS>",
"horizon.studio.replay.seeked": "Repriza u Studiju je premotana na %s ms (%s s)",
"horizon.studio.replay.seek.error": "Nije moguće premotati reprizu: %s",
"horizon.studio.replay.seek.backward_requires_standalone": "Premotavanje unazad je dostupno samo za samostalnu reprizu.",
"horizon.studio.replay.seek.invalid": "Nevažeća pozicija reprize",
"horizon.studio.replay.status": "Repriza u Studiju: %s / %s ms (%s%%), brzina %s×, pauzirano %s, završeno %s",
"horizon.studio.replay.closed": "Repriza u Studiju je zatvorena",
"horizon.studio.freecam.enabled": "Slobodna kamera u Studiju je uključena. Koristite WASD za kretanje, Space i Shift za uspon ili spuštanje, a Control za brže kretanje.",
"horizon.studio.freecam.disabled": "Slobodna kamera u Studiju je isključena",
"horizon.studio.freecam.unavailable": "Slobodna kamera u Studiju nije našla kameru igrača",
"horizon.studio.freecam.replay_required": "Slobodna kamera u Studiju je dostupna tokom reprodukcije reprize",
"horizon.studio.freecam.usage": "Upotreba: /studio freecam <on|off>",
"horizon.studio.keyframe.added": "Dodat je ključni kadar kamere na %s ms. Putanja se čuva kada zatvorite reprizu.",
"horizon.studio.keyframe.cleared": "Putanja kamere je očišćena. Promjena se čuva kada zatvorite reprizu.",
"horizon.studio.keyframe.removed": "Uklonjen je ključni kadar kamere %s. Promjena se čuva kada zatvorite reprizu.",
"horizon.studio.keyframe.save_error": "Nije moguće sačuvati putanju kamere: %s",
"horizon.studio.keyframe.remove_usage": "Upotreba: /studio keyframe remove <INDEX>",
"horizon.studio.keyframe.index_invalid": "Indeks ključnog kadra kamere je van opsega",
"horizon.studio.keyframe.usage": "Upotreba: /studio keyframe <add|remove INDEX|clear>",
"horizon.studio.path.locked": "Reprodukcija putanje kamere u Studiju je uključena",
"horizon.studio.path.unlocked": "Reprodukcija putanje kamere u Studiju je zaustavljena",
"horizon.studio.path.usage": "Upotreba: /studio path <lock|unlock> (prvo dodajte barem jedan ključni kadar)",
"horizon.pages.mods": "Modovi",
"horizon.pages.shader_packs": "Paketi šejdera",
"horizon.pages.shader_options": "Opcije šejdera",
"horizon.pages.controller": "Postavke kontrolera",
"horizon.pages.controller_binds": "Kontroler se vezuje",
"horizon.pages.extras": "Horizon Dodaci",
"horizon.pages.settings": "Horizon Postavke",
"horizon.pages.studio": "Studio za reprize",
"horizon.pages.agent": "AI agent",
"horizon.split.leave": "Odlazi",
"horizon.pages.split_settings": "Splitscreen postavke",
"horizon.pages.split_profiles": "ko igra?",
"horizon.pages.split_setup": "Splitscreen podešavanje",
"horizon.pages.music": "Muzika",
"horizon.pages.music.menu": "Meni",
"horizon.pages.music.overworld": "Gornji svijet",
"horizon.pages.music.creative": "Kreativno",
"horizon.pages.music.underwater": "Pod vodom",
"horizon.pages.music.end": "Kraj",
"horizon.pages.music.boss": "Šefe",
"horizon.pages.music.credits": "Zasluge",
"horizon.pages.music.overworld_biomes": "Biomi Gornjeg svijeta",
"horizon.pages.music.nether_biomes": "Nether biomi",
"horizon.pages.music.end_biomes": "Kraj bioma",
"horizon.pages.music.other": "Ostala muzika",
"horizon.pages.music.empty": "Nema muzičkih numera: zvuci igre se ne učitavaju.",
"horizon.pages.music.tooltip": "Ugrađene numere, po situaciji: igrajte jednu sada ili isključite bilo koju da igra prestane da je bira.",
"horizon.pages.settings.tooltip": "Horizon vlastite postavke: grafika, kontroleri, dodaci i modovi.",
"horizon.pages.studio.tooltip": "Pregledajte sačuvane snimke i njihove metapodatke.",
"horizon.pages.graphics.tooltip": "Grafika, jednostavno: svaka postavka govori koliko košta.",
"horizon.pages.graphics_advanced.tooltip": "Svaka grafička postavka, Horizon je uključena, sa onim što svaka košta.",
"horizon.pages.controller.tooltip": "Kontroleri: njihova dugmad, štapići, vibracije i navigacija u meniju.",
@@ -45,61 +166,132 @@
"horizon.pages.open.graphics_advanced": "Napredna grafika...",
"horizon.pages.open.shader_packs": "Shader paketi...",
"horizon.pages.open.controller": "Postavke kontrolera...",
"horizon.pages.open.split_setup": "Splitscreen...",
"horizon.pages.open.extras": "Dodaci...",
"horizon.pages.open.mods": "Modovi...",
"horizon.pages.open.agent": "AI agent...",
"horizon.pages.open.horizon_graphics": "Horizon Grafika...",
"horizon.pages.open.controller_binds": "Kontroler vezuje...",
"horizon.pages.open.horizon_settings": "Horizon Postavke...",
"horizon.pages.open.studio": "Studio za reprize...",
"horizon.studio.saved_recordings": "Sačuvani snimci",
"horizon.studio.empty": "U fascikli snimaka nema pronađenih snimaka.",
"horizon.studio.empty_hint": "Koristite /studio record start <name> u svijetu ili tamo stavite .mcpr datoteke.",
"horizon.studio.open_folder": "Otvori snimke",
"horizon.studio.open_replay": "Pusti reprizu",
"horizon.studio.refresh": "Osvježi",
"horizon.studio.unknown_server": "Nepoznato",
"horizon.pages.open.music": "muzika...",
"horizon.mods.empty": "Bez modova: stavite .hmod fajlove u folder horizon-mods",
"horizon.mods.stopped": " (zaustavljeno)",
"horizon.mods.not_built": " (nije izgrađeno)",
"horizon.mods.dev": " (dev)",
"horizon.mods.settings": "Postavke",
"horizon.mods.in_world": " (ostavi svijet da se promijeni)",
"horizon.mods.open_folder": "Otvorite Mod folder",
"horizon.mods.browse": "Pregledaj modove...",
"horizon.mods.browse.title": "Pregledaj modove",
"horizon.mods.browse.search": "Pretraži modove...",
"horizon.mods.browse.project": "Mod",
"horizon.mods.browse.loading": "Čitam Horizon registar modova...",
"horizon.mods.browse.failed": "Nije bilo moguće pročitati registar modova: %s",
"horizon.mods.browse.none": "Nije pronađen nijedan mod",
"horizon.mods.browse.has_update": " (ažuriranje)",
"horizon.mods.browse.is_installed": " (instalirano)",
"horizon.mods.browse.by": "od %s",
"horizon.mods.browse.installing": "Instaliranje...",
"horizon.mods.browse.install": "Instaliraj",
"horizon.mods.browse.update": "Ažuriraj",
"horizon.mods.browse.installed": "Instalirano",
"horizon.mods.browse.open_page": "Otvori stranicu",
"horizon.mods.browse.enabled": "Mod",
"horizon.mods.browse.changelog": "Historija izmjena",
"horizon.mods.browse.done": "%s %s je instaliran i uključen.",
"horizon.mods.browse.install_failed": "Nije bilo moguće instalirati: %s",
"horizon.mods.switch.worldgen": "mijenja generisanje svijeta",
"horizon.mods.switch.registries": "dodaje blokove, predmete ili druge unose u registar",
"horizon.mods.switch.restart": "Restartujte igru da primijenite %s: dodaju blokove, predmete ili generisanje svijeta.",
"horizon.shader_options.profile": "Profil",
"horizon.agent.copy_details": "Kopiraj detalje veze",
"horizon.multiplayer.join": "Pridružite se",
"horizon.multiplayer.cancel_reconnecting": "Otkaži ponovno povezivanje",
"horizon.multiplayer.join_friend": "Pridružite se prijatelju",
"horizon.multiplayer.invite_code": "Pozivni kod",
"horizon.multiplayer.relay": "Relej (opciono)",
"Preset": "Predložak",
"LOD Distance": "LOD Udaljenost",
"Far Detail": "Daleki detalj",
"Cave Culling": "Odbijanje pećina",
"GPU Culling": "GPU odbijanje",
"Entity Culling": "Uništavanje entiteta",
"Chunk Updates": "Ažuriranja komada",
"Upload Budget": "Budžet otpremanja",
"Sky": "Nebo",
"Fog": "Magla",
"Weather": "Vrijeme",
"Animated Textures": "Animirane teksture",
"Present Mode": "Način prikaza",
"Frame Pacing": "Tempo okvira",
"Worker Threads": "Radne niti",
"Resolution Scale": "Skala rezolucije",
"Anti-aliasing": "Zaglađivanje",
"Upscaling": "Povećanje",
"Upscaling Sharpness": "Oštrina povećanja",
"Paper White": "Papir bijeli",
"Peak Brightness": "Vršna svjetlina",
"Black Level": "Nivo crne",
"UI Brightness": "Svjetlina interfejsa",
"HDR Lighting": "HDR osvjetljenje",
"Light sources, the sun and sunlight shine past white. Off: just as SDR.": "Izvori svjetlosti, sunce i sunčeva svjetlost sijaju jače od bijelog. Isključeno: kao SDR.",
"Smooth Gradients": "Glatki gradijenti",
"16-bit frame: dark skies and shadows don't band. A bit more VRAM. Next start.": "16-bitni okvir: tamno nebo i sjene se ne stepenuju. Malo više VRAM-a. Sljedeće pokretanje.",
"Battery Mode": "Režim baterije",
"Graphics API": "Grafički API",
"Translucent Batching": "Grupisanje prozirnog",
"Performance Overlay": "Preklapanje performansi",
"Full Detail Distance": "Puna udaljenost do detalja",
"On": "Uključeno",
"Off": "Isključeno",
"Unlimited": "Neograničeno",
"Auto": "Automatski",
"Custom": "Prilagođeno",
"Performance": "Performanse",
"Balanced": "Balansirano",
"Quality": "Kvaliteta",
"Ultra": "Ultra",
"Ultra Performance": "Ultra performanse",
"Native": "Izvorno",
"Full": "Pun",
"Reduced": "Smanjena",
"Efficient": "Efikasno",
"Precise": "Precizno",
"Auto (VSync)": "automatski (VSync)",
"FIFO": "FIFO",
"Mailbox": "Mailbox",
"Immediate": "Odmah",
"Vulkan": "Vulkan",
"DirectX 12": "DirectX 12",
"OpenGL": "OpenGL",
"FPS and Graph": "FPS i Graf",
"FPS": "FPS",
"TAA": "TAA",
"FXAA": "FXAA",
"No cost": "Bez troškova",
"Low cost": "Niska cijena",
"Medium cost": "Srednji trošak",
"High cost": "Visoka cijena",
"Saves time": "Štedi vrijeme",
"HDR": "HDR",
"Graphics": "Grafika",
"Done": "Gotovo",
"This feature is not yet available in Horizon.": "Ova funkcija još nije dostupna u Horizon.",
"Your display or system doesn't offer HDR output.": "Vaš ekran ili sistem ne nude HDR izlaz.",
"Turn HDR on to use it.": "Uključite HDR da ga koristite.",
"Your GPU or graphics API doesn't support it.": "Vaš GPU ili grafički API to ne podržava.",
"Not with software rendering, which runs it on the CPU.": "Ne uz softversko renderovanje, koje ga izvodi na CPU-u.",
"Out of video memory: off until the next start.": "Nema video memorije: isključeno do sljedećeg pokretanja.",
"The graphics device was lost: off until the next start.": "Grafički uređaj je izgubljen: isključeno do sljedećeg pokretanja.",
"Not on DirectX 12 yet: try the Vulkan graphics API.": "Još ne na DirectX 12: probajte Vulkan grafički API.",
"%s chunks": "%s komada",
"%s per frame": "%s po kadru",
"%s MiB/frame": "%s MiB/okvir",
@@ -115,13 +307,15 @@
"Size of menus and the HUD.": "Veličina menija i HUD-a.",
"Sets the options below together. Changing one makes it Custom.": "Zajedno postavlja donje opcije. Promjenom jednog postaje prilagođeno.",
"Lava, fire and sunlight shine brighter than white. Needs an HDR display.": "Lava, vatra i sunčeva svjetlost sijaju jače od bijele. Potreban je HDR ekran.",
"Your display's darkest black. 0 for OLED; LCDs glow a little.": "Vaš ekran je najtamnije crn. 0 za OLED; LCD-i malo svijetle.",
"Lifts shadows onto your display's darkest black. 0: true black, no lift.": "Podiže sjene do najcrnje crne vašeg ekrana. 0: prava crna, bez podizanja.",
"How bright highlights get. Auto: the peak your system reports.": "Kako svetli naglasci postaju. Automatski: vrhunac o kojem vaš sistem prijavljuje.",
"How bright white paper and menus look. Auto: your desktop's white.": "Kako izgledaju svijetli bijeli papir i meniji. Auto: vaša radna površina je bijela.",
"How bright white looks in the world. Auto: your desktop's white.": "Koliko svijetlo izgleda bijelo u svijetu. Automatski: bijela vaše radne površine.",
"How bright the HUD and menus are, against paper white.": "Koliko su svijetli HUD i meniji, naspram bijelog papira.",
"How far mobs, crops and machines keep working. CPU time.": "Koliko mobovi, usjevi i mašine rade. CPU vrijeme.",
"How far away mobs and items are drawn, as a share of normal.": "Koliko su udaljeni mobovi i predmeti, kao udio normalnog.",
"Skips terrain hidden behind closed sections. Off costs a lot.": "Preskače teren skriven iza zatvorenih dijelova. Isključenje košta puno.",
"Culls nearby terrain on the GPU: less CPU at high render distance, more GPU.": "Odbacuje obližnji teren na GPU-u: manje CPU-a na velikoj udaljenosti renderovanja, više GPU-a.",
"Auto: tries it where your CPU limits the frame, keeps it if frames get faster.": "Automatski: pokušava tamo gdje vaš CPU ograničava okvir i zadržava to ako okviri postanu brži.",
"Hides entities behind opaque blocks.": "Sakriva entitete iza neprozirnih blokova.",
"No entity occlusion culling.": "Nema uklanjanja okluzije entiteta.",
"CPU occlusion test runs this frame.": "CPU test okluzije pokreće ovaj okvir.",
@@ -145,6 +339,7 @@
"World pixels per screen pixel. Below 100% is faster on the GPU.": "Svjetski pikseli po pikselu ekrana. Ispod 100% je brži na GPU-u.",
"Smooths jagged edges. FXAA: softer. TAA: smoothest, moving things may trail.": "Zaglađuje nazubljene ivice. FXAA: mekše. TAA: najglađe, pokretne stvari mogu zaostajati.",
"Renders the world smaller and rebuilds it with TAA. Faster, softer.": "Čini svijet manjim i obnavlja ga pomoću TAA. Brže, mekše.",
"Sharpens the upscaled picture. Higher: crisper edges, more shimmer.": "Izoštrava uvećanu sliku. Više: oštrije ivice, više treperenja.",
"HDR output on an HDR display: lava, fire and sunlight shine brighter.": "HDR izlaz na HDR displeju: lava, vatra i sunčeva svetlost sijaju jače.",
"How frames reach the screen while VSync is off.": "Kako okviri dolaze do ekrana dok je VSync isključen.",
"Efficient sleeps until the next frame; Precise spins, like vanilla.": "Efikasno spavanje do sljedećeg kadra; Precizno okretanje, poput vanile.",
@@ -164,6 +359,7 @@
"Frame rate and frame times over the game, with a graph.": "Brzina kadrova i vrijeme kadrova u igri, sa grafikonom.",
"horizon.multiplayer.finding_friend": "Pronalaženje svijeta svog prijatelja...",
"horizon.multiplayer.not_signed_in": "Ovaj server je u online modu. Prijavite se sa svojim Microsoft nalogom u Horizon pokretaču da mu se pridružite.",
"horizon.realms.not_signed_in": "Prijavite se svojim Microsoft nalogom u Horizon pokretaču da igrate na Realms-u.",
"horizon.multiplayer.invalid_address": "Nevažeća adresa servera: %s",
"horizon.multiplayer.connected_friend_direct": "Povezani direktno sa vašim prijateljem...",
"horizon.multiplayer.connected_friend_relay": "Povezani sa vašim prijateljem preko releja...",
@@ -173,6 +369,8 @@
"Search options": "Opcije pretraživanja",
"Shortcut": "Prečica",
"Distance": "Udaljenost",
"Display": "Ekran",
"Interface": "Interfejs",
"Sky and weather": "Nebo i vrijeme",
"Lighting and entities": "Rasvjeta i entiteti",
"Textures": "Teksture",
@@ -181,6 +379,7 @@
"Culling": "Odbijanje",
"Power": "Snaga",
"Frames": "Okviri",
"Overlay": "Preklapanje",
"Output": "Izlaz",
"Brightness": "Osvetljenost",
"How far you see, the window and the HUD.": "Koliko daleko vidite, prozor i HUD.",
@@ -190,13 +389,17 @@
"Brighter-than-white output on an HDR display.": "Izlaz svjetliji od bijelog na HDR ekranu.",
"Results for \"%s\"": "Rezultati za \"%s\"",
"No settings match.": "Nijedna postavka se ne podudara.",
"horizon.plain.official": "Zvanični Horizon sa pokretačem, modovima i ažuriranjima: playhorizon.dev",
"horizon.title.not_affiliated": "Nije povezan sa Mojangom ili Microsoftom",
"horizon.extras.header.lighting": "Osvetljenje",
"horizon.extras.header.third_person": "Treća osoba",
"horizon.extras.header.particles": "Čestice",
"horizon.extras.header.inventory": "Inventar",
"horizon.extras.header.chat": "Časkanje",
"horizon.extras.header.multiplayer": "Više igrača",
"horizon.extras.header.sound": "Zvuk",
"horizon.extras.header.menus": "Meniji",
"horizon.extras.fullbright": "Puno svjetlo",
"horizon.extras.dynamic_lights": "Dinamička svjetla",
"horizon.extras.own_name_tag": "Oznaka s vlastitim imenom",
"horizon.extras.hide_block_particles": "Sakrijte čestice razbijanja bloka",
@@ -208,8 +411,14 @@
"horizon.extras.sort": "Dugmad za sortiranje",
"horizon.extras.sort_key": "Ključ za sortiranje",
"horizon.extras.no_ai": "Nema AI",
"horizon.extras.agent_control": "Kontrola agenta",
"horizon.extras.discord_presence": "Discord Rich Presence",
"horizon.extras.zoom": "Zum",
"horizon.extras.zoom_mod_install": "Zum: instalirajte iz Modova",
"horizon.extras.splitscreen": "Splitscreen",
"horizon.extras.chat_animation": "Glatko ćaskanje",
"horizon.extras.chat_indicator": "Indikator poruke",
"horizon.extras.wheel_cycles_list_settings.tooltip": "Točkić preko dugmeta u listi postavki mijenja njegovu vrijednost. Isključeno, kao u vanili, točkić pomiče listu.",
"horizon.extras.wheel_cycles_settings": "Točkić za pomicanje mijenja postavke",
"horizon.extras.pack_browser": "Pregledač paketa resursa",
"horizon.extras.fullbright.tooltip": "Prikažite blokove i entitete pri punom svjetlu, čak iu tamnim područjima.",
@@ -261,6 +470,7 @@
"horizon.friends.hint": "Prijatelji zalijepe kod iza dugmeta Prijatelji na naslovnom ekranu, u Direktnu vezu ili na Play stranicu pokretača.",
"horizon.friends.copied": "Kopirano",
"horizon.friends.copy_code": "Kopiraj kod",
"horizon.friends.stop_sharing": "Prekini dijeljenje",
"horizon.friends.none_yet": "Ovdje još nema prijatelja.",
"horizon.friends.here_now": "Evo sada: %s",
"horizon.friends.share_intro": "Podijelite ovaj svijet iza pozivnog koda: otvara se na LAN, a prijatelji na internetu se pridružuju kodu. Nijedan naš server nije uključen.",
@@ -269,9 +479,12 @@
"horizon.friends.stopped": "Zaustavljeno je dijeljenje pozivnog koda.",
"horizon.friends.short_code_changed": "Neko je nastavio pokušavati s pogrešnim kratkim kodom, pa se to promijenilo.",
"horizon.friends.wrong_code": "Neko je probao pogrešan kod.",
"horizon.friends.relay_stopped": "Previše pogrešnih kratkih kodova, pa je relej isključen dok ponovo ne podijelite. Prijatelji se još mogu pridružiti s punim kodom.",
"horizon.friends.code_ready": "Vaš pozivni kod je spreman: Meni igre, Igrajte se sa prijateljima. Kopirajte ga i pošaljite svojim prijateljima.",
"horizon.friends.share_failed": "Ne mogu dijeliti svijet: %s",
"horizon.friends.finding_route": "Pronalaženje načina na koji prijatelji mogu doći do vas…",
"horizon.osk.shift": "Shift",
"horizon.osk.space": "Razmak",
"horizon.osk.delete": "Izbriši",
"horizon.osk.type": "Tip",
"horizon.osk.symbols": "Simboli",
@@ -280,11 +493,14 @@
"horizon.extras.chat_indicator.tooltip": "Traka u boji lijevo od linije za ćaskanje.",
"horizon.extras.pack_browser.tooltip": "Select Resource Packs dobija dugme Dodaj koje pretražuje Modrinth i instalira paket u fasciklu sa paketima. Isključeno, igra nikada ne kontaktira Modrinth.",
"horizon.extras.splitscreen.tooltip": "U svijetu se drugi kontrolor može pridružiti kao novi igrač: pritisnite A kada to zatraži, a zatim odaberite profil i gdje ćete igrati.",
"horizon.splitscreen.setup.monitor": "Monitor %s",
"horizon.splitscreen.setup.monitor_unknown": "monitor",
"horizon.splitscreen.setup.window": "Prozor %s",
"horizon.splitscreen.setup.new_window": "Novi prozor",
"horizon.splitscreen.setup.fill_monitor": "Popuni %s",
"horizon.splitscreen.setup.done": "Gotovo",
"horizon.splitscreen.setup.switch": "%s: %s",
"horizon.splitscreen.setup.on": "Uključeno",
"horizon.splitscreen.setup.off": "Isključeno",
"horizon.splitscreen.setup.player": "Igrač %s",
"horizon.splitscreen.setup.revert": "Vrati",
@@ -311,6 +527,7 @@
"horizon.splitscreen.setup.identify": "Identifikujte",
"horizon.splitscreen.setup.layout.two": "2 dijeljenja: %s",
"horizon.splitscreen.setup.layout.three": "3 dijeljenja: %s",
"horizon.splitscreen.setup.layout.two.0": "Automatski",
"horizon.splitscreen.setup.layout.two.1": "Rame uz rame",
"horizon.splitscreen.setup.layout.two.2": "Gornji i donji deo",
"horizon.splitscreen.setup.layout.three.0": "Jedan širok na vrhu",
@@ -325,6 +542,7 @@
"horizon.splitscreen.setup.help.revert": "Poništite promjene koje još nisu primijenjene.",
"horizon.splitscreen.setup.help.apply": "Igrajte onako kako je prikazano na karti.",
"horizon.splitscreen.setup.help.unchanged": "Još se ništa nije promijenilo.",
"horizon.splitscreen.setup.give_up_pad": "Predaj kontroler",
"horizon.splitscreen.prompt.controller_connected": "Kontroler povezan",
"horizon.splitscreen.prompt.controller_disconnected": "Kontroler je isključen",
"horizon.splitscreen.prompt.controller_disconnected.hint": "Ponovo ga povežite ili pritisnite dugme na drugom",
@@ -339,11 +557,16 @@
"horizon.splitscreen.setup.help.give_up_pad": "Vaš kontrolor prestaje da vas vozi i nudi mu se pridruživanje (A).",
"horizon.splitscreen.setup.note.held_at": "%s do %s: A ispušta je ovdje, B vraća.",
"horizon.splitscreen.setup.note.held": "%s podignuto: lijevo/desno bira gdje, A pada, B vraća nazad.",
"horizon.splitscreen.online_only": "Ovaj server je u online modu: offline profili mu se ne mogu pridružiti, svaki igrač treba vlastiti Minecraft nalog.",
"horizon.splitscreen.own_account": "Ovaj server zahtijeva vlastiti Minecraft nalog za svakog igrača",
"horizon.splitscreen.local_only": "Drugi igrači se mogu pridružiti ovom serveru samo sa svojim Minecraft nalogom: offline profili igraju u vašem svijetu, na LAN-u ili na serveru na vašoj lokalnoj mreži.",
"Hides models behind opaque blocks. Async checks off-thread; entities tick.": "Skriva modele iza neprozirnih blokova. Async provjerava off-thread; entiteti otkucaju.",
"Block Entities": "Blokirajte entitete",
"Shulker Boxes": "Kutije za šulkere",
"Reuses settled boxes; lid movement stays animated.": "Ponovno koristi postavljene kutije; pokreti poklopca ostaju animirani.",
"Signs": "Znakovi",
"Reuses sign text until text, light, or font changes.": "Ponovo koristi potpisni tekst dok se ne promijeni tekst, svjetlo ili font.",
"Decorated Pots": "Ukrašeni lonci",
"Reuses still pots; break wobble and sherd changes redraw.": "Ponovno koristi lonce za mirovanje; prekinuti kolebanje i promjene u komadu ponovo nacrtati.",
"Bells": "Zvona",
"Reuses still bells; ringing stays animated.": "Ponovno koristi zvona za mirovanje; zvono ostaje animirano.",
@@ -353,22 +576,45 @@
"Reuses poles; flags and pattern layers still update.": "Ponovo koristi stubove; zastavice i slojevi uzoraka se i dalje ažuriraju.",
"Copper Golem Statues": "Statue od bakrenog golema",
"Reuses statue geometry until its block state changes.": "Ponovo koristi geometriju statue dok se stanje bloka ne promijeni.",
"Leaf Culling": "Odbijanje lišća",
"Every leaf face. Vanilla's fancy leaves.": "Svaki list lice. Vanilini fensi listovi.",
"Opaque Ice": "Neprozirni led",
"Draws ice in the solid pass, without its see-through texture.": "Crta led u čvrstom prolazu, bez njegove prozirne teksture.",
"Distant Plant Dither": "Rastriranje dalekih biljaka",
"Keeps fine plants in high-res packs from fading far away.": "Čuva fine biljke u pakovanjima visoke rezolucije da ne izblijede.",
"Screenshot Quality": "Kvaliteta snimka zaslona",
"F2 screenshots at this many times the game's resolution. Higher takes longer.": "F2 screenshotovi na ovoj rezoluciji koja je mnogo puta veća od igre. Više traje duže.",
"Frame Generation": "Generacija okvira",
"Shows a made-up frame between each two real ones: smoother, a little more lag.": "Prikazuje napravljeni okvir između svaka dva stvarna: glatkiji, malo više zaostajanja.",
"Shadow Cache": "Keš senki",
"Shaders": "Šejderi",
"Shader packs, Horizon's and shaders/ folder ones, and their options.": "Paketi šejdera, Horizonovi i oni iz foldera shaders/, i njihove opcije.",
"Shader packs": "Paketi šejdera",
"Vanilla's own preset: sets only vanilla's options, as Minecraft does.": "Vanilin vlastiti predložak: postavlja samo vaniline opcije, kako to Minecraft radi.",
"Fullscreen takes the display over, at the mode below.": "Cijeli ekran preuzima prikaz, u načinu ispod.",
"The display mode exclusive fullscreen uses. Applies when you leave.": "Način prikaza koji koristi isključivi cijeli ekran. Primjenjuje se kada izađete.",
"Blurs the world behind menus.": "Zamagljuje svijet iza menija.",
"How textures are sampled: sharper at a slant with Anisotropic.": "Kako se uzorkuju teksture: oštrije pod uglom uz anizotropno filtriranje.",
"How far Anisotropic filtering goes. Higher is sharper at a slant.": "Dokle ide anizotropno filtriranje. Više je oštrije pod uglom.",
"Only with Anisotropic texture filtering.": "Samo uz anizotropno filtriranje tekstura.",
"Picks a shader pack, Horizon's or a shaders/ folder one, and sets its options.": "Bira paket šejdera, Horizonov ili onaj iz foldera shaders/, i postavlja njegove opcije.",
"Reuses the pack shadow map when sun, view and world match. Off by default.": "Ponovo koristi mapu senki paketa kada se poklope sunce, pogled i svijet. Podrazumevano isključeno.",
"Find BlueMap": "Pronađite BlueMap",
"On servers, finds their BlueMap web map for far terrain. Next join.": "Na serverima pronalazi njihovu BlueMap web kartu za daleki teren. Sljedeći spoj.",
"BlueMap Radius": "BlueMap radijus",
"How far around you BlueMap tiles are downloaded. Next join.": "Koliko daleko oko vas se preuzimaju BlueMap pločice. Sljedeći spoj.",
"Synchronous": "Sinhroni",
"Asynchronous": "Asinhroni",
"Hidden Faces": "Skrivena lica",
"Drops leaf faces behind two leaf layers: faster, same crowns.": "Odbacuje lica listova iza dva sloja lišća: brže, iste krošnje.",
"Solid": "Čvrsto",
"Solid Aggressive": "Čvrsto agresivno",
"Hollow": "Šuplje",
"Leaves fully inside a canopy draw solid: fewer see-through layers.": "Listovi u potpunosti unutar krošnje crtaju čvrsto: manje providnih slojeva.",
"Leaves with one open side draw solid too: faster, chunkier edges.": "Listovi s jednom otvorenom stranom također crtaju čvrsto: brže, zdepanije ivice.",
"Drops faces between leaves: faster, but gaps look through.": "Spušta lica između listova: brže, ali praznine gledaju kroz njih.",
"Hotbar: Previous Slot": "Hotbar: prethodni slot",
"Hotbar: Next Slot": "Hotbar: sljedeći slot",
"horizon.agent.connected.one": "Povezano: 1 veza agenta",
"horizon.agent.connected.many": "Povezano: %s veze agenta",
"horizon.agent.connected.one_ago": "Povezano: 1 veza agenta, zadnja komanda prije %s s",
@@ -382,6 +628,10 @@
"Auto (%s nits)": "automatski (%s gnjida)",
"%.2f nits": "%.2f gnjida",
"Hides models behind opaque blocks. Entities update. Off with custom packs.": "Skriva modele iza neprozirnih blokova. Ažuriranje entiteta. Isključeno sa prilagođenim paketima.",
"Entity Culling Precheck": "Prethodna provjera odbijanja entiteta",
"Runs the mob AABB precheck after this many loaded mobs. Zero disables it.": "Pokreće AABB prethodnu provjeru mobova nakon ovoliko učitanih mobova. Nula je isključuje.",
"%s mobs": "%s mobova",
"Only applies to asynchronous entity culling.": "Primjenjuje se samo na asinhrono odbijanje entiteta.",
"horizon.extras.repro_keys": "Prečice za snimanje",
"horizon.extras.repro_keys.tooltip": "F3+R pokreće ili zaustavlja repro snimanje, a F3+E upisuje njegov paket. Podrazumevano isključeno: snimak kopira svijet.",
"horizon.agent.record_session": "Započnite snimanje sesije",
@@ -390,8 +640,20 @@
"horizon.repro.started": "Snimanje je počelo; F3+R ga zaustavlja i upisuje paket.",
"horizon.repro.stopped": "Snimanje zaustavljeno: %s",
"horizon.repro.bundle": "Repro paket: %s",
"horizon.repro.error": "Repro: %s",
"horizon.repro.no_recording": "Još uvijek nema snimka za izvoz.",
"horizon.repro.replay_started": "Repriza je počela: %s",
"horizon.splitscreen.profiles.offline": " (offline)",
"horizon.splitscreen.profiles.microsoft": " (Microsoft)",
"horizon.splitscreen.profiles.sign_in": "Prijavite se ponovo u pokretaču",
"horizon.splitscreen.profiles.online": "Microsoft online",
"horizon.splitscreen.account_unavailable": "%s je offline. Prijavite se ponovo u pokretaču, pa restartujte igru.",
"horizon.splitscreen.account_expired": "%s: Microsoft prijava je istekla. Prijavite se ponovo u pokretaču, pa restartujte igru.",
"Particle Culling": "Odbijanje čestica",
"Draws every particle.": "Crta svaku česticu.",
"Skips offscreen, subpixel and terrain-hidden particles.": "Preskače čestice van ekrana, one manje od piksela i one skrivene terenom.",
"Skips offscreen, subpixel and terrain-hidden particles with less render-thread work.": "Preskače čestice van ekrana, one manje od piksela i one skrivene terenom uz manje rada na niti za renderovanje.",
"horizon.pack.add": "+",
"horizon.pack.addInfo": "Dodajte paket resursa iz Modrintha",
"horizon.pack.addWorking": "Pitam Modrintha...",
"horizon.pack.addNone": "Nije pronađen nijedan paket resursa",
@@ -405,10 +667,28 @@
"horizon.pack.filter.realistic": "Realističan",
"horizon.pack.filter.simplistic": "Pojednostavljeno",
"horizon.pack.filter.vanillaLike": "Nalik vaniliji",
"horizon.pack.filter.x16": "16x",
"horizon.pack.filter.x32": "32x",
"horizon.pack.filter.x64": "64x",
"horizon.pack.addFailed": "Paket se nije mogao dodati: %s",
"horizon.pack.options": "Opcije paketa",
"horizon.pack.optionsInfo": "Opcije ovog paketa",
"horizon.pack.options.title": "Opcije paketa",
"horizon.pack.options.general": "Opšte",
"horizon.pack.options.presets": "Predlošci",
"horizon.pack.options.presets.custom": "Prilagođeno",
"horizon.pack.options.impact": "Utjecaj na performanse: %s",
"horizon.pack.options.impact.low": "nizak",
"horizon.pack.options.impact.medium": "srednji",
"horizon.pack.options.impact.high": "visok",
"horizon.pack.options.impact.varies": "varira",
"horizon.extras.header.cosmetics": "Kozmetika",
"horizon.extras.cosmetics_mine": "Moja kozmetika",
"horizon.extras.cosmetics_mine.tooltip": "Skriveno: svaki Horizon igrač vas vidi bez vaše kozmetike, uključujući i vas. Isti prekidač kao Ormar lansera.",
"key.emote": "Točkić emotikona",
"horizon.emote_wheel.title": "Emotikone",
"horizon.emote_wheel.empty": "Još nema emotikona",
"horizon.emote_wheel.help": "Otpustite ili kliknite da pustite · 1–8 · Esc zatvara",
"horizon.extras.cosmetics_others": "Kozmetika drugih igrača",
"horizon.extras.cosmetics_others.tooltip": "Šta drugi Horizon igrači nose: sve, samo stilove oznaka sa imenom ili ništa. Skriveno, igra nikad ne pita šta nose.",
"horizon.extras.cosmetics_hide_fx": "Sakrij tragove i aure drugih igrača",
@@ -433,5 +713,349 @@
"Pack settings": "Postavke paketa",
"Built-in settings": "Ugrađene postavke",
"%s settings": "Postavke za %s",
"Settings made by the pack's author: they change only this pack.": "Postavke koje je napravio autor paketa: mijenjaju samo ovaj paket."
"Settings made by the pack's author: they change only this pack.": "Postavke koje je napravio autor paketa: mijenjaju samo ovaj paket.",
"horizon.moderation.duration.minute": "%s minuta",
"horizon.moderation.duration.minutes": "%s minuta",
"horizon.moderation.duration.hour": "%s sat",
"horizon.moderation.duration.hours": "%s sata",
"horizon.moderation.duration.day": "%s dan",
"horizon.moderation.duration.days": "%s dana",
"horizon.moderation.reason": "%1$s: %2$s",
"horizon.moderation.mutelist.empty": "Nema utišanih igrača",
"horizon.moderation.mutelist.count": "Ima %s utišanih igrača:",
"horizon.moderation.chat_filter": "filter časkanja",
"horizon.moderation.until_unmuted": "do ukidanja utišavanja",
"horizon.moderation.remaining": "još %s",
"horizon.moderation.mutelist.entry": "%2$s je utišao %1$s %3$s",
"horizon.moderation.invalid_duration": "%s nije trajanje: 10m, 2h, 1d",
"horizon.moderation.usage.mute": "/mute <player> [duration] [reason]",
"horizon.moderation.usage.tempmute": "/tempmute <player> <duration> [reason]",
"horizon.moderation.usage.unmute": "/unmute <player>",
"horizon.moderation.usage.mutelist": "/mutelist",
"horizon.moderation.for_duration": "na %s",
"horizon.moderation.muted": "Utišan %1$s %2$s",
"horizon.moderation.unmuted": "Utišavanje ukinuto: %s",
"horizon.moderation.not_muted": "%s nije utišan",
"horizon.tempban.usage": "/tempban <player> <duration> [reason]",
"horizon.tempban.invalid_duration": "%s nije trajanje: 30m, 12h, 7d",
"horizon.tempban.dedicated_required": "Privremene zabrane zahtijevaju namjenski server",
"horizon.tempban.success": "Zabranjen %1$s na %2$s, do %3$s",
"horizon.tempban.list.empty": "Nema privremenih zabrana",
"horizon.tempban.list.count": "Ima %s privremenih zabrana:",
"horizon.tempban.list.entry": "%2$s je zabranio %1$s %3$s (do %4$s)",
"horizon.options.pointer_speed": "Brzina pokazivača u meniju",
"horizon.options.scroll_speed": "Brzina pomicanja menija",
"horizon.options.repeat_delay": "Kašnjenje ponavljanja navigacije",
"horizon.options.repeat_interval": "Interval ponavljanja navigacije",
"horizon.options.milliseconds": "%s ms",
"horizon.extras.cosmetics_solo": "U igri za jednog igrača",
"horizon.extras.cosmetics_solo.tooltip": "Prikažite svoju kozmetiku i bedž svoje oznake s imenom i u svojim svjetovima za jednog igrača (svoju oznaku s imenom uz Vlastitu oznaku s imenom). Isključeno: samo na serverima.",
"horizon.extras.horizon_marks": "Horizon logotipi",
"horizon.extras.horizon_marks.tooltip": "Prikažite Horizon logo pored Horizon igrača: mali logo prije imena drugih igrača koji koriste Horizon, iznad njihovih glava i u listi igrača. Vide ga samo Horizon igrači: serveri ne primaju ništa.",
"horizon.extras.horizon_me": "Prikaži me kao Horizon igrača",
"horizon.extras.horizon_me.tooltip": "Prikažite se kao Horizon igrač: drugi Horizon igrači vide logo uz vaše ime. Uključeno, usluga svakome ko pita o vama odgovara da ste igrali Horizon u posljednja 3 sata; isključeno, nikada vas ne prijavljuje.",
"horizon.pages.pvp": "PvP i HUD",
"horizon.pages.pvp.tooltip": "Postavke nišana, HUD-a i PvP-a, sve isključene po zadanom. Ništa ovdje ne prikazuje ono što vanila ne prikazuje.",
"horizon.pages.open.pvp": "PvP i HUD...",
"horizon.pvp.header.crosshair": "Nišan",
"horizon.pvp.crosshair": "Prilagođeni nišan",
"horizon.pvp.crosshair_shape": "Oblik",
"horizon.pvp.crosshair_size": "Veličina",
"horizon.pvp.crosshair_gap": "Razmak",
"horizon.pvp.crosshair_thickness": "Debljina",
"horizon.pvp.crosshair_colour": "Boja",
"horizon.pvp.crosshair_outline": "Kontura",
"horizon.pvp.crosshair_dot": "Tačka u sredini",
"horizon.pvp.crosshair_hover": "Boja na entitetima",
"horizon.pvp.crosshair.tooltip": "Crta vaš vlastiti nišan umjesto vanilinog. Njegove prve postavke izgledaju kao vaniline.",
"horizon.pvp.crosshair_shape.tooltip": "Krst, T, X, krug, kvadrat ili samo tačka.",
"horizon.pvp.crosshair_size.tooltip": "Dužina svake ruke, u pikselima interfejsa.",
"horizon.pvp.crosshair_gap.tooltip": "Prazan prostor u sredini.",
"horizon.pvp.crosshair_thickness.tooltip": "Koliko je široka svaka linija.",
"horizon.pvp.crosshair_colour.tooltip": "Invertovano prikazuje boju ispod sebe invertovanu, kao što radi vanilin nišan.",
"horizon.pvp.crosshair_outline.tooltip": "Tanka crna ivica, da se vidi na svakoj pozadini.",
"horizon.pvp.crosshair_dot.tooltip": "Tačka u sredini nišana.",
"horizon.pvp.crosshair_hover.tooltip": "Druga boja dok je nišan iznad moba ili igrača kojeg možete udariti, kako to vanilin indikator napada prepoznaje.",
"horizon.pvp.colour.inverted": "Invertovano",
"horizon.pvp.colour.white": "Bijela",
"horizon.pvp.colour.red": "Crvena",
"horizon.pvp.colour.orange": "Narandžasta",
"horizon.pvp.colour.yellow": "Žuta",
"horizon.pvp.colour.green": "Zelena",
"horizon.pvp.colour.cyan": "Tirkizna",
"horizon.pvp.colour.blue": "Plava",
"horizon.pvp.colour.magenta": "Magenta",
"horizon.pvp.colour.black": "Crna",
"horizon.pvp.shape.cross": "Krst",
"horizon.pvp.shape.t": "T",
"horizon.pvp.shape.x": "X",
"horizon.pvp.shape.circle": "Krug",
"horizon.pvp.shape.square": "Kvadrat",
"horizon.pvp.shape.dot": "Tačka",
"horizon.pvp.outline": "Prilagođena kontura bloka",
"horizon.pvp.outline.tooltip": "Mijenja konturu oko bloka kojeg gledate.",
"horizon.pvp.outline_colour": "Boja",
"horizon.pvp.outline_colour.tooltip": "Boja konture. Crna na 40% je vanilina.",
"horizon.pvp.outline_opacity": "Neprozirnost",
"horizon.pvp.outline_opacity.tooltip": "Koliko je kontura čvrsta.",
"horizon.pvp.outline_thickness": "Debljina",
"horizon.pvp.outline_thickness.tooltip": "Širina konture, u odnosu na vanilinu.",
"horizon.pvp.outline_fill": "Ispuna",
"horizon.pvp.outline_fill.tooltip": "Blago nijansira strane bloka kojeg gledate.",
"horizon.pvp.hit": "Prilagođena boja udarca",
"horizon.pvp.hit.tooltip": "Mijenja boju kojom mobovi i igrači bljesnu kada su povrijeđeni. Crvena na 30% je vanilina.",
"horizon.pvp.hit_colour": "Boja",
"horizon.pvp.hit_colour.tooltip": "Boja kojom povrijeđeni mobovi i igrači bljesnu.",
"horizon.pvp.hit_opacity": "Neprozirnost",
"horizon.pvp.hit_opacity.tooltip": "Koliko ih ta boja prekriva.",
"horizon.pvp.header.outline": "Kontura bloka",
"horizon.pvp.header.hit": "Boja udarca",
"horizon.pvp.hud_armour": "Oklop",
"horizon.pvp.hud_armour.tooltip": "Oklop koji nosite, počevši od glave, s brojem preostalih upotreba svakog dijela.",
"horizon.pvp.hud_armour_corner": "Ugao oklopa",
"horizon.pvp.hud_armour_corner.tooltip": "Gdje se prikazuje oklop.",
"horizon.pvp.hud_armour_percent": "Trajnost u %",
"horizon.pvp.hud_armour_percent.tooltip": "Preostale upotrebe kao postotak maksimuma dijela umjesto kao broj.",
"horizon.pvp.hud_effects": "Efekti",
"horizon.pvp.hud_effects.tooltip": "Vaši efekti s imenima, nivoima i preostalim vremenom, umjesto ikona u gornjem desnom uglu.",
"horizon.pvp.hud_effects_corner": "Ugao efekata",
"horizon.pvp.hud_effects_corner.tooltip": "Gdje se prikazuju efekti.",
"horizon.pvp.hud_held": "Broj držanih predmeta",
"horizon.pvp.hud_held.tooltip": "Koliko predmeta iz vaše ruke ima u inventaru; strijele uz luk ili samostrel.",
"horizon.pvp.hud_held_corner": "Ugao broja držanih",
"horizon.pvp.hud_held_corner.tooltip": "Gdje se prikazuje broj.",
"horizon.pvp.hud_background": "Pozadine vidžeta",
"horizon.pvp.hud_background.tooltip": "Tamno polje iza svakog vidžeta.",
"horizon.pvp.hud_opacity": "Neprozirnost pozadine",
"horizon.pvp.hud_opacity.tooltip": "Koliko su tamna polja iza vidžeta.",
"horizon.pvp.hud_text_colour": "Boja teksta",
"horizon.pvp.hud_text_colour.tooltip": "Boja teksta vidžeta. Trajnost zadržava prelaz iz zelene u crvenu.",
"horizon.pvp.hud_shadow": "Sjena teksta",
"horizon.pvp.hud_shadow.tooltip": "Sjena ispod teksta vidžeta, kakvu ima vanilin HUD tekst.",
"horizon.pvp.header.hud": "HUD vidžeti",
"horizon.pvp.corner.top_left": "Gore lijevo",
"horizon.pvp.corner.top_right": "Gore desno",
"horizon.pvp.corner.bottom_left": "Dolje lijevo",
"horizon.pvp.corner.bottom_right": "Dolje desno",
"horizon.pvp.hud_ping": "Ping",
"horizon.pvp.hud_ping.tooltip": "Kašnjenje koje server prijavljuje za vas, u milisekundama. Samo za više igrača.",
"horizon.pvp.hud_ping_corner": "Ugao pinga",
"horizon.pvp.hud_ping_corner.tooltip": "Gdje se prikazuje ping.",
"horizon.pvp.hud_coords": "Koordinate",
"horizon.pvp.hud_coords.tooltip": "Vaš položaj u blokovima i smjer u kojem gledate, kako ih prikazuje F3. Skriveno kada svijet ili server ograniči F3.",
"horizon.pvp.hud_coords_corner": "Ugao koordinata",
"horizon.pvp.hud_coords_corner.tooltip": "Gdje se prikazuju koordinate.",
"horizon.pvp.hud_server": "Adresa servera",
"horizon.pvp.hud_server.tooltip": "Adresa servera na kojem igrate. Samo za više igrača.",
"horizon.pvp.hud_server_corner": "Ugao servera",
"horizon.pvp.hud_server_corner.tooltip": "Gdje se prikazuje adresa.",
"horizon.pvp.hud_combo": "Kombo brojač",
"horizon.pvp.hud_combo.tooltip": "Uzastopni udarci koje je server potvrdio, dok ne budete povrijeđeni ili prođu 3 sekunde bez njih.",
"horizon.pvp.hud_combo_corner": "Ugao komboa",
"horizon.pvp.hud_combo_corner.tooltip": "Gdje se prikazuje kombo.",
"horizon.pvp.hud.ping": "Ping: %s ms",
"horizon.pvp.hud.coords": "XYZ: %s %s %s",
"horizon.pvp.hud.facing": "Smjer: %s",
"horizon.pvp.hud.combo": "Kombo: %s",
"horizon.pvp.hud.direction.north": "Sjever",
"horizon.pvp.hud.direction.south": "Jug",
"horizon.pvp.hud.direction.east": "Istok",
"horizon.pvp.hud.direction.west": "Zapad",
"horizon.pvp.hud_fps": "FPS",
"horizon.pvp.hud_fps.tooltip": "Okviri nacrtani u posljednjoj sekundi.",
"horizon.pvp.hud_fps_corner": "Ugao FPS-a",
"horizon.pvp.hud_fps_corner.tooltip": "Gdje se prikazuje FPS.",
"horizon.pvp.hud_cps": "Klikovi u sekundi",
"horizon.pvp.hud_cps.tooltip": "Vaši lijevi i desni klikovi u posljednjoj sekundi.",
"horizon.pvp.hud_cps_corner": "Ugao CPS-a",
"horizon.pvp.hud_cps_corner.tooltip": "Gdje se prikazuju klikovi u sekundi.",
"horizon.pvp.hud_keys": "Pritisci tipki",
"horizon.pvp.hud_keys.tooltip": "Vaše tipke za kretanje, dugmad miša i skok, osvijetljeni dok ih držite, s klikovima u sekundi.",
"horizon.pvp.hud_keys_corner": "Ugao pritisaka tipki",
"horizon.pvp.hud_keys_corner.tooltip": "Gdje se prikazuju pritisci tipki.",
"horizon.pvp.hud_clock": "Sat",
"horizon.pvp.hud_clock.tooltip": "Vrijeme na vašem računaru.",
"horizon.pvp.hud_clock_corner": "Ugao sata",
"horizon.pvp.hud_clock_corner.tooltip": "Gdje se prikazuje sat.",
"horizon.pvp.hud_reach": "Doseg",
"horizon.pvp.hud_reach.tooltip": "Koliko daleko od vaših očiju je bio posljednji udarac, 3 sekunde. Samo mjeri: vaš doseg ostaje vanilin.",
"horizon.pvp.hud_reach_corner": "Ugao dosega",
"horizon.pvp.hud_reach_corner.tooltip": "Gdje se prikazuje doseg.",
"horizon.pvp.hud_speed": "Brzina",
"horizon.pvp.hud_speed.tooltip": "Koliko brzo se krećete po tlu, u blokovima u sekundi.",
"horizon.pvp.hud_speed_corner": "Ugao brzine",
"horizon.pvp.hud_speed_corner.tooltip": "Gdje se prikazuje brzina.",
"horizon.pvp.hud.fps": "%s FPS",
"horizon.pvp.hud.cps": "CPS: %s | %s",
"horizon.pvp.hud.reach": "Doseg: %s",
"horizon.pvp.hud.speed": "Brzina: %s b/s",
"horizon.pvp.hud.keys.attack": "LMB",
"horizon.pvp.hud.keys.use": "RMB",
"horizon.pvp.hud.keys.cps": "%s CPS",
"horizon.pvp.numeric_enchants": "Nivoi čaranja kao brojevi",
"horizon.pvp.numeric_enchants.tooltip": "Opisi i sto za čaranje prikazuju Oštrinu 5 umjesto Oštrine V.",
"horizon.pvp.header.items": "Predmeti",
"horizon.pvp.fire_lower": "Niža vatra",
"horizon.pvp.fire_lower.tooltip": "Spušta plamen koji vidite dok gorite, da prekriva manje ekrana.",
"horizon.pvp.shield_size": "Veličina štita",
"horizon.pvp.shield_size.tooltip": "Veličina štita u vašoj ruci, u prvom licu.",
"horizon.pvp.totem_size": "Veličina animacije totema",
"horizon.pvp.totem_size.tooltip": "Veličina totema koji ispuni ekran kada vas spasi.",
"horizon.pvp.glint_colour": "Boja sjaja čaranja",
"horizon.pvp.glint_colour.tooltip": "Boja svjetlucanja na začaranim predmetima i oklopu. Isključeno je vanilina ljubičasta.",
"horizon.pvp.clear_water": "Čista voda",
"horizon.pvp.clear_water.tooltip": "Pod vodom vidite onoliko koliko vam vanila dopušta nakon 30 sekundi, od početka.",
"horizon.pvp.clear_lava": "Čista lava",
"horizon.pvp.clear_lava.tooltip": "U lavi vidite onoliko koliko vam dopušta vanilina Otpornost na vatru, bez efekta.",
"horizon.pvp.header.visual": "Vizualne izmjene",
"horizon.pvp.view_time": "Doba dana",
"horizon.pvp.view_time.tooltip": "Doba dana koje vidite: samo nebo i njegovo svjetlo. Vrijeme u svijetu teče dalje i ništa se ne šalje serveru.",
"horizon.pvp.view_weather": "Vrijeme",
"horizon.pvp.view_weather.tooltip": "Vrijeme koje vidite: samo kiša, njeno svjetlo i magla. Vrijeme u svijetu se nastavlja i ništa se ne šalje serveru.",
"horizon.pvp.header.view": "Vaš pogled",
"horizon.pvp.view.world": "Svjetsko",
"horizon.pvp.time.morning": "Jutro",
"horizon.pvp.time.noon": "Podne",
"horizon.pvp.time.sunset": "Zalazak sunca",
"horizon.pvp.time.midnight": "Ponoć",
"horizon.pvp.weather.clear": "Vedro",
"horizon.pvp.weather.rain": "Kiša",
"horizon.pvp.weather.thunder": "Grmljavina",
"horizon.chat.timestamp": "[%s] ",
"horizon.chat.repeats": " (x%s)",
"horizon.pvp.header.chat": "Časkanje",
"horizon.chat.copied": "Poruka je kopirana",
"horizon.chat.search": "Pretraži časkanje:",
"horizon.pvp.chat_timestamps": "Vremenske oznake",
"horizon.pvp.chat_timestamps.tooltip": "Vrijeme kada je svaka poruka došla, prije nje.",
"horizon.pvp.chat_compact": "Kompaktno časkanje",
"horizon.pvp.chat_compact.tooltip": "Poruka ponovljena uzastopno prikazuje se jednom, s brojem ponavljanja: (x3).",
"horizon.pvp.chat_copy": "Kopiraj klikom",
"horizon.pvp.chat_copy.tooltip": "Klik na poruku u otvorenom časkanju kopira njen tekst, osim ako klik radi nešto drugo.",
"horizon.pvp.chat_search": "Pretraga časkanja",
"horizon.pvp.chat_search.tooltip": "Ctrl+F u otvorenom časkanju: ono što upišete prikazuje samo poruke koje ga sadrže. Enter ili ponovo Ctrl+F zaustavlja.",
"horizon.pvp.preset": "PvP predložak",
"horizon.pvp.preset.tooltip": "Uključuje prilagođeni nišan, vidžete oklopa i efekata, prebacivanje sprintanja i šunjanja, nižu vatru, te brojač FPS-a i modove pritisaka tipki ako su instalirani. Vaše postavke za svaki ostaju.",
"horizon.pvp.hud_editor_key": "Tipka za uređivanje HUD-a",
"horizon.pvp.hud_editor_key.tooltip": "Otvara uređivač HUD-a iz igre. Kliknite, pa pritisnite tipku; Escape je odvezuje.",
"horizon.pvp.editor": "Uredi HUD",
"horizon.pvp.editor.tooltip": "Pomjerajte vidžete u igri: prevucite ih bilo gdje, mijenjajte veličinu točkićem, desni klik na vidžet ga vraća u njegov ugao.",
"horizon.pvp.editor.hint": "Prevucite vidžete · Točkić: veličina · Desni klik: nazad u ugao",
"horizon.pvp.combat_feel": "Osjećaj borbe",
"horizon.pvp.combat_feel.tooltip": "Legacy crta borbe kao 1.8.9: predmet u vašoj ruci, njegov zamah, jedenje, luk i mač podignut dok držite tipku za korištenje, bez spuštanja nakon svakog udarca i bez indikatora napada. Samo izgled: ono što šaljete serveru ostaje isto.",
"horizon.pvp.combat_indicator": "Indikator napada u Legacy",
"horizon.pvp.combat_indicator.tooltip": "Zadržava indikator napada u Legacy, koji 1.8.9 nije imao.",
"horizon.pvp.combat_feel.modern": "Moderno",
"horizon.pvp.combat_feel.legacy": "Legacy (1.8.9)",
"horizon.pvp.header.combat": "Osjećaj borbe",
"horizon.pvp.combat_sounds": "Zvuci udaraca iz 1.8",
"horizon.pvp.combat_sounds.tooltip": "U Legacy načinu izostavlja zvuke napada koje je dodala 1.9 (jak, slab, zamah, kritični...). Zvuci povrede ostaju.",
"horizon.pvp.combat_rules": "1.8 borba u svjetovima koje ugostite",
"horizon.pvp.combat_rules.tooltip": "Igranje za jednog igrača i svjetovi koje otvorite na LAN-u bore se po pravilima 1.8.9: nema hlađenja napada ni zamaha, šteta oružja iz 1.8, Snaga, Oštrina i kritični udarci. Serveri kojima se pridružite zadržavaju svoja pravila.",
"Bindings": "Kontrole",
"Play": "Igraj",
"Device": "Uređaj",
"Look": "Pogled",
"Movement": "Kretanje",
"Menus": "Meniji",
"Actions": "Radnje",
"Reset": "Resetuj",
"Detected": "Otkriveno",
"Glyphs": "Simboli",
"Feedback": "Povratna informacija",
"Controller": "Kontroler",
"How the sticks turn and move you, and how menus repeat and scroll.": "Kako štapići skreću i pomjeraju vas, te kako se meniji ponavljaju i pomiču.",
"Which button does what. Each player has their own.": "Koje dugme šta radi. Svaki igrač ima svoje.",
"The controller the game sees, its button glyphs and its feedback.": "Kontroler koji igra vidi, njegovi simboli dugmadi i njegova povratna informacija.",
"Press this row, then the button that should do it.": "Pritisnite ovaj red, pa dugme koje to treba da radi.",
"Off, the game ignores every controller for this player.": "Isključeno, igra ignoriše svaki kontroler za ovog igrača.",
"How fast the right stick turns the view.": "Koliko brzo desni štapić okreće pogled.",
"Turn the view the other way, left and right.": "Okreće pogled na drugu stranu, lijevo i desno.",
"Turn the view the other way, up and down.": "Okreće pogled na drugu stranu, gore i dolje.",
"How far the right stick moves before the view turns. Raise it if the view drifts.": "Koliko se desni štapić pomjeri prije nego se pogled okrene. Povećajte ako pogled šeta.",
"Hold the button to sneak, or press it once to start and again to stop.": "Držite dugme za šunjanje ili ga pritisnite jednom da počne i ponovo da prestane.",
"How far the left stick moves before you walk. Raise it if you drift.": "Koliko se lijevi štapić pomjeri prije nego prohodate. Povećajte ako šetate.",
"How far a trigger is pulled before it counts as pressed.": "Koliko se okidač povuče prije nego se računa kao pritisnut.",
"Shows what your buttons do right now, in play and in inventories.": "Prikazuje šta vaša dugmad rade upravo sada, u igri i u inventarima.",
"Shows only what you can do with what you're looking at, and the main inventory buttons.": "Prikazuje samo šta možete uraditi s onim što gledate, i glavna dugmad inventara.",
"Doesn't show what your buttons do.": "Ne prikazuje šta vaša dugmad rade.",
"Shows the buttons of the controller you're using.": "Prikazuje dugmad kontrolera koji koristite.",
"Always shows Xbox buttons.": "Uvijek prikazuje Xbox dugmad.",
"Always shows PlayStation buttons.": "Uvijek prikazuje PlayStation dugmad.",
"Always shows Nintendo buttons.": "Uvijek prikazuje Nintendo dugmad.",
"A, on the right, confirms and jumps and B goes back, as on a Switch.": "A, s desne strane, potvrđuje i skače, a B se vraća nazad, kao na Switchu.",
"B, at the bottom, confirms and jumps and A goes back, as on other controllers.": "B, na dnu, potvrđuje i skače, a A se vraća nazad, kao na drugim kontrolerima.",
"Opens Steam's keyboard on a Steam Deck under Steam, and Horizon's otherwise.": "Otvara Steam tastaturu na Steam Decku pod Steamom, a inače Horizonovu.",
"Always opens Horizon's keyboard.": "Uvijek otvara Horizonovu tastaturu.",
"Always opens Steam's keyboard.": "Uvijek otvara Steam tastaturu.",
"A DualSense's light shows your health, from green through amber to red.": "Svjetlo DualSensea prikazuje vaše zdravlje, od zelene preko jantarne do crvene.",
"A DualSense's light changes with the dimension you're in.": "Svjetlo DualSensea mijenja se s dimenzijom u kojoj ste.",
"A DualSense keeps its own light.": "DualSense zadržava svoje vlastito svjetlo.",
"Lets LT + A craft on servers other than your own worlds. Some servers count it as a macro.": "Dopušta LT + A da izrađuje na serverima koji nisu vaši svjetovi. Neki serveri to smatraju makroom.",
"Show the buttons for what you can do, while you play with a controller.": "Prikaži dugmad za ono što možete uraditi dok igrate s kontrolerom.",
"How fast the stick moves the crosshair in inventories.": "Koliko brzo štapić pomiče nišan u inventarima.",
"How fast the right stick scrolls a list.": "Koliko brzo desni štapić pomiče listu.",
"How long a held direction waits before it repeats.": "Koliko dugo držani smjer čeka prije ponavljanja.",
"How long a held direction takes between repeats.": "Koliko traje držani smjer između ponavljanja.",
"Which on-screen keyboard opens when a text field is pressed.": "Koja tastatura na ekranu se otvara kada se pritisne tekstualno polje.",
"Every action back on its default button.": "Svaka radnja nazad na svoje zadano dugme.",
"These settings belong to this player's profile.": "Ove postavke pripadaju profilu ovog igrača.",
"The controller driving this player, or else the first one connected.": "Kontroler koji upravlja ovim igračem ili, ako ga nema, prvi povezani.",
"The USB vendor and product the system reports. Useful when reporting a controller that misbehaves.": "USB proizvođač i proizvod koje sistem prijavljuje. Korisno kada prijavljujete kontroler koji se ne ponaša kako treba.",
"The glyph family the game picked from the controller's maker.": "Porodica simbola koju je igra odabrala prema proizvođaču kontrolera.",
"Follow the controller, or always show one family's buttons.": "Prati kontroler ili uvijek prikazuj dugmad jedne porodice.",
"Whether a Nintendo controller's A and B work by where they sit or by their letters.": "Da li A i B na Nintendo kontroleru rade po položaju ili po slovima.",
"The buttons menus show right now.": "Dugmad koja meniji prikazuju upravo sada.",
"Rumble when something happens to you.": "Vibracija kada vam se nešto dogodi.",
"The controller keeps playing while this window isn't focused, and losing focus won't pause.": "Kontroler nastavlja da radi dok ovaj prozor nije u fokusu, a gubitak fokusa neće pauzirati.",
"A DualSense's triggers push back on bows and tools.": "Okidači DualSensea pružaju otpor na lukovima i alatima.",
"What a DualSense's light shows, over USB or Bluetooth.": "Šta prikazuje svjetlo DualSensea, preko USB-a ili Bluetootha.",
"Player profile": "Profil igrača",
"Vendor and product": "Proizvođač i proizvod",
"Detected glyphs": "Otkriveni simboli",
"Glyphs in use": "Simboli u upotrebi",
"Unknown": "Nepoznato",
"No controller": "Nema kontrolera",
"Editing: %s": "Uređivanje: %s",
"Press the button for this. %s cancels.": "Pritisnite dugme za ovo. %s otkazuje.",
"This button also does: %s": "Ovo dugme također radi: %s",
"Not bound: press this row, then the button that should do it.": "Nije vezano: pritisnite ovaj red, pa dugme koje to treba da radi.",
"horizon.pad.notice.connected.press": "Pritisnite da ga koristite",
"horizon.recipe_transfer.missing": "Nedostaju predmeti",
"horizon.recipe_transfer.no_room": "Inventar je pun",
"horizon.recipe_transfer.unavailable": "Recept se ne uklapa u ovaj meni",
"horizon.recipe_transfer.unsupported": "Server zahtijeva poznat recept",
"horizon.studio.timeline.freecam_on": "Slobodna kamera: UKLJUČENA",
"horizon.studio.timeline.freecam_off": "Slobodna kamera: ISKLJUČENA",
"horizon.studio.timeline.freecam_short": "Kamera",
"horizon.studio.timeline.freecam_active_short": "Kamera ✓",
"horizon.studio.timeline.add_keyframe_short": "+ kadar",
"horizon.studio.timeline.delete_keyframe_short": "− obriši",
"horizon.studio.timeline.split": "Podijeli",
"horizon.studio.timeline.marker": "Oznaka %s",
"horizon.studio.timeline.seconds": "%s s",
"horizon.studio.timeline.scene_count": "%s scena",
"horizon.studio.timeline.scene_title": "Scena %s",
"horizon.studio.timeline.sequence": "Sekvenca",
"horizon.studio.topbar.title": "✦ Studio",
"horizon.studio.topbar.file": "Datoteka",
"horizon.studio.topbar.camera": "Kamera",
"horizon.studio.topbar.view": "Prikaz",
"horizon.studio.topbar.file_short": "Datoteka",
"horizon.studio.topbar.camera_short": "Kamera",
"horizon.studio.topbar.view_short": "Prikaz",
"horizon.studio.topbar.game_pause_menu": "⏸ Meni za pauzu",
"horizon.studio.topbar.pause_short": "Pauza",
"horizon.studio.topbar.open_recordings": "📁 Otvori fasciklu snimaka",
"horizon.studio.topbar.close_replay": "✕ Zatvori reprizu",
"horizon.studio.topbar.exit": "Izlaz",
"horizon.studio.topbar.toggle_freecam": "Uključi ili isključi slobodnu kameru",
"horizon.studio.topbar.reset_to_player": "👤 Nazad na igrača",
"horizon.studio.topbar.unlock_freecam": "🔓 Otključaj slobodnu kameru",
"horizon.studio.topbar.lock_to_spline": "🔒 Zaključaj na spline",
"horizon.studio.topbar.add_keyframe": "➕ Dodaj ključni kadar",
"horizon.studio.topbar.add_marker": "Dodaj oznaku",
"horizon.studio.topbar.clear_keyframes": "🗑 Očisti ključne kadrove",
"horizon.studio.topbar.timeline_hud": "⏱ Vremenska linija",
"horizon.studio.topbar.split_scene": "Podijeli scenu"
}
@@ -1,28 +1,137 @@
{
"horizon.extras.header.world": "Món",
"horizon.extras.pregen": "Pregeneració de chunks",
"horizon.extras.pregen.tooltip": "Activa /pregen als teus mons d'un jugador i de LAN. Amb les trampes activades, fes servir /pregen start per generar una zona abans d'explorar-la. No activa l'ordre als servidors on t'uneixes.",
"horizon.options.controller": "Controlador",
"horizon.options.three_d_skin_layers": "Capes de pell 3D",
"horizon.options.vibration": "Vibració",
"horizon.options.play_in_background": "Juga en segon pla",
"horizon.options.adaptive_triggers": "Disparadors adaptatius",
"horizon.options.gameplay_guide": "Guia de joc",
"horizon.options.gameplay_guide.basic": "Bàsica",
"horizon.options.gameplay_guide.full": "Completa",
"horizon.options.gameplay_guide.off": "Apagada",
"horizon.controller.guide.use": "Utilitza",
"horizon.controller.guide.open": "Obre",
"horizon.controller.guide.ride": "Munta",
"horizon.controller.guide.trade": "Comercia",
"horizon.controller.guide.feed": "Alimenta",
"horizon.controller.guide.place": "Col·loca",
"horizon.controller.guide.eat": "Menja",
"horizon.controller.guide.drink": "Beu",
"horizon.controller.guide.shoot": "Dispara",
"horizon.controller.guide.load": "Carrega",
"horizon.controller.guide.throw": "Llança",
"horizon.controller.guide.raise": "Aixeca",
"horizon.controller.guide.equip": "Equipa",
"horizon.controller.guide.fish": "Pesca",
"horizon.controller.guide.zoom": "Apropa",
"horizon.controller.guide.boost": "Accelera",
"horizon.controller.guide.break": "Trenca",
"horizon.controller.guide.attack": "Ataca",
"horizon.controller.guide.spectate": "Observa",
"horizon.controller.guide.spectator_menu": "Menú d'espectador",
"horizon.controller.guide.release_shoot": "Deixa anar per disparar",
"horizon.controller.guide.release_throw": "Deixa anar per llançar",
"horizon.controller.guide.release_stop": "Deixa anar per aturar",
"horizon.controller.guide.fly": "Vola",
"horizon.controller.guide.fly_up": "Vola amunt",
"horizon.controller.guide.fly_down": "Vola avall",
"horizon.controller.guide.swim_up": "Neda amunt",
"horizon.controller.guide.swim_down": "Neda avall",
"horizon.controller.guide.glide": "Planeja",
"horizon.controller.guide.dismount": "Baixa",
"horizon.controller.guide.stand": "Aixeca't",
"horizon.controller.guide.drop": "Deixa anar l'objecte",
"horizon.controller.guide.swap": "Canvia de mà",
"horizon.controller.guide.take": "Agafa",
"horizon.controller.guide.take_half": "Agafa'n la meitat",
"horizon.controller.guide.put": "Deixa",
"horizon.controller.guide.put_one": "Deixa'n un",
"horizon.controller.guide.swap_stack": "Intercanvia",
"horizon.controller.guide.quick_move": "Mou ràpid",
"horizon.controller.guide.drop_stack": "Deixa anar la pila",
"horizon.controller.guide.place_recipe": "Col·loca la recepta",
"horizon.controller.guide.place_recipe_all": "Col·loca-ho tot",
"horizon.controller.guide.alternatives": "Alternatives",
"horizon.controller.guide.select": "Selecciona",
"horizon.controller.guide.close": "Tanca",
"horizon.controller.guide.pages": "Pàgines",
"horizon.controller.guide.categories": "Categories",
"horizon.controller.guide.tabs": "Pestanyes",
"horizon.options.lightbar": "Barra de llums",
"horizon.options.look_sensitivity": "Sensibilitat de la mirada",
"horizon.options.invert_x": "Invertir la mirada X",
"horizon.options.invert_y": "Invertir la mirada Y",
"horizon.options.craft_on_servers": "Fabricació ràpida als servidors",
"horizon.controller.direct_craft": "Fabricació ràpida",
"horizon.options.sneak_button": "Botó d'ajupir-se",
"horizon.options.move_deadzone": "Zona morta del moviment",
"horizon.options.look_deadzone": "Zona morta de la vista",
"horizon.options.trigger_threshold": "Llindar del gallet",
"horizon.options.button_glyphs": "Glifos de botons",
"horizon.options.on_screen_keyboard": "Teclat en pantalla",
"horizon.options.chat_delay": "Retard de xat",
"horizon.options.lightbar.health": "Salut",
"horizon.options.lightbar.dimension": "Dimensió",
"horizon.options.glyphs.auto": "Automàtic",
"horizon.options.glyphs.xbox": "Xbox",
"horizon.options.glyphs.playstation": "PlayStation",
"horizon.options.glyphs.nintendo": "Nintendo",
"horizon.options.nintendo_buttons": "Botons de Nintendo",
"horizon.options.nintendo_buttons.position": "Per posició",
"horizon.options.nintendo_buttons.letter": "Per lletra",
"horizon.options.keyboard.auto": "Automàtic",
"horizon.options.keyboard.horizon": "Horizon",
"horizon.options.keyboard.steam": "Steam",
"horizon.options.unavailable": "Aquesta funció encara no està disponible a Horizon.",
"horizon.options.search": "Configuració de cerca...",
"horizon.studio.record.usage": "Ús: /studio record <start [name]|stop|status>",
"horizon.studio.record.started": "Enregistrament de l'Studio iniciat: %s",
"horizon.studio.record.saved": "Enregistrament de l'Studio desat: %s",
"horizon.studio.record.error": "Error de l'enregistrament de l'Studio: %s",
"horizon.studio.record.status.active": "Enregistrament de l'Studio actiu — %s s, %s paquets, %s KB (%s)",
"horizon.studio.record.status.inactive": "Enregistrament de l'Studio inactiu",
"horizon.studio.replay.usage": "Ús: /studio replay <open PATH|pause|resume|speed MULTIPLIER|seek MS|status|close>",
"horizon.studio.replay.open.usage": "Ús: /studio replay open <PATH>",
"horizon.studio.replay.opened": "Reproducció de l'Studio oberta: %s (%s s, %s paquets)",
"horizon.studio.replay.open_error": "No s'ha pogut obrir la reproducció: %s",
"horizon.studio.replay.paused": "Reproducció de l'Studio en pausa",
"horizon.studio.replay.resumed": "Reproducció de l'Studio represa",
"horizon.studio.replay.inactive": "Cap reproducció de l'Studio activa",
"horizon.studio.replay.speed.usage": "Ús: /studio replay speed <MULTIPLIER>",
"horizon.studio.replay.speed.set": "Velocitat de la reproducció de l'Studio establerta a %s×",
"horizon.studio.replay.speed.invalid": "Velocitat de reproducció no vàlida",
"horizon.studio.replay.seek.usage": "Ús: /studio replay seek <MILLISECONDS>",
"horizon.studio.replay.seeked": "Reproducció de l'Studio saltada a %s ms (%s s)",
"horizon.studio.replay.seek.error": "No s'ha pogut saltar a la reproducció: %s",
"horizon.studio.replay.seek.backward_requires_standalone": "Rebobinar només està disponible en una reproducció autònoma.",
"horizon.studio.replay.seek.invalid": "Posició de reproducció no vàlida",
"horizon.studio.replay.status": "Reproducció de l'Studio: %s / %s ms (%s%%), velocitat %s×, en pausa %s, acabada %s",
"horizon.studio.replay.closed": "Reproducció de l'Studio tancada",
"horizon.studio.freecam.enabled": "Càmera lliure de l'Studio activada. Fes servir WASD per moure't, Espai i Maj per pujar o baixar, i Control per moure't més de pressa.",
"horizon.studio.freecam.disabled": "Càmera lliure de l'Studio desactivada",
"horizon.studio.freecam.unavailable": "La càmera lliure de l'Studio no ha trobat cap càmera de jugador",
"horizon.studio.freecam.replay_required": "La càmera lliure de l'Studio està disponible durant la reproducció",
"horizon.studio.freecam.usage": "Ús: /studio freecam <on|off>",
"horizon.studio.keyframe.added": "Fotograma clau de càmera afegit a %s ms. El recorregut es desa quan tanques la reproducció.",
"horizon.studio.keyframe.cleared": "Recorregut de càmera esborrat. El canvi es desa quan tanques la reproducció.",
"horizon.studio.keyframe.removed": "Fotograma clau de càmera %s eliminat. El canvi es desa quan tanques la reproducció.",
"horizon.studio.keyframe.save_error": "No s'ha pogut desar el recorregut de càmera: %s",
"horizon.studio.keyframe.remove_usage": "Ús: /studio keyframe remove <INDEX>",
"horizon.studio.keyframe.index_invalid": "L'índex del fotograma clau de càmera està fora de rang",
"horizon.studio.keyframe.usage": "Ús: /studio keyframe <add|remove INDEX|clear>",
"horizon.studio.path.locked": "Reproducció del recorregut de càmera de l'Studio activada",
"horizon.studio.path.unlocked": "Reproducció del recorregut de càmera de l'Studio aturada",
"horizon.studio.path.usage": "Ús: /studio path <lock|unlock> (afegeix primer almenys un fotograma clau)",
"horizon.pages.mods": "Mods",
"horizon.pages.shader_packs": "Paquets Shader",
"horizon.pages.shader_options": "Opcions d'ombra",
"horizon.pages.controller": "Configuració del controlador",
"horizon.pages.controller_binds": "El controlador enllaça",
"horizon.pages.extras": "Extres d'Horizon",
"horizon.pages.settings": "Configuració d'Horizon",
"horizon.pages.studio": "Replay Studio",
"horizon.pages.agent": "Agent d'IA",
"horizon.split.leave": "marxar",
"horizon.pages.split_settings": "Configuració de pantalla dividida",
@@ -30,6 +139,7 @@
"horizon.pages.split_setup": "Configuració de pantalla dividida",
"horizon.pages.music": "Música",
"horizon.pages.music.menu": "Menú",
"horizon.pages.music.overworld": "Sobremón",
"horizon.pages.music.creative": "Creatiu",
"horizon.pages.music.underwater": "Sota l'aigua",
"horizon.pages.music.end": "El final",
@@ -42,6 +152,7 @@
"horizon.pages.music.empty": "Sense pistes de música: els sons del joc no es carreguen.",
"horizon.pages.music.tooltip": "Les pistes integrades, per situació: juga-ne una ara o desactiva-ne qualsevol perquè el joc deixi de triar-ne.",
"horizon.pages.settings.tooltip": "Configuració pròpia d'Horizon: gràfics, controladors, extres i modificacions.",
"horizon.pages.studio.tooltip": "Navega pels enregistraments desats i les seves metadades.",
"horizon.pages.graphics.tooltip": "Gràfics, senzillament: cada paràmetre diu el que costa.",
"horizon.pages.graphics_advanced.tooltip": "Tots els paràmetres gràfics, inclòs Horizon, amb el que costa cadascun.",
"horizon.pages.controller.tooltip": "Controladors: els seus botons, pals, vibració i navegació per menús.",
@@ -57,10 +168,19 @@
"horizon.pages.open.controller": "Configuració del controlador...",
"horizon.pages.open.split_setup": "Pantalla dividida...",
"horizon.pages.open.extras": "Extres...",
"horizon.pages.open.mods": "Mods...",
"horizon.pages.open.agent": "Agent d'IA...",
"horizon.pages.open.horizon_graphics": "Gràfics d'Horizon...",
"horizon.pages.open.controller_binds": "El controlador s'enllaça...",
"horizon.pages.open.horizon_settings": "Configuració d'Horizon...",
"horizon.pages.open.studio": "Replay Studio...",
"horizon.studio.saved_recordings": "Enregistraments desats",
"horizon.studio.empty": "No s'ha trobat cap enregistrament a la carpeta d'enregistraments.",
"horizon.studio.empty_hint": "Fes servir /studio record start <name> en un món, o deixa fitxers .mcpr allà.",
"horizon.studio.open_folder": "Obre els enregistraments",
"horizon.studio.open_replay": "Reprodueix",
"horizon.studio.refresh": "Actualitza",
"horizon.studio.unknown_server": "Desconegut",
"horizon.pages.open.music": "Música...",
"horizon.mods.empty": "Sense modificacions: poseu fitxers .hmod a la carpeta horizon-mods",
"horizon.mods.stopped": " (aturat)",
@@ -69,6 +189,28 @@
"horizon.mods.settings": "Configuració",
"horizon.mods.in_world": " (deixar el món per canviar)",
"horizon.mods.open_folder": "Obriu la carpeta de modificacions",
"horizon.mods.browse": "Navega pels mods...",
"horizon.mods.browse.title": "Navega pels mods",
"horizon.mods.browse.search": "Cerca mods...",
"horizon.mods.browse.project": "Mod",
"horizon.mods.browse.loading": "Llegint el registre de mods d'Horizon...",
"horizon.mods.browse.failed": "No s'ha pogut llegir el registre de mods: %s",
"horizon.mods.browse.none": "No s'ha trobat cap mod",
"horizon.mods.browse.has_update": " (actualització)",
"horizon.mods.browse.is_installed": " (instal·lat)",
"horizon.mods.browse.by": "per %s",
"horizon.mods.browse.installing": "S'està instal·lant...",
"horizon.mods.browse.install": "Instal·la",
"horizon.mods.browse.update": "Actualitza",
"horizon.mods.browse.installed": "Instal·lat",
"horizon.mods.browse.open_page": "Obre la pàgina",
"horizon.mods.browse.enabled": "Mod",
"horizon.mods.browse.changelog": "Canvis",
"horizon.mods.browse.done": "%s %s està instal·lat i actiu.",
"horizon.mods.browse.install_failed": "No s'ha pogut instal·lar: %s",
"horizon.mods.switch.worldgen": "canvia la generació del món",
"horizon.mods.switch.registries": "afegeix blocs, objectes o altres entrades del registre",
"horizon.mods.switch.restart": "Reinicia el joc per aplicar-hi %s: afegeixen blocs, objectes o generació del món.",
"horizon.shader_options.profile": "Perfil",
"horizon.agent.copy_details": "Copia els detalls de la connexió",
"horizon.multiplayer.join": "Uneix-te",
@@ -80,6 +222,7 @@
"LOD Distance": "Distància LOD",
"Far Detail": "Detall llunyà",
"Cave Culling": "Culling de coves",
"GPU Culling": "Culling de GPU",
"Entity Culling": "Culling d'entitats",
"Chunk Updates": "Actualitzacions de blocs",
"Upload Budget": "Carregar pressupost",
@@ -93,10 +236,15 @@
"Resolution Scale": "Escala de resolució",
"Anti-aliasing": "Antialiasing",
"Upscaling": "Ampliació d'escala",
"Upscaling Sharpness": "Nitidesa de l'ampliació",
"Paper White": "Paper blanc",
"Peak Brightness": "Lluminositat màxima",
"Black Level": "Nivell negre",
"UI Brightness": "Brillantor de la IU",
"HDR Lighting": "Il·luminació HDR",
"Light sources, the sun and sunlight shine past white. Off: just as SDR.": "Les fonts de llum, el sol i la llum solar brillen més enllà del blanc. Apagat: igual que SDR.",
"Smooth Gradients": "Gradients suaus",
"16-bit frame: dark skies and shadows don't band. A bit more VRAM. Next start.": "Fotograma de 16 bits: els cels i les ombres foscos no fan bandes. Una mica més de VRAM. Proper inici.",
"Battery Mode": "Mode de bateria",
"Graphics API": "API de gràfics",
"Translucent Batching": "Lots translúcids",
@@ -110,6 +258,7 @@
"Performance": "Rendiment",
"Balanced": "Equilibrat",
"Quality": "Qualitat",
"Ultra": "Ultra",
"Ultra Performance": "Ultra rendiment",
"Native": "Nadiu",
"Full": "Complet",
@@ -117,22 +266,36 @@
"Efficient": "Eficient",
"Precise": "Precís",
"Auto (VSync)": "Automàtic (VSync)",
"FIFO": "FIFO",
"Mailbox": "Bústia de correu",
"Immediate": "Immediatament",
"Vulkan": "Vulkan",
"DirectX 12": "DirectX 12",
"OpenGL": "OpenGL",
"FPS and Graph": "FPS i gràfic",
"FPS": "FPS",
"TAA": "TAA",
"FXAA": "FXAA",
"No cost": "Sense cost",
"Low cost": "Baix cost",
"Medium cost": "Cost mitjà",
"High cost": "Alt cost",
"Saves time": "Estalvia temps",
"HDR": "HDR",
"Graphics": "Gràfics",
"Done": "Fet",
"This feature is not yet available in Horizon.": "Aquesta funció encara no està disponible a Horizon.",
"Your display or system doesn't offer HDR output.": "La vostra pantalla o sistema no ofereixen sortida HDR.",
"Turn HDR on to use it.": "Activa HDR per utilitzar-lo.",
"Your GPU or graphics API doesn't support it.": "La vostra GPU o API de gràfics no ho admet.",
"Not with software rendering, which runs it on the CPU.": "No amb el renderitzat per programari, que ho executa a la CPU.",
"Out of video memory: off until the next start.": "Sense memòria de vídeo: desactivat fins al proper inici.",
"The graphics device was lost: off until the next start.": "S'ha perdut el dispositiu gràfic: desactivat fins al proper inici.",
"Not on DirectX 12 yet: try the Vulkan graphics API.": "Encara no amb DirectX 12: prova l'API de gràfics Vulkan.",
"%s chunks": "%s trossos",
"%s per frame": "%s per fotograma",
"%s MiB/frame": "%s MiB/fotograma",
"%s KiB/frame": "%s KiB/fotograma",
"Auto (%s)": "Automàtic (%s)",
"Full-detail terrain range. LOD Distance can extend local worlds farther.": "Gamma de terreny amb tots els detalls. La distància LOD pot estendre els mons locals més lluny.",
"Coarse distant terrain in local worlds. Integrated GPUs stop at 128 chunks.": "Terreny llunyà gruixut en mons locals. Les GPU integrades s'aturen a 128 fragments.",
@@ -144,13 +307,15 @@
"Size of menus and the HUD.": "Mida dels menús i l'HUD.",
"Sets the options below together. Changing one makes it Custom.": "Estableix les opcions següents juntes. Canviar-ne un el fa personalitzat.",
"Lava, fire and sunlight shine brighter than white. Needs an HDR display.": "La lava, el foc i la llum del sol brillen més que el blanc. Necessita una pantalla HDR.",
"Your display's darkest black. 0 for OLED; LCDs glow a little.": "La pantalla és el negre més fosc. 0 per a OLED; Els LCD brillen una mica.",
"Lifts shadows onto your display's darkest black. 0: true black, no lift.": "Puja les ombres fins al negre més fosc de la teva pantalla. 0: negre pur, sense pujar.",
"How bright highlights get. Auto: the peak your system reports.": "Com es fan els reflexos brillants. Automàtic: el pic que informa el vostre sistema.",
"How bright white paper and menus look. Auto: your desktop's white.": "Quin aspecte brillant del paper blanc i dels menús. Automàtic: el teu escriptori és blanc.",
"How bright white looks in the world. Auto: your desktop's white.": "Com de brillant es veu el blanc al món. Automàtic: el blanc de l'escriptori.",
"How bright the HUD and menus are, against paper white.": "Què brillants són l'HUD i els menús, contra el paper blanc.",
"How far mobs, crops and machines keep working. CPU time.": "Fins on segueixen treballant les turbes, els conreus i les màquines. Temps de CPU.",
"How far away mobs and items are drawn, as a share of normal.": "A quina distància es dibuixen les turbes i els articles, com a part de la normalitat.",
"Skips terrain hidden behind closed sections. Off costs a lot.": "Salta el terreny amagat darrere de seccions tancades. Off costa molt.",
"Culls nearby terrain on the GPU: less CPU at high render distance, more GPU.": "Elimina el terreny proper a la GPU: menys CPU amb molta distància de renderitzat, més GPU.",
"Auto: tries it where your CPU limits the frame, keeps it if frames get faster.": "Automàtic: ho prova on la CPU limita el fotograma i ho manté si els fotogrames van més ràpid.",
"Hides entities behind opaque blocks.": "Amaga les entitats darrere de blocs opacs.",
"No entity occlusion culling.": "Cap eliminació d'oclusió d'entitats.",
"CPU occlusion test runs this frame.": "La prova d'oclusió de la CPU executa aquest marc.",
@@ -174,6 +339,7 @@
"World pixels per screen pixel. Below 100% is faster on the GPU.": "Píxels mundials per píxel de pantalla. Per sota del 100% és més ràpid a la GPU.",
"Smooths jagged edges. FXAA: softer. TAA: smoothest, moving things may trail.": "Allisa les vores dentades. FXAA: més suau. TAA: més suau, les coses en moviment poden seguir.",
"Renders the world smaller and rebuilds it with TAA. Faster, softer.": "Redueix el món i el reconstrueix amb TAA. Més ràpid, més suau.",
"Sharpens the upscaled picture. Higher: crisper edges, more shimmer.": "Fa més nítida la imatge ampliada. Més alt: vores més nítides, més tremolor.",
"HDR output on an HDR display: lava, fire and sunlight shine brighter.": "Sortida HDR en una pantalla HDR: la lava, el foc i la llum del sol brillen més.",
"How frames reach the screen while VSync is off.": "Com arriben els fotogrames a la pantalla mentre VSync està desactivat.",
"Efficient sleeps until the next frame; Precise spins, like vanilla.": "Dors eficients fins al següent fotograma; Girades precises, com la vainilla.",
@@ -193,9 +359,11 @@
"Frame rate and frame times over the game, with a graph.": "Velocitat de fotogrames i temps de fotogrames al llarg del joc, amb un gràfic.",
"horizon.multiplayer.finding_friend": "Trobar el món del teu amic...",
"horizon.multiplayer.not_signed_in": "Aquest servidor està en mode en línia. Inicieu la sessió amb el vostre compte de Microsoft al llançador d'Horizon per unir-vos-hi.",
"horizon.realms.not_signed_in": "Inicia la sessió amb el teu compte de Microsoft al llançador d'Horizon per jugar a Realms.",
"horizon.multiplayer.invalid_address": "Adreça del servidor no vàlida: %s",
"horizon.multiplayer.connected_friend_direct": "Connectat directament amb el teu amic...",
"horizon.multiplayer.connected_friend_relay": "Connectat amb el teu amic a través del relé...",
"General": "General",
"Advanced": "Avançat",
"Undo": "Desfer",
"Search options": "Opcions de cerca",
@@ -205,8 +373,10 @@
"Interface": "Interfície",
"Sky and weather": "Cel i temps",
"Lighting and entities": "Il·luminació i entitats",
"Textures": "Textures",
"Image": "Imatge",
"Chunk loading": "Càrrega de trossos",
"Culling": "Culling",
"Power": "Poder",
"Frames": "Marcs",
"Overlay": "Superposició",
@@ -219,6 +389,7 @@
"Brighter-than-white output on an HDR display.": "Sortida més brillant que el blanc en una pantalla HDR.",
"Results for \"%s\"": "Resultats per a \"%s\"",
"No settings match.": "No coincideix cap configuració.",
"horizon.plain.official": "Horizon oficial amb llançador, mods i actualitzacions: playhorizon.dev",
"horizon.title.not_affiliated": "No està afiliat a Mojang o Microsoft",
"horizon.extras.header.lighting": "Il·luminació",
"horizon.extras.header.third_person": "Tercera persona",
@@ -242,9 +413,12 @@
"horizon.extras.no_ai": "Sense IA",
"horizon.extras.agent_control": "Control d'agents",
"horizon.extras.discord_presence": "Presència enriquida de Discord",
"horizon.extras.zoom": "Zoom",
"horizon.extras.zoom_mod_install": "Zoom: instal·la'l des de Mods",
"horizon.extras.splitscreen": "Pantalla dividida",
"horizon.extras.chat_animation": "Xat suau",
"horizon.extras.chat_indicator": "Indicador de missatge",
"horizon.extras.wheel_cycles_list_settings.tooltip": "La roda de desplaçament sobre un botó d'una llista de configuració en canvia el valor. Apagada, com a la vainilla, la roda desplaça la llista.",
"horizon.extras.wheel_cycles_settings": "La roda de desplaçament canvia la configuració",
"horizon.extras.pack_browser": "Navegador de paquets de recursos",
"horizon.extras.fullbright.tooltip": "Mostra blocs i entitats a plena llum, fins i tot en zones fosques.",
@@ -305,6 +479,7 @@
"horizon.friends.stopped": "S'ha deixat de compartir el codi d'invitació.",
"horizon.friends.short_code_changed": "Algú va seguir provant un codi curt equivocat, així que va canviar.",
"horizon.friends.wrong_code": "Algú ha provat un codi incorrecte.",
"horizon.friends.relay_stopped": "Massa codis curts incorrectes, així que el relé està aturat fins que tornis a compartir. Els amics encara s'hi poden unir amb el codi complet.",
"horizon.friends.code_ready": "El teu codi d'invitació està preparat: Menú de joc, Juga amb els amics. Copia'l i envia'l als teus amics.",
"horizon.friends.share_failed": "No s'ha pogut compartir el món: %s",
"horizon.friends.finding_route": "Trobar com els amics et poden arribar...",
@@ -318,11 +493,13 @@
"horizon.extras.chat_indicator.tooltip": "La barra de colors a l'esquerra d'una línia de xat.",
"horizon.extras.pack_browser.tooltip": "Select Resource Packs obté un botó Afegeix que cerca Modrinth i instal·la un paquet a la carpeta de paquets. Desactivat, el joc mai contacta amb Modrinth.",
"horizon.extras.splitscreen.tooltip": "En un món, un altre controlador pot unir-se com a jugador nou: premeu A quan ho demani, després trieu un perfil i on jugar.",
"horizon.splitscreen.setup.monitor": "Monitor %s",
"horizon.splitscreen.setup.monitor_unknown": "un monitor",
"horizon.splitscreen.setup.window": "Finestra %s",
"horizon.splitscreen.setup.new_window": "Finestra nova",
"horizon.splitscreen.setup.fill_monitor": "Ompliu %s",
"horizon.splitscreen.setup.done": "Fet",
"horizon.splitscreen.setup.switch": "%s: %s",
"horizon.splitscreen.setup.on": "Encès",
"horizon.splitscreen.setup.off": "Apagat",
"horizon.splitscreen.setup.player": "Jugador %s",
@@ -373,12 +550,16 @@
"horizon.splitscreen.prompt.join.many": "%s els controladors volen unir-se",
"horizon.splitscreen.prompt.join.yes": "Uneix-te",
"horizon.splitscreen.prompt.join.as_p1": "Juga com a P1",
"horizon.splitscreen.prompt.join.no": "No",
"horizon.splitscreen.prompt.lost": "%s: controlador desconnectat",
"horizon.splitscreen.prompt.lost.use_another": "Fes servir un altre",
"horizon.splitscreen.prompt.lost.leave": "marxar",
"horizon.splitscreen.setup.help.give_up_pad": "El vostre controlador deixa de conduir-vos i se li ofereix unir-se (A).",
"horizon.splitscreen.setup.note.held_at": "%s a %s: A la deixa caure aquí, la B la torna a posar.",
"horizon.splitscreen.setup.note.held": "%s agafat: esquerra/dreta escull on, A cau, B el torna a posar.",
"horizon.splitscreen.online_only": "Aquest servidor està en mode en línia: els perfils fora de línia no s'hi poden unir, cada jugador necessita el seu propi compte de Minecraft.",
"horizon.splitscreen.own_account": "Aquest servidor necessita el compte de Minecraft propi de cada jugador",
"horizon.splitscreen.local_only": "Els altres jugadors només s'hi poden unir amb el seu propi compte de Minecraft: els perfils fora de línia juguen al teu món, a la LAN o en un servidor de la teva xarxa local.",
"Hides models behind opaque blocks. Async checks off-thread; entities tick.": "Amaga models darrere de blocs opacs. Async comprova fora de fil; les entitats marquen.",
"Block Entities": "Bloquejar entitats",
"Shulker Boxes": "Caixes Shulker",
@@ -397,12 +578,26 @@
"Reuses statue geometry until its block state changes.": "Reutilitza la geometria de l'estàtua fins que el seu estat de bloc canvia.",
"Leaf Culling": "Talla de fulles",
"Every leaf face. Vanilla's fancy leaves.": "Cada cara de fulla. Fulles elegants de vainilla.",
"Opaque Ice": "Gel opac",
"Draws ice in the solid pass, without its see-through texture.": "Dibuixa el gel a la passada sòlida, sense la seva textura transparent.",
"Distant Plant Dither": "Dither de planta llunyana",
"Keeps fine plants in high-res packs from fading far away.": "Evita que les plantes fines en paquets d'alta resolució s'esvaeixin lluny.",
"Screenshot Quality": "Qualitat de la captura de pantalla",
"F2 screenshots at this many times the game's resolution. Higher takes longer.": "Captures de pantalla F2 amb moltes vegades la resolució del joc. Més alt triga més.",
"Frame Generation": "Generació de fotogrames",
"Shows a made-up frame between each two real ones: smoother, a little more lag.": "Mostra un marc inventat entre cadascun dels dos reals: més suau, una mica més de retard.",
"Shadow Cache": "Cau d'ombres",
"Shaders": "Shaders",
"Shader packs, Horizon's and shaders/ folder ones, and their options.": "Paquets de shaders, els d'Horizon i els de la carpeta shaders/, i les seves opcions.",
"Shader packs": "Paquets de shaders",
"Vanilla's own preset: sets only vanilla's options, as Minecraft does.": "El preestablert de la vainilla: només estableix les opcions de la vainilla, com fa el Minecraft.",
"Fullscreen takes the display over, at the mode below.": "La pantalla completa ocupa la pantalla, amb el mode de sota.",
"The display mode exclusive fullscreen uses. Applies when you leave.": "El mode de pantalla que fa servir la pantalla completa exclusiva. S'aplica quan surts.",
"Blurs the world behind menus.": "Desenfoca el món darrere dels menús.",
"How textures are sampled: sharper at a slant with Anisotropic.": "Com es mostregen les textures: més nítides en angle amb Anisotròpic.",
"How far Anisotropic filtering goes. Higher is sharper at a slant.": "Fins on arriba el filtratge anisotròpic. Més alt és més nítid en angle.",
"Only with Anisotropic texture filtering.": "Només amb filtratge anisotròpic de textures.",
"Picks a shader pack, Horizon's or a shaders/ folder one, and sets its options.": "Tria un paquet de shaders, d'Horizon o de la carpeta shaders/, i n'estableix les opcions.",
"Reuses the pack shadow map when sun, view and world match. Off by default.": "Reutilitza el mapa d'ombres del paquet quan el sol, la vista i el món coincideixen. Desactivat per defecte.",
"Find BlueMap": "Busca BlueMap",
"On servers, finds their BlueMap web map for far terrain. Next join.": "Als servidors, troba el seu mapa web BlueMap per a terrenys llunyans. A continuació uneix-te.",
@@ -410,6 +605,8 @@
"How far around you BlueMap tiles are downloaded. Next join.": "A quina distància al teu voltant es descarreguen les fitxes de BlueMap. A continuació uneix-te.",
"Synchronous": "Sincrònic",
"Asynchronous": "Asíncron",
"Hidden Faces": "Cares ocultes",
"Drops leaf faces behind two leaf layers: faster, same crowns.": "Descarta les cares de fulla darrere de dues capes de fulles: més ràpid, les mateixes copes.",
"Solid": "Sòlid",
"Solid Aggressive": "Sòlid Agressiu",
"Hollow": "Buit",
@@ -427,8 +624,14 @@
"horizon.agent.session_opened": "Obert per a aquesta sessió pel programa que va iniciar el joc.",
"horizon.agent.socket": "Socket: %s (copieu els detalls del camí complet)",
"horizon.agent.local_only": "Només local: agents en aquest ordinador, mai la xarxa.",
"%s nits": "%s nits",
"Auto (%s nits)": "Automàtic (%s nits)",
"%.2f nits": "%.2f nits",
"Hides models behind opaque blocks. Entities update. Off with custom packs.": "Amaga models darrere de blocs opacs. Actualització d'entitats. Apaga amb paquets personalitzats.",
"Entity Culling Precheck": "Comprovació prèvia del culling d'entitats",
"Runs the mob AABB precheck after this many loaded mobs. Zero disables it.": "Executa la comprovació prèvia d'AABB dels mobs després d'aquest nombre de mobs carregats. Zero la desactiva.",
"%s mobs": "%s mobs",
"Only applies to asynchronous entity culling.": "Només s'aplica al culling asíncron d'entitats.",
"horizon.extras.repro_keys": "Dreceres de gravació",
"horizon.extras.repro_keys.tooltip": "F3+R inicia o atura un enregistrament de reproducció i F3+E escriu el seu paquet. Desactivat per defecte: una gravació copia el món.",
"horizon.agent.record_session": "Inicieu la sessió de gravació",
@@ -440,6 +643,17 @@
"horizon.repro.error": "Reproducció: %s",
"horizon.repro.no_recording": "Encara no hi ha cap gravació per exportar.",
"horizon.repro.replay_started": "La reproducció ha començat: %s",
"horizon.splitscreen.profiles.offline": " (Fora de línia)",
"horizon.splitscreen.profiles.microsoft": " (Microsoft)",
"horizon.splitscreen.profiles.sign_in": "Torna a iniciar la sessió al llançador",
"horizon.splitscreen.profiles.online": "Microsoft en línia",
"horizon.splitscreen.account_unavailable": "%s està fora de línia. Torna a iniciar la sessió al llançador i reinicia el joc.",
"horizon.splitscreen.account_expired": "La sessió de Microsoft de %s ha caducat. Torna a iniciar la sessió al llançador i reinicia el joc.",
"Particle Culling": "Culling de partícules",
"Draws every particle.": "Dibuixa totes les partícules.",
"Skips offscreen, subpixel and terrain-hidden particles.": "Salta les partícules fora de pantalla, subpíxel i amagades pel terreny.",
"Skips offscreen, subpixel and terrain-hidden particles with less render-thread work.": "Salta les partícules fora de pantalla, subpíxel i amagades pel terreny amb menys feina del fil de renderitzat.",
"horizon.pack.add": "+",
"horizon.pack.addInfo": "Afegiu un paquet de recursos de Modrinth",
"horizon.pack.addWorking": "Preguntant a Modrinth...",
"horizon.pack.addNone": "No s'han trobat paquets de recursos",
@@ -453,10 +667,28 @@
"horizon.pack.filter.realistic": "Realista",
"horizon.pack.filter.simplistic": "Simplista",
"horizon.pack.filter.vanillaLike": "semblant a la vainilla",
"horizon.pack.filter.x16": "16x",
"horizon.pack.filter.x32": "32x",
"horizon.pack.filter.x64": "64x",
"horizon.pack.addFailed": "No s'ha pogut afegir el paquet: %s",
"horizon.pack.options": "Opcions del paquet",
"horizon.pack.optionsInfo": "Opcions d'aquest paquet",
"horizon.pack.options.title": "Opcions del paquet",
"horizon.pack.options.general": "General",
"horizon.pack.options.presets": "Preestablerts",
"horizon.pack.options.presets.custom": "Personalitzat",
"horizon.pack.options.impact": "Impacte en el rendiment: %s",
"horizon.pack.options.impact.low": "baix",
"horizon.pack.options.impact.medium": "mitjà",
"horizon.pack.options.impact.high": "alt",
"horizon.pack.options.impact.varies": "variable",
"horizon.extras.header.cosmetics": "Cosmètics",
"horizon.extras.cosmetics_mine": "Els meus cosmètics",
"horizon.extras.cosmetics_mine.tooltip": "Ocult: tots els jugadors d'Horizon et veuen sense els teus cosmètics, tu inclòs. El mateix interruptor que l'armari del llançador.",
"key.emote": "Roda d'emotes",
"horizon.emote_wheel.title": "Emotes",
"horizon.emote_wheel.empty": "Encara no hi ha emotes",
"horizon.emote_wheel.help": "Deixa anar o fes clic per reproduir · 1–8 · Esc tanca",
"horizon.extras.cosmetics_others": "Cosmètica d'altres jugadors",
"horizon.extras.cosmetics_others.tooltip": "Què porten altres jugadors d'Horizon: tot, només els seus estils d'etiquetes de nom o res. Amagat, el joc mai pregunta què porten.",
"horizon.extras.cosmetics_hide_fx": "Amaga les esteles i les aures dels altres jugadors",
@@ -481,5 +713,349 @@
"Pack settings": "Configuració del paquet",
"Built-in settings": "Configuració integrada",
"%s settings": "Configuració de %s",
"Settings made by the pack's author: they change only this pack.": "Configuració feta per l'autor del paquet: només canvia aquest paquet."
"Settings made by the pack's author: they change only this pack.": "Configuració feta per l'autor del paquet: només canvia aquest paquet.",
"horizon.moderation.duration.minute": "%s minut",
"horizon.moderation.duration.minutes": "%s minuts",
"horizon.moderation.duration.hour": "%s hora",
"horizon.moderation.duration.hours": "%s hores",
"horizon.moderation.duration.day": "%s dia",
"horizon.moderation.duration.days": "%s dies",
"horizon.moderation.reason": "%1$s: %2$s",
"horizon.moderation.mutelist.empty": "No hi ha cap silenciament",
"horizon.moderation.mutelist.count": "Hi ha %s silenciament(s):",
"horizon.moderation.chat_filter": "el filtre de xat",
"horizon.moderation.until_unmuted": "fins que es torni a permetre el xat",
"horizon.moderation.remaining": "durant %s més",
"horizon.moderation.mutelist.entry": "%1$s va ser silenciat per %2$s %3$s",
"horizon.moderation.invalid_duration": "%s no és una durada: 10m, 2h, 1d",
"horizon.moderation.usage.mute": "/mute <player> [duration] [reason]",
"horizon.moderation.usage.tempmute": "/tempmute <player> <duration> [reason]",
"horizon.moderation.usage.unmute": "/unmute <player>",
"horizon.moderation.usage.mutelist": "/mutelist",
"horizon.moderation.for_duration": "durant %s",
"horizon.moderation.muted": "Has silenciat %1$s %2$s",
"horizon.moderation.unmuted": "Has tornat a permetre el xat de %s",
"horizon.moderation.not_muted": "%s no està silenciat",
"horizon.tempban.usage": "/tempban <player> <duration> [reason]",
"horizon.tempban.invalid_duration": "%s no és una durada: 30m, 12h, 7d",
"horizon.tempban.dedicated_required": "Els bans temporals necessiten un servidor dedicat",
"horizon.tempban.success": "%1$s durant %2$s, fins a %3$s",
"horizon.tempban.list.empty": "No hi ha cap ban temporal",
"horizon.tempban.list.count": "Hi ha %s ban(s) temporal(s):",
"horizon.tempban.list.entry": "%1$s va ser bandejat per %2$s %3$s (fins a %4$s)",
"horizon.options.pointer_speed": "Velocitat del punter als menús",
"horizon.options.scroll_speed": "Velocitat de desplaçament als menús",
"horizon.options.repeat_delay": "Retard de repetició de navegació",
"horizon.options.repeat_interval": "Interval de repetició de navegació",
"horizon.options.milliseconds": "%s ms",
"horizon.extras.cosmetics_solo": "A un jugador",
"horizon.extras.cosmetics_solo.tooltip": "Mostra els teus cosmètics i la insígnia de la teva etiqueta de nom també als teus mons d'un jugador (la teva etiqueta de nom amb Etiqueta de nom pròpia). Apagat: només als servidors.",
"horizon.extras.horizon_marks": "Logos d'Horizon",
"horizon.extras.horizon_marks.tooltip": "Mostra el logo d'Horizon al costat dels jugadors d'Horizon: un logo petit abans dels noms dels altres jugadors que fan servir Horizon, a sobre dels caps i a la llista de jugadors. Només el veuen els jugadors d'Horizon: els servidors no reben res.",
"horizon.extras.horizon_me": "Mostra'm com a Horizon",
"horizon.extras.horizon_me.tooltip": "Mostra'm com a jugador d'Horizon: els altres jugadors d'Horizon veuen el logo al costat del teu nom. Activat, el servei respon a qui pregunti per tu que has jugat a Horizon en les últimes 3 hores; apagat, no t'informa mai.",
"horizon.pages.pvp": "PvP i HUD",
"horizon.pages.pvp.tooltip": "Retícula, HUD i ajustos de PvP, tots desactivats per defecte. Res d'aquí no mostra el que la vainilla no mostra.",
"horizon.pages.open.pvp": "PvP i HUD...",
"horizon.pvp.header.crosshair": "Retícula",
"horizon.pvp.crosshair": "Retícula personalitzada",
"horizon.pvp.crosshair_shape": "Forma",
"horizon.pvp.crosshair_size": "Mida",
"horizon.pvp.crosshair_gap": "Separació",
"horizon.pvp.crosshair_thickness": "Gruix",
"horizon.pvp.crosshair_colour": "Color",
"horizon.pvp.crosshair_outline": "Contorn",
"horizon.pvp.crosshair_dot": "Punt central",
"horizon.pvp.crosshair_hover": "Color sobre entitats",
"horizon.pvp.crosshair.tooltip": "Dibuixa la teva pròpia retícula en lloc de la de la vainilla. Els seus primers ajustos s'assemblen als de la vainilla.",
"horizon.pvp.crosshair_shape.tooltip": "Una creu, una T, una X, un cercle, un quadrat o només un punt.",
"horizon.pvp.crosshair_size.tooltip": "La llargada de cada braç, en píxels de la GUI.",
"horizon.pvp.crosshair_gap.tooltip": "L'espai buit del mig.",
"horizon.pvp.crosshair_thickness.tooltip": "Com de gruixuda és cada línia.",
"horizon.pvp.crosshair_colour.tooltip": "Invertit mostra el color de sota invertit, com fa la retícula de la vainilla.",
"horizon.pvp.crosshair_outline.tooltip": "Una vora negra fina, perquè es vegi sobre qualsevol fons.",
"horizon.pvp.crosshair_dot.tooltip": "Un punt al mig de la retícula.",
"horizon.pvp.crosshair_hover.tooltip": "Un altre color mentre la retícula és sobre un mob o un jugador que podries colpejar, tal com el coneix l'indicador d'atac de la vainilla.",
"horizon.pvp.colour.inverted": "Invertit",
"horizon.pvp.colour.white": "Blanc",
"horizon.pvp.colour.red": "Vermell",
"horizon.pvp.colour.orange": "Taronja",
"horizon.pvp.colour.yellow": "Groc",
"horizon.pvp.colour.green": "Verd",
"horizon.pvp.colour.cyan": "Cian",
"horizon.pvp.colour.blue": "Blau",
"horizon.pvp.colour.magenta": "Magenta",
"horizon.pvp.colour.black": "Negre",
"horizon.pvp.shape.cross": "Creu",
"horizon.pvp.shape.t": "T",
"horizon.pvp.shape.x": "X",
"horizon.pvp.shape.circle": "Cercle",
"horizon.pvp.shape.square": "Quadrat",
"horizon.pvp.shape.dot": "Punt",
"horizon.pvp.outline": "Contorn de bloc personalitzat",
"horizon.pvp.outline.tooltip": "Canvia el contorn del bloc que mires.",
"horizon.pvp.outline_colour": "Color",
"horizon.pvp.outline_colour.tooltip": "El color del contorn. Negre al 40% és el de la vainilla.",
"horizon.pvp.outline_opacity": "Opacitat",
"horizon.pvp.outline_opacity.tooltip": "Com de sòlid és el contorn.",
"horizon.pvp.outline_thickness": "Gruix",
"horizon.pvp.outline_thickness.tooltip": "L'amplada del contorn, en vegades el de la vainilla.",
"horizon.pvp.outline_fill": "Ompliment",
"horizon.pvp.outline_fill.tooltip": "Teny suaument les cares del bloc que mires.",
"horizon.pvp.hit": "Color de cop personalitzat",
"horizon.pvp.hit.tooltip": "Canvia el color amb què parpellegen els mobs i els jugadors quan reben mal. Vermell al 30% és el de la vainilla.",
"horizon.pvp.hit_colour": "Color",
"horizon.pvp.hit_colour.tooltip": "El color amb què parpellegen els mobs i els jugadors ferits.",
"horizon.pvp.hit_opacity": "Opacitat",
"horizon.pvp.hit_opacity.tooltip": "Quant del color els cobreix.",
"horizon.pvp.header.outline": "Contorn de bloc",
"horizon.pvp.header.hit": "Color de cop",
"horizon.pvp.hud_armour": "Armadura",
"horizon.pvp.hud_armour.tooltip": "L'armadura que portes, començant pel cap, amb els usos que li queden a cada peça.",
"horizon.pvp.hud_armour_corner": "Cantonada de l'armadura",
"horizon.pvp.hud_armour_corner.tooltip": "On es mostra l'armadura.",
"horizon.pvp.hud_armour_percent": "Durabilitat en %",
"horizon.pvp.hud_armour_percent.tooltip": "Usos restants com a percentatge del màxim de la peça en lloc d'un número.",
"horizon.pvp.hud_effects": "Efectes",
"horizon.pvp.hud_effects.tooltip": "Els teus efectes amb els noms, nivells i temps restant, en lloc de les icones de dalt a la dreta.",
"horizon.pvp.hud_effects_corner": "Cantonada dels efectes",
"horizon.pvp.hud_effects_corner.tooltip": "On es mostren els efectes.",
"horizon.pvp.hud_held": "Comptador de l'objecte a la mà",
"horizon.pvp.hud_held.tooltip": "Quants de l'objecte que tens a la mà conté el teu inventari; fletxes amb un arc o ballesta.",
"horizon.pvp.hud_held_corner": "Cantonada del comptador",
"horizon.pvp.hud_held_corner.tooltip": "On es mostra el comptador.",
"horizon.pvp.hud_background": "Fons dels ginys",
"horizon.pvp.hud_background.tooltip": "Una caixa fosca darrere de cada giny.",
"horizon.pvp.hud_opacity": "Opacitat del fons",
"horizon.pvp.hud_opacity.tooltip": "Com de fosques són les caixes darrere dels ginys.",
"horizon.pvp.hud_text_colour": "Color del text",
"horizon.pvp.hud_text_colour.tooltip": "El color del text dels ginys. La durabilitat manté el seu verd a vermell.",
"horizon.pvp.hud_shadow": "Ombra del text",
"horizon.pvp.hud_shadow.tooltip": "Una ombra sota el text dels ginys, com la té el text de l'HUD de la vainilla.",
"horizon.pvp.header.hud": "Ginys de l'HUD",
"horizon.pvp.corner.top_left": "A dalt a l'esquerra",
"horizon.pvp.corner.top_right": "A dalt a la dreta",
"horizon.pvp.corner.bottom_left": "A baix a l'esquerra",
"horizon.pvp.corner.bottom_right": "A baix a la dreta",
"horizon.pvp.hud_ping": "Ping",
"horizon.pvp.hud_ping.tooltip": "La latència que el servidor t'informa, en mil·lisegons. Només multijugador.",
"horizon.pvp.hud_ping_corner": "Cantonada del ping",
"horizon.pvp.hud_ping_corner.tooltip": "On es mostra el ping.",
"horizon.pvp.hud_coords": "Coordenades",
"horizon.pvp.hud_coords.tooltip": "La teva posició en blocs i la direcció en què mires, com les mostra F3. Amagades quan el món o el servidor redueix F3.",
"horizon.pvp.hud_coords_corner": "Cantonada de les coordenades",
"horizon.pvp.hud_coords_corner.tooltip": "On es mostren les coordenades.",
"horizon.pvp.hud_server": "Adreça del servidor",
"horizon.pvp.hud_server.tooltip": "L'adreça del servidor on jugues. Només multijugador.",
"horizon.pvp.hud_server_corner": "Cantonada del servidor",
"horizon.pvp.hud_server_corner.tooltip": "On es mostra l'adreça.",
"horizon.pvp.hud_combo": "Comptador de combo",
"horizon.pvp.hud_combo.tooltip": "Cops seguits que el servidor ha confirmat, fins que reps mal o passen 3 segons sense cap.",
"horizon.pvp.hud_combo_corner": "Cantonada del combo",
"horizon.pvp.hud_combo_corner.tooltip": "On es mostra el combo.",
"horizon.pvp.hud.ping": "Ping: %s ms",
"horizon.pvp.hud.coords": "XYZ: %s %s %s",
"horizon.pvp.hud.facing": "Mirant: %s",
"horizon.pvp.hud.combo": "Combo: %s",
"horizon.pvp.hud.direction.north": "Nord",
"horizon.pvp.hud.direction.south": "Sud",
"horizon.pvp.hud.direction.east": "Est",
"horizon.pvp.hud.direction.west": "Oest",
"horizon.pvp.hud_fps": "FPS",
"horizon.pvp.hud_fps.tooltip": "Fotogrames dibuixats en l'últim segon.",
"horizon.pvp.hud_fps_corner": "Cantonada dels FPS",
"horizon.pvp.hud_fps_corner.tooltip": "On es mostren els FPS.",
"horizon.pvp.hud_cps": "Clics per segon",
"horizon.pvp.hud_cps.tooltip": "Els teus clics esquerre i dret en l'últim segon.",
"horizon.pvp.hud_cps_corner": "Cantonada dels CPS",
"horizon.pvp.hud_cps_corner.tooltip": "On es mostren els clics per segon.",
"horizon.pvp.hud_keys": "Tecles premudes",
"horizon.pvp.hud_keys.tooltip": "Les teves tecles de moviment, botons del ratolí i salt, il·luminats mentre els mantens, amb els clics per segon.",
"horizon.pvp.hud_keys_corner": "Cantonada de les tecles",
"horizon.pvp.hud_keys_corner.tooltip": "On es mostren les tecles premudes.",
"horizon.pvp.hud_clock": "Rellotge",
"horizon.pvp.hud_clock.tooltip": "L'hora del teu ordinador.",
"horizon.pvp.hud_clock_corner": "Cantonada del rellotge",
"horizon.pvp.hud_clock_corner.tooltip": "On es mostra el rellotge.",
"horizon.pvp.hud_reach": "Abast",
"horizon.pvp.hud_reach.tooltip": "A quina distància dels teus ulls ha arribat el teu últim cop, durant 3 segons. Només mesura: el teu abast segueix sent el de la vainilla.",
"horizon.pvp.hud_reach_corner": "Cantonada de l'abast",
"horizon.pvp.hud_reach_corner.tooltip": "On es mostra l'abast.",
"horizon.pvp.hud_speed": "Velocitat",
"horizon.pvp.hud_speed.tooltip": "Com de ràpid et mous pel terra, en blocs per segon.",
"horizon.pvp.hud_speed_corner": "Cantonada de la velocitat",
"horizon.pvp.hud_speed_corner.tooltip": "On es mostra la velocitat.",
"horizon.pvp.hud.fps": "%s FPS",
"horizon.pvp.hud.cps": "CPS: %s | %s",
"horizon.pvp.hud.reach": "Abast: %s",
"horizon.pvp.hud.speed": "Velocitat: %s b/s",
"horizon.pvp.hud.keys.attack": "LMB",
"horizon.pvp.hud.keys.use": "RMB",
"horizon.pvp.hud.keys.cps": "%s CPS",
"horizon.pvp.numeric_enchants": "Nivells d'encantament com a números",
"horizon.pvp.numeric_enchants.tooltip": "Els consells i la taula d'encantaments mostren Afilat 5 en lloc d'Afilat V.",
"horizon.pvp.header.items": "Objectes",
"horizon.pvp.fire_lower": "Foc més baix",
"horizon.pvp.fire_lower.tooltip": "Mou les flames que veus mentre cremes més avall, perquè cobreixin menys pantalla.",
"horizon.pvp.shield_size": "Mida de l'escut",
"horizon.pvp.shield_size.tooltip": "La mida de l'escut que tens a la mà, en primera persona.",
"horizon.pvp.totem_size": "Mida de l'animació del tòtem",
"horizon.pvp.totem_size.tooltip": "La mida del tòtem que omple la pantalla quan et salva.",
"horizon.pvp.glint_colour": "Color de la lluentor dels encantaments",
"horizon.pvp.glint_colour.tooltip": "El color de la lluentor dels objectes i armadures encantats. Apagat és el porpra de la vainilla.",
"horizon.pvp.clear_water": "Aigua clara",
"horizon.pvp.clear_water.tooltip": "Sota l'aigua, veu tan lluny com la vainilla et deixa després de 30 segons allà, des del principi.",
"horizon.pvp.clear_lava": "Lava clara",
"horizon.pvp.clear_lava.tooltip": "A la lava, veu tan lluny com et deixa la Resistència al foc de la vainilla, sense l'efecte.",
"horizon.pvp.header.visual": "Retocs visuals",
"horizon.pvp.view_time": "Hora del dia",
"horizon.pvp.view_time.tooltip": "L'hora del dia que veus: només el cel i la seva llum. L'hora del món segueix i no s'envia res al servidor.",
"horizon.pvp.view_weather": "Temps",
"horizon.pvp.view_weather.tooltip": "El temps que veus: només la pluja, la seva llum i la boira. El temps del món segueix i no s'envia res al servidor.",
"horizon.pvp.header.view": "La teva vista",
"horizon.pvp.view.world": "El del món",
"horizon.pvp.time.morning": "Matí",
"horizon.pvp.time.noon": "Migdia",
"horizon.pvp.time.sunset": "Posta de sol",
"horizon.pvp.time.midnight": "Mitjanit",
"horizon.pvp.weather.clear": "Clar",
"horizon.pvp.weather.rain": "Pluja",
"horizon.pvp.weather.thunder": "Tempesta",
"horizon.chat.timestamp": "[%s] ",
"horizon.chat.repeats": " (x%s)",
"horizon.pvp.header.chat": "Xat",
"horizon.chat.copied": "Missatge copiat",
"horizon.chat.search": "Cerca al xat:",
"horizon.pvp.chat_timestamps": "Marca de temps",
"horizon.pvp.chat_timestamps.tooltip": "L'hora en què va arribar cada missatge, abans.",
"horizon.pvp.chat_compact": "Xat compacte",
"horizon.pvp.chat_compact.tooltip": "Un missatge repetit seguit es mostra un cop, amb quantes vegades ha arribat: (x3).",
"horizon.pvp.chat_copy": "Copia en fer clic",
"horizon.pvp.chat_copy.tooltip": "Fer clic en un missatge del xat obert en copia el text, tret que el clic faci una altra cosa.",
"horizon.pvp.chat_search": "Cerca al xat",
"horizon.pvp.chat_search.tooltip": "Ctrl+F al xat obert: el que escrius només mostra els missatges que ho contenen. Retorn o Ctrl+F un altre cop ho atura.",
"horizon.pvp.preset": "Preestablert de PvP",
"horizon.pvp.preset.tooltip": "Activa la retícula personalitzada, els ginys d'armadura i efectes, l'esprint i ajupir-se alternats, el foc més baix i els mods de comptador de FPS i tecles premudes si estan instal·lats. Els teus ajustos de cadascun es mantenen.",
"horizon.pvp.hud_editor_key": "Tecla de l'editor de l'HUD",
"horizon.pvp.hud_editor_key.tooltip": "Obre l'editor de l'HUD des del joc. Fes clic i prem una tecla; Escape la desvincula.",
"horizon.pvp.editor": "Edita l'HUD",
"horizon.pvp.editor.tooltip": "Mou els ginys al joc: arrossega'ls on vulguis, canvia'n la mida amb la roda i fes clic dret sobre un per tornar-lo a la seva cantonada.",
"horizon.pvp.editor.hint": "Arrossega els ginys · Roda: mida · Clic dret: torna a la seva cantonada",
"horizon.pvp.combat_feel": "Sensació de combat",
"horizon.pvp.combat_feel.tooltip": "El mode Clàssic dibuixa les lluites com ho feia la 1.8.9: l'objecte a la mà, el seu cop, menjar, l'arc i una espasa aixecada mentre mantens ús, sense la baixada després de cada cop ni l'indicador d'atac. Només l'aspecte: el que envies a un servidor no canvia.",
"horizon.pvp.combat_indicator": "Indicador d'atac al mode Clàssic",
"horizon.pvp.combat_indicator.tooltip": "Manté l'indicador d'atac al mode Clàssic, que la 1.8.9 no tenia.",
"horizon.pvp.combat_feel.modern": "Modern",
"horizon.pvp.combat_feel.legacy": "Clàssic (1.8.9)",
"horizon.pvp.header.combat": "Sensació de combat",
"horizon.pvp.combat_sounds": "Sons de cop de la 1.8",
"horizon.pvp.combat_sounds.tooltip": "Al mode Clàssic, omet els sons d'atac que va afegir la 1.9 (fort, feble, escombrat, crític...). Els sons de ferit es mantenen.",
"horizon.pvp.combat_rules": "Combat de la 1.8 als mons que hostatges",
"horizon.pvp.combat_rules.tooltip": "El mode d'un jugador i els mons que obres a la LAN lluiten amb les regles de la 1.8.9: sense temps de reutilització d'atac ni escombrat, mal d'arma de la 1.8, Força, Afilat i cops crítics. Els servidors on t'uneixes mantenen les seves pròpies regles.",
"Bindings": "Enllaços",
"Play": "Juga",
"Device": "Dispositiu",
"Look": "Mirada",
"Movement": "Moviment",
"Menus": "Menús",
"Actions": "Accions",
"Reset": "Restableix",
"Detected": "Detectat",
"Glyphs": "Glifos",
"Feedback": "Retroalimentació",
"Controller": "Controlador",
"How the sticks turn and move you, and how menus repeat and scroll.": "Com els pals et giren i et mouen, i com els menús es repeteixen i es desplacen.",
"Which button does what. Each player has their own.": "Quin botó fa què. Cada jugador té el seu.",
"The controller the game sees, its button glyphs and its feedback.": "El controlador que veu el joc, els glifos dels seus botons i la seva retroalimentació.",
"Press this row, then the button that should do it.": "Prem aquesta fila i, després, el botó que ho ha de fer.",
"Off, the game ignores every controller for this player.": "Apagat, el joc ignora tots els controladors d'aquest jugador.",
"How fast the right stick turns the view.": "Com de ràpid el pal dret gira la vista.",
"Turn the view the other way, left and right.": "Gira la vista a l'altre sentit, esquerra i dreta.",
"Turn the view the other way, up and down.": "Gira la vista a l'altre sentit, amunt i avall.",
"How far the right stick moves before the view turns. Raise it if the view drifts.": "Quant s'ha de moure el pal dret abans que la vista giri. Apuja-ho si la vista es desplaça sola.",
"Hold the button to sneak, or press it once to start and again to stop.": "Mantén el botó per ajupir-te, o prem-lo un cop per començar i un altre per aturar.",
"How far the left stick moves before you walk. Raise it if you drift.": "Quant s'ha de moure el pal esquerre abans que caminis. Apuja-ho si et desplaces sol.",
"How far a trigger is pulled before it counts as pressed.": "Quant s'ha de prémer un gallet abans que compti com a premut.",
"Shows what your buttons do right now, in play and in inventories.": "Mostra què fan els teus botons ara mateix, al joc i als inventaris.",
"Shows only what you can do with what you're looking at, and the main inventory buttons.": "Mostra només què pots fer amb el que mires, i els botons principals de l'inventari.",
"Doesn't show what your buttons do.": "No mostra què fan els teus botons.",
"Shows the buttons of the controller you're using.": "Mostra els botons del controlador que estàs fent servir.",
"Always shows Xbox buttons.": "Mostra sempre els botons de Xbox.",
"Always shows PlayStation buttons.": "Mostra sempre els botons de PlayStation.",
"Always shows Nintendo buttons.": "Mostra sempre els botons de Nintendo.",
"A, on the right, confirms and jumps and B goes back, as on a Switch.": "A, a la dreta, confirma i salta, i B torna enrere, com a la Switch.",
"B, at the bottom, confirms and jumps and A goes back, as on other controllers.": "B, a baix, confirma i salta, i A torna enrere, com als altres controladors.",
"Opens Steam's keyboard on a Steam Deck under Steam, and Horizon's otherwise.": "Obre el teclat de Steam en una Steam Deck amb Steam, i el d'Horizon altrament.",
"Always opens Horizon's keyboard.": "Obre sempre el teclat d'Horizon.",
"Always opens Steam's keyboard.": "Obre sempre el teclat de Steam.",
"A DualSense's light shows your health, from green through amber to red.": "La llum del DualSense mostra la teva salut, del verd passant per l'ambre fins al vermell.",
"A DualSense's light changes with the dimension you're in.": "La llum del DualSense canvia segons la dimensió on ets.",
"A DualSense keeps its own light.": "El DualSense manté la seva pròpia llum.",
"Lets LT + A craft on servers other than your own worlds. Some servers count it as a macro.": "Permet fabricar amb LT + A en servidors que no són els teus mons. Alguns servidors ho compten com a macro.",
"Show the buttons for what you can do, while you play with a controller.": "Mostra els botons del que pots fer mentre jugues amb un controlador.",
"How fast the stick moves the crosshair in inventories.": "Com de ràpid el pal mou la retícula als inventaris.",
"How fast the right stick scrolls a list.": "Com de ràpid el pal dret desplaça una llista.",
"How long a held direction waits before it repeats.": "Quant espera una direcció mantinguda abans de repetir-se.",
"How long a held direction takes between repeats.": "Quant triga una direcció mantinguda entre repeticions.",
"Which on-screen keyboard opens when a text field is pressed.": "Quin teclat en pantalla s'obre quan es prem un camp de text.",
"Every action back on its default button.": "Torna cada acció al seu botó per defecte.",
"These settings belong to this player's profile.": "Aquesta configuració pertany al perfil d'aquest jugador.",
"The controller driving this player, or else the first one connected.": "El controlador que condueix aquest jugador o, si no, el primer connectat.",
"The USB vendor and product the system reports. Useful when reporting a controller that misbehaves.": "El proveïdor i el producte USB que informa el sistema. Útil per informar d'un controlador que va malament.",
"The glyph family the game picked from the controller's maker.": "La família de glifos que el joc ha triat segons el fabricant del controlador.",
"Follow the controller, or always show one family's buttons.": "Segueix el controlador, o mostra sempre els botons d'una família.",
"Whether a Nintendo controller's A and B work by where they sit or by their letters.": "Si l'A i la B d'un controlador de Nintendo funcionen per la seva posició o per les seves lletres.",
"The buttons menus show right now.": "Els botons que mostren els menús ara mateix.",
"Rumble when something happens to you.": "Vibra quan et passa alguna cosa.",
"The controller keeps playing while this window isn't focused, and losing focus won't pause.": "El controlador segueix jugant mentre aquesta finestra no té el focus, i perdre'l no posa en pausa.",
"A DualSense's triggers push back on bows and tools.": "Els gallets del DualSense fan força en els arcs i les eines.",
"What a DualSense's light shows, over USB or Bluetooth.": "Què mostra la llum del DualSense, per USB o Bluetooth.",
"Player profile": "Perfil de jugador",
"Vendor and product": "Proveïdor i producte",
"Detected glyphs": "Glifos detectats",
"Glyphs in use": "Glifos en ús",
"Unknown": "Desconegut",
"No controller": "Cap controlador",
"Editing: %s": "Editant: %s",
"Press the button for this. %s cancels.": "Prem el botó per a això. %s cancel·la.",
"This button also does: %s": "Aquest botó també fa: %s",
"Not bound: press this row, then the button that should do it.": "No vinculat: prem aquesta fila i, després, el botó que ho ha de fer.",
"horizon.pad.notice.connected.press": "Prem per fer-lo servir",
"horizon.recipe_transfer.missing": "Falten objectes",
"horizon.recipe_transfer.no_room": "Inventari ple",
"horizon.recipe_transfer.unavailable": "La recepta no cap en aquest menú",
"horizon.recipe_transfer.unsupported": "El servidor requereix una recepta coneguda",
"horizon.studio.timeline.freecam_on": "Càmera lliure: ACTIVADA",
"horizon.studio.timeline.freecam_off": "Càmera lliure: DESACTIVADA",
"horizon.studio.timeline.freecam_short": "Càm",
"horizon.studio.timeline.freecam_active_short": "Càm ✓",
"horizon.studio.timeline.add_keyframe_short": "+ Clau",
"horizon.studio.timeline.delete_keyframe_short": "− Elim.",
"horizon.studio.timeline.split": "Divideix",
"horizon.studio.timeline.marker": "Marcador %s",
"horizon.studio.timeline.seconds": "%s s",
"horizon.studio.timeline.scene_count": "%s escenes",
"horizon.studio.timeline.scene_title": "Escena %s",
"horizon.studio.timeline.sequence": "Seqüència",
"horizon.studio.topbar.title": "✦ Studio",
"horizon.studio.topbar.file": "Fitxer",
"horizon.studio.topbar.camera": "Càmera",
"horizon.studio.topbar.view": "Vista",
"horizon.studio.topbar.file_short": "Fitxer",
"horizon.studio.topbar.camera_short": "Càm",
"horizon.studio.topbar.view_short": "Vista",
"horizon.studio.topbar.game_pause_menu": "⏸ Menú de pausa",
"horizon.studio.topbar.pause_short": "Pausa",
"horizon.studio.topbar.open_recordings": "📁 Obre la carpeta d'enregistraments",
"horizon.studio.topbar.close_replay": "✕ Tanca la reproducció",
"horizon.studio.topbar.exit": "Surt",
"horizon.studio.topbar.toggle_freecam": "Activa o desactiva la càmera lliure",
"horizon.studio.topbar.reset_to_player": "👤 Torna al jugador",
"horizon.studio.topbar.unlock_freecam": "🔓 Desbloqueja la càmera lliure",
"horizon.studio.topbar.lock_to_spline": "🔒 Bloqueja a la spline",
"horizon.studio.topbar.add_keyframe": "➕ Afegeix un fotograma clau",
"horizon.studio.topbar.add_marker": "Afegeix un marcador",
"horizon.studio.topbar.clear_keyframes": "🗑 Esborra els fotogrames clau",
"horizon.studio.topbar.timeline_hud": "⏱ Línia de temps",
"horizon.studio.topbar.split_scene": "Divideix l'escena"
}
Loaded 100 of 627 files, more files were not shown because too many files have changed in this diff. Show more