Compare commits

...
Author SHA1 Message Date
rdbt ae4cea18ef Merge pull request #3004 from feeldev12/feat/slim-skins
feat(client): slim skins wear the slim player model
2026-10-28 00:47:38 +00:00
rdbt 296802a618 Merge pull request #2980 from feeldev12/fix/own-skin-first-person
fix(client): the first-person arm and the player models wear the player's own skin
2026-10-28 00:47:14 +00:00
rdbt a5d3ad165a Merge pull request #2891 from socksprox/t3/clickable-book-links
Books: page click events work, run_command ones included
2026-10-28 00:46:58 +00:00
rdbt 2120ffedb4 Merge pull request #2878 from leifiyoo/riptide-spin-server-countdown
Count down Riptide's spin on a server's copy of a player
2026-10-28 00:46:44 +00:00
rdbt 04e6e21813 Merge pull request #2883 from sirrayi/deps/webpki-advisories
Update rustls-webpki and webpki-roots for RUSTSEC-2026-0382 and -0381
2026-10-28 00:46:15 +00:00
rdbt acee253e1c Merge pull request #2940 from ASOwnerYT/tools/harness-no-vscreen
Harnesses run offscreen on a Linux without vscreen
2026-10-28 00:46:02 +00:00
rdbt 555dce8bb3 Merge pull request #2944 from kacpherek/fix/armor-stand-plates-op-warning
Armor stands press pressure plates; match vanilla's op warning in item tooltips
2026-10-28 00:43:56 +00:00
rdbt 067163ded8 Merge pull request #2968 from kacpherek/fix/block-advancement-triggers
Award the target hit and honey slide advancements
2026-10-28 00:43:49 +00:00
rdbt 704cf97780 Merge pull request #2972 from kacpherek/fix/player-nausea-eyeblossom
Show Nausea on the player: spin the view and draw the swirl
2026-10-28 00:43:30 +00:00
rdbt 007d38fe49 Merge pull request #2822 from sirrayi/fix/telemetry-ipv6-buckets
Telemetry server: count IPv6 clients by /64 in the rate limits
2026-10-28 00:43:14 +00:00
rdbt 43e2ad34c7 Merge pull request #2953 from kacpherek/fix/beacon-christmas-chest-glow-frame
Render beacon beams, Christmas chests and glow frame maps like vanilla
2026-10-28 00:42:39 +00:00
rdbt a6dbc6651f Merge pull request #2837 from iLostName/pack-prompt-keeps-pointer
server packs: the download prompt acts as the screen it is
2026-10-28 00:42:18 +00:00
rdbt 4bb3de29e7 Merge pull request #2931 from elythepreciouscat/feat/vanilla-mouse-settings-26-3
Options: add Java 26.3 mouse settings
2026-10-28 00:41:15 +00:00
rdbt 786f67f77b Merge pull request #2967 from kacpherek/fix/blindness-sky-fog
Apply blindness fog and hide the blocked sky
2026-10-28 00:40:49 +00:00
rdbt 797852c62b Merge pull request #2840 from iLostName/client-skips-loot-tables
remote: a server's client doesn't load the loot tables on its first tick
2026-10-28 00:39:53 +00:00
rdbt 1f8f1055d5 Merge pull request #2852 from levzzz5154/fix/armadillo-roll-ball
A hit rolls the armadillo up into a ball (fixes #2759)
2026-10-28 00:39:36 +00:00
rdbt 0121466ed2 Merge pull request #2943 from kacpherek/fix/2928-mod-recipes-before-world-rest
Skip mod recipe tables until the world resources are loaded
2026-10-28 00:38:21 +00:00
rdbt 55b0545c11 Merge pull request #3017 from DanielGodriNeto/perf/sprite-pixels-shrink
perf(assets): drop the spare capacity of the sprite pixel buffer
2026-10-28 00:37:35 +00:00
rdbt 9e236c7914 Merge pull request #2834 from socksprox/fix/2747-eat-sounds
Play the player's own bites while eating on a remote server
2026-10-28 00:35:59 +00:00
rdbt 2d7fc7f000 Merge pull request #2958 from kacpherek/fix/extended-keybinds
Allow F13-F24 and other extended keys in key bindings
2026-10-28 00:35:39 +00:00
rdbt 644a399389 Merge pull request #2965 from kacpherek/fix/high-contrast-block-option
Enable the High Contrast Block Outline option
2026-10-28 00:34:16 +00:00
rdbt 9e31ce2f3d Merge pull request #2844 from Domanffe/fix/pixelify-cyrillic-o-p
Fonts: Pixelify Sans gets the Cyrillic О and П it was missing
2026-10-28 00:32:49 +00:00
rdbt 66d03b4d1c Merge pull request #2887 from masa-masa-0314/fix/creative-search-reset-on-open
Creative inventory: fix the search box keeping the last search after closing
2026-10-28 00:32:36 +00:00
rdbt 84d5574887 Merge pull request #2956 from gemakfy/fix/creative-f-offhand-crash
Fix creative inventory off-hand swap crash on Key F
2026-10-28 00:32:16 +00:00
Ludovic Marie 4118e026c3 Merge pull request #3014 from horizon-rust/docs/team-merge-rule
🤖 Agent: Merged by Ludovic's AI agent after verify: docs and lists only (Team merge rule, levzzz5154 trusted reviewer, licence holder), Ludovic's request 2026-10-28.
2026-10-28 00:56:21 +01:00
Ludovic MarieandClaude Opus 5.5 51b374121b Team merge rule: ordinary pull requests merge on CI, risky ones get needs-proof; levzzz5154 trusted
The Horizon Team merges ordinary pull requests once verify and parity pass; saves, network, world generation,
launcher updates, the harnesses and big changes wait for a local proof. Also names the licence holder with the
contributors.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 00:46:44 +01:00
DanielGodriNeto 9042746704 perf(assets): drop the spare capacity of the sprite pixel buffer 2026-10-27 20:39:24 -03:00
Ludovic Marie 795490271d Merge pull request #2988 from horizon-rust/refactor/be-layer-deps
🤖 Agent: verify passes on this head; full pr_ready green on the Mac mini (1340 tests, parity 89 affected / 0 failed). Move-only step B6 of the crate split (crate-split-1).
2026-10-27 23:27:53 +01:00
feeldev 9fe3ddfec3 feat(client): slim skins wear the slim player model
A skin whose metadata says model slim (PlayerSkin.Model.SLIM) now draws
with PlayerModel's slim layer (3-pixel arms), on other players, the local
player's model, mannequins and the first-person arm. Without a skin, the
profile's default skin (DefaultPlayerSkin.get) keeps its own model, so
Alex and the other slim defaults are slim again.

Fixes #2926
2026-10-27 17:26:01 -05:00
Ludovic MarieandClaude Opus 5.5 7a62c6f4a7 Give the capture test's fixture the pack prompt
capture() now asks the server pack prompt and the dialog; the fixture
gets both, and the prompt's own case: it keeps the pointer.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 22:46:23 +01:00
Ludovic MarieandClaude Opus 5.5 e0bb0881e8 item frames: glow frame map light is block coordinate 210, as vanilla's 15728850
15728850 is 0xF000D2: sky coordinate 240 and block coordinate 210, which
the packed light here stores as 240 | 210 << 8 (208 was 13 * 16).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 22:36:57 +01:00
Ludovic Marie e902b6fc18 Merge remote-tracking branch 'origin/main' into refactor/be-layer-deps 2026-10-27 22:34:05 +01:00
Ludovic MarieandClaude Opus 5.5 46c84a8b24 Merge main into #2891: main has run_command click events since
Keep main's ClickEvent::RunCommand and click_command (now pub(super) for
the book screen) instead of the branch's copies.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 22:27:03 +01:00
Ludovic Marie ae82c1b693 Merge remote-tracking branch 'origin/main' into cg24/f2891 2026-10-27 22:26:51 +01:00
Ludovic MarieandClaude Opus 5.5 501e543105 Creative on a server: the off hand's edits reach the server too
CreativeInventoryListener sends every InventoryMenu slot that changes,
the off hand (slot 45) included; only the 36 inventory slots were sent,
so Key F's off-hand stack stayed on this client (single player runs on
its server thread too).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 22:26:47 +01:00
Ludovic MarieandClaude Opus 5.5 466fef4486 Remote eating: the sound plays for items without consume particles too
Consumable.emitParticlesAndSounds gates only the particles on
hasConsumeParticles; the sound always plays.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 22:26:02 +01:00
Ludovic MarieandClaude Opus 5.5 e34f01715c Merge main into pack-prompt-keeps-pointer
main's draw_toasts takes the F1 flag now; the prompt still hides the
HUD, sign editor and book under it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 22:22:25 +01:00
Kacpherek ad61c30f46 Award target hit and honey slide advancements 2026-10-27 22:02:19 +01:00
Kacpherek f35ee16412 Apply blindness fog and hide the blocked sky 2026-10-27 22:02:19 +01:00
Kacpherek 5216403562 Enable the high contrast block outline option 2026-10-27 22:02:18 +01:00
Kacpherek 2aea6138b7 Allow extended keyboard keys in bindings 2026-10-27 22:02:17 +01:00
Kacpherek 5b41d9ebf3 Light glow frame maps at 15728850
A map in a glow item frame is drawn at light 15728850 (sky 15, block 13), not full bright, as ItemFrameRenderer does.
2026-10-27 21:59:46 +01:00
Kacpherek 52f3dcc5ef Use christmas chest textures on 24-26 December
Normal and trapped chests use the christmas sprites on 24-26 December, as ChestRenderer does, with the local date read once. minecraft:local_time reads the system time zone instead of UTC, so the Christmas chest item switches on the local date.
2026-10-27 21:59:46 +01:00
Kacpherek 7d99d23aef Fix beacon beam distance and cull at render distance
The beam width used x and y instead of the horizontal distance, so it was wrong for beacons to the north or south. Beams beyond render distance x 16 are skipped, as BeaconRenderer.shouldRender does. A beacon with Levels 0 still draws no beam.
2026-10-27 21:59:46 +01:00
Ludovic Marie 1feb28c045 Merge pull request #2750 from horizon-rust/refactor/horizon-client-input
🤖 Agent: Merged by Ludovic's AI agent (orchestrator), crate split route (c): head 64c174118 (main merged in, no rebase) passed the full tools/pr_ready.py on the Mac mini (2327 workspace tests, 398 Python, launcher 205; parity --changed 130 run, 0 failed) plus command_exec and save_roundtrip --fresh; verify green.
2026-10-27 21:58:06 +01:00
Kacpherek 7f6e8b634e Spin the level view and draw the Nausea swirl for the player
Nausea never reached the camera or the HUD: the swirl input was hard-coded to 0 and the level projection had no portal or Nausea spin. Add vanilla's spin (max of portal and Nausea intensity, scaled by Distortion Effects squared) and the Nausea blend fade (150 in, 20 out). Vanilla: GameRenderer.renderLevel, LocalPlayer.tickSpinningEffect, MobEffects NAUSEA blend.
2026-10-27 21:56:00 +01:00
Ludovic Marie 2e69ac9886 Merge remote-tracking branch 'origin/main' into refactor/be-layer-deps 2026-10-27 21:49:00 +01:00
asownerytandClaude Opus 5.5 c309d331ad Harnesses run offscreen on a Linux without vscreen
Without vscreen, save_roundtrip, world_reopen and spawn_loot stopped on
FileNotFoundError, and lan_e2e's games opened windows on the real
desktop: unfocused, the host paused before its world loaded and waited
at "Connecting to the server..." until wait-ready gave up. They now run
offscreen there, as on macOS, and lan_e2e's games also skip the pause
(as HORIZON_E2E_HEADLESS does).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 09:47:06 +13:00
Kacpherek 7acfc655e8 Match vanilla's op warning on item tooltips
The warning was missing its bold first line and skipped spawn eggs of op-only entities. Vanilla: ItemStack.OP_NBT_WARNING, BlockItem and SpawnEggItem.shouldPrintOpWarning (non-block items never warn).
2026-10-27 21:45:45 +01:00
Kacpherek d485c688d6 Let armor stands press pressure plates and tripwires
A non-marker armor stand is a LivingEntity, so it counts for the mob and weighted plates and trips wires. ArmorStand.isIgnoringBlockTriggers() is only true for markers.
2026-10-27 21:45:44 +01:00
Kacpherek 47ca0312df Skip mod recipe tables until the world resources are loaded
On a slow start the title is drawn while the world resources still load. A running mod that reads recipes built its tables every title frame and hit the unloaded resources, which panicked. Fixes #2928.
2026-10-27 21:38:40 +01:00
feeldev 78c028321c fix(client): the first-person arm and the player models wear the player's own skin
The empty first-person arm drew Steve's skin always; it now uses the local
player's skin, as the inventory's model does (PlayerRenderer.renderRightHand
reads AbstractClientPlayer.getSkin). Player models without a downloaded skin
wear their profile's default skin (DefaultPlayerSkin.get), as the player
list does. In single player the integrated server is offline-mode and sends
no textures, so the signed-in account's own profile is fetched from the
services (Minecraft.profileFuture) and gives the local player its skin.

Fixes #2921
2026-10-27 15:21:32 -05:00
Ludovic Marie 64c174118a Merge remote-tracking branch 'origin/main' into refactor/horizon-client-input 2026-10-27 21:13:23 +01:00
ElyTheCat 127434c46c Options: add Java 26.3 mouse settings 2026-10-27 23:21:09 +03:30
gemakfy 7b40465f85 Fix creative inventory off-hand swap crash on Key F 2026-10-27 22:38:19 +03:00
Ludovic MarieandClaude Opus 5.5 9861a1ba04 refactor: cut vault, command_block, sculk and brewing loose from gameplay
Move-only step B6 of the horizon-client crate split. These four modules
only reached gameplay modules for small helpers, so after this they depend
only on the render/world layer and can move into its crate whole, impls
included.

- save::float moves to horizon_client_core::nbt_value (save re-exports it).
- mobs::spawner::normalize_id moves to horizon_client_core::identifier
  (spawner re-exports it).
- containers::Providers moves to its own module, providers.rs
  (containers re-exports it).
- vault, command_block and sculk import number/text/float, item_nbt,
  Who and scoreboard from their real homes instead of through save and
  mobs; brewing imports Providers from providers.
- The vault, spawner and trial_spawner harnesses record the client-core
  files the moved helpers now live in.
- villager_brain's `impl Poi` (max_tickets, valid_range) moves next to
  the type in poi_sections, and its two methods become pub.

No behaviour change.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 19:59:32 +01:00
Ludovic Marie d9ab241406 Merge remote-tracking branch 'origin/main' into refactor/horizon-client-input 2026-10-27 19:48:28 +01:00
socksproxandDevin (SWE-2) e36cd17dd1 Books: page click events work, run_command ones included
Two gaps left every link in a written book dead. view_pages only parsed
compound page components, so the list form ComponentSerialization also
takes (and servers behind ViaVersion send) fell back to plain text and
lost its click events. And run_command was never parsed into a
ClickEvent, so even a parsed page's command links did nothing.

The book's click handler now follows Screen.defaultHandleClickEvent:
open_url through the link flow, run_command sent unsigned (client mods
first, as clickCommandAction), suggest_command into the chat box,
copy_to_clipboard, with chat's own click match gaining run_command the
same way.

Co-Authored-By: Devin (SWE-2) <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-27 18:06:53 +01:00
masa-masa-0314andZCode e6c1a91a65 creative: rebuild the search state when the screen opens
CreativeModeInventoryScreen is a fresh screen per open (init() makes a new
search box and a zeroed scrollbar; only selectedTab is static), but Horizon
keeps one Creative for the game's lifetime, so the last search stayed in
the box after closing the inventory. Clear the box, refocus it on the
search tab and reset the scrollbar at both open paths (local and remote).

Fixes #2873.

Co-Authored-By: ZCode <[email protected]>
2026-10-28 00:53:07 +08:00
sirrayiandDevin 62bb83857c Update rustls-webpki to 0.103.16 and webpki-roots to 1.0.10 (RUSTSEC-2026-0382, -0381)
cargo deny check advisories flags rustls-webpki 0.103.15 (distrusted emSign intermediates may be accepted, RUSTSEC-2026-0382) and webpki-roots 1.0.9 (bundles the distrusted emSign ECC Root CA - G3, RUSTSEC-2026-0381). Both are patch releases; every HTTPS request of the game, the launcher, horizon-server and the telemetry server goes through them (ureq -> rustls). horizon-mod-registry's lock already has the fixed versions.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-27 16:10:51 +00:00
leifiyooandClaude Haiku 5.5 de286f236f Count down Riptide's spin on a server's copy of a player
The host runs a guest's trident release on its copy of the guest, which
sets the spin for 20 ticks. The copy never ran the client's movement
tick, so the count never went down: the copy stayed in the spin pose
(eye 0.4), and every arrow or projectile the guest shot was spawned from
the leg until something else reset it.

LivingEntity.aiStep counts autoSpinAttackTicks down on the server too.
Count it down in the guest's server tick, before updatePlayerPose.

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
2026-10-27 16:30:53 +01:00
levzzzandminimax-m3.1-flash a7d0586d95 Merge upstream/main into fix/armadillo-roll-ball
Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>
2026-10-27 20:16:53 +05:00
Alexander KrylovandHermes Agent c3fb1656e6 Fonts: Pixelify Sans gets the Cyrillic О and П it was missing
Upstream maps U+043E (о) and U+043F (п) and not their capitals, so a Russian
heading drew О and П from the fallback font: they came out smooth and taller
than the pixel letters around them (screenshots in the PR).

Both come from the font's own drawing. О is the Latin O's glyph under the
Cyrillic codepoint, which is the same round shape this font gives о: its
lowercase is the O with the top shifted down. П is that weight's lowercase п
with everything above the baseline lifted to the cap height the font uses there
(Н's top), 175 units in SemiBold, 177 in Medium and 171 in Bold, since each
weight draws its own metrics. Both now sit on the bbox of the O and Н of their
own weight.

О keeps the O's hinting instructions; П is a new outline with none, which a
pixel design does not need at the sizes the launcher draws it. GDEF, GPOS and
GSUB are byte-identical to the files they replace.

Co-Authored-By: Hermes Agent (Nous Research) <[email protected]>
2026-10-27 17:19:10 +03:00
socksproxandDevin (SWE-2 High) b98633b677 Play the player's own bites while eating on a remote server
On a remote (non-Horizon) server the use-item tick spawned the crumb
particles but never played the consumable's eat sound, so only the burp
at the end was heard. Call emit_consume for the periodic emit, as
tick_used_item does on the player's own server, so the bites sound too.

Fixes #2747.

Co-Authored-By: Devin (SWE-2 High) <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-27 15:14:34 +01:00
levzzzandminimax-m3.1-flash 3c7942d2bd Test that a hit rolls the armadillo up into a ball
An armadillo hit by a living thing curls up: Armadillo.actuallyHurt
rolls it up, Armadillo.rollUp plays entity.armadillo.roll and switches
the state to ROLLING, and ArmadilloState.shouldHideInShell then shows
the shell instead of the armadillo. The roll itself is on main (#2535),
but nothing held it in place, so this test pins it: a hit rolls the
armadillo up, the roll plays its sound and the model shows the shell
(#2759).

Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>
2026-10-27 19:14:31 +05:00
iLostName de89e758cc remote: a server's client doesn't load the loot tables on its first tick 2026-10-27 14:57:27 +01:00
iLostName 5f5a43fd54 focus loss doesn't open the pause menu over the pack prompt or a server dialog 2026-10-27 14:47:53 +01:00
sirrayiandDevin c0bf77f3c3 Telemetry server: count IPv6 clients by /64 in the rate limits
An IPv6 client rotating addresses inside its /64 got a fresh bucket for every address, so the per-address limits on batches, live pings, hub votes, reports and suggestions didn't hold, and the bucket maps grew until the daily upkeep. Key them by /64, as horizon-relay already does.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-27 13:45:27 +00:00
iLostName 86474679db server packs: the prompt takes the open screen's place, as PackConfirmScreen does 2026-10-27 14:26:17 +01:00
iLostName 560aa61e91 server packs: the download prompt keeps the pointer and stops the walking behind it 2026-10-27 14:05:45 +01:00
Ludovic MarieandClaude Opus 5.5 0d554de016 parity: game_loop records mouse, window and keymapping from the input crate
The harness hashed crates/horizon-client/src/{mouse,window,keymapping}.rs,
which this branch moves to horizon-client-input, so CI's parity job failed
with FileNotFoundError before running it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 13:48:09 +01:00
Ludovic Marie 1db0eda4ae Merge remote-tracking branch 'origin/main' into refactor/horizon-client-input 2026-10-27 11:33:45 +01:00
Ludovic Marie d8cc59dd55 Merge remote-tracking branch 'origin/main' into refactor/horizon-client-input
# Conflicts:
#	crates/horizon-client/src/lib.rs
2026-10-27 11:10:57 +01:00
Ludovic MarieandClaude Opus 5.5 beac63849c refactor: move the client's input modules into horizon-client-input
Key bindings and names, toggle keys, mouse options, controller settings,
the clipboard and window options move to their own crate, re-exported under
their old paths. Their unit tests now rebuild in about a second after an
edit instead of rebuilding the client lib's test binary.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 08:42:36 +01:00
92 changed files with 1158 additions and 344 deletions

No files matched your search

+1
View File
@@ -6,3 +6,4 @@
kacpherek # added by Ludovic, 2026-10-27
rdbtCVS # added by Ludovic, 2026-10-27
Online5277 # added by Ludovic, 2026-10-27 (his top three by merged pull requests)
levzzz5154 # added by Ludovic, 2026-10-28 (Horizon Team; author of horizon-mixins)
+3 -2
View File
@@ -39,6 +39,7 @@ Game workspace (root `Cargo.toml`); `launcher/` and `crates/horizon-telemetry-se
| `horizon-items` | Blocks and items below gameplay: `blocks.rs` (block facts), `items.rs`, `item_data.rs` (components), `enchant.rs`, `cooldowns.rs`, `bundle.rs`, `item_nbt.rs`. Re-exported as `horizon_client::items`... |
| `horizon-command` | Command parsing (Brigadier): the vanilla tree, argument types, selectors, suggestions, the tree's network form. Re-exported as `horizon_client::command` |
| `horizon-client-core` | The client lib's bottom layer: `text.rs`, `lang.rs`, `snbt.rs`, `nbt_path.rs`, `component.rs`, `scoreboard.rs`, `bossbar.rs`, `gamerules.rs`, `block_entity_nbt.rs`, registry/particle/sound tables. Re-exported under the old paths (`horizon_client::text`...) |
| `horizon-client-input` | Key bindings and key names, toggle keys, mouse options, controller settings and glyphs, the clipboard, window options (`keybinds.rs`, `gamepad.rs`, `mouse.rs`, `window.rs`...). Re-exported under the old paths |
| `horizon-world` | Vanilla world data with no gameplay loop: NBT, regions, chunks, `datafix/`, `worldgen/`, `level/` (server-side block behaviour, ticks, redstone), entity physics, items, effects, food, damage |
| `horizon-core` | Simulation primitives: Java `Random`s, `jmath` (HotSpot-exact `Math`), positions, noise, density, paletted storage, `version.rs` (the pinned version) |
| `horizon-protocol` | Network protocol 777: codecs, framing, encryption, `packets/{handshake,status,login,configuration,play}.rs`, `entity_data.rs` |
@@ -114,7 +115,7 @@ python3 tools/agent_control_e2e.py # horizon-ctl / MCP end to end
| Subsystem | Files |
|---|---|
| Game loop, tick, frame | `game.rs`: `fn run`, `impl ApplicationHandler for Game` (`window_event`, `about_to_wait`), `fn tick`, `fn tick_world`, `fn render`; L: `clock.rs`, `window.rs`, `spans.rs` |
| Game loop, tick, frame | `game.rs`: `fn run`, `impl ApplicationHandler for Game` (`window_event`, `about_to_wait`), `fn tick`, `fn tick_world`, `fn render`; L: `clock.rs`, `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` |
| Chunks, streaming, light | L: `streaming.rs` (worker pool: generate, light, mesh), `world.rs`, `chunk.rs`, `chunk_map.rs`, `light.rs`, `mesher.rs` + `mesher/`, `vanillagen.rs` (binds horizon-world's generator), `lod.rs` + `lod/`, `ticket_levels.rs`; G: `world_loading.rs`, `postprocess.rs`; game.rs `fn update_world` |
| World generation | `crates/horizon-world/src/worldgen/` (`generator*.rs`, `feature_graph.rs`, `structure/`, `carver/`) |
@@ -130,7 +131,7 @@ python3 tools/agent_control_e2e.py # horizon-ctl / MCP end to end
| Integrated server | G: `integrated.rs` (the server thread), `levels.rs` (one level per dimension, `fn tick_server`), `server_travel.rs` |
| Dedicated server | G: `dedicated.rs`, `dedicated/status.rs`; crates `horizon-server-files`, `horizon-server` |
| Save and load | L: `save.rs` (level.dat, regions, playerdata), `entity_nbt.rs`, `options_file.rs`; `horizon-client-core/src/block_entity_nbt.rs`; G: `saving.rs`, `persist.rs` (entities); `crates/horizon-world/src/{region.rs,nbt.rs,datafix/}` |
| Input | game.rs `fn window_event`, `fn key_pressed`, `fn play_mouse`; L: `keybinds.rs`, `keymapping.rs`, `key_names.rs`, `mouse.rs`, `input.rs`, `interaction_input.rs`, `gamepad.rs`, `osk.rs`, `clipboard.rs`; G: `pad.rs`, `pad_menu.rs` |
| Input | game.rs `fn window_event`, `fn key_pressed`, `fn play_mouse`; crate `horizon-client-input` (`keybinds.rs`, `keymapping.rs`, `key_names.rs`, `mouse.rs`, `input.rs`, `interaction_input.rs`, `gamepad.rs`, `clipboard.rs`); L: `osk.rs`; G: `pad.rs`, `pad_menu.rs` |
| Audio | L: `audio.rs` (mixer), `sound_engine.rs`, `sound_events.rs`, `music.rs`, `ambience.rs`; G: `music.rs`, `subtitle_hud.rs` |
| Resource packs | L: `packs.rs` + `packs/`, `pack_screen.rs`, `atlas_sources.rs`, `sprites.rs`, `mipmap.rs`, `model_library.rs`, `font.rs`; `horizon-client-core/src/lang.rs`; crate `horizon-assets`; G: `packs.rs` |
| Agent control | L: `control.rs` (socket), `bin/horizon-ctl/`; G: `control.rs`, `agent.rs`, `events.rs`, `ui_control.rs` ([docs/agent-control.md](docs/agent-control.md)) |
+3
View File
@@ -86,6 +86,9 @@ configuration and toolchain changes select the whole workspace; CI also keeps it
release notes), `skip-release` when players see no change (tooling, docs, tests), nothing for a fix.
`release-now` is for crash fixes that shouldn't wait. pr_ready suggests one.
Who reviews and merges: the Horizon Team, on CI for ordinary pull requests; risky ones (saves, network, world
generation, the launcher's updates) wait for a local proof first ([merging](docs/release-flow.md#merging)).
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).
Generated
+18 -6
View File
@@ -1370,7 +1370,6 @@ name = "horizon-client"
version = "0.1.0"
dependencies = [
"ab_glyph",
"arboard",
"bytemuck",
"chrono",
"cpal",
@@ -1380,6 +1379,7 @@ dependencies = [
"horizon-assets",
"horizon-bench",
"horizon-client-core",
"horizon-client-input",
"horizon-command",
"horizon-core",
"horizon-dualsense",
@@ -1422,6 +1422,18 @@ dependencies = [
"serde_json",
]
[[package]]
name = "horizon-client-input"
version = "0.1.0"
dependencies = [
"arboard",
"horizon-client-core",
"horizon-dualsense",
"horizon-theme",
"png",
"winit",
]
[[package]]
name = "horizon-command"
version = "0.1.0"
@@ -3194,9 +3206,9 @@ dependencies = [
[[package]]
name = "rustls-webpki"
version = "0.103.15"
version = "0.103.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281"
dependencies = [
"ring",
"rustls-pki-types",
@@ -4374,14 +4386,14 @@ version = "0.26.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9"
dependencies = [
"webpki-roots 1.0.9",
"webpki-roots 1.0.10",
]
[[package]]
name = "webpki-roots"
version = "1.0.9"
version = "1.0.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a"
checksum = "1c1c8e20c8a38fc7d65abfe74eb7280e4aa667e8eec8bc84996d2874810748d9"
dependencies = [
"rustls-pki-types",
]
+1
View File
@@ -8,6 +8,7 @@ members = [
"crates/horizon-world",
"crates/horizon-client",
"crates/horizon-client-core",
"crates/horizon-client-input",
"crates/horizon-command",
"crates/horizon-items",
"crates/horizon-server-files",
+1 -1
View File
@@ -1,6 +1,6 @@
MIT License
Copyright (c) 2026 Ludovic
Copyright (c) 2026 Ludovic Marie and the Horizon contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
@@ -16,3 +16,20 @@ pub fn parse_identifier(raw: &str) -> Option<String> {
});
(ns_ok && path_ok).then(|| format!("{ns}:{path}"))
}
/// `Identifier.CODEC` parsing an entity id: lowercase namespace and path
/// characters, `minecraft` when the namespace is left out.
pub fn normalize_id(s: &str) -> Option<String> {
let (ns, path) = match s.split_once(':') {
Some((ns, path)) => (if ns.is_empty() { "minecraft" } else { ns }, path),
None => ("minecraft", s),
};
let ok = |c: char, slash: bool| {
c.is_ascii_lowercase()
|| c.is_ascii_digit()
|| matches!(c, '_' | '-' | '.')
|| slash && c == '/'
};
(ns.chars().all(|c| ok(c, false)) && path.chars().all(|c| ok(c, true)))
.then(|| format!("{ns}:{path}"))
}
@@ -22,3 +22,11 @@ pub fn number(tag: Option<&Tag>) -> Option<i64> {
_ => return None,
})
}
/// Any numeric tag as f64, keeping a float's or double's fraction.
pub fn float(tag: Option<&Tag>) -> Option<f64> {
Some(match tag? {
Tag::Float(b) => f64::from(f32::from_bits(*b)),
Tag::Double(b) => f64::from_bits(*b),
other => number(Some(other))? as f64,
})
}
+23
View File
@@ -0,0 +1,23 @@
[package]
name = "horizon-client-input"
version = "0.1.0"
edition.workspace = true
license.workspace = true
rust-version = "1.90"
description = "The client's input layer: key bindings and their names, mouse, controllers, clipboard and window options"
[dependencies]
horizon-client-core = { path = "../horizon-client-core" }
winit = "0.30.13"
# The window icon.
horizon-theme = { path = "../horizon-theme" }
png = "0.18"
arboard = { version = "3.6.1", default-features = false, features = ["wayland-data-control"] }
# The DualSense's trigger effects (the `controllers` feature).
horizon-dualsense = { path = "../horizon-dualsense", optional = true }
[features]
controllers = ["dep:horizon-dualsense"]
[lints]
workspace = true
File renamed without changes.
@@ -145,6 +145,22 @@ const KEYS: &[(&str, KeyCode)] = &[
("key.keyboard.period", KeyCode::Period),
("key.keyboard.slash", KeyCode::Slash),
("key.keyboard.caps.lock", KeyCode::CapsLock),
("key.keyboard.world.1", KeyCode::IntlBackslash),
("key.keyboard.international1", KeyCode::IntlRo),
("key.keyboard.international3", KeyCode::IntlYen),
("key.keyboard.lang1", KeyCode::Lang1),
("key.keyboard.lang2", KeyCode::Lang2),
("key.keyboard.lang3", KeyCode::Lang3),
("key.keyboard.lang4", KeyCode::Lang4),
("key.keyboard.lang5", KeyCode::Lang5),
("key.keyboard.help", KeyCode::Help),
("key.keyboard.select", KeyCode::Select),
("key.keyboard.again", KeyCode::Again),
("key.keyboard.undo", KeyCode::Undo),
("key.keyboard.cut", KeyCode::Cut),
("key.keyboard.copy", KeyCode::Copy),
("key.keyboard.paste", KeyCode::Paste),
("key.keyboard.find", KeyCode::Find),
("key.keyboard.f1", KeyCode::F1),
("key.keyboard.f2", KeyCode::F2),
("key.keyboard.f3", KeyCode::F3),
@@ -157,6 +173,18 @@ const KEYS: &[(&str, KeyCode)] = &[
("key.keyboard.f10", KeyCode::F10),
("key.keyboard.f11", KeyCode::F11),
("key.keyboard.f12", KeyCode::F12),
("key.keyboard.f13", KeyCode::F13),
("key.keyboard.f14", KeyCode::F14),
("key.keyboard.f15", KeyCode::F15),
("key.keyboard.f16", KeyCode::F16),
("key.keyboard.f17", KeyCode::F17),
("key.keyboard.f18", KeyCode::F18),
("key.keyboard.f19", KeyCode::F19),
("key.keyboard.f20", KeyCode::F20),
("key.keyboard.f21", KeyCode::F21),
("key.keyboard.f22", KeyCode::F22),
("key.keyboard.f23", KeyCode::F23),
("key.keyboard.f24", KeyCode::F24),
("key.keyboard.print.screen", KeyCode::PrintScreen),
("key.keyboard.scroll.lock", KeyCode::ScrollLock),
("key.keyboard.pause", KeyCode::Pause),
@@ -188,6 +216,36 @@ const KEYS: &[(&str, KeyCode)] = &[
("key.keyboard.keypad.0", KeyCode::Numpad0),
("key.keyboard.keypad.period", KeyCode::NumpadDecimal),
("key.keyboard.keypad.equal", KeyCode::NumpadEqual),
("key.keyboard.keypad.comma", KeyCode::NumpadComma),
("key.keyboard.keypad.backspace", KeyCode::NumpadBackspace),
(
"key.keyboard.keypad.left.parenthesis",
KeyCode::NumpadParenLeft,
),
(
"key.keyboard.keypad.right.parenthesis",
KeyCode::NumpadParenRight,
),
("key.keyboard.keypad.hash", KeyCode::NumpadHash),
(
"key.keyboard.keypad.memory.store",
KeyCode::NumpadMemoryStore,
),
(
"key.keyboard.keypad.memory.recall",
KeyCode::NumpadMemoryRecall,
),
(
"key.keyboard.keypad.memory.clear",
KeyCode::NumpadMemoryClear,
),
("key.keyboard.keypad.memory.add", KeyCode::NumpadMemoryAdd),
(
"key.keyboard.keypad.memory.subtract",
KeyCode::NumpadMemorySubtract,
),
("key.keyboard.keypad.clear", KeyCode::NumpadClear),
("key.keyboard.keypad.clear.entry", KeyCode::NumpadClearEntry),
("key.keyboard.menu", KeyCode::ContextMenu),
("key.keyboard.left.control", KeyCode::ControlLeft),
("key.keyboard.left.shift", KeyCode::ShiftLeft),
+14
View File
@@ -0,0 +1,14 @@
//! The client's input layer, below gameplay and screens: key bindings and their
//! names, toggle keys, mouse settings, controllers, the clipboard and window
//! options. horizon-client re-exports each module under its old path
//! (`horizon_client::keybinds`, ...).
#![forbid(unsafe_code)]
pub mod clipboard;
pub mod gamepad;
pub mod input;
pub mod interaction_input;
pub mod key_names;
pub mod keybinds;
pub mod keymapping;
pub mod mouse;
pub mod window;
File renamed without changes.
+2 -2
View File
@@ -10,6 +10,7 @@ description = "Horizon native development client"
[dependencies]
horizon-assets = { path = "../horizon-assets" }
horizon-client-core = { path = "../horizon-client-core" }
horizon-client-input = { path = "../horizon-client-input" }
horizon-command = { path = "../horizon-command" }
horizon-items = { path = "../horizon-items" }
horizon-core = { path = "../horizon-core" }
@@ -55,7 +56,6 @@ horizon-theme = { path = "../horizon-theme" }
# the anonymous "I'm playing" ping the game sends itself (`heartbeat.rs`).
horizon-telemetry = { path = "../horizon-telemetry", default-features = false, features = ["live"] }
horizon-bench = { path = "../horizon-bench" }
arboard = { version = "3.6.1", default-features = false, features = ["wayland-data-control"] }
zip = { version = "2", default-features = false, features = ["deflate"] }
# Controllers (evdev + libudev on Linux, IOKit on macOS, Windows.Gaming.Input); the `controllers` feature.
gilrs = { version = "0.11", optional = true }
@@ -90,7 +90,7 @@ default = ["audio", "controllers"]
# Linux: the server build (`--no-default-features`, released as horizon-engine; docs/server.md).
audio = ["dep:cpal"]
# Controllers: gilrs, and the DualSense's own output. Without it only `horizon-ctl pad`'s virtual pad.
controllers = ["dep:gilrs", "dep:horizon-dualsense"]
controllers = ["dep:gilrs", "dep:horizon-dualsense", "horizon-client-input/controllers"]
# Runs code mods (wasmtime and cranelift: a quarter of a clean build). On in every release build
# (release.yml); the tests and probes don't need it. Content mods load without it.
mods = ["horizon-mods/runtime"]
+2
View File
@@ -218,6 +218,8 @@ impl RenderAssets {
sprites.insert(root, &format!("minecraft:block/destroy_stage_{stage}"))?;
}
sprites.finish();
// `finish` grows the buffer by doubling: keep only the bytes in use.
sprites.pixels.shrink_to_fit();
Ok(Self {
sprite_size: sprites.size,
sprite_minimum_size: sprites.minimum_size,
@@ -35,6 +35,25 @@ pub fn op_warning(stack: &crate::items::ItemStack) -> bool {
data(stack).is_some_and(|(id, _)| OP_ONLY.contains(&id.as_str()))
}
/// `SpawnEggItem.shouldPrintOpWarning` for a player with game master
/// permission: a spawn egg carries data of an op-only entity type
/// (`EntityTypes.OP_ONLY_CUSTOM_DATA`).
pub fn spawn_egg_op_warning(stack: &crate::items::ItemStack) -> bool {
stack.item.ends_with("_spawn_egg")
&& matches!(
stack
.data()
.and_then(|d| d.other("minecraft:entity_data"))
.and_then(|data| data.get("id")),
Some(Tag::String(id)) if matches!(
id.to_string_lossy().as_str(),
"minecraft:falling_block"
| "minecraft:command_block_minecart"
| "minecraft:spawner_minecart"
)
)
}
/// `Spawner.getSpawnEntityDisplayName` (`SpawnData.entity.id` of the item's
/// data, which `ItemStack.addDetailsToTooltip` reads for spawners and trial
/// spawners alike): the entity type a spawner item spawns.
@@ -97,6 +97,16 @@ pub fn diffuse(normal: Vec3, lights: (Vec3, Vec3)) -> f32 {
pub fn vertex_light(packed: u8) -> u32 {
(u32::from(packed >> 4) * 16) | ((u32::from(packed & 15) * 16) << 8)
}
/// `ChestRenderer.xmasTextures`: `SpecialDates.isExtendedChristmas` (24 to 26
/// December in the local time zone), read once when the renderer is created.
fn extended_christmas() -> bool {
use chrono::Datelike;
static XMAS: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
*XMAS.get_or_init(|| {
let now = chrono::Local::now();
now.month() == 12 && (24..=26).contains(&now.day())
})
}
fn pack_color(rgb: u32, alpha: u32, brightness: f32) -> u32 {
let c = |shift: u32| (((rgb >> shift) & 255) as f32 * brightness) as u32;
alpha << 24 | c(0) << 16 | c(8) << 8 | c(16)
@@ -1173,11 +1183,25 @@ impl BlockEntityRenderer {
}
}
Kind::Chest {
block,
texture,
part,
y_rot,
..
} => {
// ChestRenderer.getChestMaterial: normal and trapped chests use the christmas
// sprites on 24-26 December. Copper and ender chests keep theirs.
let texture = if matches!(block.as_str(), "chest" | "trapped_chest")
&& extended_christmas()
{
match part {
ChestPart::Single => "entity/chest/christmas",
ChestPart::Left => "entity/chest/christmas_left",
ChestPart::Right => "entity/chest/christmas_right",
}
} else {
*texture
};
let model = match part {
ChestPart::Single => "chest",
ChestPart::Left => "double_chest_left",
+1 -1
View File
@@ -4,8 +4,8 @@
//! `BrewingStandBlockEntity.serverTick`.
pub use crate::chunk::block_entity::BrewingStand;
use crate::{
containers::Providers,
items::{ItemStack, Items},
providers::Providers,
};
use serde_json::Value;
use std::{collections::HashSet, fs, path::Path};
+21
View File
@@ -772,6 +772,27 @@ pub fn bob_matrix(state: &BobState, damage_tilt_strength: f64, bob_view_option:
pose
}
/// The spinning effect `GameRenderer.renderLevel` multiplies into the level's
/// projection after the bob: a skew about a tilted axis, from a portal's or
/// Nausea's intensity (already scaled by Distortion Effects) and the angle in
/// degrees. The first-person hand keeps its own projection without it.
pub fn spinning_effect_matrix(intensity: f32, angle_degrees: f32) -> glam::Mat4 {
if intensity <= 0.0 {
return glam::Mat4::IDENTITY;
}
let mut skew = 5.0 / (intensity * intensity + 5.0) - intensity * 0.04;
skew *= skew;
let axis = glam::Vec3::new(
0.0,
std::f32::consts::SQRT_2 / 2.0,
std::f32::consts::SQRT_2 / 2.0,
);
let angle = angle_degrees * DEG_TO_RAD;
glam::Mat4::from_axis_angle(axis, angle)
* glam::Mat4::from_scale(glam::Vec3::new(1.0 / skew, 1.0, 1.0))
* glam::Mat4::from_axis_angle(axis, -angle)
}
#[cfg(test)]
mod tests {
#[test]
+5 -4
View File
@@ -2,8 +2,9 @@
//! count comparators read, the output it keeps, and the powered, automatic
//! and condition flags `CommandBlock.tick` runs on.
pub use crate::chunk::block_entity::CommandBlock;
use crate::{
save::{number, text},
use horizon_client_core::{
nbt_value::{number, text},
scoreboard,
text::Text,
};
use horizon_world::nbt::Tag;
@@ -108,7 +109,7 @@ impl CommandBlock {
last_output: track_output
.then(|| tag.get("LastOutput"))
.flatten()
.map(|t| crate::scoreboard::comp_text(&crate::scoreboard::tag_to_json(t))),
.map(|t| scoreboard::comp_text(&scoreboard::tag_to_json(t))),
update_last_execution,
last_execution: if update_last_execution {
number(tag.get("LastExecution")).unwrap_or(-1)
@@ -164,7 +165,7 @@ impl CommandBlock {
pub fn name(&self) -> Text {
self.custom_name.as_ref().map_or_else(
|| Text::literal("@"),
|t| crate::scoreboard::comp_text(&crate::scoreboard::tag_to_json(t)),
|t| scoreboard::comp_text(&scoreboard::tag_to_json(t)),
)
}
}
+2 -118
View File
@@ -9,8 +9,7 @@ use crate::{
crafting::Recipes,
items::{ItemStack, Items},
};
use serde_json::Value;
use std::{collections::HashMap, fs, path::Path, sync::Arc};
use std::{collections::HashMap, sync::Arc};
impl FurnaceKind {
pub fn recipe_type(self) -> &'static str {
@@ -380,122 +379,7 @@ impl BlockEntity {
}
}
}
/// Evaluator for `context_int_provider` / `context_float_provider` values.
pub struct Providers {
ints: HashMap<String, Value>,
floats: HashMap<String, Value>,
}
fn load_dir(dir: &Path, prefix: &str, out: &mut HashMap<String, Value>) {
let Ok(entries) = fs::read_dir(dir) else {
return;
};
for e in entries.flatten() {
let p = e.path();
let name = p
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("")
.to_owned();
if p.is_dir() {
load_dir(&p, &format!("{prefix}{name}/"), out);
} else if let Ok(bytes) = fs::read(&p)
&& let Ok(v) = serde_json::from_slice(&bytes)
{
out.insert(format!("minecraft:{prefix}{name}"), v);
}
}
}
impl Providers {
pub fn load(root: &Path) -> Self {
let mut ints = HashMap::new();
let mut floats = HashMap::new();
load_dir(
&root.join("data/minecraft/context_int_provider"),
"",
&mut ints,
);
load_dir(
&root.join("data/minecraft/context_float_provider"),
"",
&mut floats,
);
Self { ints, floats }
}
fn eval(v: &Value, fast: bool, table: &HashMap<String, Value>, depth: u32) -> f64 {
if depth > 16 {
return 0.0;
}
match v {
Value::Number(n) => n.as_f64().unwrap_or(0.0),
Value::String(name) => table
.get(name)
.map_or(0.0, |v| Self::eval(v, fast, table, depth + 1)),
Value::Object(o) => {
let f =
|k: &str| Self::eval(o.get(k).unwrap_or(&Value::Null), fast, table, depth + 1);
match o.get("type").and_then(Value::as_str).unwrap_or("") {
"minecraft:div" => {
let r = f("right");
if r == 0.0 {
0.0
} else {
(f("left") / r).trunc()
}
}
"minecraft:mul" => f("left") * f("right"),
"minecraft:add" | "minecraft:sum" => f("left") + f("right"),
"minecraft:sub" => f("left") - f("right"),
"minecraft:constant" => f("value"),
"minecraft:conditional" => {
// The only condition in the pinned data: block/fast_cooking.
let condition = o.get("condition").and_then(Value::as_str)
== Some("minecraft:block/fast_cooking");
if condition && fast {
f("on_true")
} else {
f("on_false")
}
}
_ => 0.0,
}
}
_ => 0.0,
}
}
/// A plain `context_int_provider` value or reference (no context conditions).
pub fn int_value(&self, v: &Value) -> i32 {
Self::eval(v, false, &self.ints, 0) as i32
}
/// A plain `context_float_provider` value or reference; `default` when absent.
pub fn float_value(&self, v: &Value, default: f32) -> f32 {
if v.is_null() {
default
} else {
Self::eval(v, false, &self.floats, 0) as f32
}
}
#[cfg(test)]
pub fn empty() -> Self {
Self {
ints: HashMap::new(),
floats: HashMap::new(),
}
}
pub fn burn_time(&self, items: &Items, stack: &ItemStack, fast: bool) -> i32 {
items
.get(&stack.item)
.and_then(|i| i.fuel.as_ref())
.map_or(0, |(time, _)| Self::eval(time, fast, &self.ints, 0) as i32)
}
pub fn speed(&self, items: &Items, stack: &ItemStack, fast: bool) -> f32 {
items
.get(&stack.item)
.and_then(|i| i.fuel.as_ref())
.map_or(1.0, |(_, speed)| {
Self::eval(speed, fast, &self.floats, 0) as f32
})
}
}
pub use crate::providers::Providers;
fn remainder(item: &str) -> Option<&'static str> {
match item {
"minecraft:lava_bucket" | "minecraft:water_bucket" | "minecraft:milk_bucket" => {
+25 -3
View File
@@ -355,6 +355,19 @@ impl Creative {
pub fn search_items(&self) -> &[ItemStack] {
&self.search_items
}
/// Opening the screen: vanilla rebuilds CreativeModeInventoryScreen, whose
/// init() makes a fresh search box and a zeroed scrollbar; only the tab
/// persists (its static selectedTab). selectTab on the reopened screen
/// takes the oldTab != tab branch, so it empties and refocuses the box.
pub fn reopen(&mut self) {
self.search.set_value("");
self.release();
self.scroll = 0.0;
self.search.focused = self.tab().search;
if self.search.focused {
self.search.reset_blink();
}
}
/// Indices into `search_items` that CreativeModeInventoryScreen.refreshSearchResults
/// shows for `term`: the whole tab when empty, the tag tree after `#`, else
/// the name tree, both over vanilla's suffix arrays (substring matches,
@@ -575,6 +588,8 @@ pub enum DisplayClick {
Nothing,
/// SWAP: the hotbar slot gets a full stack of the item.
Hotbar(usize, ItemStack),
/// SWAP with button 40 (Key F): the off-hand gets a full stack of the item.
Offhand(ItemStack),
/// THROW: a copy of the item is dropped.
Drop(ItemStack),
}
@@ -596,8 +611,11 @@ pub fn click_display(
match input {
Input::Swap => {
return match shown {
Some(s) => DisplayClick::Hotbar(button as usize, copy(s, max(s))),
None => DisplayClick::Nothing,
Some(s) if button == 40 => DisplayClick::Offhand(copy(s, max(s))),
Some(s) if (0..9).contains(&button) => {
DisplayClick::Hotbar(button as usize, copy(s, max(s)))
}
Some(_) | None => DisplayClick::Nothing,
};
}
Input::Clone => {
@@ -720,11 +738,15 @@ mod tests {
display(Some(stack("ender_pearl", 9)), 1, Pickup).0,
Some(stack("ender_pearl", 8))
);
// Number keys fill a hotbar slot, Q drops copies, pick block clones.
// Number keys fill a hotbar slot, F fills off-hand, Q drops copies, pick block clones.
assert_eq!(
display(None, 3, Swap).1,
DisplayClick::Hotbar(3, stack("stone", 64))
);
assert_eq!(
display(None, 40, Swap).1,
DisplayClick::Offhand(stack("stone", 64))
);
assert_eq!(
display(None, 0, Throw).1,
DisplayClick::Drop(stack("stone", 1))
+4
View File
@@ -29,6 +29,10 @@ impl EffectInstance {
fn infinite(&self) -> bool {
self.duration == -1
}
/// `MobEffectInstance.endsWithin`.
pub fn ends_within(&self, ticks: i32) -> bool {
!self.infinite() && self.duration <= ticks
}
fn shorter_than(&self, other: &Self) -> bool {
!self.infinite() && (self.duration < other.duration || other.infinite())
}
+101 -29
View File
@@ -670,15 +670,22 @@ impl EntityObstructionWorld for BuildingObstruction {
}
}
impl GameLevel<'_> {
/// The mobs that trigger plates and tripwires: living entities that don't
/// ignore block triggers (`Entity.isIgnoringBlockTriggers`: bats; armor
/// stands are not in `mobs`).
/// The living entities that trigger plates and tripwires and don't ignore
/// block triggers (`Entity.isIgnoringBlockTriggers`: bats and marker armor
/// stands). Armor stands live apart from `mobs`.
fn mob_trigger_boxes(&self) -> impl Iterator<Item = Aabb> + '_ {
self.blockers
.mobs
.iter()
.filter(|m| !m.removed && m.species() != horizon_client::mobs::Species::Bat)
.map(Mob::bounds)
.chain(
self.blockers
.armor_stands
.iter()
.filter(|s| !s.marker)
.map(|s| s.bounds()),
)
}
/// The non-living entities that trigger plates and tripwires (boats,
/// crystals, primed TNT, falling blocks, item entities, experience orbs and
@@ -1177,6 +1184,8 @@ struct WorldRest {
end_environment: Environment,
behaviours: Behaviours,
player_arm: Option<horizon_client::mobs::Part>,
/// The slim model's right arm (3 pixels wide).
player_arm_slim: Option<horizon_client::mobs::Part>,
player_type: Option<Arc<horizon_client::mobs::MobType>>,
/// `SpinAttackEffectModel` (Riptide's spinning boxes).
spin_attack: Option<horizon_client::mobs::Part>,
@@ -1249,8 +1258,12 @@ impl Resources {
fn threads(&self) -> usize {
self.core.threads
}
fn player_arm(&self) -> &Option<horizon_client::mobs::Part> {
&self.rest().player_arm
fn player_arm(&self, slim: bool) -> &Option<horizon_client::mobs::Part> {
if slim {
&self.rest().player_arm_slim
} else {
&self.rest().player_arm
}
}
fn player_type(&self) -> &Option<Arc<horizon_client::mobs::MobType>> {
&self.rest().player_type
@@ -2912,7 +2925,13 @@ impl Game<'_> {
behaviours: self.res.behaviours(),
items: self.res.items(),
recipes: self.res.recipes(),
loot: horizon_client::structure_blocks::loot_data(&self.options.resources),
// A client rolls no loot (LootTable.getRandomItems needs a
// ServerLevel): it doesn't load the tables on its game thread.
loot: if self.remote.is_some() {
None
} else {
horizon_client::structure_blocks::loot_data(&self.options.resources)
},
registry: self.pack.assets.registry,
rules,
difficulty: i32::from(self.ui.difficulty),
@@ -5259,8 +5278,13 @@ impl Game<'_> {
MotionEvent::Snowflake { position, velocity } => self.snowflake(position, velocity),
MotionEvent::EnterBlock { state } => self.trigger_enter_block(state),
MotionEvent::HoneySlide {
sound, particles, ..
pos,
sound,
particles,
} => {
if self.world.game_time % 20 == 0 {
self.trigger_honey_slide([pos.x, pos.y, pos.z]);
}
if sound {
self.audio
.play("minecraft:block.honey_block.slide", Some(feet), 1.0, 1.0);
@@ -5624,8 +5648,10 @@ impl Game<'_> {
if self.lan_acting.is_some() {
return;
}
// MouseHandler.grabMouse never captures an inactive window.
if capture && !self.focused {
// MouseHandler.grabMouse never captures an inactive window, and vanilla
// grabs only on `setScreen(null)`: the pack prompt and server dialogs
// are screens over the world that keep the pointer.
if capture && (!self.focused || self.server_packs.prompt.is_some() || self.dialog_open()) {
return;
}
// The container screen's press state belongs to the screen: a screen
@@ -5694,17 +5720,22 @@ impl Game<'_> {
fn apply_cursor_icon(&mut self) {
use horizon_client::widgets::Cursor;
let wanted = horizon_client::widgets::take_cursor();
if wanted == self.cursor_icon {
let effective = if self.settings.allow_cursor_changes {
wanted
} else {
Cursor::Arrow
};
if effective == self.cursor_icon {
return;
}
if let Some(window) = &self.window {
window.set_cursor(match wanted {
window.set_cursor(match effective {
Cursor::Arrow => winit::window::CursorIcon::Default,
Cursor::PointingHand => winit::window::CursorIcon::Pointer,
Cursor::NotAllowed => winit::window::CursorIcon::NotAllowed,
});
}
self.cursor_icon = wanted;
self.cursor_icon = effective;
}
/// LevelLoadingScreen.onClose -> Gui.setScreen(null) -> grabMouse.
fn capture_loaded_world(&mut self) {
@@ -6101,7 +6132,14 @@ impl Game<'_> {
// Screen.extractBlurredBackground blurs the world and HUD, while the
// screen's background, widgets, toasts and debug overlay stay sharp.
batch.blur_at = batch.vertices.len() as u32;
match self.screen {
// PackConfirmScreen takes the open screen's place (it comes back once
// the prompt is answered): the screen under the prompt isn't drawn.
let screen = if self.server_packs.prompt.is_some() {
Screen::Playing
} else {
self.screen
};
match screen {
Screen::Paused if self.pause_menu => self.draw_pause_menu(&mut batch),
// PauseScreen(false): only its title, at the top.
Screen::Paused => {
@@ -6141,9 +6179,11 @@ impl Game<'_> {
// ToastManager.extractRenderState: F1 hides the toasts too.
self.draw_toasts(&mut batch, !show_hud);
self.draw_pack_toasts(&mut batch, !show_hud);
self.remote_hud(&mut batch);
self.draw_sign_editor(&mut batch);
self.draw_book(&mut batch);
if self.server_packs.prompt.is_none() {
self.remote_hud(&mut batch);
self.draw_sign_editor(&mut batch);
self.draw_book(&mut batch);
}
batch
}
fn creative_inventory_open(&self) -> bool {
@@ -6611,9 +6651,12 @@ impl Game<'_> {
let catalog = self.res.items();
let max = |s: &ItemStack| catalog.get(&s.item).map_or(64, |i| i.max_stack);
match click_display(&mut self.carried, shown, max, button, input) {
DisplayClick::Hotbar(i, stack) => self.inventory[i] = Some(stack),
DisplayClick::Hotbar(i, stack) if i < self.inventory.len() => {
self.inventory[i] = Some(stack);
}
DisplayClick::Offhand(stack) => self.offhand = Some(stack),
DisplayClick::Drop(stack) => self.player_drop(stack),
DisplayClick::Nothing => {}
_ => {}
}
}
/// Several clicks on one menu instance (a quick craft drag is three).
@@ -10456,14 +10499,18 @@ impl Game<'_> {
if hand == Hand::Off {
continue;
}
let Some(arm) = self.res.player_arm().clone() else {
let slim = self.player_model.as_ref().is_some_and(|m| m.slim);
let Some(arm) = self.res.player_arm(slim).clone() else {
continue;
};
// The player's own skin, as the inventory's model shows it.
let sprites = &self.gui.atlas.sprites;
let Some(r) = self
.gui
.atlas
.sprites
.get("entity/player/wide/steve")
.player_model
.as_ref()
.and_then(|m| m.skin.as_deref())
.and_then(|skin| sprites.get(skin))
.or_else(|| sprites.get("entity/player/wide/steve"))
.copied()
else {
continue;
@@ -11659,6 +11706,11 @@ impl Game<'_> {
} else {
self.bob_matrix(camera_alpha as f32)
};
let spin = if title {
glam::Mat4::IDENTITY
} else {
self.spinning_matrix(camera_alpha as f32)
};
let terrain_distance = self.terrain_distance();
let distance = (terrain_distance * 16) as f32;
let depth_far =
@@ -11666,7 +11718,7 @@ impl Game<'_> {
self.view_frustum = self
.renderer
.as_ref()
.map(|r| r.camera_frustum(fov_degrees, yaw, pitch, bob, depth_far));
.map(|r| r.camera_frustum(fov_degrees, yaw, pitch, bob * spin, depth_far));
// Keep the buffer local while builders need mutable access to the game.
let mut entities = std::mem::take(&mut self.entity_vertices);
entities.clear();
@@ -11749,12 +11801,24 @@ impl Game<'_> {
pitch,
fov_degrees,
bob,
spin,
sky,
distance,
detail_distance: (self.streaming.radius * 16) as f32,
depth_far,
underwater,
title,
sky_blocked: self.potions.effects.get("blindness").is_some()
|| self.potions.effects.get("darkness").is_some()
|| matches!(
horizon_client::fog::fog_type(
&self.world,
self.res.facts(),
[camera.x, camera.y, camera.z],
self.forward(),
),
horizon_client::fog::FogType::Lava | horizon_client::fog::FogType::PowderSnow
),
skybox: self.dim == Dim::Overworld && self.settings.horizon.sky,
end_sky: self.dim == Dim::End && self.settings.horizon.sky,
end_sky_sprite: self
@@ -12067,11 +12131,12 @@ impl Game<'_> {
Screen::Playing => {
// key.inventory while riding a tamed mount opens its inventory.
if !self.open_ridden_mount_menu() {
self.screen = if self.creative {
Screen::Creative
if self.creative {
self.screen = Screen::Creative;
self.creative_screen.reopen();
} else {
Screen::Inventory
};
self.screen = Screen::Inventory;
}
self.capture(false);
}
}
@@ -12651,13 +12716,16 @@ impl ApplicationHandler for Game<'_> {
self.left = false;
self.right = false;
self.capture(false);
// Screens (books, signs) stay open instead of pausing.
// Gui.setPauseScreen: an open screen (books, signs, the
// pack prompt, a server dialog) stays instead of pausing.
if self.screen == Screen::Playing
&& self.player.is_some()
&& !self.options.no_pause
&& self.settings.file.bool("pauseOnLostFocus")
&& self.book.is_none()
&& self.sign_edit.is_none()
&& self.server_packs.prompt.is_none()
&& !self.dialog_open()
{
self.screen = Screen::Paused;
}
@@ -14511,6 +14579,8 @@ fn load_world_rest(
player_type: perspective::player_type(root),
player_arm: horizon_client::mobs::load_model(root, "minecraft:player#main")
.and_then(|m| m.children.into_iter().find(|c| c.name == "right_arm")),
player_arm_slim: horizon_client::mobs::load_model(root, "minecraft:player_slim#main")
.and_then(|m| m.children.into_iter().find(|c| c.name == "right_arm")),
spin_attack: horizon_client::mobs::load_model(root, "minecraft:spin_attack#main"),
})
}
@@ -14648,6 +14718,7 @@ fn loading_frame(hud: &HudBatch) -> Frame<'_> {
pitch: 10.0,
fov_degrees: 85.0,
bob: glam::Mat4::IDENTITY,
spin: glam::Mat4::IDENTITY,
sky: horizon_client::environment::Sky::default(),
distance: 12.0,
detail_distance: 12.0,
@@ -14655,6 +14726,7 @@ fn loading_frame(hud: &HudBatch) -> Frame<'_> {
underwater: false,
cave_culling: false,
title: true,
sky_blocked: false,
skybox: false,
end_sky: false,
end_sky_sprite: None,
@@ -993,6 +993,42 @@ impl Game<'_> {
block_ok && state_ok && adv::conditions(&c["player"], &pctx, w)
});
}
/// `SlideDownBlockTrigger`: match the actual block and its state.
pub(super) fn trigger_honey_slide(&mut self, pos: [i32; 3]) {
let me = self.player_entity();
self.trigger("minecraft:slide_down_block", move |c, w| {
let ctx = adv::Entities {
this: Some(&me),
origin: me.position,
tool: None,
block_pos: None,
};
adv::conditions(&c["player"], &ctx, w) && adv::block(c, pos, w)
});
}
/// `TargetBlockTrigger`: signal and projectile conditions are evaluated in
/// the shooter's context, including distance from the shooter.
pub(super) fn trigger_target_hit(&mut self, projectile: &EntityView, strength: i32) {
let me = self.player_entity();
let projectile = projectile.clone();
self.trigger("minecraft:target_hit", move |c, w| {
let player = adv::Entities {
this: Some(&me),
origin: me.position,
tool: None,
block_pos: None,
};
let target = adv::Entities {
this: Some(&projectile),
..player
};
adv::conditions(&c["player"], &player, w)
&& adv::range_i(&c["signal_strength"], i64::from(strength))
&& adv::conditions(&c["projectile"], &target, w)
});
}
/// `ItemUsedOnLocationTrigger` (placed_block, item_used_on_block).
pub(super) fn trigger_block(&mut self, name: &str, pos: [i32; 3], stack: &ItemStack) {
let me = self.player_entity();
+9 -1
View File
@@ -563,13 +563,21 @@ impl Game<'_> {
};
let time = (self.world.game_time.rem_euclid(40)) as f32 + alpha as f32;
let scoping = self.scoping();
let view = f64::from(self.settings.render_distance * 16);
for (k, e) in &self.world.block_entities {
let BlockEntity::Beacon(b) = e else { continue };
// `getBeamSections` is empty while Levels is 0.
if b.levels == 0 || b.sections.is_empty() {
continue;
}
let base = glam::DVec3::new(f64::from(k.0) + 0.5, f64::from(k.1), f64::from(k.2) + 0.5);
let distance = (base - camera).truncate().length() as f32;
// `BeaconRenderer.shouldRender`: horizontal distance under render distance × 16.
let offset = base - camera;
let horizontal = glam::DVec2::new(offset.x, offset.z).length();
if horizontal >= view {
continue;
}
let distance = horizontal as f32;
let scale = if scoping {
1.0
} else {
+17 -3
View File
@@ -76,9 +76,11 @@ fn view_pages(data: Option<&StackData>) -> Vec<Text> {
.iter()
.enumerate()
.map(|(i, page)| match tagged.then(|| &w.page_tags[i]) {
Some(tag @ horizon_world::nbt::Tag::Compound(_)) => {
horizon_client::entity_nbt::name_text(tag)
}
// A component object, or a component list (`ComponentSerialization`
// takes both; servers behind ViaVersion send the list form).
Some(
tag @ (horizon_world::nbt::Tag::Compound(_) | horizon_world::nbt::Tag::List(_)),
) => horizon_client::entity_nbt::name_text(tag),
_ => Text::literal(page),
})
.collect()
@@ -137,6 +139,18 @@ impl Game<'_> {
}
}
ClickEvent::OpenUrl(url) => self.click_url(url),
ClickEvent::RunCommand(command) => self.click_command(&command),
ClickEvent::SuggestCommand(command) => {
// defaultHandleClickEvent: the chat screen takes it over.
if self.player.is_some() {
self.close_book();
self.open_chat(&command);
}
}
ClickEvent::CopyToClipboard(value) => {
use horizon_client::clipboard::Clipboard;
self.clipboard.set(&value);
}
_ => {}
}
}
+1 -1
View File
@@ -859,7 +859,7 @@ impl Game<'_> {
/// first, as `ClientPacketListener.sendUnattendedCommand` lets client
/// commands take it. (Vanilla asks first when the command fails to
/// parse; here the server answers it.)
fn click_command(&mut self, command: &str) {
pub(super) fn click_command(&mut self, command: &str) {
let command = command.strip_prefix('/').unwrap_or(command);
if self.mod_command(command) {
return;
+17 -2
View File
@@ -2666,10 +2666,25 @@ impl Game<'_> {
let hit = self.with_level(|b, level, ticks| {
b.target_projectile_hit(level, ticks, block, location, face, arrow)
});
if hit.is_some() {
if let Some(strength) = hit {
let _ = self.process_level_events();
if p.owner == Some(Who::Player) {
if p.owner == Some(Who::Player) && (p.guest.is_none() || p.guest == self.lan_acting) {
self.stats.custom("target_hit", 1);
let kind = if p
.arrow
.as_ref()
.is_some_and(|a| &*a.pickup_item.item == "minecraft:spectral_arrow")
{
"minecraft:spectral_arrow"
} else {
p.kind.id()
};
let projectile = horizon_client::advancements::EntityView {
kind: kind.to_owned(),
position: [p.position.x, p.position.y, p.position.z],
..Default::default()
};
self.trigger_target_hit(&projectile, strength);
}
}
}
+25 -1
View File
@@ -1,11 +1,13 @@
//! Field of view (`Camera.tickFov`, `calculateFov`,
//! `AbstractClientPlayer.getFieldOfViewModifier`), view and hurt bobbing
//! (`GameRenderer.bobHurt`, `bobView`) and the spyglass (`SpyglassItem`,
//! (`GameRenderer.bobHurt`, `bobView`), the portal and Nausea spin
//! (`GameRenderer.renderLevel`) and the spyglass (`SpyglassItem`,
//! `Hud.extractSpyglassOverlay`). The arithmetic is `horizon_client::camera`.
use super::Game;
use horizon_client::{
camera::{
BobState, CameraFluid, FovPlayer, HurtBob, UseItem, bob_matrix, field_of_view_modifier,
spinning_effect_matrix,
},
fog::{FogType, fog_type},
hud::HudBatch,
@@ -26,6 +28,28 @@ impl Game<'_> {
pub(super) fn fov_effect_scale(&self) -> f32 {
self.settings.file.double("fovEffectScale") as f32
}
/// `options.screenEffectScale` (Distortion Effects).
pub(super) fn screen_effect_scale(&self) -> f32 {
self.settings.file.double("screenEffectScale") as f32
}
/// `LocalPlayer.getEffectBlendFactor(NAUSEA, partial)`: 0 without the effect.
pub(super) fn nausea_blend(&self, partial: f32) -> f32 {
if self.potions.effects.get("nausea").is_none() {
return 0.0;
}
let fx = &self.screen_fx;
fx.o_nausea + partial * (fx.nausea - fx.o_nausea)
}
/// The spin `GameRenderer.renderLevel` multiplies into the level's
/// projection: the larger of the portal and Nausea intensities, scaled by
/// Distortion Effects squared, turning by `LocalPlayer.getSpinningEffectAngle`.
pub(super) fn spinning_matrix(&self, partial: f32) -> glam::Mat4 {
let fx = &self.screen_fx;
let portal = fx.o_portal + partial * (fx.portal - fx.o_portal);
let scale = self.screen_effect_scale();
let intensity = portal.max(self.nausea_blend(partial)) * (scale * scale);
spinning_effect_matrix(intensity, fx.spinning_time + partial * fx.spinning_speed)
}
/// What `AbstractClientPlayer.getFieldOfViewModifier` reads.
fn fov_player(&self) -> Option<FovPlayer> {
let p = self.player.as_ref()?;
+3 -1
View File
@@ -429,7 +429,9 @@ impl Game<'_> {
if let Some(id) = map {
// ItemFrameRenderer: the map fills the face, in 90-degree steps.
let depth = if f.invisible { 0.5 } else { 0.4375 };
let item_light = if f.glow { 240 | 240 << 8 } else { packed };
// A glow frame's map is 15728850 = 0xF000D2 (sky coordinate 240,
// block coordinate 210), not full bright.
let item_light = if f.glow { 240 | 210 << 8 } else { packed };
let m = pose
* glam::DMat4::from_translation(glam::DVec3::new(0.0, 0.0, depth))
* glam::DMat4::from_rotation_z(
+4 -4
View File
@@ -82,11 +82,11 @@ fn civil(days: i64) -> (i64, u32, u32) {
(yoe + era * 400 + i64::from(m <= 2), m, d)
}
/// `DateTimeFormatter.ofPattern(pattern).format(now)` for the month, day,
/// year and time letters item models use (UTC: no time zone database).
/// year and time letters item models use, in the system time zone
/// (`LocalTime` without a `time_zone`).
pub(super) fn local_time(pattern: &str) -> String {
let secs = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_secs() as i64);
let now = chrono::Local::now();
let secs = now.timestamp() + i64::from(now.offset().local_minus_utc());
let (y, mo, d) = civil(secs.div_euclid(86_400));
let s = secs.rem_euclid(86_400);
let (h, mi, se) = (s / 3600, s / 60 % 60, s % 60);
+11 -1
View File
@@ -2607,6 +2607,9 @@ impl Game<'_> {
let _ = w.tick_environment_damage();
let _ = w.apply_fire_contact();
let _ = w.server_hazards_tick(in_wall);
// LivingEntity.aiStep: a Riptide spin counts down on the server's
// copy too, or it would stay in the spin pose (eye 0.4) for good.
w.player.count_down_server_spin();
// Player.tick ends with updatePlayerPose: a swimmer, or a player
// crawling under a block, takes the short box its moves are checked with.
let _ = w.update_player_pose();
@@ -2706,8 +2709,15 @@ impl Game<'_> {
// others (Player.playSound excludes the player, whose client
// plays it), the entity event 53 everyone around.
MotionEvent::HoneySlide {
sound, particles, ..
pos,
sound,
particles,
} => {
if self.world.game_time % 20 == 0 {
self.as_guest(host, conn, |game| {
game.trigger_honey_slide([pos.x, pos.y, pos.z])
});
}
let Some((id, at)) = host.hub.guest(conn).map(|g| (g.entity_id, g.position))
else {
continue;
@@ -95,7 +95,9 @@ impl Game<'_> {
/// Capability `recipes`'s tables and known displays for this tick's
/// snapshot; nothing while no running mod reads them.
pub(super) fn mod_recipes(&mut self) -> (Option<Arc<Recipes>>, Arc<[i32]>) {
if !self.mods.mods.wants_recipes() {
// The tables come from the world resources, which may still be
// loading behind the title on a slow start.
if !self.mods.mods.wants_recipes() || self.res.rest.get().is_none() {
return (None, Arc::from([]));
}
if self.mods.recipes.is_none() {
@@ -158,7 +158,10 @@ pub(super) fn player_type(root: &Path) -> Option<Arc<MobType>> {
ambient_interval: 80,
flags: Default::default(),
model: Arc::new(model),
layers: Default::default(),
// PlayerModel's slim arms, for slim skins.
layers: horizon_client::mobs::load_model(root, "minecraft:player_slim#main")
.map(|slim| [("player_slim#main".to_owned(), Arc::new(slim))].into())
.unwrap_or_default(),
texture: "entity/player/wide/steve".into(),
brain: Brain::Passive,
anim: Anim::Humanoid { zombie_arms: false },
+78 -27
View File
@@ -213,7 +213,8 @@ pub(super) struct Remote {
/// `LocalPlayer.showDeathScreen`: off with `immediate_respawn`.
show_death_screen: bool,
player_type: Option<Arc<MobType>>,
/// Inventory as last synchronised, for creative-mode slot updates.
/// Inventory (then the off hand) as last synchronised, for creative-mode
/// slot updates.
synced: Vec<Option<ItemStack>>,
decode_errors: u32,
clock: Option<i32>,
@@ -510,7 +511,7 @@ impl Game<'_> {
death_message: None,
show_death_screen: true,
player_type,
synced: vec![None; 36],
synced: vec![None; 37],
decode_errors: 0,
clock: None,
clock_rate: 1.0,
@@ -3086,6 +3087,20 @@ impl Game<'_> {
.collect();
Some((players, r.online_mode, upside_down))
}
/// The signed-in account's `textures`, once the services answered.
fn own_textures(&self) -> Option<horizon_client::skins::Textures> {
use horizon_client::skins::{self, NameOrId, Resolve};
let auth = horizon_client::auth::current()?;
let state = self
.skin_profiles
.borrow_mut()
.state(&NameOrId::Id(auth.uuid));
let Resolve::Done(Some(profile)) = state else {
return None;
};
let (value, signature) = profile.textures()?;
Some(skins::unpack(&value, signature, &skins::TEXTURE_URIS))
}
/// `PlayerInfo.getSkin` for every player entity, including NPCs that are
/// never in the tab list: resolve each profile's textures and give the
/// level's player models their skin.
@@ -3094,12 +3109,23 @@ impl Game<'_> {
return;
};
let local = r.profile;
let entries: Vec<(u128, horizon_client::skins::Textures)> = r
let mut entries: Vec<(u128, horizon_client::skins::Textures)> = r
.skin_textures
.iter()
.map(|(uuid, textures)| (*uuid, textures.clone()))
.collect();
// Minecraft.profileFuture: the signed-in account's own profile, fetched from the
// services, gives the local player its skin where the server sends none (single
// player's offline-mode server).
if !entries.iter().any(|(u, t)| *u == local && t.skin.is_some())
&& let Some(own) = self.own_textures()
{
entries.retain(|(u, _)| *u != local);
entries.push((local, own));
}
let mut resolved: Vec<(u128, String)> = Vec::new();
// PlayerSkin.model of each resolved skin: the textures' `slim`.
let mut slim = std::collections::HashSet::new();
for (uuid, textures) in entries {
// PlayerInfo.createSkinLookup: only the local player's own skin
// may be unsigned.
@@ -3110,10 +3136,22 @@ impl Game<'_> {
})
};
if let Some(skin) = skin {
if textures.slim {
slim.insert(uuid);
}
resolved.push((uuid, skin));
}
}
resolved.retain(|(_, skin)| !skin.starts_with("skins/") || self.again_skin(skin));
// PlayerInfo.getSkin: the downloaded skin and its model, else the
// profile's default skin (DefaultPlayerSkin.get), slim or wide.
let skin_of = |skins: &HashMap<u128, String>, uuid: u128| match skins.get(&uuid) {
Some(skin) => (skin.clone(), slim.contains(&uuid)),
None => {
let skin = horizon_client::tab_list::default_skin(uuid);
(skin.to_owned(), skin.contains("/slim/"))
}
};
let Some((level, skins, own)) = self.remote.as_mut().map(|r| {
r.skins = resolved.into_iter().collect();
(
@@ -3122,27 +3160,26 @@ impl Game<'_> {
.map(|(id, uuid)| (*id as u32, *uuid))
.collect::<Vec<_>>(),
r.skins.clone(),
r.skins.get(&r.profile).cloned(),
r.profile,
)
}) else {
return;
};
for (id, uuid) in level {
let Some(skin) = skins.get(&uuid) else {
continue;
};
let (skin, is_slim) = skin_of(&skins, uuid);
if let Some(mob) = self
.mobs
.iter_mut()
.find(|m| m.id == id && &*m.kind.id == "minecraft:player")
{
mob.skin = Some(skin.clone());
mob.skin = Some(skin);
mob.slim = is_slim;
}
}
if let Some(skin) = own
&& let Some(mob) = self.player_model.as_mut()
{
if let Some(mob) = self.player_model.as_mut() {
let (skin, is_slim) = skin_of(&skins, own);
mob.skin = Some(skin);
mob.slim = is_slim;
}
// ClientMannequin.updateSkin (PlayerSkinRenderCache.lookup): a
// mannequin's DATA_PROFILE `textures` property resolves like a
@@ -3192,7 +3229,7 @@ impl Game<'_> {
(m.id, textures)
})
.collect();
let mut resolved: Vec<(u32, Option<String>)> = Vec::new();
let mut resolved: Vec<(u32, Option<String>, bool)> = Vec::new();
for (id, textures) in mannequins {
let skin = {
let mut downloads = self.skin_downloads.borrow_mut();
@@ -3200,15 +3237,17 @@ impl Game<'_> {
downloads.state(kind, url)
})
};
resolved.push((id, skin));
let slim = skin.is_some() && textures.slim;
resolved.push((id, skin, slim));
}
for (id, skin) in resolved {
for (id, skin, slim) in resolved {
let skin = skin.filter(|s| !s.starts_with("skins/") || self.again_skin(s));
if let Some(mob) = self
.mobs
.iter_mut()
.find(|m| m.id == id && &*m.kind.id == "minecraft:mannequin")
{
mob.slim = slim && skin.is_some();
mob.skin = skin;
}
}
@@ -4307,12 +4346,14 @@ impl Game<'_> {
{
out.push(packet);
}
// Creative inventory edits are sent slot by slot (SetCreativeModeSlot).
// Creative inventory edits are sent slot by slot (SetCreativeModeSlot);
// `synced[36]` is the off hand.
let mut creative_slots = Vec::new();
if self.creative {
for i in 0..36 {
if self.inventory[i] != r.synced[i] {
r.synced[i] = self.inventory[i].clone();
for i in 0..37 {
let stack = self.inventory.get(i).unwrap_or(&self.offhand);
if *stack != r.synced[i] {
r.synced[i] = stack.clone();
creative_slots.push(i);
}
}
@@ -4321,8 +4362,13 @@ impl Game<'_> {
self.send(packet);
}
for i in creative_slots {
let slot = if i < 9 { 36 + i } else { i } as i16;
let item = self.to_network(&self.inventory[i]);
// InventoryMenu's slots: the hotbar is 36..=44, the off hand 45.
let slot = match i {
0..9 => 36 + i,
36 => 45,
_ => i,
} as i16;
let item = self.to_network(self.inventory.get(i).unwrap_or(&self.offhand));
self.send(S::SetCreativeModeSlot(play::SetCreativeModeSlot {
slot,
item,
@@ -4331,7 +4377,8 @@ impl Game<'_> {
}
/// Inventory as the server last described it.
fn remote_synced(&mut self) {
let inventory = self.inventory.clone();
let mut inventory = self.inventory.clone();
inventory.push(self.offhand.clone());
if let Some(r) = self.remote.as_mut() {
r.synced = inventory;
}
@@ -5055,8 +5102,11 @@ impl Game<'_> {
// countdown goes on past zero (LivingEntity.updateUsingItem completes
// only on the server) until the server's USE_ITEM_COMPLETE.
let remaining = consumable.ticks() - ticks as i32;
if consumable.particles && consumable.should_emit(remaining) {
self.eat_particles(&stack, 5);
if consumable.should_emit(remaining) {
// `emit_consume` plays the eat sound and spawns the bites (when the
// item has consume particles), as `tick_used_item` does on the
// player's own server.
self.emit_consume(&consumable, &stack, 5);
}
self.using = Some((slot, ticks + 1));
}
@@ -5337,11 +5387,12 @@ impl Game<'_> {
KeyCode::KeyE => {
// The inventory opens without telling the server.
self.recipe_ui.reset_screen();
self.screen = if self.creative {
Screen::Creative
if self.creative {
self.screen = Screen::Creative;
self.creative_screen.reopen();
} else {
Screen::Inventory
};
self.screen = Screen::Inventory;
}
self.craft_size = 2;
self.capture(false);
Ok(true)
+15 -4
View File
@@ -108,7 +108,11 @@ impl Game<'_> {
rain,
thunder,
skybox: self.dim == Dim::Overworld,
blindness: None,
blindness: self
.potions
.effects
.get("blindness")
.map(|effect| (effect.duration, effect.duration == -1)),
darkness: self.darkness_fog(),
darken_world: self
.boss_overlay
@@ -140,7 +144,8 @@ impl Game<'_> {
} else {
63.0
};
camera.y < horizon
self.dim == Dim::Overworld
&& camera.y < horizon
&& fog_type(
&self.world,
self.res.facts(),
@@ -209,8 +214,8 @@ impl Game<'_> {
percent_frozen: p.percent_frozen(),
partial,
portal: [fx.o_portal, fx.portal],
nausea: 0.0,
screen_effect_scale: 1.0,
nausea: self.nausea_blend(partial),
screen_effect_scale: self.screen_effect_scale(),
};
let mut scope = self.scope_scale;
let gui_overlays =
@@ -247,6 +252,12 @@ impl Game<'_> {
let brightness = self.eye_brightness(e);
let inside_portal = self.client_portal_effect();
self.screen_fx.tick(brightness, inside_portal);
let nausea = self
.potions
.effects
.get("nausea")
.map(|e| !e.ends_within(60));
self.screen_fx.tick_spinning(nausea);
// LocalPlayer water vision and AtmosphericFogEnvironment rain fog.
let in_water =
fog_type(&self.world, self.res.facts(), e, self.forward()) == FogType::Water;
@@ -269,6 +269,8 @@ impl Game<'_> {
open.requests.push((p.id, p.url, p.hash));
open.required = p.required;
open.prompt = text;
// Gui.setScreen: KeyMapping.releaseAll, so no walking behind the prompt.
self.keys.clear();
self.capture(false);
}
/// `handleResourcePackPop`: one pack, or all of them.
+18 -8
View File
@@ -2059,9 +2059,16 @@ impl Game<'_> {
);
}
}
// ItemStack.OP_NBT_WARNING, for a player with game master permission.
if !nested && self.ui.registries.permission >= 2 && block_entity_item::op_warning(stack) {
for (key, fallback) in [
// ItemStack.OP_NBT_WARNING: `BlockItem` and `SpawnEggItem`
// `.shouldPrintOpWarning` for a player with game master permission;
// a nested projectile has no player. The first line is bold.
if !nested
&& self.ui.registries.permission >= 2
&& (item_info.is_some_and(|i| i.block.is_some())
&& block_entity_item::op_warning(stack)
|| block_entity_item::spawn_egg_op_warning(stack))
{
for (i, (key, fallback)) in [
("item.op_warning.line1", "Warning:"),
(
"item.op_warning.line2",
@@ -2071,12 +2078,15 @@ impl Game<'_> {
"item.op_warning.line3",
"Do not use unless you know the exact contents!",
),
] {
push(
&mut lines,
self.translate(key, fallback),
]
.into_iter()
.enumerate()
{
let text = horizon_client::text::Text::legacy(&self.translate(key, fallback));
lines.push((
if i == 0 { text.bold() } else { text },
rgba(255, 85, 85, 255),
);
));
}
}
lines
+9 -7
View File
@@ -105,12 +105,9 @@ const COMPLETE_COLUMN: u32 = (1 << (crate::chunk::HEIGHT / 16)) - 1;
/// its fixed-point UVs (`terrain.wgsl`'s `terrain_uv`).
/// The world pass again after occlusion culling's test: it loads both
/// targets (phase 2 draws over phase 1, reading the test's results).
/// LevelRenderer: no sky inside lava or powder snow (sky fog ends within 2 blocks).
/// `LevelRenderer.addSkyPass`: fluids and camera effects block the sky pass.
fn sky_open(frame: &Frame) -> bool {
match frame.fog {
Some(f) => f.sky_end > 2.0,
None => !frame.underwater,
}
!frame.sky_blocked
}
/// The Overworld-style sky (disc, sunset, stars, void) draws this frame.
@@ -222,6 +219,9 @@ pub struct Frame<'a> {
/// The `GameRenderer.bobHurt`/`bobView` pose multiplied into the
/// projection (`crate::camera::bob_matrix`).
pub bob: Mat4,
/// The spinning effect multiplied into the level's projection only
/// (`crate::camera::spinning_effect_matrix`), not the hand's.
pub spin: Mat4,
pub sky: Sky,
/// Render distance in blocks (fog end).
pub distance: f32,
@@ -235,6 +235,8 @@ pub struct Frame<'a> {
pub cave_culling: bool,
/// Title screen: only the dedicated panorama cube map and the HUD.
pub title: bool,
/// Lava, powder snow, blindness or darkness blocks the sky pass.
pub sky_blocked: bool,
/// Dimensions without a skybox (the Nether) clear to the fog colour.
pub skybox: bool,
/// The End: clear to black and draw the resident sky box behind the world.
@@ -3418,7 +3420,7 @@ impl Renderer {
frame.fov_degrees,
frame.yaw,
frame.pitch,
frame.bob,
frame.bob * frame.spin,
frame.depth_far,
)
}
@@ -4508,7 +4510,7 @@ impl Renderer {
cull.bind_depth(&self.device, world_depth);
}
let cull = self.cull.as_ref().filter(|_| culling && solid_draws > 0);
let clear = if frame.title || (frame.end_sky && !frame.underwater) {
let clear = if frame.title || (frame.end_sky && !frame.underwater && sky_open(frame)) {
wgpu::Color::BLACK
} else {
wgpu::Color {
+3 -2
View File
@@ -361,8 +361,9 @@ impl NativePost {
pub fn frame_inputs(frame: &Frame, size: [u32; 2], seconds: f32, index: u32) -> FrameInputs {
let aspect = size[0] as f32 / size[1].max(1) as f32;
let view = crate::camera::view_matrix(frame.yaw, frame.pitch);
let projection =
super::world_projection(frame.fov_degrees, aspect, frame.depth_far) * frame.bob;
let projection = super::world_projection(frame.fov_degrees, aspect, frame.depth_far)
* frame.bob
* frame.spin;
// A celestial body's direction at `angle` degrees (SkyRenderer: Y -90°, then X by the
// angle, of straight up): overhead at 0, rising in the east (+x).
let body = |angle: f32| {
@@ -287,7 +287,8 @@ impl FrameUniforms {
aspect,
0.05,
frame.depth_far,
) * frame.bob;
) * frame.bob
* frame.spin;
let camera = frame.camera.as_vec3();
let (previous_camera, previous_view, previous_projection) =
self.previous
+10 -9
View File
@@ -31,7 +31,6 @@ pub mod cauldron;
pub mod chat;
pub mod chunk;
pub mod chunk_map;
pub mod clipboard;
pub mod clock;
pub mod clouds;
pub mod collisions;
@@ -72,7 +71,6 @@ pub mod font;
pub mod friends_screen;
pub mod game_mode_switcher;
pub mod game_rules_screen;
pub mod gamepad;
pub mod gfx;
pub mod gizmos;
pub mod gossip;
@@ -86,17 +84,12 @@ pub mod hud;
pub mod hud_layout;
pub mod hud_overlays;
pub mod hunger;
pub mod input;
pub mod interaction_input;
pub mod inv_sort;
pub mod inventory_effects;
pub mod item_render;
pub mod item_sync;
pub mod item_tooltip;
pub mod java_text;
pub mod key_names;
pub mod keybinds;
pub mod keymapping;
pub mod lan;
pub mod landing;
pub mod level_loading;
@@ -116,7 +109,6 @@ pub mod mod_list;
mod model_library;
pub mod models;
pub mod motion;
pub mod mouse;
pub mod moved_extras;
pub mod multiplayer;
pub mod music;
@@ -141,6 +133,7 @@ pub mod portal_sections;
pub mod pose;
pub mod pregen;
pub mod projectiles;
pub mod providers;
pub mod raid;
pub mod random_tick_sections;
pub mod recipe_book;
@@ -187,7 +180,6 @@ pub mod vault;
pub mod weather;
pub mod widgets;
pub mod win_screen;
pub mod window;
pub mod world;
pub mod world_spawn;
pub mod worldborder;
@@ -203,6 +195,15 @@ pub use horizon_client_core::nbt_path;
pub use horizon_client_core::scoreboard;
pub use horizon_client_core::snbt;
pub use horizon_client_core::text;
pub use horizon_client_input::clipboard;
pub use horizon_client_input::gamepad;
pub use horizon_client_input::input;
pub use horizon_client_input::interaction_input;
pub use horizon_client_input::key_names;
pub use horizon_client_input::keybinds;
pub use horizon_client_input::keymapping;
pub use horizon_client_input::mouse;
pub use horizon_client_input::window;
pub use horizon_command as command;
pub use horizon_items::blocks;
pub use horizon_items::bundle;
+3
View File
@@ -937,6 +937,8 @@ pub struct Mob {
/// The skin texture of a player mob (`PlayerInfo.getSkin`), which
/// replaces the type's default when its download is ready.
pub skin: Option<String>,
/// `PlayerSkin.model` is `SLIM`: the player model with 3-pixel arms.
pub slim: bool,
/// A mannequin's `DATA_PROFILE` (ResolvableProfile): the profile's
/// `textures` property resolves to `skin` like a player's.
pub profile: Option<horizon_protocol::entity_data::Profile>,
@@ -1908,6 +1910,7 @@ impl Mob {
rider: None,
outbox: Vec::new(),
skin: None,
slim: false,
profile: None,
attrs: Attrs {
speed: kind.speed,
@@ -405,3 +405,43 @@ impl Goal for Rolled {
scared(m)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::mobs::sensor_scan_tests::kind;
use std::sync::Arc;
/// An armadillo on the ground.
fn armadillo() -> Mob {
let mut kind = kind();
let k = Arc::make_mut(&mut kind);
k.id = "minecraft:armadillo".into();
k.species = Species::Armadillo;
let mut m = Mob::new(1, kind, Vec3::ZERO, 0.0);
m.on_ground = true;
m
}
#[test]
fn a_hit_rolls_it_up_into_a_ball() {
// Armadillo.actuallyHurt: a hit by a living thing curls it up and
// keeps the danger in mind for 80 ticks, so the model shows the
// shell instead of the armadillo (#2759).
let mut m = armadillo();
after_hurt(&mut m, &Damage::player([0.0, 0.0, 0.0]));
assert_eq!(state(&m), ROLLING);
// Armadillo.rollUp: the roll sound.
assert!(
m.outbox
.iter()
.any(|e| matches!(e, MobEvent::Sound(s, ..) if *s == "entity.armadillo.roll")),
"the roll plays its sound"
);
// ArmadilloState.shouldHideInShell: the shell shows after five ticks.
for _ in 0..6 {
tick(&mut m);
}
assert!(hiding_in_shell(&m), "the model shows the shell");
}
}
+4 -2
View File
@@ -115,8 +115,9 @@ pub fn body(m: &Mob) -> (String, String) {
}
};
if avatar(m) {
// AvatarRenderer: PlayerModel's slim layer for a slim skin.
return (
"main".into(),
if m.slim { "player_slim#main" } else { "main" }.into(),
m.skin.clone().unwrap_or_else(|| m.kind.texture.clone()),
);
}
@@ -784,7 +785,8 @@ pub fn layers(m: &Mob) -> Vec<Layer> {
/// `HumanoidArmorLayer`: the armour models of the mob's main model with the
/// equipment textures (leather tinted, with its overlay).
fn humanoid_armor(m: &Mob, v: &mut Vec<Layer>) {
if super::equine::is_equine(m.species()) {
// A player's or mannequin's armour is drawn by the game's armour layers.
if super::equine::is_equine(m.species()) || avatar(m) {
return;
}
let (model, _) = body(m);
+1 -16
View File
@@ -111,22 +111,7 @@ impl SpawnData {
}
}
/// `Identifier.CODEC` parsing an entity id: lowercase namespace and path
/// characters, `minecraft` when the namespace is left out.
pub fn normalize_id(s: &str) -> Option<String> {
let (ns, path) = match s.split_once(':') {
Some((ns, path)) => (if ns.is_empty() { "minecraft" } else { ns }, path),
None => ("minecraft", s),
};
let ok = |c: char, slash: bool| {
c.is_ascii_lowercase()
|| c.is_ascii_digit()
|| matches!(c, '_' | '-' | '.')
|| slash && c == '/'
};
(ns.chars().all(|c| ok(c, false)) && path.chars().all(|c| ok(c, true)))
.then(|| format!("{ns}:{path}"))
}
pub use horizon_client_core::identifier::normalize_id;
/// How much a slime's or magma cube's box is scaled by the size its data
/// loads (`Slime.readAdditionalSaveData`: Size + 1, kept in 1..=127), which
@@ -1331,6 +1331,7 @@ impl Def {
let mut v = vec![base];
let extra: &[&str] = match self.species {
Species::Pig => &["pig_baby", "cold_pig"],
Species::Mannequin => &["player_slim"],
Species::Cow => &[
"cow_baby",
"cold_cow",
@@ -790,20 +790,6 @@ pub fn golem_detected(m: &mut Mob, now: i64) {
// ---- Behaviours ---------------------------------------------------------
pub use crate::poi_sections::Poi;
impl Poi {
fn max_tickets(self) -> usize {
match self {
Poi::Meeting => 32,
_ => 1,
}
}
fn valid_range(self) -> i32 {
match self {
Poi::Meeting => 6,
_ => 1,
}
}
}
/// The POI a block state is (`PoiTypes`): job site blocks, bed heads, bells.
pub fn poi_at(w: &MobWorld, x: i32, y: i32, z: i32) -> Option<Poi> {
poi_of_state(w, w.world.block(x, y, z))
+44 -1
View File
@@ -1551,7 +1551,10 @@ impl MotionWorld<'_, '_, '_> {
// Only the server broadcasts event 53 (`!level.isClientSide()`).
let particles = !self.player.client_side
&& random.next_int_bound(5).map_err(|e| format!("{e:?}"))? == 0;
if sound || particles {
// The server also checks the slide criterion every 20 game ticks,
// including ticks without a sound or a particle event.
let criterion = !self.player.client_side && self.scene.game_time % 20 == 0;
if sound || particles || criterion {
self.player.events.push(MotionEvent::HoneySlide {
pos,
sound,
@@ -3511,6 +3514,46 @@ mod tests {
}
}
#[test]
fn a_server_copy_of_a_riptide_spin_counts_down_and_stands_again() {
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client")
.join(horizon_core::VANILLA_VERSION);
if !root.join("movement-properties.nbt").exists() {
return;
}
let assets = Assets::load(&root).unwrap();
let render = assets.reload(&root).unwrap();
let binding = crate::collisions::load(&root, assets.registry).unwrap();
let resources = MotionResources::load(&root, &assets).unwrap();
let mut scene = World::test_scene(&assets, &render, 2).unwrap();
let mut player = PlayerMotion::new(&resources, Vec3::new(20.5, 10.0, 20.5)).unwrap();
player.auto_spin_attack_ticks = 2;
player.auto_spin_attack_dmg = 8.0;
let mut w = MotionWorld {
entities: &[],
player: &mut player,
scene: &mut scene,
assets: &assets,
collision_binding: &binding,
resources: &resources,
};
w.update_player_pose().unwrap();
assert_eq!(w.player.pose, crate::pose::Pose::SpinAttack);
w.player.count_down_server_spin();
w.update_player_pose().unwrap();
assert_eq!(w.player.pose, crate::pose::Pose::SpinAttack);
assert_eq!(w.player.auto_spin_attack_dmg, 8.0);
w.player.count_down_server_spin();
assert_eq!(w.player.auto_spin_attack_ticks, 0);
assert_eq!(w.player.auto_spin_attack_dmg, 0.0);
w.update_player_pose().unwrap();
assert_eq!(w.player.pose, crate::pose::Pose::Standing);
assert_eq!(w.player.dimensions.eye_height, 1.62);
// Nothing to count once the spin is over.
w.player.count_down_server_spin();
assert_eq!(w.player.auto_spin_attack_ticks, 0);
}
#[test]
fn a_riptide_spin_lies_flat_and_ends_on_the_first_entity_it_touches() {
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client")
+58 -7
View File
@@ -73,6 +73,17 @@ pub(crate) const ACCESSIBILITY_OPTIONS: &[Opt] = &[
Opt::RotateWithMinecarts,
Opt::HighContrastBlockOutlines,
];
/// `MouseSettingsScreen.options` in Java Edition 26.3, in vanilla order.
pub(crate) const MOUSE_OPTIONS: &[Opt] = &[
Opt::Sensitivity,
Opt::WheelSensitivity,
Opt::DiscreteScroll,
Opt::InvertX,
Opt::InvertY,
Opt::AllowCursorChanges,
];
impl Opt {
/// Keep controls visible while their underlying systems are still missing.
pub(crate) fn available(self) -> bool {
@@ -89,7 +100,6 @@ impl Opt {
| Opt::DistortionEffects
| Opt::MonochromeLogo
| Opt::RotateWithMinecarts
| Opt::HighContrastBlockOutlines
// Saved, but nothing in rendering reads them yet.
| Opt::ChunkFade
| Opt::ImprovedTransparency
@@ -129,8 +139,10 @@ impl Opt {
Opt::SimulationDistance => "options.simulationDistance",
Opt::Sensitivity => "options.sensitivity",
Opt::WheelSensitivity => "options.mouseWheelSensitivity",
Opt::InvertX => "options.invertMouseX",
Opt::InvertY => "options.invertMouseY",
Opt::DiscreteScroll => "options.discrete_mouse_scroll",
Opt::AllowCursorChanges => "options.allowCursorChanges",
Opt::Volume(_) => "soundCategory.master",
Opt::GuiScale => "options.guiScale",
Opt::ViewBobbing => "options.viewBobbing",
@@ -454,8 +466,10 @@ impl Opt {
Opt::NarratorHotkey => on_off(s.file.bool("narratorHotkey")),
Opt::RotateWithMinecarts => on_off(s.file.bool("rotateWithMinecart")),
Opt::HighContrastBlockOutlines => on_off(s.file.bool("highContrastBlockOutline")),
Opt::InvertX => on_off(s.invert_x),
Opt::InvertY => on_off(s.invert_y),
Opt::DiscreteScroll => on_off(s.discrete_scroll),
Opt::AllowCursorChanges => on_off(s.allow_cursor_changes),
Opt::ViewBobbing => on_off(s.view_bobbing),
Opt::Vsync => on_off(s.vsync),
Opt::Fullscreen => on_off(s.fullscreen),
@@ -652,6 +666,7 @@ impl Opt {
Opt::TextBackground
| Opt::HideLightningFlashes
| Opt::HideSplashTexts
| Opt::HighContrastBlockOutlines
| Opt::HideMatchedNames
| Opt::OnlySecureChat
| Opt::SaveChatDrafts => {
@@ -659,8 +674,10 @@ impl Opt {
s.file
.set(key, crate::options_file::V::Bool(!s.file.bool(key)));
}
Opt::InvertX => s.invert_x = !s.invert_x,
Opt::InvertY => s.invert_y = !s.invert_y,
Opt::DiscreteScroll => s.discrete_scroll = !s.discrete_scroll,
Opt::AllowCursorChanges => s.allow_cursor_changes = !s.allow_cursor_changes,
Opt::ViewBobbing => s.view_bobbing = !s.view_bobbing,
Opt::Vsync => s.vsync = !s.vsync,
Opt::Fullscreen => s.fullscreen = !s.fullscreen,
@@ -1434,12 +1451,7 @@ impl OptionsUi {
]));
rows
}
Page::Mouse => pairs(&[
Opt::Sensitivity,
Opt::WheelSensitivity,
Opt::DiscreteScroll,
Opt::InvertY,
]),
Page::Mouse => pairs(MOUSE_OPTIONS),
Page::Chat => pairs(&[
Opt::ChatVisibility,
Opt::ChatColors,
@@ -2876,6 +2888,45 @@ mod tests {
assert_ne!(s.text_background(0.0), chat_only);
}
#[test]
fn mouse_settings_match_java_26_3_option_order() {
let mut ui = OptionsUi::default();
ui.show(Page::Mouse);
let mut options = Vec::new();
for row in ui.list_rows(&Settings::default()) {
if let Row::Pair(a, b) = row {
options.push(a);
if let Some(b) = b {
options.push(b);
}
}
}
assert_eq!(
options,
vec![
Opt::Sensitivity,
Opt::WheelSensitivity,
Opt::DiscreteScroll,
Opt::InvertX,
Opt::InvertY,
Opt::AllowCursorChanges,
]
);
}
#[test]
fn mouse_settings_round_trip_with_java_defaults() {
let mut settings = Settings::default();
assert!(!settings.invert_x);
assert!(settings.allow_cursor_changes);
Opt::InvertX.cycle(&mut settings, false, [854, 480]);
Opt::AllowCursorChanges.cycle(&mut settings, false, [854, 480]);
let loaded = Settings::parse(&settings.serialize());
assert!(loaded.invert_x);
assert!(!loaded.allow_cursor_changes);
assert!(!loaded.file.bool("allowCursorChanges"));
}
#[test]
fn accessibility_has_all_options_and_disabled_placeholders() {
let mut s = Settings::default();
+16
View File
@@ -87,6 +87,22 @@ pub enum Poi {
Home,
Meeting,
}
impl Poi {
/// `PoiType.maxTickets`.
pub fn max_tickets(self) -> usize {
match self {
Poi::Meeting => 32,
_ => 1,
}
}
/// `PoiType.validRange`.
pub fn valid_range(self) -> i32 {
match self {
Poi::Meeting => 6,
_ => 1,
}
}
}
#[cfg(test)]
mod tests {
+12
View File
@@ -78,6 +78,18 @@ impl Dimensions {
}
}
impl PlayerMotion {
/// `LivingEntity.aiStep` counting down `autoSpinAttackTicks` for a
/// server's copy of a player, which runs no client tick: the spin ends
/// after its ticks and its damage goes with it, so the pose (and the eye
/// a bow or a trident is shot from) comes back to standing.
pub fn count_down_server_spin(&mut self) {
if self.auto_spin_attack_ticks > 0 {
self.auto_spin_attack_ticks -= 1;
if self.auto_spin_attack_ticks <= 0 {
self.auto_spin_attack_dmg = 0.0;
}
}
}
pub fn dimensions_for_pose(&mut self, pose: Pose) -> Result<Dimensions> {
Ok(pose.dimensions(self.attribute("minecraft:scale")? as f32))
}
+122
View File
@@ -0,0 +1,122 @@
//! Vanilla's `context_int_provider` and `context_float_provider` values (furnace
//! fuel times, brewing times), evaluated from the data pack's JSON.
use crate::items::{ItemStack, Items};
use serde_json::Value;
use std::{collections::HashMap, fs, path::Path};
/// Evaluator for `context_int_provider` / `context_float_provider` values.
pub struct Providers {
ints: HashMap<String, Value>,
floats: HashMap<String, Value>,
}
fn load_dir(dir: &Path, prefix: &str, out: &mut HashMap<String, Value>) {
let Ok(entries) = fs::read_dir(dir) else {
return;
};
for e in entries.flatten() {
let p = e.path();
let name = p
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("")
.to_owned();
if p.is_dir() {
load_dir(&p, &format!("{prefix}{name}/"), out);
} else if let Ok(bytes) = fs::read(&p)
&& let Ok(v) = serde_json::from_slice(&bytes)
{
out.insert(format!("minecraft:{prefix}{name}"), v);
}
}
}
impl Providers {
pub fn load(root: &Path) -> Self {
let mut ints = HashMap::new();
let mut floats = HashMap::new();
load_dir(
&root.join("data/minecraft/context_int_provider"),
"",
&mut ints,
);
load_dir(
&root.join("data/minecraft/context_float_provider"),
"",
&mut floats,
);
Self { ints, floats }
}
fn eval(v: &Value, fast: bool, table: &HashMap<String, Value>, depth: u32) -> f64 {
if depth > 16 {
return 0.0;
}
match v {
Value::Number(n) => n.as_f64().unwrap_or(0.0),
Value::String(name) => table
.get(name)
.map_or(0.0, |v| Self::eval(v, fast, table, depth + 1)),
Value::Object(o) => {
let f =
|k: &str| Self::eval(o.get(k).unwrap_or(&Value::Null), fast, table, depth + 1);
match o.get("type").and_then(Value::as_str).unwrap_or("") {
"minecraft:div" => {
let r = f("right");
if r == 0.0 {
0.0
} else {
(f("left") / r).trunc()
}
}
"minecraft:mul" => f("left") * f("right"),
"minecraft:add" | "minecraft:sum" => f("left") + f("right"),
"minecraft:sub" => f("left") - f("right"),
"minecraft:constant" => f("value"),
"minecraft:conditional" => {
// The only condition in the pinned data: block/fast_cooking.
let condition = o.get("condition").and_then(Value::as_str)
== Some("minecraft:block/fast_cooking");
if condition && fast {
f("on_true")
} else {
f("on_false")
}
}
_ => 0.0,
}
}
_ => 0.0,
}
}
/// A plain `context_int_provider` value or reference (no context conditions).
pub fn int_value(&self, v: &Value) -> i32 {
Self::eval(v, false, &self.ints, 0) as i32
}
/// A plain `context_float_provider` value or reference; `default` when absent.
pub fn float_value(&self, v: &Value, default: f32) -> f32 {
if v.is_null() {
default
} else {
Self::eval(v, false, &self.floats, 0) as f32
}
}
#[cfg(test)]
pub fn empty() -> Self {
Self {
ints: HashMap::new(),
floats: HashMap::new(),
}
}
pub fn burn_time(&self, items: &Items, stack: &ItemStack, fast: bool) -> i32 {
items
.get(&stack.item)
.and_then(|i| i.fuel.as_ref())
.map_or(0, |(time, _)| Self::eval(time, fast, &self.ints, 0) as i32)
}
pub fn speed(&self, items: &Items, stack: &ItemStack, fast: bool) -> f32 {
items
.get(&stack.item)
.and_then(|i| i.fuel.as_ref())
.map_or(1.0, |(_, speed)| {
Self::eval(speed, fast, &self.floats, 0) as f32
})
}
}
+1 -8
View File
@@ -105,14 +105,7 @@ pub fn chunks_with_block_entities<'a>(
}
use horizon_client_core::nbt_value::string;
pub use horizon_client_core::nbt_value::{number, text};
pub(crate) fn float(tag: Option<&Tag>) -> Option<f64> {
Some(match tag? {
Tag::Float(b) => f64::from(f32::from_bits(*b)),
Tag::Double(b) => f64::from_bits(*b),
other => number(Some(other))? as f64,
})
}
pub use horizon_client_core::nbt_value::{float, number, text};
/// Insert or replace a key of a compound tag.
pub(crate) fn put(tag: &mut Tag, key: &str, value: Tag) {
if let Tag::Compound(map) = tag {
@@ -69,6 +69,12 @@ pub struct ScreenEffects {
pub vignette_brightness: f32,
pub portal: f32,
pub o_portal: f32,
/// Nausea's `MobEffectInstance.BlendState` factor and the previous tick's.
pub nausea: f32,
pub o_nausea: f32,
/// `LocalPlayer.spinningEffectTime` and `spinningEffectSpeed` (degrees).
pub spinning_time: f32,
pub spinning_speed: f32,
}
impl Default for ScreenEffects {
fn default() -> Self {
@@ -76,6 +82,10 @@ impl Default for ScreenEffects {
vignette_brightness: 1.0,
portal: 0.0,
o_portal: 0.0,
nausea: 0.0,
o_nausea: 0.0,
spinning_time: 0.0,
spinning_speed: 0.0,
}
}
}
@@ -105,6 +115,28 @@ impl ScreenEffects {
};
self.portal = (self.portal + step).clamp(0.0, 1.0);
}
/// `LocalPlayer.tickSpinningEffect` and Nausea's blend. `nausea` is `None`
/// without the effect, else whether it is past its blend-out point
/// (`!endsWithin(60)`, `MobEffects.NAUSEA`).
pub fn tick_spinning(&mut self, nausea: Option<bool>) {
self.o_nausea = self.nausea;
match nausea {
// BlendState.tick: fades in over 150 ticks and out over 20.
Some(active) => {
let (target, duration) = if active { (1.0, 150.0) } else { (0.0, 20.0) };
self.nausea += (target - self.nausea).clamp(-1.0 / duration, 1.0 / duration);
}
// The blend state belongs to the instance, so a new one starts at 0.
None => self.nausea = 0.0,
}
if self.portal > 0.0 || self.nausea > 0.0 {
self.spinning_speed =
(self.portal * 20.0 + self.nausea * 7.0) / (self.portal + self.nausea);
self.spinning_time += self.spinning_speed;
} else {
self.spinning_speed = 0.0;
}
}
pub fn build(&self, input: &ScreenInputs) -> Vec<ScreenQuad> {
let mut out = Vec::new();
let sprite = |name: &str| input.sprites.get(name).map(|r| [r.u0, r.v0, r.u1, r.v1]);
+4 -2
View File
@@ -2,7 +2,7 @@
//! `VibrationSelector`) shared by sculk sensors, shriekers and wardens, the
//! sculk block entities and the per-player warden spawn tracker.
pub use crate::chunk::block_entity::{Data, Info, SculkEntity, SculkKind, Selector};
use crate::mobs::Who;
use horizon_client_core::who::Who;
use horizon_core::{
geometry::{Direction, Vec3, traverse_blocks},
position::BlockPos,
@@ -315,7 +315,9 @@ fn info_to_nbt(info: &Info) -> Tag {
])
}
fn info_from_nbt(tag: &Tag) -> Option<Info> {
let event = intern(&crate::save::text(tag.get("game_event"))?)?;
let event = intern(&horizon_client_core::nbt_value::text(
tag.get("game_event"),
)?)?;
let distance = match tag.get("distance") {
Some(Tag::Float(f)) => f32::from_bits(*f),
_ => 0.0,
+8 -1
View File
@@ -167,6 +167,8 @@ pub struct Settings {
/// Every option in its stored form (the typed fields above mirror it).
pub file: OptionsFile,
pub invert_x: bool,
/// Allow widgets to replace the default mouse cursor icon.
pub allow_cursor_changes: bool,
pub toggle_attack: bool,
pub toggle_use: bool,
/// Taps of the sprint key counted as double taps (sprintWindow).
@@ -222,6 +224,7 @@ impl Default for Settings {
invert_y: false,
mouse_wheel_sensitivity: 1.0,
discrete_scroll: false,
allow_cursor_changes: true,
toggle_crouch: false,
toggle_sprint: false,
auto_jump: false,
@@ -408,6 +411,7 @@ impl Settings {
invert_y: f.bool("invertYMouse"),
mouse_wheel_sensitivity: f.double("mouseWheelSensitivity") as f32,
discrete_scroll: f.bool("discrete_mouse_scroll"),
allow_cursor_changes: f.bool("allowCursorChanges"),
toggle_crouch: f.bool("toggleCrouch"),
toggle_sprint: f.bool("toggleSprint"),
toggle_attack: f.bool("toggleAttack"),
@@ -497,6 +501,7 @@ impl Settings {
self.mouse_wheel_sensitivity,
);
put_b(&mut f, "discrete_mouse_scroll", self.discrete_scroll);
put_b(&mut f, "allowCursorChanges", self.allow_cursor_changes);
put_b(&mut f, "toggleCrouch", self.toggle_crouch);
put_b(&mut f, "toggleSprint", self.toggle_sprint);
put_b(&mut f, "toggleAttack", self.toggle_attack);
@@ -616,8 +621,10 @@ pub enum Opt {
SimulationDistance,
Sensitivity,
WheelSensitivity,
InvertY,
DiscreteScroll,
InvertX,
InvertY,
AllowCursorChanges,
Volume(usize),
GuiScale,
ViewBobbing,
+24
View File
@@ -17,6 +17,8 @@ pub enum Signature {
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Textures {
pub skin: Option<String>,
/// The skin's `metadata.model` is `slim` (`PlayerModelType.SLIM`).
pub slim: bool,
pub cape: Option<String>,
pub elytra: Option<String>,
pub signed: bool,
@@ -101,6 +103,13 @@ pub fn unpack(value: &str, signature: Signature, valid_uris: &[&str]) -> Texture
if !allowed {
return empty;
}
if kind == "SKIN" {
out.slim = texture
.get("metadata")
.and_then(|m| m.get("model"))
.and_then(|m| m.as_str())
== Some("slim");
}
let slot = match kind.as_str() {
"SKIN" => &mut out.skin,
"CAPE" => &mut out.cape,
@@ -809,6 +818,21 @@ pub fn sha1(data: &[u8]) -> [u8; 20] {
#[cfg(test)]
mod tests {
/// `MinecraftProfileTexture.getMetadata("model")`: `slim` marks a slim
/// skin; no metadata is the wide (classic) model.
#[test]
fn a_skins_model_comes_from_its_metadata() {
use super::{Signature, TEXTURE_URIS, unpack};
let slim = "eyJ0ZXh0dXJlcyI6eyJTS0lOIjp7InVybCI6Imh0dHBzOi8vdGV4dHVyZXMubWluZWNyYWZ0Lm5ldC90ZXh0dXJlL2FiYyIsIm1ldGFkYXRhIjp7Im1vZGVsIjoic2xpbSJ9fX19";
let wide = "eyJ0ZXh0dXJlcyI6eyJTS0lOIjp7InVybCI6Imh0dHBzOi8vdGV4dHVyZXMubWluZWNyYWZ0Lm5ldC90ZXh0dXJlL2FiYyJ9fX0=";
let t = unpack(slim, Signature::Signed, &TEXTURE_URIS);
assert_eq!(
t.skin.as_deref(),
Some("https://textures.minecraft.net/texture/abc")
);
assert!(t.slim);
assert!(!unpack(wide, Signature::Signed, &TEXTURE_URIS).slim);
}
#[test]
fn profiles_parse_like_resolvable_profiles() {
use super::{NameOrId, Signature, parse_profile};
+18 -10
View File
@@ -48,7 +48,9 @@ impl VaultConfig {
let Tag::Compound(_) = tag else {
return None;
};
let range = |k: &str, default: f64| crate::save::float(tag.get(k)).unwrap_or(default);
let range = |k: &str, default: f64| {
horizon_client_core::nbt_value::float(tag.get(k)).unwrap_or(default)
};
let config = Self {
loot_table: identifier(tag.get("loot_table"))
.unwrap_or_else(|| DEFAULT_LOOT_TABLE.into()),
@@ -81,7 +83,7 @@ impl VaultConfig {
);
}
if let Some(key) = &self.key_item {
out.insert("key_item".into(), crate::save::item_to_nbt(key));
out.insert("key_item".into(), horizon_items::item_nbt::item_to_nbt(key));
}
if let Some(table) = &self.override_loot_table_to_display {
out.insert(
@@ -181,7 +183,7 @@ impl Default for Vault {
/// An `Identifier` (`minecraft` when the namespace is left out).
fn identifier(t: Option<&Tag>) -> Option<String> {
match t? {
Tag::String(s) => crate::mobs::spawner::normalize_id(&s.to_string_lossy()),
Tag::String(s) => horizon_client_core::identifier::normalize_id(&s.to_string_lossy()),
_ => None,
}
}
@@ -189,11 +191,12 @@ fn identifier(t: Option<&Tag>) -> Option<String> {
fn stack(tag: &Tag) -> Option<ItemStack> {
let id = identifier(tag.get("id"))?;
if let Some(count) = tag.get("count")
&& !crate::save::number(Some(count)).is_some_and(|c| (1..=99).contains(&c))
&& !horizon_client_core::nbt_value::number(Some(count))
.is_some_and(|c| (1..=99).contains(&c))
{
return None;
}
let mut item = crate::save::item_from_nbt(tag)?;
let mut item = horizon_items::item_nbt::item_from_nbt(tag)?;
item.item = id.as_str().into();
(&*item.item != "minecraft:air").then_some(item)
}
@@ -266,7 +269,8 @@ impl Vault {
let get = |k: &str| server.and_then(|s| s.get(k));
v.rewarded_players = uuids(get("rewarded_players")).unwrap_or_default();
v.state_updating_resumes_at =
crate::save::number(get("state_updating_resumes_at")).unwrap_or(0);
horizon_client_core::nbt_value::number(get("state_updating_resumes_at"))
.unwrap_or(0);
v.items_to_eject = match get("items_to_eject") {
Some(Tag::List(list)) => list
.iter()
@@ -281,8 +285,9 @@ impl Vault {
let get = |k: &str| shared.and_then(|s| s.get(k));
v.display_item = get("display_item").and_then(stack);
v.connected_players = uuids(get("connected_players")).unwrap_or_default();
v.connected_particles_range = crate::save::float(get("connected_particles_range"))
.unwrap_or(DEFAULT_DEACTIVATION_RANGE);
v.connected_particles_range =
horizon_client_core::nbt_value::float(get("connected_particles_range"))
.unwrap_or(DEFAULT_DEACTIVATION_RANGE);
}
v
}
@@ -305,7 +310,7 @@ impl Vault {
let items = self
.items_to_eject
.iter()
.map(crate::save::item_to_nbt)
.map(horizon_items::item_nbt::item_to_nbt)
.collect();
server.insert("items_to_eject".into(), Tag::List(items));
}
@@ -325,7 +330,10 @@ impl Vault {
fn update_shared(&self) -> Tag {
let mut out = Compound::new();
if let Some(item) = &self.display_item {
out.insert("display_item".into(), crate::save::item_to_nbt(item));
out.insert(
"display_item".into(),
horizon_items::item_nbt::item_to_nbt(item),
);
}
if !self.connected_players.is_empty() {
out.insert(
+5 -5
View File
@@ -632,9 +632,9 @@ dependencies = [
[[package]]
name = "rustls-webpki"
version = "0.103.15"
version = "0.103.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281"
dependencies = [
"ring",
"rustls-pki-types",
@@ -950,14 +950,14 @@ version = "0.26.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9"
dependencies = [
"webpki-roots 1.0.9",
"webpki-roots 1.0.10",
]
[[package]]
name = "webpki-roots"
version = "1.0.9"
version = "1.0.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a"
checksum = "1c1c8e20c8a38fc7d65abfe74eb7280e4aa667e8eec8bc84996d2874810748d9"
dependencies = [
"rustls-pki-types",
]
+42 -4
View File
@@ -265,7 +265,7 @@ impl App {
/// The address requests are counted under, for the rate limits only: kept in memory, never
/// stored or logged. Behind a proxy (Railway) the TCP peer is the proxy, so it is the proxy's
/// `X-Real-IP`, else the last `X-Forwarded-For` entry: the one the proxy added, which a client
/// can't choose (the first one it can).
/// can't choose (the first one it can). An IPv6 client counts by its /64.
fn client(&self, headers: &HeaderMap, peer: Option<SocketAddr>) -> IpAddr {
let header = |name: &str| headers.get(name)?.to_str().ok();
let forwarded = self
@@ -284,9 +284,11 @@ impl App {
})
})
.flatten();
forwarded
.or(peer.map(|p| p.ip()))
.unwrap_or(IpAddr::from([0, 0, 0, 0]))
rate_key(
forwarded
.or(peer.map(|p| p.ip()))
.unwrap_or(IpAddr::from([0, 0, 0, 0])),
)
}
/// The day an install's newest row came in, if it has any left.
@@ -339,6 +341,22 @@ fn take<K: Hash + Eq>(buckets: &Mutex<HashMap<K, (f64, Instant)>>, per_hour: f64
true
}
/// The address a client's requests count under: IPv4 (an IPv4-mapped IPv6 address as IPv4 too),
/// or an IPv6 address's /64, which one home or host holds whole, so addresses rotated inside it
/// don't get fresh buckets.
fn rate_key(ip: IpAddr) -> IpAddr {
match ip {
IpAddr::V6(v6) => match v6.to_ipv4_mapped() {
Some(v4) => IpAddr::V4(v4),
None => {
let s = v6.segments();
IpAddr::V6(std::net::Ipv6Addr::new(s[0], s[1], s[2], s[3], 0, 0, 0, 0))
}
},
v4 => v4,
}
}
fn peer(req: &Request) -> Option<SocketAddr> {
req.extensions()
.get::<ConnectInfo<SocketAddr>>()
@@ -580,3 +598,23 @@ pub fn router(app: Arc<App>) -> Router {
.route("/healthz", get(|| async { "ok" }))
.with_state(app)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ipv6_rates_count_by_64() {
let ip = |s: &str| s.parse::<IpAddr>().unwrap();
// Rotated inside a /64: one key. Another /64: another key.
assert_eq!(rate_key(ip("2001:db8::1")), rate_key(ip("2001:db8::99")));
assert_ne!(rate_key(ip("2001:db8::1")), rate_key(ip("2001:db8:1::1")));
// IPv4-mapped counts as the IPv4 it is.
assert_eq!(
rate_key(ip("::ffff:203.0.113.7")),
IpAddr::from([203, 0, 113, 7])
);
// Plain IPv4 unchanged.
assert_eq!(rate_key(ip("203.0.113.8")), IpAddr::from([203, 0, 113, 8]));
}
}
@@ -261,6 +261,21 @@ async fn an_address_is_rate_limited() {
s.send(from("203.0.113.9, 198.51.100.7", None)).await.0,
StatusCode::NO_CONTENT
);
// Two IPv6 addresses of one /64 share a bucket; another /64 doesn't.
for forwarded in ["2001:db8::1", "2001:db8::2", "2001:db8::3"] {
assert_eq!(
s.send(from(forwarded, None)).await.0,
StatusCode::NO_CONTENT
);
}
assert_eq!(
s.send(from("2001:db8::ffff:ffff", None)).await.0,
StatusCode::TOO_MANY_REQUESTS
);
assert_eq!(
s.send(from("2001:db8:1::1", None)).await.0,
StatusCode::NO_CONTENT
);
}
#[tokio::test]
Binary file not shown.
Binary file not shown.
Binary file not shown.
+1 -1
View File
@@ -25,7 +25,7 @@ Select/Start). No second windowing or event stack: gilrs is polled once per
loop iteration next to winit's events.
- The pure logic (mapping table, deadzones and curves, button edges, the
settings file, glyph choice) lives in `crates/horizon-client/src/gamepad.rs`
settings file, glyph choice) lives in `crates/horizon-client-input/src/gamepad.rs`
and is unit-tested. The game owns a thin gilrs backend and applies actions.
- Controller actions go through the same path a keyboard key takes after
rebinding: an action names a vanilla key mapping (`key.jump`, `key.attack`,
+3 -1
View File
@@ -158,7 +158,9 @@ of the main action. The game's menus keep `LIGHT` and `DARK`.
- **Pixel type for what names and what you act on.** Headings, navigation, buttons and the
wordmark use Pixelify Sans (OFL; Medium, SemiBold, Bold); paragraphs and numbers stay in Geist
and Geist Mono so long text reads easily.
and Geist Mono so long text reads easily. The bundled copies add the two uppercase Cyrillic
letters upstream leaves out, О and П: the Latin O's glyph and the lowercase п lifted to that
weight's cap height, so a Russian heading stays in the pixel face.
- **Blocks, not rounded cards.** Every surface is square: a 2 px dark outline, a 2 px light bevel
along the top and a darker lip along the bottom. The main action is the violet face with its own
bevel and a 4 px lip; it sinks a pixel when pressed. Code and fields are sunk wells.
+36 -7
View File
@@ -119,10 +119,39 @@ ones only there.
## Merging
Pull requests merge with a merge commit. The orchestrator sessions review a pull request, then turn on
auto-merge (`gh pr merge <n> --auto --merge`): GitHub merges it once the required checks pass. Branch
protection on main requires `verify`. Branches don't have to be up to date with main: two sessions merge in
parallel, and main's own `checks` run catches the rare semantic conflict.
Pull requests merge with a merge commit, by hand: never `gh pr merge --auto`. Branch protection on main
requires `verify` for everyone, and Ludovic's review for the files `.github/CODEOWNERS` lists (what builds, signs
and ships releases). Branches don't have to be up to date with main: main's own `checks` run catches the rare
semantic conflict.
**Who merges.** The Horizon Team (GitHub team `horizon-team`: kacpherek, levzzz5154, rdbtCVS, Online5277) and
Ludovic. The orchestrator's agents prove only the risky pull requests (below), so a contributor's ordinary pull
request doesn't wait for them.
**An ordinary pull request merges on CI.** A Team member:
1. reads the whole diff (for a fork, before adding `run-parity`: that runs the fork's code on a maintainer's machine);
2. adds `run-parity` to a fork's pull request; our own branches run `parity` by themselves. Since #2448 it tests
the pull request merged into main;
3. merges once `verify` and `parity` pass, nobody's change request is open, and every comment has been read (a
"don't merge" from anyone holds it).
Never merge your own pull request: another Team member reviews and merges it. Never force-push or rebase a
contributor's branch; push a fixup commit, or merge main into it.
**A risky pull request gets the `needs-proof` label** and waits for an agent's local proof (both single-player
paths, `lan_e2e`, a screenshot for anything visible) before it merges. Risky means any of:
- it changes what is saved or loaded (`save.rs`, `entity_nbt.rs`, `block_entity_nbt.rs`, `region.rs`, `datafix/`):
a bug there damages players' worlds;
- the network or the server thread: `horizon-protocol`, `net.rs`, `remote*.rs`, `lan/`, `lan*.rs`,
`integrated.rs`, `levels.rs`, `dedicated*`;
- world generation (`worldgen/`): seeds must keep generating the same terrain;
- the launcher's installs and updates, or the mod runtime;
- the harnesses themselves (`tools/test_*_parity.py`, the Java oracles): a wrong harness hides regressions;
- over ~500 lines, or `parity` failed, was skipped or ran no harness for gameplay code.
Unsure? Add `needs-proof`: it costs a few hours, a broken release costs more.
`parity` becomes required once it has run green on pull requests for a while (a week without a false failure):
@@ -131,7 +160,7 @@ gh api -X POST repos/horizon-rust/horizon/branches/main/protection/required_stat
```
A skipped `parity` (a fork's pull request without the label) counts as passed, so requiring it never blocks a
fork's pull request; the maintainer decides whether to run it.
fork's pull request: the Team member decides whether to run it, and a skipped run makes the pull request risky.
## Parity on main
@@ -423,7 +452,7 @@ before the gate.
### Trusted reviewers
One list, `.github/trusted-reviewers.txt`, governs two things for the people on it (kacpherek, rdbtCVS and
Online5277 since 2026-10-27) and for Ludovic, with no repository role: `/nightly` (below) and triage by comment.
Online5277 since 2026-10-27, levzzz5154 since 2026-10-28) and for Ludovic, with no repository role: `/nightly` (below) and triage by comment.
```text
/close closes an issue as completed; a pull request is closed without merging
@@ -449,7 +478,7 @@ Online5277 since 2026-10-27) and for Ludovic, with no repository role: `/nightly
### Trusted reviewers pick the Nightly's pull requests
The Nightly is where contributors test pull requests before the gate. The trusted reviewers listed in
`.github/trusted-reviewers.txt` (kacpherek, rdbtCVS and Online5277 since 2026-10-27) and Ludovic pick them with a
`.github/trusted-reviewers.txt` (kacpherek, rdbtCVS and Online5277 since 2026-10-27, levzzz5154 since 2026-10-28) and Ludovic pick them with a
comment on any issue or pull request (`.github/workflows/nightly-command.yml`, `tools/ci/nightly_set.py`):
```text
+5 -5
View File
@@ -5062,9 +5062,9 @@ dependencies = [
[[package]]
name = "rustls-webpki"
version = "0.103.15"
version = "0.103.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281"
dependencies = [
"ring",
"rustls-pki-types",
@@ -6871,14 +6871,14 @@ version = "0.26.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9"
dependencies = [
"webpki-roots 1.0.9",
"webpki-roots 1.0.10",
]
[[package]]
name = "webpki-roots"
version = "1.0.9"
version = "1.0.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a"
checksum = "1c1c8e20c8a38fc7d65abfe74eb7280e4aa667e8eec8bc84996d2874810748d9"
dependencies = [
"rustls-pki-types",
]
+1 -1
View File
@@ -73,7 +73,7 @@ class AllowTests(unittest.TestCase):
def test_the_repos_list_names_the_three_reviewers(self):
reviewers = ns.read_reviewers((ROOT / ns.REVIEWERS).read_text())
self.assertEqual(reviewers.logins, {"kacpherek", "rdbtcvs", "online5277"})
self.assertEqual(reviewers.logins, {"kacpherek", "rdbtcvs", "online5277", "levzzz5154"})
self.assertFalse(reviewers.paused)
class RefusalTests(unittest.TestCase):
+1 -1
View File
@@ -104,7 +104,7 @@ class AllowTests(unittest.TestCase):
def test_the_repos_list_is_the_nightlys(self):
self.assertEqual(ns.REVIEWERS, ".github/trusted-reviewers.txt")
listed = ns.read_reviewers((ROOT / ns.REVIEWERS).read_text())
self.assertEqual(listed.logins, {"kacpherek", "rdbtcvs", "online5277"})
self.assertEqual(listed.logins, {"kacpherek", "rdbtcvs", "online5277", "levzzz5154"})
self.assertFalse(listed.paused)
+15 -1
View File
@@ -86,25 +86,39 @@ impl Window {
struct Mouse { ignore_first: bool }
impl Mouse { fn set_ignore_first_move(&mut self) { self.ignore_first = true; } }
struct Remote;
#[derive(Default)]
struct ServerPacks { prompt: Option<()> }
struct Game {
focused: bool, agent_pointer: bool, window: Option<Window>, mouse: Mouse,
captured: bool, title: Option<()>, screen: Screen, remote: Option<Remote>,
mining: bool, lan_acting: Option<u32>, renderer: Option<Renderer>, cursor: (f32, f32),
server_packs: ServerPacks,
}
impl Default for Game {
fn default() -> Self {
Self { focused: true, agent_pointer: false, window: Some(Window::default()),
mouse: Mouse::default(), captured: false, title: None,
screen: Screen::Playing, remote: None, mining: false, lan_acting: None,
renderer: None, cursor: (0.0, 0.0) }
renderer: None, cursor: (0.0, 0.0), server_packs: ServerPacks::default() }
}
}
impl Game {
fn stop_mining(&mut self) { self.mining = false; }
fn reset_inventory_clicks(&mut self) {}
fn dialog_open(&self) -> bool { false }
METHODS
}
fn main() {
// The server pack prompt keeps the pointer: nothing grabs it while it is open.
{
let mut game = Game::default();
game.server_packs.prompt = Some(());
game.capture(true);
assert!(!game.captured);
game.server_packs.prompt = None;
game.capture(true);
assert!(game.captured);
}
// New and existing worlds, returning to title, and reopening a world.
for remote in [false, true] {
let mut game = Game::default();
+7 -5
View File
@@ -274,8 +274,8 @@ both sides go to --out (host-*.png, guest-*.png).
python3 tools/lan_e2e.py --check chest --check tab
python3 tools/lan_e2e.py --server-thread --check portals # the host's world on its server thread
Needs the prepared resources in .local/horizon-client and, on Linux, vscreen (the desktop); on
macOS both games render offscreen. Games started here have no audio (HORIZON_NO_AUDIO=1).
Needs the prepared resources in .local/horizon-client. On Linux the games run on vscreen (the
desktop), or headless (offscreen, no pause) where there is none; on macOS both games render offscreen. Games started here have no audio (HORIZON_NO_AUDIO=1).
"""
import argparse
import json
@@ -4989,9 +4989,11 @@ def run(ctl, out, resources, checks):
host, guest = Game('host', ctl, resources), Game('guest', ctl, resources)
# Either way explicitly: the game's default is the server thread.
host.d.env['HORIZON_SERVER_THREAD'] = '1' if SERVER_THREAD else '0'
# A headless run (HORIZON_E2E_HEADLESS) has no focused window to draw in: the games render
# offscreen and never pause on the focus loss, or the world would stand still.
headless = bool(os.environ.get('HORIZON_E2E_HEADLESS'))
# A headless run (HORIZON_E2E_HEADLESS, or a Linux without vscreen) has no focused window to draw
# in: the games render offscreen and never pause on the focus loss, or the world would stand still
# (a host paused before its world loads waits at "Connecting to the server..." for ever).
headless = bool(os.environ.get('HORIZON_E2E_HEADLESS')) or (
sys.platform.startswith('linux') and not shutil.which('vscreen'))
launch = ['launch']
if headless or sys.platform == 'darwin':
launch.append('--offscreen')
+1 -1
View File
@@ -62,7 +62,7 @@ def main():
'real OS sleep accuracy is outside the comparison.')
harness.differential(
'rust-frame-limiter-parity', 'VanillaFrameLimiterOracle', 'frame_limiter_probe', make_cases, scope,
sources=[lab.ROOT / 'crates/horizon-client/src/window.rs'])
sources=[lab.ROOT / 'crates/horizon-client-input/src/window.rs'])
if __name__ == '__main__':
+2 -1
View File
@@ -154,7 +154,8 @@ def main():
'FramerateLimiter and vsync present-mode selection are covered by Rust unit tests only.')
harness.differential(
'rust-game-loop-parity', 'VanillaGameLoopOracle', 'game_loop_probe', make_cases, scope,
sources=[lab.ROOT / f'crates/horizon-client/src/{name}.rs' for name in ('clock', 'mouse', 'window', 'keymapping')])
sources=[lab.ROOT / 'crates/horizon-client/src/clock.rs']
+ [lab.ROOT / f'crates/horizon-client-input/src/{name}.rs' for name in ('mouse', 'window', 'keymapping')])
if __name__ == '__main__':
+1 -1
View File
@@ -43,7 +43,7 @@ def main():
"Includes passing entity interactions over solid blocks: only the held "
"item may be tried afterward. "
"Game-mode side effects and world simulation are controlled fixtures.",
sources=[lab.ROOT / "crates/horizon-client/src/interaction_input.rs",
sources=[lab.ROOT / "crates/horizon-client-input/src/interaction_input.rs",
lab.ROOT / "crates/horizon-client/src/game/remote.rs",
lab.ROOT / "crates/horizon-client/src/game/hand.rs",
lab.ROOT / "mods/horizon/src/test/java/dev/horizonlab/parity/ParityData.java"])
+1 -1
View File
@@ -35,7 +35,7 @@ def main():
build=lab.LOCAL/'rust-local-input';build.mkdir(exist_ok=True)
report=build/'report.json';report.unlink(missing_ok=True)
oracle=lab.ROOT/'mods/horizon/src/test/java/dev/horizonlab/parity/VanillaLocalInputOracle.java'
sources=[Path(__file__),lab.ROOT/'tools/test_rust_parity.py',lab.ROOT/'tools/test_model_baking_parity.py',lab.ROOT/'tools/lab.py',oracle,lab.ROOT/'Cargo.toml',lab.ROOT/'Cargo.lock',lab.ROOT/'crates/horizon-client/Cargo.toml',lab.ROOT/'crates/horizon-client/src/input.rs',lab.ROOT/'crates/horizon-client/src/lib.rs',lab.ROOT/'crates/horizon-client/examples/probe/input_probe.rs']
sources=[Path(__file__),lab.ROOT/'tools/test_rust_parity.py',lab.ROOT/'tools/test_model_baking_parity.py',lab.ROOT/'tools/lab.py',oracle,lab.ROOT/'Cargo.toml',lab.ROOT/'Cargo.lock',lab.ROOT/'crates/horizon-client/Cargo.toml',lab.ROOT/'crates/horizon-client-input/src/input.rs',lab.ROOT/'crates/horizon-client/src/lib.rs',lab.ROOT/'crates/horizon-client/examples/probe/input_probe.rs']
tested=base.source_hashes(sources)
def run(command,name):
with (build/(name+'.log')).open('w') as log:subprocess.run(list(map(str,command)),cwd=lab.ROOT,check=True,timeout=600,stdout=log,stderr=subprocess.STDOUT)
+2 -2
View File
@@ -189,8 +189,8 @@ def main():
harness.differential(
'rust-options-parity', 'VanillaOptionsOracle', 'options_probe', make_cases, scope,
sources=[lab.ROOT / 'crates/horizon-client/src/options_file.rs', lab.ROOT / 'crates/horizon-client/src/options_fixes.rs',
lab.ROOT / 'crates/horizon-client/src/java_text.rs', lab.ROOT / 'crates/horizon-client/src/key_names.rs',
lab.ROOT / 'crates/horizon-client/src/keybinds.rs'])
lab.ROOT / 'crates/horizon-client/src/java_text.rs', lab.ROOT / 'crates/horizon-client-input/src/key_names.rs',
lab.ROOT / 'crates/horizon-client-input/src/keybinds.rs'])
if __name__ == '__main__':
+2 -1
View File
@@ -347,7 +347,8 @@ def horizon_resave(source, destination, bin_dir):
socket = Path(tempfile.mkdtemp(prefix='hzrt')) / 'control.sock' # sockets need short paths
ctl = [str(bin_dir / 'horizon-ctl'), '--socket', str(socket)]
extra = []
if sys.platform == 'darwin':
# Offscreen on macOS, and on a Linux without vscreen (never a window on a real monitor).
if sys.platform == 'darwin' or not shutil.which('vscreen'):
env = lab.game_env()
extra = ['--offscreen']
else:
+1
View File
@@ -233,6 +233,7 @@ def main():
sources = [oracle, Path(__file__), lab.ROOT / 'crates/horizon-client/examples/probe/natural_spawn_fixture.rs',
lab.ROOT / 'crates/horizon-client/src/mobs/spawner.rs',
lab.ROOT / 'crates/horizon-client/src/chunk/block_entity.rs',
lab.ROOT / 'crates/horizon-client-core/src/identifier.rs',
lab.ROOT / 'crates/horizon-client/src/mobs/spawn_selection.rs',
lab.ROOT / 'crates/horizon-client/src/mobs/spawn_rules.rs']
tested = base.source_hashes(sources)
+1 -1
View File
@@ -40,7 +40,7 @@ def main():
build=lab.LOCAL/'rust-sprint-input';build.mkdir(exist_ok=True)
report=build/'report.json';report.unlink(missing_ok=True)
oracle=lab.ROOT/'mods/horizon/src/test/java/dev/horizonlab/parity/VanillaSprintInputOracle.java'
sources=[Path(__file__),lab.ROOT/'tools/test_rust_parity.py',lab.ROOT/'tools/test_model_baking_parity.py',lab.ROOT/'tools/lab.py',oracle,lab.ROOT/'Cargo.toml',lab.ROOT/'Cargo.lock',lab.ROOT/'crates/horizon-client/Cargo.toml',lab.ROOT/'crates/horizon-client/src/input.rs',lab.ROOT/'crates/horizon-client/src/sprint.rs',lab.ROOT/'crates/horizon-client/src/lib.rs',lab.ROOT/'crates/horizon-client/examples/probe/sprint_probe.rs']
sources=[Path(__file__),lab.ROOT/'tools/test_rust_parity.py',lab.ROOT/'tools/test_model_baking_parity.py',lab.ROOT/'tools/lab.py',oracle,lab.ROOT/'Cargo.toml',lab.ROOT/'Cargo.lock',lab.ROOT/'crates/horizon-client/Cargo.toml',lab.ROOT/'crates/horizon-client-input/src/input.rs',lab.ROOT/'crates/horizon-client/src/sprint.rs',lab.ROOT/'crates/horizon-client/src/lib.rs',lab.ROOT/'crates/horizon-client/examples/probe/sprint_probe.rs']
tested=base.source_hashes(sources)
def run(command,name):
with (build/(name+'.log')).open('w') as log:subprocess.run(list(map(str,command)),cwd=lab.ROOT,check=True,timeout=600,stdout=log,stderr=subprocess.STDOUT)
+1 -1
View File
@@ -69,7 +69,7 @@ def main():
'not exercised; KeyboardInput then reads isDown as the sprint and local input harnesses compare.')
harness.differential(
'rust-toggle-keys-parity', 'VanillaToggleKeysOracle', 'toggle_keys_probe', make_cases, scope,
sources=[lab.ROOT / 'crates/horizon-client/src/keymapping.rs'])
sources=[lab.ROOT / 'crates/horizon-client-input/src/keymapping.rs'])
if __name__ == '__main__':
+1
View File
@@ -198,6 +198,7 @@ def main():
lab.ROOT / 'crates/horizon-client/src/mobs/trial_spawner.rs',
lab.ROOT / 'crates/horizon-client/src/mobs/spawner.rs',
lab.ROOT / 'crates/horizon-client/src/chunk/block_entity.rs',
lab.ROOT / 'crates/horizon-client-core/src/identifier.rs',
lab.ROOT / 'crates/horizon-client/src/mobs/spawn_selection.rs',
lab.ROOT / 'crates/horizon-client/src/mobs/spawn_rules.rs']
tested = base.source_hashes(sources)
+3 -1
View File
@@ -175,7 +175,9 @@ def main():
oracle = lab.ROOT / 'mods/horizon/src/test/java/dev/horizonlab/parity/VanillaNaturalSpawnOracle.java'
sources = [oracle, Path(__file__), lab.ROOT / 'crates/horizon-client/examples/probe/natural_spawn_fixture.rs',
lab.ROOT / 'crates/horizon-client/src/vault.rs',
lab.ROOT / 'crates/horizon-client/src/chunk/block_entity.rs']
lab.ROOT / 'crates/horizon-client/src/chunk/block_entity.rs',
lab.ROOT / 'crates/horizon-client-core/src/identifier.rs',
lab.ROOT / 'crates/horizon-client-core/src/nbt_value.rs']
tested = base.source_hashes(sources)
inputs = build / 'inputs.txt'
cases = corpus()
+3 -2
View File
@@ -34,9 +34,10 @@ CYCLES = 3
def game_env():
"""macOS renders offscreen; Linux uses the virtual screen (as test_save_roundtrip_parity)."""
"""macOS renders offscreen; Linux uses the virtual screen (as test_save_roundtrip_parity), or renders
offscreen too where there is no vscreen."""
env = lab.game_env()
if sys.platform == 'darwin':
if sys.platform == 'darwin' or not shutil.which('vscreen'):
return env, ['--offscreen']
subprocess.run(['vscreen', 'up'], check=False, capture_output=True)
text = subprocess.run(['vscreen', 'env'], capture_output=True, text=True, check=True).stdout