Compare commits

...
Author SHA1 Message Date
Ludovic Marie 2a8c60b6df Merge pull request #3351 from horizon-rust/draft/remove-wasm-mods-crates
🤖 Agent: WebAssembly removal 2/4 (crates: runtime, SDK, official WebAssembly mods), on #3350, merged up to date with main c7065027b. Workspace compiles (--all-targets --locked). Merge after #3350.
2026-10-30 00:56:47 +01:00
levzzz 4693954aa1 Merge pull request #4016 from d3grando/fix-3393-eggs
Thrown eggs and snowballs turn with the camera, no flip looking straight up
2026-10-30 04:56:16 +05:00
Ludovic Marie 0208c75ef4 Merge pull request #3979 from DUzzL/fix/issue-3975-owners-doc
🤖 Agent: Ludovic's AI agent (Claude), triage. Docs only: a server.properties → server.toml page for owners, linked from docs/README. verify is green. Queued.
2026-10-30 00:51:53 +01:00
Ludovic Marie 71527fa938 Merge remote-tracking branch 'origin/main' into upd-4016
# Conflicts:
#	crates/horizon-client/src/game/creatures.rs
2026-10-30 00:42:49 +01:00
levzzz d6adbe1cb5 Merge pull request #4057 from degepie/fix/issue-3422-shield-slowdown
A raised shield keeps a sprint from starting, as in vanilla (#3422)
2026-10-30 04:37:03 +05:00
levzzz bc93f7da59 Merge pull request #4063 from arismoko/fix/command-loot-name-parity
Preserve item modifier custom name components
2026-10-30 04:36:46 +05:00
levzzz 8b843008eb Merge pull request #3776 from GIGABAIT93/fix/text-display-passengers
Text displays ride their vehicles: an NPC's name sits over its head
2026-10-30 04:36:36 +05:00
levzzz 8bd015284b Merge pull request #3605 from deloskiytbackup/fix/pumpkin-head-rendering
Render: draw carved pumpkins and mob heads on player and humanoid heads
2026-10-30 04:36:20 +05:00
levzzz b97e677cbc Merge pull request #3998 from d3grando/fix-3829
Players and heads keep their skins on busy servers
2026-10-30 04:36:04 +05:00
levzzz 117fbcd1aa Merge pull request #3922 from GIGABAIT93/feat/remote-player-scale
Other players and mannequins take their minecraft:scale
2026-10-30 04:35:35 +05:00
levzzz 5bafa93fa0 Merge pull request #4020 from DenisHumen/fix/sleep-camera
Sleeping: lie 0.125 above the bed's top, as vanilla does
2026-10-30 04:35:18 +05:00
levzzz ae4660dfdf Merge pull request #4021 from phexuss/fix/station-scrollbar-drag
Drag the stonecutter and villager trade scrollers
2026-10-30 04:35:02 +05:00
levzzz 141b1e0cf5 Merge pull request #4026 from JustKarkat/test/text-display-shadow-parity
Add shadow_color coverage to the text display render parity test
2026-10-30 04:34:45 +05:00
levzzz bf4d46df7e Merge pull request #3594 from d3grando/fix-3503
Paintings: the item's variant is placed and named in its tooltip
2026-10-30 04:34:28 +05:00
levzzz 4bf451bd2d Merge pull request #3611 from degepie/fix/issue-3027-adventure-ghost-blocks
Adventure: don't predict breaking a block the player may not break (#3027)
2026-10-30 04:33:59 +05:00
levzzz 4c8178f4fd Merge pull request #3620 from iLostName/pearl-through-end-crystal
ender pearl: one that hits an end crystal still takes its thrower there
2026-10-30 04:33:43 +05:00
levzzz f38db18183 Merge pull request #3624 from deloskiytbackup/fix/tool-break-sound
Audio: play break sound and track stats when tools break while mining
2026-10-30 04:33:26 +05:00
levzzz 919c6803c5 Merge pull request #3647 from iLostName/elder-curse-skips-cursed
elder guardian: a player already cursed isn't cursed again every minute
2026-10-30 04:33:10 +05:00
levzzz abce5d7864 Merge pull request #4044 from DenisHumen/fix/armor-stand-swing
Armor stands: placing one swings the hand, as vanilla does
2026-10-30 04:32:54 +05:00
levzzz 54ac508d90 Merge pull request #3686 from k3yh/fix/spawn-egg-entity-data
An op's spawn egg sets its entity data on a spawner
2026-10-30 04:32:37 +05:00
levzzz 2f6babf098 Merge pull request #3900 from DUzzL/fix/issue-3885-function-tag-dedupe
Fix O(n^2) function-tag resolution and tag cycles
2026-10-30 04:32:20 +05:00
levzzz df051f7089 Merge pull request #3906 from DUzzL/fix/issue-3889-explosion-drop-merge
Explosion drops merge by item, components and max stack size
2026-10-30 04:32:04 +05:00
levzzz 706fc7aa13 Merge pull request #3907 from DUzzL/fix/issue-3884-recipe-count-range
Reject recipe result counts outside 1..=99 (Fixes #3884)
2026-10-30 04:31:55 +05:00
levzzz db0a98d645 Merge pull request #4017 from DUzzL/fix/issue-4013-blocked-fireball-no-ignite
Keep a shield-blocked small fireball from igniting its target
2026-10-30 04:31:26 +05:00
levzzz b8eb63db68 Merge pull request #4054 from DUzzL/fix/issue-4038-enderman-arrow-teleport
Arrows fly on when an enderman teleports away from them
2026-10-30 04:31:10 +05:00
levzzz ce18dafd4e Merge pull request #3928 from xFedeT/fix/riptide-with-elytra
Riptide in rain works on a server's client: isInRain from its own chunks and weather
2026-10-30 04:30:26 +05:00
levzzz 971af532f9 Merge pull request #3991 from d3grando/fix-3393-darkness
Darkness dims the screen on servers and in single player
2026-10-30 04:30:10 +05:00
levzzz bdf9cb8eb0 Merge pull request #4029 from d3grando/fix-3810
No rain splashes or rain sound under a high roof
2026-10-30 04:29:42 +05:00
levzzz 1b90753fab Merge pull request #4033 from d3grando/fix-2780-wolfbeg
Wolves tilt their head at food they want
2026-10-30 04:29:26 +05:00
levzzz b890329854 Merge pull request #4035 from d3grando/fix-2893
Ender Dragon wings push the player only when their hit lands
2026-10-30 04:29:09 +05:00
levzzz e618347e38 Merge pull request #4036 from d3grando/fix-2780-babypet
Food grows a tamed baby cat or wolf instead of making it sit
2026-10-30 04:28:53 +05:00
levzzz dbf445db2d Merge pull request #4053 from d3grando/fix-3616
Your body keeps up with the camera at Levitation 255
2026-10-30 04:28:37 +05:00
levzzz bf702cfe22 Merge pull request #3853 from DUzzL/fix/armor-stand-elytra-3843
Draw a worn elytra on armour stands
2026-10-30 04:28:20 +05:00
levzzz 03805b8eeb Merge pull request #3965 from GIGABAIT93/fix/shadow-scale
Entity shadows follow the entity's scale and age
2026-10-30 04:28:03 +05:00
levzzz c081525853 Merge pull request #3959 from DenisHumen/fix/golem-pattern-orientations
Golems: a snow or iron golem builds in any orientation, as vanilla does
2026-10-30 04:27:46 +05:00
levzzz 6a6acac27c Merge pull request #4022 from d3grando/fix-3098
Withers and golems build in any orientation, like vanilla
2026-10-30 04:27:30 +05:00
levzzz c5e9265bf0 Merge pull request #3797 from Bloodulon/pr/packs-overlay
Packs: read deflate entries to their end; skip a bad entry
2026-10-30 04:27:14 +05:00
levzzz 421834b82e Merge pull request #3799 from DenisHumen/fix/bucket-eye-block
Buckets and bottles pick up the water the eyes are in
2026-10-30 04:26:58 +05:00
levzzz a98cf88458 Merge pull request #3865 from elythepreciouscat/fix/server-disconnect-saving-screen
Disconnect: do not show saving world screen on external server
2026-10-30 04:26:41 +05:00
levzzz 79aca92478 Merge pull request #3867 from bxnjxmin27/fix/3689-screenshot-while-chat
Screenshots: the key works with chat, a sign or a book open
2026-10-30 04:26:25 +05:00
levzzz 48d29b20f1 Merge pull request #3873 from DUzzL/fix/issue-3863-breed-advancement
Fix breeding advancement and statistic on the server thread
2026-10-30 04:26:09 +05:00
levzzz 0229716f22 Merge pull request #3895 from ccclosed/fix/mob-effect-attribute-modifiers
Mobs: apply Weakness, Slowness and Health Boost attribute modifiers
2026-10-30 04:25:52 +05:00
levzzz a3809490f6 Merge pull request #3898 from Bloodulon/pr/item-definitions
Client: load item model definitions from nested folders
2026-10-30 04:25:36 +05:00
levzzz 8e49a414c2 Merge pull request #3857 from DUzzL/fix/issue-3854-lever-piston
Fix lever dropping when placed on a piston's back
2026-10-30 04:24:46 +05:00
levzzz 33e56c7a56 Merge pull request #3851 from DUzzL/fix/axolotl-nbt-glowing-test-3840
Fix axolotl NBT test: query glowing effect by namespaced id
2026-10-30 04:24:18 +05:00
Ludovic MarieandClaude Opus 5.5 37a1e8859f Merge main: RemoteTextDisplay::new takes what the display shows
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 00:18:16 +01:00
Ludovic Marie 6d666c30e2 Merge remote-tracking branch 'origin/main' into upd-3776 2026-10-30 00:16:56 +01:00
Ludovic Marie 4bd1313677 Merge pull request #4049 from horizon-rust/fix/launcher-java-home-version
🤖 Agent: A Java instance's Home no longer shows Horizon's version picker or nightly updates; its chip names its own version and loader. Checked in the real launcher (scratch homes, Fabric Java instance and Horizon instance), launcher fmt/clippy/278 tests pass on this head.
2026-10-30 00:14:55 +01:00
Ludovic Marie 6eeeb454f5 Merge pull request #3805 from DenisHumen/fix/rea-vanilla-windows
🤖 Agent: Ludovic's AI agent (Claude), triage. Tooling only: `npx` is resolved by path (`npx.cmd` on Windows), with a clear error when it's missing. verify is green. Queued.
2026-10-30 00:14:52 +01:00
arismoko 8eb2ecfa76 Preserve loot name components in item commands 2026-10-29 18:10:27 -05:00
Ludovic Marie c3071c7b61 Merge pull request #3575 from d3grando/fix-3153
🤖 Agent: pr-triage-1 (Ludovic's AI agent, Claude), not Ludovic himself. Thanks for the fix: with the wither held in place the scenario now matches vanilla. On this head (f5de793b3) merged into current main, the harnesses this change can reach pass: `command_exec`'s wither spawn (4/4), wither breaks (9/9) and ender dragon (5/5) groups match vanilla. Its 2 remaining mismatches are `/data get` printing a `custom_name` as a component; they come from main and this PR doesn't touch them. fmt, clippy and the horizon-client tests pass too. Queued for merge.
2026-10-30 00:09:06 +01:00
DUzzL 80cb00a494 Arrows fly on when an enderman teleports away from them 2026-10-30 00:08:47 +01:00
Ludovic MarieandClaude Opus 5.5 fcea81151c Launcher: a Java instance's Home shows its own game, not Horizon's versions
Home drew Horizon's version picker ("No version" or a nightly) beside a Java instance's Play,
and offered Horizon's next nightly in its hero. A Java instance now shows neither: its chip
says its Minecraft version and loader ("Minecraft 1.21.1 · Fabric 0.16.9").

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 00:05:05 +01:00
d3grandoandClaude Opus 5.5 14d453048e Your body keeps up with the camera at Levitation 255
The third-person model dropped its interpolation whenever it moved more
than 10 blocks in a tick, while the camera kept lerping from the old
position. Levitation 254/255 rises about 12 blocks a tick, so the body
sat a whole tick ahead of the camera and left the frame.

The model now lerps from the player's own old position, as vanilla's
EntityRenderer.extractRenderState does from Entity.xOld/yOld/zOld, the
same one Camera.setup uses. Teleports and respawns already reset that
old position, so they still jump without a sweep.

Fixes #3616

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 06:03:07 +07:00
Ludovic Marie 224b99b724 Merge pull request #3930 from DUzzL/fix/issue-3916-dead-code
🤖 Agent: Ludovic's AI agent (Claude), triage. Removes `ctov.rs`'s modular_compat code, which nothing calls any more (lithostitched owns that processor), with no change in behaviour. verify is green. Queued.
2026-10-29 23:59:44 +01:00
Ludovic Marie e8ecd77cc8 Merge pull request #3879 from matyden/tools/port-publish-utf8
🤖 Agent: Ludovic's AI agent (Claude), triage. Tooling only: the issue body is written as UTF-8 whatever the code page. verify is green. Queued.
2026-10-29 23:57:20 +01:00
Ludovic Marie 97b6e16684 Merge pull request #3696 from xFedeT/fix/chicken-water-malus
🤖 Agent: Ludovic's AI agent (Claude), triage. Main gained another mob_ai case beside yours, so I merged main into your branch (no rebase). The only conflict was the new cases' place in `tools/test_mob_ai_parity.py`, and both are kept. Requeued at the merged head.
2026-10-29 23:57:16 +01:00
DenisHumenandClaude Opus 5.5 fdc7593180 Armor stands: placing one swings the hand, as vanilla does
ArmorStandItem.useOn returns SUCCESS once the stand fits (FAIL on a bottom
face or when something is in its box), and SUCCESS swings the hand. The
local placement now swings, and a server's client runs the same check in
startPrediction, before UseItemOn goes out, and swings when it holds.

armor_stand_spawn holds the checks both share.

Fixes #4024

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 00:57:02 +02:00
degepieandClaude Sonnet 5.5 b1dcbdc0eb A raised shield (or any item in use) stops a sprint from starting
LocalPlayer.canStartSprinting refuses while isSlowDueToUsingItem (an item in
use whose use_effects do not set can_sprint), and LocalPlayer.aiStep zeroes
the double-tap window while it holds. The sprint phase had the condition but
the player never fed it, so the sprint key or a double-tap of forward could
start a sprint with a shield up, which vanilla does not allow (measured: 0.055
blocks per tick against 0.043 for the shielded walk). Items now carry
use_effects.can_sprint (spears set it) and the window passes the state to the
sprint phase.

Fixes #3422

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UE3PMKbnTcxfBSeT5uQWCK
2026-10-30 03:46:46 +05:00
d3grandoandClaude Opus 5.5 450ff69b4e Food grows a tamed baby cat or wolf instead of making it sit
The owner's sit toggle on tamed wolves and cats only stood aside for food
an adult could breed with (age 0, not in love), so fish or meat on a tamed
baby flipped it between sitting and standing and it never grew.

Wolf.mobInteract and Cat.mobInteract call TamableAnimal/Animal.mobInteract
first and toggle setOrderedToSit only when that did not consume the click;
Animal.mobInteract takes the food from an adult that canFallInLove at age 0
or from a baby that canAgeUp (AgeableMob.canAgeUp: isBaby and not
isAgeLocked), growing it by getSpeedUpSecondsWhenFeeding.

mobs::ai::takes_food is that test, shared by the sit guards and
feed_animal (which now also leaves age-locked babies alone, as canAgeUp
does).

Part of #2780.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 05:46:18 +07:00
Ludovic Marie 49aa9dd00b Merge remote-tracking branch 'origin/main' into upd-3696
# Conflicts:
#	tools/test_mob_ai_parity.py
2026-10-29 23:46:09 +01:00
d3grandoandClaude Opus 5.5 b3fdebcb92 Ender Dragon wings push the player only when their hit lands
EnderDragon.knockBack pushes the server's copy of every player in a wing's
zone each tick, but a player's own client only receives its velocity when a
hurt marks it (LivingEntity.hurtServer's markHurt, sent by
ServerEntity.sendChanges). Horizon added the push straight to the player's
velocity on every tick they stayed in the zone, perched dragon or not, so
players were flung several blocks per tick around the exit portal.

The push now goes out only with a 5-damage wing hit that lands outside the
hurt cooldown: never under a perched dragon (isSitting), and not within 2
ticks of a mob's hurt (getLastHurtByMobTimestamp() < tickCount - 2). The
vertical push is 0.2f widened, as in vanilla.

Fixes #2893

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 05:45:42 +07:00
d3grandoandClaude Opus 5.5 3ffaf67e22 Wolves tilt their head at food they want
BegGoal sets Wolf.DATA_INTERESTED_ID on the server, but the LAN host
never sent entry 20 and a client never read it, so every window
(single player joins its server thread the same way) kept the head
straight. Send the flag with its default, read it into the remote
wolf, and run the part of Wolf.tick both sides run on the client:
interestedAngle eases 0.4 of the way toward the flag each tick, which
getHeadRollAngle turns into the head's roll.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 05:43:53 +07:00
Ludovic MarieandClaude Opus 5.5 3d68cb4d71 Merge main: CustomHeadLayer beside main's player pumpkin (#3584)
head_item_geometry now draws the local player's and other players' head
items; main's player_pumpkin_geometry stays only for LAN guests drawn on
the in-frame host, whose models carry no worn stacks.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 23:41:28 +01:00
Ludovic Marie bd665f28ad Merge pull request #3782 from xFedeT/fix/armor-stand-bubble-column
🤖 Agent: Ludovic's AI agent (Claude), triage. `travelInWater` (the 0.8 slowdown, then `getFluidFallingAdjustedMovement` with gravity/16) and the bubble column push for armour stands match vanilla. The per-tick water scan only covers the stand's own box. CI parity is green at this head. Queued.
2026-10-29 23:40:26 +01:00
JustKarkat 2982f6eb9a Add shadow_color coverage to the text display render parity test
Fixes #3531's render side landed in #3341; this adds the shadow cases its
branch carried for tools/test_text_display_render_parity.py so the
packed ARGB ints, the [r, g, b, a] lists and NO_SHADOW=0 are checked
against the vanilla TextDisplayRenderer oracle.
2026-10-29 17:40:25 -05:00
Ludovic Marie 499a08ba81 Merge pull request #3690 from xFedeT/fix/fish-random-swimming-parity
🤖 Agent: Ludovic's AI agent (Claude), triage. `WaterAnimal` and `AgeableWaterCreature` set the WATER malus to 0, and FishMoveControl's yaw uses `Mth.atan2` in degrees. CI parity (mob_ai with new fish cases) is green at this head. Queued.
2026-10-29 23:40:22 +01:00
Ludovic Marie 7f54b8abb4 Merge pull request #2705 from senjinthedragon/hdr-tiers
🤖 Agent: pr-triage-1: the parity run was cancelled (not failed); I reran it on this same head.
2026-10-29 23:36:18 +01:00
Ludovic Marie 573140cc5a Merge pull request #3639 from xFedeT/fix/wither-immune-to-wither-friends
🤖 Agent: Ludovic's AI agent (Claude), triage. `WitherBoss.hurtServer` ignores damage whose source entity is in `#wither_friends` (the undead), for explosions and wither skulls alike. CI parity is green at this head. Queued.
2026-10-29 23:35:00 +01:00
Ludovic Marie bdd6275570 Merge pull request #3722 from vvnikita74/fix/no-day-sleep-thunder-flag
🤖 Agent: Ludovic's AI agent (Claude), triage. Sleeping now follows the sky darkening (`skyDarken >= 4`, `isDarkOutside`) instead of a fixed time window or the thunder flag alone, so a thunderstorm at noon allows it and rain alone doesn't. CI parity is green at this head. Queued.
2026-10-29 23:34:55 +01:00
Ludovic Marie 588c0ea77a Merge pull request #3779 from leifiyoo/fix/powered-minecart-speed
🤖 Agent: Ludovic's AI agent (Claude), triage. `MinecartFurnace.push` is a Vec3, and `calculateNewPushAlong` (`projectedOn(...).normalize().scale(push.length())`) keeps its 3D length; saving drops y as `addAdditionalSaveData` does. CI parity is green at this head. Queued.
2026-10-29 23:34:52 +01:00
Ludovic Marie 3f08e59ad2 Merge pull request #3767 from leifiyoo/fix/template-block-entity
🤖 Agent: Ludovic's AI agent (Claude), triage. Matches `StructureTemplate.placeInWorld`, which sets a barrier wherever saved data goes, so the old block entity always goes and the template's data always loads. CI parity is green at this head. Queued.
2026-10-29 23:34:48 +01:00
Ludovic Marie 518ecfb511 Merge pull request #3750 from leifiyoo/fix/chunk-block-entity-replace
🤖 Agent: Ludovic's AI agent (Claude), triage. Read the whole diff: a loaded chunk's block entity replacing one at the same position keeps its ticker slot only if the new state ticks, as `LevelChunk.setBlockEntity` + `updateBlockEntityTicker` do. CI parity is green at this head. Queued.
2026-10-29 23:34:45 +01:00
levzzz 6daad36de2 Merge pull request #3931 from arismoko/content-identity-handshake
Reject mismatched content block registries
2026-10-30 03:31:18 +05:00
Ludovic Marie 91635b6852 Merge pull request #3637 from horizon-rust/feat/launcher-night-sky
🤖 Agent: Redesign R2 (the animated night sky behind every page), stacked on #3636 which merged; approved by Ludovic 2026-10-29 (8 px/s clouds accepted); launcher fmt/clippy/tests and sky.rs tests pass on this head, CI green.
2026-10-29 23:30:36 +01:00
d3grandoandClaude Opus 5.5 58cdd754cd No rain splashes or rain sound under a high roof
ClientLevel.tickWeatherEffects reads the MOTION_BLOCKING heightmap for
each splash column, but the client scanned down from 64 blocks above the
camera, so under a roof higher than that (a deep excavation) it found
the floor and made rain particles and the rain sound there. The splash
columns, the sound's roof check and the animate tick's isRainingAt now
read the chunk heightmap that the rain columns already use.

Fixes #3810

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 05:30:02 +07:00
Ludovic Marie 0e15c274da Merge pull request #3636 from horizon-rust/feat/launcher-night-specimen
🤖 Agent: Redesign R1b (the night look's specimen page, pixel-cut plates in plate.rs), stacked on #3635 which merged; approved by Ludovic 2026-10-29; launcher fmt/clippy/tests and plate.rs tests pass on this head, CI green.
2026-10-29 23:28:48 +01:00
Ludovic MarieandClaude Opus 5.5 3b6972a40a Slowness: the -0.15F modifier widened from a float, as vanilla's MobEffects
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 23:27:40 +01:00
Ludovic Marie 829c4021a3 Merge pull request #3635 from horizon-rust/feat/launcher-night-tokens
🤖 Agent: Redesign R1 (night tokens, Bricolage with Geist's Cyrillic, pixel-cut corners), approved by Ludovic on 2026-10-29; launcher fmt/clippy/278 tests and horizon-theme tests pass on this head, CI green.
2026-10-29 23:26:38 +01:00
DUzzL 7a12e4444a Keep a shield-blocked small fireball from igniting its target 2026-10-29 23:25:57 +01:00
DenisHumenandClaude Opus 5.5 c3e9ec54a9 Sleeping: lie 0.125 above the bed's top, as vanilla does
LivingEntity.setPosToBed puts a sleeper at the bed's x/z centre and at
pos.y + getSleepHeight + 0.125, getSleepHeight being the top of the bed's
shape (0.5625). Horizon left out the 0.125, so the player and sleeping
villagers lay 0.125 lower, and the first-person camera (eye 0.2 + 0.15)
ended up inside the player's own head.

Fixes #4006

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 00:25:36 +02:00
d3grandoandClaude Opus 5.5 4b09c9d7d1 Thrown eggs and snowballs turn with the camera, no flip looking straight up
The item billboard of eggs, snowballs, ender pearls, fireballs and llama
spit took its right axis from forward x Y. At pitch exactly ±90 forward is
vertical; f32 cos(90°) is slightly negative, so the axis flipped sign and
the sprite turned upside down. It also ignored third-person front view.

ThrownItemRenderer.submit rotates the item by CameraRenderState.orientation
(Camera.setRotation: rotationYXZ(π - yaw, -pitch, 0), turned round in front
view). Use the same rotation: camera_orientation's math moves to a free
camera_rotation so the billboard's axes come from it.

Part of #3393

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 05:18:25 +07:00
Ludovic Marie 1884052eda Merge pull request #3471 from d3grando/bench-windows-results
🤖 Agent: pr-triage-1: read the new head. It only merges main and replaces the RD 8 and RD 32 reports with reruns on an idle machine, so the write-up no longer leans on runs with builds alongside. tests/test_perf_bench.py and the public tree check pass. skip-release stays. Thank you!
2026-10-29 23:17:02 +01:00
Claude 0801c954b5 Drag the stonecutter and villager trade scrollers
The scrollers on the stonecutter and merchant screens only moved with the
mouse wheel: pressing and dragging them did nothing. Port
StonecutterScreen.mouseClicked/mouseDragged and
MerchantScreen.mouseClicked/mouseDragged, and keep the stonecutter's
scrollOffs as a float like vanilla so the wheel and the scroller share it.

Part of #3518.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Rx5M6NS763D2WVnb2HmcTH
2026-10-29 22:14:39 +00:00
d3grandoandClaude Opus 5.5 5200d36a7b Withers build in any orientation, like vanilla's BlockPattern
The wither checks (WitherSkullBlock.checkSpawn and the dispenser's
canSpawnMob) only tried upright T builds with the arms along X or Z.
Vanilla's BlockPattern.find tries all 24 forwards/up pairs from every
corner in the cube at the placed block, so flat, upside-down and
sideways withers build there.

Port BlockPattern (find, matches, translateAndRotate) to
horizon_client::block_pattern with WitherSkullBlock's full and base
patterns, and use it for checkSpawn and canSpawnMob. The wither now
takes its yaw from the match's forwards axis (main had it inverted for
upright builds), its base blocks from #minecraft:wither_summon_base_blocks,
and its summoned_entity trigger from the 50-block box, sharing the
golems' box check.

Fixes #3098.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 05:14:38 +07:00
Ludovic Marie 2204e39515 Merge pull request #2987 from kacpherek/fix/entity-physics-lan-parity
🤖 Agent: proven by pr-triage-3 (Ludovic's AI agent) on union/pt3-w2 db5591ac7: parity --changed all passed, clippy, tests, lan_e2e 11 checks on both paths; tipped/spectral arrows and text/block/item displays drawn alike by host and LAN guest, save and reload identical, the ominous item spawner seen by a LAN guest. Mojang rules, supply chain, per-tick cost read.
2026-10-29 23:13:04 +01:00
arismoko cfff58e66f Reject mismatched content block registries 2026-10-29 17:10:20 -05:00
levzzz c1255761d6 Merge pull request #3871 from arismoko/upstream/named-hook-catalog
Catalog versioned native hook points
2026-10-30 03:05:20 +05:00
levzzz a88e84f3c9 Merge pull request #3880 from arismoko/native-bundle-data
Allow data files in native mod bundles
2026-10-30 03:05:09 +05:00
Ludovic Marie 33f0fa8c94 Merge pull request #3341 from damnurmum/fix/rich-item-name-colors
🤖 Agent: proven by pr-triage-3 (Ludovic's AI agent): 6da84cf19 was proven on union/pt3-w2; this head only merges main and adapts main's two new held-item tests to the Tag custom name. Workspace check, 1508 tests and the 11 item/save harnesses pass on it.
2026-10-29 23:02:14 +01:00
d3grandoandClaude Opus 5.5 42ff9e6482 Players and heads keep their skins on busy servers
The 128 atlas skin slots went round every frame once more skins were in
use than slots: update_remote_skins touched every player-list entry's skin,
each frame's uploads pushed out the least recently shown one (even one drawn
the frame before), and that one came back the next frame by pushing out
another. Players vanished for a frame (their name tag stayed) and heads
swapped to the default skin and back.

- skins::SkinSlots holds the slots: a new skin takes a free slot or the
  least recently drawn one, never one drawn in the last frame; with every
  slot drawn it waits and its player keeps the default skin.
- Downloader::drawn records what each frame draws (level players and their
  capes, the local player, mannequins, the faces the tab list and spectator
  menu show, skull blocks, item and text heads).
- Only drawn skins ask for a slot, not every player-list entry, and a skin
  not in the atlas yet draws the default one (SkinManager.createLookup's
  getNow(DefaultPlayerSkin.get)) instead of a missing texture.

Fixes #3829

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 05:01:48 +07:00
Ludovic Marie 4f3457a4dc Merge pull request #2689 from levzzz5154/fix/end-gateway-entities
🤖 Agent: verified by Ludovic's AI agent (pr-triage-2, Claude) at c566e69c6, merged with current main: the End gateway follows EndGatewayBlock.entityInside and Entity.handlePortal; the latest commit puts back the tick_items call that the first one had dropped (dropped items fall, merge and despawn again) and, as Entity.setAsInsidePortal does, only restarts the portal cooldown of an entity that is still cooling down (checked against the 26.3 bytecode). clippy -D warnings, the portal/gateway/item unit tests and tools/singleplayer_end_cargo_audit.py on both paths pass (cow and a 7-diamond stack arrive at [1035.5, 76.0, 1.5]). Thanks @levzzz5154.
2026-10-29 22:58:08 +01:00
Ludovic Marie 117241eebb Merge pull request #2872 from levzzz5154/fix/forceload-rings
🤖 Agent: verified by Ludovic's AI agent (Claude): the review point is addressed (decorated protos still save as minecraft:features); the forced ticket rings follow FORCED_TICKET_LEVEL 31 rising one per ring; merged with main, clippy -D warnings and the horizon-client tests (forced_chunks included) pass. Queued; the merger waits for CI parity.
2026-10-29 22:58:03 +01:00
Ludovic Marie 3d23a9ff95 Merge pull request #3360 from levzzz5154/fix/launcher-mixins-public-modules
🤖 Agent: pr-triage-2 checked the pin: 3f99a5c60 is horizon-rust/horizon-mixins main, 8 commits ahead of 0d6b103c7 (preserved standard macros, Windows verbatim output paths), nothing behind; only MIXINS_REV changes and explicit per-bundle pins still win. Thanks @levzzz5154.
2026-10-29 22:57:59 +01:00
Ludovic Marie 4bf65778b6 Merge feat/launcher-night-specimen into feat/launcher-night-sky 2026-10-29 22:56:35 +01:00
Ludovic MarieandClaude Opus 5.5 cd44c4f495 Specimen: plates and the main action cut their corners in pixel steps
plate.rs draws a plate as rectangles that don't overlap (one opacity, the 1 px stroke follows
the steps); the specimen compares it with the rounded controls.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 22:56:27 +01:00
Ludovic Marie 768099d32c Merge feat/launcher-night-tokens into feat/launcher-night-specimen 2026-10-29 22:55:58 +01:00
Ludovic Marie 9d53e6ce35 Merge origin/main into feat/launcher-night-tokens 2026-10-29 22:54:15 +01:00
Ludovic MarieandClaude Opus 5.5 194970773d Night: pixel-cut plate corners, Geist's Cyrillic merged into Bricolage
Plates cut their corners in two 4 px steps (PIXEL_CORNER); RADIUS_PLATE is gone
from the night tokens. tools/bake_bricolage.py now merges Geist's Cyrillic into
every cut, so Russian uses the launcher's own face.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 22:54:06 +01:00
Ludovic Marie c8012dff3a Merge pull request #3119 from NetuosoDEV/fix/enderman-carried-block-tints
🤖 Agent: pr-triage-2 (Ludovic's AI agent, Claude): proven at c32dddb1f: carried and worn blocks draw their block-state model with vanilla's per-face default tints (block_tint harness against 26.3), grass colour once per draw; parity --changed passes, clippy clean, tests pass but for main's own glowing test. Merging.
2026-10-29 22:53:15 +01:00
Ludovic Marie 04da927a44 Merge pull request #3815 from horizon-rust/perf/light-view-cache
🤖 Agent: verified by server-tick-1: proto light views built once per proto state (streaming span 10-24 → 3-4 ms/tick on 25 explore bots, RSS unchanged); new generation_saves test fails on main and passes here; pr_ready --quick green; merging once CI parity passes.
2026-10-29 22:53:11 +01:00
k3yhandClaude Opus 5.5 67c4e5eb1a Merge main into the branch; the guest swap keeps both the acting permission and known_movement
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 16:47:34 -05:00
Ludovic Marie 12930d6237 Merge pull request #3572 from v3nn7/PolishLang
🤖 Agent: pr-triage-1 merged main once more (fast-forward): your Polish checks sit beside main's new test that every key has a Russian value. horizon-client-core's lang tests, the launcher's i18n tests and rustfmt pass on this head; proven before on union/pt1-w4.
2026-10-29 22:46:15 +01:00
d3grandoandClaude Opus 5.5 945ad05579 Darkness dims the screen on servers and in single player
The darkness effect's blend (MobEffectInstance.BlendState, which the
lightmap and the fog read) was ticked only by tick_sculk_entities, which
runs in the integrated level's tick_world. A server's client (multiplayer
and the default single player, whose server runs on a thread) ticks its
effects in tick_client_effects instead, so the blend stayed at 0 and the
effect did nothing. tick_client_effects now ticks the blend after counting
the effects down, as MobEffectInstance.tickClient does.

A new darkness from ClientboundUpdateMobEffectPacket starts its fade from
nothing, or at full strength when the server sent it unblended (joining,
changing dimension), as MobEffectInstance.skipBlending does.

Part of #3393.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 04:44:02 +07:00
Ludovic MarieandClaude Opus 5.5 9bba7215f5 Merge main into #3341: main's new held-item tests set a Tag custom name
Two tests main added (entity_nbt, mobs) set custom_name to a string and compared custom_name() with Option<&str>; with this PR it is the component tag and the plain text is an owned String.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 22:43:55 +01:00
DUzzL f822855c64 docs: add an owner's page mapping server.properties to server.toml 2026-10-29 22:43:10 +01:00
Ludovic Marie 0fff625c22 Merge pull request #3441 from xKumorio/fix-arrow-known-movement
🤖 Agent: proven by pr-triage-3 (Ludovic's AI agent) on union/pt3-w2 db5591ac7: parity --changed all passed, clippy, tests, lan_e2e 11 checks on both paths, and the throws levzzz asked for (walking/jumping carry the movement, none after a tick without a move) for host and guest on both paths. Mojang rules, supply chain, per-tick cost (a Vec3 copy per guest turn) read.
2026-10-29 22:39:35 +01:00
Ludovic Marie d52705984a Merge remote-tracking branch 'origin/main' into fix/pt3-3341 2026-10-29 22:37:25 +01:00
Ludovic MarieandClaude Opus 5.5 73c8eb9b60 Merge main into PolishLang: the Polish checks beside main's every-key test
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 22:34:22 +01:00
Ludovic Marie a305d38fc5 Merge pull request #3581 from v3nn7/fix/furnace-cart-client
🤖 Agent: proven on the integration branch union/pt1-w4 by pr-triage-1. The affected vanilla harnesses, clippy -D warnings and the tests pass, and so does lan_e2e (vehicles, commands, armour, self, tab) on both single-player paths. lan_e2e's minecarts check fails on main's own code too, with or without this PR. Furnace carts now clear their lit flag for clients, and their smoke follows MinecartFurnace.tick. Thank you!
2026-10-29 22:27:41 +01:00
Ludovic Marie 14b7b252ac Merge pull request #3106 from malordin/fix/missing-assets-download
🤖 Agent: proven by pr-triage-3 (Ludovic's AI agent): launcher-only change; on this exact head launcher fmt, clippy -D warnings and 299 tests pass on the Mac mini, clean merge with main. Asset refill only through download_for_owner (owners only).
2026-10-29 22:27:28 +01:00
Ludovic Marie 393ff56184 Merge pull request #3584 from v3nn7/fix/pumpkin-player-head
🤖 Agent: proven on the integration branch union/pt1-w4 by pr-triage-1: the affected vanilla harnesses pass (the only failures are main's own or another PR's), clippy -D warnings and the tests pass, the launcher's checks pass, and lan_e2e passes vehicles, commands, armour, self and tab on both single-player paths. Thank you!
2026-10-29 22:27:21 +01:00
Ludovic Marie e515379b04 Merge pull request #3925 from horizon-rust/feat/claim-respects-kira
🤖 Agent: merging the Kira/GitHub claim alignment (#3859 step 1 follow-up): verify green, claim tests pass.
2026-10-29 22:20:19 +01:00
Ludovic Marie eb19c2723b Merge pull request #3522 from Domanffe/i18n/own-screens-remainder-2
🤖 Agent: pr-triage-2 (Ludovic's AI agent, Claude): proven at d8e912738 in union/pt2-e3 (17 PRs on main cbea22fbb): parity --changed passes but for main's own failures, lan_e2e on both paths, policy read (Mojang rules, supply chain, speed). Merging.
2026-10-29 22:18:20 +01:00
Ludovic Marie ccc323a1b7 Merge pull request #3374 from ThatCakeIsAPie/feat/upgrade-datapack-4
🤖 Agent: pr-triage-2 (Ludovic's AI agent, Claude): proven at a717d6d58 in union/pt2-e3 (17 PRs on main cbea22fbb): parity --changed passes but for main's own failures, lan_e2e on both paths, policy read (Mojang rules, supply chain, speed). Merging.
2026-10-29 22:18:15 +01:00
Ludovic Marie d242616d68 Merge pull request #3373 from ThatCakeIsAPie/feat/upgrade-datapack-3
🤖 Agent: pr-triage-2 (Ludovic's AI agent, Claude): proven at 1ffe68635 in union/pt2-e3 (17 PRs on main cbea22fbb): parity --changed passes but for main's own failures, lan_e2e on both paths, policy read (Mojang rules, supply chain, speed). Merging.
2026-10-29 22:18:11 +01:00
DenisHumenandClaude Opus 5.5 d1a62e68ec Golems: build a snow or iron golem in any orientation, as vanilla does
CarvedPumpkinBlock.trySpawnGolem uses BlockPattern.find, which tries every
corner in the box from the pumpkin and every forwards/up pair, so the snow
column and the iron T also count lying down or upside down. Horizon only
checked the upright column and the upright T with arms along X or Z.

find_pattern follows find and translateAndRotate; the cells and the spawn
base come from the match (getBlock(0, 2) for snow, getBlock(1, 2) for iron).

Fixes #3929

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 23:18:08 +02:00
Ludovic Marie 6175960663 Merge pull request #3372 from ThatCakeIsAPie/feat/upgrade-datapack-2b
🤖 Agent: pr-triage-2 (Ludovic's AI agent, Claude): proven at 217216e26 in union/pt2-e3 (17 PRs on main cbea22fbb): parity --changed passes but for main's own failures, lan_e2e on both paths, policy read (Mojang rules, supply chain, speed). Merging.
2026-10-29 22:12:19 +01:00
Ludovic Marie 7eaa770716 Merge pull request #3370 from ThatCakeIsAPie/feat/upgrade-datapack-2a
🤖 Agent: pr-triage-2 (Ludovic's AI agent, Claude): proven at 825f8f884 in union/pt2-e3 (17 PRs on main cbea22fbb): parity --changed passes but for main's own failures, lan_e2e on both paths, policy read (Mojang rules, supply chain, speed). Merging.
2026-10-29 22:12:15 +01:00
Ludovic Marie 7d6a05e18f Merge pull request #2847 from sirrayi/fix/release-notes-contributors-heading
🤖 Agent: pr-triage-2 (Ludovic's AI agent, Claude): proven at c455bb6ec in union/pt2-e3 (17 PRs on main cbea22fbb): parity --changed passes but for main's own failures, lan_e2e on both paths, policy read (Mojang rules, supply chain, speed). Merging.
2026-10-29 22:12:11 +01:00
Ludovic Marie fab10b1922 Merge pull request #2821 from levzzz5154/fix/death-message-item-name
🤖 Agent: pr-triage-2 (Ludovic's AI agent, Claude): proven at a11e54e7b in union/pt2-e3 (17 PRs on main cbea22fbb): parity --changed passes but for main's own failures, lan_e2e on both paths, policy read (Mojang rules, supply chain, speed). Merging.
2026-10-29 22:12:06 +01:00
Ludovic Marie 8a363878ee Merge pull request #2570 from levzzz5154/fix/world-types
🤖 Agent: pr-triage-2 (Ludovic's AI agent, Claude): proven at c7aca8489 in union/pt2-e3 (17 PRs on main cbea22fbb): parity --changed passes but for main's own failures, lan_e2e on both paths, policy read (Mojang rules, supply chain, speed). Merging.
2026-10-29 22:12:02 +01:00
GIGABAIT93andClaude Opus 5.5 d7f9535406 Entity shadows follow the entity's scale and age
LivingEntityRenderer.getShadowRadius multiplies the type's radius by
minecraft:scale, and MobRenderer by the age scale (babies 0.5, or the
species' own: goat 0.55, camel 0.6...). Villagers and striders halve a
baby's shadow again, turtles take 0.83 of it; slimes, magma cubes,
sulfur cubes and pufferfish size theirs by their size or puff state.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 23:10:55 +02:00
Ludovic Marie c770074449 Merge pull request #3405 from degepie/fix/issue-3137-suspicious-stew
🤖 Agent: pr-triage-2 (Ludovic's AI agent, Claude): proven at 95c234cc1 in union/pt2-e3 (17 PRs on main cbea22fbb): parity --changed passes but for main's own failures, lan_e2e on both paths, policy read (Mojang rules, supply chain, speed). Merging.
2026-10-29 22:06:55 +01:00
DUzzL 9226f507a3 Remove dead ctov modular_compat code and unused test helper in horizon-port-pack 2026-10-29 22:05:48 +01:00
Ludovic Marie 8169314c66 Merge pull request #3369 from ThatCakeIsAPie/feat/upgrade-datapack-1
🤖 Agent: pr-triage-2 (Ludovic's AI agent, Claude): proven at 0771f59bc in union/pt2-e3 (17 PRs on main cbea22fbb): parity --changed passes but for main's own failures, lan_e2e on both paths, policy read (Mojang rules, supply chain, speed). Merging.
2026-10-29 22:04:32 +01:00
Ludovic Marie 7b581fb43c Merge pull request #3366 from ThatCakeIsAPie/perf/worldgen-surface-biomes
🤖 Agent: pr-triage-2 (Ludovic's AI agent, Claude): proven at 52cc53a4f in union/pt2-e3 (17 PRs on main cbea22fbb): parity --changed passes but for main's own failures, lan_e2e on both paths, policy read (Mojang rules, supply chain, speed). Merging.
2026-10-29 22:04:28 +01:00
Ludovic MarieandClaude Opus 5.5 c32dddb1fe Merge main into the branch: default tints through the tint closure
Main's #3401 drew blocks through tinted_block_model_vertices with a per-face
tint closure; block_model_vertices now passes the default tints there
(grass colour once per call). The enderman's carried block uses the default
tints, so block_model_vertices_tinted has no caller left.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 22:03:53 +01:00
Ludovic Marie 5b1d64d4f7 Merge pull request #3401 from JacobRWebb/JacobRWebb/sticky-piston-texture-color
🤖 Agent: pr-triage-2 (Ludovic's AI agent, Claude): proven at 1a01f5ef5 in union/pt2-e3 (17 PRs on main cbea22fbb): parity --changed passes but for main's own failures, lan_e2e on both paths, policy read (Mojang rules, supply chain, speed). Merging.
2026-10-29 22:01:50 +01:00
Federico Tagliani 55db71a9ff Merge remote-tracking branch 'origin/main' into fix/riptide-with-elytra 2026-10-29 22:01:45 +01:00
Federico TaglianiandClaude Sonnet 5.5 9751bdf0ab Riptide in rain on a server's client: isInRain from its own chunks and weather
A client of a server has no live biome features, so the behaviours'
isRainingAt answered false and a Riptide trident never started charging in
rain.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-29 22:01:44 +01:00
Ludovic Marie bf0d6dd423 Merge pull request #3920 from horizon-rust/feat/roadmap-owners
🤖 Agent: merging the short roadmap (#3859 step 4): verify green, roadmap tests and the online check pass. Proposed owners can change their line with a one-line PR.
2026-10-29 21:59:32 +01:00
Ludovic MarieandClaude Opus 5.5 91523637ac CONTRIBUTING: rewrap the claims paragraph
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 21:58:59 +01:00
Ludovic MarieandClaude Opus 5.5 d64f34c96c Claims: an issue claimed on Discord is taken too
Kira's Discord /claim marks an issue with the `in progress` label instead of
assigning it; `/claim` on GitHub now refuses such an issue, so the two
claim paths can't hand the same issue to two people. Kira's side (refusing a
Discord claim on a GitHub-assigned issue) is in horizon-rust/kira.

Part of #3859.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 21:58:34 +01:00
Ludovic Marie 74904e32c1 Merge pull request #3903 from horizon-rust/feat/overlap-note
🤖 Agent: merging the overlap note (#3859 step 2): verify green, tests pass, false positives measured on the 207 open PRs (21 pairs: 16 related, 4 doubtful, 1 unrelated).
2026-10-29 21:55:40 +01:00
Ludovic MarieandClaude Opus 5.5 7c8e0d8e9b Roadmap: Now at most 8 items, each with an owner and a tracking issue
roadmap.toml items get `owner` (a GitHub login) and `issue` (the tracking
issue); `check` requires both on every Now item, at most 8 Now and 12 Next
items, and the tracking issue to be an open issue. ROADMAP.md shows them,
roadmap.json carries them (the hub ignores unknown keys).

The proposed lists, from the work actually happening (open pull requests,
agents' workstreams):
- Now: smooth-frames, server-tick, mods, port-ledger (the player-visible
  gaps), modpack-worldgen, server-thread, java-instances, lean-code, with
  tracking issues #3909-#3916.
- Mods: one line (Ludovic's decision), owner levzzz5154; native-mods,
  mod-registry, server-mods, mod-screens, modder-tools, machines-api and
  mod-world-items fold into it.
- Shipped (`sync`, all their pull requests in a release): server-dialogs,
  discord-presence, native-shaders, pregen, item-cooldowns, chat-settings,
  pointer-hand, text-field-keys, chicken-flap. phones-and-browser is not
  shipped: it becomes ongoing so `sync` stops taking its first pieces for it.
- Next: 12 (splitscreen, owner martipops, and launcher-redesign come up);
  the rest of Next moves to Later.

Part of #3859.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 21:48:46 +01:00
GIGABAIT93andClaude Opus 5.5 8974e07eec Other players and mannequins take their minecraft:scale
ClientPacketListener.handleUpdateAttributes gives every living entity its
attributes; the client kept only a mob's max health, so a server's scale
on another player or a mannequin was dropped and NPC plugins' enlarged
fake players showed at their normal size. The scale now goes to the
model as the local player's already does (LivingEntityRenderState.scale),
computed like AttributeInstance.calculateValue, now a function with a test.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 22:46:54 +02:00
Ludovic MarieandClaude Opus 5.5 6da84cf195 A plain custom name is saved as a string, as vanilla's codec writes it
Component.tryCollapseToString: ComponentSerialization.CODEC writes plain text with no style and no siblings as a bare string, so a legacy display Name '{"text":"Cool"}' becomes custom_name:"Cool". Keeping the compound made 215 of hotbar's 905 cases differ.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 21:46:31 +01:00
DUzzL 2403ca40a2 Reject data-pack recipe result counts outside 1..=99
ItemStackTemplate.CODEC bounds the count with intRange(1, 99). Crafting,
cooking and stonecutting results were cast with 'as u32', so 0, 2^31 and
wrapped 2^32 reached item stacks. Such recipes are now not loaded.
2026-10-29 21:40:55 +01:00
DUzzL e7b19995ea Explosion drops merge by item, components and max stack size
ServerExplosion.addOrAppendStack uses ItemEntity.areMergable: same item and
components, and the combined count within the item's max stack size.
2026-10-29 21:39:27 +01:00
Ludovic Marie f47c14d93f Merge pull request #3872 from horizon-rust/feat/kind-labels
🤖 Agent: merging the kind and area labels (#3859 step 3): verify green, workflow-safety and labelling tests pass; labels created and the open pull requests labelled once.
2026-10-29 21:38:13 +01:00
DUzzL a5f4e32bfc Dedupe function-tag expansion with hash sets and end tag cycles
Fixes #3885
2026-10-29 21:37:00 +01:00
Ludovic MarieandClaude Opus 5.5 ca0888663e Overlap note: one comment naming the open pull requests doing the same work
On open and push, overlap-note.yml (pull_request_target, main's script only)
lists, in one comment edited on every push, the other open pull requests that
close the same issue or change the same functions. Functions come from git's
own rust/python hunk headers, which name indented methods (GitHub's stop at
`impl Game<'_>`). The heads are fetched as objects and diffed, never checked
out; hooks, fsmonitor, external diff and textconv are off.

Measured on the 207 open pull requests: one shared function, or a few of a
big pull request's hundred, was mostly chance, so a pair needs a shared
issue, or 2 shared functions and a tenth of the bigger one's; functions 5
compared pull requests change, probes, tests and stacks don't count.
21 pairs (34 notes): 16 related (4 duplicates), 4 doubtful, 1 unrelated.

Part of #3859.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 21:36:37 +01:00
bloodulonandQoder cdd3254dbb Load item definitions from nested folders
ModelEngine names a bone's item model `modelengine:<model>/<bone>` and
ships it at assets/modelengine/items/<model>/<bone>.json. Only the files
at the top of assets/<ns>/items were read, so every bone stack resolved
to the built-in missing model, whose sprite is absent from the HUD
atlas: the quads were dropped and the NPC rendered nothing while its
interaction hitbox stayed clickable.

Walk the tree like ParsedModels::load already does for models.

Co-Authored-By: Qoder <[email protected]>
2026-10-30 01:36:04 +05:00
ccclosed's AI agentandDeepSeek Harness 410bde729b Mobs: apply the Weakness, Slowness and Health Boost attribute modifiers
effect_modifiers only mapped strength and speed, so Weakness and Slowness
had no effect on a mob: its melee damage and movement speed stayed at the
base values. Map every attribute-carrying effect the way vanilla's
MobEffect.addAttributeModifier does, scaled by amplifier + 1:

- weakness: attack damage -4 per level (ADD_VALUE)
- slowness: movement speed -0.15 per level (ADD_MULTIPLIED_TOTAL)
- health_boost: max health +4 per level (ADD_VALUE)

Move the per-effect table into a testable effect_modifier() and cover all
five rows with a unit test.

Fixes #3887.

Co-Authored-By: DeepSeek Harness (deepseek-v4.1-flash) <[email protected]>
2026-10-29 23:33:35 +03:00
Ludovic Marie 90aded96a3 Merge pull request #3874 from horizon-rust/fix/display-log-linux-only
🤖 Agent: macOS clippy fix (log_display is only called on Linux); clippy clean on macOS.
2026-10-29 21:24:43 +01:00
matydenandClaude Opus 5.5 7b33479e15 Port publish: write the issue body as UTF-8
issue() wrote the body file in the system's code page, while gh reads
it as UTF-8, so on Windows a body with "→" raised UnicodeEncodeError
(cp1251 here) or would reach GitHub garbled. #3587 described this fix,
but the merged pull request kept only its roadmap files.
test_apply_lists_the_new_issue_numbers_in_the_tracking_issue now passes
on Windows without PYTHONUTF8.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 22:15:05 +02:00
Ludovic Marie 7c1e0f9794 Merge pull request #3862 from horizon-rust/feat/claim-command
🤖 Agent: merging the claims workflow (#3859 step 1): verify green, Python tests and workflow-safety tests pass, the GraphQL query and release --dry-run tried live.
2026-10-29 21:13:27 +01:00
Ludovic MarieandClaude Opus 5.5 0c9e995257 Render: log_display only where Wayland's HDR reports call it
On macOS and Windows nothing calls it, so clippy -D warnings failed there
(dead code) while Linux CI passed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 21:13:27 +01:00
Ludovic Marie 97032398df Merge pull request #3834 from horizon-rust/perf/scheduled-one-level
🤖 Agent: verified by server-tick-1: due scheduled ticks run in one with_level while a step leaves no level events or spill (identical order and state); tick_blocks span 15.4/14.8 → 10.4/9.3 ms at 50 bots; pr_ready --quick green; merging once CI parity passes.
2026-10-29 21:13:23 +01:00
DUzzL a8f0a64bd6 Credit breeding to the player who fed the animals
On the server thread (the default) breeding runs in the mob tick, outside
any player's turn, and the server has no host player, so the
bred_animals trigger and the animals_bred statistic were dropped and
"The Parrots and the Bats" was never granted.

Remember who fed each animal into love (Animal.loveCause) and credit that
player, the parent's feeder first and else the partner's, as vanilla does.
2026-10-29 21:11:07 +01:00
Aria Figueredo d3e0774ca3 Catalog versioned native hook points 2026-10-29 15:09:42 -05:00
bxnjxmin27andDeepSeek V4.1 Flash c3fdf7192d Screenshots: the key works with chat, a sign or a book open
F2 was handled in `Game::key_pressed`, which a screen with a text box never
reaches: chat, a sign, a book, the command block and the search boxes take the
key first, so F2 did nothing there (#3689).

Vanilla runs `Minecraft.handleGlobalKeyPress` before a screen sees the key:
`key.fullscreen` and `key.screenshot` are window shortcuts that work on every
screen, and a held debug modifier suppresses both. The fullscreen half was
already global here; this adds the screenshot half beside it, driven by the
`key.screenshot` mapping instead of a hard-coded F2, and drops the old arm in
`key_pressed`. `horizon-ctl key` takes the same shortcut, so an agent drives
what a player does.

Co-Authored-By: DeepSeek V4.1 Flash (opencode) <[email protected]>
2026-10-29 21:05:06 +01:00
Ludovic MarieandClaude Opus 5.5 59b1c33bb2 PR labels: a kind from the template's ticked box, areas from the paths
The template gets a "Kind" list (parity, bug, perf, feature, extra, tooling,
docs). pr-labels.yml (pull_request_target, main's script only, never the
pull request's code) sets the ticked kind when the pull request has none, swaps
it when the author changes the tick, and never removes a label set by hand.
Area labels come from the changed paths: at most 3, each a tenth of the lines.
`feature` keeps its release meaning.

pr_labels.py also creates the labels and labels the open pull requests once
(backfill: kind only where clear, else none).

Part of #3859.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 21:04:01 +01:00
Aria Figueredo d1d99be74f Allow data files in native mod bundles 2026-10-29 15:00:57 -05:00
ElyTheCatandAntigravity cc4c49e8f9 Disconnect: do not show saving world screen on external server
In vanilla Minecraft (26.3), exiting or disconnecting from a remote server
(`isLocalServer() == false`) never shows the saving world screen
(`GenericMessageScreen(Gui.SAVING_LEVEL)`). In `disconnect()`, vanilla only
halts the integrated server and displays the saving level screen when
`singleplayerServer != null`.

Previously in Horizon, `begin_closing_save(quit = true)` set `Stage::Writes`
when quitting from a remote server or the title screen, causing
`render_closing_save` to draw "Saving world...", and `save_for_exit` called
`save_game()` on exit even for remote worlds.

- In `begin_closing_save`: introduce `Stage::Quit` for clean termination
  without drawing the save progress screen. When quitting while connected
  to a remote server, immediately disconnect the session, mark
  `saved_for_exit`, and transition directly to `Stage::Quit`. Quitting
  from the title screen also uses `Stage::Quit`.
- In `close_world`: directly call `leave_server(None)` when on an external
  server (`self.external_server()`), skipping `begin_closing_save`.
- In `save_for_exit`: cleanly disconnect `remote` and return without saving
  when on an external server.

Co-Authored-By: Antigravity <[email protected]>
2026-10-29 23:30:08 +03:30
Ludovic MarieandClaude Opus 5.5 f9e5264df7 Claims: /claim and /unclaim on issues, stale claims released after 5 days
Anyone comments `/claim` on an issue to be assigned (GitHub lets an issue's
commenters be assigned, no role needed); an assigned issue is taken.
`/unclaim` gives it back; the trusted reviewers can `/unclaim @login`.
A daily run releases claims this workflow made over 5 days ago when no open
pull request links the issue; hand assignments are never released.

Same shape as triage-command: main's copy runs, no pull request is checked
out, the comment reaches the script as env vars, the token writes issues only.

Part of #3859.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 20:54:06 +01:00
Ludovic Marie 668419b92a Merge pull request #3526 from kacpherek/tools/port-ledger-harness
🤖 Agent: verified by pr-triage-3. Dev tooling only (tools/port_ledger.py harness coverage + gaps --kind untested, docs, port_map paths moved to the split crates: every new path exists on main). Merges cleanly with main; tests/test_port* 30 pass; skip-release.
2026-10-29 20:52:30 +01:00
d3grandoandClaude Opus 5.5 f5de793b38 Wither breaks parity: hold the wither in place until it breaks
The scenario depended on the wither staying put for the 20 ticks before
destroyBlocksTick fires. The wither's WaterAvoidingRandomFlyingGoal can
set off on a stroll in that time (its random is unseeded in vanilla),
which shifts the 3x3x4 box it breaks, so the stone at 7 210 7 was broken
or left depending on the run, on either side.

Teleport it back to 8.5 210 8.5 every tick until then: customServerAiStep
runs before travel, so the break always sees that position.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 02:44:22 +07:00
DUzzL bfda2bf165 Only a piston base stands in for a retracting moving_piston 2026-10-29 20:38:51 +01:00
DUzzL 5782df1c03 Lever on a piston's back no longer drops when the piston retracts
A retracting piston is a moving_piston block for two ticks. The support
facts are exported without a block entity, so the block read as empty and
every face as unsupported, and the lever attached to the piston's back
popped off. Read the extended base for a retracting source piston, which
is what vanilla's moving-piston block entity shape contains.
2026-10-29 20:36:08 +01:00
Ludovic MarieandClaude Opus 5.5 d3c6f0d937 Merge main into #2987 (lan_entities.rs: lan_display_data beside main's lan_entity_uuids)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 20:34:23 +01:00
Ludovic Marie baa379d717 Merge pull request #3578 from d3grando/fix-3432
🤖 Agent: proven on union/pt1-w3 by pr-triage-1: every vanilla harness this branch reaches passes (mob_ai and the equine ones included), clippy -D warnings and the horizon-client tests pass (the one failing test is main's own, fixed by #3851), and the last commit only reorders an import for rustfmt. Thank you!
2026-10-29 20:31:31 +01:00
d3grandoandClaude Opus 5.5 52a380cd2f Merge origin/main into fix-3503
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 02:30:27 +07:00
d3grandoandClaude Opus 5.5 150412d353 benchmarks: rerun the Windows RD 8 and RD 32 games on an idle machine
ludovic111 pointed out that the first pass's RD 8 and RD 32 games had builds
running alongside, which docs/benchmark.md keeps out of published results.
Each game now started only with no compiler or other game running and the
CPU under 15 % for 30 s, and a game during which one started (or other
processes used over 15 % for 10 s) was run again. RD 16 was already idle.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 02:30:03 +07:00
Ludovic MarieandClaude Opus 5.5 5fb9092073 Merge main into #3341: inline objects (#3327) beside the component shadow colour
The glyph loop takes both: an inline object draws and skips the shadow passes; other glyphs use the component's shadow_color. Both sides' tests kept.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 20:28:16 +01:00
Ludovic Marie 28a65af5fc Merge pull request #3848 from horizon-rust/perf/block-light-check-node
🤖 Agent: merging. Block light follows vanilla BlockLightEngine.checkNode (an emitter in its own light only brightens): in-frame relight during the RD 32 ocean flight 17–30 ms per run down to 7–8 ms; light harness and the light tests pass; CI verify green.
2026-10-29 20:26:44 +01:00
Ludovic Marie a6751f2aa9 Merge pull request #2960 from kacpherek/fix/shelf-interaction
🤖 Agent: proven by pr-triage-3 (Ludovic's AI agent) on union/pt3-w1 7e05a39a9 (main 62a23ba02 + 15 PRs): pr_ready green (fmt, clippy -D warnings, 1810 tests, doc, content root), parity --changed 192/192, lan_e2e 13 checks on both paths. Mojang rules, supply chain and per-frame cost read.
2026-10-29 20:24:20 +01:00
Ludovic Marie ea60e1e062 Merge pull request #3327 from damnurmum/fix/inline-object-text
🤖 Agent: proven by pr-triage-3 (Ludovic's AI agent) on union/pt3-w1 7e05a39a9 (main 62a23ba02 + 15 PRs): pr_ready green (fmt, clippy -D warnings, 1810 tests, doc, content root), parity --changed 192/192, lan_e2e 13 checks on both paths. Mojang rules, supply chain and per-frame cost read.
2026-10-29 20:24:16 +01:00
Ludovic Marie 047815c5d7 Merge pull request #3303 from doodlyyousuf/fix/container-use-swing
🤖 Agent: proven by pr-triage-3 (Ludovic's AI agent) on union/pt3-w1 7e05a39a9 (main 62a23ba02 + 15 PRs): pr_ready green (fmt, clippy -D warnings, 1810 tests, doc, content root), parity --changed 192/192, lan_e2e 13 checks on both paths. Mojang rules, supply chain and per-frame cost read.
2026-10-29 20:24:11 +01:00
Ludovic MarieandClaude Opus 5.5 d22aecd526 Format the equine tests' imports
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 20:20:19 +01:00
DUzzL 5f931e3659 Draw a worn elytra on armour stands (#3843)
ArmorStandRenderer adds WingsLayer after the armour layers, but
armor_stand_geometry never drew the chest slot's wings. Split the
wings drawing out of player_elytra_geometry into wings_geometry and
call it for stands, at the resting wing angles (a stand never glides
or crouches) and with the baby elytra model for a small stand.
2026-10-29 20:20:07 +01:00
DUzzL b9ed9d58de Fix axolotl NBT test: query glowing effect by namespaced id
Mob::add_effect normalizes ids to their minecraft: form (08b97652), but the
test asked has_effect for the bare "glowing", so the assertion failed.

Fixes #3840
2026-10-29 20:18:27 +01:00
Ludovic MarieandClaude Opus 5.5 e40ea748d1 Merge main into this branch: Polish beside main's Chinese, Spanish and Ukrainian
Language::ALL holds eight and every list of codes names pl. Polish gets the
seven launcher strings main added since (instance trash, native mods, the
Java Edition line) and the build-time line main reworded.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 20:17:49 +01:00
d3grandoandClaude Opus 5.5 9afb898e80 Merge remote-tracking branch 'origin/main' into fix-3153
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 02:15:41 +07:00
Ludovic MarieandClaude Opus 5.5 01b4e5cbc4 Light: an emitter placed in its own light only brightens
Vanilla's BlockLightEngine.checkNode clears a changed block's light and
decreases from it only when the new emission is below the stored level;
otherwise the level stays, the neighbours' light is pulled in and the
emission increases from there. Horizon's relight cleared every changed
block, so each flowing lava block placed next to lava (fluids settling
in fresh chunks while flying over the ocean) darkened and relit up to
14 blocks around it on the render thread, and marked every section
there for remeshing.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 20:13:11 +01:00
Ludovic MarieandClaude Opus 5.5 84c3af5122 Merge main into this branch: the pumpkin beside main's cape and glowing changes
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 20:00:31 +01:00
GIGABAIT93andClaude Opus 5.5 b6b9a5a307 Text displays riding the local player sit over its head too
/ride a text_display mount @s makes the window's own player the
vehicle, which isn't in mobs: the display stayed where it spawned. The
local player seats it at the top of its box in its pose and scale, its
passenger attachment (Avatar.POSES), as for other players.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 20:48:57 +02:00
Ludovic Marie 7ebf7d26cb Merge pull request #3350 from horizon-rust/draft/remove-wasm-mods-game
🤖 Agent: WebAssembly removal 1/4 (game), merged up to date with main c7065027b. The official mods are native in the registry (Zoom, FPS Counter, Keystrokes, Minimap, Recipes, Coordinates HUD 2.0.0, verified in the published index), and the authors of the WebAssembly mods with no native version were told (list in the body). Each layer compiles; pr_ready --quick on the tip: only main's own macOS clippy (log_display) and axolotl test fail. Parity: 340/340 locally on the previous heads; CI parity on these. Merge before #3351, #3353, #3371.
2026-10-29 19:48:54 +01:00
Ludovic MarieandClaude Opus 5.5 d8e912738b Merge main into the branch: the HDR graphics page shares the video title key
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 19:46:02 +01:00
Ludovic Marie cbea22fbb6 Merge pull request #3831 from horizon-rust/perf/streaming-results-no-wait
🤖 Agent: merging. The frame no longer waits on a descheduled worker's mpsc send: 8–24 ms stalls in Streaming::update gone in the RD 32 ocean flight (0–1 per run vs 5–14), 109/109 affected harnesses pass, CI verify green.
2026-10-29 19:43:45 +01:00
Ludovic MarieandClaude Opus 5.5 c7aca8489f Merge main into the branch: keep the world icon beside the preset check
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 19:42:46 +01:00
Ludovic Marie 319876a186 Merge pull request #2795 from s1f102500012/launcher-simplified-chinese
🤖 Agent: correction from pr-triage-1: my first push of the main merge missed one line (`Language::ALL` still said six), so it didn't build; 46ac49ad0 adds it and merges main once more. On this exact head the launcher's fmt, clippy -D warnings and all its tests pass, including the check that every language has every string. Sorry for the noise.
2026-10-29 19:38:07 +01:00
levzzz 9093960719 Merge pull request #3355 from Sticky-Bug/feat/audio-output-device
options: choose the audio output device in Music & Sounds (#1035)
2026-10-29 23:37:50 +05:00
Ludovic MarieandClaude Opus 5.5 68e57e86c5 Streaming: the frame takes finished jobs without waiting for a worker
The game thread drained the workers' results with std's mpsc try_recv,
which spins and then yields until a sender it finds half way through a
send has written its message (std's mpmc list: start_recv, wait_write).
A worker descheduled at that point on a busy machine held the frame for
a scheduler quantum: 8 to 24 ms waits with no CPU time in
Streaming::update, measured with HORIZON_SPIKES during the RD 32 ocean
flight. The results now go through a mutex-guarded queue the frame
takes with try_lock: while a worker holds it, the frame leaves the
results for the next one. The blocking waits (save, wait, drop) keep
their timeouts on a condvar.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 19:28:52 +01:00
d3grandoandClaude Opus 5.5 67a960b045 Merge origin/main into bench-windows-results
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 01:25:47 +07:00
Ludovic MarieandClaude Opus 5.5 36ca9df57b Merge the game layer (main c7065027b) into the crates layer
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 19:22:04 +01:00
Ludovic MarieandClaude Opus 5.5 ab0309581c Merge main (c7065027b) into the WebAssembly removal (game)
Conflict: the help text; main's --in-frame deprecation kept, --dev-mod still
takes content mods only.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 19:21:29 +01:00
Ludovic MarieandClaude Opus 5.5 20acff0b3c Scheduled ticks: run the due ticks against one level
`tick_scheduled` built the level (`with_level`: the boxes of every cart,
arrow and guest, the fire-spreading players, the game rules, the ticket
levels) again for each due block and fluid tick. Run the due ticks in one
`with_level` while each leaves nothing for the game to apply between
them, level events or a spill; then the game handles those as before, so
every tick still sees what the ones before it did.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 19:20:16 +01:00
Ludovic MarieandClaude Opus 5.5 1ea5ec5964 Merge main into hdr-tiers: the tiers paragraph beside HDR's own screen and Auto
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 19:19:29 +01:00
d3grandoandClaude Opus 5.5 8168d96082 Merge origin/main into fix-3503
Conflicts: the creative screen's tooltip takes main's tab names
(draw_stack_tooltip_with) and this branch's TooltipFlag.isCreative for a
grid slot; horizon-protocol's item.rs keeps main's instrument holder and
this branch's painting_variant holder. The new wire test's Dynamic gets
main's dialog_tags by default.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 01:12:36 +07:00
Ludovic MarieandClaude Opus 5.5 46ac49ad05 Launcher: Language::ALL holds seven languages, formatted
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 19:02:12 +01:00
Ludovic Marie d23ab5ae50 Merge remote-tracking branch 'origin/main' into pt1-2795 2026-10-29 19:02:03 +01:00
Ludovic MarieandClaude Opus 5.5 9ad4c9fb8b Merge main into launcher-simplified-chinese: keep Chinese beside main's Ukrainian
Language::ALL holds seven, and every list of codes names zh, es and uk.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 19:01:03 +01:00
bloodulonandQoder 27e16c3e01 Merge origin/main into pr/packs-overlay
Resolved zip.rs: main added a_name_falls_back_to_its_folder_entry where this
branch added its deflate tests; kept both.

Co-Authored-By: Qoder <[email protected]>
2026-10-29 22:54:48 +05:00
levzzz c7065027b1 Merge pull request #3461 from senjinthedragon/hdr-wayland
HDR: Auto Paper White and Peak Brightness, from what the compositor reports
2026-10-29 22:52:21 +05:00
rdbt b97e39e074 Merge pull request #3800 from horizon-rust/chore/deprecate-in-frame
Deprecate --in-frame (unsupported, will be removed soon)
2026-10-29 17:49:28 +00:00
Ludovic MarieandClaude Opus 5.5 2fb276d5f6 Merge the game branch (main d009ebd) into the crate removal
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 18:48:54 +01:00
levzzz 1deaac7263 Merge pull request #3820 from horizon-rust/fix/prepare-output-version-2
horizon-prepare: OUTPUT_VERSION 2 (the command registries now export selected_entities)
2026-10-29 22:48:11 +05:00
Ludovic MarieandClaude Opus 5.5 26f099d168 Merge main (d009ebd) into the WebAssembly removal (game)
Conflict: main made chat_tree callable from command blocks, with the
WebAssembly mods for its argument; it stays callable, without them.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 18:47:49 +01:00
rdbt ef66d5b892 Merge branch 'main' into chore/deprecate-in-frame 2026-10-29 17:41:01 +00:00
Ludovic MarieandClaude Opus 5.5 b732e582b9 Merge main into feat/audio-output-device: keep soundDevice beside main's new option keys
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 18:38:36 +01:00
levzzzandClaude Opus 5.5 f145c903b6 Merge main into hdr-wayland (with #3176 and #3203 merged)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 22:38:32 +05:00
levzzz d009ebd140 Merge pull request #3203 from senjinthedragon/hdr-screen
HDR: its settings get a screen of their own
2026-10-29 22:38:18 +05:00
k3yhandClaude Opus 5.5 7830c6de4a Merge main into the branch; keep both spawner.rs additions (acting_player_is_op, tick_remote_spawners)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 12:37:58 -05:00
Ludovic Marie 0c49146fc8 Merge remote-tracking branch 'origin/main' into fix/prepare-output-version-2 2026-10-29 18:37:49 +01:00
Ludovic MarieandClaude Opus 5.5 cf558b0c0a Merge the game branch (main 1b38710) into the crate removal
main's change to the SDK's horizon.wit goes with the SDK.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 18:37:38 +01:00
Ludovic MarieandClaude Opus 5.5 1fb8e84561 Merge main into the WebAssembly removal (game)
Conflicts: main's changes to mod_render.rs, server_mods.rs and the runtime
part of mods.rs sit in WebAssembly code this branch deletes. Kept main's
Event::NativeSettings (a native mod's page asks for its labels again) and
the bundle's wheel; dropped the WebAssembly ModSettings event, the mod
screen's wheel and its title-screen settings page (9c6325c1f).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 18:36:42 +01:00
Ludovic MarieandClaude Opus 5.5 0f9adac090 Merge main into hdr-tiers: keep #3176's UI brightness beside the light tiers
UI Brightness takes params[6]; the tier lift also skips pixels the hand or
HUD covers, as main's lift does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 18:34:26 +01:00
Ludovic Marie cbbdf773e2 Merge pull request #3817 from GIGABAIT93/fix/launcher-language-count
Launcher: Language::ALL holds six languages (main doesn't build)
2026-10-29 18:34:25 +01:00
Ludovic Marie e1207bb017 Merge pull request #3453 from d3grando/fix-3357
🤖 Agent: proven on the integration branch union/pt1-w2 (23 PRs on main 89f949155) by pr-triage-1: tools/pr_ready.py green (clippy -D warnings, the touched crates' tests, launcher checks), the 126 affected vanilla harnesses pass, and lan_e2e passes 13 checks on both single-player paths. Thank you!
2026-10-29 18:34:17 +01:00
Ludovic Marie 94a737918a Merge pull request #3443 from d3grando/fix-shulker-box-menu
🤖 Agent: proven on the integration branch union/pt1-w2 (23 PRs on main 89f949155) by pr-triage-1: tools/pr_ready.py green (clippy -D warnings, the touched crates' tests, launcher checks), the 126 affected vanilla harnesses pass, and lan_e2e passes 13 checks on both single-player paths. Thank you!
2026-10-29 18:34:10 +01:00
Ludovic MarieandClaude Opus 5.5 7ed5855e4a horizon-prepare: OUTPUT_VERSION 2 (command registries export selected_entities)
#3410 made the command parse oracle, which also exports
command-registries.json during preparation, write "selected_entities". The
prepared output changed (digest 55f3f289…) but OUTPUT_VERSION stayed 1, so
every parity run since fails "the prepared output changed", and players
would keep resources prepared before it. #3410 was proven in a local union,
which skipped CI parity and with it this check.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 18:32:53 +01:00
levzzz e3cbe2688f Merge pull request #2911 from masa-masa-0314/fix/world-select-icons
Select World: show each world's preview icon
2026-10-29 22:30:37 +05:00
levzzz 7dc72d6c33 Merge pull request #3582 from Username59138/fix/command-block-screen
Important fix: command blocks keep their command, suggest commands and dim the screen as vanilla
2026-10-29 22:30:23 +05:00
Ludovic Marie f4e1a98127 Merge pull request #3446 from d3grando/fix-3407
🤖 Agent: proven on the integration branch union/pt1-w2 (23 PRs on main 89f949155) by pr-triage-1: tools/pr_ready.py green (clippy -D warnings, the touched crates' tests, launcher checks), the 126 affected vanilla harnesses pass, and lan_e2e passes 13 checks on both single-player paths. Thank you!
2026-10-29 18:27:36 +01:00
levzzzandClaude Opus 5.5 e5087bdb5a Merge main into hdr-screen (with #3176 merged)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 22:27:34 +05:00
levzzz 0e7918fb88 Merge pull request #3176 from senjinthedragon/hdr-ui
HDR: UI Brightness, and the HUD and menus never lift
2026-10-29 22:27:20 +05:00
levzzz e5076cd6ab Merge pull request #3230 from Mik00000/fix/spawner-mob-display
Display mob inside spawners and trial spawners (fixes #3107)
2026-10-29 22:23:08 +05:00
Ludovic MarieandClaude Opus 5.5 73558e3673 Merge main into hdr-tiers: keep #3145's black level beside the light tiers
The black level takes params[5], before the tiers' vec4s at 8, 12 and 16.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 18:22:59 +01:00
levzzz 3d4cade73b Merge pull request #3544 from degepie/fix/issue-2841-frog-jump
Frogs long jump: FrogAi's long jump activity (fixes #2841)
2026-10-29 22:22:45 +05:00
levzzzandClaude Opus 5.5 1332c90868 Merge main into the branch; the command box types AltGr characters as main's other text boxes do
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 22:22:27 +05:00
levzzz 1b38710e92 Merge pull request #2970 from kacpherek/fix/multiplayer-safety-notice
Show the multiplayer safety notice on the first Multiplayer click
2026-10-29 22:20:02 +05:00
levzzz aaa301b2e5 Merge pull request #2924 from elythepreciouscat/feat/vanilla-controls-26-3
Options: add Java 26.3 controls
2026-10-29 22:19:46 +05:00
levzzzandClaude Opus 5.5 3052227936 HDR Auto: watch the display only while HDR output is on
The Wayland watch was made whenever the surface offered HDR, so with
HDR off (the default) the renderer still did a blocking dispatch on
winit's connection at startup. It's now made when set_hdr turns HDR
output on and dropped when it turns off; the next display_changed
applies what it first reported.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 22:19:42 +05:00
levzzz 95a366fe89 Merge pull request #3049 from kacpherek/feat/render-distance-128
Raise the render distance cap from 64 to 128 chunks
2026-10-29 22:19:30 +05:00
levzzz fc0e275414 Merge pull request #3229 from Mik00000/fix/bow-arrow-types
Support firing spectral and tipped arrows from bows and crossbows
2026-10-29 22:19:13 +05:00
levzzz 5dc0515248 Merge pull request #3148 from echo-ilovech3ss/fix/middle-click-pick-block-selection
Select the picked hotbar slot in singleplayer and LAN
2026-10-29 22:18:57 +05:00
levzzzandClaude Opus 5.5 20a3c3e399 Merge main, #3176 and #3203 into hdr-wayland: Auto beside Black Level and UI Brightness
Paper White and Peak keep their Auto step (0) on Graphics: HDR, where
#3203 moved them; Black Level and UI Brightness are saved, parsed and
passed to set_hdr beside the Auto nits. The docs paragraph keeps the
HDR screen, Auto, Black Level and UI Brightness.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 22:18:50 +05:00
levzzz 0a6020695b Merge pull request #3131 from Mik00000/fix/invisible-frames-custom-heads
Fix invisible item frames (#2925)
2026-10-29 22:18:42 +05:00
levzzzandClaude Opus 5.5 dfde0ac091 Merge main and #3176 into hdr-screen: all five HDR settings on HDR's screen
Main gained Black Level (#3145) and #3176 adds UI Brightness; both
move from Display to Graphics: HDR with the rest, in main's order
(Black Level, Peak, Paper White, UI Brightness), and wait for the HDR
switch like the other sliders.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 22:16:24 +05:00
GIGABAIT93andClaude Opus 5.5 dc274d681c Launcher: Language::ALL holds six languages
Merging main into #3625 put Spanish and Ukrainian both in Language::ALL
but left its length at 5, so the launcher no longer builds on main
(E0308: expected an array with a size of 5, found one with a size of 6).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 19:14:26 +02:00
levzzzandClaude Opus 5.5 246a071dbc Merge main into hdr-ui: UI Brightness beside main's Black Level
Main gained HDR Black Level (#3145). Both settings are kept: the
params slot after `pq` is the black level and the next one is the UI
brightness; set_hdr takes both; options save hdrBlackLevel and
hdrUiBrightness; the UI Brightness row follows Paper White.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 22:14:22 +05:00
levzzz 1e965ec5a8 Merge pull request #3749 from reservedbytes/fix/jmath-atan2
fix(core): atan2 matches StrictMath
2026-10-29 22:12:08 +05:00
Federico TaglianiandClaude Sonnet 5.5 bc5fd427ab Armor stands: isFalling before the move, isInWater from the fluid surface
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-29 18:12:03 +01:00
levzzzandClaude Opus 5.5 a7ff35cf7b Merge horizon-rust/main into fix/world-select-icons
Resolves two conflicts:
- gfx.rs: main made padded_row/copy_frame free functions (copy_frame takes
  the device and size) and dropped write_screenshot's COPY_SRC check for
  the OpenGL readback target; take_readback now calls the free copy_frame
  and write_screenshot keeps the ScreenshotRequest signature.
- remote.rs leave_server: keeps main's hud_disconnected (which resets the
  boss overlay), overlay_time and item_activation reset, plus this branch's
  world_icon = None.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 22:10:21 +05:00
levzzz 3e31a55950 Merge pull request #3644 from iLostName/invisible-player-third-person
invisibility: the player's own body is hidden in third person and the inventory, its armour still drawn
2026-10-29 22:10:15 +05:00
levzzz 4e910746ce Merge pull request #3625 from GIGABAIT93/feat/ukrainian
Launcher: Ukrainian
2026-10-29 22:10:04 +05:00
levzzz 06254caea3 Merge pull request #3751 from leifiyoo/fix/creative-drop-items
Creative display drops reach the server on a server connection (#3523)
2026-10-29 22:07:59 +05:00
levzzz 7b78be8d4e Merge pull request #3738 from GIGABAIT93/fix/server-pack-overlay-cache
Server resource packs: keep the overlay when the pack is only touched
2026-10-29 22:07:53 +05:00
levzzz 73602536bb Merge pull request #3717 from 1038april/feat/own-name-tag
Extras: show your own name tag in third person
2026-10-29 22:07:47 +05:00
Ludovic MarieandClaude Opus 5.5 2c8b7ee172 Merge main into the branch: keep both lang test blocks
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 18:04:37 +01:00
levzzzandClaude Opus 5.5 cb3f4387db Merge main into the invisibility branch: render_layers beside main's cape, and no cape while invisible
Main added CapeLayer (mobs::render::cape) at the spot this branch adds render_layers;
both stay. CapeLayer draws only when !state.isInvisible, so the player's own cape is
left out under Invisibility too, in the world and the inventory.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 22:00:59 +05:00
Ludovic MarieandClaude Opus 5.5 95c234cc1e Merge main into the branch
Keep main's Component::Instrument beside this branch's StewEffects (enum,
test and item_wire_probe).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 18:00:27 +01:00
levzzzandClaude Opus 5.5 5815bf4f11 jmath::atan2: use the fdlibm port geometry::trig already has
horizon_core::geometry::trig::fdlibm_atan2 is the same e_atan2.c port, and it
passes every vector of atan2_matches_strictmath, so jmath calls it instead of
keeping a second copy.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 21:58:48 +05:00
levzzzandClaude Opus 5.5 9c21a95409 Merge main into feat/ukrainian: Ukrainian beside main's Spanish
Both languages stay in Language; the translation test walks Language::ALL; the CLI's
help, its error and the docs now list es too.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 21:57:20 +05:00
Ludovic MarieandClaude Opus 5.5 3e1f39899c Merge main into fix-3153: keep both the wither breaks and ender dragon corpus groups
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 17:51:27 +01:00
DenisHumenandClaude Opus 5.5 07cd6c90a8 rea_vanilla: run npx by its path, so it starts on Windows
On Windows npx is npx.cmd, and subprocess (CreateProcess) does not find
it by name: the script stopped with FileNotFoundError. Resolve it with
shutil.which, as the script already does for rea, and say what is
missing when Node.js isn't installed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 18:49:33 +02:00
rdbtCVSandClaude Sonnet 5.5 74f0265db1 Deprecate --in-frame, --no-server-thread and HORIZON_SERVER_THREAD=0
The in-frame single-player path is unsupported and will be removed soon.
The game prints a deprecation warning when it is requested, the help text
and docs say never to use it, and the e2e drivers warn on their flags.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-29 16:43:43 +00:00
levzzzandClaude Opus 5.5 fe47804f28 Merge main into hdr-wayland: display_changed beside main's TAA set_world_post
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 21:43:21 +05:00
levzzzandClaude Opus 5.5 f12d87b5d9 Merge main into hdr-screen: the HDR offer beside main's log, and the test on main's slider API
Main's graphics screen now takes the settings in layout and extra_rows, and a
slider press starts a drag (SliderDrag) that saves on release (#2711).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 21:40:34 +05:00
DenisHumenandClaude Opus 5.5 01bf07dd5d Buckets and bottles pick up the water the eyes are in
BlockGetter.traverseBlocks visits the block a ray starts in first, and
VoxelShape.clip hits a shape that contains the ray's start. pick_fluid
began stepping from the eyes' block without testing it, so under water
a bucket took the next source along the ray (the feet's, looking down)
and no air pocket could be made. Fixes #3787, #3233.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 18:40:25 +02:00
levzzzandClaude Opus 5.5 0b63d040ea Merge main into hdr-ui: hdrUiBrightness beside main's taa and upscaling
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 21:38:13 +05:00
levzzzandClaude Opus 5.5 f9b002f804 Merge horizon-rust/main into fix/spawner-mob-display
Resolves the conflict in game/remote.rs tick_remote: keeps both the
remote spawner tick and main's client_tick_beacons.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 21:31:36 +05:00
bloodulon 1ab99bec27 Packs: an unreadable zip entry costs that file, not the whole overlay 2026-10-29 21:26:49 +05:00
Ludovic MarieandClaude Opus 5.5 2286b52f01 Streaming: build each proto chunk's light view once
A column at the edge of the loaded area lights against its decorated
neighbours past the area, so the streamer turns those proto chunks into
columns (`light_view`) on the tick thread. It built them again for every
light job and for every retry of a column still waiting on its last
neighbour: on a dedicated server with 50 bots, 59 220 views for 5 345
proto chunks. Keep each view until its proto is checked out, replaced or
removed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 17:20:51 +01:00
Ludovic MarieandClaude Opus 5.5 a11e54e7b8 Merge main into the branch
Keep #3543's hurt flash for other players beside the main-hand stack.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 17:01:41 +01:00
Federico TaglianiandClaude Sonnet 5.5 8b5145eaa5 Armor stands: bubble columns push them, and water slows and floats them
An armor stand used the air travel in water too and ignored bubble columns,
so one standing in a soul sand column never rose. LivingEntity.travel now has
its water branch (slow down 0.8, gravity / 16 through getFluidFallingAdjusted
Movement), and the blocks the stand is inside (a bubble column) push it
between the move and the friction, as Entity.move's checkInsideBlocks does.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-29 16:55:45 +01:00
levzzzandClaude Opus 5.5 f065bab2e4 Merge main into the branch; the zombie villager's cure step gets the level random
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 20:55:13 +05:00
levzzz cefa6153d1 Merge remote-tracking branch 'hr/main' into fix-3544 2026-10-29 20:54:41 +05:00
Federico TaglianiandClaude Sonnet 5.5 2f804ed55f Merge main into fix/wither-immune-to-wither-friends
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-29 16:48:18 +01:00
ThatCakeIsAPieandClaude Opus 5.5 52cc53a4f9 Surface rules: an empty biome list keeps the name lookup
An empty `biome_is` list on a surface that does not number its biomes
compiled to the id comparison, since collecting no indices never asks
`biome_index`. It then failed with MissingBiomeProvider where vanilla and
main answer false (material_condition's codec-corpus condition 49). It
keeps the name lookup main uses. Review on #3366.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 10:41:38 -05:00
NetuosoDEVandOpenAI Codex 267b92182e Format the hoisted block tint lookup
Apply rustfmt to the shorter default_tint call after hoisting the grass colour.

Co-Authored-By: OpenAI Codex <[email protected]>
2026-10-29 17:41:19 +02:00
NetuosoDEVandOpenAI Codex 240ac19e3c Compute default grass colour once per block-model draw
Move the active colormap lookup outside the model/face loops, as requested in
review. All tinted faces in this draw share the same default grass colour.

Implemented by OpenAI Codex on behalf of NetuosoDEV.

Co-Authored-By: OpenAI Codex <[email protected]>
2026-10-29 17:40:45 +02:00
GIGABAIT93andClaude Opus 5.5 bf9928423c Text displays ride their vehicles: an NPC's name sits over its head
NPC plugins hang a text display on a fake player with SetPassengers, and
vanilla seats it every tick (Entity.rideTick, positionRider) at the
vehicle's passenger attachment, the top of its box, less the display's
own vehicle attachment, which is 0. remote_set_passengers placed only
mobs and the local player, so a ridden display stayed where it spawned,
at the NPC's feet (tensa.co.ua's lobby: the glyph titles under the NPCs).

A remote text display now keeps its vehicle and position_riders seats
it after the tick, from the last tick's seat, so it follows a moving
vehicle smoothly; a teleport sent meanwhile no longer drags it off.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 17:32:24 +02:00
Gitlab Main 8fdbad2d4b Merge origin/main into feat/audio-output-device 2026-10-29 20:58:32 +05:30
leifiyooandClaude Haiku 5.5 39b6aa46bc Furnace minecart keeps its push length, as vanilla does
MinecartFurnace.calculateNewPushAlong projects the push onto the
movement and scales it back to the push's own 3D length. Horizon kept
the push as x/z only and normalized with the vertical part, so the
push lost length every tick while gravity gave the movement a vertical
part (the cart moved with a 0.04 per tick fall). A cart fuelled from
about half a block away slowed down to about 0.56 to 0.79 of vanilla's
distance over its 3 minutes of coal.

Keep the push as a Vec3 between ticks. Saving still writes only PushX
and PushZ, and loading and fuelling set y to 0, as vanilla does.

Vanilla: MinecartFurnace.applyNaturalSlowdown, calculateNewPushAlong,
addFuel, addAdditionalSaveData, readAdditionalSaveData;
Vec3.projectedOn, Vec3.normalize.

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
2026-10-29 16:26:53 +01:00
leifiyooandClaude Haiku 5.5 f9cac972ad Template placement clears the block entity it replaces
/place template wrote a block's saved data over whatever block entity
was at its position and dropped the old one without removal handling.
Over the same block state, place_state returns early, so a playing
jukebox kept its song and an open container screen stayed on the
replaced entity.

StructureTemplate.placeInWorld first sets a barrier where a block with
saved data goes, which makes LevelChunk.setBlockState remove the entity
there. Do the same before placing: remove the entity and its custom name,
stop a removed jukebox (place_state does that itself when the block's
name changes) and close a container screen open on it.

Refs #3708

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
2026-10-29 16:25:28 +01:00
Ludovic MarieandClaude Opus 5.5 38da7d8d6e Merge main into this branch
brush.rs: main's player_generates_container_loot trigger (#33c15a6d7) is
kept in unpack_brush_loot, which now reads the Brushable block entity.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 16:24:59 +01:00
leifiyooandClaude Haiku 5.5 bb6305da25 Keep creative display drops on a server connection
A throw on an item tab's display slot (Q or a right-click in the creative
screen) dropped the stack with a local Player.drop. On a server connection
(single player's server thread, LAN, multiplayer) that item entity was
never sent to the server, so it vanished. Send the stack as
SetCreativeModeSlot to slot -1 instead, as the survival tab's throw and
vanilla's handleCreativeModeItemDrop already do.

Vanilla: CreativeModeInventoryScreen.slotClicked (item tab THROW),
MultiPlayerGameMode.handleCreativeModeItemDrop.

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
2026-10-29 15:54:24 +01:00
leifiyooandClaude Haiku 5.5 978c42216a Replace block entities in a loaded chunk by block state
load_chunk put each loaded block entity into the map over any entity
already at its position and kept that position's ticker slot whatever
the new block state was. Vanilla's LevelChunk.setBlockEntity drops the
previous entry, and updateBlockEntityTicker rebinds the ticker only when
the new state has one, removing it otherwise.

Mark replaced positions and, at the next tick, free their slot unless
the block state ticks. Add a test covering a replacement that keeps the
slot and one that frees it.

Vanilla: LevelChunk.setBlockEntity, LevelChunk.updateBlockEntityTicker,
LevelChunk.registerAllBlockEntitiesAfterLevelLoad.

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
2026-10-29 15:53:51 +01:00
reservedbytes d385d188f3 fix(core): atan2 matches StrictMath 2026-10-29 13:47:57 -01:00
NetuosoDEVandOpenAI Codex ddc2a6d7e6 Merge origin/main into fix/enderman-carried-block-tints
Resolve the carried-block overlap with #2986 while preserving upstream's iron
golem flower layer, fox item context, minecart changes and renderer crate split.
Keep the captured carry transform and per-face BlockTintSource defaults; remove
the superseded single-tint helper that only the old enderman path used.

Carry the tint implementation into horizon-client-render/src/mesher.rs and
update the differential harness's source path after the upstream module move.

Resolved by OpenAI Codex on behalf of NetuosoDEV. No additional local tests
were run, as requested by NetuosoDEV; the updated PR head is checked by CI.

Co-Authored-By: OpenAI Codex <[email protected]>
2026-10-29 16:27:13 +02:00
NetuosoDEVandOpenAI Codex d3e2fe8bf3 Render enderman carried block states with vanilla tints
Use the baked block-state model instead of the block item's model so flowers,
fungi and roots keep their cross geometry and state properties survive. Remove
the item-centering compensation while retaining the vanilla carry transform.

Apply BlockTintSource.color(state) to tinted block-model faces using the active
grass colormap and vanilla default foliage colours. Keep in-world sugar cane
and lily pad colours unchanged. Use BlockModelResolver's fixed model seed 42.

Add a differential adapter checking 6,616 state/tint-index combinations against
Minecraft 26.3 and a scratch-world offscreen screenshot driver. The existing
captured-vertex transform regression now checks block coordinates directly.

Implemented by OpenAI Codex on behalf of NetuosoDEV. NetuosoDEV manually tested
the fix in game and confirmed it works.

Co-Authored-By: OpenAI Codex <[email protected]>
2026-10-29 16:23:20 +02:00
GIGABAIT93andClaude Opus 5.5 f383fdf74c Server resource packs: keep the overlay when the pack is only touched
Joining a server whose resource pack is already in the download cache
marks the file used (HttpUtil.updateModificationTime, ported as touch),
and the overlay cache keyed a ZIP pack by its stamp, modified time
included. Every join therefore rebuilt the whole overlay and reloaded
from thousands of new files: on Windows, where Defender scans each, a
join waited about a minute on "Preparing resources" (a 1.2 MB Nexo
pack on tensa.co.ua). A ZIP is now known by its bytes, one file read;
directory packs keep their stamps.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 16:22:06 +02:00
Ludovic MarieandClaude Opus 5.5 1a01f5ef59 Merge main into the branch
Main added block_model_vertices_tinted (one tint for every tinted face)
for mob_blocks.rs; it now calls this branch's tinted_block_model_vertices
with that tint for tint indices >= 0.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 15:10:01 +01:00
ThatCakeIsAPie a717d6d583 Merge feat/upgrade-datapack-3: refuse pack entries that leave the pack 2026-10-29 09:08:50 -05:00
ThatCakeIsAPie 1ffe686350 Merge feat/upgrade-datapack-2b: refuse pack entries that leave the pack 2026-10-29 09:08:50 -05:00
ThatCakeIsAPie 217216e263 Merge feat/upgrade-datapack-2a: refuse pack entries that leave the pack 2026-10-29 09:08:48 -05:00
ThatCakeIsAPie 825f8f8845 Merge feat/upgrade-datapack-1: refuse pack entries that leave the pack 2026-10-29 09:08:47 -05:00
ThatCakeIsAPieandClaude Opus 5.5 0771f59bca upgrade-datapack: refuse pack entries that leave the pack
A zip entry named `../x` or `/x` (or `C:\x` on Windows) was written as it
was, outside `<world>/datapacks/<name>-26.3/`. `read_pack` now takes each
entry's name from `enclosed_name()` and refuses a pack with one that has
anything but plain components, and `upgrade_into` refuses to write such a
path. Review on #3369.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 09:08:46 -05:00
Ludovic MarieandClaude Opus 5.5 c455bb6ec7 Merge main into the branch
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 15:04:30 +01:00
xKumorioandClaude Opus 5.5 a91952a8c0 Merge main: keep only what #3085 doesn't have
#3085 already moved the bow, trident, throws, potions and wind charge to
known_player_movement(), and main has the Windows test fixes (#3088, and
#3587 for port_publish.py). This branch takes main's version of all of
those. What it keeps: Guest::known_movement, set on every accepted move
and zeroed by a ClientTickEnd without one (handleClientTickEnd), and the
save and restore of Game::known_movement in enter_guest/leave_guest.
The moves.last() copies in UseItem, the spear's jab and the per-tick
using turn go, since enter_guest sets the value for every turn.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 18:58:55 +05:00
vvnikita74 c5a734eace fix: sleep only when it is dark outside, not whenever the thunder flag is set (#3709)
Beds checked the level's raw thundering flag, whose timer also runs in
clear weather, so players could sleep through a sunny day. The Overworld's
bed_rule sleeps WHEN_DARK, as vanilla's Level.isDarkOutside: skyDarken >= 4,
from the time of day and the rain and thunder levels.
2026-10-29 16:47:08 +03:00
Claude f4f9897c9e Extras: show your own name tag in third person
A new extra, Own name tag (ownNameTag, off by default), under a Third
person header on the Extras page. With it on, the player's own model in
third person gets the name tag other players see: the profile name in
its team's colours, the below-name score, faded while crouching. It goes
through the same checks as other players' tags (team name tag
visibility, distance, F1) and hides in first person, while sleeping, in
spectator mode and while invisible. Vanilla never names the camera
entity, so this stays off by default.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018ksHJwFp11RfYk6S9xnyCf
2026-10-29 23:26:36 +10:00
levzzzandClaude Opus 5.5 35eb60e2f1 Merge main into launcher-simplified-chinese: keep Spanish and Chinese
Both languages stay in Language::ALL; the PR's loop over every language
replaces main's fixed list in the completeness test.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 18:12:09 +05:00
Ludovic MarieandClaude Opus 5.5 558904775f Merge main into this branch
Text displays: main's text_display::draw and Lines (#2812) replace this
branch's inline drawing, with this branch's display_light (the same light);
this branch's display_data and transformation are kept beside them.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 14:10:43 +01:00
Federico TaglianiandClaude Sonnet 5.5 01125c3023 mob_ai: chicken and pig panic scenarios, one beside a pool
chicken_panic_192 fails without the Chicken water malus (nav path of 7
nodes through the pool instead of vanilla's 5).

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-29 14:06:08 +01:00
degepieandClaude Opus 5.5 8537e29c92 Merge remote-tracking branch 'origin/main' into fix/issue-3137-suspicious-stew
Conflicts:
- game/stations.rs: keep both tooltip lines, the ominous bottle's before
  the suspicious stew's, in ItemStack.addDetailsToTooltip's component order.
- item_tooltip.rs: keep both tests; the stew test now expects main's
  zero-padded durations ("00:03", "00:07").

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UE3PMKbnTcxfBSeT5uQWCK
2026-10-29 18:06:00 +05:00
Ludovic MarieandClaude Opus 5.5 5a1c095724 Merge main into this branch
hud.rs: main's name_tag (and its test) and this branch's draw_inline_object
(and its tests) both kept.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 14:00:51 +01:00
Ludovic MarieandClaude Opus 5.5 bc78cba5b9 Merge main into this branch
remote.rs: main's spectator check and off-hand anchor charge are kept with
this branch's InteractionResult: an anchor charged from the off hand is
SUCCESS (RespawnAnchorBlock.useItemOn) and swings, a jukebox taking a disc
is SUCCESS (JukeboxPlayable.tryInsertIntoJukebox), an anchor left to the
off hand's glowstone is PASS. level/tests.rs: both new tests kept.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 13:59:19 +01:00
Federico TaglianiandClaude Sonnet 5.5 398b1dd2d8 Chicken: water costs nothing to path through, as in vanilla's constructor
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-29 13:59:17 +01:00
Ludovic MarieandClaude Opus 5.5 62c0834880 Merge main into this branch (rearing beside the horse finalize tests, one tests module)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 13:57:27 +01:00
Ludovic MarieandClaude Opus 5.5 98c0883c8f Merge main into this branch; suspicious blocks are not containers
remote.rs: main's spectator check (a spectator's use predicts nothing) and
this branch's shelf prediction are both kept, the spectator first.

machines.rs: the new Brushable block entity made the dispenser harness's
dropper under suspicious sand treat it as an empty container and drop
nothing; BrushableBlockEntity is no Container, so the dropper drops in
front of it as vanilla does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 13:55:05 +01:00
Ludovic MarieandClaude Opus 5.5 0c06082dfa Merge main into this branch
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 13:51:05 +01:00
Federico TaglianiandClaude Sonnet 5.5 e0fecd8b8e Fish: swim targets pass the water malus check, and FishMoveControl turns as vanilla
Cod, salmon, tropical fish, pufferfish, tadpoles, squid and dolphins keep
PathType.WATER at 0 (WaterAnimal and AgeableWaterCreature constructors).
Horizon left it at 8, so DefaultRandomPos rejected every water position
and RandomSwimmingGoal retried ten times, ending on a spot in the air.
FishMoveControl also computes its yaw like MoveControl does.

Adds cod, salmon and tropical fish scenarios to the mob_ai harness; the
probe names the fish goal FishSwimGoal as vanilla does.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-29 13:51:02 +01:00
Ludovic MarieandClaude Opus 5.5 e2de446128 Merge main into this branch
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 13:51:02 +01:00
levzzzandClaude Opus 5.5 f7efb0d710 The launcher's Simplified Chinese covers the strings added on main: native mods, moving an instance to the Trash, and the build's new time estimate.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 17:45:38 +05:00
k3yhandClaude Opus 5.5 a839b0cef1 An op's spawn egg sets its entity data on a spawner
SpawnEggItem.useOn on a spawner takes the type from getType, the egg's
entity data id when it has one, and for an op (PlayerList.isOp) calls
Spawner.setEntityData: TypedEntityData.loadInto sets the next spawn
data's id and merges the egg's data into it. Horizon set only the egg's
own type, so a custom egg gave a plain mob.

The acting server guest's permission level is kept beside its name and
UUID (lan_acting_permission), so the check is the acting player's own.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 07:42:40 -05:00
levzzz 9da77d9d4b Merge remote-tracking branch 'hr/main' into pr-2795
# Conflicts:
#	launcher/src/i18n/tests.rs
2026-10-29 17:41:21 +05:00
Ludovic Marie 2cd11c926f Merge branch 'draft/remove-wasm-mods-game' into draft/remove-wasm-mods-crates 2026-10-29 13:38:59 +01:00
Ludovic Marie a7f1f70508 Merge remote-tracking branch 'origin/main' into draft/remove-wasm-mods-game 2026-10-29 13:38:59 +01:00
damnurmumandCodex 0d0411fc7a Merge latest main into rich item names and shadows
Pick up launcher and dragon changes that landed while the earlier merge was being tested.

Co-Authored-By: Codex <[email protected]>
2026-10-29 15:29:59 +03:00
damnurmumandCodex 8c739d2bc2 Merge main into rich item names and shadows
Resolve HUD shadow rendering conflict by keeping styled shadow colors and the new world text depth behavior.

Co-Authored-By: Codex <[email protected]>
2026-10-29 15:29:52 +03:00
Ludovic Marie b25d588c91 Merge branch 'draft/remove-wasm-mods-game' into draft/remove-wasm-mods-crates 2026-10-29 13:25:05 +01:00
Ludovic Marie 6e8ee3ed07 Merge remote-tracking branch 'origin/main' into draft/remove-wasm-mods-game
# Conflicts:
#	crates/horizon-client/src/game/mods.rs
2026-10-29 13:23:27 +01:00
levzzzandClaude Opus 5.5 c566e69c6c Tick dropped items again, and keep a cooling-down entity out of a gateway
The first commit replaced the tick_items span with the gateway handler, so
dropped items stopped falling, merging and despawning in every mode. The
call is back, next to the gateway's.

An entity that has just come through a portal carries PortalCooldown 300.
EndGatewayBlock.entityInside still triggers the gateway's own cooldown, but
Entity.setAsInsidePortal then only restarts the entity's cooldown, whatever
the portal: the gateway now asks the shared portal state first.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 17:21:13 +05:00
Ludovic MarieandClaude Opus 5.5 461a8757e3 Merge main into the branch
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 13:19:44 +01:00
damnurmumandCodex a5a0e1bb98 Merge main into inline object text
Resolve HUD text rendering conflict by preserving inline player and atlas objects alongside the new world text shadow and depth behavior.

Co-Authored-By: Codex <[email protected]>
2026-10-29 15:18:19 +03:00
iLostName 4e5b26f9fb elder guardian: a player already cursed isn't cursed again every minute
The check looked up `minecraft:mining_fatigue`, but the player's effects are kept by their bare name, so it never matched: the curse, its sound and the apparition came back at every 1200-tick pulse. As MobEffectUtil.addEffectToPlayersAround does, a curse as strong that doesn't end within the next minute now stays.
2026-10-29 12:19:58 +01:00
degepieandClaude Opus 5.5 7e459e5fb1 Frogs long jump: level and unseeded randoms, activity gate TODO
Address the review on #3544:
- goals::Cx carries the level random (the one the brains already get);
  the weighted jump-candidate pick and the three TIME_BETWEEN_LONG_JUMPS
  samples draw from it, as LongJumpToRandomPos/LongJumpMidJump do.
- The angle shuffle in calculateOptimalJumpVector uses a fresh unseeded
  random (Collections.shuffle's global one), not the frog's.
- The preferred-block roll stays on the frog's random (vanilla uses the
  body's random there).
- TODO in LongJump::can_use for FrogAi.initJumpActivity's TEMPTING_PLAYER /
  BREED_TARGET gate and LAY_SPAWN priority: frogs have no tempting,
  breeding or frogspawn here yet.

Other mobs only get the new field threaded through; nothing else reads it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UE3PMKbnTcxfBSeT5uQWCK
2026-10-29 16:15:54 +05:00
Federico TaglianiandClaude Sonnet 5.5 9828599067 Wither: take no damage from wither friends (skulls, blasts)
WitherBoss.hurtServer rejects damage caused by a non-player mob in
#wither_friends (the undead, withers included). Horizon only filtered
the wither's own targeting, so skulls and explosions from one wither hurt
its neighbours, which then retaliated through HurtByTarget.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-29 12:10:07 +01:00
Ludovic MarieandClaude Opus 5.5 84185641bc Merge main into perf/worldgen-surface-biomes
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 12:07:11 +01:00
Ludovic MarieandClaude Opus 5.5 c0b3b5b3e9 Launcher: the night sky behind every page
sky.rs draws the night look's backdrop: the sky in texel bands, stars that
twinkle in steps, three layers of hand-drawn clouds drifting a texel at a
time, a sun cut in slices and two skylines. Sprites are baked once per
appearance and scale into tiles that don't depend on the window's size; a
frame is asked for only when something on screen changes, never under
reduced motion (macOS, or HORIZON_LAUNCHER_MOTION=off), and not 10 s after
the window loses focus. It replaces the stone texture and Home's landscape.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 11:58:28 +01:00
Ludovic Marie e932a2da61 Merge remote-tracking branch 'origin/main' into upd/2924
# Conflicts:
#	crates/horizon-client-render/src/options_ui.rs
2026-10-29 11:47:35 +01:00
Ludovic Marie edd11b6103 Merge remote-tracking branch 'origin/main' into upd/3401 2026-10-29 11:47:06 +01:00
iLostName 2677a397e5 invisibility: the player's own body is hidden in third person and the inventory, its armour still drawn
The model check looked up `minecraft:invisibility`, but the player's effects are kept without the namespace, so the body always showed. As LivingEntityRenderer.submit does, the body is now skipped unless the player's team sees friendly invisibles, while armour, elytra and held items still draw. Fixes #3615.
2026-10-29 11:42:52 +01:00
damnurmumandCodex be2d3582e7 Keep rich item names in inventory sync and match shadow channel floors
Send the stored custom-name component through the server inventory codec and use vanilla float-to-channel conversion for RGBA shadow colors. Cover both paths with focused tests.

Co-Authored-By: Codex <[email protected]>
2026-10-29 13:28:40 +03:00
Ludovic MarieandClaude Opus 5.5 3c6bdc2e5b Launcher: the night look's specimen (HORIZON_LAUNCHER_PAGE=specimen)
Draws the night tokens' type and colours in the launcher itself, over the
sky's stops, with each text colour's contrast on a plate over the brightest
stop and the rounded and pixel-cut plate corners side by side. Not a page
players reach; for checking the tokens in the real renderer.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 11:19:38 +01:00
Ludovic MarieandClaude Opus 5.5 99071a1160 Launcher: night tokens and Bricolage Grotesque in horizon-theme
horizon_theme::night: the night sky look's colours (NIGHT, and DAWN for Light),
type steps, radii and the Bricolage Grotesque cuts (tools/bake_bricolage.py,
OFL), with AA tests over every sky stop. tokens.css gains --night-*. The
launcher reads them: Night::blocks() dresses the screens not rebuilt yet in
the night's colours, and Bricolage replaces Geist and Pixelify Sans there.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 11:19:37 +01:00
Mik00000 e0f11804f2 Address review: drop legacy readers; keep flag sync and entity_data placement 2026-10-29 12:17:39 +02:00
GIGABAIT93andClaude Opus 5.5 e7e5413667 Launcher: Ukrainian
launcher/lang/uk.json translates the launcher's 194 strings, Horizon's
own, written from the English; Language::Ukrainian ("uk", Українська)
joins the picker, the system language pick and `settings language`. The
translation test now covers every language but English.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 12:15:38 +02:00
deloskiyt c6fbd42aee Audio: play break sound and track stats when tools break while mining (#3524)
Fixes missing item break sound effect and broken statistic tracking:
- When a tool breaks from mining a block (mine_block_with_held), route
  durability wear through damage_held rather than clearing the slot silently.
- Route weapon wear in mace.rs and brush wear on armadillos in creatures.rs
  through damage_held.
- Play entity.item.break sound and re-apply equipment when worn armor
  breaks from Thorns in enchant_effects.rs.
2026-10-29 11:13:20 +01:00
Mik00000 7fb1f77870 Address review: drop overlapping spectral arrow paths and remove double arms 2026-10-29 12:06:20 +02:00
GIGABAIT93andClaude Opus 5.5 8a9db2dc7b Fonts: Pixelify Sans gets the Cyrillic І
Ukrainian headings, buttons and navigation need the capital І (U+0406),
which upstream doesn't map, so it came from the fallback font, smooth
and taller than the pixel letters around it. As #2844 did for О, it
takes each weight's own Latin I glyph, the same shape. Only cmap changes
(and head's checksum adjustment); every other table, the timestamps
included, is byte-identical to the files it replaces.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 12:06:06 +02:00
malordinandClaude Sonnet 5.5 ff0c089ac5 Launcher: refill the asset store at start-up, not on Play
`complete_assets` also runs when Play is pressed, so a store with most of its objects
missing made the player wait for thousands of downloads and a sign-in with nothing on
screen. `refill_own_store` now runs in `reuse_prepared_everywhere`, on the start-up
thread, and Play only copies what is there; the gate's test pins both places.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-29 15:03:16 +05:00
Mik00000 4d13a4ebcc Address review comments: empty Spawner default, remove legacy fallbacks, use block light 2026-10-29 12:00:54 +02:00
Ludovic MarieandClaude Opus 5.5 a20aaf5396 Text objects: vanilla's default fallback when none is given
ObjectContents.visit uses the object's defaultFallback without one:
[item/porkchop], [porkchop@gui] off the blocks atlas, [Steve head].

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 10:51:40 +01:00
degepieandClaude Opus 5.5 d9f40c6942 Adventure: don't predict breaking a block the player may not break
MultiPlayerGameMode.startDestroyBlock refuses before predicting when
Player.blockActionRestricted holds: an adventure player without a
can_break item. The client predicted the break anyway (sound, crack,
the block gone) and the server sent it back, and continueAttack swung
every tick. A stack the server sent with can_break still breaks; the
server judges its predicate.

Fixes #3027.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UE3PMKbnTcxfBSeT5uQWCK
2026-10-29 14:43:19 +05:00
deloskiyt eaa5549a3b Render: draw carved pumpkins and mob heads on player and humanoid heads (#3549)
Ports CustomHeadLayer.submit for players and humanoid mobs:
- Renders carved pumpkins, jack o'lanterns, and mob/player heads worn
  in the head equipment slot on the player model and humanoid mobs.
- Adds head_item_geometry to mob_block_geometry, player_model_geometry
  (third person), and draw_player_preview (inventory preview).
- Correctly skips armor helmets (handled by HumanoidArmorLayer) and
  body-slot species (equines, wolves, etc.).
- Keeps player model extra.head and extra.armor synchronized in
  tick_player_model.
- Updates tools/port_map/client-005.tsv mapping.
2026-10-29 10:41:02 +01:00
iLostName 0bf91b8822 ender pearl: one that hits an end crystal still takes its thrower there
A thrown projectile that hit a crystal or a dragon part stopped at onHitEntity; a pearl, an egg and a snowball now go on to their onHit. Fixes #3081.
2026-10-29 10:40:17 +01:00
damnurmumandCodex c01f4f646b Merge main into rich item name colors
Preserve styled custom names in loot component updates and cover the merged behavior.

Co-Authored-By: Codex <[email protected]>
2026-10-29 12:36:47 +03:00
Ludovic MarieandClaude Opus 5.5 f44042e0eb Sound device: vanilla's own option names, and a missing device asks once
options.audioDevice and options.audioDevice.default come from vanilla's
language files ("Device", "System Default"): the game's own lang no longer
overrides them. Audio keeps the device asked for, so a saved device that is
unplugged plays on the default without reopening the stream at every
settings change.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 10:25:05 +01:00
Ludovic MarieandClaude Opus 5.5 4a2a5b9977 Merge main into this branch after the render crate split (#3186)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 10:25:05 +01:00
levzzzandOpenAI Codex 6534db4f6d Specify the equipment regression fixture's mob registry type
Co-Authored-By: OpenAI Codex <[email protected]>
2026-10-29 14:24:18 +05:00
levzzzandOpenAI Codex 69689b3fdd Cover named equipment replacement through save and reload
Co-Authored-By: OpenAI Codex <[email protected]>
2026-10-29 14:23:48 +05:00
levzzzandOpenAI Codex c6dfae6dd7 Keep live enchantments when displaying and saving named equipment
Co-Authored-By: OpenAI Codex <[email protected]>
2026-10-29 14:18:51 +05:00
Ludovic MarieandClaude Opus 5.5 1eb34fa68e Merge main into this branch
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 10:18:40 +01:00
Ludovic MarieandClaude Opus 5.5 4623d2e59f Merge main into this branch
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 10:18:33 +01:00
Ludovic Marie 04cd6ece23 Drop the test that only exercised std::mem::replace (the fix is the one line in as_guest) 2026-10-29 10:16:08 +01:00
Ludovic Marie 9218dc3e7d Leave horizon-server's lists test as on main (not part of this fix) 2026-10-29 10:15:47 +01:00
Ludovic Marie 5b66bd0ec0 Merge remote-tracking branch 'origin/main' into upd/3148 2026-10-29 10:15:46 +01:00
levzzzandOpenAI Codex f75896ec45 Retry unloaded gateway arrivals and identify the fixture entities by tag
Co-Authored-By: OpenAI Codex <[email protected]>
2026-10-29 14:13:49 +05:00
levzzzandOpenAI Codex 7d0e5aa953 Do not save replaced main-hand components from the raw entity
Co-Authored-By: OpenAI Codex <[email protected]>
2026-10-29 14:04:47 +05:00
levzzzandOpenAI Codex a80973335c Merge current main before rerunning End gateway cargo proof
Co-Authored-By: OpenAI Codex <[email protected]>
2026-10-29 14:03:47 +05:00
levzzzandOpenAI Codex ace27c11a9 Keep named held stacks current and use full death-message display names
Co-Authored-By: OpenAI Codex <[email protected]>
2026-10-29 14:01:23 +05:00
levzzzandOpenAI Codex c91ca07115 Merge current main and use its preset-aware terrain interface
Co-Authored-By: OpenAI Codex <[email protected]>
2026-10-29 13:58:50 +05:00
levzzzandOpenAI Codex c001f56d0f Merge current main before death-message review fixes
Co-Authored-By: OpenAI Codex <[email protected]>
2026-10-29 13:58:45 +05:00
levzzzandOpenAI Codex b36a63fb39 Merge current main and retain vanilla features save status
Co-Authored-By: OpenAI Codex <[email protected]>
2026-10-29 13:58:03 +05:00
v3nn7andCodex 65043ba092 Sync furnace minecart fuel exhaustion
Co-Authored-By: Codex <[email protected]>
2026-10-29 09:56:12 +01:00
d3grandoandClaude Opus 5.5 daa5415724 Paintings: the item's variant is placed and named in its tooltip
A painting item with a `painting/variant` component (every one but the
first in the creative tab) placed a random painting and showed only
"Painting". HangingEntityItem.useOn runs Painting.create's random pick,
then EntityType.createDefaultStackConfig applies the stack's components
(Painting.applyImplicitComponents sets the variant) and the painting must
still survive. HangingEntityItem.appendHoverText adds the variant's title,
author and size, or "Random variant" in a creative tab without one.

The component also crosses the wire now (a painting_variant holder,
component 109): a creative client's pick reaches the server, and the
server's stacks keep their variant on the client.

Fixes #3503

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 15:54:33 +07:00
v3nn7andCodex f2edc255d8 Render carved pumpkins on player heads
Co-Authored-By: Codex <[email protected]>
2026-10-29 09:54:31 +01:00
v3nn7 f6144df193 Show powered furnace minecarts on remote clients 2026-10-29 09:53:17 +01:00
Username59138andClaude Opus 5.5 3451e4e29a Command blocks: keep the command in the edit screen, suggest as vanilla
Command blocks barely worked from the default single player (the window
is a client of its server thread), a LAN guest or a dedicated server:

- ServerPlayer.openCommandBlock: the server now sends the block's data
  (saveCustomOnly) to the player whose use opened it, and the open
  screen refills from it (handleBlockEntityData, updateGui). The chunk's
  update tag leaves the command out, so the screen showed an empty box
  and Done then wiped the block's command.
- Screen.extractBackground of an isInGameUi screen: the dimmed
  gradient, not the in-world menu background.
- CommandSuggestions in the command box with the arguments
  AbstractCommandBlockEditScreen passes (commands without a slash,
  completions from the start, 7 lines under the box, fill 0x80000000):
  the chat's suggestions take a SuggestLayout. Server completions
  (ask_server) reach the box too.
- horizon-ctl key presses reach the command block screen.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 11:53:14 +03:00
levzzzandOpenAI Codex bd59c0bab2 Pick up validated standard macros for body hooks
Co-Authored-By: OpenAI Codex <[email protected]>
2026-10-29 13:47:44 +05:00
levzzzandOpenAI Codex d3cb2f5c29 Pick up HEAD macro preservation in the launcher mixins pin
Co-Authored-By: OpenAI Codex <[email protected]>
2026-10-29 13:47:44 +05:00
d3grandoandClaude Opus 5.5 a30b9525d1 Horses rear when hurt
AbstractHorse.hurtServer had no port: after a hurt that went through,
vanilla draws random.nextInt(3) and on 0 calls standIfPossible, which
rears the horse for 20 ticks unless canPerformRearing is false (llamas,
camels). Mob::damage_with now ends with that roll for every equine, and
make_mad shares the new stand_if_possible.

The stand counter runs down in AbstractHorse.tick, with or without AI,
but Horizon ran it down only in the AI step, so a NoAI horse that reared
from a hit stayed reared for good. A mob with no AI now runs it down too.

The mob_ai harness gets a llama_hurt scenario (the draw without the
rear), which matched for 10 ticks before and all 201 after; the probe
names MountPanic and LlamaHurtBy as vanilla does.

Fixes #3432

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 15:45:42 +07:00
v3nn7andCodex e851af06d0 Add Polish translations
Co-Authored-By: Codex <[email protected]>
2026-10-29 09:43:11 +01:00
SenjinandClaude Opus 5.5 ef2ec4c0dc CI: run the checks again (horizon-server's lists test got no answer from Mojang's services)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 09:33:06 +01:00
d3grandoandClaude Opus 5.5 11d3f08c12 Wither: broken blocks give way to their fluid, so water stays water
A wither's block breaking (WitherBoss.customServerAiStep) calls
Level.destroyBlock, which sets the cell to fluidState.createLegacyBlock()
with flag 3. Horizon's mob BreakBlock event wrote air instead, without
neighbour updates: a wither in water carved an air pocket, a waterlogged
block lost its water, and the pool did not flow back in. The event now
sets the fluid's block through setBlock (skipped when that is already
the cell), and the wither's break sound (level event 1022) only counts
cells that changed, as destroyBlock's return value does.

Checked by a new "wither breaks" group in the command execution harness.

Fixes #3153

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 15:28:03 +07:00
Kacpherek 25e39a2f2e Follow the crate split: repoint 1,272 map rows to their files' new crates 2026-10-29 09:07:15 +01:00
Kacpherek 630f3feec5 Merge branch 'main' of https://github.com/ludovic111/horizon into tools/port-ledger-harness
# Conflicts:
#	docs/port-ledger.md
#	tools/port_ledger.py
2026-10-29 09:07:15 +01:00
Senjin a0964a8010 Merge remote-tracking branch 'origin/main' into hdr-wayland 2026-10-29 08:32:57 +01:00
Senjin d91bd61281 Merge remote-tracking branch 'origin/main' into hdr-screen 2026-10-29 08:32:57 +01:00
Senjin 289c2df527 Merge remote-tracking branch 'origin/main' into hdr-ui 2026-10-29 08:32:56 +01:00
Senjin 84d49077c0 Merge remote-tracking branch 'origin/main' into hdr-tiers 2026-10-29 08:29:53 +01:00
時穂andCodex 6b96bb96b6 Merge main into launcher-simplified-chinese
Keep German and Simplified Chinese together, retain token-based translation
checks, and refresh Chinese strings for the current launcher.

Co-Authored-By: Codex <[email protected]>
2026-10-29 16:29:05 +09:00
Ludovic MarieandClaude Opus 5.5 0c9a49390e Crates: remove the WebAssembly mod runtime, its SDK and the official WebAssembly mods
The game no longer runs WebAssembly code mods (the previous commit), so the
workspace drops what made and ran them: horizon-mods' runtime, commands and
test harness (wasmtime, wit-component, wit-parser), the SDK and its WIT
contract and examples, the sandbox's guest crates, and mods/official (the
official mods are native now, one native/<name>/ folder each in horizon-rust/horizon-mods).

horizon-mods keeps content mods, modpacks and the registry index (whose
api_level still reads published .hmod files). `cargo horizon-mod` makes
content mods only: `new` has one template, `build` refuses a crate,
`check` refuses a .hmod with code and points to native mods, and `test`
(the sandbox) is gone. CI no longer installs the wasm32 target.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 07:46:57 +01:00
Ludovic MarieandClaude Opus 5.5 e7614d2197 Game: WebAssembly code mods don't run any more
Horizon's code mods are native now (game/native_hooks.rs, built in by the
launcher). The game drops the WebAssembly runtime and everything around
it: the `mods` feature (wasmtime and cranelift), the mods' hooks, HUD
panels and screens, the item panel and its recipes, input claims, camera
shakes, far terrain, server mods and the extras that moved to WebAssembly
mods. Content mods (blocks, items, data and assets) load as before.

A WebAssembly code mod's row on the Mods page says it doesn't run any more,
or that its native version replaced it. The first world after the update
says once in the chat which stopped and where to get their native
versions; their files stay where they are.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 07:46:57 +01:00
malordinandClaude Sonnet 5.5 2d98bf8dae Launcher: fetch the assets a stopped download left out, and stop waiting on quiet connections
A download from Mojang that stopped on the way (the assets' failure is one log line)
leaves the asset store with some of its files, and every completion that copies from
it (the sounds, the languages, #1870) then finds nothing to copy and the game plays
silent and in English. Asking for it again did not help: the folder is found as it is,
so Prepare again never reached Mojang.

`complete_assets` now asks the owner gate again (`download_for_owner`, the one door to
Mojang's files) when Horizon's own store exists and a prepared version has no sounds or
no languages, once per run. The download keeps the files already whole, checked by sha1,
and fetches the rest; a non-owner fetches nothing, which the gate's test now also covers
for a store that lacks assets.

In that download each asset is tried three times and one that keeps failing does not
stop the rest: the log says how many failed and the first one's reason, and the next
run fetches what is left.

Requests had no timeouts, so one connection that went quiet held the whole download
for ever: the agent now gives up after 15 s to connect and 60 s without data.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-29 11:04:15 +05:00
Kacpherek 451720bb56 Port ledger: link parity harnesses to the methods they cover; list untested ports 2026-10-29 06:40:06 +01:00
Alexander KrylovandHermes Agent 5485e189f8 Options: Horizon's own captions read the bundled language
The options pages, the mod and shader pack lists, the agent button and the
multiplayer friend screens drew their own labels as literals, so a client with
ru_ru stayed English there. They read `horizon.*` keys now, and the bundled
namespace carries both languages for every one of them.

Co-Authored-By: Hermes Agent (Nous Research) <[email protected]>
2026-10-29 08:35:09 +03:00
Ludovic MarieandClaude Opus 5.5 bc33470d5c Merge main into this branch
Main now has the render crate split (#3186); Git followed the moved files,
and nothing in this branch needed changing.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 06:13:15 +01:00
Ludovic MarieandClaude Opus 5.5 c48c2f2167 Merge main into this branch
Main now has the render crate split (#3186); Git followed the moved files,
and nothing in this branch needed changing.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 06:13:14 +01:00
Ludovic MarieandClaude Opus 5.5 cef862471f Merge main into this branch
Main now has the render crate split (#3186); Git followed the moved files,
and nothing in this branch needed changing.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 06:13:13 +01:00
d3grandoandClaude Opus 5.5 44d9bddebd Open shulker boxes with the shulker box menu on servers
ShulkerBoxBlockEntity.createMenu opens a ShulkerBoxMenu
(minecraft:shulker_box), whose ShulkerBoxSlots refuse shulker boxes. The
server (single player on its server thread, LAN, dedicated) sent a
generic_9x3 chest menu instead, so it took a shulker box put into a
shulker box, and a vanilla client drew a chest screen.

lan_open_container now sends the block entity's menu type from
horizon_client::containers::block_menu_type; the server already refuses
shulker boxes in that menu, and the client already draws it.

New harness tools/test_storage_menu_parity.py (VanillaStorageMenuOracle,
storage_menu_fixture): 31/31 blocks, 14/31 with the old menu type. New
lan_e2e check shulker_menu.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 11:41:34 +07:00
Ludovic MarieandClaude Opus 5.5 984273f5c8 Merge main into this branch after the render crate split (#3186)
containers.rs moved to horizon-client-render (#3186): this branch's SkullSkin fallback (the "Name" key and a plain string profile) is carried over to the moved file.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 05:41:28 +01:00
Gitlab Main 48e731483a options: choose the audio output device in Music & Sounds (#1035)
Adds audio output device selection matching vanilla options.txt format:
- Bind soundDevice option in Settings with serialization roundtrip
- Enumerate output devices and support live stream switching in Audio
- Add SoundDevice option button to Music & Sounds page cycling devices
- Apply audio device changes dynamically on options save and startup
- Unit tests for settings parsing and options UI button cycling
2026-10-29 09:13:13 +05:30
SenjinandClaude Opus 5.5 cd70f518e9 HDR Auto: what Hyprland 0.57 reports, in the docs and the code comment
Tested on Hyprland 0.57 (always-on HDR, NVIDIA): it reports reference white
203 and 0..1060 nits, so Auto gives Paper White 203 / Peak 1060, and it shows
HDR windows below its desktop's SDR white (sdr_max_luminance 430, which the
protocol doesn't carry): a white browser page is brighter than the game's
white and than a 1000-nit patch in mpv.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 04:35:38 +01:00
d3grandoandClaude Opus 5.5 aded903dd2 benchmarks: Horizon v0.2.97 vs vanilla 26.3 on Windows 11 (9800X3D, RTX 5080)
The first perf_bench run on Windows: Horizon v0.2.97, vanilla 26.3 and vanilla with Sodium,
Lithium, FerriteCore and ImmediatelyFast, render distances 8, 16 and 32 at 1280x720, three
games each. The reports are in benchmarks/perf. The page next to them gives the setup, the CPU
load and compiler activity during each run (Windows has no load average), and the tables.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 08:29:52 +07:00
Ludovic MarieandClaude Opus 5.5 f7300bd96b Merge main into fix/multiplayer-safety-notice
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 02:12:57 +01:00
SenjinandClaude Opus 5.5 8202f02f4e HDR: log what the compositor reports once per change
The renderer is made anew a few times while the game starts, and each asked
the compositor again and logged the same line; a slider dragged in the
desktop's HDR settings logged a line per step. Now a line comes only when the
values changed, and a change within a second of the last line waits until the
values have held for a second, so only the value they settled on is logged.
The values themselves still apply at once.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 01:50:57 +01:00
Ludovic MarieandClaude Opus 5.5 b6cd09442e World icon: wait for more than 10 rendered sections
GameRenderer.takeAutoScreenshot saves only when countRenderedSections() > 10
and every section is rendered. A window that joined its server thread
had nothing pending before its first chunks, so the icon was only sky.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 01:48:51 +01:00
Ludovic MarieandClaude Opus 5.5 e3b1ae512f Single player: the world icon keeps its save folder through the async start
Since #3011 the window joins the server thread in poll_integrated_start,
where open_integrated's folder is no longer in scope: Starting carries it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 01:37:33 +01:00
Ludovic Marie 5b4dd8b72e Merge main into fix/world-select-icons 2026-10-29 01:37:27 +01:00
degepieandClaude Opus 5.5 5f269772d9 Frogs long jump: FrogAi's long jump activity (fixes #2841)
Frogs had no long jump on the server, so they only ever walked. Port the
LONG_JUMP activity as one goal: LongJumpToPreferredBlock searches the
9x5x9 box (weighted by squared distance, lily pads and big dripleaf first
half the time) for a spot FrogAi.isAcceptableLandingSpot accepts and an
8-block path cannot reach, faces it for 40 ticks and leaps along the
LongJumpUtil arc (now with its collision check); LongJumpMidJump holds the
LONG_JUMPING pose without friction until the frog lands. The
LONG_JUMP_COOLDOWN_TICKS memory counts down and is reset as vanilla does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UE3PMKbnTcxfBSeT5uQWCK
2026-10-29 05:23:58 +05:00
d3grandoandClaude Opus 5.5 7211d029ab Swing the hand when Q drops a stack from an inventory slot on a server
On a server (single player included) a menu click is predicted by the
menu engine and sent, and what the prediction dropped was ignored, so
Q over a slot threw the item without the arm swing. Vanilla's
AbstractContainerMenu.doClick drops with Prediction.PREDICTED, and
LivingEntity.drop then swings the main hand on the client itself; the
server shows that swing only to the other players.

The prediction loop moves to predict_clicks, which also says whether a
click dropped something; remote_menu_ops swings the main hand then.

Fixes #3357

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 07:17:59 +07:00
Senjin 25e00a0ecf Merge remote-tracking branch 'origin/main' into hdr-wayland
# Conflicts:
#	crates/horizon-client/src/game.rs
#	crates/horizon-client/src/game/options.rs
2026-10-29 01:05:23 +01:00
d3grandoandClaude Opus 5.5 1d2d495686 Shields: an enchanted banner shield keeps its patterns under the glint
An enchanted shield with a banner turned white: after the patterns, the
renderer drew the shield base again with the foil for vanilla's
`patternedShieldGlint` pass. In vanilla that pass draws the glint alone
(`RenderPipelines.GLINT` with only the glint texture), but a foil quad
here is its texture with the glint over it, so the plain base covered the
base colour and every pattern.

Every layer of a patterned shield now carries the foil instead, and the
extra pass is gone. The GUI adds the glint masked by each layer's alpha
and the world shader composites it per layer, so each pixel ends with
the glint once over the layers below it, as vanilla's single glint pass
over the finished plate gives.

Vanilla: ShieldSpecialRenderer.submit, BannerRenderer.submitPatterns,
RenderTypes.patternedShieldGlint.

Fixes #3407

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 06:55:53 +07:00
Senjin 3ea82790ea Merge remote-tracking branch 'origin/main' into hdr-ui
# Conflicts:
#	crates/horizon-client/src/game/options.rs
#	crates/horizon-client/src/gfx.rs
2026-10-29 00:45:46 +01:00
xKumorioandClaude Opus 5.5 f9494ef93e Arrows shot mid-jump carry the jump: projectiles add the known movement
Projectile.shootFromRotation adds source.getKnownMovement() to the
projectile, the vertical part only when the shooter is off the ground. For
a ServerPlayer that is lastKnownClientMovement: the move the client last
sent (handlePlayerKnownMovement), reset to zero by handleClientTickEnd when
no move came that tick.

Horizon added the player's PlayerMotion velocity instead. On the server
(single player's server thread, LAN, dedicated) a guest's velocity only
holds knockback, not the player's own movement, so an arrow shot while
jumping or running lost the shooter's motion and flew lower and shorter
than vanilla's.

Guests now keep known_movement: set on each accepted move, zeroed at a
ClientTickEnd without one. enter_guest puts it in Game::known_movement for
the turn and leave_guest restores the outer turn's, so nested turns keep
their own. Bows, tridents, snowballs/eggs/ender pearls, thrown potions
and wind charges take known_player_movement(), which the spear already
used. The spear and the per-tick using/digging turn no longer copy the
last of the tick's moves themselves, which was zero once those moves had
been consumed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 04:18:46 +05:00
SenjinandClaude Opus 5.5 31ead52d21 HDR: follow the compositor's changes while the game runs
The renderer keeps the window's surface feedback and looks at it every frame
(only events winit already read; no waiting). On preferred_changed it asks
again, and the game applies its settings, so Auto follows a move to another
output or a recalibrated desktop without a restart. Docs: the Auto values.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 00:08:10 +01:00
xKumorioandClaude Opus 5.5 ad644778bc Three Windows-only test failures in pr_ready
session_lock_is_exclusive read session.lock while its lock was held.
Windows file locks are mandatory, so that read failed with OS error 33.
It now reads the file once the first lock is dropped.

port_publish wrote the store path as str(path) with the home replaced by
"~", which kept Windows backslashes (~\code\...). The path under the home
is now written as ~/ plus its POSIX form, as on Linux. The temporary body
file is opened as UTF-8, so the "→" in a facts line no longer fails to
encode in the Windows code page.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 04:01:17 +05:00
JacobRWebbandClaude Opus 5.5 ee90b3b34b Moving blocks take their light from the cells their faces look at
A finished piston's block is drawn over the chunk mesh until that mesh
shows it, but it was lit with the light stored at the moving piston's own
cell. Once the placed block stands there and the edit is relit, that cell
is opaque and holds 0: the pushed or pulled block and the piston base
flashed black for the frames until the mesh came up (#3002).

Vanilla's PistonHeadRenderer.createMovingBlock gives each block a
MovingBlockRenderState (the lone block at the cell it moves from, the
level's light engine), and MovingBlockFeatureRenderer tesselates it with
ModelBlockRenderer.tesselateBlock: in tesselateFlat each quad reads
BlockModelLighter.getLightCoords at its cull-face neighbour, or
prepareQuadFlat's position (the neighbour when cubic, else the block).
Moving blocks now follow that through MeshTables::flat_light, the chunk
mesher's own flat-light rules; a finished one is lit as the block placed
at its cell, which it stands in for.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 17:46:24 -05:00
SenjinandClaude Opus 5.5 7ef092fabc HDR: Auto paper white, at the desktop's SDR white
Paper White 0 is Auto, the new default: where the system puts SDR white (on
Wayland the compositor's reference white, 203 nits in HDR output's scale), else
203. Both Auto values come from one report the renderer makes at surface open.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 23:42:34 +01:00
Ludovic MarieandClaude Opus 5.5 326719539d Merge main into fix/shelf-interaction
main's #2952 added banner, vault and trial spawner arms to lan::chunks::entity_tag next to this branch's brushable and shelf arms; both stay.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 23:32:54 +01:00
JacobRWebbandClaude Opus 5.5 a72f06cfab Test the moving piston's saved fields against vanilla's
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 17:28:37 -05:00
JacobRWebbandClaude Opus 5.5 fab9c37413 Moving pistons save facing as a byte, as Direction.LEGACY_ID_CODEC does
PistonMovingBlockEntity.saveAdditional stores facing through
Direction.LEGACY_ID_CODEC, Codec.BYTE of get3DDataValue. The block-entity
canonicaliser rewrote it as an int too.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 17:27:45 -05:00
SenjinandClaude Opus 5.5 dc418c7623 HDR: Auto peak brightness from what the compositor reports
Peak Brightness 0 is Auto, the new default: the peak the system reports for
the display (on Wayland, the surface's preferred image description, in HDR
output's scale), else 1000. The slider's lowest step is Auto; 200..2000 stay
manual.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 23:23:15 +01:00
degepie ac7e491f3b Merge remote-tracking branch 'origin/main' into fix/issue-3137-suspicious-stew
# Conflicts:
#	crates/horizon-protocol/src/item.rs
2026-10-29 03:04:30 +05:00
Ludovic MarieandClaude Opus 5.5 d3559e2a5f Proceed past the safety notice in the F5 server list test
main's f5_reloads_the_server_list (4c4fcedf4) came after this branch: its first Multiplayer click now opens SafetyScreen.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 23:00:28 +01:00
Ludovic MarieandClaude Opus 5.5 c90893c29c Merge main into fix/multiplayer-safety-notice
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 23:00:18 +01:00
degepieandClaude Opus 5.5 cb1c3499bf Keep suspicious stew effects across the wire and list them in creative
A stew taken from the creative inventory gave no effect when eaten and
its tooltip listed none (#3137). The protocol read the
`suspicious_stew_effects` component past without decoding it, so the
creative client's `SetCreativeModeSlot` sent the server a bare stew and
the server's copy had nothing for `SuspiciousStewEffects.onConsume` to
apply. Decode and encode it (`Entry.STREAM_CODEC`: mob effect id,
duration) both ways, and add `SuspiciousStewEffects.addToTooltip`'s
creative-only effect lines (`PotionContents.addPotionTooltip`).

Fixes #3137.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UE3PMKbnTcxfBSeT5uQWCK
2026-10-29 02:29:33 +05:00
JacobRWebbandClaude Opus 5.5 4a094bee90 Pistons: moving blocks keep their tint, survive saves and leave no black ghost
- Moving blocks are tinted per face with the chunk mesher's block colours
  (MeshTables::block_tints), not the terrain particle tint, which is none
  for grass blocks: a pushed grass block was drawn gray.
- PistonMovingBlockEntity is saved with its chunk as vanilla's
  minecraft:piston block entity and loaded back into the level's moving
  pistons, so blocks caught mid-move finish it after a rejoin instead of
  leaving an invisible, solid moving_piston (#3310).
- A server client drops its moving piston when the server changes that
  block (LevelChunk.setBlockState removes the block entity), instead of
  animating it for five more death ticks over the placed block, black and
  z-fighting (#3002).
- A finished piston stays drawn until the chunk mesh shows its block, and
  no longer, so no frame shows neither.

Fixes #3310
Fixes #3002

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 16:29:10 -05:00
ThatCakeIsAPieandClaude Opus 5.5 85bfbc7d46 upgrade-datapack: docs, and checks against vanilla 26.2 and 26.3
- docs/datapack-upgrade.md: how to run `horizon-port-pack
  upgrade-datapack`, every rewrite and how it is checked, and what is not
  rewritten; linked from docs/README.md and the command's help.
- tools/datapack_upgrade_rosetta.py writes vanilla 26.2's own worldgen out
  as a pack, upgrades it and compares it with vanilla 26.3's file by file:
  registries whose format changed and whose content did not must come out
  identical.
- tools/datapack_upgrade_fidelity.py starts vanilla's 26.2 and 26.3
  dedicated servers on the same seed, the first with a pack and the second
  with its upgrade, force loads the same chunks and compares the saved
  blocks; without packs it measures what 26.3 itself changed.

Both need the 26.2 server jar the player downloaded; nothing is fetched.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 14:57:24 -05:00
ThatCakeIsAPieandClaude Opus 5.5 f983223354 port-pack: upgrade-datapack rewrites features and placements
The fourth part of the 26.2 to 26.3 data pack upgrade.

- Configured features move to `feature/` with `config` unwrapped, block
  state providers take their 26.3 names (a `simple_state_provider` is its
  state), and nested and inline placed features are upgraded with them.
  Feature types 26.3 replaced by compositions of other features are listed
  in the report and left as they are.
- Placement `random_offset` becomes `offset` with one provider per axis
  (x and z both from `xz_spread`).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 14:57:23 -05:00
ThatCakeIsAPieandClaude Opus 5.5 3d39bbdaf1 port-pack: upgrade-datapack rewrites noise settings, dimensions and presets
The third part of the 26.2 to 26.3 data pack upgrade: noise settings in
26.3's form, with the parts 26.2 hard-coded written out as data.

- The beardifier, which 26.2's NoiseChunk added, is added to the final
  density.
- The aquifer router fields move to `aquifers`, whose `exclusion` spells
  out 26.2's `isDeepDarkRegion` (erosion < -0.225 and depth > 0.9) from
  the pack's own erosion and depth; `surface_level` is the preliminary
  surface, and `chunk_surface_level` its per-chunk 16 x 1 interpolation.
- 26.2's ore veinifier becomes two `ore_vein` material rules (copper,
  iron) with OreVeinifier's heights, thresholds, edge roundoff, solidness,
  richness and gap, ahead of the surface rule: 26.2 placed veins while
  filling the noise and its surface pass only replaced the default block.
  The rule is written as a named `material_rule` under the settings' id.
- The climate functions are named, and the spawn target refers to them by
  id (`depth` and `offset` dropped, as vanilla did).
- `size_horizontal`/`size_vertical` are dropped: each `interpolated` holds
  its cell.
- Inline noise settings in dimensions and world presets get the same.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 14:57:23 -05:00
ThatCakeIsAPieandClaude Opus 5.5 2aca6fe06c port-pack: upgrade-datapack rewrites density functions
The second part of the 26.2 to 26.3 data pack upgrade: named density
functions in 26.3's form.

- Inputs are named `input`/`left`/`right`; `invert` is `reciprocal`,
  `y_clamped_gradient` a `gradient` on y, `shifted_noise` a `noise` with
  shifts, `end_islands` `end_outer_islands`, and `constant` its number.
- 26.2's `flat_cache` held each column's value at y = 0, so it becomes a
  `cache` around a `slice` at y = 0 when its input depends on y (through
  the pack's own functions); 26.2's other caches only memoise in 26.3.
- `interpolated` takes its cell from the noise settings whose router
  reaches it (`size_horizontal * 4`, `size_vertical * 4`), which 26.2 read
  from the settings, or vanilla's overworld cell when none does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 14:57:23 -05:00
ThatCakeIsAPieandClaude Opus 5.5 81b3803017 port-pack: upgrade-datapack, a 26.2 data pack's registries in 26.3's formats
Vanilla 26.3 refuses a world generation data pack written for 26.2: the
registries changed format. `horizon-port-pack upgrade-datapack <pack>
(<out> | --world <world>)` reads a pack (zip or folder) and writes it in
26.3's formats, with a report of what it rewrote, what has no faithful 26.3
form and which types it does not know. Each rewrite is the one vanilla's own
data went through between 26.2 and 26.3.

This first part handles the registries that need no context:
- noise parameters: `firstOctave`/`amplitudes` become `base_octave`,
  `octave_count`, `amplitude_modifiers` and the base amplitude of
  `NormalNoise.createParity`, which keeps 26.2's normalisation;
- block states: `{Name, Properties}` become `{id, properties}`, or the id;
- configured carvers move to `carver/` with `config` unwrapped and the cave
  carvers' hard-coded count, thickness and room scale written out;
- biome spawners and spawn probability become 26.3's gameplay attributes;
- structure spawn overrides and the dimension type's bed rule.

Density functions, noise settings and features follow in their own
changes. serde_json gains `float_roundtrip` so the numbers the upgrade
writes back are the ones it read.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 14:57:19 -05:00
Lyle ColeandClaude Opus 5.5 c8f484e040 Surface biomes: keep each quart cell's corner jitter and biomes
The surface stage's per-chunk cell cache only proved cells whose eight
quart corners hold one biome; a block in any other cell ran the full
eight-corner zoom mix and a stored lookup again. Now a mixed cell keeps
its corners' jitter (BiomeZoom::corner_jitter, one mix per cell) and
their eight biomes, so each block of the cell only redoes the distance
comparison (BiomeZoom::select_corner, same arithmetic and tie order); the
cell of the previous lookup is kept too, as columns walk down in y. A
test checks the split zoom against quart_at_block.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 14:57:16 -05:00
ThatCakeIsAPieandClaude Opus 5.5 d4a1ebaa33 Surface rules: compare biome ids, not names
Every solid block of every column asks the rule tree's biome conditions
for its biome. Each one cloned and dropped the biome's Arc<str> (an atomic
count all generator threads share) and looked its name up in a string set.
Queries that number their biomes (the generator's) now answer with the
index, and biome conditions keep a bit per index; others compare names as
before. Same answers: the generator's name -> id map is exact.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 14:57:16 -05:00
damnurmumandCodex 2ae5640704 Preserve styled item names and component shadow colors
Keep the original custom-name component as the sole stored value, deriving plain text for gameplay and rendering its styles in tooltips, held labels, and item links. Honor shadow_color in parsed text and HUD glyph passes. Extend the Java/Rust tooltip parity fixture with styled names; its fixture format builds on PR #1702 by RVZO6.

Co-Authored-By: Codex <[email protected]>
2026-10-28 22:31:12 +03:00
Kacpherek 2f95ce431c Follow the furnace code to furnace.rs on main; answer Fox.getBreedOffspring 2026-10-28 20:15:39 +01:00
Kacpherek 0536ebbe98 Merge remote-tracking branch 'origin/main' into tools/port-ledger 2026-10-28 20:11:46 +01:00
damnurmumandCodex dd130f1902 Render player heads and atlas sprites in text components
Parse Minecraft object components into inline spans and draw player heads and atlas sprites with the existing HUD atlas. Resolve head profiles through the skin cache and preserve fallback text for plain output.

Co-Authored-By: Codex <[email protected]>
2026-10-28 22:07:28 +03:00
Kacpherek 59538c7dc1 Map the remaining vanilla methods: commands, network, packs, advancements and the last ten 2026-10-28 20:05:02 +01:00
Muhammad Asif 1a9040ad79 Swing the arm when opening containers
MultiPlayerGameMode.useItemOn swings on InteractionResult.SUCCESS
(SwingSource.CLIENT). The remote client only swung when the held item
was a block, so opening a chest, barrel, furnace, or similar storage
did not start the arm animation.
2026-10-28 22:33:23 +05:00
Kacpherek 2c10febff1 issue: read registry names, so a mooshroom report finds MushroomCow 2026-10-28 18:27:10 +01:00
Kacpherek a32e0ac4ec Add a vanilla-parity skill: start parity fixes from the port ledger 2026-10-28 18:09:19 +01:00
Kacpherek fd8f1a02a1 Map client screens, toasts, recipe book and world screens 2026-10-28 17:43:08 +01:00
Kacpherek a1cf544de7 map-check: accept full Class#method keys 2026-10-28 17:29:51 +01:00
Kacpherek 8d8737a266 Map client rendering, particles, sounds and resources 2026-10-28 17:29:16 +01:00
Kacpherek c045245e8e Map client packet handling, entity renderers and models, block renderers
Also let PORT_LEDGER_NO_MAP build without the verdict map, to measure the
automatic matches on their own.
2026-10-28 16:54:18 +01:00
Kacpherek 91358d8088 Map the rest of the game logic: classes with no Rust trace, then method batches 2026-10-28 16:26:00 +01:00
Senjin 8ebcf6c13d WIP HDR: ask the compositor for the window's luminances (log only) 2026-10-28 16:00:54 +01:00
Mik00000 08b9765278 Ensure mob effect IDs are namespaced so tipped arrows tick damage on living mobs 2026-10-28 16:09:27 +02:00
Kacpherek 969749da3d Map 13 game-logic batches and add map-check
map-check checks verdict-map files against the last build without
rebuilding, so several mappers can check their own rows at once.
2026-10-28 14:42:02 +01:00
SenjinandClaude Opus 5.5 380d5ebe9f HDR: its settings get a screen of their own
Paper White and Peak Brightness sat among the display settings, where players
without an HDR display took them for settings of their own display. Display now
has only HDR Settings..., greyed where the display path offers no HDR output (the
renderer tells the settings when it opens its surface, `graphics::offer_hdr`).
It opens Graphics: HDR: the HDR switch, then its sliders, greyed while it is off.

Where HDR isn't offered, `horizon-graphics.txt`'s HDR settings are left as saved
and not used: the renderer already keeps HDR off there, and the screen no longer
opens, so a game folder copied from another computer, or a display swapped for
an SDR one, shows nothing that does nothing; an HDR display brings them back.

The screen leaves room for a calibration step and an in-game shortcut to it
later. horizon-ctl opens it as `options graphics-hdr`.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 13:58:55 +01:00
Kacpherek 5a8b9687e5 Document class homes, structural matches and the verdict map 2026-10-28 13:53:58 +01:00
Kacpherek 0f7036f9b1 Map the first three batches: animals, inventory screens, world generation 2026-10-28 13:53:58 +01:00
Kacpherek 12c503e154 Port ledger: class homes, structural matches and the verdict map
Give every vanilla class its Rust home files, weighed from all its matches.
In those files, match a method to the fn that touches the same fields and
reaches the ports of the same callees. Read tools/port_map/*.tsv, where a
person or agent records a verdict for what nothing else finds (port, inline,
missing, skip with a reason), and list the next unanswered batch with todo.
Count field-only constructors as trivial and fold initializer lambdas into
<clinit> and <init>.
2026-10-28 13:53:58 +01:00
Kacpherek 2dd963a539 Document the port ledger's new matching, gaps and commands 2026-10-28 13:16:02 +01:00
Kacpherek cd42938b88 Port ledger: bytecode calls, wider matching, drift, issue and check
Read the game jar's bytecode for exact call targets, constants and ids, and
resolve Rust calls through the receiver's type. Match more methods: same-named
fns in an impl or file named for the class (the impl type must fit the class),
and callees of known pairs linked through the call graph. Leave trivial methods
out of coverage.

New gap kind drift: a port that never names a sound, particle, item, block,
effect or game event its vanilla method uses, or lacks one of its rare numbers.
Seam gaps on the old in-frame path sort last; maths and geometry callees no
longer make seam or callee gaps.

New commands: issue (the classes, Rust code and gaps a bug report points at),
check (what a branch touches, gaps it opens and closes against its base), diff,
and a searchable offline report.html.
2026-10-28 13:16:01 +01:00
SenjinandClaude Opus 5.5 18c23a6df3 HDR: UI Brightness, and the HUD and menus never lift
Pixels counted as the world when they matched it darkened evenly (the vignette),
and grey text over the white sun matches that: menu text in front of the sun was
lifted to the peak. Once the world is kept for the last pass, the frame's alpha
is now zeroed; the hand, HUD and menus raise it where they draw (the vignettes
leave it), and after the hand a blend pass scales its alpha down to 1/255. The
last pass lifts nothing covered, keeps the hand at the world's brightness (it's
the player's, not UI), and scales the rest by UI Brightness.

UI Brightness (Display, 100% by default) is the HUD and menus as a share of paper
white, so pure white labels needn't sit at the world's white. At 100% the picture
is as before.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 12:57:35 +01:00
Mik00000 445183a7df Display mob inside spawners and trial spawners (fixes #3107) 2026-10-28 13:22:28 +02:00
Shreyas 380d71d8a8 lists: fall back to offline resolution when services do not answer in tests 2026-10-28 16:31:23 +05:30
Mik00000 85f53198ce Allow bows and crossbows to shoot spectral and tipped arrows (#3071) 2026-10-28 12:37:54 +02:00
Shreyas 0543cf0345 Select the picked hotbar slot in singleplayer and LAN
Preserve the guest's updated hotbar selection when restoring the host
player state after an action. The previous restore sequence discarded
the guest's new selection while keeping the inventory changes.

The held-slot response now uses the selected slot produced by the pick,
so integrated-server and LAN players select the picked block or spawn
egg. Keep the host's selection independent of the guest's selection.

Vanilla reference: ServerGamePacketListenerImpl.tryPickItem,
Minecraft.pickBlockOrEntity, and ClientPacketListener.handleSetHeldSlot.

Fixes #3031
2026-10-28 16:00:22 +05:30
Kacpherek ef304f30c9 Document the port ledger 2026-10-28 11:23:50 +01:00
Kacpherek e36fd24008 Add the port ledger: vanilla methods next to the Rust functions that port them
tools/port_ledger.py indexes the decompiled sources and the Rust tree into .local/port-ledger/ledger.json and answers where/what/class/coverage queries both ways, with the gaps where a port isn't wired like vanilla (seam, client, class, callee, stale).
2026-10-28 11:23:50 +01:00
NetuosoDEVandOpenAI Codex 818a2ec52a Align the carried block between the enderman hands
Cancel item-model centering in the vanilla carried-block pose. Add a regression test using point coordinates captured from Minecraft 26.3 CarriedBlockLayer.submit, including a transformed entity root.

Implemented by OpenAI Codex on behalf of NetuosoDEV. NetuosoDEV tested the updated Linux binary in game and confirmed the fix works.

Co-Authored-By: OpenAI Codex <[email protected]>
2026-10-28 12:04:11 +02:00
Mik00000 13e441e235 Fix invisible item frames and custom head skins 2026-10-28 12:00:31 +02:00
NetuosoDEVandOpenAI Codex f852985693 Fix enderman carried block textures and item tints
Render the carried block through the inventory item model pipeline so every quad uses its proper texture and tint source. Remove the single-sprite cube renderer and let the item pipeline center the model.

Implemented by OpenAI Codex on behalf of NetuosoDEV. NetuosoDEV confirmed the fix works in game.

Co-Authored-By: OpenAI Codex <[email protected]>
2026-10-28 11:40:04 +02:00
SenjinandClaude Opus 5.5 41d3890a3c Revert "HDR: bright lifts from darker pixels"
Only a soul lantern needed it, and it lifted less for its light level, not for
its texture: that is better fixed where the lift is worked out.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 10:14:15 +01:00
SenjinandClaude Opus 5.5 2f13cb3ac6 HDR: bright lifts from darker pixels
The bright tier's window goes to 0.04-0.28, from a round in the HDR gallery: a soul
lantern's glass (light level 10) now lifts, and the level-15 bright blocks still look
right.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 09:34:28 +01:00
SenjinandClaude Opus 5.5 f52e94cfb9 docs: glowing particles shine in HDR now; translucent blocks don't yet
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 06:41:34 +01:00
Kacpherek e0a48b3f66 Document the 2..128 render distance range 2026-10-28 05:56:49 +01:00
Kacpherek d35efa25ec Accept render distances up to 128 from the command line and horizon-ctl
--render-distance and horizon-ctl render-distance check against the same cap as the options.
2026-10-28 05:56:48 +01:00
Kacpherek b8b15b1682 Raise the render distance cap from 64 to 128 chunks
The options slider and options.txt now accept 2..128. 128 is where the server protocol's view distance (an i8) tops out.
2026-10-28 05:56:48 +01:00
Kacpherek 05f8ec650d Pass the safety notice in the direct connection test
Multiplayer now opens SafetyScreen first, so the test proceeds through it.
2026-10-28 05:45:51 +01:00
時穂andCodex 2702542c73 launcher: complete Chinese strings and strengthen translation checks
Fill the 17 missing Microsoft sign-in, resource setup and Nightly strings.
Scan Rust tokens for multiline translation calls and require shared catalog
keys in every language, including labels translated through widgets or constants.

Co-Authored-By: Codex <[email protected]>
2026-10-28 13:37:54 +09:00
SenjinandClaude Opus 5.5 113d694a87 HDR: very bright lifts over bright's window, a step above bright
Very bright's window (0.88 to 0.94) was tuned on the sun alone: on a block
only the hottest pixels reached it, and they blew out while the rest
stayed below bright. It now lifts over bright's window with the whole
headroom; the sun keeps its yellow corona near the horizon.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 05:22:02 +01:00
Ludovic MarieandClaude Opus 5.5 3e930fc74b Launcher reference: the language setting lists zh
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 22:55:39 +01:00
Ludovic MarieandClaude Opus 5.5 04ec03745d Launcher Chinese: drop the coming-soon strings main removed
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 22:52:52 +01:00
Ludovic MarieandClaude Opus 5.5 6a6b5957eb Launcher Chinese: the nightly strings main added since
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 22:52:29 +01:00
Ludovic Marie 8c18d9429f Merge remote-tracking branch 'origin/main' into cg24/f2795 2026-10-27 22:50:42 +01:00
Kacpherek 1944dd45e4 Fix display light borrow and format
display_light took &self while text displays were borrowed mutably; it now takes the world. Also rustfmt.
2026-10-27 22:35:25 +01:00
Ludovic MarieandClaude Opus 5.5 2ee238e5f1 Merge #2931 into #2924: both add vanilla 26.3 options to options_ui.rs
The controls list and the mouse list sit side by side; #2931 lands
first.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 22:28:53 +01:00
Ludovic MarieandClaude Opus 5.5 6bb648db1e Controls: the three switches nothing reads yet show as unavailable
Operator Items Tab, Quit Shortcuts and Control-click are stored and saved
like vanilla's, but no code reads them yet: grey them out like the other
placeholders instead of offering a switch that does nothing.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 22:26:24 +01:00
Ludovic MarieandClaude Opus 5.5 e1d825312f Read the flat world's horizon from WorldGen's preset
main's dark disc and void fog read WorldGen.flat, which is now the preset
(seen by sirrayi's review).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 22:25:52 +01:00
Ludovic MarieandClaude Opus 5.5 5ea61ed3d8 Merge main into world-types
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 22:25:45 +01:00
Kacpherek b910cdd257 Draw block and item display entities on clients
A client only had an arm for text displays. DisplayRenderer draws a block display's model or an item display's item under the same billboard and transformation, with the brightness override or the light at the entity.
2026-10-27 22:25:37 +01:00
Kacpherek 3934833369 Send text, block and item display data to clients
Summoned displays reached clients as bare entities, so a text display showed nothing. Vanilla syncs Display's entries 8 to 22 and each type's own (text 23 to 27, block state 23, item and context 23 and 24) from the saved compound.
2026-10-27 22:25:37 +01:00
Ludovic MarieandClaude Opus 5.5 231b309634 Name the forced terrain ring where the protos are kept
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 22:24:48 +01:00
Kacpherek 621473f60e Send the ominous item spawner to guests and draw its floating item
The spawner existed only on the server, so a client never saw the item grow and turn. Vanilla tracks it with DATA_ITEM; the client draws OminousItemSpawnerRenderer and runs tickClient's particles.
2026-10-27 22:21:29 +01:00
Kacpherek 2b2dc1929f Show tipped and spectral arrows to guests and clients
A server sent every arrow as a plain one. Vanilla sends a spectral arrow as its own type and a tipped arrow's ID_EFFECT_COLOR, and the client draws arrow_spectral.png and sheds the effect swirls (Arrow.tick) and sparkles (SpectralArrow.tick).
2026-10-27 22:20:33 +01:00
Kacpherek 42388d2512 Swap items with shelves and powered shelf chains 2026-10-27 22:02:24 +01:00
Kacpherek 2deaab2602 Render items stored in shelves 2026-10-27 22:02:24 +01:00
Kacpherek ca28a0edad Show items revealed by brushing suspicious blocks 2026-10-27 22:02:18 +01:00
Kacpherek 694568a89c Show the multiplayer safety notice on the first Multiplayer click
Horizon opened the server list at once and never read skipMultiplayerWarning. Vanilla TitleScreen shows SafetyScreen first, with a do-not-show-again box saved to options.txt, unless that option is set.
2026-10-27 21:56:00 +01:00
ElyTheCat 80df20837b Options: add Java 26.3 controls 2026-10-27 22:09:31 +03:30
masa-masa-0314andZCode 2092c5a2e8 Worlds: take the save's icon.png from the rendered frame
GameRenderer.tryTakeScreenshotIfNeeded saves a world icon the Select
World list never had: while a local world is on screen without one, the
frame is grabbed after the level and the hand (before the HUD), centre
cropped to a square, averaged down to 64x64 and written to the save's
icon.png once a second until it sticks. The list already drew the icon
when a save had one; nothing ever created it.

Co-Authored-By: ZCode <[email protected]>
2026-10-28 02:29:39 +08:00
Senjin fbab7d29a5 Merge remote-tracking branch 'origin/main' into hdr-tiers
# Conflicts:
#	crates/horizon-client/src/entity.wgsl
2026-10-27 17:57:13 +01:00
levzzz b901415c80 Merge remote-tracking branch 'upstream/main' into fix/forceload-rings 2026-10-27 20:32:05 +05:00
levzzzandminimax-m3.1-flash da877fe1db Give a forced chunk vanilla's rings
A `/forceload`ed chunk holds a FORCED ticket at level 31, and vanilla's
level rises one per ring, so the chunk itself and the two rings around it
are level chunks with their light, the third ring has its features and the
fourth only its terrain. Horizon took the forced chunk's reach from the
caller's margin instead, so only the chunk itself ever finished and its
neighbours stayed proto chunks.

The reach now follows the ticket levels: the full ring is two chunks out,
the features ring three and the terrain ring four, and the protos of the
terrain ring are kept so the features ring can be decorated from them. A
decorated proto chunk is saved as `minecraft:initialize_light`, the status
vanilla gives a chunk that has its features but no light.

Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>
2026-10-27 20:18:42 +05:00
sirrayiandDevin 6143e258ab Release notes: a Contributors heading and a rule before the disclaimer
The "Thanks to ..." line read as part of the section before it (often Known issues), on GitHub and on playhorizon.dev/releases. It now has its own heading, and a horizontal rule sets the disclaimer apart. Fixes #2314.

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-27 14:55:35 +00:00
levzzzandminimax-m3.1-flash f9ca0a76cf The death message names the cause's item when it has a custom name
`DamageSource.getLocalizedDeathMessage` uses the `<id>.item` message when
the cause holds an item with a custom name, with the item's name as a
third argument: "Steve was shot by Zombie using My Sword". Horizon always
used the two-argument message, so the item was never named.

The mob's main hand item now keeps its custom name, and the death message
picks the message and its arguments from it.

Fixes #2802

Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>
2026-10-27 18:38:37 +05:00
levzzzandminimax-m3.1-flash 1232745064 Keep a traveller that waits for an End gateway's exit
The traveller was dropped from the queue before the exit was resolved, so a
failure to resolve it lost the entity for good. The traveller now waits and
the failure is retried, with the reason in the log.

Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>
2026-10-27 18:34:07 +05:00
levzzzandminimax-m3.1-flash 3382b666ce Add the audit that drives a cow and a stack through an End gateway
The script plays the End in a scratch home: it places a gateway beside the
spawn platform, puts a cow and a seven-diamond stack in it, and checks that
both leave it and land at the gateway's exit with their data, in the
server-thread and the in-frame singleplayer.

Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>
2026-10-27 18:27:43 +05:00
1F10250001andClaude Opus 5.5 66969d8a93 launcher: Simplified Chinese
Adds Simplified Chinese (zh) beside English, French and Russian: the 160 strings
of launcher/lang, written for Horizon, and the language in Settings > Launcher,
the system pick (zh-Hans-CN and other zh locales) and `settings language zh`.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 21:29:17 +09:00
levzzzandminimax-m3.1-flash 92c073de61 Merge upstream/main into fix/end-gateway-entities
Upstream now keeps a gateway's exit in the gateway's own block entity
(TheEndGatewayBlockEntity.setExitPosition), so the player travels to the
exit the gateway was given (#1517). The entity travel stays: the mobs and
the dropped items go through the same gateway, and the exit the first
traveller found is reused, so the player and the entities land in the same
place and only one island is built for a gateway.

Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>
2026-10-27 17:27:46 +05:00
levzzzandminimax-m3.1-flash 139de824aa Re-run the checks after the audit script fix
The pull request's last push did not start a run; this empty commit
starts one on the same tree.

Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>
2026-10-27 17:10:44 +05:00
Senjin 523dd491c4 Merge remote-tracking branch 'origin/main' into hdr-tiers
# Conflicts:
#	crates/horizon-client/src/entity.wgsl
#	crates/horizon-client/src/gfx.rs
2026-10-27 13:07:04 +01:00
Ludovic MarieandClaude Opus 5.5 ed01a93df6 Merge main into fix/world-types
main's Superflat Customize screen (#2214) still set the removed WorldOptions.flat: it now picks WorldPreset::Flat,
and the agent's customize-flat state reads the preset. structure_mobs.rs's test passes the new preset argument
(minecraft:normal) to VanillaTerrain::for_dimension.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 12:39:11 +01:00
levzzzandminimax-m3.1-flash 1a56e74324 Fix the f-string in the end cargo audit script
The braces were one too many, so the file did not parse and the Python
tests failed on it. The command it builds is the same now: the item
stack {Item:{id:"minecraft:diamond",count:3}}.

Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>
2026-10-27 16:35:44 +05:00
Claude Codeandminimax-m3.1-flash 25b84dfc2e Format the world preset test
rustfmt keeps the registry read on one line.

Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>
2026-10-27 16:11:14 +05:00
levzzzandminimax-m3.1-flash a2098c2ffa Let mobs and dropped items through End gateways
Only the player was checked for End gateways, so a cow or a dropped
stack standing in one stayed there for good. The entities now go
through a gateway the way the player does: the block their box swept
through takes them in (unless the gateway is cooling down, as
EndGatewayBlock.entityInside does), they travel on the next tick
(Entity.handlePortal, with the End gateway's transition time of 0) and
land where the gateway leads once its chunks have loaded, keeping their
data and counts.

Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>
2026-10-27 16:11:14 +05:00
Claude Codeandminimax-m3.1-flash d98dc8edc9 Read the test registry without the explicit deref
clippy's explicit_auto_deref asks for the LazyLock itself, which
derefs to the registry the generator data keeps.

Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>
2026-10-27 16:11:14 +05:00
Claude Codeandminimax-m3.1-flash bf05176692 Add the amplified, large-biome and single-biome world types
The create screen only offered Default and Superflat, so the amplified
world type of #1873 could not be chosen at all, and the generator
always built the overworld's noise settings.

The world-type button now cycles through the presets vanilla's
WorldPresetTags.NORMAL tag holds (normal, flat, large_biomes, amplified,
single_biome_surface), the chosen preset is written into the new world's
world_gen_settings.dat with the generator
worldgen/world_preset/<preset>.json has, and the generator is built from
that preset, so an amplified world gets the amplified noise settings.
A world opened again reads its preset back from its generator.

Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>
2026-10-27 16:11:14 +05:00
SenjinandClaude Opus 5.5 58f2ea700e HDR: light sources shine by the kind of light, not its level
Light level says how far a source lights its surroundings, not how bright
it looks: a candle's flame is as bright as a torch's. Light sources now
get a tier (mesher::hdr_tier): very bright (the sun), bright (glowstone,
campfires, the soul fires), incandescent (flames, lava), low (redstone
torch, magma, a candle's wick) or none (glow lichen). Unlisted ones go by
their level. The tier rides in the emission bits as the level did, and
each has its own share of the headroom and its own lift window
(gfx/hdr.rs TIERS), tuned by eye on an HDR1000 OLED.

Flames, lava drops and obsidian tears now shine too: their tier rides in
the particle vertex's overlay bits, and with HDR on a second draw of the
entities writes only the frame's alpha for them, so their colour still
blends as before.

HORIZON_HDR_DEBUG=1 now greys what isn't lifted and tints what is, so a
lift shows on purple and red light too.

Part of #1441.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 09:07:23 +01:00
Senjin 9da81f9293 Merge remote-tracking branch 'origin/main' into hdr-tiers 2026-10-27 09:01:54 +01:00
493 changed files with 28894 additions and 17970 deletions

No files matched your search

+12
View File
@@ -4,6 +4,18 @@
<!-- What this changes, and why. Link the issue it fixes ("Fixes #123"). -->
## Kind
<!-- Tick one: it sets the pull request's label (CONTRIBUTING.md "Pull requests"). -->
- [ ] parity: Horizon now does what vanilla 26.3 does
- [ ] bug: a crash or a breakage
- [ ] perf: faster, or less memory
- [ ] feature: something new for players (listed under New in the release notes)
- [ ] extra: Horizon-only, off by default
- [ ] tooling: tools, CI, tests, harnesses
- [ ] docs
## Vanilla reference
<!-- The vanilla classes and methods this follows (tools/rea_vanilla.py), and the harness that checks it.
+5 -6
View File
@@ -51,7 +51,7 @@ jobs:
if: steps.rust.outputs.required != 'false'
run: |
sudo apt-get update && sudo apt-get install -y --no-install-recommends libasound2-dev libudev-dev
rustup toolchain install stable --profile minimal --component rustfmt --component clippy --target wasm32-unknown-unknown
rustup toolchain install stable --profile minimal --component rustfmt --component clippy
# Dependencies and the workspace's own build, saved from main's runs (pull requests read main's cache).
- uses: Swatinem/rust-cache@v2
if: steps.rust.outputs.required != 'false'
@@ -62,8 +62,7 @@ jobs:
if: steps.rust.outputs.required != 'false'
run: |
cargo +stable fmt --all -- --check
# With the game's code mods (on in every release build, off by default).
cargo +stable clippy --workspace --all-targets --features horizon-client/mods -- -D warnings
cargo +stable clippy --workspace --all-targets -- -D warnings
# The server build (released as horizon-engine): the game without sound or controllers.
cargo +stable clippy -p horizon-client --all-targets --no-default-features -- -D warnings
# Libraries, binaries and integration tests, then doc tests: not the ~275 examples, which clippy above already
@@ -71,11 +70,11 @@ jobs:
- name: Test Rust core and voxel kernel correctness
if: steps.rust.outputs.required != 'false'
run: |
cargo +stable test --workspace --lib --bins --tests --features horizon-client/mods,horizon-mods/pack
cargo +stable test --workspace --lib --bins --tests
# The full content-root tests (#[ignore]d: minutes each on a laptop). The same build as above; they need the
# prepared vanilla data, so without it they pass at once.
cargo +stable test --workspace --lib --features horizon-client/mods,horizon-mods/pack mod_content::tests:: -- --ignored
cargo +stable test --workspace --doc --features horizon-client/mods,horizon-mods/pack
cargo +stable test --workspace --lib mod_content::tests:: -- --ignored
cargo +stable test --workspace --doc
# The launcher is its own workspace (launcher/Cargo.toml), so verify's cargo runs never build it. A pull request that
# changes it or a crate it builds is checked here, and so is every push to main: two pull requests can each pass
# alone and break it together (#2059 and the registry ratings did, 2026-10-26).
+74
View File
@@ -0,0 +1,74 @@
name: claim command
# `/claim` and `/unclaim` comments: anyone takes an issue before working on it, and a daily run releases the claims
# older than 5 days that no open pull request links (CONTRIBUTING.md "Claim before you start",
# tools/ci/claim_command.py).
#
# Why no pull request's code runs here, though the token can write:
# - `issue_comment` and `schedule` always run this file from main, whoever comments and wherever.
# - It checks out main's script and list only, never a pull request; it reads threads through the API.
# - The comment's text and author reach the script as env vars, never in a shell line. The token stays in the step
# and can only write issues (assignees, comments, reactions).
on:
issue_comment:
types: [created]
schedule:
- cron: "23 5 * * *"
workflow_dispatch:
permissions:
contents: read
jobs:
command:
# Every other comment skips here, before a runner starts. Pull requests are answered by the script.
if: >-
github.event_name == 'issue_comment' && github.event.comment.user.type != 'Bot' && (
startsWith(github.event.comment.body, '/claim') ||
startsWith(github.event.comment.body, '/unclaim'))
# One command per issue at a time, so two `/claim`s can't both find it free. On the job, not the workflow:
# the skipped runs of ordinary comments must not cancel a waiting one.
concurrency:
group: claim-${{ github.event.issue.number }}
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: read
issues: write
steps:
- uses: actions/checkout@v4
with:
ref: main
persist-credentials: false
sparse-checkout: |
.github/trusted-reviewers.txt
tools/ci/nightly_set.py
tools/ci/claim_command.py
sparse-checkout-cone-mode: false
- name: Run the command
env:
GH_TOKEN: ${{ github.token }}
BODY: ${{ github.event.comment.body }}
AUTHOR: ${{ github.event.comment.user.login }}
ISSUE: ${{ github.event.issue.number }}
COMMENT_ID: ${{ github.event.comment.id }}
REPO: ${{ github.repository }}
run: python3 tools/ci/claim_command.py command
release:
if: github.event_name != 'issue_comment' && github.repository == 'horizon-rust/horizon'
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
issues: write
steps:
- uses: actions/checkout@v4
with:
ref: main
persist-credentials: false
sparse-checkout: |
tools/ci/nightly_set.py
tools/ci/claim_command.py
sparse-checkout-cone-mode: false
- name: Release stale claims
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
run: python3 tools/ci/claim_command.py release
+3 -5
View File
@@ -138,8 +138,7 @@ jobs:
if: runner.os == 'Linux'
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends libasound2-dev libudev-dev
- name: Build the client, horizon-ctl, horizon-prepare and horizon-server
# `horizon-client/mods`: every released game runs code mods (the feature is off in dev builds).
run: cargo build --release --locked --features horizon-client/mods -p horizon-client --bin horizon-client --bin horizon-ctl -p horizon-prepare --bin horizon-prepare -p horizon-server --bin horizon-server
run: cargo build --release --locked -p horizon-client --bin horizon-client --bin horizon-ctl -p horizon-prepare --bin horizon-prepare -p horizon-server --bin horizon-server
- name: Every program the release folder takes was built
run: |
for binary in horizon-client horizon-ctl horizon-prepare horizon-server; do
@@ -267,9 +266,8 @@ jobs:
run: |
set -euo pipefail
# After the client is packaged: target/release/horizon-client is rebuilt without the audio and
# controllers features, so it needs neither libasound nor libudev (minimal server images lack them),
# and with code mods like the client.
cargo build --release --locked -p horizon-client --no-default-features --features mods --bin horizon-client
# controllers features, so it needs neither libasound nor libudev (minimal server images lack them).
cargo build --release --locked -p horizon-client --no-default-features --bin horizon-client
cargo run --locked -p xtask -- package-server target/release/horizon-client "dist/${{ matrix.server_dir }}"
folder="dist/${{ matrix.server_dir }}"
python3 tools/check_imports.py --server "$folder/horizon-engine" "$folder/horizon-server" "$folder/horizon-prepare"
+41
View File
@@ -0,0 +1,41 @@
name: overlap note
# One comment on a pull request naming the other open pull requests that close the same issue or change the same
# functions, edited on every push (tools/ci/overlap_note.py, issue #3859).
#
# Why no pull request's code runs here, though `pull_request_target` gives a token that can write:
# - `pull_request_target` runs this file and the script from main, never the pull request's copy.
# - The checkout is main's (`ref: main`), blobless, with the script alone on disk. The pull requests' heads are fetched
# as git objects and only diffed, never checked out; git runs with hooks, fsmonitor, external diff and textconv off.
# - The rest comes from the API. The token writes the comment only.
on:
pull_request_target:
types: [opened, reopened, synchronize, ready_for_review]
permissions:
contents: read
jobs:
note:
if: github.event.pull_request.base.ref == 'main'
# A newer push replaces the note being computed.
concurrency:
group: overlap-note-${{ github.event.pull_request.number }}
cancel-in-progress: true
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
pull-requests: write
steps:
- uses: actions/checkout@v4
with:
ref: main
fetch-depth: 0
filter: blob:none
persist-credentials: false
sparse-checkout: |
tools/ci/overlap_note.py
sparse-checkout-cone-mode: false
- name: Note
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
run: python3 tools/ci/overlap_note.py event
+36
View File
@@ -0,0 +1,36 @@
name: pr labels
# A pull request's kind label (the box ticked in the template) and area labels (the paths it changes):
# tools/ci/pr_labels.py, CONTRIBUTING.md "Pull requests".
#
# Why no pull request's code runs here, though `pull_request_target` gives a token that can write to fork PRs:
# - `pull_request_target` runs this file and the script from main, never the pull request's copy.
# - It checks out main's script only (`ref: main`), never the pull request's head or merge commit.
# - The script reads the event file (the description is data in JSON, never in a shell line) and the API's list of
# changed file names; it runs, builds and imports nothing of theirs. The token writes labels only.
on:
pull_request_target:
types: [opened, reopened, synchronize, edited, ready_for_review]
permissions:
contents: read
jobs:
label:
# Title-only edits change nothing: skip them before a runner starts.
if: github.event.action != 'edited' || github.event.changes.body != null
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: read
pull-requests: write
steps:
- uses: actions/checkout@v4
with:
ref: main
persist-credentials: false
sparse-checkout: |
tools/ci/pr_labels.py
sparse-checkout-cone-mode: false
- name: Label
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
run: python3 tools/ci/pr_labels.py event
+10 -7
View File
@@ -30,6 +30,8 @@ Feature → files → harness for the most-changed areas: [docs/architecture.md]
change), none for a fix. Agents never add `release-now`: the maintainer decides it. Merges ship in batches of 5, or after
6 hours ([docs/release-flow.md](docs/release-flow.md)).
- Contributors first: `gh pr list --state open --search <area>` before you start; never duplicate theirs.
- **Claim before you start**: comment `/claim` on the issue (no issue yet: open one, then claim it). An assigned
issue is taken: pick another. `/unclaim` when you stop; a claim with no open PR linking it is released after 5 days.
## Crates
@@ -76,7 +78,7 @@ cargo build --profile fast -p horizon-client # optimized, quick to link:
python3 tools/parity.py --changed origin/main # the harnesses your branch can affect (CI's parity job)
python3 tools/parity.py --changed-files paths.txt --list # which harnesses cover these files, without running them
python3 tools/parity.py mob_ai sculk # named harnesses
python3 tools/lan_e2e.py --check chest # two real games over LAN (server-thread host); --in-frame compares the fallback
python3 tools/lan_e2e.py --check chest # two real games over LAN (server-thread host)
python3 tools/agent_control_e2e.py # horizon-ctl / MCP end to end
```
@@ -169,17 +171,18 @@ the same `horizon_client::<module>` paths), `G:` = bin (`crates/horizon-client/s
"Server thread" with one level per dimension; the window joins it as a client through memory (`remote`),
exactly as a LAN guest or a dedicated server's player does. This is what players run. Open
`[server-thread]` issues list the gaps left.
2. **In-frame (`--in-frame`, `--no-server-thread` or `HORIZON_SERVER_THREAD=0`):** the window's `Game` ticks
the world itself, one level that follows the player (`fn enter_dimension`). Deterministic rule fixtures still use this path; player-facing integration uses the default server thread.
2. **In-frame (`--in-frame`, `--no-server-thread` or `HORIZON_SERVER_THREAD=0`): DEPRECATED, never use it, it
will be removed soon.** The window's `Game` ticks the world itself, one level that follows the player
(`fn enter_dimension`). Compatibility does not matter: don't add features or fixes for it, don't write new
tests against it, and move code off it when you touch it. The deterministic rule fixtures still run on it
until they are ported.
LAN guests and dedicated servers always use the server code, so a fix there serves both. Test both paths
when you touch player-facing gameplay:
LAN guests and dedicated servers always use the server code, so a fix there serves both. Test the server
thread when you touch player-facing gameplay:
```sh
cargo run --profile fast -p horizon-client -- --resources .local/horizon-client/26.3 --world T # server thread
cargo run --profile fast -p horizon-client -- --resources .local/horizon-client/26.3 --world T --in-frame # in-frame
python3 tools/lan_e2e.py --check <name> # host on its server thread, guest is a server client
python3 tools/lan_e2e.py --in-frame --check <name> # comparison (portals, levels, distance, lava need server-thread mode)
python3 tools/parity.py dedicated_server server_entities server_player_damage
```
+20 -3
View File
@@ -74,6 +74,19 @@ including examples. The dependency scope includes optional, platform, build and
Tests run library, binary, integration and doc tests without building unrelated example executables. Shared Cargo
configuration and toolchain changes select the whole workspace; CI also keeps its workspace-wide checks.
## Claim before you start
Work is tracked in GitHub issues, and an issue that is assigned is taken (so is one with the `in progress` label:
claimed on Discord with Kira's `/claim`): pick another, or ask its holder in the thread. Before you start, comment
**`/claim`** on the issue; you are assigned at once, no role needed. No issue for your work yet? Open one (a line is
enough), then claim it. `/unclaim` gives it back.
A claim with no open pull request linking the issue (`Fixes #123` in the description, or a draft) is released after
5 days, with a comment: open a draft early. You can hold 5 open issues at a time. Agents claim the same way.
When your pull request changes the same functions as another open one, or closes the same issue, a bot comment names
it (edited on every push, never repeated): talk to its author in the thread before you both finish the same work.
## Pull requests
- **Small.** One change per pull request, under about 500 lines of code where you can; split bigger work into
@@ -82,8 +95,12 @@ configuration and toolchain changes select the whole workspace; CI also keeps it
can see.
- **Tick "Allow edits by maintainers".** When something small is left (a lint, a rebase, a nit) we push the fixup
ourselves instead of sending it back to you; only design questions come back.
- **Labels** (we set them if you can't): `feature` when players get something new (it appears under "New" in the
release notes), `skip-release` when players see no change (tooling, docs, tests), nothing for a fix.
- **Tick its kind** in the template: `parity` (Horizon now does what vanilla does), `bug` (a crash or breakage),
`perf`, `feature` (new for players), `extra` (Horizon-only, off by default), `tooling` or `docs`. A workflow sets
that label, and area labels (`launcher`, `mobs`, `worldgen`, `rendering`...) from the paths you change.
`label:parity` is the list of real progress toward vanilla.
- **Release labels** (we set them if you can't): `feature` also puts it under "New" in the 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
@@ -102,4 +119,4 @@ new pushes, we add it again.
## Talk to us
[Discord](https://discord.gg/HcTeMZxe5E) for questions and ideas, [issues](https://github.com/horizon-rust/horizon/issues)
for bugs. If you want to take something on, say so in its issue so nobody else starts the same work.
for bugs. To take something on, `/claim` its issue ([above](#claim-before-you-start)).
Generated
+8 -815
View File
File diff suppressed because it is too large. Load diff
-13
View File
@@ -23,26 +23,13 @@ members = [
"crates/horizon-bench",
"crates/horizon-friends",
"crates/horizon-relay",
"crates/horizon-mod-sdk",
"crates/horizon-mods",
"crates/horizon-mods/tests/guest",
"crates/horizon-mods/tests/ui_guest",
"crates/cargo-horizon-mod",
"crates/horizon-port-pack",
"crates/horizon-shaders",
"crates/horizon-shader-api",
"crates/horizon-release-sign",
"crates/horizon-mod-sdk/examples/coords-hud",
"crates/horizon-mod-sdk/examples/predator-missile",
"crates/horizon-mod-sdk/examples/welcome",
"crates/horizon-mod-sdk/examples/beacons",
"mods/official/fps-overlay",
"mods/official/keystrokes",
"mods/official/far-view",
"mods/official/minimap",
"mods/official/zoom",
"xtask",
"mods/official/recipes",
]
resolver = "2"
+307 -452
View File
@@ -12,11 +12,11 @@ Horizon is Minecraft: Java Edition 26.3 rewritten from scratch in Rust, so it ru
- **Steady frames at long render distances**: Making Horizon faster than vanilla on every computer, with smooth camera movement even at 32 chunks, by spreading the work better over the processor's cores.
- **Modpacks' world generation, natively**: Making the world-generation mods of popular modpacks (new structures, YUNG's dungeons and mineshafts, packs built on Lithostitched) generate their worlds in Horizon, natively and fast.
- **Playing on servers: everything shows as it should**: When you play on a server, other players, mobs, items and menus look and behave as in vanilla: skins, poses, item names, inventories and server networks.
- **Single player as a server: the last differences**: Single player now runs a small server in the background, as vanilla does. We are fixing what still behaves differently there; every fix also works for LAN and server players.
- **Horizon's mod registry**: One place for mods, modpacks and resource packs made for Horizon: browse them on the website and install them from the launcher in one click.
- **Vanilla parity: the player-visible gaps, closed**: Every method of the Java game is tracked with where Horizon does it, and the differences players can see are being closed, list by list: mobs, blocks, items, screens, rendering, servers.
- **Mods**: Mods for Horizon, as one line of work led by the modding team: native mods the launcher builds into your game, mods on servers, mod screens and settings, tools for mod makers, the registry and machines.
**Next**: A new launcher design; CurseForge in the mod browser (coming soon); Fresh benchmarks against vanilla; Pistons push players and mobs; Horizon on Intel Macs; Faster world creation and entry on small CPUs; Servers that hold 20 TPS with 50 to 100 players; Low-end computers: Steam Deck, old Macs, 8 GB; Native upscaling; A batch of targeted optimisations; A Convert to Rust button for what isn't rewritten yet; Mods with code: machines, magic, mobs; Data packs in the mod browser; Prepare a version without Java; Server console, bans and a full admin API; Hosting polish: Windows service, safe updates; Guided hosting with Tailscale and an invite button; An official Docker image for servers; One single-player path: remove the in-frame world; Villages and combat, exactly as in vanilla; Choose the audio output device; Mods that read and change the world; Machines and blocks with inventories (API 0.4); Well-known shader looks, rewritten for Horizon; A visible beta label; Wayland and Hyprland polish; A versioned agent-control protocol; Port mods with your own AI agent; Horizon on X (coming soon); Pipeline protection and a GitHub App for agents; Signed updates everywhere, installers included; A server you install with one command; Ready-made setups for hosting panels.
**Next**: Fresh benchmarks against vanilla; Faster world creation and entry on small CPUs; Low-end computers: Steam Deck, old Macs, 8 GB; Native upscaling; Server console, bans and a full admin API; Hosting polish: Windows service, safe updates; One single-player path: remove the in-frame world; Faster block lookups; Playing on servers: everything shows as it should; Shader packs, written again from scratch; A new launcher design; Splitscreen.
**How to help**: Play it and tell us what differs from vanilla (Discord or GitHub), run the benchmark on your computer, vote for what you want next, or pick one of the Help wanted items.
@@ -30,19 +30,25 @@ Horizon is Minecraft: Java Edition 26.3 rewritten from scratch in Rust, so it ru
Making Horizon faster than vanilla on every computer, with smooth camera movement even at 32 chunks, by spreading the work better over the processor's cores.
Owner: [ludovic111](https://github.com/ludovic111) · Tracking: [#3909](https://github.com/horizon-rust/horizon/issues/3909)
<details><summary>Technical</summary>
Frame pacing at render distance 32 ([#678](https://github.com/horizon-rust/horizon/issues/678)): long frames come from work competing with the render thread. In review: world generation workers build free structure starts instead of queueing them ([#1760](https://github.com/horizon-rust/horizon/issues/1760)), the worker pool leaves one core as vanilla does ([#1778](https://github.com/horizon-rust/horizon/issues/1778)), and the terrain vertex arena is read as storage where flat draws run ([#1742](https://github.com/horizon-rust/horizon/issues/1742)). Still open: far terrain at 48 to 64 chunks ([#718](https://github.com/horizon-rust/horizon/issues/718)) and CPU-bound fields of block entities ([#717](https://github.com/horizon-rust/horizon/issues/717)). Every change shows before and after numbers from `tools/perf_bench.py` against vanilla 26.3 ([docs/benchmark.md](docs/benchmark.md)).
Frame pacing at render distance 32 ([#678](https://github.com/horizon-rust/horizon/issues/678)): long frames come from work competing with the render thread. In review: no main-thread tick stutter at high render distances ([#3827](https://github.com/horizon-rust/horizon/issues/3827)), each proto chunk's light view built once ([#3815](https://github.com/horizon-rust/horizon/issues/3815)), video memory handed back by the vertex arena ([#3466](https://github.com/horizon-rust/horizon/issues/3466)), chunk batches assembled on helper threads ([#3036](https://github.com/horizon-rust/horizon/issues/3036)), free structure starts in the generation workers ([#1760](https://github.com/horizon-rust/horizon/issues/1760)). Still open: far terrain at 48 to 64 chunks ([#718](https://github.com/horizon-rust/horizon/issues/718)) and CPU-bound fields of block entities ([#717](https://github.com/horizon-rust/horizon/issues/717)). Every change shows before and after numbers from `tools/perf_bench.py` against vanilla 26.3 ([docs/benchmark.md](docs/benchmark.md)).
</details>
#### Faster block lookups
#### Servers that hold 20 TPS with 50 to 100 players
Speeding up the code that reads and writes the blocks of the world, which almost everything in the game goes through: generation, lighting, meshing and ticking.
Servers that keep 20 ticks per second with 50 to 100 players. Today Horizon holds it up to 50 bots and drops to about 10 at 100.
Owner: [ludovic111](https://github.com/ludovic111) · Tracking: [#3910](https://github.com/horizon-rust/horizon/issues/3910)
<details><summary>Technical</summary>
`PalettedContainer` and `PackedStorage` find an entry's word by multiply and shift instead of an integer division ([#1790](https://github.com/horizon-rust/horizon/issues/1790), [#1794](https://github.com/horizon-rust/horizon/issues/1794)); meshing looks up baked models by index, not by name ([#1747](https://github.com/horizon-rust/horizon/issues/1747)). Each change keeps the paletted storage harnesses identical and shows its microbenchmark ([docs/performance/paletted-container-access-results.md](docs/performance/paletted-container-access-results.md)).
Measured against vanilla on equal work ([docs/server.md](docs/server.md), Scale): Horizon is ahead with few players and when exploring, but vanilla's tick is shorter with 25 to 50 players near spawn. Ideas on the list: send entity changes instead of rebuilding every entity each tick (about 15 percent), fewer memory reads in random ticks and collision scans, cheaper item collision checks, mob AI and natural spawning. The bot benchmark (`horizon-bots`) is the proof.
More: [#3847](https://github.com/horizon-rust/horizon/issues/3847) · [#2608](https://github.com/horizon-rust/horizon/issues/2608)
</details>
@@ -52,101 +58,27 @@ Speeding up the code that reads and writes the blocks of the world, which almost
Making the world-generation mods of popular modpacks (new structures, YUNG's dungeons and mineshafts, packs built on Lithostitched) generate their worlds in Horizon, natively and fast.
<details><summary>Technical</summary>
`horizon-port-pack` carries a pack's data-driven content over; structure mods that need code get a native port: YUNG's worldgen as written ([#1746](https://github.com/horizon-rust/horizon/issues/1746)), Lithostitched's limited and guaranteed elements with pool and processor modifiers ([#1777](https://github.com/horizon-rust/horizon/issues/1777)), its processors as the game's own types ([#1765](https://github.com/horizon-rust/horizon/issues/1765)). Proved by the structure blocks and layout harnesses ([#1753](https://github.com/horizon-rust/horizon/issues/1753), [#1757](https://github.com/horizon-rust/horizon/issues/1757)). Mods whose Java code does more than world generation don't run ([docs/modpacks.md](docs/modpacks.md)).
</details>
### Servers and multiplayer
#### Playing on servers: everything shows as it should
When you play on a server, other players, mobs, items and menus look and behave as in vanilla: skins, poses, item names, inventories and server networks.
Owner: [ThatCakeIsAPie](https://github.com/ThatCakeIsAPie) · Tracking: [#3913](https://github.com/horizon-rust/horizon/issues/3913)
<details><summary>Technical</summary>
Client-of-a-server fixes in `game/remote*.rs` and `horizon-protocol`: mobs' aggressive and item-use flags ([#1802](https://github.com/horizon-rust/horizon/issues/1802), [#1798](https://github.com/horizon-rust/horizon/issues/1798)), ghost items after a remote menu ([#1795](https://github.com/horizon-rust/horizon/issues/1795)), skins ([#1652](https://github.com/horizon-rust/horizon/issues/1652)), cookies and transfer packets for proxy networks ([#1650](https://github.com/horizon-rust/horizon/issues/1650)), firework boosts ([#1671](https://github.com/horizon-rust/horizon/issues/1671)), living-entity attributes and simulation distance ([#1731](https://github.com/horizon-rust/horizon/issues/1731)), styled item names ([#1702](https://github.com/horizon-rust/horizon/issues/1702)), primed TNT between updates ([#1710](https://github.com/horizon-rust/horizon/issues/1710)).
</details>
#### Server dialogs and login screens
Servers that show dialogs, such as the register and login screens of AuthMe, will work in Horizon.
<details><summary>Technical</summary>
26.3's dialog packets and model ([#1720](https://github.com/horizon-rust/horizon/issues/1720)), the dialog screen with its buttons and actions ([#1734](https://github.com/horizon-rust/horizon/issues/1734)), then text and boolean inputs ([#1735](https://github.com/horizon-rust/horizon/issues/1735)).
</details>
#### Pre-generate an area with /pregen
A /pregen command that builds an area of the world ahead of time, so players don't wait for new terrain when they explore.
<details><summary>Technical</summary>
Generates, lights and saves an area before anyone walks there ([#1805](https://github.com/horizon-rust/horizon/issues/1805), a contributor's pull request, in review). Useful on servers, and it moves the generation cost away from the first visit; the generation speed-ups are under 'Steady frames at long render distances'.
`horizon-port-pack` carries a pack's data-driven content over, and 26.2 data packs are upgraded to 26.3's format ([#3369](https://github.com/horizon-rust/horizon/issues/3369)). In review: worlds taller than 32 sections and each level's shape from its dimension type ([#3480](https://github.com/horizon-rust/horizon/issues/3480), [#3488](https://github.com/horizon-rust/horizon/issues/3488)), data-pack biomes in local and remote worlds ([#3491](https://github.com/horizon-rust/horizon/issues/3491)), block state providers ([#3802](https://github.com/horizon-rust/horizon/issues/3802)), surface rules by biome id ([#3366](https://github.com/horizon-rust/horizon/issues/3366)), Lithostitched's pool and processor modifiers ([#1777](https://github.com/horizon-rust/horizon/issues/1777)). Mods whose Java code does more than world generation don't run ([docs/modpacks.md](docs/modpacks.md)).
</details>
### Mods and API
#### Horizon's mod registry
#### Mods
One place for mods, modpacks and resource packs made for Horizon: browse them on the website and install them from the launcher in one click.
Mods for Horizon, as one line of work led by the modding team: native mods the launcher builds into your game, mods on servers, mod screens and settings, tools for mod makers, the registry and machines.
Owner: [levzzz5154](https://github.com/levzzz5154) · Tracking: [#3911](https://github.com/horizon-rust/horizon/issues/3911)
<details><summary>Technical</summary>
Merged: the registry index (schema 1, `.hpack`, checks before install, [#1634](https://github.com/horizon-rust/horizon/issues/1634)), `cargo horizon-mod check` ([#1642](https://github.com/horizon-rust/horizon/issues/1642)) and the submission service (`crates/horizon-mod-registry`, [#1659](https://github.com/horizon-rust/horizon/issues/1659)). In review: the website's /mods ([#1596](https://github.com/horizon-rust/horizon/issues/1596)) and /mods/submit ([#1635](https://github.com/horizon-rust/horizon/issues/1635)), and in the launcher the registry as a source ([#1636](https://github.com/horizon-rust/horizon/issues/1636)), install, enable and takedowns ([#1637](https://github.com/horizon-rust/horizon/issues/1637)), the Mods page ([#1638](https://github.com/horizon-rust/horizon/issues/1638)) and modpacks as their own profiles ([#1640](https://github.com/horizon-rust/horizon/issues/1640)).
One item for all modding work ([#3911](https://github.com/horizon-rust/horizon/issues/3911)), led by levzzz5154 with vladislav_hagen; the order between the parts is theirs. Native mods on stable hook points (game/native_hooks.rs, [#3345](https://github.com/horizon-rust/horizon/issues/3345), [#3346](https://github.com/horizon-rust/horizon/issues/3346), [#3434](https://github.com/horizon-rust/horizon/issues/3434), [#3435](https://github.com/horizon-rust/horizon/issues/3435)) built by the launcher ([#3388](https://github.com/horizon-rust/horizon/issues/3388), [#3855](https://github.com/horizon-rust/horizon/issues/3855)); server-side hooks ([#3878](https://github.com/horizon-rust/horizon/issues/3878), [#3706](https://github.com/horizon-rust/horizon/issues/3706)); mod-owned screens, input and drawing; tools for mod makers (templates, `native.py check`); the registry ([#1634](https://github.com/horizon-rust/horizon/issues/1634), [#1642](https://github.com/horizon-rust/horizon/issues/1642), [#1659](https://github.com/horizon-rust/horizon/issues/1659)); machines with inventories and menus ([#1189](https://github.com/horizon-rust/horizon/issues/1189)); mods that read and change the world through the server. The WebAssembly runtime is being removed ([#3351](https://github.com/horizon-rust/horizon/issues/3351), [#3353](https://github.com/horizon-rust/horizon/issues/3353)). [docs/native-mods.md](docs/native-mods.md).
More: [docs/mods.md](docs/mods.md)
</details>
#### Native mods, built by the launcher
Horizon's own mods become native code that the launcher builds into your game, with no Rust to install: Zoom, FPS Counter, Keystrokes and Minimap already are, and Recipes is next.
<details><summary>Technical</summary>
Stable hook points in the game (game/native_hooks.rs, [#3345](https://github.com/horizon-rust/horizon/issues/3345), [#3346](https://github.com/horizon-rust/horizon/issues/3346); v2 with the item panel and recipe pages, [#3434](https://github.com/horizon-rust/horizon/issues/3434), [#3435](https://github.com/horizon-rust/horizon/issues/3435)), horizon-mixins applied by the launcher with its own portable Rust ([#3388](https://github.com/horizon-rust/horizon/issues/3388)), on/off in Options > Mods. The official mods live in horizon-mods/native/ and are in the registry as native mods. Next: Recipes, native mods on automatically when a player installs one (with a notice about the first build), then the WebAssembly runtime is removed ([#3350](https://github.com/horizon-rust/horizon/issues/3350), [#3351](https://github.com/horizon-rust/horizon/issues/3351), [#3353](https://github.com/horizon-rust/horizon/issues/3353), [#3371](https://github.com/horizon-rust/horizon/issues/3371)).
More: [docs/native-mods.md](docs/native-mods.md)
</details>
#### Mods that run on the server
Mods that run on the server side of a world, so a dedicated server or a LAN world can have mods that react to players joining, chat and blocks being broken or placed, and that add commands.
<details><summary>Technical</summary>
The same hook points on the server thread and the dedicated server: world events (block broken or placed, entity spawned or killed, player joined or left), game rules and server commands with typed arguments. The earlier WebAssembly API 0.3 ([#1833](https://github.com/horizon-rust/horizon/issues/1833)) is the reference for what to cover.
More: [docs/native-mods.md](docs/native-mods.md)
</details>
#### Screens, input and drawing for mods
Mods will be able to open their own screens and settings pages, react to keys and item use, and draw, so bigger mods, such as the Orbital Laser, run on the normal game.
<details><summary>Technical</summary>
On native hook points ([docs/native-mods.md](docs/native-mods.md)): settings pages under Options > Mods and the item panel are there ([#3346](https://github.com/horizon-rust/horizon/issues/3346), [#3435](https://github.com/horizon-rust/horizon/issues/3435)). Next: mod-owned screens built from vanilla's widgets, an item's use, key binds and the crosshair target, and a budgeted draw list on the native shader API. Orbital Laser is the acceptance test.
</details>
#### Tools for people who make mods
Better tools for mod makers: templates for each kind of mod, a test run with no game window, and clear promises about which hook points keep working from one version to the next.
<details><summary>Technical</summary>
Native mod template and the content mod template (horizon-mods), `native.py check` that applies mods on a chosen Horizon and builds them together, a weekly check of every official mod on the newest Nightly. Left: a versioned list of stable hook points (NATIVE_HOOKS_API), a test runner without a window, and docs on the site for native mods.
More: [#3388](https://github.com/horizon-rust/horizon/issues/3388) · [docs/native-mods.md](docs/native-mods.md)
More: [#3360](https://github.com/horizon-rust/horizon/issues/3360) · [#1596](https://github.com/horizon-rust/horizon/issues/1596) · [#1635](https://github.com/horizon-rust/horizon/issues/1635)
</details>
@@ -156,103 +88,11 @@ More: [#3388](https://github.com/horizon-rust/horizon/issues/3388) · [docs/nati
Single player now runs a small server in the background, as vanilla does. We are fixing what still behaves differently there; every fix also works for LAN and server players.
<details><summary>Technical</summary>
Since [#1514](https://github.com/horizon-rust/horizon/issues/1514) single player runs on the server thread (`game/integrated.rs`) and the window joins it as a client; `--in-frame` keeps the old path. The open `[server-thread]` issues list the gaps. In review: a tool that changes a block ends the click ([#1797](https://github.com/horizon-rust/horizon/issues/1797)), potions keep their potion over the connection ([#1796](https://github.com/horizon-rust/horizon/issues/1796)), mobs' aggression and arm swings ([#1802](https://github.com/horizon-rust/horizon/issues/1802)). [docs/server-levels.md](docs/server-levels.md).
</details>
#### Play in your language
Item names, container titles and server messages follow the language you pick, as in vanilla. Today parts of the game stay in English.
Owner: [ludovic111](https://github.com/ludovic111) · Tracking: [#3914](https://github.com/horizon-rust/horizon/issues/3914)
<details><summary>Technical</summary>
Translatable components resolved through the selected language for items, containers and server text ([#1806](https://github.com/horizon-rust/horizon/issues/1806)), on top of the bundled languages the prepared resources carry ([#1594](https://github.com/horizon-rust/horizon/issues/1594)). Left: the client's own screens ([#1672](https://github.com/horizon-rust/horizon/issues/1672)) and the main menu with zh_cn ([#1774](https://github.com/horizon-rust/horizon/issues/1774)).
</details>
#### Cooldowns drawn on items
After you throw an ender pearl, its slot should show the cooldown sweeping away, as in vanilla.
<details><summary>Technical</summary>
Vanilla draws `ItemCooldowns`' percent as a white overlay over the slot (`GuiGraphicsExtractor.itemDecorations` and `itemCooldown`) in the hotbar and in inventories. Issue [#1763](https://github.com/horizon-rust/horizon/issues/1763).
More: [#1933](https://github.com/horizon-rust/horizon/issues/1933)
</details>
#### Chat settings and team prefixes
The Hidden and Commands Only chat settings should hide chat, and team prefixes should appear before names in chat.
<details><summary>Technical</summary>
Vanilla checks `ChatVisiblity` before it delivers chat and system messages ([#1376](https://github.com/horizon-rust/horizon/issues/1376)), and chat uses the team-formatted display name ([#1378](https://github.com/horizon-rust/horizon/issues/1378)). Both issues have exact steps to reproduce.
More: [#1932](https://github.com/horizon-rust/horizon/issues/1932) · [#1841](https://github.com/horizon-rust/horizon/issues/1841)
</details>
#### A pointing hand over buttons
The mouse pointer should turn into a pointing hand over menu buttons, as in vanilla 26.3.
<details><summary>Technical</summary>
26.3's `AbstractWidget` asks for a cursor from `CursorTypes` when it is hovered; winit's `CursorIcon::Pointer` gives the same shape. Issue [#1347](https://github.com/horizon-rust/horizon/issues/1347).
More: [#1897](https://github.com/horizon-rust/horizon/issues/1897)
</details>
#### Ctrl+Backspace and scroll bars in menus
Ctrl+Backspace should delete a whole word in the creative search, and every list's scroll bar should be draggable.
<details><summary>Technical</summary>
`EditBox` deletes by word with Ctrl ([#1764](https://github.com/horizon-rust/horizon/issues/1764)); the Game Rules and Statistics lists' scroll bars can't be dragged ([#1782](https://github.com/horizon-rust/horizon/issues/1782), after [#978](https://github.com/horizon-rust/horizon/issues/978)).
More: [#1884](https://github.com/horizon-rust/horizon/issues/1884) · [#1931](https://github.com/horizon-rust/horizon/issues/1931)
</details>
#### Chickens flap while they fall
Chickens should flap their wings while they fall, not stand still in the air.
<details><summary>Technical</summary>
Vanilla's `Chicken.aiStep` updates `flap` and `flapSpeed` when the chicken isn't on the ground, and the model's wings follow them. Issue [#1706](https://github.com/horizon-rust/horizon/issues/1706).
More: [#1892](https://github.com/horizon-rust/horizon/issues/1892)
</details>
### Rendering
#### Shader packs, written again from scratch
Shader packs were removed and are being rebuilt from scratch. They come back, off by default, once the best-known packs draw properly.
<details><summary>Technical</summary>
A clean-room rewrite in `horizon-shaderpack`, from the packs' public format documentation only: reading packs, the preprocessor and `.properties` files are merged ([#1584](https://github.com/horizon-rust/horizon/issues/1584), [#1585](https://github.com/horizon-rust/horizon/issues/1585), [#1586](https://github.com/horizon-rust/horizon/issues/1586)); translating programs to WGSL through naga, buffers, uniforms and the terrain, entity, sky, weather and hand stages are in review ([#1698](https://github.com/horizon-rust/horizon/issues/1698) to [#1801](https://github.com/horizon-rust/horizon/issues/1801)).
More: [#1561](https://github.com/horizon-rust/horizon/issues/1561) · [#1771](https://github.com/horizon-rust/horizon/issues/1771) · [#1786](https://github.com/horizon-rust/horizon/issues/1786)
</details>
#### Horizon's own shader format and kit
Horizon's own way to make shader packs, with tools to write and test them. Every pack is checked before it runs, and the registry will list them.
<details><summary>Technical</summary>
`horizon-shader-api` (API 0.1, experimental): WGSL stages with typed inputs inside the renderer's passes, options as WGSL override constants, and the `horizon-shader new|check|pack` tool with templates and the example pack Golden Hour. Merged: the check every pack passes ([#1820](https://github.com/horizon-rust/horizon/issues/1820)) and the tool, templates and guide ([#1822](https://github.com/horizon-rust/horizon/issues/1822)). In review: the post stage running in the game with hot reload ([#1861](https://github.com/horizon-rust/horizon/issues/1861)), the sky stage ([#1863](https://github.com/horizon-rust/horizon/issues/1863)) and registry checks that use the game's own check ([#1855](https://github.com/horizon-rust/horizon/issues/1855)). Left: the material, lighting and shadow stages, a last-good fallback on reload, a GPU-time overlay per stage and budgets ([docs/shaders.md](docs/shaders.md), [docs/api/shaders.md](docs/api/shaders.md)). The Iris/OptiFine loader is a separate item, 'Shader packs, written again from scratch'.
Since [#1514](https://github.com/horizon-rust/horizon/issues/1514) single player runs on the server thread (`game/integrated.rs`) and the window joins it as a client; `--in-frame` keeps the old path. The open `[server-thread]` issues list the gaps. In review: breeding's advancement and statistic ([#3873](https://github.com/horizon-rust/horizon/issues/3873)), the client's menu when the server replaces the player ([#3841](https://github.com/horizon-rust/horizon/issues/3841)), splashes when falling in water ([#3621](https://github.com/horizon-rust/horizon/issues/3621)). [docs/server-levels.md](docs/server-levels.md).
</details>
@@ -262,6 +102,8 @@ Horizon's own way to make shader packs, with tools to write and test them. Every
Play the real Java game from the Horizon launcher too: vanilla and modded instances, any version, mods from Modrinth filtered to what fits, and your instances imported from other launchers.
Owner: [ludovic111](https://github.com/ludovic111) · Tracking: [#3915](https://github.com/horizon-rust/horizon/issues/3915)
<details><summary>Technical</summary>
For an account that owns the game: any version downloaded with the Java it needs ([#3406](https://github.com/horizon-rust/horizon/issues/3406)), Java instances, the version list and the CLI ([#3395](https://github.com/horizon-rust/horizon/issues/3395)), the start command read from the version JSON ([#3419](https://github.com/horizon-rust/horizon/issues/3419)). Next: Play for a Java instance, Fabric and Quilt, then NeoForge and Forge; Modrinth with compatibility filtering and dependencies; imports from Prism, MultiMC, the CurseForge app, the Modrinth App, ATLauncher and the official launcher, each from its documented on-disk format.
@@ -270,37 +112,31 @@ More: [#3506](https://github.com/horizon-rust/horizon/issues/3506)
</details>
### Horizon extras
#### Discord Rich Presence
Your Discord profile can show that you are playing Horizon, with a switch to turn it off.
<details><summary>Technical</summary>
A Discord IPC client on its own thread ([#1787](https://github.com/horizon-rust/horizon/issues/1787)) and the presence with its setting ([#1788](https://github.com/horizon-rust/horizon/issues/1788)). Merged: it ships with the next release.
</details>
### Code and tools
#### A leaner codebase that builds faster
Cleaning up Horizon's code: removing what nothing uses and making it quicker to build, so fixes and new features land faster.
Owner: [ludovic111](https://github.com/ludovic111) · Tracking: [#3916](https://github.com/horizon-rust/horizon/issues/3916)
<details><summary>Technical</summary>
A review of the whole tree: dead code and stubs removed ([#1432](https://github.com/horizon-rust/horizon/issues/1432), [#1658](https://github.com/horizon-rust/horizon/issues/1658)), one probe binary per crate ([#1453](https://github.com/horizon-rust/horizon/issues/1453), [#1454](https://github.com/horizon-rust/horizon/issues/1454)), shared harness helpers ([#1448](https://github.com/horizon-rust/horizon/issues/1448)), clippy clean on the whole tree ([#1707](https://github.com/horizon-rust/horizon/issues/1707)). [docs/architecture.md](docs/architecture.md) maps each feature to its files and harnesses.
</details>
#### Every vanilla method, tracked
#### Vanilla parity: the player-visible gaps, closed
A public list of every method of the Java game and where Horizon does it, so anyone can see what is ported, what is missing and pick something to fix.
Every method of the Java game is tracked with where Horizon does it, and the differences players can see are being closed, list by list: mobs, blocks, items, screens, rendering, servers.
Owner: [kacpherek](https://github.com/kacpherek) · Tracking: [#3912](https://github.com/horizon-rust/horizon/issues/3912)
<details><summary>Technical</summary>
[tools/port_ledger.py](tools/port_ledger.py) and tools/port_map/ ([#3139](https://github.com/horizon-rust/horizon/issues/3139)): every 26.3 method outside the skipped packages has a verdict (port, inline, missing, skip, trivial), linked to the harnesses that cover it ([#3526](https://github.com/horizon-rust/horizon/issues/3526)), with one tracking issue per area. Bug fixes start from it (.claude/skills/vanilla-parity).
The port ledger ([tools/port_ledger.py](tools/port_ledger.py), tools/port_map/, [#3139](https://github.com/horizon-rust/horizon/issues/3139)) gives every 26.3 method a verdict and links the harnesses that cover it ([#3526](https://github.com/horizon-rust/horizon/issues/3526)). The player-visible gaps are ten lists, one per area ([#3512](https://github.com/horizon-rust/horizon/issues/3512) to [#3521](https://github.com/horizon-rust/horizon/issues/3521)); a fix translates the vanilla method, ticks its row and carries the `parity` label. Start from .claude/skills/vanilla-parity.
More: [#3513](https://github.com/horizon-rust/horizon/issues/3513) · [#3514](https://github.com/horizon-rust/horizon/issues/3514) · [#3515](https://github.com/horizon-rust/horizon/issues/3515) · [#3516](https://github.com/horizon-rust/horizon/issues/3516) · [#3517](https://github.com/horizon-rust/horizon/issues/3517) · [#3518](https://github.com/horizon-rust/horizon/issues/3518) · [#3519](https://github.com/horizon-rust/horizon/issues/3519) · [#3520](https://github.com/horizon-rust/horizon/issues/3520)
</details>
@@ -328,16 +164,6 @@ Cut the per-column generation cost (noise sampling, surface rules, aquifers) and
</details>
#### Servers that hold 20 TPS with 50 to 100 players
Servers that keep 20 ticks per second with 50 to 100 players. Today Horizon holds it up to 50 bots and drops to about 10 at 100.
<details><summary>Technical</summary>
Measured against vanilla on equal work ([docs/server.md](docs/server.md), Scale): Horizon is ahead with few players and when exploring, but vanilla's tick is shorter with 25 to 50 players near spawn. Ideas on the list: send entity changes instead of rebuilding every entity each tick (about 15 percent), fewer memory reads in random ticks and collision scans, cheaper item collision checks, mob AI and natural spawning. The bot benchmark (`horizon-bots`) is the proof.
</details>
#### Low-end computers: Steam Deck, old Macs, 8 GB
Horizon should run well on small machines: integrated graphics, the Steam Deck, older Macs and 8 GB of memory, with a battery mode that saves power on laptops.
@@ -358,74 +184,22 @@ Draw the game at a lower resolution and sharpen it to your screen's size, in the
A render-scale setting below the window size with an upscaler of our own that runs on every wgpu backend. Proof: frame time against native resolution on integrated GPUs, and screenshots for image quality. Issue [#1403](https://github.com/horizon-rust/horizon/issues/1403).
</details>
#### A batch of targeted optimisations
Several smaller speed-ups, each measured: idle block entities that sleep, cheaper particles, faster structure placement, high-rate mice and very large texture packs.
<details><summary>Technical</summary>
Block entities and hoppers that skip work when idle (the hopper item scan is already skipped when no item is near); a particle frustum test first (also a parity gap), then particle quads built on the GPU; jigsaw shortcuts that skip blocked connectors and pool elements known not to fit, with layouts unchanged (trial chambers and ancient cities first); batched raw mouse input on Windows for 8000 Hz mice; a better atlas packer for huge resource packs (a 64x pack builds a 704 MiB atlas for 452 MiB of textures); sign-heavy scenes ([#1461](https://github.com/horizon-rust/horizon/issues/1461)). Profile first, keep what pays, show before and after numbers.
More: [docs/profiling.md](docs/profiling.md)
More: [#3762](https://github.com/horizon-rust/horizon/issues/3762) · [#3763](https://github.com/horizon-rust/horizon/issues/3763) · [#3764](https://github.com/horizon-rust/horizon/issues/3764)
</details>
### Modpacks
#### Faster block lookups
#### CurseForge in the mod browser (coming soon)
Coming soon: search and download CurseForge mods and modpacks from the launcher, as you can with Modrinth today.
Speeding up the code that reads and writes the blocks of the world, which almost everything in the game goes through: generation, lighting, meshing and ticking.
<details><summary>Technical</summary>
The mod browser lists CurseForge as coming soon ([docs/mod-browser.md](docs/mod-browser.md)): it needs CurseForge API access. Meanwhile packs import from a CurseForge zip or instance folder, and jars only CurseForge hosts come from the player's own instance ([docs/modpacks.md](docs/modpacks.md)).
</details>
#### A Convert to Rust button for what isn't rewritten yet
When a mod, shader pack or modpack hasn't been rewritten for Horizon, one button converts what can be converted, so you don't have to wait for the rewrite.
<details><summary>Technical</summary>
Generalise the mod browser's Port step (`horizon-port-pack`: the data, assets, worldgen and block and item definitions of Java mods, .mrpack and CurseForge zips) to every kind of content: code mods where the API allows it (see 'Mods with code') and shader packs, converting an Iris/OptiFine pack into Horizon's format. The browser says what works before the install. Java code itself does not run ([docs/modpacks.md](docs/modpacks.md), [docs/mod-browser.md](docs/mod-browser.md)).
</details>
#### Mods with code: machines, magic, mobs
Making mods that add machines, magic and mobs work in Horizon, in steps, starting with machines. They are most of what a big modpack is made of.
<details><summary>Technical</summary>
333 of the 496 mods in a big pack (ATM10) contain code. Issue [#1854](https://github.com/horizon-rust/horizon/issues/1854) ranks the API gaps (ticking block entities, networking, rendering, menus, inventories) that unlock them in tiers, with machines first as the recommended path and a furnace ported tick for tick as proof. The first API step is a draft ([#1189](https://github.com/horizon-rust/horizon/issues/1189), see 'Machines and blocks with inventories'). The path isn't started yet ([docs/modpacks.md](docs/modpacks.md)).
</details>
#### Data packs in the mod browser
Search and install data packs from the launcher's mod browser, for the world you choose, as you do mods and resource packs.
<details><summary>Technical</summary>
A Datapacks tab beside Mods and Resource packs: install into a chosen world's datapacks folder, with the same checks and 'works in Horizon' notes as the other kinds ([docs/mod-browser.md](docs/mod-browser.md)).
`PalettedContainer` and `PackedStorage` find an entry's word by multiply and shift instead of an integer division ([#1790](https://github.com/horizon-rust/horizon/issues/1790), [#1794](https://github.com/horizon-rust/horizon/issues/1794)); meshing looks up baked models by index, not by name ([#1747](https://github.com/horizon-rust/horizon/issues/1747)). Each change keeps the paletted storage harnesses identical and shows its microbenchmark ([docs/performance/paletted-container-access-results.md](docs/performance/paletted-container-access-results.md)).
</details>
### Servers and multiplayer
#### Prepare a version without Java
Set up Horizon, a server first, without Java on your computer: published tables are combined with your own copy of the game.
<details><summary>Technical</summary>
Today `horizon-prepare` runs Mojang's exporters with Java. Issue [#1750](https://github.com/horizon-rust/horizon/issues/1750) proposes publishing the computed version tables once from CI and assembling the rest locally from the player's own jar and assets, provided the tables hold no Mojang content; a contributor offered a pull request, and it would help phones too. A server-only prepare from `server.jar` alone is the first step ([docs/server.md](docs/server.md)).
</details>
#### Server console, bans and a full admin API
Manage a server from a console, ban and pardon players, and drive it from other tools through a full admin API and an MCP server for AI agents.
@@ -446,86 +220,18 @@ A Windows service wrapper instead of the logon scheduled task; `backups.before_u
</details>
#### Guided hosting with Tailscale and an invite button
#### Playing on servers: everything shows as it should
In the launcher, a guide and a button to host a server and invite friends over Tailscale, so nobody has to open ports on a router.
When you play on a server, other players, mobs, items and menus look and behave as in vanilla: skins, poses, item names, inventories and server networks.
<details><summary>Technical</summary>
On the Servers page (create, start, back up, update and invite are shipped) add a Tailscale setup that explains and checks each step, and an invite button that hands friends what they need to join over the tailnet. Buttons first, no docs needed, with the details in an Advanced section. Remote access over Tailscale is already described in [docs/server.md](docs/server.md).
More: [docs/hosting.md](docs/hosting.md)
</details>
#### An official Docker image for servers
An official image to run a Horizon dedicated server in Docker with one command, your own copy of the game files mounted in.
<details><summary>Technical</summary>
The repository has the Dockerfile (`packaging/server/Dockerfile`: a multi-stage build on Debian bookworm-slim, user `horizon`, volume `/data`, port 25565, game files mounted read-only, `HORIZON_EULA` and friends answering the first-start questions), and anyone can build it ([docs/hosting.md](docs/hosting.md), section 9). Left: publish the image to a public registry from the release workflow, tagged per release, so it follows the same checks, and update the docs and the launcher. The image never holds Mojang's files.
More: [docs/server.md](docs/server.md)
</details>
#### A server you install with one command
Install and run a Horizon dedicated server from a terminal with a single command, no launcher needed, for VPSes and headless machines.
<details><summary>Technical</summary>
A player's idea (#horizon-ideas). `horizon-server` already runs headless; left: an install script (Linux/macOS shell, Windows PowerShell) that fetches the signed release, checks it, asks for or points at the player's own game files and starts the server, documented in [docs/hosting.md](docs/hosting.md).
</details>
#### Ready-made setups for hosting panels
Install a Horizon server from the panels hosts use, such as Pterodactyl and AMP, the usual way for each panel.
<details><summary>Technical</summary>
Asked in the suggestion queue for Pterodactyl eggs and AMP templates. Builds on the one-command install ('A server you install with one command') and horizon-server's server.toml ([docs/hosting.md](docs/hosting.md)).
</details>
### Mods and API
#### Mods that read and change the world
Mods that can see and change blocks, read inventories and items, and find and affect entities, all through the server so every player sees the same thing.
<details><summary>Technical</summary>
Hook points for reading blocks, states and biomes and changing them through the server's block log, so clients and the parity harnesses see vanilla's updates; the player's inventory and item stacks with components; entity queries near a position. Batched per tick, never per-block calls in hot paths.
More: [docs/native-mods.md](docs/native-mods.md) · [docs/modpacks.md](docs/modpacks.md)
</details>
#### Machines and blocks with inventories (API 0.4)
Mods that add machines: blocks that tick, hold items and have their own menus. This unlocks the technology mods of big modpacks.
<details><summary>Technical</summary>
Block entities and menus: ticking machines with slots, server menu logic and the menu protocol to clients, energy, item and fluid transfer, data attachments, accessory slots and custom recipe types, with a typed client and server channel and typed config beside them. A draft ([#1189](https://github.com/horizon-rust/horizon/issues/1189)) proves a furnace ported tick for tick and holds the ATM10 code-mods plan; the design is redone through the API policy before it merges ([docs/modpacks.md](docs/modpacks.md), docs/api/stability.md).
Client-of-a-server fixes in `game/remote*.rs` and `horizon-protocol`: mobs' aggressive and item-use flags ([#1802](https://github.com/horizon-rust/horizon/issues/1802), [#1798](https://github.com/horizon-rust/horizon/issues/1798)), ghost items after a remote menu ([#1795](https://github.com/horizon-rust/horizon/issues/1795)), skins ([#1652](https://github.com/horizon-rust/horizon/issues/1652)), cookies and transfer packets for proxy networks ([#1650](https://github.com/horizon-rust/horizon/issues/1650)), firework boosts ([#1671](https://github.com/horizon-rust/horizon/issues/1671)), living-entity attributes and simulation distance ([#1731](https://github.com/horizon-rust/horizon/issues/1731)), styled item names ([#1702](https://github.com/horizon-rust/horizon/issues/1702)), primed TNT between updates ([#1710](https://github.com/horizon-rust/horizon/issues/1710)).
</details>
### Gameplay parity
#### Pistons push players and mobs
Pistons and slime blocks will push players, mobs and items as in vanilla. Today moving blocks pass through them.
<details><summary>Technical</summary>
Port `PistonMovingBlockEntity.moveCollidedEntities` and `moveStuckEntities` (honey), the slime bounce and `Entity.limitPistonMovement` for `MoverType.PISTON`, in single player and for a server's players. Issues [#1496](https://github.com/horizon-rust/horizon/issues/1496), [#1609](https://github.com/horizon-rust/horizon/issues/1609), [#1608](https://github.com/horizon-rust/horizon/issues/1608), [#1704](https://github.com/horizon-rust/horizon/issues/1704), [#1758](https://github.com/horizon-rust/horizon/issues/1758).
</details>
#### One single-player path: remove the in-frame world
Single player will have one way to run, the server thread, so every fix works everywhere and there are fewer bugs. The older in-frame mode goes once everything passes without it.
@@ -536,37 +242,31 @@ Single player will have one way to run, the server thread, so every fix works ev
</details>
#### Villages and combat, exactly as in vanilla
#### Splitscreen
Villages and fights behaving as in vanilla: iron golems defending villages, villagers remembering their homes, mace smashes and knockback.
Play with a friend on the same screen, each with a controller.
Owner: [martipops](https://github.com/martipops)
<details><summary>Technical</summary>
Villages: iron golem village goals, villager memories across saves, villager age, job-site claiming on dedicated servers, flower offers ([#1639](https://github.com/horizon-rust/horizon/issues/1639) is one report). Combat: the mobs' `attack_knockback` attribute, mace smash logic, the hit knockback source on the host, the fire burnout biome tag. Each is checked against vanilla by a harness ([docs/rust-parity.md](docs/rust-parity.md)).
Issue [#2054](https://github.com/horizon-rust/horizon/issues/2054), built by martipops as a series: up to 16 local players on the server thread's multiplayer ([#3541](https://github.com/horizon-rust/horizon/issues/3541)), each controller driving its own player ([#3794](https://github.com/horizon-rust/horizon/issues/3794)), joining with a controller ([#3801](https://github.com/horizon-rust/horizon/issues/3801)), players in windows of their own ([#3825](https://github.com/horizon-rust/horizon/issues/3825)) and the profile page ([#3826](https://github.com/horizon-rust/horizon/issues/3826)).
</details>
#### Choose the audio output device
Pick the speakers or headphones Horizon plays through, in Music & Sounds.
<details><summary>Technical</summary>
An output device list in Music & Sounds, falling back to the system default. [#1035](https://github.com/horizon-rust/horizon/issues/1035) started it: a player on an older Windows computer had no sound at all, and a device choice is how such cases are found and fixed.
More: [#3777](https://github.com/horizon-rust/horizon/issues/3777)
</details>
### Rendering
#### Well-known shader looks, rewritten for Horizon
#### Shader packs, written again from scratch
Popular shader packs rewritten in Horizon's format, so good packs exist from the start. Only packs whose licence is open source and compatible with ours are rewritten, never anything else.
Shader packs were removed and are being rebuilt from scratch. They come back, off by default, once the best-known packs draw properly.
<details><summary>Technical</summary>
The first candidates are Photon, Chocapic13 and MakeUp Ultra Fast, each only after its licence is read and recorded: a pack is rewritten only if it is open source and compatible with Horizon's licence, and the rewrite is written from the shader format's documentation and the look on screen, not from anyone's code. Today only the Golden Hour example exists (`[crates/horizon-shader-api/packs/golden_hour](crates/horizon-shader-api/packs/golden_hour)`).
A clean-room rewrite in `horizon-shaderpack`, from the packs' public format documentation only: reading packs, the preprocessor and `.properties` files are merged ([#1584](https://github.com/horizon-rust/horizon/issues/1584), [#1585](https://github.com/horizon-rust/horizon/issues/1585), [#1586](https://github.com/horizon-rust/horizon/issues/1586)); translating programs to WGSL through naga, buffers, uniforms and the terrain, entity, sky, weather and hand stages are in review ([#1698](https://github.com/horizon-rust/horizon/issues/1698) to [#1801](https://github.com/horizon-rust/horizon/issues/1801)).
More: [docs/shaders.md](docs/shaders.md)
More: [#1561](https://github.com/horizon-rust/horizon/issues/1561) · [#1771](https://github.com/horizon-rust/horizon/issues/1771) · [#1786](https://github.com/horizon-rust/horizon/issues/1786)
</details>
@@ -576,101 +276,13 @@ More: [docs/shaders.md](docs/shaders.md)
A redesigned launcher: every button where you expect it, the fewest clicks to play, add an instance or add mods, smooth animations, and instant startup.
Owner: [ludovic111](https://github.com/ludovic111)
<details><summary>Technical</summary>
Based on the site concept by the Team (Bricolage Grotesque, a night sky and an animated pixel sunset). Screens and flows are designed first, then small PRs; tokens in [crates/horizon-theme](crates/horizon-theme) and rules in [docs/design-system.md](docs/design-system.md), the game's vanilla look unchanged. Measured before and after: startup, frame time and idle CPU/GPU, reduced motion honoured. Mods screens separate Horizon native mods from Java mods; a running instance can be stopped from the launcher.
</details>
#### A visible beta label
A clear 'beta' label in the launcher and the game, so nobody is surprised that Horizon is still being built.
<details><summary>Technical</summary>
Show Beta in the launcher's header and about page, on the game's title screen and on the website, beside the version number ([docs/versioning.md](docs/versioning.md)).
</details>
### Platforms
#### Horizon on Intel Macs
Builds of the game and the launcher for Macs with an Intel processor.
<details><summary>Technical</summary>
x86_64 game and launcher builds, picked per processor ([#1171](https://github.com/horizon-rust/horizon/issues/1171)). Before it ships it needs a run on a real Intel Mac: Rosetta on Apple Silicon only proves the CPU code. Requests: [#1161](https://github.com/horizon-rust/horizon/issues/1161), [#1792](https://github.com/horizon-rust/horizon/issues/1792).
</details>
#### Wayland and Hyprland polish
Fixes for Linux desktops that use Wayland and Hyprland: fullscreen, the clipboard, the GUI scale after a restart and the like.
<details><summary>Technical</summary>
Open: the GUI Scale is clamped to 2 after a restart under Hyprland, while XWayland keeps it ([#1866](https://github.com/horizon-rust/horizon/issues/1866)). Earlier reports covered fullscreen dropping on menu clicks, the clipboard and a white overlay ([#1007](https://github.com/horizon-rust/horizon/issues/1007), closed). Checked on a real Wayland session.
</details>
### AI and agents
#### A versioned agent-control protocol
A stable, documented way for AI agents and scripts to drive the game and the launcher, with versions and an event stream so tools keep working as Horizon changes.
<details><summary>Technical</summary>
`hello` answers with the API version, the release and the features; `horizon-ctl --api`; MCP serverInfo; reply schemas generated into the reference; a `test.` namespace for hooks; transcript compatibility tests; `subscribe` as a stream of events on one connection; Horizon option files by key and saved-server commands. Agent control itself (`horizon-ctl`, both MCP servers, the No AI switch) is done: [docs/agent-control.md](docs/agent-control.md), [docs/api/agent-control.md](docs/api/agent-control.md).
More: [docs/api/stability.md](docs/api/stability.md)
</details>
#### Port mods with your own AI agent
You can port a mod to Horizon with your own AI agent on your own computer. Horizon documents how and gives you the tools, but provides no hosted agents.
<details><summary>Technical</summary>
A player runs their own agent locally with `horizon-ctl` and the MCP servers ([docs/agent-control.md](docs/agent-control.md)), `cargo horizon-mod`, `horizon-port-pack` and the parity harnesses as the check. Horizon writes the guide (playhorizon.dev/docs/agents, [docs/modpacks.md](docs/modpacks.md)) and runs no agents, accounts or keys for players. The No AI switch turns all of it off.
More: [https://playhorizon.dev/docs/agents](https://playhorizon.dev/docs/agents)
</details>
### Community
#### Horizon on X (coming soon)
Coming soon: an official Horizon account on X, used to announce releases and the weekly summary.
<details><summary>Technical</summary>
Kira already posts the roadmap items marked `announce = true` when they ship, and the weekly summary, as soon as the account's credentials exist ([roadmap/README.md](roadmap/README.md), 'The journal'). Waiting for the account itself.
</details>
### Code and tools
#### Pipeline protection and a GitHub App for agents
Safer releases: changes to the release machinery need the owner's review, and AI agents use a smaller identity of their own instead of a personal account.
<details><summary>Technical</summary>
`.github/CODEOWNERS` lists the workflows, the release and signing tools, the update-checking code and the installers ([#1015](https://github.com/horizon-rust/horizon/issues/1015)); the branch protection that makes it count waits for the `horizon-agent` GitHub App, which reads and writes code, pull requests and issues but can't change settings, workflows, secrets or members, and whose tokens last an hour. It needs the owner to create the App and switch the protection on ([docs/security.md](docs/security.md)).
</details>
#### Signed updates everywhere, installers included
Every download Horizon makes is checked against the project's signature, including the first install with install.sh and install.ps1.
<details><summary>Technical</summary>
Releases carry signed checksums, and the launcher and `horizon-server update` refuse anything unsigned ([#1011](https://github.com/horizon-rust/horizon/issues/1011), [#1014](https://github.com/horizon-rust/horizon/issues/1014)). Left: the first install through `install.sh` and `install.ps1` still trusts only the checksum GitHub publishes, because verifying Ed25519 signatures needs OpenSSL 3, which macOS doesn't ship ([docs/security.md](docs/security.md)).
More: [#3635](https://github.com/horizon-rust/horizon/issues/3635) · [#3636](https://github.com/horizon-rust/horizon/issues/3636) · [#3637](https://github.com/horizon-rust/horizon/issues/3637)
</details>
@@ -688,6 +300,18 @@ Keep a 3D mip store on disk built from real chunks, drawn by the GPU beside the
</details>
#### A batch of targeted optimisations
Several smaller speed-ups, each measured: idle block entities that sleep, cheaper particles, faster structure placement, high-rate mice and very large texture packs.
<details><summary>Technical</summary>
Block entities and hoppers that skip work when idle (the hopper item scan is already skipped when no item is near); a particle frustum test first (also a parity gap), then particle quads built on the GPU; jigsaw shortcuts that skip blocked connectors and pool elements known not to fit, with layouts unchanged (trial chambers and ancient cities first); batched raw mouse input on Windows for 8000 Hz mice; a better atlas packer for huge resource packs (a 64x pack builds a 704 MiB atlas for 452 MiB of textures); sign-heavy scenes ([#1461](https://github.com/horizon-rust/horizon/issues/1461)). Profile first, keep what pays, show before and after numbers.
More: [docs/profiling.md](docs/profiling.md)
</details>
### Modpacks
#### Older Fabric and Forge mods
@@ -730,6 +354,46 @@ Earlier research ([#527](https://github.com/horizon-rust/horizon/issues/527), cl
</details>
#### CurseForge in the mod browser (coming soon)
Coming soon: search and download CurseForge mods and modpacks from the launcher, as you can with Modrinth today.
<details><summary>Technical</summary>
The mod browser lists CurseForge as coming soon ([docs/mod-browser.md](docs/mod-browser.md)): it needs CurseForge API access. Meanwhile packs import from a CurseForge zip or instance folder, and jars only CurseForge hosts come from the player's own instance ([docs/modpacks.md](docs/modpacks.md)).
</details>
#### A Convert to Rust button for what isn't rewritten yet
When a mod, shader pack or modpack hasn't been rewritten for Horizon, one button converts what can be converted, so you don't have to wait for the rewrite.
<details><summary>Technical</summary>
Generalise the mod browser's Port step (`horizon-port-pack`: the data, assets, worldgen and block and item definitions of Java mods, .mrpack and CurseForge zips) to every kind of content: code mods where the API allows it (see 'Mods with code') and shader packs, converting an Iris/OptiFine pack into Horizon's format. The browser says what works before the install. Java code itself does not run ([docs/modpacks.md](docs/modpacks.md), [docs/mod-browser.md](docs/mod-browser.md)).
</details>
#### Mods with code: machines, magic, mobs
Making mods that add machines, magic and mobs work in Horizon, in steps, starting with machines. They are most of what a big modpack is made of.
<details><summary>Technical</summary>
333 of the 496 mods in a big pack (ATM10) contain code. Issue [#1854](https://github.com/horizon-rust/horizon/issues/1854) ranks the API gaps (ticking block entities, networking, rendering, menus, inventories) that unlock them in tiers, with machines first as the recommended path and a furnace ported tick for tick as proof. The first API step is a draft ([#1189](https://github.com/horizon-rust/horizon/issues/1189), see 'Machines and blocks with inventories'). The path isn't started yet ([docs/modpacks.md](docs/modpacks.md)).
</details>
#### Data packs in the mod browser
Search and install data packs from the launcher's mod browser, for the world you choose, as you do mods and resource packs.
<details><summary>Technical</summary>
A Datapacks tab beside Mods and Resource packs: install into a chosen world's datapacks folder, with the same checks and 'works in Horizon' notes as the other kinds ([docs/mod-browser.md](docs/mod-browser.md)).
</details>
### Servers and multiplayer
#### Ely.by and other sign-in providers
@@ -764,6 +428,60 @@ Play with Friends joins over an encrypted tunnel with an invite code ([docs/play
</details>
#### Prepare a version without Java
Set up Horizon, a server first, without Java on your computer: published tables are combined with your own copy of the game.
<details><summary>Technical</summary>
Today `horizon-prepare` runs Mojang's exporters with Java. Issue [#1750](https://github.com/horizon-rust/horizon/issues/1750) proposes publishing the computed version tables once from CI and assembling the rest locally from the player's own jar and assets, provided the tables hold no Mojang content; a contributor offered a pull request, and it would help phones too. A server-only prepare from `server.jar` alone is the first step ([docs/server.md](docs/server.md)).
</details>
#### Guided hosting with Tailscale and an invite button
In the launcher, a guide and a button to host a server and invite friends over Tailscale, so nobody has to open ports on a router.
<details><summary>Technical</summary>
On the Servers page (create, start, back up, update and invite are shipped) add a Tailscale setup that explains and checks each step, and an invite button that hands friends what they need to join over the tailnet. Buttons first, no docs needed, with the details in an Advanced section. Remote access over Tailscale is already described in [docs/server.md](docs/server.md).
More: [docs/hosting.md](docs/hosting.md)
</details>
#### An official Docker image for servers
An official image to run a Horizon dedicated server in Docker with one command, your own copy of the game files mounted in.
<details><summary>Technical</summary>
The repository has the Dockerfile (`packaging/server/Dockerfile`: a multi-stage build on Debian bookworm-slim, user `horizon`, volume `/data`, port 25565, game files mounted read-only, `HORIZON_EULA` and friends answering the first-start questions), and anyone can build it ([docs/hosting.md](docs/hosting.md), section 9). Left: publish the image to a public registry from the release workflow, tagged per release, so it follows the same checks, and update the docs and the launcher. The image never holds Mojang's files.
More: [docs/server.md](docs/server.md)
</details>
#### A server you install with one command
Install and run a Horizon dedicated server from a terminal with a single command, no launcher needed, for VPSes and headless machines.
<details><summary>Technical</summary>
A player's idea (#horizon-ideas). `horizon-server` already runs headless; left: an install script (Linux/macOS shell, Windows PowerShell) that fetches the signed release, checks it, asks for or points at the player's own game files and starts the server, documented in [docs/hosting.md](docs/hosting.md).
</details>
#### Ready-made setups for hosting panels
Install a Horizon server from the panels hosts use, such as Pterodactyl and AMP, the usual way for each panel.
<details><summary>Technical</summary>
Asked in the suggestion queue for Pterodactyl eggs and AMP templates. Builds on the one-command install ('A server you install with one command') and horizon-server's server.toml ([docs/hosting.md](docs/hosting.md)).
</details>
### Mods and API
#### Scripts for mods
@@ -842,16 +560,6 @@ The game side is merged ([docs/controllers.md](docs/controllers.md)): pads read
</details>
#### Splitscreen
Play with a friend on the same screen, each with a controller.
<details><summary>Technical</summary>
Issue [#2054](https://github.com/horizon-rust/horizon/issues/2054); a contributor is working on it. Two local players on the same world, each with their own camera and HUD, built on the server thread's multiplayer.
</details>
#### The full debug screen
Vanilla's complete F3 screen: debug options, a profiler chart, ten-second captures and an atlas dump.
@@ -862,6 +570,46 @@ Today F3 shows position, chunk, light, biome and FPS ([docs/native-client.md](do
</details>
#### Pistons push players and mobs
Pistons and slime blocks will push players, mobs and items as in vanilla. Today moving blocks pass through them.
<details><summary>Technical</summary>
Port `PistonMovingBlockEntity.moveCollidedEntities` and `moveStuckEntities` (honey), the slime bounce and `Entity.limitPistonMovement` for `MoverType.PISTON`, in single player and for a server's players. Issues [#1496](https://github.com/horizon-rust/horizon/issues/1496), [#1609](https://github.com/horizon-rust/horizon/issues/1609), [#1608](https://github.com/horizon-rust/horizon/issues/1608), [#1704](https://github.com/horizon-rust/horizon/issues/1704), [#1758](https://github.com/horizon-rust/horizon/issues/1758).
</details>
#### Villages and combat, exactly as in vanilla
Villages and fights behaving as in vanilla: iron golems defending villages, villagers remembering their homes, mace smashes and knockback.
<details><summary>Technical</summary>
Villages: iron golem village goals, villager memories across saves, villager age, job-site claiming on dedicated servers, flower offers ([#1639](https://github.com/horizon-rust/horizon/issues/1639) is one report). Combat: the mobs' `attack_knockback` attribute, mace smash logic, the hit knockback source on the host, the fire burnout biome tag. Each is checked against vanilla by a harness ([docs/rust-parity.md](docs/rust-parity.md)).
</details>
#### Choose the audio output device
Pick the speakers or headphones Horizon plays through, in Music & Sounds.
<details><summary>Technical</summary>
An output device list in Music & Sounds, falling back to the system default. [#1035](https://github.com/horizon-rust/horizon/issues/1035) started it: a player on an older Windows computer had no sound at all, and a device choice is how such cases are found and fixed.
</details>
#### Play in your language
Item names, container titles and server messages follow the language you pick, as in vanilla. Today parts of the game stay in English.
<details><summary>Technical</summary>
Translatable components resolved through the selected language for items, containers and server text ([#1806](https://github.com/horizon-rust/horizon/issues/1806)), on top of the bundled languages the prepared resources carry ([#1594](https://github.com/horizon-rust/horizon/issues/1594)). Left: the client's own screens ([#1672](https://github.com/horizon-rust/horizon/issues/1672)) and the main menu with zh_cn.
</details>
### Rendering
#### A light default shader pack
@@ -906,6 +654,18 @@ Not implemented ([docs/graphics.md](docs/graphics.md)). Anisotropic filtering ne
</details>
#### Well-known shader looks, rewritten for Horizon
Popular shader packs rewritten in Horizon's format, so good packs exist from the start. Only packs whose licence is open source and compatible with ours are rewritten, never anything else.
<details><summary>Technical</summary>
The first candidates are Photon, Chocapic13 and MakeUp Ultra Fast, each only after its licence is read and recorded: a pack is rewritten only if it is open source and compatible with Horizon's licence, and the rewrite is written from the shader format's documentation and the look on screen, not from anyone's code. Today only the Golden Hour example exists (`[crates/horizon-shader-api/packs/golden_hour](crates/horizon-shader-api/packs/golden_hour)`).
More: [docs/shaders.md](docs/shaders.md)
</details>
### Launcher
#### Lean installs that use less disk
@@ -918,6 +678,16 @@ A shared resource store keyed by the preparer's digest, no prepare intermediates
</details>
#### A visible beta label
A clear 'beta' label in the launcher and the game, so nobody is surprised that Horizon is still being built.
<details><summary>Technical</summary>
Show Beta in the launcher's header and about page, on the game's title screen and on the website, beside the version number ([docs/versioning.md](docs/versioning.md)).
</details>
### Platforms
#### Horizon on Linux ARM64
@@ -952,6 +722,26 @@ No official build. Android was first closed as not planned, with touch controls,
</details>
#### Horizon on Intel Macs
Builds of the game and the launcher for Macs with an Intel processor.
<details><summary>Technical</summary>
x86_64 game and launcher builds, picked per processor ([#1171](https://github.com/horizon-rust/horizon/issues/1171)). Before it ships it needs a run on a real Intel Mac: Rosetta on Apple Silicon only proves the CPU code. Requests: [#1161](https://github.com/horizon-rust/horizon/issues/1161), [#1792](https://github.com/horizon-rust/horizon/issues/1792).
</details>
#### Wayland and Hyprland polish
Fixes for Linux desktops that use Wayland and Hyprland: fullscreen, the clipboard, the GUI scale after a restart and the like.
<details><summary>Technical</summary>
Open: the GUI Scale is clamped to 2 after a restart under Hyprland, while XWayland keeps it ([#1866](https://github.com/horizon-rust/horizon/issues/1866)). Earlier reports covered fullscreen dropping on menu clicks, the clipboard and a white overlay ([#1007](https://github.com/horizon-rust/horizon/issues/1007), closed). Checked on a real Wayland session.
</details>
#### AppImage and a Nix flake
Install Horizon on any Linux distribution as an AppImage, and on NixOS through a Nix flake.
@@ -996,6 +786,30 @@ Draft pull requests: the companion ([#1068](https://github.com/horizon-rust/hori
</details>
#### A versioned agent-control protocol
A stable, documented way for AI agents and scripts to drive the game and the launcher, with versions and an event stream so tools keep working as Horizon changes.
<details><summary>Technical</summary>
`hello` answers with the API version, the release and the features; `horizon-ctl --api`; MCP serverInfo; reply schemas generated into the reference; a `test.` namespace for hooks; transcript compatibility tests; `subscribe` as a stream of events on one connection; Horizon option files by key and saved-server commands. Agent control itself (`horizon-ctl`, both MCP servers, the No AI switch) is done: [docs/agent-control.md](docs/agent-control.md), [docs/api/agent-control.md](docs/api/agent-control.md).
More: [docs/api/stability.md](docs/api/stability.md)
</details>
#### Port mods with your own AI agent
You can port a mod to Horizon with your own AI agent on your own computer. Horizon documents how and gives you the tools, but provides no hosted agents.
<details><summary>Technical</summary>
A player runs their own agent locally with `horizon-ctl` and the MCP servers ([docs/agent-control.md](docs/agent-control.md)), `cargo horizon-mod`, `horizon-port-pack` and the parity harnesses as the check. Horizon writes the guide (playhorizon.dev/docs/agents, [docs/modpacks.md](docs/modpacks.md)) and runs no agents, accounts or keys for players. The No AI switch turns all of it off.
More: [https://playhorizon.dev/docs/agents](https://playhorizon.dev/docs/agents)
</details>
### Community
#### A video series about Horizon
@@ -1008,6 +822,38 @@ Kira already shows new uploads in a read-only Discord channel (done). The series
</details>
#### Horizon on X (coming soon)
Coming soon: an official Horizon account on X, used to announce releases and the weekly summary.
<details><summary>Technical</summary>
Kira already posts the roadmap items marked `announce = true` when they ship, and the weekly summary, as soon as the account's credentials exist ([roadmap/README.md](roadmap/README.md), 'The journal'). Waiting for the account itself.
</details>
### Code and tools
#### Pipeline protection and a GitHub App for agents
Safer releases: changes to the release machinery need the owner's review, and AI agents use a smaller identity of their own instead of a personal account.
<details><summary>Technical</summary>
`.github/CODEOWNERS` lists the workflows, the release and signing tools, the update-checking code and the installers ([#1015](https://github.com/horizon-rust/horizon/issues/1015)); the branch protection that makes it count waits for the `horizon-agent` GitHub App, which reads and writes code, pull requests and issues but can't change settings, workflows, secrets or members, and whose tokens last an hour. It needs the owner to create the App and switch the protection on ([docs/security.md](docs/security.md)).
</details>
#### Signed updates everywhere, installers included
Every download Horizon makes is checked against the project's signature, including the first install with install.sh and install.ps1.
<details><summary>Technical</summary>
Releases carry signed checksums, and the launcher and `horizon-server update` refuse anything unsigned ([#1011](https://github.com/horizon-rust/horizon/issues/1011), [#1014](https://github.com/horizon-rust/horizon/issues/1014)). Left: the first install through `install.sh` and `install.ps1` still trusts only the checksum GitHub publishes, because verifying Ed25519 signatures needs OpenSSL 3, which macOS doesn't ship ([docs/security.md](docs/security.md)).
</details>
## Help wanted
### Performance
@@ -1048,6 +894,15 @@ Armour and tools in End city chests should come enchanted, as in vanilla.
## Recently shipped
- **Chickens flap while they fall** (v0.2.67): Chickens should flap their wings while they fall, not stand still in the air. [#1706](https://github.com/horizon-rust/horizon/issues/1706) · [#1892](https://github.com/horizon-rust/horizon/issues/1892)
- **Ctrl+Backspace and scroll bars in menus** (v0.2.67): Ctrl+Backspace should delete a whole word in the creative search, and every list's scroll bar should be draggable. [#1764](https://github.com/horizon-rust/horizon/issues/1764) · [#1782](https://github.com/horizon-rust/horizon/issues/1782) · [#1884](https://github.com/horizon-rust/horizon/issues/1884) · [#1931](https://github.com/horizon-rust/horizon/issues/1931)
- **A pointing hand over buttons** (v0.2.67): The mouse pointer should turn into a pointing hand over menu buttons, as in vanilla 26.3. [#1347](https://github.com/horizon-rust/horizon/issues/1347) · [#1897](https://github.com/horizon-rust/horizon/issues/1897)
- **Chat settings and team prefixes** (v0.2.67): The Hidden and Commands Only chat settings should hide chat, and team prefixes should appear before names in chat. [#1376](https://github.com/horizon-rust/horizon/issues/1376) · [#1378](https://github.com/horizon-rust/horizon/issues/1378) · [#1932](https://github.com/horizon-rust/horizon/issues/1932) · [#1841](https://github.com/horizon-rust/horizon/issues/1841)
- **Cooldowns drawn on items** (v0.2.67): After you throw an ender pearl, its slot should show the cooldown sweeping away, as in vanilla. [#1763](https://github.com/horizon-rust/horizon/issues/1763) · [#1933](https://github.com/horizon-rust/horizon/issues/1933)
- **Pre-generate an area with /pregen** (v0.2.67): A /pregen command that builds an area of the world ahead of time, so players don't wait for new terrain when they explore. [#1805](https://github.com/horizon-rust/horizon/issues/1805)
- **Horizon's own shader format and kit** (v0.2.70): Horizon's own way to make shader packs, with tools to write and test them. Every pack is checked before it runs, and the registry will list them. [#1820](https://github.com/horizon-rust/horizon/issues/1820) · [#1822](https://github.com/horizon-rust/horizon/issues/1822) · [#1861](https://github.com/horizon-rust/horizon/issues/1861) · [#1863](https://github.com/horizon-rust/horizon/issues/1863) · [#1855](https://github.com/horizon-rust/horizon/issues/1855) · [docs/shaders.md](docs/shaders.md) · [docs/api/shaders.md](docs/api/shaders.md)
- **Discord Rich Presence** (v0.2.58): Your Discord profile can show that you are playing Horizon, with a switch to turn it off. [#1787](https://github.com/horizon-rust/horizon/issues/1787) · [#1788](https://github.com/horizon-rust/horizon/issues/1788)
- **Server dialogs and login screens** (v0.2.67): Servers that show dialogs, such as the register and login screens of AuthMe, will work in Horizon. [#1720](https://github.com/horizon-rust/horizon/issues/1720) · [#1734](https://github.com/horizon-rust/horizon/issues/1734) · [#1735](https://github.com/horizon-rust/horizon/issues/1735)
- **Microsoft sign-in** (launcher@0.2.17): Sign in with your Microsoft account in the launcher to play on online-mode servers. [docs/microsoft-account.md](docs/microsoft-account.md) · [#2879](https://github.com/horizon-rust/horizon/issues/2879)
- **OpenGL for old computers** (v0.2.63): Horizon now starts on old PCs whose graphics card has no Vulkan or DirectX 12 driver: it switches to OpenGL by itself, and Options > Graphics can pick OpenGL by hand. [#1917](https://github.com/horizon-rust/horizon/issues/1917) · [#1920](https://github.com/horizon-rust/horizon/issues/1920) · [#1936](https://github.com/horizon-rust/horizon/issues/1936)
- **Server resource packs** (v0.2.48): Servers can send you their resource pack: Horizon asks, downloads it and applies it, like vanilla. [#1562](https://github.com/horizon-rust/horizon/issues/1562) · [#1569](https://github.com/horizon-rust/horizon/issues/1569) · [#1570](https://github.com/horizon-rust/horizon/issues/1570)
@@ -0,0 +1,773 @@
{
"format": "horizon-benchmark-report",
"version": 1,
"kind": "game",
"created": "2026-10-29T01:13:01Z",
"app": {
"name": "horizon-client",
"version": "v0.2.97",
"commit": null
},
"hardware": {
"os": "windows",
"arch": "x86_64",
"cpu_threads": 16,
"os_version": "Microsoft Windows [Version 10.0.26200.8037]",
"cpu": "AMD Ryzen 7 9800X3D 8-Core Processor",
"ram_mb": 95943,
"gpu": {
"name": "NVIDIA GeForce RTX 5080",
"vendor": "nvidia",
"driver": "NVIDIA 617.42",
"backend": "vulkan",
"kind": "discrete"
}
},
"conditions": {
"scenario": "perf_bench:village,forest,ocean,mountain",
"seed": "1234",
"render_distance": 16,
"simulation_distance": 12,
"vsync": false,
"offscreen": false,
"repeats": 3,
"warmup_s": 2.0,
"window": [
1280,
720
]
},
"metrics": {
"startup.title_ms": {
"n": 3,
"mean": 3158.208,
"stdev": 215.77,
"min": 3023.046,
"max": 3407.049,
"ci95": 536.046
},
"startup.world_ms": {
"n": 3,
"mean": 7170.164,
"stdev": 557.958,
"min": 6703.242,
"max": 7788.08,
"ci95": 1386.157
},
"chunks.village.load_ms": {
"n": 3,
"mean": 1694.647,
"stdev": 41.285,
"min": 1653.122,
"max": 1735.687,
"ci95": 102.566
},
"chunks.village.meshed_count": {
"n": 3,
"mean": 23544.0,
"stdev": 47.127,
"min": 23495.0,
"max": 23589.0,
"ci95": 117.081
},
"fps.village.avg_fps": {
"n": 3,
"mean": 1711.652,
"stdev": 77.412,
"min": 1623.403,
"max": 1768.093,
"ci95": 192.317
},
"fps.village.low_1pct_fps": {
"n": 3,
"mean": 593.407,
"stdev": 32.279,
"min": 571.485,
"max": 630.473,
"ci95": 80.191
},
"fps.village.low_01pct_fps": {
"n": 3,
"mean": 358.473,
"stdev": 27.832,
"min": 326.35,
"max": 375.353,
"ci95": 69.143
},
"memory.village.gpu_mb": {
"n": 3,
"mean": 631.813,
"stdev": 8.381,
"min": 626.803,
"max": 641.489,
"ci95": 20.822
},
"chunks.forest.load_ms": {
"n": 3,
"mean": 2521.363,
"stdev": 291.624,
"min": 2336.881,
"max": 2857.57,
"ci95": 724.493
},
"chunks.forest.meshed_count": {
"n": 3,
"mean": 27346.667,
"stdev": 57.934,
"min": 27306.0,
"max": 27413.0,
"ci95": 143.927
},
"fps.forest.avg_fps": {
"n": 3,
"mean": 2423.783,
"stdev": 55.746,
"min": 2386.353,
"max": 2487.851,
"ci95": 138.493
},
"fps.forest.low_1pct_fps": {
"n": 3,
"mean": 709.165,
"stdev": 66.309,
"min": 638.666,
"max": 770.286,
"ci95": 164.734
},
"fps.forest.low_01pct_fps": {
"n": 3,
"mean": 401.039,
"stdev": 40.413,
"min": 354.472,
"max": 426.929,
"ci95": 100.399
},
"memory.forest.gpu_mb": {
"n": 3,
"mean": 705.008,
"stdev": 6.062,
"min": 698.466,
"max": 710.435,
"ci95": 15.06
},
"chunks.ocean.load_ms": {
"n": 3,
"mean": 2094.909,
"stdev": 521.225,
"min": 1758.024,
"max": 2695.274,
"ci95": 1294.9
},
"chunks.ocean.meshed_count": {
"n": 3,
"mean": 24312.667,
"stdev": 261.934,
"min": 24116.0,
"max": 24610.0,
"ci95": 650.732
},
"fps.ocean.avg_fps": {
"n": 3,
"mean": 2201.125,
"stdev": 73.595,
"min": 2153.6,
"max": 2285.897,
"ci95": 182.834
},
"fps.ocean.low_1pct_fps": {
"n": 3,
"mean": 553.968,
"stdev": 100.955,
"min": 445.599,
"max": 645.355,
"ci95": 250.806
},
"fps.ocean.low_01pct_fps": {
"n": 3,
"mean": 325.078,
"stdev": 131.154,
"min": 177.4,
"max": 427.981,
"ci95": 325.831
},
"memory.ocean.gpu_mb": {
"n": 3,
"mean": 652.944,
"stdev": 5.177,
"min": 647.51,
"max": 657.818,
"ci95": 12.862
},
"chunks.mountain.load_ms": {
"n": 3,
"mean": 3304.58,
"stdev": 134.041,
"min": 3155.996,
"max": 3416.409,
"ci95": 333.003
},
"chunks.mountain.meshed_count": {
"n": 3,
"mean": 26591.0,
"stdev": 57.472,
"min": 26525.0,
"max": 26630.0,
"ci95": 142.779
},
"fps.mountain.avg_fps": {
"n": 3,
"mean": 1961.086,
"stdev": 39.725,
"min": 1921.241,
"max": 2000.69,
"ci95": 98.69
},
"fps.mountain.low_1pct_fps": {
"n": 3,
"mean": 536.973,
"stdev": 38.828,
"min": 495.61,
"max": 572.635,
"ci95": 96.461
},
"fps.mountain.low_01pct_fps": {
"n": 3,
"mean": 296.586,
"stdev": 30.236,
"min": 275.859,
"max": 331.281,
"ci95": 75.118
},
"memory.mountain.gpu_mb": {
"n": 3,
"mean": 896.941,
"stdev": 5.177,
"min": 891.506,
"max": 901.814,
"ci95": 12.862
},
"memory.rss_peak_mb": {
"n": 3,
"mean": 1458.578,
"stdev": 3.229,
"min": 1455.32,
"max": 1461.777,
"ci95": 8.022
},
"memory.rss_steady_mb": {
"n": 3,
"mean": 1236.28,
"stdev": 4.869,
"min": 1230.701,
"max": 1239.672,
"ci95": 12.096
}
},
"frames": [
{
"phase": "village",
"repeat": 0,
"frames": 21218,
"seconds": 12.0,
"avg_fps": 1768.093,
"low_1pct_fps": 630.473,
"low_01pct_fps": 375.353,
"p50_ms": 0.537,
"p95_ms": 0.846,
"p99_ms": 1.222,
"p999_ms": 2.081,
"max_ms": 6.256,
"cpu_p50_ms": 0.38,
"gpu_p50_ms": 0.104,
"histogram": [
20695,
499,
22,
1,
0,
0,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "village",
"repeat": 1,
"frames": 19482,
"seconds": 12.001,
"avg_fps": 1623.403,
"low_1pct_fps": 578.262,
"low_01pct_fps": 373.717,
"p50_ms": 0.586,
"p95_ms": 0.95,
"p99_ms": 1.357,
"p999_ms": 2.158,
"max_ms": 7.35,
"cpu_p50_ms": 0.445,
"gpu_p50_ms": 0.104,
"histogram": [
18729,
713,
38,
1,
0,
0,
0,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "village",
"repeat": 2,
"frames": 20922,
"seconds": 12.0,
"avg_fps": 1743.46,
"low_1pct_fps": 571.485,
"low_01pct_fps": 326.35,
"p50_ms": 0.526,
"p95_ms": 0.883,
"p99_ms": 1.313,
"p999_ms": 2.14,
"max_ms": 12.027,
"cpu_p50_ms": 0.379,
"gpu_p50_ms": 0.104,
"histogram": [
20308,
582,
28,
2,
1,
0,
0,
0,
0,
0,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "forest",
"repeat": 0,
"frames": 29855,
"seconds": 12.0,
"avg_fps": 2487.851,
"low_1pct_fps": 770.286,
"low_01pct_fps": 421.717,
"p50_ms": 0.365,
"p95_ms": 0.619,
"p99_ms": 0.948,
"p999_ms": 1.717,
"max_ms": 9.446,
"cpu_p50_ms": 0.232,
"gpu_p50_ms": 0.157,
"histogram": [
29607,
233,
13,
0,
0,
1,
0,
0,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "forest",
"repeat": 1,
"frames": 28766,
"seconds": 12.0,
"avg_fps": 2397.145,
"low_1pct_fps": 718.542,
"low_01pct_fps": 426.929,
"p50_ms": 0.374,
"p95_ms": 0.662,
"p99_ms": 1.016,
"p999_ms": 1.858,
"max_ms": 6.379,
"cpu_p50_ms": 0.239,
"gpu_p50_ms": 0.157,
"histogram": [
28457,
293,
14,
0,
0,
1,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "forest",
"repeat": 2,
"frames": 28637,
"seconds": 12.0,
"avg_fps": 2386.353,
"low_1pct_fps": 638.666,
"low_01pct_fps": 354.472,
"p50_ms": 0.373,
"p95_ms": 0.704,
"p99_ms": 1.159,
"p999_ms": 1.952,
"max_ms": 14.388,
"cpu_p50_ms": 0.233,
"gpu_p50_ms": 0.157,
"histogram": [
28198,
415,
18,
5,
0,
0,
0,
0,
0,
0,
0,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 0,
"frames": 25968,
"seconds": 12.001,
"avg_fps": 2163.877,
"low_1pct_fps": 570.949,
"low_01pct_fps": 369.854,
"p50_ms": 0.408,
"p95_ms": 0.767,
"p99_ms": 1.339,
"p999_ms": 2.38,
"max_ms": 3.329,
"cpu_p50_ms": 0.271,
"gpu_p50_ms": 0.141,
"histogram": [
25383,
534,
47,
4,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 1,
"frames": 25843,
"seconds": 12.0,
"avg_fps": 2153.6,
"low_1pct_fps": 445.599,
"low_01pct_fps": 177.4,
"p50_ms": 0.413,
"p95_ms": 0.725,
"p99_ms": 1.309,
"p999_ms": 3.451,
"max_ms": 12.071,
"cpu_p50_ms": 0.275,
"gpu_p50_ms": 0.142,
"histogram": [
25375,
373,
56,
22,
1,
7,
3,
5,
0,
0,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 2,
"frames": 27431,
"seconds": 12.0,
"avg_fps": 2285.897,
"low_1pct_fps": 645.355,
"low_01pct_fps": 427.981,
"p50_ms": 0.388,
"p95_ms": 0.693,
"p99_ms": 1.163,
"p999_ms": 2.041,
"max_ms": 3.068,
"cpu_p50_ms": 0.254,
"gpu_p50_ms": 0.141,
"histogram": [
27036,
363,
31,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "mountain",
"repeat": 0,
"frames": 23536,
"seconds": 12.0,
"avg_fps": 1961.326,
"low_1pct_fps": 542.675,
"low_01pct_fps": 331.281,
"p50_ms": 0.436,
"p95_ms": 0.856,
"p99_ms": 1.425,
"p999_ms": 2.22,
"max_ms": 9.458,
"cpu_p50_ms": 0.296,
"gpu_p50_ms": 0.14,
"histogram": [
22819,
671,
40,
5,
0,
0,
0,
0,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "mountain",
"repeat": 1,
"frames": 23056,
"seconds": 12.001,
"avg_fps": 1921.241,
"low_1pct_fps": 495.61,
"low_01pct_fps": 282.617,
"p50_ms": 0.44,
"p95_ms": 0.911,
"p99_ms": 1.516,
"p999_ms": 2.543,
"max_ms": 14.342,
"cpu_p50_ms": 0.299,
"gpu_p50_ms": 0.141,
"histogram": [
22198,
783,
65,
7,
0,
2,
0,
0,
0,
0,
0,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "mountain",
"repeat": 2,
"frames": 24009,
"seconds": 12.0,
"avg_fps": 2000.69,
"low_1pct_fps": 572.635,
"low_01pct_fps": 275.859,
"p50_ms": 0.436,
"p95_ms": 0.819,
"p99_ms": 1.274,
"p999_ms": 2.085,
"max_ms": 14.003,
"cpu_p50_ms": 0.297,
"gpu_p50_ms": 0.14,
"histogram": [
23421,
560,
22,
0,
1,
0,
2,
2,
0,
0,
0,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
}
],
"label": "horizon-win-9800x3d-5080"
}
@@ -0,0 +1,773 @@
{
"format": "horizon-benchmark-report",
"version": 1,
"kind": "game",
"created": "2026-10-29T18:55:59Z",
"app": {
"name": "horizon-client",
"version": "v0.2.97",
"commit": null
},
"hardware": {
"os": "windows",
"arch": "x86_64",
"cpu_threads": 16,
"os_version": "Microsoft Windows [Version 10.0.26200.8037]",
"cpu": "AMD Ryzen 7 9800X3D 8-Core Processor",
"ram_mb": 95943,
"gpu": {
"name": "NVIDIA GeForce RTX 5080",
"vendor": "nvidia",
"driver": "NVIDIA 617.42",
"backend": "vulkan",
"kind": "discrete"
}
},
"conditions": {
"scenario": "perf_bench:village,forest,ocean,mountain",
"seed": "1234",
"render_distance": 32,
"simulation_distance": 12,
"vsync": false,
"offscreen": false,
"repeats": 3,
"warmup_s": 2.0,
"window": [
1280,
720
]
},
"metrics": {
"startup.title_ms": {
"n": 3,
"mean": 2897.812,
"stdev": 82.455,
"min": 2844.677,
"max": 2992.8,
"ci95": 204.846
},
"startup.world_ms": {
"n": 3,
"mean": 10503.454,
"stdev": 493.592,
"min": 9934.491,
"max": 10816.992,
"ci95": 1226.251
},
"chunks.village.load_ms": {
"n": 3,
"mean": 3717.47,
"stdev": 1330.649,
"min": 2942.872,
"max": 5253.954,
"ci95": 3305.783
},
"chunks.village.meshed_count": {
"n": 3,
"mean": 47525.333,
"stdev": 1369.122,
"min": 46133.0,
"max": 48870.0,
"ci95": 3401.363
},
"fps.village.avg_fps": {
"n": 3,
"mean": 1163.541,
"stdev": 3.485,
"min": 1159.52,
"max": 1165.693,
"ci95": 8.658
},
"fps.village.low_1pct_fps": {
"n": 3,
"mean": 415.992,
"stdev": 15.742,
"min": 399.062,
"max": 430.188,
"ci95": 39.108
},
"fps.village.low_01pct_fps": {
"n": 3,
"mean": 215.549,
"stdev": 34.678,
"min": 177.857,
"max": 246.101,
"ci95": 86.151
},
"memory.village.gpu_mb": {
"n": 3,
"mean": 1833.432,
"stdev": 4.415,
"min": 1828.334,
"max": 1835.997,
"ci95": 10.969
},
"chunks.forest.load_ms": {
"n": 3,
"mean": 5455.049,
"stdev": 593.897,
"min": 4973.659,
"max": 6118.723,
"ci95": 1475.44
},
"chunks.forest.meshed_count": {
"n": 3,
"mean": 82758.0,
"stdev": 413.547,
"min": 82487.0,
"max": 83234.0,
"ci95": 1027.39
},
"fps.forest.avg_fps": {
"n": 3,
"mean": 1389.46,
"stdev": 28.128,
"min": 1371.096,
"max": 1421.843,
"ci95": 69.88
},
"fps.forest.low_1pct_fps": {
"n": 3,
"mean": 504.377,
"stdev": 13.251,
"min": 492.444,
"max": 518.637,
"ci95": 32.919
},
"fps.forest.low_01pct_fps": {
"n": 3,
"mean": 329.682,
"stdev": 47.451,
"min": 294.872,
"max": 383.732,
"ci95": 117.885
},
"memory.forest.gpu_mb": {
"n": 3,
"mean": 1985.175,
"stdev": 6.354,
"min": 1978.907,
"max": 1991.612,
"ci95": 15.786
},
"chunks.ocean.load_ms": {
"n": 3,
"mean": 4504.145,
"stdev": 745.393,
"min": 3650.589,
"max": 5026.8,
"ci95": 1851.809
},
"chunks.ocean.meshed_count": {
"n": 3,
"mean": 74927.333,
"stdev": 399.375,
"min": 74476.0,
"max": 75235.0,
"ci95": 992.182
},
"fps.ocean.avg_fps": {
"n": 3,
"mean": 1188.639,
"stdev": 10.807,
"min": 1177.005,
"max": 1198.365,
"ci95": 26.849
},
"fps.ocean.low_1pct_fps": {
"n": 3,
"mean": 154.773,
"stdev": 3.712,
"min": 151.38,
"max": 158.738,
"ci95": 9.223
},
"fps.ocean.low_01pct_fps": {
"n": 3,
"mean": 67.809,
"stdev": 3.032,
"min": 65.903,
"max": 71.305,
"ci95": 7.532
},
"memory.ocean.gpu_mb": {
"n": 3,
"mean": 2084.845,
"stdev": 9.674,
"min": 2074.009,
"max": 2092.615,
"ci95": 24.034
},
"chunks.mountain.load_ms": {
"n": 3,
"mean": 9048.431,
"stdev": 344.33,
"min": 8821.41,
"max": 9444.624,
"ci95": 855.432
},
"chunks.mountain.meshed_count": {
"n": 3,
"mean": 89659.667,
"stdev": 87.123,
"min": 89561.0,
"max": 89726.0,
"ci95": 216.442
},
"fps.mountain.avg_fps": {
"n": 3,
"mean": 1085.847,
"stdev": 13.819,
"min": 1070.378,
"max": 1096.974,
"ci95": 34.332
},
"fps.mountain.low_1pct_fps": {
"n": 3,
"mean": 257.7,
"stdev": 31.437,
"min": 224.366,
"max": 286.814,
"ci95": 78.101
},
"fps.mountain.low_01pct_fps": {
"n": 3,
"mean": 107.783,
"stdev": 14.883,
"min": 91.541,
"max": 120.768,
"ci95": 36.975
},
"memory.mountain.gpu_mb": {
"n": 3,
"mean": 2708.833,
"stdev": 9.674,
"min": 2697.997,
"max": 2716.603,
"ci95": 24.034
},
"memory.rss_peak_mb": {
"n": 3,
"mean": 2044.895,
"stdev": 10.562,
"min": 2032.875,
"max": 2052.691,
"ci95": 26.238
},
"memory.rss_steady_mb": {
"n": 3,
"mean": 1730.107,
"stdev": 14.764,
"min": 1717.52,
"max": 1746.357,
"ci95": 36.678
}
},
"frames": [
{
"phase": "village",
"repeat": 0,
"frames": 13986,
"seconds": 12.001,
"avg_fps": 1165.41,
"low_1pct_fps": 399.062,
"low_01pct_fps": 177.857,
"p50_ms": 0.822,
"p95_ms": 1.21,
"p99_ms": 1.691,
"p999_ms": 3.499,
"max_ms": 13.089,
"cpu_p50_ms": 0.665,
"gpu_p50_ms": 0.193,
"histogram": [
12380,
1527,
58,
13,
2,
2,
1,
2,
0,
0,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "village",
"repeat": 1,
"frames": 13915,
"seconds": 12.001,
"avg_fps": 1159.52,
"low_1pct_fps": 430.188,
"low_01pct_fps": 246.101,
"p50_ms": 0.809,
"p95_ms": 1.212,
"p99_ms": 1.702,
"p999_ms": 3.291,
"max_ms": 6.894,
"cpu_p50_ms": 0.648,
"gpu_p50_ms": 0.192,
"histogram": [
11996,
1839,
59,
17,
2,
1,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "village",
"repeat": 2,
"frames": 13989,
"seconds": 12.001,
"avg_fps": 1165.693,
"low_1pct_fps": 418.725,
"low_01pct_fps": 222.688,
"p50_ms": 0.816,
"p95_ms": 1.22,
"p99_ms": 1.692,
"p999_ms": 3.264,
"max_ms": 7.781,
"cpu_p50_ms": 0.669,
"gpu_p50_ms": 0.193,
"histogram": [
12321,
1578,
72,
11,
2,
4,
0,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "forest",
"repeat": 0,
"frames": 17063,
"seconds": 12.001,
"avg_fps": 1421.843,
"low_1pct_fps": 502.049,
"low_01pct_fps": 294.872,
"p50_ms": 0.666,
"p95_ms": 0.935,
"p99_ms": 1.549,
"p999_ms": 2.692,
"max_ms": 6.831,
"cpu_p50_ms": 0.533,
"gpu_p50_ms": 0.362,
"histogram": [
16417,
596,
40,
7,
2,
0,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "forest",
"repeat": 1,
"frames": 16454,
"seconds": 12.001,
"avg_fps": 1371.096,
"low_1pct_fps": 492.444,
"low_01pct_fps": 310.443,
"p50_ms": 0.688,
"p95_ms": 0.979,
"p99_ms": 1.556,
"p999_ms": 2.693,
"max_ms": 4.596,
"cpu_p50_ms": 0.553,
"gpu_p50_ms": 0.36,
"histogram": [
15692,
697,
57,
7,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "forest",
"repeat": 2,
"frames": 16506,
"seconds": 12.001,
"avg_fps": 1375.442,
"low_1pct_fps": 518.637,
"low_01pct_fps": 383.732,
"p50_ms": 0.675,
"p95_ms": 1.017,
"p99_ms": 1.605,
"p999_ms": 2.369,
"max_ms": 3.201,
"cpu_p50_ms": 0.54,
"gpu_p50_ms": 0.364,
"histogram": [
15628,
833,
44,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 0,
"frames": 14381,
"seconds": 12.001,
"avg_fps": 1198.365,
"low_1pct_fps": 154.2,
"low_01pct_fps": 66.219,
"p50_ms": 0.731,
"p95_ms": 1.023,
"p99_ms": 3.003,
"p999_ms": 12.478,
"max_ms": 18.863,
"cpu_p50_ms": 0.596,
"gpu_p50_ms": 0.315,
"histogram": [
13586,
546,
105,
60,
13,
13,
8,
16,
8,
11,
5,
7,
3,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 1,
"frames": 14287,
"seconds": 12.0,
"avg_fps": 1190.548,
"low_1pct_fps": 158.738,
"low_01pct_fps": 71.305,
"p50_ms": 0.741,
"p95_ms": 1.041,
"p99_ms": 2.9,
"p999_ms": 12.0,
"max_ms": 17.23,
"cpu_p50_ms": 0.602,
"gpu_p50_ms": 0.315,
"histogram": [
13450,
591,
109,
57,
13,
8,
10,
11,
14,
10,
7,
6,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 2,
"frames": 14124,
"seconds": 12.0,
"avg_fps": 1177.005,
"low_1pct_fps": 151.38,
"low_01pct_fps": 65.903,
"p50_ms": 0.739,
"p95_ms": 1.096,
"p99_ms": 2.928,
"p999_ms": 11.884,
"max_ms": 19.375,
"cpu_p50_ms": 0.604,
"gpu_p50_ms": 0.314,
"histogram": [
13052,
822,
114,
54,
16,
8,
4,
14,
11,
15,
3,
8,
3,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "mountain",
"repeat": 0,
"frames": 12845,
"seconds": 12.0,
"avg_fps": 1070.378,
"low_1pct_fps": 224.366,
"low_01pct_fps": 91.541,
"p50_ms": 0.825,
"p95_ms": 1.362,
"p99_ms": 2.325,
"p999_ms": 9.621,
"max_ms": 13.309,
"cpu_p50_ms": 0.689,
"gpu_p50_ms": 0.307,
"histogram": [
10937,
1699,
143,
25,
5,
6,
4,
8,
8,
8,
2,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "mountain",
"repeat": 1,
"frames": 13083,
"seconds": 12.001,
"avg_fps": 1090.188,
"low_1pct_fps": 261.921,
"low_01pct_fps": 111.039,
"p50_ms": 0.816,
"p95_ms": 1.335,
"p99_ms": 2.14,
"p999_ms": 8.222,
"max_ms": 11.95,
"cpu_p50_ms": 0.681,
"gpu_p50_ms": 0.306,
"histogram": [
11105,
1812,
112,
21,
5,
5,
5,
6,
11,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "mountain",
"repeat": 2,
"frames": 13164,
"seconds": 12.0,
"avg_fps": 1096.974,
"low_1pct_fps": 286.814,
"low_01pct_fps": 120.768,
"p50_ms": 0.822,
"p95_ms": 1.307,
"p99_ms": 1.989,
"p999_ms": 7.701,
"max_ms": 9.429,
"cpu_p50_ms": 0.687,
"gpu_p50_ms": 0.307,
"histogram": [
11280,
1758,
82,
13,
5,
5,
6,
8,
7,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
}
],
"label": "horizon-win-9800x3d-5080"
}
@@ -0,0 +1,773 @@
{
"format": "horizon-benchmark-report",
"version": 1,
"kind": "game",
"created": "2026-10-29T18:37:03Z",
"app": {
"name": "horizon-client",
"version": "v0.2.97",
"commit": null
},
"hardware": {
"os": "windows",
"arch": "x86_64",
"cpu_threads": 16,
"os_version": "Microsoft Windows [Version 10.0.26200.8037]",
"cpu": "AMD Ryzen 7 9800X3D 8-Core Processor",
"ram_mb": 95943,
"gpu": {
"name": "NVIDIA GeForce RTX 5080",
"vendor": "nvidia",
"driver": "NVIDIA 617.42",
"backend": "vulkan",
"kind": "discrete"
}
},
"conditions": {
"scenario": "perf_bench:village,forest,ocean,mountain",
"seed": "1234",
"render_distance": 8,
"simulation_distance": 12,
"vsync": false,
"offscreen": false,
"repeats": 3,
"warmup_s": 2.0,
"window": [
1280,
720
]
},
"metrics": {
"startup.title_ms": {
"n": 3,
"mean": 2950.343,
"stdev": 162.732,
"min": 2822.925,
"max": 3133.657,
"ci95": 404.282
},
"startup.world_ms": {
"n": 3,
"mean": 6135.1,
"stdev": 863.831,
"min": 5547.861,
"max": 7126.98,
"ci95": 2146.05
},
"chunks.village.load_ms": {
"n": 3,
"mean": 985.519,
"stdev": 28.184,
"min": 956.244,
"max": 1012.469,
"ci95": 70.019
},
"chunks.village.meshed_count": {
"n": 3,
"mean": 6908.0,
"stdev": 9.849,
"min": 6897.0,
"max": 6916.0,
"ci95": 24.468
},
"fps.village.avg_fps": {
"n": 3,
"mean": 2941.608,
"stdev": 79.283,
"min": 2850.416,
"max": 2994.188,
"ci95": 196.965
},
"fps.village.low_1pct_fps": {
"n": 3,
"mean": 1271.318,
"stdev": 58.797,
"min": 1203.758,
"max": 1310.917,
"ci95": 146.072
},
"fps.village.low_01pct_fps": {
"n": 3,
"mean": 839.261,
"stdev": 37.982,
"min": 795.415,
"max": 862.067,
"ci95": 94.36
},
"memory.village.gpu_mb": {
"n": 3,
"mean": 363.984,
"stdev": 0.926,
"min": 363.449,
"max": 365.053,
"ci95": 2.299
},
"chunks.forest.load_ms": {
"n": 3,
"mean": 1350.752,
"stdev": 293.128,
"min": 1106.764,
"max": 1675.913,
"ci95": 728.23
},
"chunks.forest.meshed_count": {
"n": 3,
"mean": 8066.0,
"stdev": 108.885,
"min": 7986.0,
"max": 8190.0,
"ci95": 270.508
},
"fps.forest.avg_fps": {
"n": 3,
"mean": 3494.693,
"stdev": 110.997,
"min": 3389.894,
"max": 3610.993,
"ci95": 275.755
},
"fps.forest.low_1pct_fps": {
"n": 3,
"mean": 1295.14,
"stdev": 159.638,
"min": 1148.468,
"max": 1465.171,
"ci95": 396.596
},
"fps.forest.low_01pct_fps": {
"n": 3,
"mean": 859.326,
"stdev": 119.845,
"min": 721.994,
"max": 942.743,
"ci95": 297.734
},
"memory.forest.gpu_mb": {
"n": 3,
"mean": 381.189,
"stdev": 5.222,
"min": 376.423,
"max": 386.77,
"ci95": 12.972
},
"chunks.ocean.load_ms": {
"n": 3,
"mean": 1123.386,
"stdev": 604.175,
"min": 734.302,
"max": 1819.414,
"ci95": 1500.976
},
"chunks.ocean.meshed_count": {
"n": 3,
"mean": 6613.0,
"stdev": 135.632,
"min": 6523.0,
"max": 6769.0,
"ci95": 336.955
},
"fps.ocean.avg_fps": {
"n": 3,
"mean": 3346.003,
"stdev": 59.312,
"min": 3303.535,
"max": 3413.77,
"ci95": 147.352
},
"fps.ocean.low_1pct_fps": {
"n": 3,
"mean": 1357.991,
"stdev": 95.364,
"min": 1248.887,
"max": 1425.451,
"ci95": 236.917
},
"fps.ocean.low_01pct_fps": {
"n": 3,
"mean": 911.822,
"stdev": 94.717,
"min": 802.528,
"max": 969.989,
"ci95": 235.308
},
"memory.ocean.gpu_mb": {
"n": 3,
"mean": 391.636,
"stdev": 5.435,
"min": 387.424,
"max": 397.771,
"ci95": 13.502
},
"chunks.mountain.load_ms": {
"n": 3,
"mean": 1588.954,
"stdev": 458.924,
"min": 1309.548,
"max": 2118.606,
"ci95": 1140.123
},
"chunks.mountain.meshed_count": {
"n": 3,
"mean": 7887.667,
"stdev": 41.041,
"min": 7862.0,
"max": 7935.0,
"ci95": 101.959
},
"fps.mountain.avg_fps": {
"n": 3,
"mean": 3375.623,
"stdev": 26.82,
"min": 3352.309,
"max": 3404.933,
"ci95": 66.629
},
"fps.mountain.low_1pct_fps": {
"n": 3,
"mean": 1383.745,
"stdev": 67.628,
"min": 1324.071,
"max": 1457.203,
"ci95": 168.01
},
"fps.mountain.low_01pct_fps": {
"n": 3,
"mean": 850.213,
"stdev": 103.149,
"min": 790.305,
"max": 969.318,
"ci95": 256.256
},
"memory.mountain.gpu_mb": {
"n": 3,
"mean": 448.64,
"stdev": 5.435,
"min": 444.428,
"max": 454.775,
"ci95": 13.502
},
"memory.rss_peak_mb": {
"n": 3,
"mean": 1171.629,
"stdev": 0.337,
"min": 1171.398,
"max": 1172.016,
"ci95": 0.837
},
"memory.rss_steady_mb": {
"n": 3,
"mean": 1082.078,
"stdev": 12.494,
"min": 1069.117,
"max": 1094.047,
"ci95": 31.04
}
},
"frames": [
{
"phase": "village",
"repeat": 0,
"frames": 35764,
"seconds": 12.0,
"avg_fps": 2980.22,
"low_1pct_fps": 1310.917,
"low_01pct_fps": 860.301,
"p50_ms": 0.317,
"p95_ms": 0.433,
"p99_ms": 0.633,
"p999_ms": 0.937,
"max_ms": 2.807,
"cpu_p50_ms": 0.192,
"gpu_p50_ms": 0.063,
"histogram": [
35741,
22,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "village",
"repeat": 1,
"frames": 35931,
"seconds": 12.0,
"avg_fps": 2994.188,
"low_1pct_fps": 1299.278,
"low_01pct_fps": 795.415,
"p50_ms": 0.318,
"p95_ms": 0.428,
"p99_ms": 0.62,
"p999_ms": 0.943,
"max_ms": 5.797,
"cpu_p50_ms": 0.191,
"gpu_p50_ms": 0.063,
"histogram": [
35906,
23,
1,
0,
0,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "village",
"repeat": 2,
"frames": 34206,
"seconds": 12.0,
"avg_fps": 2850.416,
"low_1pct_fps": 1203.758,
"low_01pct_fps": 862.067,
"p50_ms": 0.326,
"p95_ms": 0.508,
"p99_ms": 0.705,
"p999_ms": 1.025,
"max_ms": 1.398,
"cpu_p50_ms": 0.196,
"gpu_p50_ms": 0.063,
"histogram": [
34167,
39,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "forest",
"repeat": 0,
"frames": 41800,
"seconds": 12.0,
"avg_fps": 3483.193,
"low_1pct_fps": 1271.782,
"low_01pct_fps": 913.242,
"p50_ms": 0.264,
"p95_ms": 0.415,
"p99_ms": 0.657,
"p999_ms": 0.935,
"max_ms": 1.482,
"cpu_p50_ms": 0.144,
"gpu_p50_ms": 0.075,
"histogram": [
41768,
32,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "forest",
"repeat": 1,
"frames": 43333,
"seconds": 12.0,
"avg_fps": 3610.993,
"low_1pct_fps": 1465.171,
"low_01pct_fps": 942.743,
"p50_ms": 0.263,
"p95_ms": 0.355,
"p99_ms": 0.561,
"p999_ms": 0.859,
"max_ms": 1.694,
"cpu_p50_ms": 0.143,
"gpu_p50_ms": 0.074,
"histogram": [
43310,
23,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "forest",
"repeat": 2,
"frames": 40679,
"seconds": 12.0,
"avg_fps": 3389.894,
"low_1pct_fps": 1148.468,
"low_01pct_fps": 721.994,
"p50_ms": 0.267,
"p95_ms": 0.446,
"p99_ms": 0.67,
"p999_ms": 1.183,
"max_ms": 2.434,
"cpu_p50_ms": 0.146,
"gpu_p50_ms": 0.076,
"histogram": [
40590,
87,
2,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 0,
"frames": 39849,
"seconds": 12.0,
"avg_fps": 3320.704,
"low_1pct_fps": 1399.634,
"low_01pct_fps": 969.989,
"p50_ms": 0.281,
"p95_ms": 0.404,
"p99_ms": 0.601,
"p999_ms": 0.875,
"max_ms": 1.494,
"cpu_p50_ms": 0.159,
"gpu_p50_ms": 0.069,
"histogram": [
39831,
18,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 1,
"frames": 40966,
"seconds": 12.0,
"avg_fps": 3413.77,
"low_1pct_fps": 1425.451,
"low_01pct_fps": 962.948,
"p50_ms": 0.278,
"p95_ms": 0.372,
"p99_ms": 0.581,
"p999_ms": 0.857,
"max_ms": 1.576,
"cpu_p50_ms": 0.157,
"gpu_p50_ms": 0.069,
"histogram": [
40947,
19,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 2,
"frames": 39643,
"seconds": 12.0,
"avg_fps": 3303.535,
"low_1pct_fps": 1248.887,
"low_01pct_fps": 802.528,
"p50_ms": 0.28,
"p95_ms": 0.413,
"p99_ms": 0.642,
"p999_ms": 1.087,
"max_ms": 2.215,
"cpu_p50_ms": 0.158,
"gpu_p50_ms": 0.069,
"histogram": [
39582,
60,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "mountain",
"repeat": 0,
"frames": 40436,
"seconds": 12.0,
"avg_fps": 3369.626,
"low_1pct_fps": 1369.962,
"low_01pct_fps": 790.305,
"p50_ms": 0.279,
"p95_ms": 0.403,
"p99_ms": 0.604,
"p999_ms": 0.812,
"max_ms": 10.313,
"cpu_p50_ms": 0.159,
"gpu_p50_ms": 0.069,
"histogram": [
40425,
8,
1,
1,
0,
0,
0,
0,
0,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "mountain",
"repeat": 1,
"frames": 40860,
"seconds": 12.0,
"avg_fps": 3404.933,
"low_1pct_fps": 1457.203,
"low_01pct_fps": 969.318,
"p50_ms": 0.278,
"p95_ms": 0.389,
"p99_ms": 0.574,
"p999_ms": 0.814,
"max_ms": 2.714,
"cpu_p50_ms": 0.158,
"gpu_p50_ms": 0.069,
"histogram": [
40847,
11,
2,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "mountain",
"repeat": 2,
"frames": 40229,
"seconds": 12.0,
"avg_fps": 3352.309,
"low_1pct_fps": 1324.071,
"low_01pct_fps": 791.016,
"p50_ms": 0.28,
"p95_ms": 0.402,
"p99_ms": 0.617,
"p999_ms": 0.968,
"max_ms": 3.746,
"cpu_p50_ms": 0.159,
"gpu_p50_ms": 0.069,
"histogram": [
40192,
35,
1,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
}
],
"label": "horizon-win-9800x3d-5080"
}
@@ -0,0 +1,706 @@
{
"format": "horizon-benchmark-report",
"version": 1,
"kind": "game",
"created": "2026-10-29T01:25:53Z",
"app": {
"name": "minecraft",
"version": "26.3",
"mods": [
{
"name": "fabric-loader",
"version": "0.19.5"
},
{
"name": "sodium",
"version": "mc26.3-0.9.2-fabric"
},
{
"name": "lithium",
"version": "mc26.3-0.26.2-fabric"
},
{
"name": "ferrite-core",
"version": "9.0.0-fabric"
},
{
"name": "immediatelyfast",
"version": "1.17.1+26.3-fabric"
}
]
},
"hardware": {
"os": "windows",
"arch": "x86_64",
"cpu_threads": 16,
"os_version": "Microsoft Windows [Version 10.0.26200.8037]",
"cpu": "AMD Ryzen 7 9800X3D 8-Core Processor",
"ram_mb": 95943,
"gpu": {
"name": "NVIDIA GeForce RTX 5080/PCIe/SSE2",
"vendor": "NVIDIA Corporation",
"driver": "3.3.0 NVIDIA 617.42",
"backend": "opengl",
"kind": ""
}
},
"conditions": {
"scenario": "perf_bench:village,forest,ocean,mountain",
"seed": "1234",
"render_distance": 16,
"simulation_distance": 12,
"vsync": false,
"offscreen": false,
"repeats": 3,
"warmup_s": 2.0,
"window": [
1280,
720
]
},
"metrics": {
"startup.title_ms": {
"n": 3,
"mean": 10777.756,
"stdev": 129.67,
"min": 10636.92,
"max": 10892.2,
"ci95": 322.144
},
"startup.world_ms": {
"n": 3,
"mean": 23477.423,
"stdev": 346.614,
"min": 23130.92,
"max": 23824.148,
"ci95": 861.106
},
"chunks.village.load_ms": {
"n": 3,
"mean": 6007.033,
"stdev": 104.227,
"min": 5891.431,
"max": 6093.825,
"ci95": 258.936
},
"fps.village.avg_fps": {
"n": 3,
"mean": 1282.11,
"stdev": 65.207,
"min": 1228.13,
"max": 1354.56,
"ci95": 161.997
},
"fps.village.low_1pct_fps": {
"n": 3,
"mean": 343.465,
"stdev": 9.96,
"min": 335.223,
"max": 354.532,
"ci95": 24.744
},
"fps.village.low_01pct_fps": {
"n": 3,
"mean": 131.669,
"stdev": 4.692,
"min": 126.427,
"max": 135.476,
"ci95": 11.656
},
"chunks.forest.load_ms": {
"n": 3,
"mean": 8669.311,
"stdev": 434.39,
"min": 8280.854,
"max": 9138.35,
"ci95": 1079.173
},
"fps.forest.avg_fps": {
"n": 3,
"mean": 1745.757,
"stdev": 13.014,
"min": 1734.41,
"max": 1759.963,
"ci95": 32.332
},
"fps.forest.low_1pct_fps": {
"n": 3,
"mean": 460.958,
"stdev": 10.859,
"min": 449.051,
"max": 470.316,
"ci95": 26.978
},
"fps.forest.low_01pct_fps": {
"n": 3,
"mean": 178.955,
"stdev": 3.777,
"min": 175.213,
"max": 182.766,
"ci95": 9.383
},
"chunks.ocean.load_ms": {
"n": 3,
"mean": 7056.368,
"stdev": 117.329,
"min": 6986.912,
"max": 7191.833,
"ci95": 291.485
},
"fps.ocean.avg_fps": {
"n": 3,
"mean": 1815.056,
"stdev": 47.875,
"min": 1759.788,
"max": 1843.745,
"ci95": 118.937
},
"fps.ocean.low_1pct_fps": {
"n": 3,
"mean": 514.215,
"stdev": 57.205,
"min": 452.445,
"max": 565.368,
"ci95": 142.117
},
"fps.ocean.low_01pct_fps": {
"n": 3,
"mean": 191.842,
"stdev": 23.217,
"min": 175.546,
"max": 218.426,
"ci95": 57.68
},
"chunks.mountain.load_ms": {
"n": 3,
"mean": 14094.582,
"stdev": 399.624,
"min": 13641.534,
"max": 14397.0,
"ci95": 992.801
},
"fps.mountain.avg_fps": {
"n": 3,
"mean": 1499.768,
"stdev": 22.197,
"min": 1477.269,
"max": 1521.651,
"ci95": 55.146
},
"fps.mountain.low_1pct_fps": {
"n": 3,
"mean": 408.466,
"stdev": 16.114,
"min": 395.097,
"max": 426.358,
"ci95": 40.032
},
"fps.mountain.low_01pct_fps": {
"n": 3,
"mean": 139.743,
"stdev": 3.969,
"min": 136.465,
"max": 144.156,
"ci95": 9.86
},
"memory.rss_peak_mb": {
"n": 3,
"mean": 2495.607,
"stdev": 75.339,
"min": 2412.738,
"max": 2559.965,
"ci95": 187.166
},
"memory.rss_steady_mb": {
"n": 3,
"mean": 2326.611,
"stdev": 15.702,
"min": 2314.98,
"max": 2344.473,
"ci95": 39.01
}
},
"frames": [
{
"phase": "village",
"repeat": 0,
"frames": 14738,
"seconds": 12.0,
"avg_fps": 1228.13,
"low_1pct_fps": 335.223,
"low_01pct_fps": 133.103,
"p50_ms": 0.722,
"p95_ms": 1.384,
"p99_ms": 2.043,
"p999_ms": 5.224,
"max_ms": 11.459,
"histogram": [
12159,
2421,
128,
14,
1,
5,
2,
3,
4,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "village",
"repeat": 1,
"frames": 16255,
"seconds": 12.0,
"avg_fps": 1354.56,
"low_1pct_fps": 354.532,
"low_01pct_fps": 135.476,
"p50_ms": 0.672,
"p95_ms": 1.136,
"p99_ms": 1.841,
"p999_ms": 4.913,
"max_ms": 16.94,
"histogram": [
14841,
1298,
86,
13,
2,
5,
4,
1,
3,
1,
0,
0,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "village",
"repeat": 2,
"frames": 15164,
"seconds": 12.0,
"avg_fps": 1263.64,
"low_1pct_fps": 340.639,
"low_01pct_fps": 126.427,
"p50_ms": 0.708,
"p95_ms": 1.299,
"p99_ms": 1.895,
"p999_ms": 5.173,
"max_ms": 16.748,
"histogram": [
13026,
2014,
94,
13,
1,
3,
3,
6,
2,
1,
0,
0,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "forest",
"repeat": 0,
"frames": 20915,
"seconds": 12.0,
"avg_fps": 1742.897,
"low_1pct_fps": 470.316,
"low_01pct_fps": 182.766,
"p50_ms": 0.5,
"p95_ms": 0.936,
"p99_ms": 1.437,
"p999_ms": 2.77,
"max_ms": 13.414,
"histogram": [
20067,
788,
44,
4,
0,
4,
3,
3,
1,
0,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "forest",
"repeat": 1,
"frames": 21120,
"seconds": 12.0,
"avg_fps": 1759.963,
"low_1pct_fps": 449.051,
"low_01pct_fps": 178.887,
"p50_ms": 0.49,
"p95_ms": 0.901,
"p99_ms": 1.531,
"p999_ms": 3.005,
"max_ms": 12.907,
"histogram": [
20414,
644,
40,
10,
1,
3,
2,
2,
2,
1,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "forest",
"repeat": 2,
"frames": 20813,
"seconds": 12.0,
"avg_fps": 1734.41,
"low_1pct_fps": 463.506,
"low_01pct_fps": 175.213,
"p50_ms": 0.496,
"p95_ms": 0.924,
"p99_ms": 1.414,
"p999_ms": 2.733,
"max_ms": 16.832,
"histogram": [
20049,
707,
41,
5,
0,
1,
5,
2,
2,
0,
0,
0,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 0,
"frames": 22100,
"seconds": 12.0,
"avg_fps": 1841.634,
"low_1pct_fps": 565.368,
"low_01pct_fps": 218.426,
"p50_ms": 0.483,
"p95_ms": 0.835,
"p99_ms": 1.209,
"p999_ms": 2.082,
"max_ms": 14.627,
"histogram": [
21540,
532,
18,
1,
1,
1,
2,
2,
1,
1,
0,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 1,
"frames": 21118,
"seconds": 12.0,
"avg_fps": 1759.788,
"low_1pct_fps": 452.445,
"low_01pct_fps": 175.546,
"p50_ms": 0.489,
"p95_ms": 0.915,
"p99_ms": 1.532,
"p999_ms": 2.811,
"max_ms": 11.846,
"histogram": [
20308,
755,
36,
9,
0,
2,
0,
3,
2,
3,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 2,
"frames": 22125,
"seconds": 12.0,
"avg_fps": 1843.745,
"low_1pct_fps": 524.832,
"low_01pct_fps": 181.555,
"p50_ms": 0.477,
"p95_ms": 0.855,
"p99_ms": 1.241,
"p999_ms": 2.236,
"max_ms": 12.478,
"histogram": [
21487,
608,
18,
1,
1,
1,
2,
2,
1,
2,
2,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "mountain",
"repeat": 0,
"frames": 18005,
"seconds": 12.0,
"avg_fps": 1500.383,
"low_1pct_fps": 403.944,
"low_01pct_fps": 136.465,
"p50_ms": 0.591,
"p95_ms": 1.058,
"p99_ms": 1.592,
"p999_ms": 3.186,
"max_ms": 12.608,
"histogram": [
16864,
1084,
32,
8,
0,
5,
3,
5,
1,
2,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "mountain",
"repeat": 1,
"frames": 17728,
"seconds": 12.001,
"avg_fps": 1477.269,
"low_1pct_fps": 395.097,
"low_01pct_fps": 138.609,
"p50_ms": 0.598,
"p95_ms": 1.042,
"p99_ms": 1.561,
"p999_ms": 5.033,
"max_ms": 11.462,
"histogram": [
16612,
1051,
39,
7,
1,
6,
5,
3,
1,
3,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "mountain",
"repeat": 2,
"frames": 18260,
"seconds": 12.0,
"avg_fps": 1521.651,
"low_1pct_fps": 426.358,
"low_01pct_fps": 144.156,
"p50_ms": 0.585,
"p95_ms": 1.015,
"p99_ms": 1.458,
"p999_ms": 3.609,
"max_ms": 11.945,
"histogram": [
17267,
943,
26,
6,
5,
2,
3,
3,
2,
3,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
}
],
"label": "vanilla-mods-win-9800x3d-5080"
}
@@ -0,0 +1,706 @@
{
"format": "horizon-benchmark-report",
"version": 1,
"kind": "game",
"created": "2026-10-29T19:27:56Z",
"app": {
"name": "minecraft",
"version": "26.3",
"mods": [
{
"name": "fabric-loader",
"version": "0.19.5"
},
{
"name": "sodium",
"version": "mc26.3-0.9.2-fabric"
},
{
"name": "lithium",
"version": "mc26.3-0.26.2-fabric"
},
{
"name": "ferrite-core",
"version": "9.0.0-fabric"
},
{
"name": "immediatelyfast",
"version": "1.17.1+26.3-fabric"
}
]
},
"hardware": {
"os": "windows",
"arch": "x86_64",
"cpu_threads": 16,
"os_version": "Microsoft Windows [Version 10.0.26200.8037]",
"cpu": "AMD Ryzen 7 9800X3D 8-Core Processor",
"ram_mb": 95943,
"gpu": {
"name": "NVIDIA GeForce RTX 5080/PCIe/SSE2",
"vendor": "NVIDIA Corporation",
"driver": "3.3.0 NVIDIA 617.42",
"backend": "opengl",
"kind": ""
}
},
"conditions": {
"scenario": "perf_bench:village,forest,ocean,mountain",
"seed": "1234",
"render_distance": 32,
"simulation_distance": 12,
"vsync": false,
"offscreen": false,
"repeats": 3,
"warmup_s": 2.0,
"window": [
1280,
720
]
},
"metrics": {
"startup.title_ms": {
"n": 3,
"mean": 11038.922,
"stdev": 118.058,
"min": 10935.424,
"max": 11167.506,
"ci95": 293.295
},
"startup.world_ms": {
"n": 3,
"mean": 39248.588,
"stdev": 479.755,
"min": 38964.834,
"max": 39802.506,
"ci95": 1191.875
},
"chunks.village.load_ms": {
"n": 3,
"mean": 11176.953,
"stdev": 356.144,
"min": 10836.65,
"max": 11547.064,
"ci95": 884.781
},
"fps.village.avg_fps": {
"n": 3,
"mean": 925.977,
"stdev": 6.727,
"min": 918.832,
"max": 932.189,
"ci95": 16.713
},
"fps.village.low_1pct_fps": {
"n": 3,
"mean": 250.092,
"stdev": 1.868,
"min": 248.431,
"max": 252.114,
"ci95": 4.64
},
"fps.village.low_01pct_fps": {
"n": 3,
"mean": 92.692,
"stdev": 4.466,
"min": 88.423,
"max": 97.332,
"ci95": 11.095
},
"chunks.forest.load_ms": {
"n": 3,
"mean": 17335.638,
"stdev": 687.588,
"min": 16544.168,
"max": 17785.772,
"ci95": 1708.201
},
"fps.forest.avg_fps": {
"n": 3,
"mean": 929.034,
"stdev": 3.11,
"min": 926.32,
"max": 932.428,
"ci95": 7.727
},
"fps.forest.low_1pct_fps": {
"n": 3,
"mean": 312.69,
"stdev": 16.386,
"min": 297.667,
"max": 330.163,
"ci95": 40.708
},
"fps.forest.low_01pct_fps": {
"n": 3,
"mean": 129.331,
"stdev": 12.786,
"min": 115.486,
"max": 140.694,
"ci95": 31.765
},
"chunks.ocean.load_ms": {
"n": 3,
"mean": 12601.063,
"stdev": 148.042,
"min": 12430.182,
"max": 12690.506,
"ci95": 367.785
},
"fps.ocean.avg_fps": {
"n": 3,
"mean": 1016.598,
"stdev": 5.481,
"min": 1010.482,
"max": 1021.068,
"ci95": 13.618
},
"fps.ocean.low_1pct_fps": {
"n": 3,
"mean": 325.361,
"stdev": 15.112,
"min": 315.682,
"max": 342.774,
"ci95": 37.543
},
"fps.ocean.low_01pct_fps": {
"n": 3,
"mean": 117.042,
"stdev": 11.095,
"min": 107.807,
"max": 129.349,
"ci95": 27.563
},
"chunks.mountain.load_ms": {
"n": 3,
"mean": 38513.008,
"stdev": 958.119,
"min": 37447.333,
"max": 39303.226,
"ci95": 2380.292
},
"fps.mountain.avg_fps": {
"n": 3,
"mean": 644.782,
"stdev": 3.94,
"min": 640.866,
"max": 648.745,
"ci95": 9.788
},
"fps.mountain.low_1pct_fps": {
"n": 3,
"mean": 166.029,
"stdev": 4.758,
"min": 162.326,
"max": 171.395,
"ci95": 11.819
},
"fps.mountain.low_01pct_fps": {
"n": 3,
"mean": 86.231,
"stdev": 8.587,
"min": 76.632,
"max": 93.184,
"ci95": 21.334
},
"memory.rss_peak_mb": {
"n": 3,
"mean": 2964.911,
"stdev": 8.27,
"min": 2959.813,
"max": 2974.453,
"ci95": 20.545
},
"memory.rss_steady_mb": {
"n": 3,
"mean": 2894.541,
"stdev": 12.847,
"min": 2879.928,
"max": 2904.059,
"ci95": 31.917
}
},
"frames": [
{
"phase": "village",
"repeat": 0,
"frames": 11123,
"seconds": 12.0,
"avg_fps": 926.911,
"low_1pct_fps": 249.732,
"low_01pct_fps": 92.322,
"p50_ms": 0.989,
"p95_ms": 1.492,
"p99_ms": 2.33,
"p999_ms": 7.409,
"max_ms": 15.258,
"histogram": [
6224,
4701,
147,
28,
3,
2,
5,
3,
3,
3,
1,
3,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "village",
"repeat": 1,
"frames": 11187,
"seconds": 12.001,
"avg_fps": 932.189,
"low_1pct_fps": 252.114,
"low_01pct_fps": 88.423,
"p50_ms": 0.984,
"p95_ms": 1.496,
"p99_ms": 2.328,
"p999_ms": 7.858,
"max_ms": 16.843,
"histogram": [
6518,
4486,
138,
20,
9,
1,
1,
6,
1,
2,
3,
1,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "village",
"repeat": 2,
"frames": 11026,
"seconds": 12.0,
"avg_fps": 918.832,
"low_1pct_fps": 248.431,
"low_01pct_fps": 97.332,
"p50_ms": 0.989,
"p95_ms": 1.524,
"p99_ms": 2.37,
"p999_ms": 7.446,
"max_ms": 16.573,
"histogram": [
6075,
4740,
162,
23,
5,
2,
4,
7,
4,
1,
1,
2,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "forest",
"repeat": 0,
"frames": 11117,
"seconds": 12.001,
"avg_fps": 926.32,
"low_1pct_fps": 310.239,
"low_01pct_fps": 131.814,
"p50_ms": 1.011,
"p95_ms": 1.34,
"p99_ms": 2.045,
"p999_ms": 4.711,
"max_ms": 14.077,
"histogram": [
3849,
7150,
78,
26,
5,
1,
3,
2,
1,
0,
1,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "forest",
"repeat": 1,
"frames": 11190,
"seconds": 12.001,
"avg_fps": 932.428,
"low_1pct_fps": 330.163,
"low_01pct_fps": 140.694,
"p50_ms": 1.008,
"p95_ms": 1.307,
"p99_ms": 1.869,
"p999_ms": 4.341,
"max_ms": 13.489,
"histogram": [
4273,
6822,
60,
20,
7,
0,
3,
3,
0,
1,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "forest",
"repeat": 2,
"frames": 11141,
"seconds": 12.001,
"avg_fps": 928.354,
"low_1pct_fps": 297.667,
"low_01pct_fps": 115.486,
"p50_ms": 1.005,
"p95_ms": 1.352,
"p99_ms": 2.054,
"p999_ms": 5.292,
"max_ms": 14.339,
"histogram": [
4901,
6118,
83,
23,
3,
2,
4,
3,
0,
1,
2,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 0,
"frames": 12126,
"seconds": 12.0,
"avg_fps": 1010.482,
"low_1pct_fps": 317.626,
"low_01pct_fps": 113.971,
"p50_ms": 0.933,
"p95_ms": 1.23,
"p99_ms": 1.935,
"p999_ms": 4.393,
"max_ms": 14.824,
"histogram": [
9183,
2830,
82,
17,
3,
1,
2,
3,
0,
1,
3,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 1,
"frames": 12219,
"seconds": 12.0,
"avg_fps": 1018.243,
"low_1pct_fps": 342.774,
"low_01pct_fps": 129.349,
"p50_ms": 0.929,
"p95_ms": 1.222,
"p99_ms": 1.867,
"p999_ms": 4.329,
"max_ms": 10.33,
"histogram": [
9421,
2710,
60,
15,
3,
0,
0,
5,
3,
2,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 2,
"frames": 12253,
"seconds": 12.0,
"avg_fps": 1021.068,
"low_1pct_fps": 315.682,
"low_01pct_fps": 107.807,
"p50_ms": 0.923,
"p95_ms": 1.225,
"p99_ms": 1.969,
"p999_ms": 5.235,
"max_ms": 19.524,
"histogram": [
9231,
2904,
92,
10,
2,
5,
0,
4,
1,
2,
0,
1,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "mountain",
"repeat": 0,
"frames": 7691,
"seconds": 12.001,
"avg_fps": 640.866,
"low_1pct_fps": 164.367,
"low_01pct_fps": 93.184,
"p50_ms": 1.454,
"p95_ms": 1.95,
"p99_ms": 3.4,
"p999_ms": 9.754,
"max_ms": 14.995,
"histogram": [
36,
7331,
233,
26,
17,
13,
20,
3,
6,
5,
0,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "mountain",
"repeat": 1,
"frames": 7737,
"seconds": 12.0,
"avg_fps": 644.736,
"low_1pct_fps": 162.326,
"low_01pct_fps": 76.632,
"p50_ms": 1.454,
"p95_ms": 1.905,
"p99_ms": 3.676,
"p999_ms": 9.926,
"max_ms": 18.459,
"histogram": [
46,
7399,
199,
24,
21,
24,
10,
6,
1,
1,
4,
1,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "mountain",
"repeat": 2,
"frames": 7785,
"seconds": 12.0,
"avg_fps": 648.745,
"low_1pct_fps": 171.395,
"low_01pct_fps": 88.876,
"p50_ms": 1.439,
"p95_ms": 1.886,
"p99_ms": 3.562,
"p999_ms": 10.342,
"max_ms": 12.927,
"histogram": [
57,
7432,
186,
44,
23,
20,
9,
3,
3,
7,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
}
],
"label": "vanilla-mods-win-9800x3d-5080"
}
@@ -0,0 +1,706 @@
{
"format": "horizon-benchmark-report",
"version": 1,
"kind": "game",
"created": "2026-10-29T18:49:40Z",
"app": {
"name": "minecraft",
"version": "26.3",
"mods": [
{
"name": "fabric-loader",
"version": "0.19.5"
},
{
"name": "sodium",
"version": "mc26.3-0.9.2-fabric"
},
{
"name": "lithium",
"version": "mc26.3-0.26.2-fabric"
},
{
"name": "ferrite-core",
"version": "9.0.0-fabric"
},
{
"name": "immediatelyfast",
"version": "1.17.1+26.3-fabric"
}
]
},
"hardware": {
"os": "windows",
"arch": "x86_64",
"cpu_threads": 16,
"os_version": "Microsoft Windows [Version 10.0.26200.8037]",
"cpu": "AMD Ryzen 7 9800X3D 8-Core Processor",
"ram_mb": 95943,
"gpu": {
"name": "NVIDIA GeForce RTX 5080/PCIe/SSE2",
"vendor": "NVIDIA Corporation",
"driver": "3.3.0 NVIDIA 617.42",
"backend": "opengl",
"kind": ""
}
},
"conditions": {
"scenario": "perf_bench:village,forest,ocean,mountain",
"seed": "1234",
"render_distance": 8,
"simulation_distance": 12,
"vsync": false,
"offscreen": false,
"repeats": 3,
"warmup_s": 2.0,
"window": [
1280,
720
]
},
"metrics": {
"startup.title_ms": {
"n": 3,
"mean": 11089.831,
"stdev": 46.336,
"min": 11036.36,
"max": 11118.192,
"ci95": 115.114
},
"startup.world_ms": {
"n": 3,
"mean": 17643.831,
"stdev": 47.672,
"min": 17589.192,
"max": 17676.942,
"ci95": 118.433
},
"chunks.village.load_ms": {
"n": 3,
"mean": 3603.773,
"stdev": 161.342,
"min": 3488.106,
"max": 3788.086,
"ci95": 400.828
},
"fps.village.avg_fps": {
"n": 3,
"mean": 2103.54,
"stdev": 40.465,
"min": 2078.464,
"max": 2150.222,
"ci95": 100.529
},
"fps.village.low_1pct_fps": {
"n": 3,
"mean": 577.326,
"stdev": 33.693,
"min": 557.613,
"max": 616.23,
"ci95": 83.704
},
"fps.village.low_01pct_fps": {
"n": 3,
"mean": 195.53,
"stdev": 18.899,
"min": 181.495,
"max": 217.019,
"ci95": 46.951
},
"chunks.forest.load_ms": {
"n": 3,
"mean": 3282.612,
"stdev": 48.569,
"min": 3233.16,
"max": 3330.246,
"ci95": 120.661
},
"fps.forest.avg_fps": {
"n": 3,
"mean": 2424.739,
"stdev": 26.074,
"min": 2394.789,
"max": 2442.383,
"ci95": 64.777
},
"fps.forest.low_1pct_fps": {
"n": 3,
"mean": 732.987,
"stdev": 11.926,
"min": 724.144,
"max": 746.55,
"ci95": 29.627
},
"fps.forest.low_01pct_fps": {
"n": 3,
"mean": 249.926,
"stdev": 8.826,
"min": 241.293,
"max": 258.934,
"ci95": 21.928
},
"chunks.ocean.load_ms": {
"n": 3,
"mean": 2705.954,
"stdev": 173.487,
"min": 2538.486,
"max": 2884.891,
"ci95": 431.0
},
"fps.ocean.avg_fps": {
"n": 3,
"mean": 2431.99,
"stdev": 33.071,
"min": 2394.02,
"max": 2454.493,
"ci95": 82.159
},
"fps.ocean.low_1pct_fps": {
"n": 3,
"mean": 764.065,
"stdev": 14.782,
"min": 747.287,
"max": 775.168,
"ci95": 36.723
},
"fps.ocean.low_01pct_fps": {
"n": 3,
"mean": 261.954,
"stdev": 14.724,
"min": 252.457,
"max": 278.916,
"ci95": 36.58
},
"chunks.mountain.load_ms": {
"n": 3,
"mean": 4982.865,
"stdev": 115.523,
"min": 4849.837,
"max": 5057.932,
"ci95": 286.997
},
"fps.mountain.avg_fps": {
"n": 3,
"mean": 2538.05,
"stdev": 67.405,
"min": 2462.815,
"max": 2592.938,
"ci95": 167.458
},
"fps.mountain.low_1pct_fps": {
"n": 3,
"mean": 760.16,
"stdev": 13.503,
"min": 746.001,
"max": 772.895,
"ci95": 33.547
},
"fps.mountain.low_01pct_fps": {
"n": 3,
"mean": 240.897,
"stdev": 17.872,
"min": 224.391,
"max": 259.878,
"ci95": 44.401
},
"memory.rss_peak_mb": {
"n": 3,
"mean": 2052.982,
"stdev": 57.756,
"min": 1992.574,
"max": 2107.66,
"ci95": 143.487
},
"memory.rss_steady_mb": {
"n": 3,
"mean": 1903.921,
"stdev": 62.254,
"min": 1838.307,
"max": 1962.156,
"ci95": 154.659
}
},
"frames": [
{
"phase": "village",
"repeat": 0,
"frames": 25803,
"seconds": 12.0,
"avg_fps": 2150.222,
"low_1pct_fps": 616.23,
"low_01pct_fps": 217.019,
"p50_ms": 0.433,
"p95_ms": 0.642,
"p99_ms": 1.04,
"p999_ms": 2.307,
"max_ms": 7.466,
"histogram": [
25503,
269,
8,
6,
7,
6,
2,
2,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "village",
"repeat": 1,
"frames": 24942,
"seconds": 12.0,
"avg_fps": 2078.464,
"low_1pct_fps": 557.613,
"low_01pct_fps": 181.495,
"p50_ms": 0.45,
"p95_ms": 0.665,
"p99_ms": 1.1,
"p999_ms": 2.305,
"max_ms": 9.819,
"histogram": [
24615,
288,
17,
2,
4,
9,
2,
3,
2,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "village",
"repeat": 2,
"frames": 24984,
"seconds": 12.0,
"avg_fps": 2081.934,
"low_1pct_fps": 558.135,
"low_01pct_fps": 188.076,
"p50_ms": 0.444,
"p95_ms": 0.682,
"p99_ms": 1.101,
"p999_ms": 2.412,
"max_ms": 11.567,
"histogram": [
24645,
308,
9,
3,
5,
5,
7,
1,
0,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "forest",
"repeat": 0,
"frames": 29245,
"seconds": 12.0,
"avg_fps": 2437.044,
"low_1pct_fps": 728.266,
"low_01pct_fps": 249.55,
"p50_ms": 0.38,
"p95_ms": 0.555,
"p99_ms": 0.892,
"p999_ms": 1.851,
"max_ms": 6.973,
"histogram": [
29066,
154,
4,
6,
7,
6,
2,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "forest",
"repeat": 1,
"frames": 28738,
"seconds": 12.0,
"avg_fps": 2394.789,
"low_1pct_fps": 724.144,
"low_01pct_fps": 241.293,
"p50_ms": 0.384,
"p95_ms": 0.571,
"p99_ms": 0.907,
"p999_ms": 1.833,
"max_ms": 8.35,
"histogram": [
28573,
140,
6,
3,
6,
5,
3,
1,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "forest",
"repeat": 2,
"frames": 29309,
"seconds": 12.0,
"avg_fps": 2442.383,
"low_1pct_fps": 746.55,
"low_01pct_fps": 258.934,
"p50_ms": 0.378,
"p95_ms": 0.566,
"p99_ms": 0.876,
"p999_ms": 1.744,
"max_ms": 10.291,
"histogram": [
29138,
148,
5,
6,
5,
2,
4,
0,
0,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 0,
"frames": 29454,
"seconds": 12.0,
"avg_fps": 2454.493,
"low_1pct_fps": 769.741,
"low_01pct_fps": 252.457,
"p50_ms": 0.378,
"p95_ms": 0.55,
"p99_ms": 0.853,
"p999_ms": 1.471,
"max_ms": 6.629,
"histogram": [
29323,
110,
2,
2,
7,
7,
3,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 1,
"frames": 28729,
"seconds": 12.0,
"avg_fps": 2394.02,
"low_1pct_fps": 747.287,
"low_01pct_fps": 254.49,
"p50_ms": 0.383,
"p95_ms": 0.579,
"p99_ms": 0.896,
"p999_ms": 1.56,
"max_ms": 7.367,
"histogram": [
28567,
141,
3,
5,
4,
4,
4,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 2,
"frames": 29370,
"seconds": 12.0,
"avg_fps": 2447.458,
"low_1pct_fps": 775.168,
"low_01pct_fps": 278.916,
"p50_ms": 0.376,
"p95_ms": 0.559,
"p99_ms": 0.887,
"p999_ms": 1.567,
"max_ms": 9.65,
"histogram": [
29209,
141,
3,
9,
0,
5,
1,
1,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "mountain",
"repeat": 0,
"frames": 31116,
"seconds": 12.0,
"avg_fps": 2592.938,
"low_1pct_fps": 761.584,
"low_01pct_fps": 224.391,
"p50_ms": 0.356,
"p95_ms": 0.511,
"p99_ms": 0.767,
"p999_ms": 1.843,
"max_ms": 8.478,
"histogram": [
31011,
76,
4,
3,
13,
4,
3,
1,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "mountain",
"repeat": 1,
"frames": 29554,
"seconds": 12.0,
"avg_fps": 2462.815,
"low_1pct_fps": 746.001,
"low_01pct_fps": 238.423,
"p50_ms": 0.366,
"p95_ms": 0.579,
"p99_ms": 0.849,
"p999_ms": 1.75,
"max_ms": 7.607,
"histogram": [
29430,
101,
2,
3,
9,
4,
3,
2,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "mountain",
"repeat": 2,
"frames": 30701,
"seconds": 12.0,
"avg_fps": 2558.396,
"low_1pct_fps": 772.895,
"low_01pct_fps": 259.878,
"p50_ms": 0.357,
"p95_ms": 0.537,
"p99_ms": 0.81,
"p999_ms": 1.702,
"max_ms": 6.682,
"histogram": [
30568,
112,
1,
6,
5,
3,
6,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
}
],
"label": "vanilla-mods-win-9800x3d-5080"
}
@@ -0,0 +1,690 @@
{
"format": "horizon-benchmark-report",
"version": 1,
"kind": "game",
"created": "2026-10-29T01:19:23Z",
"app": {
"name": "minecraft",
"version": "26.3",
"mods": [
{
"name": "fabric-loader",
"version": "0.19.5"
}
]
},
"hardware": {
"os": "windows",
"arch": "x86_64",
"cpu_threads": 16,
"os_version": "Microsoft Windows [Version 10.0.26200.8037]",
"cpu": "AMD Ryzen 7 9800X3D 8-Core Processor",
"ram_mb": 95943,
"gpu": {
"name": "NVIDIA GeForce RTX 5080/PCIe/SSE2",
"vendor": "NVIDIA Corporation",
"driver": "3.3.0 NVIDIA 617.42",
"backend": "opengl",
"kind": ""
}
},
"conditions": {
"scenario": "perf_bench:village,forest,ocean,mountain",
"seed": "1234",
"render_distance": 16,
"simulation_distance": 12,
"vsync": false,
"offscreen": false,
"repeats": 3,
"warmup_s": 2.0,
"window": [
1280,
720
]
},
"metrics": {
"startup.title_ms": {
"n": 3,
"mean": 7681.834,
"stdev": 189.48,
"min": 7538.777,
"max": 7896.728,
"ci95": 470.732
},
"startup.world_ms": {
"n": 3,
"mean": 19898.834,
"stdev": 192.981,
"min": 19695.728,
"max": 20079.777,
"ci95": 479.431
},
"chunks.village.load_ms": {
"n": 3,
"mean": 6127.947,
"stdev": 26.756,
"min": 6097.19,
"max": 6145.854,
"ci95": 66.471
},
"fps.village.avg_fps": {
"n": 3,
"mean": 1218.486,
"stdev": 26.337,
"min": 1188.13,
"max": 1235.248,
"ci95": 65.43
},
"fps.village.low_1pct_fps": {
"n": 3,
"mean": 318.468,
"stdev": 6.292,
"min": 313.2,
"max": 325.435,
"ci95": 15.632
},
"fps.village.low_01pct_fps": {
"n": 3,
"mean": 126.59,
"stdev": 1.844,
"min": 124.744,
"max": 128.431,
"ci95": 4.58
},
"chunks.forest.load_ms": {
"n": 3,
"mean": 8548.814,
"stdev": 152.302,
"min": 8380.912,
"max": 8678.071,
"ci95": 378.37
},
"fps.forest.avg_fps": {
"n": 3,
"mean": 1912.359,
"stdev": 130.238,
"min": 1791.386,
"max": 2050.215,
"ci95": 323.555
},
"fps.forest.low_1pct_fps": {
"n": 3,
"mean": 401.907,
"stdev": 35.911,
"min": 379.849,
"max": 443.345,
"ci95": 89.216
},
"fps.forest.low_01pct_fps": {
"n": 3,
"mean": 149.26,
"stdev": 9.983,
"min": 139.809,
"max": 159.701,
"ci95": 24.801
},
"chunks.ocean.load_ms": {
"n": 3,
"mean": 6953.478,
"stdev": 276.636,
"min": 6635.649,
"max": 7140.072,
"ci95": 687.258
},
"fps.ocean.avg_fps": {
"n": 3,
"mean": 1860.512,
"stdev": 116.512,
"min": 1726.149,
"max": 1933.597,
"ci95": 289.454
},
"fps.ocean.low_1pct_fps": {
"n": 3,
"mean": 446.336,
"stdev": 56.993,
"min": 382.26,
"max": 491.369,
"ci95": 141.589
},
"fps.ocean.low_01pct_fps": {
"n": 3,
"mean": 164.934,
"stdev": 11.839,
"min": 152.493,
"max": 176.062,
"ci95": 29.413
},
"chunks.mountain.load_ms": {
"n": 3,
"mean": 15211.386,
"stdev": 256.348,
"min": 14938.008,
"max": 15446.375,
"ci95": 636.856
},
"fps.mountain.avg_fps": {
"n": 3,
"mean": 1653.223,
"stdev": 61.705,
"min": 1584.652,
"max": 1704.273,
"ci95": 153.297
},
"fps.mountain.low_1pct_fps": {
"n": 3,
"mean": 331.929,
"stdev": 10.858,
"min": 319.394,
"max": 338.396,
"ci95": 26.974
},
"fps.mountain.low_01pct_fps": {
"n": 3,
"mean": 113.436,
"stdev": 3.255,
"min": 110.869,
"max": 117.097,
"ci95": 8.086
},
"memory.rss_peak_mb": {
"n": 3,
"mean": 2540.129,
"stdev": 152.057,
"min": 2373.152,
"max": 2670.633,
"ci95": 377.761
},
"memory.rss_steady_mb": {
"n": 3,
"mean": 2299.514,
"stdev": 81.451,
"min": 2207.129,
"max": 2360.973,
"ci95": 202.352
}
},
"frames": [
{
"phase": "village",
"repeat": 0,
"frames": 14785,
"seconds": 12.0,
"avg_fps": 1232.081,
"low_1pct_fps": 325.435,
"low_01pct_fps": 128.431,
"p50_ms": 0.742,
"p95_ms": 1.265,
"p99_ms": 2.032,
"p999_ms": 5.653,
"max_ms": 12.791,
"histogram": [
12845,
1784,
127,
7,
1,
8,
4,
3,
5,
0,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "village",
"repeat": 1,
"frames": 14258,
"seconds": 12.0,
"avg_fps": 1188.13,
"low_1pct_fps": 313.2,
"low_01pct_fps": 126.595,
"p50_ms": 0.757,
"p95_ms": 1.349,
"p99_ms": 2.12,
"p999_ms": 5.541,
"max_ms": 13.998,
"histogram": [
11994,
2076,
157,
9,
2,
8,
2,
4,
5,
0,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "village",
"repeat": 2,
"frames": 14823,
"seconds": 12.0,
"avg_fps": 1235.248,
"low_1pct_fps": 316.768,
"low_01pct_fps": 124.744,
"p50_ms": 0.744,
"p95_ms": 1.199,
"p99_ms": 2.047,
"p999_ms": 5.628,
"max_ms": 15.519,
"histogram": [
13214,
1453,
118,
16,
2,
8,
6,
3,
0,
1,
0,
2,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "forest",
"repeat": 0,
"frames": 21497,
"seconds": 12.0,
"avg_fps": 1791.386,
"low_1pct_fps": 382.527,
"low_01pct_fps": 139.809,
"p50_ms": 0.474,
"p95_ms": 0.931,
"p99_ms": 1.646,
"p999_ms": 5.274,
"max_ms": 13.459,
"histogram": [
20598,
799,
66,
9,
3,
8,
8,
2,
2,
0,
2,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "forest",
"repeat": 1,
"frames": 24603,
"seconds": 12.0,
"avg_fps": 2050.215,
"low_1pct_fps": 443.345,
"low_01pct_fps": 159.701,
"p50_ms": 0.426,
"p95_ms": 0.752,
"p99_ms": 1.442,
"p999_ms": 3.134,
"max_ms": 13.282,
"histogram": [
24041,
487,
48,
6,
5,
6,
2,
3,
3,
1,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "forest",
"repeat": 2,
"frames": 22746,
"seconds": 12.0,
"avg_fps": 1895.476,
"low_1pct_fps": 379.849,
"low_01pct_fps": 148.269,
"p50_ms": 0.456,
"p95_ms": 0.829,
"p99_ms": 1.561,
"p999_ms": 4.176,
"max_ms": 10.671,
"histogram": [
22092,
523,
91,
17,
4,
7,
3,
4,
4,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 0,
"frames": 23062,
"seconds": 12.0,
"avg_fps": 1921.791,
"low_1pct_fps": 465.379,
"low_01pct_fps": 166.247,
"p50_ms": 0.463,
"p95_ms": 0.809,
"p99_ms": 1.45,
"p999_ms": 2.398,
"max_ms": 11.808,
"histogram": [
22454,
561,
27,
3,
2,
6,
0,
4,
3,
2,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 1,
"frames": 23204,
"seconds": 12.0,
"avg_fps": 1933.597,
"low_1pct_fps": 491.369,
"low_01pct_fps": 176.062,
"p50_ms": 0.466,
"p95_ms": 0.766,
"p99_ms": 1.406,
"p999_ms": 2.229,
"max_ms": 15.102,
"histogram": [
22699,
469,
19,
4,
1,
1,
4,
2,
2,
2,
0,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 2,
"frames": 20714,
"seconds": 12.0,
"avg_fps": 1726.149,
"low_1pct_fps": 382.26,
"low_01pct_fps": 152.493,
"p50_ms": 0.5,
"p95_ms": 0.956,
"p99_ms": 1.742,
"p999_ms": 3.852,
"max_ms": 12.888,
"histogram": [
19814,
787,
83,
11,
2,
7,
3,
5,
0,
1,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "mountain",
"repeat": 0,
"frames": 20049,
"seconds": 12.0,
"avg_fps": 1670.745,
"low_1pct_fps": 338.396,
"low_01pct_fps": 117.097,
"p50_ms": 0.502,
"p95_ms": 0.949,
"p99_ms": 1.673,
"p999_ms": 6.456,
"max_ms": 16.791,
"histogram": [
19187,
753,
70,
8,
1,
6,
12,
4,
3,
4,
0,
0,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "mountain",
"repeat": 1,
"frames": 19016,
"seconds": 12.0,
"avg_fps": 1584.652,
"low_1pct_fps": 337.998,
"low_01pct_fps": 112.343,
"p50_ms": 0.531,
"p95_ms": 1.082,
"p99_ms": 1.717,
"p999_ms": 5.939,
"max_ms": 12.958,
"histogram": [
17849,
1069,
63,
7,
3,
6,
2,
7,
4,
5,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "mountain",
"repeat": 2,
"frames": 20452,
"seconds": 12.0,
"avg_fps": 1704.273,
"low_1pct_fps": 319.394,
"low_01pct_fps": 110.869,
"p50_ms": 0.496,
"p95_ms": 0.911,
"p99_ms": 1.706,
"p999_ms": 5.938,
"max_ms": 24.863,
"histogram": [
19694,
626,
82,
14,
3,
14,
5,
4,
5,
3,
1,
0,
0,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
}
],
"label": "vanilla-win-9800x3d-5080"
}
@@ -0,0 +1,690 @@
{
"format": "horizon-benchmark-report",
"version": 1,
"kind": "game",
"created": "2026-10-29T19:06:49Z",
"app": {
"name": "minecraft",
"version": "26.3",
"mods": [
{
"name": "fabric-loader",
"version": "0.19.5"
}
]
},
"hardware": {
"os": "windows",
"arch": "x86_64",
"cpu_threads": 16,
"os_version": "Microsoft Windows [Version 10.0.26200.8037]",
"cpu": "AMD Ryzen 7 9800X3D 8-Core Processor",
"ram_mb": 95943,
"gpu": {
"name": "NVIDIA GeForce RTX 5080/PCIe/SSE2",
"vendor": "NVIDIA Corporation",
"driver": "3.3.0 NVIDIA 617.42",
"backend": "opengl",
"kind": ""
}
},
"conditions": {
"scenario": "perf_bench:village,forest,ocean,mountain",
"seed": "1234",
"render_distance": 32,
"simulation_distance": 12,
"vsync": false,
"offscreen": false,
"repeats": 3,
"warmup_s": 2.0,
"window": [
1280,
720
]
},
"metrics": {
"startup.title_ms": {
"n": 3,
"mean": 7518.874,
"stdev": 42.968,
"min": 7469.532,
"max": 7548.049,
"ci95": 106.748
},
"startup.world_ms": {
"n": 3,
"mean": 35424.207,
"stdev": 144.711,
"min": 35318.049,
"max": 35589.041,
"ci95": 359.511
},
"chunks.village.load_ms": {
"n": 3,
"mean": 11146.828,
"stdev": 49.683,
"min": 11094.798,
"max": 11193.773,
"ci95": 123.43
},
"fps.village.avg_fps": {
"n": 3,
"mean": 882.053,
"stdev": 19.433,
"min": 866.126,
"max": 903.706,
"ci95": 48.279
},
"fps.village.low_1pct_fps": {
"n": 3,
"mean": 183.995,
"stdev": 4.924,
"min": 178.512,
"max": 188.041,
"ci95": 12.234
},
"fps.village.low_01pct_fps": {
"n": 3,
"mean": 83.193,
"stdev": 6.797,
"min": 77.734,
"max": 90.806,
"ci95": 16.886
},
"chunks.forest.load_ms": {
"n": 3,
"mean": 18184.002,
"stdev": 266.879,
"min": 17880.667,
"max": 18382.735,
"ci95": 663.017
},
"fps.forest.avg_fps": {
"n": 3,
"mean": 1202.907,
"stdev": 20.426,
"min": 1179.9,
"max": 1218.906,
"ci95": 50.744
},
"fps.forest.low_1pct_fps": {
"n": 3,
"mean": 228.139,
"stdev": 5.166,
"min": 222.394,
"max": 232.401,
"ci95": 12.833
},
"fps.forest.low_01pct_fps": {
"n": 3,
"mean": 107.956,
"stdev": 4.675,
"min": 105.185,
"max": 113.354,
"ci95": 11.614
},
"chunks.ocean.load_ms": {
"n": 3,
"mean": 13146.632,
"stdev": 507.943,
"min": 12572.118,
"max": 13536.145,
"ci95": 1261.902
},
"fps.ocean.avg_fps": {
"n": 3,
"mean": 1223.169,
"stdev": 13.11,
"min": 1208.837,
"max": 1234.556,
"ci95": 32.569
},
"fps.ocean.low_1pct_fps": {
"n": 3,
"mean": 236.27,
"stdev": 6.77,
"min": 228.686,
"max": 241.702,
"ci95": 16.818
},
"fps.ocean.low_01pct_fps": {
"n": 3,
"mean": 99.089,
"stdev": 8.354,
"min": 91.897,
"max": 108.253,
"ci95": 20.755
},
"chunks.mountain.load_ms": {
"n": 3,
"mean": 41846.641,
"stdev": 661.649,
"min": 41435.666,
"max": 42609.895,
"ci95": 1643.76
},
"fps.mountain.avg_fps": {
"n": 3,
"mean": 815.008,
"stdev": 24.644,
"min": 797.565,
"max": 843.201,
"ci95": 61.224
},
"fps.mountain.low_1pct_fps": {
"n": 3,
"mean": 55.371,
"stdev": 5.128,
"min": 50.389,
"max": 60.633,
"ci95": 12.739
},
"fps.mountain.low_01pct_fps": {
"n": 3,
"mean": 7.966,
"stdev": 1.103,
"min": 6.848,
"max": 9.053,
"ci95": 2.74
},
"memory.rss_peak_mb": {
"n": 3,
"mean": 2892.492,
"stdev": 29.54,
"min": 2859.574,
"max": 2916.691,
"ci95": 73.387
},
"memory.rss_steady_mb": {
"n": 3,
"mean": 2799.159,
"stdev": 12.256,
"min": 2786.234,
"max": 2810.613,
"ci95": 30.447
}
},
"frames": [
{
"phase": "village",
"repeat": 0,
"frames": 10845,
"seconds": 12.001,
"avg_fps": 903.706,
"low_1pct_fps": 185.432,
"low_01pct_fps": 81.039,
"p50_ms": 0.98,
"p95_ms": 1.708,
"p99_ms": 3.082,
"p999_ms": 9.304,
"max_ms": 15.487,
"histogram": [
6030,
4403,
297,
49,
25,
16,
8,
2,
6,
2,
4,
3,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "village",
"repeat": 1,
"frames": 10516,
"seconds": 12.0,
"avg_fps": 876.328,
"low_1pct_fps": 188.041,
"low_01pct_fps": 90.806,
"p50_ms": 1.012,
"p95_ms": 1.806,
"p99_ms": 3.354,
"p999_ms": 8.714,
"max_ms": 14.39,
"histogram": [
4927,
5158,
292,
70,
33,
13,
6,
5,
6,
2,
3,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "village",
"repeat": 2,
"frames": 10394,
"seconds": 12.001,
"avg_fps": 866.126,
"low_1pct_fps": 178.512,
"low_01pct_fps": 77.734,
"p50_ms": 1.026,
"p95_ms": 1.789,
"p99_ms": 3.399,
"p999_ms": 10.6,
"max_ms": 15.389,
"histogram": [
4437,
5552,
273,
65,
18,
28,
8,
1,
1,
3,
5,
3,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "forest",
"repeat": 0,
"frames": 14519,
"seconds": 12.0,
"avg_fps": 1209.916,
"low_1pct_fps": 232.401,
"low_01pct_fps": 105.185,
"p50_ms": 0.726,
"p95_ms": 1.306,
"p99_ms": 2.25,
"p999_ms": 7.033,
"max_ms": 13.303,
"histogram": [
13121,
1197,
103,
36,
24,
19,
4,
3,
8,
2,
2,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "forest",
"repeat": 1,
"frames": 14159,
"seconds": 12.0,
"avg_fps": 1179.9,
"low_1pct_fps": 222.394,
"low_01pct_fps": 105.33,
"p50_ms": 0.732,
"p95_ms": 1.366,
"p99_ms": 2.499,
"p999_ms": 8.061,
"max_ms": 11.797,
"histogram": [
12501,
1356,
195,
45,
25,
14,
6,
3,
9,
5,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "forest",
"repeat": 2,
"frames": 14627,
"seconds": 12.0,
"avg_fps": 1218.906,
"low_1pct_fps": 229.622,
"low_01pct_fps": 113.354,
"p50_ms": 0.713,
"p95_ms": 1.292,
"p99_ms": 2.317,
"p999_ms": 7.136,
"max_ms": 12.041,
"histogram": [
13194,
1224,
97,
47,
27,
21,
2,
6,
6,
2,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 0,
"frames": 14714,
"seconds": 12.001,
"avg_fps": 1226.113,
"low_1pct_fps": 238.423,
"low_01pct_fps": 97.117,
"p50_ms": 0.722,
"p95_ms": 1.223,
"p99_ms": 2.243,
"p999_ms": 7.032,
"max_ms": 13.21,
"histogram": [
13490,
1027,
115,
29,
18,
13,
7,
3,
3,
5,
4,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 1,
"frames": 14507,
"seconds": 12.001,
"avg_fps": 1208.837,
"low_1pct_fps": 228.686,
"low_01pct_fps": 91.897,
"p50_ms": 0.719,
"p95_ms": 1.256,
"p99_ms": 2.354,
"p999_ms": 7.473,
"max_ms": 15.432,
"histogram": [
13124,
1112,
180,
35,
23,
9,
8,
4,
2,
6,
2,
2,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 2,
"frames": 14815,
"seconds": 12.0,
"avg_fps": 1234.556,
"low_1pct_fps": 241.702,
"low_01pct_fps": 108.253,
"p50_ms": 0.721,
"p95_ms": 1.2,
"p99_ms": 2.197,
"p999_ms": 6.689,
"max_ms": 12.791,
"histogram": [
13565,
1055,
107,
29,
23,
15,
8,
3,
4,
5,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "mountain",
"repeat": 0,
"frames": 10119,
"seconds": 12.001,
"avg_fps": 843.201,
"low_1pct_fps": 60.633,
"low_01pct_fps": 9.053,
"p50_ms": 0.891,
"p95_ms": 1.845,
"p99_ms": 3.91,
"p999_ms": 8.209,
"max_ms": 316.409,
"histogram": [
6956,
2747,
240,
83,
45,
22,
8,
8,
5,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
4,
0,
0
]
},
{
"phase": "mountain",
"repeat": 1,
"frames": 9693,
"seconds": 12.052,
"avg_fps": 804.258,
"low_1pct_fps": 50.389,
"low_01pct_fps": 6.848,
"p50_ms": 0.906,
"p95_ms": 1.924,
"p99_ms": 4.157,
"p999_ms": 8.578,
"max_ms": 297.695,
"histogram": [
6578,
2678,
248,
94,
45,
18,
16,
5,
4,
2,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
5,
0,
0
]
},
{
"phase": "mountain",
"repeat": 2,
"frames": 9571,
"seconds": 12.0,
"avg_fps": 797.565,
"low_1pct_fps": 55.09,
"low_01pct_fps": 7.997,
"p50_ms": 0.929,
"p95_ms": 1.944,
"p99_ms": 4.495,
"p999_ms": 7.764,
"max_ms": 321.994,
"histogram": [
5954,
3172,
230,
103,
39,
34,
19,
11,
4,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
4,
0,
0
]
}
],
"label": "vanilla-win-9800x3d-5080"
}
@@ -0,0 +1,690 @@
{
"format": "horizon-benchmark-report",
"version": 1,
"kind": "game",
"created": "2026-10-29T18:43:17Z",
"app": {
"name": "minecraft",
"version": "26.3",
"mods": [
{
"name": "fabric-loader",
"version": "0.19.5"
}
]
},
"hardware": {
"os": "windows",
"arch": "x86_64",
"cpu_threads": 16,
"os_version": "Microsoft Windows [Version 10.0.26200.8037]",
"cpu": "AMD Ryzen 7 9800X3D 8-Core Processor",
"ram_mb": 95943,
"gpu": {
"name": "NVIDIA GeForce RTX 5080/PCIe/SSE2",
"vendor": "NVIDIA Corporation",
"driver": "3.3.0 NVIDIA 617.42",
"backend": "opengl",
"kind": ""
}
},
"conditions": {
"scenario": "perf_bench:village,forest,ocean,mountain",
"seed": "1234",
"render_distance": 8,
"simulation_distance": 12,
"vsync": false,
"offscreen": false,
"repeats": 3,
"warmup_s": 2.0,
"window": [
1280,
720
]
},
"metrics": {
"startup.title_ms": {
"n": 3,
"mean": 7676.526,
"stdev": 305.763,
"min": 7426.024,
"max": 8017.247,
"ci95": 759.617
},
"startup.world_ms": {
"n": 3,
"mean": 13862.526,
"stdev": 284.577,
"min": 13569.024,
"max": 14137.247,
"ci95": 706.985
},
"chunks.village.load_ms": {
"n": 3,
"mean": 3808.014,
"stdev": 61.275,
"min": 3737.989,
"max": 3851.799,
"ci95": 152.227
},
"fps.village.avg_fps": {
"n": 3,
"mean": 2216.576,
"stdev": 45.247,
"min": 2166.285,
"max": 2253.985,
"ci95": 112.409
},
"fps.village.low_1pct_fps": {
"n": 3,
"mean": 545.307,
"stdev": 14.876,
"min": 528.15,
"max": 554.607,
"ci95": 36.957
},
"fps.village.low_01pct_fps": {
"n": 3,
"mean": 215.412,
"stdev": 5.692,
"min": 209.522,
"max": 220.882,
"ci95": 14.14
},
"chunks.forest.load_ms": {
"n": 3,
"mean": 3212.958,
"stdev": 151.722,
"min": 3076.655,
"max": 3376.428,
"ci95": 376.928
},
"fps.forest.avg_fps": {
"n": 3,
"mean": 2456.542,
"stdev": 336.369,
"min": 2068.162,
"max": 2654.549,
"ci95": 835.654
},
"fps.forest.low_1pct_fps": {
"n": 3,
"mean": 624.759,
"stdev": 90.0,
"min": 521.303,
"max": 685.008,
"ci95": 223.59
},
"fps.forest.low_01pct_fps": {
"n": 3,
"mean": 252.469,
"stdev": 21.044,
"min": 229.228,
"max": 270.233,
"ci95": 52.28
},
"chunks.ocean.load_ms": {
"n": 3,
"mean": 2983.236,
"stdev": 168.416,
"min": 2788.777,
"max": 3082.249,
"ci95": 418.403
},
"fps.ocean.avg_fps": {
"n": 3,
"mean": 2469.831,
"stdev": 333.258,
"min": 2085.121,
"max": 2669.9,
"ci95": 827.925
},
"fps.ocean.low_1pct_fps": {
"n": 3,
"mean": 688.684,
"stdev": 96.943,
"min": 576.803,
"max": 747.759,
"ci95": 240.839
},
"fps.ocean.low_01pct_fps": {
"n": 3,
"mean": 286.444,
"stdev": 21.36,
"min": 262.183,
"max": 302.422,
"ci95": 53.066
},
"chunks.mountain.load_ms": {
"n": 3,
"mean": 6079.869,
"stdev": 719.864,
"min": 5542.196,
"max": 6897.69,
"ci95": 1788.385
},
"fps.mountain.avg_fps": {
"n": 3,
"mean": 2605.12,
"stdev": 326.424,
"min": 2228.824,
"max": 2812.075,
"ci95": 810.948
},
"fps.mountain.low_1pct_fps": {
"n": 3,
"mean": 699.63,
"stdev": 87.846,
"min": 599.44,
"max": 763.45,
"ci95": 218.239
},
"fps.mountain.low_01pct_fps": {
"n": 3,
"mean": 264.461,
"stdev": 28.459,
"min": 239.602,
"max": 295.503,
"ci95": 70.701
},
"memory.rss_peak_mb": {
"n": 3,
"mean": 2120.426,
"stdev": 116.406,
"min": 2046.93,
"max": 2254.637,
"ci95": 289.191
},
"memory.rss_steady_mb": {
"n": 3,
"mean": 1912.642,
"stdev": 38.925,
"min": 1889.766,
"max": 1957.586,
"ci95": 96.702
}
},
"frames": [
{
"phase": "village",
"repeat": 0,
"frames": 27048,
"seconds": 12.0,
"avg_fps": 2253.985,
"low_1pct_fps": 553.164,
"low_01pct_fps": 215.833,
"p50_ms": 0.412,
"p95_ms": 0.617,
"p99_ms": 1.187,
"p999_ms": 2.238,
"max_ms": 9.792,
"histogram": [
26716,
296,
15,
4,
7,
5,
2,
2,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "village",
"repeat": 1,
"frames": 26754,
"seconds": 12.0,
"avg_fps": 2229.458,
"low_1pct_fps": 554.607,
"low_01pct_fps": 220.882,
"p50_ms": 0.417,
"p95_ms": 0.626,
"p99_ms": 1.212,
"p999_ms": 2.448,
"max_ms": 8.388,
"histogram": [
26423,
293,
16,
9,
4,
5,
1,
1,
2,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "village",
"repeat": 2,
"frames": 25996,
"seconds": 12.0,
"avg_fps": 2166.285,
"low_1pct_fps": 528.15,
"low_01pct_fps": 209.522,
"p50_ms": 0.43,
"p95_ms": 0.662,
"p99_ms": 1.308,
"p999_ms": 2.477,
"max_ms": 11.033,
"histogram": [
25615,
338,
23,
5,
5,
5,
2,
2,
0,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "forest",
"repeat": 0,
"frames": 31855,
"seconds": 12.0,
"avg_fps": 2654.549,
"low_1pct_fps": 667.966,
"low_01pct_fps": 257.945,
"p50_ms": 0.341,
"p95_ms": 0.519,
"p99_ms": 0.932,
"p999_ms": 1.761,
"max_ms": 10.944,
"histogram": [
31558,
275,
6,
2,
7,
0,
4,
2,
0,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "forest",
"repeat": 1,
"frames": 31763,
"seconds": 12.0,
"avg_fps": 2646.916,
"low_1pct_fps": 685.008,
"low_01pct_fps": 270.233,
"p50_ms": 0.344,
"p95_ms": 0.52,
"p99_ms": 0.94,
"p999_ms": 1.685,
"max_ms": 11.406,
"histogram": [
31474,
272,
3,
4,
2,
2,
1,
3,
1,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "forest",
"repeat": 2,
"frames": 24818,
"seconds": 12.0,
"avg_fps": 2068.162,
"low_1pct_fps": 521.303,
"low_01pct_fps": 229.228,
"p50_ms": 0.439,
"p95_ms": 0.767,
"p99_ms": 1.284,
"p999_ms": 2.351,
"max_ms": 11.468,
"histogram": [
24356,
410,
36,
4,
4,
3,
2,
2,
0,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 0,
"frames": 32039,
"seconds": 12.0,
"avg_fps": 2669.9,
"low_1pct_fps": 741.491,
"low_01pct_fps": 294.727,
"p50_ms": 0.345,
"p95_ms": 0.501,
"p99_ms": 0.86,
"p999_ms": 1.431,
"max_ms": 9.257,
"histogram": [
31771,
250,
3,
2,
4,
6,
1,
1,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 1,
"frames": 31854,
"seconds": 12.0,
"avg_fps": 2654.471,
"low_1pct_fps": 747.759,
"low_01pct_fps": 302.422,
"p50_ms": 0.347,
"p95_ms": 0.503,
"p99_ms": 0.853,
"p999_ms": 1.404,
"max_ms": 13.925,
"histogram": [
31580,
261,
3,
2,
0,
1,
3,
3,
0,
0,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 2,
"frames": 25022,
"seconds": 12.0,
"avg_fps": 2085.121,
"low_1pct_fps": 576.803,
"low_01pct_fps": 262.183,
"p50_ms": 0.428,
"p95_ms": 0.758,
"p99_ms": 1.214,
"p999_ms": 2.109,
"max_ms": 9.206,
"histogram": [
24600,
390,
20,
3,
2,
3,
2,
1,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "mountain",
"repeat": 0,
"frames": 33745,
"seconds": 12.0,
"avg_fps": 2812.075,
"low_1pct_fps": 736.0,
"low_01pct_fps": 258.279,
"p50_ms": 0.327,
"p95_ms": 0.471,
"p99_ms": 0.808,
"p999_ms": 1.689,
"max_ms": 10.07,
"histogram": [
33516,
205,
7,
0,
7,
4,
3,
1,
1,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "mountain",
"repeat": 1,
"frames": 33294,
"seconds": 12.0,
"avg_fps": 2774.461,
"low_1pct_fps": 763.45,
"low_01pct_fps": 295.503,
"p50_ms": 0.334,
"p95_ms": 0.471,
"p99_ms": 0.813,
"p999_ms": 1.63,
"max_ms": 8.512,
"histogram": [
33056,
221,
3,
5,
1,
2,
3,
2,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "mountain",
"repeat": 2,
"frames": 26747,
"seconds": 12.0,
"avg_fps": 2228.824,
"low_1pct_fps": 599.44,
"low_01pct_fps": 239.602,
"p50_ms": 0.403,
"p95_ms": 0.678,
"p99_ms": 1.107,
"p999_ms": 2.054,
"max_ms": 9.178,
"histogram": [
26418,
298,
13,
6,
2,
8,
0,
1,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
}
],
"label": "vanilla-win-9800x3d-5080"
}
@@ -0,0 +1,106 @@
# Horizon v0.2.97 vs vanilla 26.3 on Windows 11: Ryzen 7 9800X3D + RTX 5080 (2026-10-29, RD 8 and 32 rerun 2026-10-30)
The first `tools/perf_bench.py` run on Windows ([docs/benchmark.md](../../docs/benchmark.md)): Horizon, vanilla 26.3 and
vanilla with performance mods, render distances 8, 16 and 32, 1280x720, three games each, every game from a fresh
world. The reports are the `*-win-9800x3d-5080-rd*.json` files next to this page.
```sh
python tools/perf_bench.py --table horizon-win-9800x3d-5080 vanilla-win-9800x3d-5080 vanilla-mods-win-9800x3d-5080
```
## Setup
- **Machine:** AMD Ryzen 7 9800X3D (8 cores, 16 threads), 94 GB RAM (DDR5, EXPO off), NVIDIA GeForce RTX 5080
(driver 617.42), Windows 11 Pro 26200, Balanced power plan, desktop. One 1920x1080 120 Hz display. The game windows
were on the desktop of an unlocked session.
- **Horizon:** release v0.2.97 as the launcher installs it (`--build %APPDATA%\horizon-launcher\versions\v0.2.97`,
`--resources` its `.local\horizon-client\26.3`), Vulkan.
- **Vanilla:** the 26.3 client jar with Fabric Loader 0.19.5 and the driver mod, Java 25.0.5 (Temurin), `-Xmx2G`
with the launcher's G1 flags and the manifest's `-XX:StackShadowPages=32`, OpenGL 3.3.
- **Vanilla + mods:** the same with Sodium mc26.3-0.9.2, Lithium mc26.3-0.26.2, FerriteCore 9.0.0 and
ImmediatelyFast 1.17.1 (`tools/benchmark-deps/mods.lock.json`).
- **Commands:** `python tools/perf_bench.py --game <game> --label <game>-win-9800x3d-5080 --distances <d>
--sizes 1280x720 --repeats 3` (plus `--build` and `--resources` for Horizon), with the games taking turns at
each distance.
- **Memory** is the working set on Windows (what Task Manager shows), sampled from outside the process. Horizon's
GPU figure is its own Vulkan allocator total. `nvidia-smi` gives no per-process figure under WDDM, so vanilla has
none.
## Load
Windows has no load average, so the reports have no `load_avg_1m`. Every game ran on an otherwise idle machine:
- **RD 8 and RD 32** (rerun on 2026-10-30, after the first pass had builds running alongside): a script started each
game only once no `rustc`, `cargo`, `link` (or other compiler) process and no other game (`horizon-client`, `java`,
`javaw`, Minecraft) was running, and the machine's CPU load had stayed under 15 % in every 5-second sample for
30 seconds. During each game it sampled every 2 seconds the machine's CPU load and the CPU of everything outside the
benchmark's own processes. A game during which a compiler or another game ran, or other processes used over 15 % of
the machine for 10 seconds, was discarded and run again: two of the 20 games were (vanilla + mods at RD 32, a
build started during one, background processes during the other).
- **RD 16** (2026-10-29): no `rustc`, `cargo` or `link` process at any point, the load logged every 5 seconds.
| Game | RD | Mean / max CPU load (machine, the game included) | Other processes' CPU, mean / max | Compiler processes |
|---|---|---|---|---|
| Horizon | 8 | 18 % / 49 % | 6 % / 12 % | 0 |
| Vanilla | 8 | 28 % / 75 % | 7 % / 20 % | 0 |
| Vanilla + mods | 8 | 30 % / 81 % | 7 % / 20 % | 0 |
| Horizon | 16 | 29 % / 80 % | – | 0 |
| Vanilla | 16 | 41 % / 91 % | – | 0 |
| Vanilla + mods | 16 | 51 % / 90 % | – | 0 |
| Horizon | 32 | 36 % / 100 % | 5 % / 15 % | 0 |
| Vanilla | 32 | 39 % / 73 % | 6 % / 18 % | 0 |
| Vanilla + mods | 32 | 42 % / 88 % | 6 % / 21 % | 0 |
"Other processes" is the machine's load minus the benchmark's own processes (the game and `perf_bench.py`), in
2-second samples. Most of it is the system's share of the game's own drawing (the graphics driver and the
compositor), which no process of the benchmark is charged for; the desktop apps left open (a browser, chat apps)
briefly reached 5 % at most. RD 16's load was logged from outside every 5 seconds, so it has no such split, and its mean includes the time between
games. Compared with the first pass, the idle RD 8 runs give Horizon about 10 % higher averages and about 50 % higher
1% lows, and vanilla up to 10 % higher averages.
## Results
Mean of 3 games ± 95% confidence interval. FPS over the 12-second flight at 16 blocks/s (average / 1% low /
0.1% low):
| RD | scene | Horizon v0.2.97 | Vanilla 26.3 | Vanilla + mods |
| --- | --- | --- | --- | --- |
| 8 | forest | 3495 ±276 / 1295 ±397 / 859 ±298 | 2457 ±836 / 625 ±224 / 252 ±52 | 2425 ±65 / 733 ±30 / 250 ±22 |
| 8 | mountain | 3376 ±67 / 1384 ±168 / 850 ±256 | 2605 ±811 / 700 ±218 / 264 ±71 | 2538 ±167 / 760 ±34 / 241 ±44 |
| 8 | ocean | 3346 ±147 / 1358 ±237 / 912 ±235 | 2470 ±828 / 689 ±241 / 286 ±53 | 2432 ±82 / 764 ±37 / 262 ±37 |
| 8 | village | 2942 ±197 / 1271 ±146 / 839 ±94 | 2217 ±112 / 545 ±37 / 215 ±14 | 2104 ±101 / 577 ±84 / 196 ±47 |
| 16 | forest | 2424 ±138 / 709 ±165 / 401 ±100 | 1912 ±324 / 402 ±89 / 149 ±25 | 1746 ±32 / 461 ±27 / 179 ±9 |
| 16 | mountain | 1961 ±99 / 537 ±96 / 297 ±75 | 1653 ±153 / 332 ±27 / 113 ±8 | 1500 ±55 / 408 ±40 / 140 ±10 |
| 16 | ocean | 2201 ±183 / 554 ±251 / 325 ±326 | 1861 ±289 / 446 ±142 / 165 ±29 | 1815 ±119 / 514 ±142 / 192 ±58 |
| 16 | village | 1712 ±192 / 593 ±80 / 358 ±69 | 1218 ±65 / 318 ±16 / 127 ±5 | 1282 ±162 / 343 ±25 / 132 ±12 |
| 32 | forest | 1389 ±70 / 504 ±33 / 330 ±118 | 1203 ±51 / 228 ±13 / 108 ±12 | 929 ±8 / 313 ±41 / 129 ±32 |
| 32 | mountain | 1086 ±34 / 258 ±78 / 108 ±37 | 815 ±61 / 55 ±13 / 8 ±3 | 645 ±10 / 166 ±12 / 86 ±21 |
| 32 | ocean | 1189 ±27 / 155 ±9 / 68 ±8 | 1223 ±33 / 236 ±17 / 99 ±21 | 1017 ±14 / 325 ±38 / 117 ±28 |
| 32 | village | 1164 ±9 / 416 ±39 / 216 ±86 | 882 ±48 / 184 ±12 / 83 ±17 | 926 ±17 / 250 ±5 / 93 ±11 |
Startup, loading and memory (means of 3 games):
| RD | | Horizon v0.2.97 | Vanilla 26.3 | Vanilla + mods |
| --- | --- | --- | --- | --- |
| 8 | start to title / in-world | 3.0 s / 6.1 s | 7.7 s / 13.9 s | 11.1 s / 17.6 s |
| 8 | mean scene load | 1.3 s | 4.0 s | 3.6 s |
| 8 | working set, steady / peak | 1082 / 1172 MB | 1913 / 2120 MB | 1904 / 2053 MB |
| 16 | start to title / in-world | 3.2 s / 7.2 s | 7.7 s / 19.9 s | 10.8 s / 23.5 s |
| 16 | mean scene load | 2.4 s | 9.2 s | 9.0 s |
| 16 | working set, steady / peak | 1236 / 1459 MB | 2300 / 2540 MB | 2327 / 2496 MB |
| 32 | start to title / in-world | 2.9 s / 10.5 s | 7.5 s / 35.4 s | 11.0 s / 39.2 s |
| 32 | mean scene load | 5.7 s | 21.1 s | 19.9 s |
| 32 | working set, steady / peak | 1730 / 2045 MB | 2799 / 2892 MB | 2895 / 2965 MB |
## What it shows
- At RD 8 and 16, Horizon's average FPS is 18–42 % above vanilla's in every scene. Its 1% lows are 1.2–2.3 times
vanilla's and its 0.1% lows 2.0–3.9 times. On this CPU and GPU the performance mods leave vanilla's averages
level or slightly lower, and raise its 1% lows by 6–23 %.
- At RD 32, Horizon's average is 15–33 % above vanilla's over the forest, the mountain and the village, with 1% lows
2.2–4.7 times vanilla's. Over the ocean its average is level with vanilla's, but its lows are lower than vanilla's
and the modded client's (155 vs 236 and 325 FPS 1% low, 68 vs 99 and 117 FPS 0.1% low), on an idle machine too.
That scene is worth a closer look.
- Horizon reaches the title screen 2.4–2.6 times faster than vanilla (3.4–3.8 times faster than the modded client)
and loads a scene's chunks 3–4 times faster. Its steady working set is 38–46 % smaller.
- Each game uses its own graphics API: Horizon Vulkan, vanilla OpenGL. That difference is part of what's compared.
+2 -2
View File
@@ -4,10 +4,10 @@ version = "0.1.0"
edition.workspace = true
license.workspace = true
rust-version = "1.86"
description = "cargo horizon-mod new/build/run/check: make Horizon mods in Rust (docs/mods.md)"
description = "cargo horizon-mod new/build/run/check: make Horizon content mods (docs/mods.md)"
[dependencies]
horizon-mods = { path = "../horizon-mods", default-features = false, features = ["pack", "runtime", "index"] }
horizon-mods = { path = "../horizon-mods", default-features = false, features = ["index"] }
serde_json = "1.0.151"
[lints]
+18 -167
View File
@@ -1,35 +1,19 @@
//! `cargo horizon-mod check <file>`: what the registry checks of a submission,
//! run anywhere: its kind's own checks (docs/api/mod-index.md), its signature
//! when given a key, and for a mod a trial run in the sandbox: it must start,
//! survive ticks, frames, a chat line and each of its commands (a server mod:
//! a player joining, chatting, typing them and leaving), and import no more
//! than its capabilities.
//! when given a key, and for a `.hmod` that it is content only.
use horizon_mods::{
Clock, GameMode, Mods, Output, Package, Player, ServerPlayer, Snapshot, Status,
Package,
index::{self, Facts, Kind},
manifest::component_imports,
};
use serde_json::{Value, json};
use std::path::Path;
/// How long the trial run plays: two seconds of game.
const TICKS: u64 = 40;
const FRAMES_PER_TICK: u64 = 3;
pub struct Report {
pub kind: Kind,
pub facts: Facts,
pub sha256: String,
pub size: u64,
pub signed: bool,
/// A mod has code to run (a content mod may have none).
pub code: bool,
/// A mod's component imports.
pub imports: Vec<String>,
/// A mod's commands, each run once.
pub commands: Vec<String>,
/// What the mod showed during the trial run.
pub output: Vec<String>,
}
impl Report {
@@ -53,10 +37,6 @@ impl Report {
"sha256": self.sha256,
"size": self.size,
"signed": self.signed,
"code": self.code,
"imports": self.imports,
"commands": self.commands,
"output": self.output,
})
}
@@ -100,18 +80,8 @@ impl Report {
if self.kind == Kind::NativeMod {
out += " native source: unsandboxed; not executed by the registry check. Compile and test against the exact pinned Horizon and Mixins revisions before review.\n";
}
if self.kind == Kind::Mod && !self.code {
if self.kind == Kind::Mod {
out += " no code: content only, nothing to run\n";
} else if self.kind == Kind::Mod {
let commands = if self.commands.is_empty() {
String::new()
} else {
format!(", /{}", self.commands.join(", /"))
};
out += &format!(
" trial run: started, {TICKS} ticks, {} frames, a chat line{commands}: ok\n",
TICKS * FRAMES_PER_TICK
);
}
out
}
@@ -153,145 +123,26 @@ pub fn check(
(None, None) => false,
_ => return Err("--key and --signature go together".into()),
};
let mut report = Report {
if kind == Kind::Mod {
content_only(path)?;
}
Ok(Report {
kind,
facts,
sha256: index::sha256_file(path)?,
size,
signed,
code: false,
imports: Vec::new(),
commands: Vec::new(),
output: Vec::new(),
};
if report.kind == Kind::Mod {
trial_run(path, &mut report)?;
}
Ok(report)
})
}
/// Runs a mod in the sandbox, as the game does, from a scratch copy so its
/// storage lands in a folder removed afterwards. A content mod has no code to
/// run: `inspect` already read its content as the game does.
fn trial_run(path: &Path, report: &mut Report) -> Result<(), String> {
let package = Package::read(path)?;
let Some(wasm) = package.wasm.as_deref() else {
return Ok(());
};
report.code = true;
report.imports = component_imports(wasm)?;
package
.manifest
.check_imports(report.imports.iter().map(String::as_str))?;
let dir = std::env::temp_dir().join(format!("horizon-mod-check-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
let copy = dir.join("mod.hmod");
let result = std::fs::copy(path, &copy)
.map_err(|e| e.to_string())
.and_then(|_| play(&copy, report));
let _ = std::fs::remove_dir_all(&dir);
result
}
fn play(path: &Path, report: &mut Report) -> Result<(), String> {
let mut mods = Mods::load(&[path.to_path_buf()], |_| true, |_| false);
let failed = |mods: &Mods| match &mods.entries()[0].status {
Status::Failed(reason) => Some(reason.clone()),
_ => None,
};
if let Some(reason) = failed(&mods) {
return Err(format!("it didn't start: {reason}"));
}
let client: Vec<String> = mods.commands().map(str::to_owned).collect();
let server: Vec<String> = mods.server_commands().map(|c| c.name.clone()).collect();
report.commands = [client.clone(), server.clone()].concat();
// A server mod plays with one player on, who joins, chats and leaves.
let steve = ServerPlayer {
id: "00000000-0000-4000-8000-000000000001".into(),
name: "Steve".into(),
x: 0.5,
y: 64.0,
z: 0.5,
dimension: "minecraft:overworld".into(),
game_mode: GameMode::Survival,
health: 20.0,
};
let snapshot = |t: u64| Snapshot {
player: Some(Player {
x: 0.5 + t as f64 * 0.1,
y: 64.0,
z: 0.5,
yaw: 90.0,
pitch: 0.0,
health: 20.0,
food: 20,
dimension: "minecraft:overworld".into(),
}),
clock: Some(Clock {
game_time: t,
day_time: 1000 + t,
}),
players: vec![steve.clone()],
// The GUI of a 1280 x 720 window at scale 3.
gui_size: (427.0, 240.0),
..Snapshot::default()
};
mods.set_snapshot(snapshot(0));
mods.player_joined(&steve);
for t in 0..TICKS {
mods.set_snapshot(snapshot(t));
mods.tick(t);
mods.server_tick(t);
for _ in 0..FRAMES_PER_TICK {
mods.frame(1.0 / 60.0);
}
}
mods.chat("<Steve> hello");
mods.player_chat(&steve, "hello");
for name in &client {
mods.command(name);
}
for name in &server {
mods.server_command(&steve, name);
}
mods.set_snapshot(Snapshot::default());
mods.player_left(&steve);
for out in mods.drain() {
report.output.push(match out {
Output::Chat { text, .. } => format!("chat: {text}"),
Output::Tell { text, .. } => format!("tell: {text}"),
Output::Broadcast { text, .. } => format!("broadcast: {text}"),
Output::Feedback { text, error } | Output::Reply { text, error, .. } => {
format!("{}: {text}", if error { "error" } else { "feedback" })
}
Output::Command { line } => format!("command: /{line}"),
Output::QuietCommands { lines, .. } => {
format!("{} quiet command(s)", lines.len())
}
Output::Stopped { reason, .. } => return Err(format!("it stopped: {reason}")),
Output::SetBlocks {
dimension, blocks, ..
} => {
format!("set {} block(s) in {dimension}", blocks.len())
}
Output::OpenScreen { .. } => "opened a screen".to_string(),
Output::CloseScreen { .. } => "closed its screen".to_string(),
Output::PlaceRecipe { book_id, all } => {
format!("placed recipe {book_id}{}", if all { " (all)" } else { "" })
}
Output::Give { item, count, .. } => format!("gave {count} {item}"),
Output::Shake { strength, seconds } => {
format!("shook the camera: {strength:.2} for {seconds:.1} s")
}
});
}
let shapes = mods.hud_shapes().count();
if shapes > 0 {
report.output.push(format!("drew {shapes} HUD shape(s)"));
}
match failed(&mods) {
Some(reason) => Err(format!("it stopped: {reason}")),
/// A mod is content only: WebAssembly code mods don't run any more, code mods are native
/// (`.hnmod`, docs/native-mods.md). `inspect` already read its content as the game does.
fn content_only(path: &Path) -> Result<(), String> {
match Package::read(path)?.wasm {
Some(_) => Err(
"it has WebAssembly code, which Horizon doesn't run any more: make it a native mod (docs/native-mods.md)"
.into(),
),
None => Ok(()),
}
}
@@ -316,7 +167,7 @@ mod tests {
.text()
.contains("cozy 1.0.0 (modpack, format 1, for 26.3)")
);
// The fixture mod's code is a stand-in: its manifest reads, its run fails.
// A mod with WebAssembly code is refused: code mods are native.
let hmod = fixture("coords_hud-0.1.0.hmod");
let key = std::fs::read_to_string(fixture("test.pub")).unwrap();
let sig = std::fs::read_to_string(fixture("coords_hud-0.1.0.hmod.minisig")).unwrap();
@@ -324,7 +175,7 @@ mod tests {
let e = check(&hmod, Kind::Mod, Some(key), Some(&sig))
.err()
.unwrap();
assert!(e.contains("didn't start"), "{e}");
assert!(e.contains("native mod"), "{e}");
let e = check(
&hmod,
Kind::Mod,
+38 -196
View File
@@ -1,32 +1,23 @@
//! `cargo horizon-mod`: make Horizon mods in Rust (`docs/mods.md`).
//! `cargo horizon-mod`: make Horizon content mods (`docs/modpacks.md`): blocks,
//! items, data and assets, no code. Code mods are native (docs/native-mods.md).
//!
//! - `new <path> [--template code|content] [--name "Name"] [--sdk-path <dir>]`:
//! a mod from a template: a Rust crate, or a content mod (no code).
//! - `build [--debug]`: the `.hmod` in `target/horizon-mod/`, with the mod's
//! `assets/` and `data/`; a folder without `Cargo.toml` is content only.
//! - `new <path> [--name "Name"]`: a content mod from the template.
//! - `build`: the `.hmod` in `target/horizon-mod/`, with the mod's `assets/`
//! and `data/`.
//! - `run [--game <horizon-client>] [-- <game args>]`: build, then play with
//! the mod; run `build` again and the game reloads it.
//! - `test [--debug] [--hmod <file>] [<name>]`: build, then run the mod's
//! `tests/*.toml` scripts in the sandbox, with no game.
//! the mod.
//! - `check <file> [--json]`: what the registry checks of a submission
//! (`.hmod`, `.hpack`, resource pack `.zip`), with a trial run for a mod.
//! (`.hmod`, `.hnmod`, `.hpack`, resource pack `.zip`, `.hshader`).
//! - `registry build|review|check|list`: the registry repository's CI.
use horizon_mods::{
Capability, Content, Manifest, Package,
manifest::{MANIFEST, component_imports, componentize},
testing::{Script, TestGame},
};
use horizon_mods::{Capability, Content, Manifest, Package, manifest::MANIFEST};
mod check;
mod registry;
use std::{
path::{Path, PathBuf},
process::{Command, ExitCode, Stdio},
process::{Command, ExitCode},
};
const TARGET: &str = "wasm32-unknown-unknown";
const SDK_GIT: &str = "{ git = \"https://github.com/horizon-rust/horizon\" }";
fn main() -> ExitCode {
// Cargo runs `cargo-horizon-mod horizon-mod <args>`.
let mut args: Vec<String> = std::env::args().skip(1).collect();
@@ -35,14 +26,13 @@ fn main() -> ExitCode {
}
let result = match args.first().map(String::as_str) {
Some("new") => new(&args[1..]),
Some("build") => build(!args.iter().any(|a| a == "--debug")).map(|p| {
Some("build") => build().map(|p| {
println!("Built {}", p.display());
}),
Some("run") => run(&args[1..]),
Some("test") => test(&args[1..]),
Some("check") => check(&args[1..]),
Some("registry") => registry::main(&args[1..]),
_ => Err("usage: cargo horizon-mod new <path> [--template code|content] | build [--debug] | run [--game <horizon-client>] [-- <game args>] | test [--debug] [--hmod <file>] [<name>] | check <file> [--kind <kind>] [--key <public key> --signature <file.minisig>] [--json] | registry build|review|check|list".into()),
_ => Err("usage: cargo horizon-mod new <path> [--name \"Name\"] | build | run [--game <horizon-client>] [-- <game args>] | check <file> [--kind <kind>] [--key <public key> --signature <file.minisig>] [--json] | registry build|review|check|list".into()),
};
match result {
Ok(()) => ExitCode::SUCCESS,
@@ -83,31 +73,9 @@ fn new(args: &[String]) -> Result<(), String> {
})
.collect();
let name = flag(args, "--name").map_or_else(|| base.replace(['-', '_'], " "), str::to_string);
let sdk = match flag(args, "--sdk-path") {
Some(p) => {
let p = std::fs::canonicalize(p).map_err(|e| format!("--sdk-path {p}: {e}"))?;
format!("{{ path = {:?} }}", p.display().to_string())
}
None => SDK_GIT.to_string(),
};
let fill = |t: &str| {
t.replace("{crate}", &id.replace('_', "-"))
.replace("{id}", &id)
.replace("{name}", &name)
.replace("{sdk}", &sdk)
};
let code = [
("Cargo.toml", include_str!("../template/Cargo.toml.in")),
(MANIFEST, include_str!("../template/horizon-mod.toml")),
("src/lib.rs", include_str!("../template/src/lib.rs")),
(".gitignore", include_str!("../template/gitignore")),
(
"tests/hello.toml",
include_str!("../template/tests/hello.toml"),
),
];
let fill = |t: &str| t.replace("{id}", &id).replace("{name}", &name);
// A block copying a vanilla one, with its model, name, loot, recipe and tag.
let content = [
let files = [
(
MANIFEST,
include_str!("../template/content/horizon-mod.toml"),
@@ -146,12 +114,12 @@ fn new(args: &[String]) -> Result<(), String> {
),
(".gitignore", include_str!("../template/gitignore")),
];
let files: &[(&str, &str)] = match flag(args, "--template").unwrap_or("code") {
"code" => &code,
"content" => &content,
other => return Err(format!("no template {other}: code or content")),
};
for &(file, text) in files {
if flag(args, "--template").is_some_and(|t| t != "content") {
return Err(
"only content mods are made here: code mods are native (docs/native-mods.md)".into(),
);
}
for (file, text) in files {
let file = fill(file);
let file = file.as_str();
let to = dir.join(file);
@@ -169,29 +137,24 @@ fn new(args: &[String]) -> Result<(), String> {
Ok(())
}
/// Builds the mod in the current folder and packs `target/horizon-mod/<id>.hmod`:
/// the manifest, the crate's component (none without a `Cargo.toml`: a
/// content mod) and the files in `assets/` and `data/`.
fn build(release: bool) -> Result<PathBuf, String> {
build_in(Path::new("."), release)
/// Packs the content mod in the current folder into `target/horizon-mod/<id>.hmod`: the
/// manifest and the files in `assets/` and `data/`.
fn build() -> Result<PathBuf, String> {
build_in(Path::new("."))
}
fn build_in(root: &Path, release: bool) -> Result<PathBuf, String> {
fn build_in(root: &Path) -> Result<PathBuf, String> {
let manifest_text = std::fs::read_to_string(root.join(MANIFEST))
.map_err(|e| format!("{MANIFEST}: {e} (run this in a mod's folder)"))?;
let manifest = Manifest::parse(&manifest_text)?;
let (wasm, target_dir) = if root.join("Cargo.toml").exists() {
let (wasm, target) = compile(root, release)?;
// The game refuses a component importing capabilities the manifest lacks.
manifest.check_imports(component_imports(&wasm)?.iter().map(String::as_str))?;
(Some(wasm), target)
} else if manifest.capabilities.contains(&Capability::Registries) {
(None, root.join("target"))
} else {
if root.join("Cargo.toml").exists() {
return Err("a Cargo.toml: code mods are native now (docs/native-mods.md)".into());
}
if !manifest.capabilities.contains(&Capability::Registries) {
return Err(format!(
"no Cargo.toml: only a content mod (capability registries in {MANIFEST}) has no code"
"a content mod has capability registries in {MANIFEST}"
));
};
}
// The same checks as the game's, and the files in the order it lists them.
let content = Content::read(root, &manifest)?;
let mut reader = content.reader()?;
@@ -200,53 +163,17 @@ fn build_in(root: &Path, release: bool) -> Result<PathBuf, String> {
files.push((name.as_str(), reader.read(name)?));
}
let files: Vec<(&str, &[u8])> = files.iter().map(|(n, b)| (*n, b.as_slice())).collect();
let dir = target_dir.join("horizon-mod");
let dir = root.join("target/horizon-mod");
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
let path = dir.join(format!("{}.hmod", manifest.id));
// Write then rename, so a running game never reads half a file.
let partial = path.with_extension("hmod.partial");
let bytes = Package::write_files(&manifest_text, wasm.as_deref(), &files)?;
let bytes = Package::write_files(&manifest_text, None, &files)?;
std::fs::write(&partial, bytes).map_err(|e| e.to_string())?;
std::fs::rename(&partial, &path).map_err(|e| e.to_string())?;
Ok(path)
}
/// Builds the crate for WebAssembly: its component, and Cargo's target folder.
fn compile(root: &Path, release: bool) -> Result<(Vec<u8>, PathBuf), String> {
let cargo = std::env::var("CARGO").unwrap_or_else(|_| "cargo".into());
let mut cmd = Command::new(cargo);
cmd.current_dir(root).args([
"build",
"--lib",
"--target",
TARGET,
"--message-format=json-render-diagnostics",
]);
if release {
cmd.arg("--release");
}
let out = cmd
.stderr(Stdio::inherit())
.output()
.map_err(|e| e.to_string())?;
if !out.status.success() {
return Err(format!(
"cargo build failed (is the target installed? rustup target add {TARGET})"
));
}
let core = String::from_utf8_lossy(&out.stdout)
.lines()
.filter_map(|l| serde_json::from_str::<serde_json::Value>(l).ok())
.filter(|m| m["reason"] == "compiler-artifact")
.flat_map(|m| m["filenames"].as_array().cloned().unwrap_or_default())
.filter_map(|f| f.as_str().map(PathBuf::from))
.rfind(|f| f.extension().is_some_and(|e| e == "wasm"))
.ok_or("the build made no .wasm: is the crate a cdylib?")?;
let wasm = componentize(&std::fs::read(&core).map_err(|e| e.to_string())?)?;
let target = core.ancestors().nth(3).ok_or("unexpected target folder")?;
Ok((wasm, target.to_path_buf()))
}
/// `check <file>`: the registry's checks of a submission, printed; fails
/// when one does.
fn check(args: &[String]) -> Result<(), String> {
@@ -278,74 +205,14 @@ fn check(args: &[String]) -> Result<(), String> {
Ok(())
}
/// `test`: every `tests/*.toml` script (those whose name holds `<name>`)
/// against the built mod, or the `.hmod` given with `--hmod`.
fn test(args: &[String]) -> Result<(), String> {
let hmod = match flag(args, "--hmod") {
Some(p) => PathBuf::from(p),
None => build(!args.iter().any(|a| a == "--debug"))?,
};
let filter = args
.iter()
.enumerate()
.find(|(i, a)| !a.starts_with("--") && (*i == 0 || args[i - 1] != "--hmod"))
.map(|(_, a)| a.as_str());
let mut scripts: Vec<PathBuf> = std::fs::read_dir("tests")
.map_err(|e| {
format!(
"tests/: {e} (a mod's tests are tests/<name>.toml: docs/mods.md, \"Testing a mod\")"
)
})?
.filter_map(|e| Some(e.ok()?.path()))
.filter(|p| p.extension().is_some_and(|e| e == "toml"))
.filter(|p| filter.is_none_or(|f| p.to_string_lossy().contains(f)))
.collect();
scripts.sort();
if scripts.is_empty() {
return Err("no tests/*.toml to run".into());
}
println!(
"running {} scripts against {}",
scripts.len(),
hmod.display()
);
let mut failed = 0;
for path in &scripts {
let result = std::fs::read_to_string(path)
.map_err(|e| e.to_string())
.and_then(|text| Script::parse(&text))
.and_then(|script| {
let mut game = TestGame::load(&hmod)?;
script.run(&mut game)?;
Ok(script.steps.len())
});
match result {
Ok(steps) => println!("test {} ... ok ({steps} steps)", path.display()),
Err(e) => {
failed += 1;
println!("test {} ... FAILED\n {e}", path.display());
}
}
}
let passed = scripts.len() - failed;
if failed > 0 {
return Err(format!("{failed} failed, {passed} passed"));
}
println!("test result: ok. {passed} passed");
Ok(())
}
fn run(args: &[String]) -> Result<(), String> {
let path = build(true)?;
let path = build()?;
let game = flag(args, "--game")
.map(str::to_string)
.or_else(|| std::env::var("HORIZON_CLIENT").ok())
.unwrap_or_else(|| "horizon-client".into());
let extra = args.iter().skip_while(|a| *a != "--").skip(1);
println!(
"Starting {game} with {} (rebuild with `cargo horizon-mod build` to reload it)",
path.display()
);
println!("Starting {game} with {}", path.display());
let status = Command::new(&game)
.arg("--dev-mod")
.arg(&path)
@@ -364,28 +231,6 @@ fn run(args: &[String]) -> Result<(), String> {
mod tests {
use super::*;
#[test]
fn the_template_is_a_valid_mod() {
let dir = std::env::temp_dir().join(format!("horizon-mod-new-{}", std::process::id()));
let path = dir.join("My-Mod");
new(&[path.display().to_string(), "--name".into(), "My Mod".into()]).unwrap();
let manifest =
Manifest::parse(&std::fs::read_to_string(path.join(MANIFEST)).unwrap()).unwrap();
assert_eq!(
(manifest.id.as_str(), manifest.name.as_str()),
("my_mod", "My Mod")
);
let cargo = std::fs::read_to_string(path.join("Cargo.toml")).unwrap();
assert!(cargo.contains("name = \"my-mod\"") && cargo.contains(SDK_GIT));
let script = std::fs::read_to_string(path.join("tests/hello.toml")).unwrap();
assert_eq!(Script::parse(&script).unwrap().steps.len(), 3);
assert!(
new(&[path.display().to_string()]).is_err(),
"never overwrites"
);
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn the_content_template_builds_and_checks_with_no_code() {
let dir = std::env::temp_dir().join(format!("horizon-mod-content-{}", std::process::id()));
@@ -397,7 +242,7 @@ mod tests {
];
new(&new_args).unwrap();
assert!(!path.join("Cargo.toml").exists());
let hmod = build_in(&path, true).unwrap();
let hmod = build_in(&path).unwrap();
assert_eq!(hmod, path.join("target/horizon-mod/gems.hmod"));
let package = Package::read(&hmod).unwrap();
assert!(package.wasm.is_none());
@@ -409,17 +254,14 @@ mod tests {
);
assert_eq!(content.files.len(), 8, "{:?}", content.files);
let report = check::check(&hmod, check::kind_of(&hmod).unwrap(), None, None).unwrap();
assert!(!report.code);
assert!(
report.text().contains("no code: content only"),
"{}",
report.text()
);
// Without registries, a mod needs code.
let manifest = std::fs::read_to_string(path.join(MANIFEST)).unwrap();
let manifest = manifest.replace("[\"registries\"]", "[]");
std::fs::write(path.join(MANIFEST), manifest).unwrap();
assert!(build_in(&path, true).unwrap_err().contains("no Cargo.toml"));
// Code is native now.
std::fs::write(path.join("Cargo.toml"), "").unwrap();
assert!(build_in(&path).unwrap_err().contains("native"));
assert!(
new(&[
dir.join("x").display().to_string(),
@@ -1,14 +0,0 @@
[package]
name = "{crate}"
version = "0.1.0"
edition = "2024"
[lib]
crate-type = ["cdylib"]
[dependencies]
horizon-mod-sdk = {sdk}
[profile.release]
opt-level = "s"
lto = true
@@ -1,12 +0,0 @@
id = "{id}"
name = "{name}"
version = "0.1.0"
description = ""
authors = []
# The Horizon mod API the SDK targets.
api = "0.1"
# client: runs in the player's game.
side = "client"
# What the mod may do, shown to players before they turn it on:
# game-read (position, health, time), chat (show messages), hud (draw text).
capabilities = ["chat"]
@@ -1,19 +0,0 @@
use horizon_mod_sdk::{Mod, Registration, chat};
struct MyMod {
greeted: u32,
}
impl Mod for MyMod {
fn init() -> Result<(Self, Registration), String> {
Ok((MyMod { greeted: 0 }, Registration::new().command("hello")))
}
fn command(&mut self, _name: &str, args: &str) -> Result<Option<String>, String> {
self.greeted += 1;
chat::show(&format!("Hello {args}! ({} so far)", self.greeted));
Ok(None)
}
}
horizon_mod_sdk::export_mod!(MyMod);
@@ -1,14 +0,0 @@
# `cargo horizon-mod test` runs every tests/*.toml against the built mod, with no game:
# steps happen in order, and `expect` checks what the mod showed during the step.
# Every key: docs/mods.md, "Testing a mod".
[[step]]
expect.commands = ["hello"]
[[step]]
command = "hello world"
expect.chat = ["Hello world! (1 so far)"]
[[step]]
command = "hello again"
expect.chat = ["Hello again! (2 so far)"]
@@ -494,7 +494,11 @@ pub fn canonical(tag: &Tag) -> Option<Tag> {
_ => string("minecraft:air"),
},
);
f.put("facing", Tag::Int(int(tag.get("facing")).unwrap_or(0)));
// Direction.LEGACY_ID_CODEC: the 3D data value as a byte.
f.put(
"facing",
Tag::Byte(int(tag.get("facing")).unwrap_or(0) as i8),
);
f.float("progress", 0.0);
f.flag("extending", false);
f.flag("source", false);
@@ -873,6 +877,23 @@ mod tests {
);
}
/// `PistonMovingBlockEntity.saveAdditional`: `facing` through
/// `Direction.LEGACY_ID_CODEC` (a byte), `progress` a float.
#[test]
fn moving_piston_fields_match_vanilla() {
let out = canon(
"{id:\"minecraft:piston\",x:1,y:2,z:3,blockState:{Name:\"minecraft:stone\"},\
facing:5,progress:0.5f,extending:1b,source:0b}",
);
assert_eq!(out.get("facing"), Some(&Tag::Byte(5)));
assert_eq!(out.get("progress"), Some(&f32_tag(0.5)));
assert_eq!(out.get("extending"), Some(&Tag::Byte(1)));
assert_eq!(out.get("source"), Some(&Tag::Byte(0)));
let fresh = canon("{id:\"minecraft:piston\",x:1,y:2,z:3}");
assert_eq!(fresh.get("facing"), Some(&Tag::Byte(0)));
assert_eq!(fresh.get("blockState"), Some(&string("minecraft:air")));
}
#[test]
fn loot_table_replaces_items_and_zero_seed_is_omitted() {
let out = canon(
+146 -2
View File
@@ -1,6 +1,6 @@
//! Text components from commands: `ComponentArgument` reads an SNBT (or JSON)
//! value, `ComponentSerialization` turns it into styled text.
use crate::text::{Style, Text, named_color};
use crate::text::{InlineObject, Style, Text, named_color};
use horizon_world::nbt::Tag;
use serde_json::{Map, Value};
@@ -195,6 +195,7 @@ fn object_style(o: &serde_json::Map<String, Value>) -> Style {
.and_then(|h| u32::from_str_radix(h, 16).ok())
});
}
style.shadow_color = o.get("shadow_color").and_then(shadow_color);
let flag = |k: &str| o.get(k).and_then(Value::as_bool);
style.bold = flag("bold");
style.italic = flag("italic");
@@ -256,6 +257,28 @@ fn object_style(o: &serde_json::Map<String, Value>) -> Style {
.map(str::to_owned);
style
}
/// `Style.shadow_color`: a packed ARGB int or normalized RGBA array.
pub(crate) fn shadow_color(value: &Value) -> Option<u32> {
if let Some(packed) = value.as_i64() {
return i32::try_from(packed).ok().map(|n| n as u32);
}
let rgba = value.as_array()?;
if rgba.len() != 4 {
return None;
}
let channels = rgba
.iter()
.map(|channel| {
// ExtraCodecs.ARGB_COLOR_CODEC reads floats; ARGB.as8BitChannel
// floors each normalized channel multiplied by 255.
let value = channel.as_f64()? as f32;
(value.is_finite() && (0.0..=1.0).contains(&value))
.then_some((value * 255.0_f32).floor() as u32)
})
.collect::<Option<Vec<_>>>()?;
Some(channels[3] << 24 | channels[0] << 16 | channels[1] << 8 | channels[2])
}
/// `ComponentSerialization.CODEC` decoding into text. `selector` components
/// are resolved by `resolve` (ComponentUtils.updateForEntity).
pub fn to_text(v: &Value, resolve: &mut dyn FnMut(&str) -> Text) -> Text {
@@ -296,6 +319,10 @@ pub fn to_text_with(
let style = object_style(o);
let content = if let Some(t) = contents(o) {
t
} else if let Some(object) = inline_object(o, resolve, contents) {
let mut text = Text::literal("\u{fffc}");
text.spans[0].style.inline_object = Some(object);
text
} else if let Some(t) = o.get("text") {
match t {
Value::String(s) => Text::literal(s.clone()),
@@ -332,12 +359,74 @@ pub fn to_text_with(
{
out.push(to_text_with(extra, resolve, contents).with_style(&style));
}
out
if o.contains_key("object") || o.contains_key("player") || o.contains_key("sprite") {
out.with_component(Value::Object(o.clone()))
} else {
out
}
}
Value::Null => Text::empty(),
}
}
fn inline_object(
object: &serde_json::Map<String, Value>,
resolve: &mut dyn FnMut(&str) -> Text,
contents: &mut dyn FnMut(&serde_json::Map<String, Value>) -> Option<Text>,
) -> Option<InlineObject> {
let kind = object.get("object").and_then(Value::as_str);
let fallback = object
.get("fallback")
.map(|value| to_text_with(value, resolve, contents).plain());
match kind {
Some("player") | None if object.contains_key("player") => {
let profile = object.get("player")?.clone();
// PlayerSprite.defaultFallback.
let fallback = fallback.unwrap_or_else(|| {
match profile
.as_str()
.or_else(|| profile.get("name").and_then(Value::as_str))
{
Some(name) => format!("[{name} head]"),
None => "[unknown player head]".to_owned(),
}
});
Some(InlineObject::Player {
profile,
hat: object.get("hat").and_then(Value::as_bool).unwrap_or(true),
fallback,
})
}
Some("atlas") | None if object.contains_key("sprite") => {
let atlas = object
.get("atlas")
.and_then(Value::as_str)
.unwrap_or("minecraft:blocks")
.to_owned();
let sprite = object.get("sprite")?.as_str()?.to_owned();
// AtlasSprite.defaultFallback: short names, the atlas unless blocks.
let fallback = fallback.unwrap_or_else(|| {
let short = |id: &str| match id.split_once(':') {
Some(("minecraft", path)) => path.to_owned(),
Some(_) => id.to_owned(),
None => id.to_owned(),
};
if short(&atlas) == "blocks" {
format!("[{}]", short(&sprite))
} else {
format!("[{}@{}]", short(&sprite), short(&atlas))
}
});
Some(InlineObject::Atlas {
atlas,
sprite,
fallback,
})
}
_ => None,
}
}
/// `CustomName` as text: a string keeps its legacy formatting codes, anything
/// else is a component (`ComponentSerialization`).
pub fn name_text(tag: &Tag) -> Text {
@@ -351,6 +440,61 @@ pub fn name_text(tag: &Tag) -> Text {
mod tests {
use super::*;
#[test]
fn shadow_color_accepts_packed_and_rgba_styles() {
let packed = serde_json::json!({"text": "A", "shadow_color": -16711936});
let text = to_text(&packed, &mut |_| Text::empty());
assert_eq!(text.spans[0].style.shadow_color, Some(0xff00ff00));
assert_eq!(text.component()["shadow_color"], -16711936);
let rgba = serde_json::json!({"text": "A", "shadow_color": [1, 0, 0.5, 1]});
let text = to_text(&rgba, &mut |_| Text::empty());
assert_eq!(text.spans[0].style.shadow_color, Some(0xffff007f));
let translucent = serde_json::json!({"text": "A", "shadow_color": [1, 0, 0.5, 0.5]});
let text = to_text(&translucent, &mut |_| Text::empty());
assert_eq!(text.spans[0].style.shadow_color, Some(0x7fff007f));
let no_shadow = serde_json::json!({"text": "A", "shadow_color": 0});
let text = to_text(&no_shadow, &mut |_| Text::empty());
assert_eq!(text.spans[0].style.shadow_color, Some(0));
}
#[test]
fn player_object_survives_text_parsing_and_keeps_its_siblings() {
let component = serde_json::json!({
"text": "<",
"extra": [
{"object": "player", "player": {"name": "elyapluggin"}, "hat": false,
"fallback": "[head]"},
{"text": " >", "color": "gold"}
]
});
let text = to_text(&component, &mut |_| Text::empty());
assert_eq!(text.plain(), "<[head] >");
assert!(matches!(
&text.spans[1].style.inline_object,
Some(InlineObject::Player { hat: false, .. })
));
assert!(text.spans[2].style.inline_object.is_none());
assert_eq!(text.component(), component);
let sprite = serde_json::json!({"object": "atlas", "sprite": "minecraft:item/porkchop"});
let text = to_text(&sprite, &mut |_| Text::empty());
assert!(matches!(
&text.spans[0].style.inline_object,
Some(InlineObject::Atlas { sprite, .. }) if sprite == "minecraft:item/porkchop"
));
// ObjectContents.visit without a fallback: the sprite's defaultFallback.
assert_eq!(text.plain(), "[item/porkchop]");
let head = serde_json::json!({"object": "player", "player": {"name": "Steve"}});
assert_eq!(
to_text(&head, &mut |_| Text::empty()).plain(),
"[Steve head]"
);
let gui = serde_json::json!({"atlas": "gui", "sprite": "hud/heart/full"});
assert_eq!(
to_text(&gui, &mut |_| Text::empty()).plain(),
"[hud/heart/full@gui]"
);
}
#[test]
fn snbt_and_json_components() {
let input = "{text:'Hi ',color:red,extra:[{text:\"there\",bold:1b}]} rest";
+13
View File
@@ -60,6 +60,9 @@ impl ResourceSource for Bundled {
"assets/horizon/lang/ru_ru.json" => {
include_str!("../../horizon-client/assets/horizon/lang/ru_ru.json")
}
"assets/horizon/lang/pl_pl.json" => {
include_str!("../../horizon-client/assets/horizon/lang/pl_pl.json")
}
"assets/horizon/lang/uk_ua.json" => {
include_str!("../../horizon-client/assets/horizon/lang/uk_ua.json")
}
@@ -269,6 +272,16 @@ mod tests {
assert_eq!(en.get("horizon.options.controller"), Some("Controller"));
let ru = Language::load(&[&empty], "ru_ru");
assert_eq!(ru.get("horizon.options.controller"), Some("Геймпад"));
// Every bundled string carries both languages: a key added to one file
// and forgotten in the other shows as a key name on screen.
for key in en.map().keys() {
assert!(ru.get(key).is_some(), "{key} has no ru_ru value");
}
assert_eq!(en.get("horizon.pages.mods"), Some("Mods"));
assert_eq!(ru.get("horizon.pages.mods"), Some("Моды"));
let pl = Language::load(&[&empty], "pl_pl");
assert_eq!(pl.get("horizon.options.controller"), Some("Kontroler"));
assert_eq!(pl.get("horizon.options.vibration"), Some("Wibracje"));
let uk = Language::load(&[&empty], "uk_ua");
assert_eq!(
uk.get("horizon.options.on_screen_keyboard"),
+2 -1
View File
@@ -91,6 +91,7 @@ pub fn effect(id: &str) -> Option<&'static EffectDef> {
/// Namespaced effect ids for mob effect storage.
pub fn namespaced(id: &'static str) -> &'static str {
static IDS: OnceLock<HashMap<&'static str, &'static str>> = OnceLock::new();
let key = id.strip_prefix("minecraft:").unwrap_or(id);
IDS.get_or_init(|| {
EFFECTS
.iter()
@@ -102,7 +103,7 @@ pub fn namespaced(id: &'static str) -> &'static str {
})
.collect()
})
.get(id)
.get(key)
.copied()
.unwrap_or(id)
}
@@ -112,6 +112,7 @@ pub fn style_from_json(v: &Value) -> Style {
.and_then(|h| u32::from_str_radix(h, 16).ok())
});
}
s.shadow_color = o.get("shadow_color").and_then(component::shadow_color);
let flag = |k: &str| o.get(k).and_then(Value::as_bool);
s.bold = flag("bold");
s.italic = flag("italic");
@@ -1563,3 +1564,49 @@ mod tests {
);
}
}
/// `DamageSource.getLocalizedDeathMessage`: the cause's item with a custom
/// name gets the `<id>.item` message, with the item's name as a third
/// argument.
pub fn death_message_args(
key: &str,
victim: &Text,
cause: &Text,
item: Option<&Text>,
) -> (String, Vec<Text>) {
match item {
Some(item) => (
format!("{key}.item"),
vec![victim.clone(), cause.clone(), item.clone()],
),
None => (key.to_owned(), vec![victim.clone(), cause.clone()]),
}
}
#[cfg(test)]
mod death_message_tests {
use super::*;
#[test]
fn the_death_message_names_the_causes_item_when_it_has_a_custom_name() {
let victim = Text::literal("Steve");
let cause = Text::literal("Zombie");
// DamageSource.getLocalizedDeathMessage: the item variant, with the
// item's name as the third argument.
let (key, args) = death_message_args(
"death.attack.arrow",
&victim,
&cause,
Some(&Text::literal("My Sword")),
);
assert_eq!(key, "death.attack.arrow.item");
assert_eq!(args.len(), 3);
assert_eq!(args[0].plain(), "Steve");
assert_eq!(args[1].plain(), "Zombie");
assert_eq!(args[2].plain(), "My Sword");
// Without a custom name the message keeps its two arguments.
let (key, args) = death_message_args("death.attack.arrow", &victim, &cause, None);
assert_eq!(key, "death.attack.arrow");
assert_eq!(args.len(), 2);
}
}
+39 -2
View File
@@ -58,10 +58,34 @@ pub enum ClickEvent {
OpenFile(String),
}
/// A non-character `minecraft:object` embedded in a text component.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum InlineObject {
Player {
profile: Value,
hat: bool,
fallback: String,
},
Atlas {
atlas: String,
sprite: String,
fallback: String,
},
}
impl InlineObject {
pub fn fallback(&self) -> &str {
match self {
Self::Player { fallback, .. } | Self::Atlas { fallback, .. } => fallback,
}
}
}
/// Text style; `None` fields inherit from the enclosing style.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Style {
pub color: Option<u32>,
/// Text component `shadow_color` as packed ARGB. Zero suppresses shadows.
pub shadow_color: Option<u32>,
pub bold: Option<bool>,
pub italic: Option<bool>,
pub underlined: Option<bool>,
@@ -77,6 +101,8 @@ pub struct Style {
/// The font (`FontDescription.Resource`), a full id like
/// `minecraft:alt`; the default font when unset.
pub font: Option<String>,
/// Content metadata for a single U+FFFC object span; never inherited.
pub inline_object: Option<InlineObject>,
}
/// A style's `font` field: an identifier (`alt` is `minecraft:alt`).
pub fn font_id(v: &Value) -> Option<String> {
@@ -92,6 +118,7 @@ impl Style {
/// Fill unset fields from `base` (Style.applyTo).
pub fn inherit(&mut self, base: &Style) {
self.color = self.color.or(base.color);
self.shadow_color = self.shadow_color.or(base.shadow_color);
self.bold = self.bold.or(base.bold);
self.italic = self.italic.or(base.italic);
self.underlined = self.underlined.or(base.underlined);
@@ -337,7 +364,13 @@ impl Text {
}
/// Component.getString: the plain characters.
pub fn plain(&self) -> String {
self.spans.iter().map(|s| s.text.as_str()).collect()
self.spans
.iter()
.map(|s| match &s.style.inline_object {
Some(object) if s.text == "\u{fffc}" => object.fallback(),
_ => s.text.as_str(),
})
.collect()
}
pub fn is_empty(&self) -> bool {
self.spans.iter().all(|s| s.text.is_empty())
@@ -490,8 +523,9 @@ impl Text {
}
/// The style fields of a component's JSON.
const STYLE_KEYS: [&str; 10] = [
const STYLE_KEYS: [&str; 11] = [
"color",
"shadow_color",
"bold",
"italic",
"underlined",
@@ -512,6 +546,9 @@ fn style_json(style: &Style) -> Vec<(String, Value)> {
.map_or_else(|| format!("#{c:06X}"), |n| n.1.to_owned());
out.push(("color".to_owned(), name.into()));
}
if let Some(shadow) = style.shadow_color {
out.push(("shadow_color".to_owned(), Value::from(shadow as i32)));
}
for (key, flag) in [
("bold", style.bold),
("italic", style.italic),
+91 -5
View File
@@ -144,6 +144,7 @@ type Stream = cpal::Stream;
type Stream = std::convert::Infallible;
pub struct Audio {
_stream: Option<Stream>,
device_name: String,
pub engine: SoundEngine,
backend: MixerBackend,
/// Record `Heard` sounds (the subtitle overlay is listening).
@@ -543,15 +544,48 @@ fn build_stream<T: cpal::SizedSample + cpal::FromSample<f32>>(
)?;
Ok(stream)
}
/// Available audio output device names.
#[cfg(feature = "audio")]
pub fn output_devices() -> Vec<String> {
if output_disabled(|name| std::env::var_os(name)).is_some() {
return Vec::new();
}
let host = cpal::default_host();
let mut names = Vec::new();
if let Ok(devices) = host.output_devices() {
for device in devices {
if let Ok(name) = device.name()
&& !names.contains(&name)
{
names.push(name);
}
}
}
names
}
#[cfg(not(feature = "audio"))]
pub fn output_devices() -> Vec<String> {
Vec::new()
}
impl Audio {
pub fn new(root: &Path) -> Self {
Self::with_output(root, true)
Self::with_device(root, "")
}
pub fn with_device(root: &Path, device_name: &str) -> Self {
Self::with_output(root, true, device_name)
}
/// No output device: the dedicated server.
pub fn silent(root: &Path) -> Self {
Self::with_output(root, false)
Self::with_output(root, false, "")
}
fn with_output(root: &Path, output: bool) -> Self {
/// The output device asked for (`soundDevice`), empty for the system's default; the
/// default plays when that one can't be opened, as in vanilla.
pub fn device(&self) -> &str {
&self.device_name
}
fn with_output(root: &Path, output: bool, target_device: &str) -> Self {
let registry = SoundRegistry::load(root);
if output && registry.is_empty() {
// The game then plays nothing on a working output (#1035, #1076).
@@ -564,7 +598,7 @@ impl Audio {
}
let mixer = Arc::new(Mutex::new(Mixer::default()));
let opened = if output {
Self::open(mixer.clone())
Self::open_named(mixer.clone(), target_device)
} else {
Err("no output on a dedicated server".into())
};
@@ -581,6 +615,7 @@ impl Audio {
engine.loaded = live;
Self {
_stream: stream,
device_name: target_device.to_owned(),
engine,
backend: MixerBackend {
mixer,
@@ -602,10 +637,61 @@ impl Audio {
}
}
#[cfg(not(feature = "audio"))]
fn open(_: Arc<Mutex<Mixer>>) -> Result<Stream> {
fn open_named(_: Arc<Mutex<Mixer>>, _: &str) -> Result<Stream> {
Err("this build has no sound output (built without the `audio` feature)".into())
}
#[cfg(feature = "audio")]
fn open_named(mixer: Arc<Mutex<Mixer>>, target: &str) -> Result<Stream> {
if let Some(reason) = output_disabled(|name| std::env::var_os(name)) {
return Err(reason.into());
}
let host = cpal::default_host();
if !target.is_empty()
&& let Ok(devices) = host.output_devices()
{
for device in devices {
let name = device.name().unwrap_or_else(|_| "unnamed device".into());
if name == target {
match open_device(&device, mixer.clone()) {
Ok((stream, config)) => {
log::info!(
"Horizon: audio output {name} ({} Hz, {} channels, {})",
config.sample_rate().0,
config.channels(),
config.sample_format()
);
return Ok(stream);
}
Err(e) => {
log::warn!(
"Horizon: could not open audio device '{target}': {e}; falling back to default"
);
}
}
break;
}
}
}
Self::open(mixer)
}
#[cfg(feature = "audio")]
pub fn set_device(&mut self, name: &str) -> Result<()> {
if !self.backend.live && self._stream.is_none() && !self.engine.loaded {
self.device_name = name.to_owned();
return Ok(());
}
self._stream = Some(Self::open_named(self.backend.mixer.clone(), name)?);
self.device_name = name.to_owned();
self.backend.live = true;
self.engine.loaded = true;
Ok(())
}
#[cfg(not(feature = "audio"))]
pub fn set_device(&mut self, name: &str) -> Result<()> {
self.device_name = name.to_owned();
Ok(())
}
#[cfg(feature = "audio")]
fn open(mixer: Arc<Mutex<Mixer>>) -> Result<Stream> {
if let Some(reason) = output_disabled(|name| std::env::var_os(name)) {
return Err(reason.into());
@@ -64,6 +64,10 @@ pub struct BlockEntities {
/// Positions whose block entity `take` took and `put` has not brought
/// back.
out: FxHashSet<Pos>,
/// Positions a loaded chunk gave a new block entity over an old one: at
/// the next tick their slot stays only if their block state ticks
/// (`updateBlockEntityTicker`: rebind, or remove without a ticker).
replaced: FxHashSet<Pos>,
/// `Ticking` by block state id; empty until the game builds it.
states: Vec<Ticking>,
}
@@ -239,15 +243,20 @@ impl BlockEntities {
self.added.clear();
self.pending.clear();
self.out.clear();
self.replaced.clear();
self.freed = false;
}
/// A loaded or generated chunk's block entities, in the order the chunk
/// lists them; they tick in its block entity map's order
/// (`registerAllBlockEntitiesAfterLevelLoad`).
/// (`registerAllBlockEntitiesAfterLevelLoad`). One replacing an entity
/// already at its position (`LevelChunk.setBlockEntity`) drops that one;
/// its slot is decided by the block state at the next tick.
pub fn load_chunk(&mut self, entities: Vec<(Pos, BlockEntity)>) {
let order = chunk_map_order(entities.iter().map(|(p, _)| *p));
for (p, e) in entities {
self.map.insert(p, e);
if self.map.insert(p, e).is_some() {
self.replaced.insert(p);
}
}
for p in order {
self.add(p);
@@ -291,6 +300,11 @@ impl BlockEntities {
self.live.insert(pos, slot);
Arc::make_mut(&mut self.tickers).push((pos, slot));
}
for pos in std::mem::take(&mut self.replaced) {
if self.live.contains_key(&pos) && self.ticking(state(pos)) != Ticking::Yes {
self.cancel(&pos);
}
}
if !std::mem::take(&mut self.freed) && self.out.is_empty() {
return Arc::clone(&self.tickers);
}
@@ -390,4 +404,33 @@ mod tests {
let order = chunk_map_order(many.iter().copied().chain(many.iter().copied()));
assert_eq!(order.len(), 100);
}
/// A chunk loaded over a block entity already there: the new one replaces
/// it, and keeps the old slot only while its block state ticks.
#[test]
fn loaded_chunk_replaces_block_entity_by_state() {
let mut b = level();
let mut s = HashMap::new();
let (p, q) = ((0, 0, 0), (1, 0, 0));
for pos in [p, q] {
s.insert(pos, 1);
}
// Two chunks, so the order is the order they load in.
b.load_chunk(vec![(p, hopper())]);
b.load_chunk(vec![(q, hopper())]);
assert_eq!(order(&mut b, &s), [p, q]);
// Replaced where the state ticks: the slot stays in its place.
b.load_chunk(vec![(p, hopper())]);
assert_eq!(order(&mut b, &s), [p, q]);
// Replaced where the state has no ticker: the slot is freed, and the
// new block entity is the one in the map.
s.insert(p, 2);
b.load_chunk(vec![(p, BlockEntity::Chest(vec![None; 27]))]);
assert_eq!(order(&mut b, &s), [q]);
assert!(matches!(b.get(&p), Some(BlockEntity::Chest(items)) if items.len() == 27));
// Ticking again later appends it, as any new ticker does.
s.insert(p, 1);
b.state_changed(p, 2, 1);
assert_eq!(order(&mut b, &s), [q, p]);
}
}
+16 -6
View File
@@ -50,20 +50,30 @@ pub fn tooltip(
Category::Neutral,
)];
}
def.effects
.iter()
effect_lines(def.effects.iter().copied(), scale, translate)
}
/// `PotionContents.addPotionTooltip`'s line per effect: its name, potency and
/// scaled duration, for (effect id, duration, amplifier) entries.
pub fn effect_lines<'a>(
effects: impl IntoIterator<Item = (&'a str, i32, i32)>,
scale: f32,
translate: &dyn Fn(&str) -> Option<String>,
) -> Vec<(String, Category)> {
effects
.into_iter()
.map(|(e, duration, amp)| {
let e = e.trim_start_matches("minecraft:");
let mut line =
translate(&format!("effect.minecraft.{e}")).unwrap_or_else(|| (*e).to_owned());
if *amp > 0 {
translate(&format!("effect.minecraft.{e}")).unwrap_or_else(|| e.to_owned());
if amp > 0 {
line.push(' ');
line.push_str(
&translate(&format!("potion.potency.{amp}"))
.unwrap_or_else(|| crate::enchant::level_name(*amp as u32 + 1)),
.unwrap_or_else(|| crate::enchant::level_name(amp as u32 + 1)),
);
}
let instant = effect(e).is_some_and(|d| d.instant);
let scaled = (*duration as f32 * scale) as i32;
let scaled = (duration as f32 * scale) as i32;
if !instant && scaled > 20 {
line = format!("{line} ({})", format_duration(scaled));
}
+177 -43
View File
@@ -653,6 +653,42 @@ pub struct Commands<'a> {
pub registries: &'a Registries,
}
/// `CommandSuggestions`' constructor arguments: how a screen's suggestions
/// parse and where they are drawn.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct SuggestLayout {
/// `commandsOnly`: the input is a command without a leading `/`.
pub commands_only: bool,
/// `onlyShowIfCursorPastError`: completions from the command's start,
/// not only past the `/`.
pub only_past_error: bool,
/// `suggestionLineLimit`.
pub line_limit: usize,
/// `anchorToBottom`: the list and usage over a box at the bottom, else
/// under one at the top (y 72).
pub anchor_to_bottom: bool,
/// `fillColor`: behind the list and the usage lines.
pub fill: i32,
}
impl SuggestLayout {
/// ChatScreen.init's `CommandSuggestions`.
pub const CHAT: Self = Self {
commands_only: false,
only_past_error: false,
line_limit: 10,
anchor_to_bottom: true,
fill: FILL,
};
/// AbstractCommandBlockEditScreen.init's `CommandSuggestions`.
pub const COMMAND_BLOCK: Self = Self {
commands_only: true,
only_past_error: true,
line_limit: 7,
anchor_to_bottom: false,
fill: i32::MIN,
};
}
/// The open chat screen: vanilla's `ChatScreen` with its `EditBox` and
/// `CommandSuggestions` (argument colouring, usage lines, parse errors,
/// the suggestion list, Tab completion, history).
@@ -680,6 +716,7 @@ pub struct ChatScreen {
pub auto_suggestions: bool,
/// `CommandSuggestions.setRestrictions`: what the Chat option allows.
pub abilities: ChatAbilities,
pub layout: SuggestLayout,
}
/// Local completions waiting for the server's reply to request `id`.
struct Awaiting {
@@ -750,6 +787,43 @@ impl ChatScreen {
keep_suggestions: false,
auto_suggestions: true,
abilities,
layout: SuggestLayout::CHAT,
};
s.update_command_info(m, c);
s
}
/// `AbstractCommandBlockEditScreen.init`: the command box at the top,
/// focused, holding `command`, with suggestions from the start.
pub fn command_block(
m: &Measure,
c: &Commands,
command: &str,
width: i32,
height: i32,
) -> Self {
let mut input = TextField::new(width / 2 - 150, 50, 300, 20);
input.max_length = 32500;
input.focused = true;
input.set_value(m, command);
let mut s = Self {
input,
width,
height,
history_buffer: String::new(),
history_pos: 0,
parse: None,
pending: None,
awaiting: None,
request: None,
suggestions: None,
usage: Vec::new(),
usage_position: 0,
usage_width: 0,
allow_suggestions: true,
keep_suggestions: false,
auto_suggestions: true,
abilities: ChatAbilities::new(ChatVisibility::Full),
layout: SuggestLayout::COMMAND_BLOCK,
};
s.update_command_info(m, c);
s
@@ -768,7 +842,11 @@ impl ChatScreen {
let value = self.input.value.clone();
let auto = self.auto_suggestions;
let abilities = self.abilities;
*self = Self::new(m, c, &value, self.history_pos, width, height, abilities);
*self = if self.layout == SuggestLayout::CHAT {
Self::new(m, c, &value, self.history_pos, width, height, abilities)
} else {
Self::command_block(m, c, &value, width, height)
};
self.auto_suggestions = auto;
}
}
@@ -814,11 +892,18 @@ impl ChatScreen {
self.usage.clear();
let cursor = self.input.cursor;
let cursor_b = byte_at(&command, cursor);
if command.starts_with('/') {
let slash = command.starts_with('/');
if self.layout.commands_only || slash {
let start = usize::from(slash);
if self.parse.is_none() {
self.parse = Some(c.dispatcher.parse_at(&command, 1, c.registries));
self.parse = Some(c.dispatcher.parse_at(&command, start, c.registries));
}
if cursor >= 1 && (self.suggestions.is_none() || !self.keep_suggestions) {
let from = if self.layout.only_past_error {
start
} else {
1
};
if cursor >= from && (self.suggestions.is_none() || !self.keep_suggestions) {
let parse = self.parse.as_ref().expect("parsed above");
let (range, list, ask) =
c.dispatcher
@@ -1002,7 +1087,11 @@ impl ChatScreen {
0,
self.input.screen_x(m, 0) + self.input.inner_width() - width,
);
let y = self.height - 12;
let y = if self.layout.anchor_to_bottom {
self.height - 12
} else {
72
};
// sortSuggestions: those starting with the word at the cursor first.
let partial = &value[..byte_at(&value, self.input.cursor)];
let word = partial[last_word_index(partial)..].to_lowercase();
@@ -1011,9 +1100,16 @@ impl ChatScreen {
s.text.starts_with(&word) || s.text.starts_with(&format!("minecraft:{word}"))
});
first.extend(rest);
let n = first.len().min(10) as i32;
// SuggestionsList: beside a bordered box, or over an unbordered one.
let n = first.len().min(self.layout.line_limit) as i32;
let bordered = i32::from(self.input.bordered);
let top = if self.layout.anchor_to_bottom {
y - 3 - n * 12
} else {
y - bordered
};
let mut l = SuggestionList {
rect: [x - 1, y - 3 - n * 12, width + 1, n * 12],
rect: [x - (1 - bordered), top, width + 1, n * 12],
original: value,
list: first,
offset: 0,
@@ -1042,11 +1138,12 @@ impl ChatScreen {
}
fn cycle(&mut self, l: &mut SuggestionList, dir: isize) {
self.select(l, l.current as isize + dir);
let max = l.list.len().saturating_sub(10);
let limit = self.layout.line_limit;
let max = l.list.len().saturating_sub(limit);
if l.current < l.offset {
l.offset = l.current.min(max);
} else if l.current > l.offset + 9 {
l.offset = (l.current as isize + 1 - 10).clamp(0, max as isize) as usize;
} else if l.current >= l.offset + limit {
l.offset = (l.current as isize + 1 - limit as isize).clamp(0, max as isize) as usize;
}
}
/// `SuggestionsList.useSuggestion`.
@@ -1074,7 +1171,57 @@ impl ChatScreen {
ctrl: bool,
) -> ChatAction {
use winit::keyboard::KeyCode as K;
// CommandSuggestions.keyPressed.
if self.suggestions_key(m, c, key, shift) {
return ChatAction::None;
}
match key {
K::Escape => return ChatAction::Close,
K::Enter | K::NumpadEnter => {
// ChatScreen.keyPressed: without `hasAllowedInput` the input
// stays, unsent, and the screen stays open.
if !self.abilities.allows(&self.input.value) {
return ChatAction::None;
}
return ChatAction::Submit(self.normalized());
}
K::ArrowUp => self.move_in_history(m, c, history, -1),
K::ArrowDown => self.move_in_history(m, c, history, 1),
_ => {
self.field_key(m, c, key, shift, ctrl);
}
}
ChatAction::None
}
/// The input box's own keys (`EditBox.keyPressed`), the suggestions
/// following an edit: whether the box took the key.
pub fn field_key(
&mut self,
m: &Measure,
c: &Commands,
key: winit::keyboard::KeyCode,
shift: bool,
ctrl: bool,
) -> bool {
match self.input.key(m, key, shift, ctrl) {
Some(changed) => {
if changed {
self.edited(m, c);
}
true
}
None => false,
}
}
/// `CommandSuggestions.keyPressed`: the list's arrows, Tab and Escape,
/// and Tab to show it. True when the suggestions took the key.
pub fn suggestions_key(
&mut self,
m: &Measure,
c: &Commands,
key: winit::keyboard::KeyCode,
shift: bool,
) -> bool {
use winit::keyboard::KeyCode as K;
if let Some(mut l) = self.suggestions.take() {
let handled = match key {
K::ArrowUp | K::ArrowDown => {
@@ -1091,37 +1238,18 @@ impl ChatScreen {
}
K::Escape => {
self.input.suggestion = None;
return ChatAction::None;
return true;
}
_ => false,
};
self.suggestions = Some(l);
if handled {
return ChatAction::None;
}
handled
} else if key == K::Tab {
self.show_suggestions(m);
return ChatAction::None;
true
} else {
false
}
match key {
K::Escape => return ChatAction::Close,
K::Enter | K::NumpadEnter => {
// ChatScreen.keyPressed: without `hasAllowedInput` the input
// stays, unsent, and the screen stays open.
if !self.abilities.allows(&self.input.value) {
return ChatAction::None;
}
return ChatAction::Submit(self.normalized());
}
K::ArrowUp => self.move_in_history(m, c, history, -1),
K::ArrowDown => self.move_in_history(m, c, history, 1),
_ => {
if self.input.key(m, key, shift, ctrl) == Some(true) {
self.edited(m, c);
}
}
}
ChatAction::None
}
/// `ChatScreen.moveInHistory`.
fn move_in_history(&mut self, m: &Measure, c: &Commands, history: &[String], dir: isize) {
@@ -1175,7 +1303,7 @@ impl ChatScreen {
if !l.contains(mouse.0, mouse.1) {
return false;
}
let max = l.list.len().saturating_sub(10) as i32;
let max = l.list.len().saturating_sub(self.layout.line_limit) as i32;
l.offset = ((l.offset as f64 - amount.clamp(-1.0, 1.0)) as i32).clamp(0, max) as usize;
true
}
@@ -1246,7 +1374,9 @@ impl ChatScreen {
) -> Vec<Draw> {
let (w, h) = (self.width, self.height);
let mut out = Vec::new();
d::fill(&mut out, 2, h - 14, w - 2, h - 2, background);
if self.layout.anchor_to_bottom {
d::fill(&mut out, 2, h - 14, w - 2, h - 2, background);
}
let format = |s: &str, o: usize| self.format(s, o);
self.input.draw(&mut out, m, blink_ms, Some(&format));
if let Some(mut l) = self.suggestions.take() {
@@ -1254,14 +1384,18 @@ impl ChatScreen {
self.suggestions = Some(l);
} else {
for (i, line) in self.usage.iter().enumerate() {
let y = h - 27 - 12 * i as i32;
let y = if self.layout.anchor_to_bottom {
h - 27 - 12 * i as i32
} else {
72 + 12 * i as i32
};
d::fill(
&mut out,
self.usage_position - 1,
y,
self.usage_position + self.usage_width + 1,
y + 12,
FILL,
self.layout.fill,
);
d::text(&mut out, line.clone(), self.usage_position, y + 2, -1, true);
}
@@ -1275,7 +1409,7 @@ impl ChatScreen {
l: &mut SuggestionList,
mouse: (i32, i32),
) {
let limit = l.list.len().min(10);
let limit = l.list.len().min(self.layout.line_limit);
let [rx, ry, rw, rh] = l.rect;
let has_prev = l.offset > 0;
let has_next = l.list.len() > l.offset + limit;
@@ -1284,8 +1418,8 @@ impl ChatScreen {
l.last_mouse = mouse;
}
if has_prev || has_next {
d::fill(out, rx, ry - 1, rx + rw, ry, FILL);
d::fill(out, rx, ry + rh, rx + rw, ry + rh + 1, FILL);
d::fill(out, rx, ry - 1, rx + rw, ry, self.layout.fill);
d::fill(out, rx, ry + rh, rx + rw, ry + rh + 1, self.layout.fill);
if has_prev {
for x in (0..rw).step_by(2) {
d::fill(out, rx + x, ry - 1, rx + x + 1, ry, -1);
@@ -1300,7 +1434,7 @@ impl ChatScreen {
let mut hovered = false;
for i in 0..limit {
let y = ry + 12 * i as i32;
d::fill(out, rx, y, rx + rw, y + 12, FILL);
d::fill(out, rx, y, rx + rw, y + 12, self.layout.fill);
if mouse.0 > rx && mouse.0 < rx + rw && mouse.1 > y && mouse.1 < y + 12 {
if moved {
self.select(l, (i + l.offset) as isize);
@@ -74,9 +74,28 @@ pub enum BlockEntity {
/// `DaylightDetectorBlockEntity`: no data, only the ticker
/// (`DaylightDetectorBlock.tickEntity`).
DaylightDetector,
/// Suspicious sand and gravel: saved loot and the transient brushed face.
Brushable(Box<Brushable>),
/// A shelf holds three displayed item stacks.
Shelf(Box<Shelf>),
/// `ChiseledBookShelfBlockEntity`: a `Container` of six one-book slots.
ChiseledBookshelf(Box<ChiseledBookshelf>),
}
/// `BrushableBlockEntity`: the loot table is resolved on the first brush.
#[derive(Clone, Debug, Default)]
pub struct Brushable {
pub loot_table: Option<horizon_world::nbt::Tag>,
pub loot_seed: Option<horizon_world::nbt::Tag>,
pub item: Option<ItemStack>,
/// Direction's legacy network id; not saved to disk by vanilla.
pub hit_direction: Option<i32>,
}
/// `ShelfBlockEntity`'s displayed slots and alignment flag.
#[derive(Clone, Debug, Default)]
pub struct Shelf {
pub items: [Option<ItemStack>; 3],
pub align_to_bottom: bool,
}
/// `ChiseledBookShelfBlockEntity`: six slots and `lastInteractedSlot`.
#[derive(Clone, Debug, PartialEq)]
pub struct ChiseledBookshelf {
+42 -5
View File
@@ -2,8 +2,8 @@
//! kinds, ticking as `AbstractFurnaceBlockEntity.serverTick` with fuel times
//! from the data-driven `context_int_provider`/`context_float_provider` files.
pub use crate::chunk::block_entity::{
Banner, Beacon, BlockEntity, Campfire, ChiseledBookshelf, Crafter, CreakingHeart, EndGateway,
Furnace, FurnaceKind, Jukebox, Lectern, Skull, SkullSkin,
Banner, Beacon, BlockEntity, Brushable, Campfire, ChiseledBookshelf, Crafter, CreakingHeart,
EndGateway, Furnace, FurnaceKind, Jukebox, Lectern, Shelf, Skull, SkullSkin,
};
use crate::items::ItemStack;
use std::collections::HashMap;
@@ -230,6 +230,14 @@ impl BlockEntity {
recipes_used: HashMap::new(),
}));
}
if matches!(
name,
"minecraft:suspicious_sand"
| "minecraft:suspicious_gravel"
| "minecraft:brushable_block"
) {
return Some(Self::Brushable(Box::default()));
}
if name == "minecraft:creaking_heart" {
return Some(Self::CreakingHeart(Box::default()));
}
@@ -292,6 +300,9 @@ impl BlockEntity {
if name == "minecraft:campfire" || name == "minecraft:soul_campfire" {
return Some(Self::Campfire(Box::default()));
}
if name == "minecraft:shelf" || name.ends_with("_shelf") {
return Some(Self::Shelf(Box::default()));
}
if name == "minecraft:chiseled_bookshelf" {
return Some(Self::ChiseledBookshelf(Box::default()));
}
@@ -334,8 +345,10 @@ impl BlockEntity {
Self::Skull(_) => "minecraft:skull",
Self::EndGateway(_) => "minecraft:end_gateway",
Self::DaylightDetector => "minecraft:daylight_detector",
Self::Brushable(_) => "minecraft:brushable_block",
Self::Campfire(_) => "minecraft:campfire",
Self::ChiseledBookshelf(_) => "minecraft:chiseled_bookshelf",
Self::Shelf(_) => "minecraft:shelf",
}
}
pub fn contents(&self) -> Vec<ItemStack> {
@@ -354,13 +367,15 @@ impl BlockEntity {
| Self::CommandBlock(_)
| Self::EndGateway(_)
| Self::Skull(_)
| Self::DaylightDetector => Vec::new(),
| Self::DaylightDetector
| Self::Brushable(_) => Vec::new(),
Self::Lectern(l) => l.book.iter().cloned().collect(),
Self::Sculk(_) => Vec::new(),
Self::Banner(_) => Vec::new(),
Self::Crafter(c) => c.items.iter().flatten().cloned().collect(),
Self::Campfire(c) => c.items.iter().flatten().cloned().collect(),
Self::ChiseledBookshelf(b) => b.items.iter().flatten().cloned().collect(),
Self::Shelf(b) => b.items.iter().flatten().cloned().collect(),
}
}
pub fn slots(&self) -> &[Option<ItemStack>] {
@@ -376,9 +391,11 @@ impl BlockEntity {
| Self::Banner(_)
| Self::EndGateway(_)
| Self::Skull(_)
| Self::DaylightDetector => &[],
| Self::DaylightDetector
| Self::Brushable(_) => &[],
Self::Crafter(c) => &c.items,
Self::ChiseledBookshelf(b) => &b.items,
Self::Shelf(b) => &b.items,
Self::CreakingHeart(_)
| Self::Spawner(_)
| Self::TrialSpawner(_)
@@ -402,9 +419,11 @@ impl BlockEntity {
| Self::Banner(_)
| Self::EndGateway(_)
| Self::Skull(_)
| Self::DaylightDetector => &mut [],
| Self::DaylightDetector
| Self::Brushable(_) => &mut [],
Self::Crafter(c) => &mut c.items,
Self::ChiseledBookshelf(b) => &mut b.items,
Self::Shelf(b) => &mut b.items,
Self::CreakingHeart(_)
| Self::Spawner(_)
| Self::TrialSpawner(_)
@@ -486,6 +505,24 @@ pub fn create_experience(
xp
}
/// The `MenuType` the block entity of a one-block storage container opens
/// (`createMenu`): `ChestMenu.threeRows` for chests, trapped and copper chests
/// and barrels, `ShulkerBoxMenu` for shulker boxes of any colour (its
/// `ShulkerBoxSlot`s refuse shulker boxes), `DispenserMenu` for dispensers
/// and droppers, `HopperMenu` for hoppers. None for other blocks.
pub fn block_menu_type(block: &str) -> Option<&'static str> {
Some(match block {
"minecraft:chest" | "minecraft:trapped_chest" | "minecraft:barrel" => {
"minecraft:generic_9x3"
}
n if n.ends_with("copper_chest") => "minecraft:generic_9x3",
n if n.ends_with("shulker_box") => "minecraft:shulker_box",
"minecraft:dispenser" | "minecraft:dropper" => "minecraft:generic_3x3",
"minecraft:hopper" => "minecraft:hopper",
_ => return None,
})
}
#[cfg(test)]
mod tests {
use super::*;
+49 -5
View File
@@ -479,11 +479,21 @@ impl DataStore {
/// included, each function once).
pub fn tag_functions(&self, id: &str) -> Vec<String> {
let mut out = Vec::new();
self.expand(id, &mut out, 0);
let mut seen_fns = std::collections::HashSet::new();
let mut seen_tags = std::collections::HashSet::new();
self.expand(id, &mut out, &mut seen_fns, &mut seen_tags);
out
}
fn expand(&self, id: &str, out: &mut Vec<String>, depth: usize) {
if depth > 16 {
/// Depth-first walk keeping first-seen order. Each tag is expanded at most
/// once, so reference cycles end and nesting depth is not capped.
fn expand<'a>(
&'a self,
id: &'a str,
out: &mut Vec<String>,
seen_fns: &mut std::collections::HashSet<&'a str>,
seen_tags: &mut std::collections::HashSet<&'a str>,
) {
if !seen_tags.insert(id) {
return;
}
let Some(entries) = self.function_tags.get(id) else {
@@ -491,9 +501,9 @@ impl DataStore {
};
for (e, required) in entries {
match e.strip_prefix('#') {
Some(inner) => self.expand(inner, out, depth + 1),
Some(inner) => self.expand(inner, out, seen_fns, seen_tags),
None => {
if (self.functions.contains_key(e) || *required) && !out.contains(e) {
if (self.functions.contains_key(e) || *required) && seen_fns.insert(e) {
out.push(e.clone());
}
}
@@ -767,6 +777,40 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn tag_resolution_dedupes_fast_ends_cycles_and_nests_deeply() {
let mut store = DataStore::default();
let big: Vec<(String, bool)> = (0..50_000)
.map(|i| (format!("t:f{}", i % 25_000), true))
.collect();
store.function_tags.insert("t:big".into(), big);
let start = std::time::Instant::now();
let got = store.tag_functions("t:big");
assert_eq!(got.len(), 25_000);
assert_eq!(got[0], "t:f0");
assert!(start.elapsed() < std::time::Duration::from_secs(5));
// A cycle terminates and keeps first-seen order.
store.function_tags.insert(
"t:a".into(),
vec![("#t:b".into(), true), ("t:x".into(), true)],
);
store.function_tags.insert(
"t:b".into(),
vec![("#t:a".into(), true), ("t:y".into(), true)],
);
assert_eq!(store.tag_functions("t:a"), ["t:y", "t:x"]);
// Nesting deeper than the old cap of 16 still resolves.
for i in 0..40 {
store
.function_tags
.insert(format!("d:{i}"), vec![(format!("#d:{}", i + 1), true)]);
}
store
.function_tags
.insert("d:40".into(), vec![("t:deep".into(), true)]);
assert_eq!(store.tag_functions("d:0"), ["t:deep"]);
}
#[test]
fn folder_packs_list_the_needed_kinds_in_walk_order() {
let dir = std::env::temp_dir().join(format!("hz-datapack-kinds-{}", std::process::id()));
+13 -1
View File
@@ -57,6 +57,9 @@ struct Out {
@location(11) glint_uv: vec2<f32>,
// 1 drops the quad's back-facing pixels (Vertex::normal w = 1), like a culling render type.
@location(12) @interpolate(flat) cull: u32,
// HDR tier of a light-giving particle (mesher::hdr_tier), from the overlay's
// high bits (particles.rs): fs_hdr_mark marks the frame with it.
@location(13) @interpolate(flat) tier: u32,
}
// Shipped light.glsl; normals are already transformed and RGBA8_SNORM packed.
fn entity_light(normal: vec3<f32>, nether: bool) -> f32 {
@@ -126,8 +129,9 @@ fn entity_vertex(position: vec3<f32>, uv: vec2<f32>,
@vertex fn vs_entity(@location(0) position: vec3<f32>, @location(1) uv: vec2<f32>,
@location(2) overlay: u32, @location(3) color: vec4<f32>, @location(4) light: vec4<u32>,
@location(5) normal: vec4<f32>, @location(6) glint_uv: vec2<f32>) -> Out {
var out = entity_vertex(position, uv, overlay, color, light, normal);
var out = entity_vertex(position, uv, overlay & 0xffffu, color, light, normal);
out.glint_uv = glint_uv;
out.tier = overlay >> 16u;
return out;
}
// Retained bolt-local geometry, translated using the same f64 camera
@@ -279,6 +283,14 @@ fn crumbling_uv(uv: vec2<f32>, packed_origin: vec2<f32>, sprite_size: vec2<f32>,
return vec4(in.color.rgb, 1.0);
}
// HDR output: a light-giving particle's pixels marked in the frame's alpha the
// way terrain.wgsl marks a light source's, drawn again after the entities with
// only alpha written, so its colour stays as blended.
@fragment fn fs_hdr_mark(in: Out) -> @location(0) vec4<f32> {
if in.tier == 0u || textureSample(atlas, atlas_sampler, in.uv).a < 0.1 { discard; }
return vec4(0.0, 0.0, 0.0, 0.7 * f32(4u - min(in.tier, 4u)) / 4.0);
}
fn celestial_color(in: Out) -> vec4<f32> {
return textureSample(atlas, atlas_sampler, in.uv) * in.color * in.light_color;
}
@@ -389,3 +389,36 @@ pub fn apply_weather(sky: &mut Sky, rain: f32, thunder: f32) {
);
}
}
pub fn sky_darken(environment: &Environment, day_time: i64, rain: f32, thunder: f32) -> i32 {
let mut sky = environment.sample(day_time, None, None);
apply_weather(&mut sky, rain, thunder);
// Level.updateSkyBrightness: darkening from the sky light level.
(15.0 - 15.0 * sky.sky_light_level).round() as i32
}
#[cfg(test)]
mod tests {
use super::*;
fn overworld() -> Option<Environment> {
let root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client/26.3");
root.join("data/minecraft/timeline/day.json")
.exists()
.then(|| Environment::load(&root).unwrap())
}
#[test]
fn dark_outside_follows_time_and_weather_levels() {
let Some(env) = overworld() else {
return;
};
let dark = |time, rain, thunder| sky_darken(&env, time, rain, thunder) >= 4;
assert!(!dark(6000, 0.0, 0.0), "clear noon");
assert!(!dark(6000, 1.0, 0.0), "rainy noon");
assert!(dark(6000, 1.0, 1.0), "thunderstorm at noon");
assert!(dark(18000, 0.0, 0.0), "clear midnight");
assert!(!dark(1000, 0.0, 0.0), "clear morning");
}
}
+27 -10
View File
@@ -72,9 +72,8 @@ pub struct Extras {
pub zoom_smooth: bool,
/// Divide mouse turning by the zoom.
pub zoom_sensitivity: bool,
/// The FPS counter and keystrokes extras are official mods now
/// (`moved_extras`): their old settings stay here until the game has
/// installed the mod for a player who had one on.
/// The FPS counter and keystrokes extras are official mods now: their
/// old settings stay here, kept as they were.
pub fps: bool,
pub fps_corner: Corner,
/// Also show the minimum and 1% low.
@@ -83,8 +82,8 @@ pub struct Extras {
pub keystrokes_corner: Corner,
/// Clicks per second under the mouse buttons.
pub keystrokes_cps: bool,
/// The official mods installed for the extras they replace, by mod id
/// (`movedToMods`): each is done once.
/// The official mods once installed for the extras they replace, by mod
/// id (`movedToMods`).
pub moved: Vec<String>,
pub minimap: bool,
pub minimap_corner: Corner,
@@ -97,6 +96,8 @@ pub struct Extras {
pub minimap_rotate: bool,
/// Retry lost multiplayer connections (`reconnect`).
pub auto_reconnect: bool,
/// The player's own name tag over its model in third person.
pub own_name_tag: bool,
/// Mouse Tweaks-style drags in the inventory and storage containers.
pub mouse_tweaks: bool,
/// With them, the wheel over a slot moves its items one at a time.
@@ -137,6 +138,7 @@ impl Default for Extras {
minimap_zoom: 1.0,
minimap_rotate: false,
auto_reconnect: false,
own_name_tag: false,
mouse_tweaks: false,
mouse_tweaks_wheel: true,
sort: false,
@@ -211,6 +213,7 @@ impl Extras {
}
"minimapRotate" => e.minimap_rotate = flag(e.minimap_rotate),
"autoReconnect" => e.auto_reconnect = flag(e.auto_reconnect),
"ownNameTag" => e.own_name_tag = flag(e.own_name_tag),
"mouseTweaks" => e.mouse_tweaks = flag(e.mouse_tweaks),
"mouseTweaksWheel" => e.mouse_tweaks_wheel = flag(e.mouse_tweaks_wheel),
"sort" => e.sort = flag(e.sort),
@@ -225,7 +228,7 @@ impl Extras {
}
pub fn serialize(&self) -> String {
format!(
"fullbright:{}\ndynamicLights:{}\nzoom:{}\nzoomKey:{}\nzoomLevel:{}\nzoomSmooth:{}\nzoomSensitivity:{}\nfps:{}\nfpsCorner:{}\nfpsLows:{}\nkeystrokes:{}\nkeystrokesCorner:{}\nkeystrokesCps:{}\nmovedToMods:{}\nminimap:{}\nminimapCorner:{}\nminimapSize:{}\nminimapShape:{}\nminimapZoom:{}\nminimapRotate:{}\nautoReconnect:{}\nmouseTweaks:{}\nmouseTweaksWheel:{}\nsort:{}\nsortKey:{}\n{}:{}\nagentControl:{}\ndiscordPresence:{}\n",
"fullbright:{}\ndynamicLights:{}\nzoom:{}\nzoomKey:{}\nzoomLevel:{}\nzoomSmooth:{}\nzoomSensitivity:{}\nfps:{}\nfpsCorner:{}\nfpsLows:{}\nkeystrokes:{}\nkeystrokesCorner:{}\nkeystrokesCps:{}\nmovedToMods:{}\nminimap:{}\nminimapCorner:{}\nminimapSize:{}\nminimapShape:{}\nminimapZoom:{}\nminimapRotate:{}\nautoReconnect:{}\nownNameTag:{}\nmouseTweaks:{}\nmouseTweaksWheel:{}\nsort:{}\nsortKey:{}\n{}:{}\nagentControl:{}\ndiscordPresence:{}\n",
bool_text(self.fullbright),
bool_text(self.dynamic_lights),
bool_text(self.zoom),
@@ -251,6 +254,7 @@ impl Extras {
self.minimap_zoom,
bool_text(self.minimap_rotate),
bool_text(self.auto_reconnect),
bool_text(self.own_name_tag),
bool_text(self.mouse_tweaks),
bool_text(self.mouse_tweaks_wheel),
bool_text(self.sort),
@@ -282,7 +286,7 @@ impl Extras {
}
/// The field-of-view modifier target `Camera.tickFov` eases toward: vanilla's
/// (`getFieldOfViewModifier`) divided by a mod's zoom (`render.set-zoom`). A
/// (`getFieldOfViewModifier`) divided by a mod's zoom (`native_zoom`). A
/// zoom of 1 leaves it as is.
pub fn fov_target(vanilla: f32, zoom: f32) -> f32 {
if zoom == 1.0 { vanilla } else { vanilla / zoom }
@@ -378,6 +382,7 @@ impl Cps {
pub enum Item {
Fullbright,
DynamicLights,
OwnNameTag,
AutoReconnect,
MouseTweaks,
MouseTweaksWheel,
@@ -396,6 +401,8 @@ pub fn rows() -> Vec<Result<Item, &'static str>> {
Err("Lighting"),
Ok(Fullbright),
Ok(DynamicLights),
Err("Third person"),
Ok(OwnNameTag),
Err("Inventory"),
Ok(MouseTweaks),
Ok(MouseTweaksWheel),
@@ -410,6 +417,7 @@ impl Item {
match self {
Item::Fullbright => "Fullbright",
Item::DynamicLights => "Dynamic lights",
Item::OwnNameTag => "Own name tag",
Item::AutoReconnect => "Auto-reconnect",
Item::MouseTweaks => "Mouse Tweaks controls",
Item::MouseTweaksWheel => "Scroll wheel moves items",
@@ -426,7 +434,7 @@ impl Item {
Some(match self {
MouseTweaksWheel => MouseTweaks,
SortKey => Sort,
Fullbright | DynamicLights | MouseTweaks | Sort | AutoReconnect | NoAi
Fullbright | DynamicLights | OwnNameTag | MouseTweaks | Sort | AutoReconnect | NoAi
| AgentControl | DiscordPresence => {
return None;
}
@@ -439,6 +447,9 @@ impl Item {
Item::DynamicLights => {
"Torches and other light sources held, dropped or on fire light the blocks around them."
}
Item::OwnNameTag => {
"Shows your name above your head in third person, as other players see it. Hidden when the server hides your team's names or you're invisible."
}
Item::AutoReconnect => {
"Rejoins a server after a lost connection or a restart, never after a kick or ban."
}
@@ -471,7 +482,9 @@ impl Item {
match self {
Item::Fullbright => Some(Cost::None),
Item::DynamicLights => Some(Cost::Low),
Item::AutoReconnect | Item::MouseTweaks | Item::Sort => Some(Cost::None),
Item::OwnNameTag | Item::AutoReconnect | Item::MouseTweaks | Item::Sort => {
Some(Cost::None)
}
_ => None,
}
}
@@ -480,6 +493,7 @@ impl Item {
Some(match self {
Item::Fullbright => e.fullbright,
Item::DynamicLights => e.dynamic_lights,
Item::OwnNameTag => e.own_name_tag,
Item::AutoReconnect => e.auto_reconnect,
Item::MouseTweaks => e.mouse_tweaks,
Item::MouseTweaksWheel => e.mouse_tweaks_wheel,
@@ -506,6 +520,7 @@ impl Item {
match self {
Item::Fullbright => e.fullbright = !e.fullbright,
Item::DynamicLights => e.dynamic_lights = !e.dynamic_lights,
Item::OwnNameTag => e.own_name_tag = !e.own_name_tag,
Item::AutoReconnect => e.auto_reconnect = !e.auto_reconnect,
Item::MouseTweaks => e.mouse_tweaks = !e.mouse_tweaks,
Item::MouseTweaksWheel => e.mouse_tweaks_wheel = !e.mouse_tweaks_wheel,
@@ -534,7 +549,7 @@ mod tests {
let d = Extras::default();
assert!(!d.fullbright && !d.dynamic_lights);
assert!(!d.zoom && !d.fps && !d.keystrokes && !d.minimap && !d.auto_reconnect);
assert!(!d.mouse_tweaks && !d.sort);
assert!(!d.mouse_tweaks && !d.sort && !d.own_name_tag);
assert!(d.discord_presence);
assert_eq!(Extras::parse(""), d);
assert_eq!(Extras::parse(&d.serialize()), d);
@@ -558,6 +573,8 @@ mod tests {
e.minimap_rotate = true;
e.minimap_corner = Corner::BottomLeft;
e.auto_reconnect = true;
Item::OwnNameTag.cycle(&mut e, false);
assert_eq!(Item::OwnNameTag.switch(&e), Some(true));
e.mouse_tweaks = true;
e.mouse_tweaks_wheel = false;
e.sort = true;
+3
View File
@@ -896,6 +896,9 @@ impl Font {
std::mem::take(&mut self.cache.borrow_mut().region.pending)
}
pub fn advance(&self, c: char, style: &Style) -> f32 {
if c == '\u{fffc}' && style.inline_object.is_some() {
return 8.0;
}
let g = self.glyph_styled(c, style);
g.advance + if style.is_bold() { g.offset } else { 0.0 }
}
+254 -22
View File
@@ -357,6 +357,14 @@ struct BorderUniform {
color: [f32; 4],
offset: [f32; 4],
}
/// A frame to save as a PNG: F2's screenshot, an agent's `game_screenshot`,
/// or the icon the Select World list shows.
pub struct ScreenshotRequest {
pub path: PathBuf,
/// `GameRenderer.takeAutoScreenshot`: centre-crop the frame to a square
/// and average it down to this size. `None` saves the frame as drawn.
pub size: Option<[u32; 2]>,
}
pub struct Renderer {
instance: wgpu::Instance,
adapter: wgpu::Adapter,
@@ -384,6 +392,11 @@ pub struct Renderer {
pub last_wait: std::time::Duration,
/// CPU time of the last `render`, waits excluded.
pub last_encode: std::time::Duration,
/// What the system says about the window's display, watched only while HDR output
/// is on; before `window`, so it's dropped first.
display: DisplayWatch,
/// The watch was just made: the settings' Auto values take what it reported.
display_fresh: bool,
window: Arc<Window>,
pub device: wgpu::Device,
pub queue: wgpu::Queue,
@@ -415,6 +428,7 @@ pub struct Renderer {
crosshair_group: wgpu::BindGroup,
crosshair_globals: wgpu::Buffer,
entity_pipeline: wgpu::RenderPipeline,
hdr_mark_pipeline: wgpu::RenderPipeline,
weather: weather::Weather,
crumbling_pipeline: wgpu::RenderPipeline,
shadow_blob_pipeline: wgpu::RenderPipeline,
@@ -581,8 +595,8 @@ pub struct Renderer {
pub section_draw_calls: usize,
pub uploaded_bytes: u64,
started: Instant,
/// Write the next presented frame to this PNG path (control socket).
pub screenshot: Option<PathBuf>,
/// The next frame to write to a PNG path (control socket, F2, world icon).
pub screenshot: Option<ScreenshotRequest>,
/// Outcome of the last screenshot: frame size, or the error.
pub screenshot_result: Option<std::result::Result<[u32; 2], String>>,
/// Brightness option (lightmap gamma), 0..1.
@@ -1174,6 +1188,7 @@ impl Renderer {
}
surface.configure(&device, &config);
let hdr_output = hdr::output(&caps);
crate::graphics::offer_hdr(hdr_output.is_some());
if previous.is_none() {
log::info!(
"Horizon renderer: HDR output {}",
@@ -2113,6 +2128,18 @@ impl Renderer {
"fs_energy_swirl",
wgpu::ColorWrites::ALL,
);
// HDR output's mark on light-giving particles (entity.wgsl fs_hdr_mark).
let hdr_mark_pipeline = entity_pipeline_with(
"HDR particle mark",
None,
false,
wgpu::CompareFunction::GreaterEqual,
Default::default(),
wgpu::BlendState::REPLACE,
false,
"fs_hdr_mark",
wgpu::ColorWrites::ALPHA,
);
// Attribute 7: 6 is the entity vertex buffer's glint UV.
let lightning_attributes = wgpu::vertex_attr_array![7=>Float32x3];
let lightning_vertex = [
@@ -2586,6 +2613,8 @@ impl Renderer {
));
}
Ok(Self {
display: DisplayWatch::default(),
display_fresh: false,
instance,
adapter,
surface,
@@ -2624,6 +2653,7 @@ impl Renderer {
crosshair_group,
crosshair_globals,
entity_pipeline,
hdr_mark_pipeline,
weather,
crumbling_pipeline,
shadow_blob_pipeline,
@@ -3299,6 +3329,7 @@ impl Renderer {
paper_white: f32,
peak: f32,
black: f32,
ui: f32,
marker: &std::path::Path,
) {
let output = self.hdr_output.filter(|_| on && self.surface.is_some());
@@ -3311,6 +3342,16 @@ impl Renderer {
} else if let Some(marker) = self.hdr_marker.take() {
let _ = std::fs::remove_file(marker);
}
// Only HDR output asks the system about the display, so with HDR off the
// game never waits on the compositor for it.
self.display = if output.is_some() {
let (watch, now) = DisplayWatch::new(&self.window);
crate::graphics::report(now);
self.display_fresh = true;
watch
} else {
DisplayWatch::default()
};
let format = self.config.format.remove_srgb_suffix();
self.hdr = output.map(|output| hdr::Hdr::new(&self.device, format, output));
self.configure_surface();
@@ -3323,6 +3364,9 @@ impl Renderer {
None => log::info!("Horizon renderer: HDR off"),
}
}
if let Some(hdr) = &mut self.hdr {
hdr.ui = ui;
}
if let Some(hdr) = &mut self.hdr
&& (hdr.paper_white, hdr.peak, hdr.black) != (paper_white, peak, black)
{
@@ -3332,6 +3376,20 @@ impl Renderer {
self.send_hdr_metadata();
}
}
/// Whether what the system reports for the display changed (another output, the
/// player recalibrating the desktop): the settings' Auto values follow it then, so
/// apply them again.
pub fn display_changed(&mut self) -> bool {
flush_display_log();
if std::mem::take(&mut self.display_fresh) {
return true;
}
let Some(now) = self.display.changed() else {
return false;
};
crate::graphics::report(now);
true
}
/// `upscale`: output pixels per rendered pixel, for TAA to rebuild (1: native).
pub fn set_world_post(&mut self, scale: f32, antialias: bool, taa: bool, upscale: f32) {
let taa = taa && self.world_post.supports_taa();
@@ -4882,6 +4940,10 @@ impl Renderer {
pass.set_bind_group(0, &self.entity_group, &[]);
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
pass.draw_indexed(0..entity_quads * 6, 0, 0..1);
if self.hdr.is_some() {
pass.set_pipeline(&self.hdr_mark_pipeline);
pass.draw_indexed(0..entity_quads * 6, 0, 0..1);
}
pass.set_bind_group(0, &self.group, &[]);
}
if text_quads > 0 {
@@ -5219,6 +5281,10 @@ impl Renderer {
((world_quads + additive_quads) * 4) as i32,
0..1,
);
drop(pass);
if let Some(hdr) = &self.hdr {
hdr.mark_hand(&mut encoder);
}
}
// GameRenderer.render: LevelRenderer.blitEntityOutline after the level and the hand.
self.entity_outline.draw(
@@ -5227,6 +5293,13 @@ impl Renderer {
&self.entity_group,
&self.indices,
);
// The world icon grabs here: after the level and the hand, before
// the effects and the HUD draw over them (`tryTakeScreenshotIfNeeded`
// runs right after `renderLevel`).
let readback = self
.screenshot
.take_if(|request| request.size.is_some())
.and_then(|request| self.take_readback(request, &mut encoder, &target_texture));
if !frame.screen.is_empty() {
let attachments = [Some(wgpu::RenderPassColorAttachment {
view: &view_target,
@@ -5330,22 +5403,10 @@ impl Renderer {
(&view_target, self.config.format.remove_srgb_suffix()),
);
}
let readback = self.screenshot.take().and_then(|path| {
// The copy and PNG writer both assume 8-bit pixels; a float surface would make
// the row stride too small for the copy and abort in validation.
if self.config.format.block_copy_size(None) == Some(4) {
let size = [self.config.width, self.config.height];
Some((
path,
copy_frame(&self.device, &mut encoder, &target_texture, size),
))
} else {
self.screenshot_result = Some(Err(format!(
"screenshots need a 32-bit surface format, not {:?}",
self.config.format
)));
None
}
let readback = readback.or_else(|| {
self.screenshot
.take()
.and_then(|request| self.take_readback(request, &mut encoder, &target_texture))
});
self.uploads.finish(&encoder);
if has_section_copies {
@@ -5376,9 +5437,9 @@ impl Renderer {
if let Some(Some(timer)) = Self::pass_timer(&mut self.pack_post, &mut self.native) {
timer.submitted(&self.queue);
}
if let Some((path, buffer)) = readback {
if let Some((request, buffer)) = readback {
self.screenshot_result = Some(
self.write_screenshot(&path, &buffer)
self.write_screenshot(&request, &buffer)
.map_err(|e| e.to_string()),
);
}
@@ -5464,7 +5525,32 @@ fn copy_frame(
buffer
}
impl Renderer {
fn write_screenshot(&self, path: &std::path::Path, buffer: &wgpu::Buffer) -> Result<[u32; 2]> {
/// Take a screenshot request: queue the frame's copy, or report why the
/// frame cannot be read back.
fn take_readback(
&mut self,
request: ScreenshotRequest,
encoder: &mut wgpu::CommandEncoder,
texture: &wgpu::Texture,
) -> Option<(ScreenshotRequest, wgpu::Buffer)> {
// The copy and PNG writer both assume 8-bit pixels; a float surface would make
// the row stride too small for the copy and abort in validation.
if self.config.format.block_copy_size(None) == Some(4) {
let size = [self.config.width, self.config.height];
Some((request, copy_frame(&self.device, encoder, texture, size)))
} else {
self.screenshot_result = Some(Err(format!(
"screenshots need a 32-bit surface format, not {:?}",
self.config.format
)));
None
}
}
fn write_screenshot(
&self,
request: &ScreenshotRequest,
buffer: &wgpu::Buffer,
) -> Result<[u32; 2]> {
let (sender, receiver) = std::sync::mpsc::channel();
buffer.map_async(wgpu::MapMode::Read, .., move |r| {
let _ = sender.send(r);
@@ -5499,8 +5585,18 @@ impl Renderer {
}
}
buffer.unmap();
// NativeImage.resizeSubRectTo on takeAutoScreenshot's grab: the icon
// is the frame's centre square averaged down to its size.
let (width, height, pixels) = match request.size {
Some(size) => (
size[0],
size[1],
square_downscale(&pixels, self.config.width, self.config.height, size),
),
None => (self.config.width, self.config.height, pixels),
};
let mut encoder = png::Encoder::new(
std::io::BufWriter::new(std::fs::File::create(path)?),
std::io::BufWriter::new(std::fs::File::create(&request.path)?),
width,
height,
);
@@ -5510,6 +5606,42 @@ impl Renderer {
Ok([width, height])
}
}
/// Centre-crop the frame to a square and average it down to `size`.
pub(crate) fn square_downscale(pixels: &[u8], width: u32, height: u32, size: [u32; 2]) -> Vec<u8> {
let (side, cx, cy) = if width > height {
(height, (width - height) / 2, 0)
} else {
(width, 0, (height - width) / 2)
};
let [tw, th] = size;
let mut out = vec![0u8; (tw * th * 4) as usize];
for ty in 0..th {
let y0 = cy + ty * side / th;
let y1 = cy + (ty + 1) * side / th;
for tx in 0..tw {
let x0 = cx + tx * side / tw;
let x1 = cx + (tx + 1) * side / tw;
let (mut r, mut g, mut b) = (0u32, 0u32, 0u32);
for y in y0..y1 {
for x in x0..x1 {
let p = ((y * width + x) * 4) as usize;
r += u32::from(pixels[p]);
g += u32::from(pixels[p + 1]);
b += u32::from(pixels[p + 2]);
}
}
let count = (x1 - x0) * (y1 - y0);
let o = ((ty * tw + tx) * 4) as usize;
out[o..o + 4].copy_from_slice(&[
(r / count) as u8,
(g / count) as u8,
(b / count) as u8,
255,
]);
}
}
out
}
/// Camera-relative clip planes (`Frustum`).
#[derive(Clone, Copy, Debug)]
pub struct Frustum {
@@ -6040,3 +6172,103 @@ mod sprite_upload_tests {
}
}
}
/// What the system says about the window's display, kept up to date: on Wayland, the
/// compositor's preferred image description of the surface.
#[derive(Default)]
struct DisplayWatch {
#[cfg(target_os = "linux")]
wayland: Option<horizon_hdr::wayland::Watch>,
}
impl DisplayWatch {
/// Starts watching, with what the system reports now.
fn new(window: &Window) -> (Self, Option<crate::graphics::Reported>) {
#[cfg(target_os = "linux")]
if let Some((wayland, now)) = horizon_hdr::wayland::Watch::new(window) {
let watch = Self {
wayland: Some(wayland),
};
return (watch, now.map(reported));
}
let _ = window;
(Self::default(), None)
}
/// What the system reports, if that changed since the last call.
fn changed(&mut self) -> Option<Option<crate::graphics::Reported>> {
#[cfg(target_os = "linux")]
if let Some(wayland) = &mut self.wayland {
return wayland.changed().map(|now| now.map(reported));
}
None
}
}
/// The log of what the system reports about the display: one line per change. The
/// renderer is made anew a few times while the game starts and each asks again, and a
/// slider dragged in the desktop's settings changes the values many times a second, so
/// a change within [`DISPLAY_LOG_QUIET`] of the last line waits until the values have
/// held that long, and only the one they settled on is logged.
struct DisplayLog {
last: String,
at: Option<std::time::Instant>,
pending: Option<(String, std::time::Instant)>,
}
static DISPLAY_LOG: std::sync::Mutex<DisplayLog> = std::sync::Mutex::new(DisplayLog {
last: String::new(),
at: None,
pending: None,
});
const DISPLAY_LOG_QUIET: std::time::Duration = std::time::Duration::from_secs(1);
// Only Wayland's HDR reports call it so far.
#[cfg(target_os = "linux")]
fn log_display(line: String) {
let now = std::time::Instant::now();
let mut log = DISPLAY_LOG
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if line == log.last {
log.pending = None;
} else if log.at.is_none_or(|at| now - at >= DISPLAY_LOG_QUIET) {
log::info!("{line}");
(log.last, log.at, log.pending) = (line, Some(now), None);
} else {
log.pending = Some((line, now));
}
}
/// Logs a change [`log_display`] held back, once the values have held for
/// [`DISPLAY_LOG_QUIET`].
fn flush_display_log() {
let now = std::time::Instant::now();
let mut log = DISPLAY_LOG
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if let Some((_, since)) = &log.pending
&& now - *since >= DISPLAY_LOG_QUIET
&& let Some((line, _)) = log.pending.take()
{
log::info!("{line}");
(log.last, log.at) = (line, Some(now));
}
}
/// What a compositor's luminances mean in HDR output's scale.
///
/// KWin maps content's reference white (203 nits in PQ, BT.2408) onto its own, so the
/// desktop's white is 203 there and the peak is the target's maximum over that ratio.
/// Checked on KWin. Hyprland (0.57) reports the standard 203, and shows HDR windows below
/// the SDR white of its desktop (which it doesn't report), so Auto can't match its desktop
/// there. Other compositors are unchecked. The peak is kept within the settings' lowest and
/// PQ's top, so the metadata never ends below the black level.
#[cfg(target_os = "linux")]
fn reported(l: horizon_hdr::wayland::Luminances) -> crate::graphics::Reported {
let peak = (l.target_max * 203.0 / l.reference.max(1.0)).round() as u32;
log_display(format!(
"Horizon renderer: the compositor shows this window with reference white {} nits, \
{}..{} nits: peak {peak} nits in HDR output's scale",
l.reference, l.target_min, l.target_max,
));
crate::graphics::Reported {
paper_white: 203,
peak: peak.clamp(200, 10_000),
}
}
+170 -8
View File
@@ -60,21 +60,62 @@ struct Targets {
group: wgpu::BindGroup,
}
/// How one tier of light sources (`mesher::hdr_tier`) shines: `share` of the headroom for
/// its brightest pixels, lifted from brightness `start` (none) to `full` (all of it).
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Tier {
pub share: f32,
pub start: f32,
pub full: f32,
}
/// Low, incandescent, bright and very bright.
pub const TIERS: [Tier; 4] = [
Tier {
share: 0.2,
start: 0.08,
full: 0.09,
},
Tier {
share: 0.5,
start: 0.14,
full: 0.38,
},
Tier {
share: 0.6,
start: 0.14,
full: 0.38,
},
Tier {
share: 1.0,
start: 0.14,
full: 0.38,
},
];
pub struct Hdr {
layout: wgpu::BindGroupLayout,
pipeline: wgpu::RenderPipeline,
/// Zeroes the frame's alpha once the world is kept (`keep_world`): what draws after,
/// the hand, HUD and menus, raises it, so HDR output knows where they cover the world.
clear_alpha: wgpu::RenderPipeline,
/// Scales the frame's alpha down to 1/255 where the hand drew (`mark_hand`), so HDR
/// output keeps the hand at the world's brightness.
hand_mark: wgpu::RenderPipeline,
params: wgpu::Buffer,
pub output: Output,
/// The frame's format: what every other pipeline draws in.
format: wgpu::TextureFormat,
targets: Option<Targets>,
/// `HORIZON_HDR_DEBUG=1`: paint expanded pixels magenta.
/// `HORIZON_HDR_DEBUG=1`: grey what isn't expanded, tint what is magenta.
debug: bool,
/// Paper white and peak brightness, in nits.
pub paper_white: f32,
pub peak: f32,
/// The display's black level, in nits: shadows are lifted onto it (`hdr.wgsl`).
pub black: f32,
pub tiers: [Tier; 4],
/// The HUD and menus' brightness, as a share of paper white (0.25..=1).
pub ui: f32,
}
impl Hdr {
@@ -140,15 +181,59 @@ impl Hdr {
multiview_mask: None,
cache: None,
});
let alpha_pass = |label, blend| {
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some(label),
layout: Some(&device.create_pipeline_layout(&Default::default())),
vertex: wgpu::VertexState {
module: &shader,
entry_point: Some("vs_hdr"),
compilation_options: Default::default(),
buffers: &[],
},
primitive: Default::default(),
depth_stencil: None,
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: Some("fs_clear_alpha"),
compilation_options: Default::default(),
targets: &[Some(wgpu::ColorTargetState {
format,
blend,
write_mask: wgpu::ColorWrites::ALPHA,
})],
}),
multiview_mask: None,
cache: None,
})
};
let clear_alpha = alpha_pass("Horizon HDR UI mask", None);
// Alpha times the blend constant (1/255): the hand's 1 becomes 1/255, the world's 0
// stays 0.
let scale_alpha = wgpu::BlendComponent {
src_factor: wgpu::BlendFactor::Zero,
dst_factor: wgpu::BlendFactor::Constant,
operation: wgpu::BlendOperation::Add,
};
let hand_mark = alpha_pass(
"Horizon HDR hand mark",
Some(wgpu::BlendState {
color: scale_alpha,
alpha: scale_alpha,
}),
);
let params = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Horizon HDR parameters"),
size: 32,
size: 80,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
Self {
layout,
pipeline,
clear_alpha,
hand_mark,
params,
output,
format,
@@ -157,6 +242,8 @@ impl Hdr {
paper_white: 200.0,
peak: 1000.0,
black: 0.0,
tiers: TIERS,
ui: 1.0,
}
}
@@ -213,7 +300,9 @@ impl Hdr {
self.targets.as_ref().unwrap().frame.clone()
}
/// Keeps the world as drawn so far, before the hand and HUD cover it.
/// Keeps the world as drawn so far, before the hand and HUD cover it, then zeroes the
/// frame's alpha: the world's marks live on in the copy, and whatever draws from here
/// on raises the alpha where it covers the world.
pub fn keep_world(&self, encoder: &mut wgpu::CommandEncoder) {
let Some(t) = &self.targets else {
return;
@@ -223,6 +312,56 @@ impl Hdr {
t.world.as_image_copy(),
t.frame.size(),
);
let view = t.frame.create_view(&Default::default());
let attachments = [Some(wgpu::RenderPassColorAttachment {
view: &view,
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Load,
store: wgpu::StoreOp::Store,
},
})];
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Horizon HDR UI mask"),
color_attachments: &attachments,
..Default::default()
});
pass.set_pipeline(&self.clear_alpha);
pass.draw(0..3, 0..1);
}
/// After the hand: its pixels' alpha goes down to 1/255, below anything the HUD and
/// menus leave, so HDR output tells the hand (the world's brightness, never lifted)
/// from the UI (UI Brightness).
pub fn mark_hand(&self, encoder: &mut wgpu::CommandEncoder) {
let Some(t) = &self.targets else {
return;
};
let view = t.frame.create_view(&Default::default());
let attachments = [Some(wgpu::RenderPassColorAttachment {
view: &view,
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Load,
store: wgpu::StoreOp::Store,
},
})];
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Horizon HDR hand mark"),
color_attachments: &attachments,
..Default::default()
});
pass.set_pipeline(&self.hand_mark);
let a = 1.0 / 255.0;
pass.set_blend_constant(wgpu::Color {
r: a,
g: a,
b: a,
a,
});
pass.draw(0..3, 0..1);
}
/// Writes the finished frame to the swapchain image `target`.
@@ -237,16 +376,21 @@ impl Hdr {
return;
};
let paper = self.paper_white.max(1.0);
let params = [
let mut params = [0.0; 20];
params[..5].copy_from_slice(&[
paper / 80.0,
(self.peak / paper).max(1.0),
f32::from(u8::from(world)),
f32::from(u8::from(self.debug)),
f32::from(u8::from(self.output == Output::Pq)),
self.black.clamp(0.0, paper),
0.0,
0.0,
];
]);
params[5] = self.black.clamp(0.0, paper);
params[6] = self.ui.clamp(0.25, 1.0);
for (i, t) in self.tiers.iter().enumerate() {
params[8 + i] = t.share;
params[12 + i] = t.start;
params[16 + i] = t.full;
}
queue.write_buffer(&self.params, 0, bytemuck::cast_slice(&params));
let view = target.create_view(&Default::default());
let attachments = [Some(wgpu::RenderPassColorAttachment {
@@ -274,6 +418,24 @@ mod tests {
use super::{Output, pick};
use wgpu::{SurfaceColorSpaces as S, TextureFormat as F};
#[test]
fn hdr_shaders_validate() {
let entity = crate::overlay::wgsl_constants() + include_str!("../entity.wgsl");
for source in [
include_str!("../hdr.wgsl"),
include_str!("../terrain.wgsl"),
&entity,
] {
let module = naga::front::wgsl::parse_str(source).unwrap();
naga::valid::Validator::new(
naga::valid::ValidationFlags::all(),
naga::valid::Capabilities::all(),
)
.validate(&module)
.unwrap();
}
}
fn caps(offered: &[(F, S)]) -> wgpu::SurfaceCapabilities {
wgpu::SurfaceCapabilities {
format_capabilities: offered
+86 -8
View File
@@ -2,13 +2,39 @@
//! `horizon-graphics.txt` next to `options.txt` so that file stays exactly
//! vanilla's. Every default reproduces vanilla's rendering; only choices the
//! player makes here depart from it.
use std::{fs, path::Path};
use std::{
fs,
path::Path,
sync::atomic::{AtomicU32, Ordering},
};
pub const FILE: &str = "horizon-graphics.txt";
/// Written beside it before HDR output is switched on and removed once the first HDR frame is
/// presented. Found at the next start, it means HDR output took the game down, so HDR starts off.
pub const HDR_MARKER: &str = "horizon-hdr-pending";
/// What the system reports for the display HDR output goes to, in nits in HDR output's
/// scale: where its SDR white sits (the desktop's white) and the peak.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Reported {
pub paper_white: u32,
pub peak: u32,
}
static REPORTED: [AtomicU32; 2] = [AtomicU32::new(0), AtomicU32::new(0)];
/// Sets what the system reports (the renderer, when it opens its surface).
pub fn report(reported: Option<Reported>) {
let (paper_white, peak) = reported.map_or((0, 0), |r| (r.paper_white, r.peak));
REPORTED[0].store(paper_white, Ordering::Relaxed);
REPORTED[1].store(peak, Ordering::Relaxed);
}
/// What the system reports, if it does.
pub fn reported() -> Option<Reported> {
let [paper_white, peak] = REPORTED.each_ref().map(|n| n.load(Ordering::Relaxed));
(peak > 0).then_some(Reported { paper_white, peak })
}
/// Simple-level presets. `Custom` leaves every option as the player set it.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum Preset {
@@ -285,6 +311,16 @@ pub fn reduced_far_detail_suggested() -> bool {
REDUCED_SUGGESTED.load(std::sync::atomic::Ordering::Relaxed)
}
static HDR_OFFERED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
/// Whether the display path offers HDR output (HDR10 or scRGB): set when the renderer
/// opens its surface; the settings open HDR's screen only then.
pub fn offer_hdr(on: bool) {
HDR_OFFERED.store(on, std::sync::atomic::Ordering::Relaxed);
}
pub fn hdr_offered() -> bool {
HDR_OFFERED.load(std::sync::atomic::Ordering::Relaxed)
}
/// The farthest LOD Distance, in chunks, on integrated GPUs other than
/// Apple's: they share the CPU's memory bandwidth, and past it far terrain
/// costs them more frame time than it is worth.
@@ -343,13 +379,18 @@ pub struct Graphics {
pub upscaling: Upscaling,
/// HDR output (HDR10, or scRGB), where the display path offers it.
pub hdr: bool,
/// HDR paper white (where SDR white sits), in nits.
/// HDR paper white (where SDR white sits), in nits; 0 is Auto: the desktop's SDR
/// white, as the system reports it ([`Graphics::paper_white_nits`]).
pub hdr_paper_white: u32,
/// HDR peak brightness the world's highlights reach, in nits.
/// HDR peak brightness the world's highlights reach, in nits; 0 is Auto:
/// what the system reports for the display ([`Graphics::peak_nits`]).
pub hdr_peak: u32,
/// HDR black level: the display's darkest black, in hundredths of a nit (0 for
/// an OLED's true black). Shadows are lifted onto it, and the HDR metadata says it.
pub hdr_black_level: u32,
/// HDR brightness of the HUD and menus, in percent of paper white (25..=100); the
/// first-person hand stays at the world's.
pub hdr_ui_brightness: u32,
/// Battery mode: at most 60 frames per second in a world and 30 in menus
/// or behind other windows, and half the worker threads. Off by default;
/// it changes when frames are drawn, never what they show or the ticks.
@@ -384,9 +425,10 @@ impl Default for Graphics {
taa: false,
upscaling: Upscaling::Native,
hdr: false,
hdr_paper_white: 200,
hdr_peak: 1000,
hdr_paper_white: 0,
hdr_peak: 0,
hdr_black_level: 0,
hdr_ui_brightness: 100,
battery: false,
api: GraphicsApi::Auto,
shader_pack: String::new(),
@@ -449,9 +491,22 @@ impl Graphics {
"taa" => g.taa = flag(g.taa),
"upscaling" => g.upscaling = Upscaling::parse(value),
"hdr" => g.hdr = flag(g.hdr),
"hdrPaperWhite" => g.hdr_paper_white = number(g.hdr_paper_white).clamp(80, 480),
"hdrPeak" => g.hdr_peak = number(g.hdr_peak).clamp(200, 2000),
"hdrPaperWhite" => {
g.hdr_paper_white = match number(g.hdr_paper_white) {
0 => 0,
n => n.clamp(80, 480),
}
}
"hdrPeak" => {
g.hdr_peak = match number(g.hdr_peak) {
0 => 0,
n => n.clamp(200, 2000),
}
}
"hdrBlackLevel" => g.hdr_black_level = number(g.hdr_black_level).min(100),
"hdrUiBrightness" => {
g.hdr_ui_brightness = number(g.hdr_ui_brightness).clamp(25, 100)
}
"batteryMode" => g.battery = flag(g.battery),
"graphicsApi" => g.api = GraphicsApi::from_name(value).unwrap_or_default(),
"pickedShaderPack" => g.shader_pack = value.to_owned(),
@@ -469,7 +524,7 @@ impl Graphics {
}
pub fn serialize(&self) -> String {
format!(
"preset:{}\nlodDistance:{}\nfarDetail:{}\ncaveCulling:{}\nentityCulling:{}\nchunkUpdates:{}\nuploadBudgetKiB:{}\nsky:{}\nfog:{}\nweather:{}\nanimatedTextures:{}\npresentMode:{}\nframePacing:{}\nworkers:{}\noverlay:{}\nresolutionScale:{}\nfxaa:{}\ntaa:{}\nupscaling:{}\nhdr:{}\nhdrPaperWhite:{}\nhdrPeak:{}\nhdrBlackLevel:{}\nbatteryMode:{}\ngraphicsApi:{}\npickedShaderPack:{}\n",
"preset:{}\nlodDistance:{}\nfarDetail:{}\ncaveCulling:{}\nentityCulling:{}\nchunkUpdates:{}\nuploadBudgetKiB:{}\nsky:{}\nfog:{}\nweather:{}\nanimatedTextures:{}\npresentMode:{}\nframePacing:{}\nworkers:{}\noverlay:{}\nresolutionScale:{}\nfxaa:{}\ntaa:{}\nupscaling:{}\nhdr:{}\nhdrPaperWhite:{}\nhdrPeak:{}\nhdrBlackLevel:{}\nhdrUiBrightness:{}\nbatteryMode:{}\ngraphicsApi:{}\npickedShaderPack:{}\n",
self.preset.name(),
self.lod_distance,
self.far_detail.name(),
@@ -500,6 +555,7 @@ impl Graphics {
self.hdr_paper_white,
self.hdr_peak,
self.hdr_black_level,
self.hdr_ui_brightness,
bool_text(self.battery),
self.api.name(),
self.shader_pack,
@@ -508,6 +564,22 @@ impl Graphics {
pub fn save(&self, dir: &Path) {
let _ = fs::write(dir.join(FILE), self.serialize());
}
/// Paper white, in nits: the player's, or on Auto the desktop's SDR white as the
/// system reports it, else 203 (BT.2408's reference white).
pub fn paper_white_nits(&self) -> u32 {
match self.hdr_paper_white {
0 => reported().map_or(203, |r| r.paper_white),
n => n,
}
}
/// The peak the world's highlights reach, in nits: the player's, or on Auto
/// what the system reports, else 1000.
pub fn peak_nits(&self) -> u32 {
match self.hdr_peak {
0 => reported().map_or(1000, |r| r.peak),
n => n,
}
}
/// Worker threads for chunk generation and meshing.
pub fn worker_threads(&self) -> usize {
if self.workers > 0 {
@@ -618,12 +690,18 @@ mod tests {
hdr_paper_white: 240,
hdr_peak: 1400,
hdr_black_level: 12,
hdr_ui_brightness: 60,
battery: true,
api: GraphicsApi::Dx12,
shader_pack: "Golden Hour".into(),
};
assert_eq!(Graphics::parse(&g.serialize()), g);
assert_eq!(Graphics::parse(""), Graphics::default());
// 0 is Auto; anything else is a peak the slider reaches.
assert_eq!(Graphics::parse("hdrPeak:0\n").hdr_peak, 0);
assert_eq!(Graphics::parse("hdrPeak:50\n").hdr_peak, 200);
assert_eq!(Graphics::parse("hdrPaperWhite:0\n").hdr_paper_white, 0);
assert_eq!(Graphics::parse("hdrPaperWhite:5\n").hdr_paper_white, 80);
// The old menus' Simple / Advanced choice is ignored, and so is the
// shader pack older versions saved.
assert_eq!(Graphics::parse("level:advanced\n"), Graphics::default());
@@ -130,8 +130,9 @@ impl Style {
label: &Text,
value: f32,
hover: bool,
active: bool,
) {
ui::slider(batch, atlas, x, y, w, label, value, hover, true);
ui::slider(batch, atlas, x, y, w, label, value, hover && active, active);
}
/// The cost tag and the description beside a control, wrapped to two lines.
pub fn description(
+160 -30
View File
@@ -1,4 +1,4 @@
//! Horizon's graphics settings screens, Simple and Advanced: which settings
//! Horizon's graphics settings screens, Simple, Advanced and HDR: which settings
//! they show, in what groups, with what description and cost, and their
//! input. How they look comes from `graphics_style`.
use crate::{
@@ -14,6 +14,8 @@ use crate::{
pub enum Level {
Simple,
Advanced,
/// HDR output's own settings, opened from Advanced while HDR is on.
Hdr,
}
/// Horizon's own settings (`graphics::Graphics`).
@@ -38,9 +40,12 @@ pub enum H {
Antialiasing,
Upscaling,
Hdr,
/// Opens `Level::Hdr`.
HdrSettings,
HdrBlackLevel,
HdrPeak,
HdrPaperWhite,
HdrUiBrightness,
BatteryMode,
GraphicsApi,
}
@@ -121,6 +126,34 @@ pub fn rows(level: Level) -> Vec<Row> {
);
let mut rows = match level {
Level::Simple => vec![preset, render, lod, far, fov, fps, vsync, gui],
Level::Hdr => vec![
Row::Header("HDR"),
h(
H::Hdr,
None,
"Lava, fire and sunlight shine brighter than white. Needs an HDR display.",
),
h(
H::HdrBlackLevel,
None,
"Your display's darkest black. 0 for OLED; LCDs glow a little.",
),
h(
H::HdrPeak,
None,
"How bright highlights get. Auto: the peak your system reports.",
),
h(
H::HdrPaperWhite,
None,
"How bright white paper and menus look. Auto: your desktop's white.",
),
h(
H::HdrUiBrightness,
None,
"How bright the HUD and menus are, against paper white.",
),
],
Level::Advanced => vec![
Row::Header("Rendering"),
render,
@@ -231,25 +264,10 @@ pub fn rows(level: Level) -> Vec<Row> {
"Renders the world smaller and rebuilds it with TAA. Faster, softer.",
),
h(
H::Hdr,
H::HdrSettings,
None,
"HDR output on an HDR display: lava, fire and sunlight shine brighter.",
),
h(
H::HdrBlackLevel,
None,
"HDR: your display's darkest black. 0 for OLED; LCDs glow a little.",
),
h(
H::HdrPeak,
None,
"HDR: how bright the brightest highlights get. Your display's peak.",
),
h(
H::HdrPaperWhite,
None,
"HDR: how bright white paper and menus look. Match your desktop.",
),
h(
H::PresentMode,
None,
@@ -357,9 +375,18 @@ impl H {
}
H::Workers => Some(g.workers as f32 / max_workers() as f32),
H::ResolutionScale => Some((g.resolution_scale as f32 - 50.0) / 150.0),
H::HdrPaperWhite => Some((g.hdr_paper_white as f32 - 80.0) / 400.0),
H::HdrPeak => Some((g.hdr_peak as f32 - 200.0) / 1800.0),
// Auto, then 80..480 by 10.
H::HdrPaperWhite => Some(match g.hdr_paper_white {
0 => 0.0,
n => (n as f32 - 70.0) / 410.0,
}),
// Auto, then 200..2000 by 50.
H::HdrPeak => Some(match g.hdr_peak {
0 => 0.0,
n => (n as f32 - 150.0) / 1850.0,
}),
H::HdrBlackLevel => Some(g.hdr_black_level as f32 / 100.0),
H::HdrUiBrightness => Some((g.hdr_ui_brightness as f32 - 25.0) / 75.0),
_ => None,
}
}
@@ -410,12 +437,23 @@ impl H {
),
H::Upscaling => labelled("Upscaling", g.upscaling.label()),
H::Hdr => labelled("HDR", on_off(g.hdr)),
H::HdrPaperWhite => labelled("Paper White", format!("{} nits", g.hdr_paper_white)),
H::HdrPeak => labelled("Peak Brightness", format!("{} nits", g.hdr_peak)),
H::HdrSettings => Text::literal("HDR Settings..."),
H::HdrPaperWhite => match g.hdr_paper_white {
0 => labelled(
"Paper White",
format!("Auto ({} nits)", g.paper_white_nits()),
),
n => labelled("Paper White", format!("{n} nits")),
},
H::HdrPeak => match g.hdr_peak {
0 => labelled("Peak Brightness", format!("Auto ({} nits)", g.peak_nits())),
n => labelled("Peak Brightness", format!("{n} nits")),
},
H::HdrBlackLevel => labelled(
"Black Level",
format!("{:.2} nits", g.hdr_black_level as f32 / 100.0),
),
H::HdrUiBrightness => labelled("UI Brightness", format!("{}%", g.hdr_ui_brightness)),
H::BatteryMode => labelled("Battery Mode", on_off(g.battery)),
H::GraphicsApi => labelled("Graphics API", g.api.label()),
H::Overlay => labelled(
@@ -438,9 +476,20 @@ impl H {
}
H::Workers => g.workers = (v * max_workers() as f32).round() as u32,
H::ResolutionScale => g.resolution_scale = (50.0 + (v * 30.0).round() * 5.0) as u32,
H::HdrPaperWhite => g.hdr_paper_white = (80.0 + (v * 40.0).round() * 10.0) as u32,
H::HdrPeak => g.hdr_peak = (200.0 + (v * 36.0).round() * 50.0) as u32,
H::HdrPaperWhite => {
g.hdr_paper_white = match (v * 41.0).round() as u32 {
0 => 0,
step => 70 + step * 10,
}
}
H::HdrPeak => {
g.hdr_peak = match (v * 37.0).round() as u32 {
0 => 0,
step => 150 + step * 50,
}
}
H::HdrBlackLevel => g.hdr_black_level = (v * 100.0).round() as u32,
H::HdrUiBrightness => g.hdr_ui_brightness = (25.0 + (v * 15.0).round() * 5.0) as u32,
_ => {}
}
}
@@ -516,6 +565,25 @@ impl Item {
fn available(self) -> bool {
!matches!(self, Item::Vanilla(o) if !o.available())
}
/// Why a control can't be used now, when it can't: HDR's screen opens only where
/// the display offers HDR output, whatever `horizon-graphics.txt` says (a copied game
/// folder, a display swapped for an SDR one: the renderer leaves HDR off there, and
/// the saved values wait for an HDR display), and its sliders do something only
/// while it is on.
fn blocked(self, s: &Settings) -> Option<&'static str> {
if !self.available() {
return Some("This feature is not yet available in Horizon.");
}
match self {
Item::Horizon(H::HdrSettings | H::Hdr) if !crate::graphics::hdr_offered() => {
Some("Your display or system doesn't offer HDR output.")
}
Item::Horizon(
H::HdrBlackLevel | H::HdrPeak | H::HdrPaperWhite | H::HdrUiBrightness,
) if !s.horizon.hdr => Some("Turn HDR on to use it."),
_ => None,
}
}
/// On/off settings and their value.
pub fn switch(self, s: &Settings) -> Option<bool> {
let g = &s.horizon;
@@ -617,6 +685,7 @@ pub enum Action {
SliderDrag,
OpenAdvanced,
OpenSimple,
OpenHdr,
Done,
}
@@ -700,6 +769,17 @@ impl GraphicsUi {
let other = match level {
Level::Simple => Action::OpenAdvanced,
Level::Advanced => Action::OpenSimple,
// One Done, back to Advanced.
Level::Hdr => {
widgets.push(Widget {
x: cx - 100.0,
y: by,
w: 200.0,
target: Target::Button(Action::Done),
list: false,
});
return (widgets, headers, content);
}
};
widgets.push(Widget {
x: cx - 155.0,
@@ -761,12 +841,10 @@ impl GraphicsUi {
continue;
}
let label = item.label(s);
let live = item.available();
if hover && !live {
tooltip = Some(atlas.font.split(
&Text::literal("This feature is not yet available in Horizon."),
170.0,
));
let blocked = item.blocked(s);
let live = blocked.is_none();
if hover && let Some(why) = blocked {
tooltip = Some(atlas.font.split(&Text::literal(why), 170.0));
}
match item.slider(s) {
Some(value) => STYLE.slider(
@@ -776,6 +854,7 @@ impl GraphicsUi {
&label,
value,
hover || self.dragging == Some(item),
live,
),
None => STYLE.button(
batch,
@@ -825,6 +904,7 @@ impl GraphicsUi {
let title = match level {
Level::Simple => "Graphics",
Level::Advanced => "Graphics: Advanced",
Level::Hdr => "Graphics: HDR",
};
STYLE.title(batch, atlas, &Text::literal(title));
if let Some(lines) = tooltip {
@@ -880,7 +960,10 @@ impl GraphicsUi {
return match wd.target {
Target::Button(action) => vec![Action::ButtonClick, action],
// A disabled control does nothing: no sound, no preset change.
Target::Item(item) if !item.available() => Vec::new(),
Target::Item(item) if item.blocked(s).is_some() => Vec::new(),
Target::Item(Item::Horizon(H::HdrSettings)) => {
vec![Action::ButtonClick, Action::OpenHdr]
}
Target::Item(item) => {
if item.slider(s).is_some() {
self.dragging = Some(item);
@@ -912,6 +995,53 @@ impl GraphicsUi {
mod tests {
use super::*;
#[test]
fn hdr_settings_open_where_hdr_is_offered() {
let mut s = Settings::default();
let mut ui = GraphicsUi::default();
let size = (480.0, 2000.0);
let at = |ui: &mut GraphicsUi, s: &Settings, level, item| {
let (widgets, _, _) = ui.layout(level, s, size.0, size.1);
let w = widgets
.iter()
.find(|w| matches!(w.target, Target::Item(i) if i == item))
.unwrap();
(w.x + 1.0, w.y + 1.0)
};
let button = at(&mut ui, &s, Level::Advanced, Item::Horizon(H::HdrSettings));
let click = |ui: &mut GraphicsUi, s: &mut Settings, level, point| {
ui.click(level, s, point, size, [480, 2000], false, false)
};
crate::graphics::offer_hdr(false);
assert!(click(&mut ui, &mut s, Level::Advanced, button).is_empty());
// Not even with HDR saved on, from another computer.
s.horizon.hdr = true;
assert!(click(&mut ui, &mut s, Level::Advanced, button).is_empty());
s.horizon.hdr = false;
crate::graphics::offer_hdr(true);
assert_eq!(
click(&mut ui, &mut s, Level::Advanced, button),
[Action::ButtonClick, Action::OpenHdr]
);
// On HDR's screen, the sliders wait for the switch above them.
let peak = at(&mut ui, &s, Level::Hdr, Item::Horizon(H::HdrPeak));
assert!(click(&mut ui, &mut s, Level::Hdr, peak).is_empty());
s.horizon.hdr = true;
assert_eq!(
click(&mut ui, &mut s, Level::Hdr, peak),
[Action::SliderDrag]
);
let on = |level, item| {
extra_rows(level, &s)
.iter()
.any(|r| matches!(r, Row::Setting(x) if x.item == Item::Horizon(item)))
};
assert!(on(Level::Hdr, H::Hdr) && !on(Level::Advanced, H::Hdr));
assert!(on(Level::Hdr, H::HdrPeak) && !on(Level::Advanced, H::HdrPeak));
for item in [H::HdrBlackLevel, H::HdrPaperWhite, H::HdrUiBrightness] {
assert!(on(Level::Hdr, item) && !on(Level::Advanced, item));
}
}
#[test]
fn graphics_buttons_sound_on_press_and_sliders_on_release() {
let mut s = Settings::default();
+49 -17
View File
@@ -12,25 +12,31 @@ struct Params {
peak: f32,
// 1 when this frame drew a world.
world: f32,
// 1 paints expanded pixels magenta (HORIZON_HDR_DEBUG=1).
// 1 greys what isn't expanded and tints what is (HORIZON_HDR_DEBUG=1).
debug: f32,
// 1 for an HDR10 swapchain, 0 for scRGB.
pq: f32,
// The display's black level in nits; 0 leaves the picture as it is.
black: f32,
// The HUD and menus' brightness as a share of paper white.
ui: f32,
// Per tier, low to very bright (hdr.rs `Tier`): the share of the headroom
// the brightest pixels get, and the brightness where the lift starts and
// where it is full.
share: vec4<f32>,
start: vec4<f32>,
full: vec4<f32>,
};
@group(0) @binding(2) var<uniform> params: Params;
// Light sources are lifted evenly rather than by a highlight curve: vanilla's
// textures were drawn for SDR, so a curve on their brightest pixels blows out
// near-white spots (glowstone) and leaves the rest (lava, a jack o'lantern's
// face) nearly as it was. Pixels brighter than LIFT_FULL get STRENGTH of the
// headroom, dimmer ones less down to none at LIFT_START (lava's crust, a
// lantern's metal), and no channel goes past the peak. Brightness is the mean
// of luminance and the brightest channel, so saturated light counts as bright.
const LIFT_START: f32 = 0.18;
const LIFT_FULL: f32 = 0.42;
const STRENGTH: f32 = 0.7;
// face) nearly as it was. Each tier's pixels brighter than its `full` get its
// `share` of the headroom, dimmer ones less down to none at its `start`
// (lava's crust, a lantern's metal), and no channel goes past the peak.
// Brightness is the mean of luminance and the brightest channel, so saturated
// light counts as bright.
// Whether `c` still shows the world pixel `w`: the same, or darkened evenly
// across its channels, as the vignette and other screen-wide tints darken it.
@@ -80,29 +86,55 @@ fn lift_black(e: vec3<f32>) -> vec3<f32> {
return vec4(p * 2.0 - 1.0, 0.0, 1.0);
}
// Hdr::keep_world: once the world is kept, the frame's alpha starts at 0 again.
@fragment fn fs_clear_alpha() -> @location(0) vec4<f32> {
return vec4(0.0);
}
@fragment fn fs_hdr(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> {
let at = vec2<i32>(pos.xy);
let c = textureLoad(frame, at, 0).rgb;
let f = textureLoad(frame, at, 0);
let c = f.rgb;
// What covers the world here (Hdr::keep_world): nothing at 0, the hand at
// 1/255 (Hdr::mark_hand: drawn at the world's brightness), else the HUD or a
// menu, by how much.
let covered = f.a > 0.0;
let ui = select(f.a, 0.0, f.a < 1.5 / 255.0);
// Gamma 2.2, as SDR displays and the compositors' SDR paths decode it.
var light = pow(max(c, vec3(0.0)), vec3(2.2));
let w = textureLoad(world, at, 0);
// The world's alpha is 0 on light-emitting blocks, the sun and the moon
// (terrain.wgsl, the celestial pipeline): only those shine.
if params.world > 0.5 && params.peak > 1.0 && w.a < 0.75 && shows_world(c, w.rgb) {
// The world's alpha is below 0.75 on light sources, the sun and the moon
// (terrain.wgsl, the celestial pipeline): only those shine, by the tier it
// holds, the lower the brighter, and only where nothing was drawn over them
// since: grey text over the white sun still looks like the sun darkened, but
// it raised the frame's alpha.
var lifted = false;
if params.world > 0.5 && params.peak > 1.0 && w.a < 0.75 && !covered
&& shows_world(c, w.rgb) {
// Brightness of the world pixel, before the vignette darkens the frame
// toward its edges: a light source shines the same anywhere on screen.
let wl = pow(max(w.rgb, vec3(0.0)), vec3(2.2));
let y = 0.5 * (dot(wl, vec3(0.2126, 0.7152, 0.0722)) + max(wl.r, max(wl.g, wl.b)));
let lift = smoothstep(LIFT_START, LIFT_FULL, y);
let tier = u32(clamp(round(4.0 - 4.0 * w.a / 0.7), 1.0, 4.0)) - 1u;
let lift = smoothstep(params.start[tier], params.full[tier], y);
if lift > 0.0 {
let top = max(light.r, max(light.g, light.b));
let gain = min(1.0 + (params.peak - 1.0) * STRENGTH * lift, params.peak / max(top, 1e-4));
let gain = min(1.0 + (params.peak - 1.0) * params.share[tier] * lift, params.peak / max(top, 1e-4));
light *= max(gain, 1.0);
if params.debug > 0.5 {
light = mix(light, vec3(gain, 0.0, gain), 0.6);
}
lifted = true;
}
}
// HORIZON_HDR_DEBUG=1: what isn't lifted turns grey, what is keeps its
// colour with a magenta tint, so a lift shows on any colour.
if params.debug > 0.5 && params.world > 0.5 {
if lifted {
light *= vec3(1.0, 0.6, 1.0);
} else {
light = vec3(dot(light, vec3(0.2126, 0.7152, 0.0722)));
}
}
// The HUD and menus at their own brightness, by how much they cover.
light *= mix(1.0, params.ui, ui);
let scrgb = light * params.paper;
if params.pq > 0.5 {
let e = pq(BT709_TO_BT2020 * scrgb * 80.0);
+267 -22
View File
@@ -59,6 +59,8 @@ pub struct HudAtlas {
pub width: u32,
pub height: u32,
pub sprites: HashMap<String, Rect>,
/// Resolved player-profile names and UUIDs for inline head components.
pub object_skins: HashMap<String, String>,
/// Sprites with a 0 < alpha < 255 texel (`SpriteContents.transparency().hasTranslucent`),
/// which puts their particles on vanilla's translucent layer.
pub translucent: HashSet<String>,
@@ -90,9 +92,9 @@ pub const MAP_SLOTS: usize = 256;
/// The atlas slot the minimap extra draws into.
pub const MINIMAP_SPRITE: &str = "horizon/minimap";
/// Atlas slots for downloaded player skins (`skins/<sha1>`), 64x64 each.
/// Vanilla registers a texture per skin. Here a skin evicted from its slot
/// is not stored again, and its player falls back to the default skin, so
/// the slots must cover a busy server's player list.
/// Vanilla registers a texture per skin. Here the slots are shared
/// (`skins::SkinSlots`): skins drawn in the last frame keep theirs, and a
/// skin past them shows the default skin until a slot frees up.
pub const SKIN_SLOTS: usize = 128;
/// Reused by the visible rows of the classic world selector.
pub const WORLD_ICON_SLOTS: usize = 32;
@@ -252,6 +254,24 @@ fn atlas_sprites(root: &Path, packed: &[PackedImage]) -> Vec<PackedImage> {
out.into_iter().map(|(_, image)| image).collect()
}
impl HudAtlas {
/// Keep a ready profile skin when another source only has its default.
pub fn set_object_skin(&mut self, key: String, skin: String) {
use std::collections::hash_map::Entry;
if self.object_skins.len() >= 1024 && !self.object_skins.contains_key(&key) {
self.object_skins.clear();
}
match self.object_skins.entry(key) {
Entry::Occupied(entry)
if skin.starts_with("entity/player/") && self.sprites.contains_key(entry.get()) => {
}
Entry::Occupied(mut entry) => {
entry.insert(skin);
}
Entry::Vacant(entry) => {
entry.insert(skin);
}
}
}
/// A font glyph's atlas cell (8x8 GUI pixels) and advance.
pub fn glyph(&self, ch: char) -> Option<(Rect, f32)> {
let g = self.font.glyph(ch);
@@ -592,6 +612,7 @@ impl HudAtlas {
width,
height,
sprites,
object_skins: HashMap::new(),
translucent,
flags,
sprite_animations,
@@ -641,6 +662,8 @@ pub struct HudBatch {
pub glint_alpha: f32,
/// Screen's first vertex: the preceding HUD is part of its blurred background.
pub blur_at: u32,
/// Player object profiles encountered while painting text this frame.
pub inline_profiles: Vec<serde_json::Value>,
}
/// How a run of HUD vertices blends and tests depth.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
@@ -692,6 +715,7 @@ impl HudBatch {
end_portal: EndPortalFill::default(),
glint_alpha: 0.75,
blur_at: 0,
inline_profiles: Vec::new(),
}
}
/// Draw the vertices added since `start` with the inverting blend.
@@ -1042,40 +1066,67 @@ impl HudBatch {
let mut cursor = x;
let mut previous = None;
for (i, (ch, style)) in text.chars().enumerate() {
let object = (ch == '\u{fffc}')
.then_some(style.inline_object.as_ref())
.flatten();
if let Some(crate::text::InlineObject::Player { profile, .. }) = object
&& self.inline_profiles.len() < 256
&& !self.inline_profiles.contains(profile)
{
self.inline_profiles.push(profile.clone());
}
// Font.PreparedTextBuilder.accept: lines start 1 left of a
// sequence's first character. Language.getVisualOrder makes a
// sequence of each run of one style, which world text has.
let first = i == 0 || world && previous != Some(style);
previous = Some(style);
// The style's font (`Style.getFont`), the default one unless set.
let mut g = font.glyph_styled(ch, style);
let mut g = if object.is_some() {
crate::font::Glyph {
rect: None,
left: 0.0,
top: 0.0,
right: 0.0,
bottom: 8.0,
advance: 8.0,
offset: 0.0,
}
} else {
font.glyph_styled(ch, style)
};
// Font.StringRenderOutput.accept: obfuscated characters draw a
// random glyph of the same width each frame.
if style.obfuscated == Some(true) && ch != ' ' {
if object.is_none() && style.obfuscated == Some(true) && ch != ' ' {
g = font.random_glyph(style, g.advance);
}
let c = style.color.map_or(color, |rgb| {
rgba((rgb >> 16) as u8, (rgb >> 8) as u8, rgb as u8, alpha)
});
let bold = style.is_bold();
let bold = object.is_none() && style.is_bold();
let advance = g.advance + if bold { g.offset } else { 0.0 };
glyphs.push((
cursor,
g,
c,
style.shadow_color,
bold,
style.is_italic(),
style.underlined == Some(true),
style.strikethrough == Some(true),
first,
advance,
object.cloned(),
));
cursor += advance;
}
self.patches.extend(font.take_patches());
let white = font.white();
let passes: &[bool] = if shadow { &[true, false] } else { &[false] };
// ARGB.scaleRGB(color, 0.25): the shadow's colour.
let has_shadow = shadow
|| glyphs
.iter()
.any(|glyph| glyph.3.is_some_and(|color| color != 0));
let passes: &[bool] = if has_shadow { &[true, false] } else { &[false] };
// ARGB.scaleRGB(color, 0.25): the default shadow colour.
let shade = |c: u32| {
let b = c.to_le_bytes();
rgba(
@@ -1086,20 +1137,48 @@ impl HudBatch {
)
};
for &is_shadow in passes {
for &(gx, g, c, bold, italic, underline, strike, first, advance) in &glyphs {
// In the world a glyph whose shadow is 0 has none (hasShadow).
let has_shadow = shadow && !(world && shade(c) == 0);
for (
gx,
g,
c,
custom_shadow,
bold,
italic,
underline,
strike,
first,
advance,
object,
) in &glyphs
{
if let Some(object) = object {
if !is_shadow {
self.draw_inline_object(atlas, object, *gx, y, alpha);
}
continue;
}
let shadow_color = if let Some(argb) = custom_shadow {
let text_alpha = (c >> 24) as u8 as u16;
let shadow_alpha = (argb >> 24) as u8 as u16;
let alpha = (shadow_alpha * text_alpha / 255) as u8;
rgba((argb >> 16) as u8, (argb >> 8) as u8, *argb as u8, alpha)
} else {
shade(*c)
};
let has_shadow = (custom_shadow.is_some_and(|argb| argb != 0)
|| custom_shadow.is_none() && shadow)
&& !(world && shadow_color == 0);
if is_shadow && !has_shadow {
continue;
}
let (dx, col) = if is_shadow {
(g.offset, shade(c))
(g.offset, shadow_color)
} else {
(0.0, c)
(0.0, *c)
};
let (px, py) = (gx + dx, y + dx);
let shear = |v: f32| if italic { 1.0 - 0.25 * v } else { 0.0 };
let thick = if bold { 0.1 } else { 0.0 };
let (px, py) = (*gx + dx, y + dx);
let shear = |v: f32| if *italic { 1.0 - 0.25 * v } else { 0.0 };
let thick = if *bold { 0.1 } else { 0.0 };
let draw = |batch: &mut Self, ox: f32| match g.rect {
Some(r) => {
let (x0, x1, y0, y1) = (
@@ -1144,24 +1223,86 @@ impl HudBatch {
let start = self.vertices.len();
draw(self, 0.0);
lift(self, start, base);
if bold {
if *bold {
let start = self.vertices.len();
draw(self, g.offset);
lift(self, start, base + 0.001);
}
let x0 = if first { px - 1.0 } else { px };
let x0 = if *first { px - 1.0 } else { px };
let start = self.vertices.len();
if strike {
self.rect_uv(x0, py + 3.5, px + advance - x0, 1.0, white, col);
if *strike {
self.rect_uv(x0, py + 3.5, px + *advance - x0, 1.0, white, col);
}
if underline {
self.rect_uv(x0, py + 8.0, px + advance - x0, 1.0, white, col);
if *underline {
self.rect_uv(x0, py + 8.0, px + *advance - x0, 1.0, white, col);
}
lift(self, start, base + 0.01);
}
}
cursor - x
}
fn draw_inline_object(
&mut self,
atlas: &HudAtlas,
object: &crate::text::InlineObject,
x: f32,
y: f32,
alpha: u8,
) {
use crate::text::InlineObject;
let tint = rgba(255, 255, 255, alpha);
match object {
InlineObject::Player { profile, hat, .. } => {
let id = crate::skins::parse_profile(profile).id;
let name = inline_profile_name(profile);
let by_id = atlas.object_skins.get(&format!("id:{id:032x}"));
let by_name = name.and_then(|name| {
atlas
.object_skins
.get(&format!("name:{}", name.to_ascii_lowercase()))
});
let resolved = if profile.get("id").is_some() {
by_id.or(by_name)
} else {
by_name.or(by_id)
};
let default = crate::tab_list::default_skin(id);
let skin = resolved
.filter(|path| atlas.sprites.contains_key(*path))
.map_or(default, String::as_str);
let Some(rect) = atlas.sprites.get(skin).copied() else {
return;
};
for u in [8.0, 40.0].into_iter().take(if *hat { 2 } else { 1 }) {
let du = (rect.u1 - rect.u0) / 64.0;
let dv = (rect.v1 - rect.v0) / 64.0;
self.rect_uv(
x,
y,
8.0,
8.0,
Rect {
u0: rect.u0 + du * u,
v0: rect.v0 + dv * 8.0,
u1: rect.u0 + du * (u + 8.0),
v1: rect.v0 + dv * 16.0,
w: 8,
h: 8,
},
tint,
);
}
}
InlineObject::Atlas { sprite, .. } => {
let key = sprite.strip_prefix("minecraft:").unwrap_or(sprite);
if let Some(rect) = atlas.sprites.get(key).copied() {
self.rect_uv(x, y, 8.0, 8.0, rect, tint);
}
}
}
}
/// SubmitNodeCollection.submitNameTag's text in its own space (x right,
/// y down, in GUI pixels): centred at `-Font.width / 2`, drawn as
/// Font.prepareText draws any text (styles, fonts and glyph boxes; no
@@ -1190,6 +1331,13 @@ impl HudBatch {
behind
}
}
fn inline_profile_name(profile: &serde_json::Value) -> Option<&str> {
profile
.as_str()
.or_else(|| profile.get("name").and_then(serde_json::Value::as_str))
}
#[repr(C)]
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
struct HudUniform {
@@ -1904,6 +2052,102 @@ mod tests {
assert!(missing.rect.is_none() && missing.right == 5.0);
}
#[test]
fn component_shadow_color_overrides_text_call_shadow() {
let root = two_glyph_font("component-shadow");
let atlas = HudAtlas::build(&root).unwrap();
let mut batch = HudBatch::new([320, 240]);
let text = crate::text::Text::styled(
"a",
crate::text::Style {
shadow_color: Some(0x8040_2010),
..Default::default()
},
);
batch.text_styled(&atlas, &text, 10.0, 10.0, u32::MAX, false);
assert_eq!(batch.vertices.len(), 12);
assert_eq!(batch.vertices[0].color, rgba(0x40, 0x20, 0x10, 0x80));
batch.vertices.clear();
batch.text_styled_world(&atlas, &text, 10.0, 10.0, u32::MAX, false);
assert_eq!(batch.vertices.len(), 12);
assert_eq!(batch.vertices[0].depth, 0.0);
assert_eq!(batch.vertices[6].depth, 0.03);
batch.vertices.clear();
let no_shadow = crate::text::Text::styled(
"a",
crate::text::Style {
shadow_color: Some(0),
..Default::default()
},
);
batch.text_styled(&atlas, &no_shadow, 10.0, 10.0, u32::MAX, true);
assert_eq!(batch.vertices.len(), 6);
batch.vertices.clear();
let gradient = crate::component::to_text(
&serde_json::json!({
"text": "",
"extra": [
{"text": "a", "color": "#ff0000"},
{"text": "b", "color": "#0000ff"}
]
}),
&mut |_| crate::text::Text::empty(),
);
batch.text_styled(&atlas, &gradient, 10.0, 10.0, u32::MAX, false);
assert_eq!(batch.vertices[0].color, rgba(255, 0, 0, 255));
assert_eq!(batch.vertices[6].color, rgba(0, 0, 255, 255));
}
#[test]
fn player_object_draws_an_inline_head() {
let root = two_glyph_font("inline-head");
write_png(&root, "entity/player/slim/alex", 64, 64);
write_png(&root, "entity/player/wide/steve", 64, 64);
let mut atlas = HudAtlas::build(&root).unwrap();
let text = crate::component::to_text(
&serde_json::json!({"object":"player","player":{"id":"00000000-0000-0000-0000-000000000000"}}),
&mut |_| crate::text::Text::empty(),
);
let mut batch = HudBatch::new([320, 240]);
assert_eq!(
batch.text_styled(&atlas, &text, 10.0, 10.0, u32::MAX, false),
8.0
);
assert_eq!(batch.vertices.len(), 12); // face and hat, six vertices each
assert_eq!(batch.inline_profiles.len(), 1);
atlas.set_object_skin("name:elyapluggin".into(), "entity/player/wide/steve".into());
let named = crate::component::to_text(
&serde_json::json!({"object":"player","player":{"name":"elyapluggin"}}),
&mut |_| crate::text::Text::empty(),
);
batch.vertices.clear();
batch.text_styled(&atlas, &named, 10.0, 10.0, u32::MAX, false);
let skin = atlas.sprites["entity/player/wide/steve"];
assert_eq!(batch.vertices[0].uv[0], skin.u0 + (skin.u1 - skin.u0) / 8.0);
fs::remove_dir_all(root).unwrap();
}
#[test]
fn atlas_object_draws_an_item_sprite_inline() {
let root = two_glyph_font("inline-item");
write_png(&root, "item/porkchop", 16, 16);
let atlas = HudAtlas::build(&root).unwrap();
let text = crate::component::to_text(
&serde_json::json!({
"text": "a",
"extra": [{"object": "atlas", "sprite": "minecraft:item/porkchop"}]
}),
&mut |_| crate::text::Text::empty(),
);
let mut batch = HudBatch::new([320, 240]);
let width = batch.text_styled(&atlas, &text, 10.0, 10.0, u32::MAX, false);
assert_eq!(width, atlas.font.glyph('a').advance + 8.0);
assert_eq!(batch.vertices.len(), 12); // glyph and one sprite
let sprite = atlas.sprites["item/porkchop"];
assert_eq!(batch.vertices[6].uv, [sprite.u0, sprite.v0]);
fs::remove_dir_all(root).unwrap();
}
#[test]
fn name_tags_draw_bold_text_and_tall_glyphs_like_any_text() {
// `ascii.png`'s 8x8 'a' (4 pixels wide) and `accented.png`'s 9x12
@@ -2149,6 +2393,7 @@ mod tests {
end_portal: EndPortalFill::default(),
glint_alpha: 0.75,
blur_at: 0,
inline_profiles: Vec::new(),
};
for (image_width, image_height) in [(195, 136), (176, 166), (176, 222)] {
assert_eq!(
+61
View File
@@ -883,6 +883,24 @@ fn relight_layer(
let Some(old) = level(world, sky, p) else {
continue;
};
// `BlockLightEngine.checkNode`: a block emitting at least the light
// already there keeps it and pulls its neighbours' light in. Clearing
// it would darken and relight everything it lit (flowing lava next to
// lava: thousands of cells per block).
if !sky && !force && old > 0 {
let emission = world
.state(p.0, p.1, p.2)
.map_or(0, |s| tables.cells[s as usize].emission);
if emission >= old {
if emission > old {
set_level(world, sky, p, emission);
changed.push(p);
}
decrease.push_back((p, 0));
increase.push_back(p);
continue;
}
}
if old > 0 {
set_level(world, sky, p, 0);
changed.push(p);
@@ -1089,6 +1107,49 @@ mod tests {
assert!(same(&w, (1, 0)));
assert_eq!(block(&w, 14, 0, 8), 13);
}
/// Emitters placed in light at most as bright as theirs (flowing lava
/// next to lava) only brighten: nothing they lit is darkened and lit
/// again, and the result is the full relight's.
#[test]
fn emitters_placed_in_their_own_light_only_brighten() {
let mut w = world(|c| {
c.set(8, 0, 8, TORCH);
});
let block = |w: &HashMap<(i32, i32), Arc<Chunk>>, p: Pos| {
w[&(p.0 >> 4, p.2 >> 4)].light(p.0 & 15, p.1, p.2 & 15) & 15
};
for (x, y, z) in [(9, 0, 8), (10, 0, 8), (10, 1, 8), (11, 1, 9), (8, 0, 9)] {
let around: Vec<Pos> = (-15..=15)
.flat_map(|dx| {
(-3..=3).flat_map(move |dy| (-15..=15).map(move |dz| (x + dx, y + dy, z + dz)))
})
.collect();
let before: Vec<u8> = around.iter().map(|&p| block(&w, p)).collect();
Arc::make_mut(w.get_mut(&(0, 0)).unwrap()).set(x, y, z, TORCH);
let mut changed = LightChanges::default();
relight(&mut w, &tables(), true, &[(x, y, z)], false, &mut changed);
for (&p, &was) in around.iter().zip(&before) {
assert!(block(&w, p) >= was, "{p:?} darkened from {was}");
}
}
// An emitter checked again with its block light unchanged changes
// nothing, so no section is remeshed.
let mut changed = LightChanges::default();
relight(&mut w, &tables(), false, &[(8, 0, 8)], false, &mut changed);
assert!(changed.is_empty());
let region: [[Option<&Chunk>; 3]; 3] = std::array::from_fn(|dz| {
std::array::from_fn(|dx| w.get(&(dx as i32 - 1, dz as i32 - 1)).map(|c| &**c))
});
for (si, s) in light_chunk(&region, &tables(), true).iter().enumerate() {
for i in 0..4096 {
assert_eq!(
s.get(i),
w[&(0, 0)].sections[si].light(i),
"section {si} index {i}"
);
}
}
}
#[test]
fn random_border_relights_match_full_relight() {
// Edits around every border of chunk (0, 0), at the top and bottom of
+135 -19
View File
@@ -123,9 +123,25 @@ mod vertex_tests {
fn emission_keeps_the_colour_and_the_free_bits() {
let rgb = super::pack_rgb([0x12, 0x34, 0x56]);
assert_eq!(super::emitting(rgb, 0), rgb);
assert_eq!(super::emitting(rgb, 9), 0xF656_3412);
assert_eq!(super::emitting(rgb, 15), 0xF056_3412);
assert_eq!(super::emitting(rgb, 15) | super::EMISSION_BITS, rgb);
assert_eq!(super::emitting(rgb, super::INCANDESCENT), 0xFD56_3412);
assert_eq!(super::emitting(rgb, super::VERY_BRIGHT), 0xFB56_3412);
assert_eq!(
super::emitting(rgb, super::VERY_BRIGHT) | super::EMISSION_BITS,
rgb
);
}
#[test]
fn light_sources_shine_by_kind_not_light_level() {
use super::{BRIGHT, INCANDESCENT, LOW, hdr_tier};
assert_eq!(hdr_tier("glowstone", 15), BRIGHT);
// A candle's flame is a particle: the block keeps only its wick.
assert_eq!(hdr_tier("torch", 14), INCANDESCENT);
assert_eq!(hdr_tier("red_candle", 3), LOW);
assert_eq!(hdr_tier("glow_lichen", 7), 0);
assert_eq!(hdr_tier("stone", 0), 0);
// Unlisted ones by their light level.
assert_eq!(hdr_tier("ochre_froglight", 15), INCANDESCENT);
assert_eq!(hdr_tier("sculk_sensor", 1), LOW);
}
}
pub struct SectionMesh {
@@ -263,6 +279,8 @@ enum Tint {
/// `BlockTintSources.grassBlock`: grass in the world, none on its particles.
GrassBlock,
TallGrassUpper,
SugarCane,
LilyPad,
Foliage,
DryFoliage,
Water,
@@ -282,6 +300,8 @@ struct Info {
/// `getShadeBrightness` (ambient occlusion contribution).
shade: f32,
emission: u8,
/// `hdr_tier`.
tier: u8,
/// `emissiveRendering`: drawn and sampled at full brightness.
emissive: bool,
fluid: FluidState,
@@ -329,6 +349,10 @@ pub struct MeshTables {
lava_still: u16,
lava_flow: u16,
}
fn stem_color(age: u8) -> u32 {
let a = u32::from(age);
(a * 32) << 16 | (255 - a * 8) << 8 | (a * 4)
}
fn redstone_color(power: u8) -> u32 {
// RedstoneWireBlock.COLORS
let f = f32::from(power) / 15.0;
@@ -377,10 +401,9 @@ impl MeshTables {
([Tint::Grass; 2], 1)
}
}
"fern" | "short_grass" | "potted_fern" | "bush" | "sugar_cane" => {
([Tint::Grass; 2], 1)
}
"fern" | "short_grass" | "potted_fern" | "bush" => ([Tint::Grass; 2], 1),
"grass_block" => ([Tint::GrassBlock; 2], 1),
"sugar_cane" => ([Tint::SugarCane; 2], 1),
"pink_petals" | "wildflowers" => ([Tint::None, Tint::Grass], 2),
"spruce_leaves" => ([Tint::Constant(0x61_9961); 2], 1),
"birch_leaves" => ([Tint::Constant(0x80_A755); 2], 1),
@@ -395,7 +418,7 @@ impl MeshTables {
([Tint::Constant(0xE0_C71C); 2], 1)
}
"melon_stem" | "pumpkin_stem" => ([Tint::Stem(prop("age")); 2], 1),
"lily_pad" => ([Tint::Constant(0x20_8030); 2], 1),
"lily_pad" => ([Tint::LilyPad; 2], 1),
_ => ([Tint::None; 2], 0),
};
// getShadeBrightness overrides; the default is 0.2 for full collision shapes.
@@ -450,6 +473,7 @@ impl MeshTables {
permeable: !s.solid_render || s.dampening == 0,
shade,
emission: s.emission,
tier: hdr_tier(name, s.emission),
emissive,
fluid: FluidState::from_block(state)?,
model: s.render_shape == RenderShape::Model,
@@ -491,6 +515,25 @@ impl MeshTables {
tint_sources: i.tint_count,
})
}
/// `BlockTintSource.color(state)`: block-model colour without a level.
/// `grass` is `GrassColor.getDefaultColor()` from the active pack's colormap.
/// Untinted faces, blank sources and invalid tint indices return `None`.
pub fn default_tint(&self, state: BlockStateId, index: i32, grass: u32) -> Option<u32> {
let info = self.info.get(state.0 as usize)?;
if index < 0 || index >= i32::from(info.tint_count) {
return None;
}
Some(match info.tints[index as usize] {
Tint::None | Tint::SugarCane | Tint::Water => return None,
Tint::Grass | Tint::GrassBlock | Tint::TallGrassUpper => grass & 0xFF_FFFF,
Tint::Foliage => 0x48_B518,
Tint::DryFoliage => 0x5C_3C32,
Tint::LilyPad => 0x71_C35C,
Tint::Constant(c) => c,
Tint::Redstone(p) => redstone_color(p),
Tint::Stem(age) => stem_color(age),
})
}
/// `TerrainParticle`'s colour: tint source 0's `colorAsTerrainParticle`,
/// which is the in-world (biome-blended) colour except on grass blocks,
/// whose dirt crumbs stay untinted. `None` when the state has no source.
@@ -513,6 +556,56 @@ impl MeshTables {
let mut tints = Tints::new(region, biomes, zoom, blend_radius, origin);
Mesher::tint(info, 0, &mut tints, [x & 15, y & 15, z & 15])
}
/// `BlockColors` for each of the state's tint indices at `pos`, as the
/// chunk mesher tints them (grass blocks included): `None` for an
/// untinted (-1) layer. For block models drawn outside the chunk mesh.
pub fn block_tints(
&self,
state: BlockStateId,
pos: [i32; 3],
region: &[[Option<&Chunk>; 3]; 3],
biomes: &Biomes,
zoom: BiomeZoom,
blend_radius: i32,
) -> impl Fn(i32) -> Option<u32> + use<> {
let sources = self.info.get(state.0 as usize).map(|info| {
let [x, y, z] = pos;
let origin = [x >> 4, z >> 4, y & !15];
let mut tints = Tints::new(region, biomes, zoom, blend_radius, origin);
let local = [x & 15, y & 15, z & 15];
let colors = [0, 1].map(|i| Mesher::tint(info, i, &mut tints, local));
(colors, i32::from(info.tint_count))
});
move |index| {
let (colors, count) = sources?;
if index < 0 || index >= count {
return None;
}
colors[(index as usize).min(1)]
}
}
/// `ModelBlockRenderer.tesselateFlat`'s light coords (`LightCoordsUtil`
/// packing, the quad's own emission included) for `quad` of `state`
/// drawn as the only block at `pos`, as `MovingBlockRenderState` gives
/// it: `light` is the world's stored light (sky << 4 | block) at a
/// position. For block models drawn outside the chunk mesh.
pub fn flat_light(
&self,
state: BlockStateId,
pos: [i32; 3],
quad: &horizon_assets::bake::ModelQuad,
light: impl Fn([i32; 3]) -> u8,
) -> u32 {
let own = self.info.get(state.0 as usize);
let full = own.is_some_and(|i| i.full);
let at = lighting::flat_position(pos, &quad.positions, quad.facing, quad.cull, full);
let packed = light(at);
let coords = match own {
Some(own) => lighting::packed_coords(own, packed),
None => u32::from(packed & 15) << 4 | u32::from(packed >> 4) << 20,
};
lighting::with_emission(coords, quad.light_emission)
}
/// `BlockState.getOffset` for blocks with a random model offset.
pub fn offset(&self, state: BlockStateId, x: i32, z: i32) -> Option<[f64; 3]> {
offset(self.info.get(state.0 as usize)?, x, z)
@@ -580,13 +673,37 @@ impl Padded {
}
/// Set on a terrain vertex's sprite layer when its texture alpha must be ignored.
pub const OPAQUE_LAYER: u16 = 1 << 15;
/// The bits of `TerrainVertex::color`'s alpha byte that hold the light its face emits, for
/// HDR output's highlights (`gfx/hdr.rs`): 15 minus the level, so the usual 0xFF means none.
/// The byte's other four bits stay set.
/// The bits of `TerrainVertex::color`'s alpha byte that hold how brightly its face shines
/// in HDR output (`gfx/hdr.rs`): 15 minus the tier, so the usual 0xFF means none. The
/// byte's other four bits stay set.
pub const EMISSION_BITS: u32 = 0x0F00_0000;
/// `color` marked as emitting light `level` (0 to 15).
pub fn emitting(color: u32, level: u8) -> u32 {
color & !EMISSION_BITS | u32::from(15 - level.min(15)) << 24
/// HDR tiers, by the kind of light rather than its level: a candle's flame is as bright as
/// a torch's, it only lights less around it. 0 stays as in SDR.
pub const LOW: u8 = 1;
pub const INCANDESCENT: u8 = 2;
pub const BRIGHT: u8 = 3;
/// The sun and lightning.
pub const VERY_BRIGHT: u8 = 4;
/// `color` marked as shining at `tier` (0 to `VERY_BRIGHT`).
pub fn emitting(color: u32, tier: u8) -> u32 {
color & !EMISSION_BITS | u32::from(15 - tier.min(VERY_BRIGHT)) << 24
}
/// The HDR tier of block `name` (no namespace), which emits light `level`.
pub fn hdr_tier(name: &str, level: u8) -> u8 {
match name {
_ if level == 0 => 0,
"glowstone" | "jack_o_lantern" | "campfire" | "soul_campfire" | "soul_lantern"
| "soul_torch" | "soul_wall_torch" | "soul_fire" => BRIGHT,
"torch" | "wall_torch" | "lantern" | "lava" | "crying_obsidian" | "fire"
| "sea_lantern" => INCANDESCENT,
"redstone_torch" | "redstone_wall_torch" | "magma_block" => LOW,
// Its flame is a particle.
_ if name.ends_with("candle") || name.ends_with("candle_cake") => LOW,
// A faint glow in the dark, as in SDR.
"glow_lichen" => 0,
_ if level >= 10 => INCANDESCENT,
_ => LOW,
}
}
const UNSET: u32 = u32::MAX;
const NO_BIOME: u16 = u16::MAX;
@@ -838,17 +955,15 @@ impl Mesher<'_> {
}
Some(match state.tints[(index as usize).min(1)] {
Tint::None => return None,
Tint::Grass | Tint::GrassBlock => tints.get(Resolver::Grass, x, y, z),
Tint::Grass | Tint::GrassBlock | Tint::SugarCane => tints.get(Resolver::Grass, x, y, z),
Tint::TallGrassUpper => tints.get(Resolver::Grass, x, y, z),
Tint::Foliage => tints.get(Resolver::Foliage, x, y, z),
Tint::DryFoliage => tints.get(Resolver::Dry, x, y, z),
Tint::Water => tints.get(Resolver::Water, x, y, z),
Tint::Constant(c) => c,
Tint::LilyPad => 0x20_8030,
Tint::Redstone(p) => redstone_color(p),
Tint::Stem(age) => {
let a = u32::from(age);
(a * 32) << 16 | (255 - a * 8) << 8 | (a * 4)
}
Tint::Stem(age) => stem_color(age),
})
}
#[allow(clippy::too_many_arguments)]
@@ -864,6 +979,7 @@ impl Mesher<'_> {
state: &Info,
) {
let q = &face.quad;
let tier = state.tier.max(hdr_tier("", q.light_emission));
let shade_face = q.shade_direction_override.unwrap_or(q.facing);
let directional = lighting::cardinal(self.nether_shading, shade_face);
// Grey level per vertex before tinting, and vanilla light coords.
@@ -903,7 +1019,7 @@ impl Mesher<'_> {
} else {
face.layer as u16
},
emitting(pack_rgb(rgb), state.emission.max(q.light_emission)),
emitting(pack_rgb(rgb), tier),
pack_coords(lighting::with_emission(coords[i], q.light_emission)),
));
}
@@ -227,8 +227,11 @@ pub(super) fn tesselate(
} else {
0xFF_FFFF
};
// Lava emits light level 15.
let emission = if water { 0 } else { 15 };
let emission = if water {
0
} else {
crate::mesher::INCANDESCENT
};
let color = |by: f32| {
let rgb = [16, 8, 0].map(|shift| lighting::scale((tint >> shift & 255) as i32, by) as u32);
crate::mesher::emitting(pack_rgb(rgb), emission)
@@ -39,10 +39,13 @@ fn state<'a>(pad: &Padded, info: &'a [Info], p: [i32; 3]) -> &'a Info {
/// `LightCoordsUtil.getLightCoords`: light at `p` as seen through `state`
/// (block level << 4 | sky level << 20).
pub(super) fn coords(pad: &Padded, state: &Info, p: [i32; 3]) -> u32 {
packed_coords(state, pad.light[pi(p[0], p[1], p[2])])
}
/// `coords` of a stored light byte (sky level << 4 | block level).
pub(super) fn packed_coords(state: &Info, packed: u8) -> u32 {
if state.emissive {
return FULL_BRIGHT;
}
let packed = pad.light[pi(p[0], p[1], p[2])];
let block = u32::from((packed & 15).max(state.emission));
block << 4 | u32::from(packed >> 4) << 20
}
@@ -274,8 +277,28 @@ pub(super) fn flat(
positions: &[[f32; 3]; 4],
face: D,
) -> u32 {
let cubic = shape(positions, face, own.full).cubic;
coords(pad, own, if cubic { step(pos, face) } else { pos })
coords(
pad,
own,
flat_position(pos, positions, face, None, own.full),
)
}
/// Where `tesselateFlat` reads the light of a quad of a block at `pos`: the
/// neighbour on its cull face, else `prepareQuadFlat`'s choice (the neighbour
/// when the quad is cubic, else the block itself). `full` is the block's
/// `isCollisionShapeFullBlock`.
pub(super) fn flat_position(
pos: [i32; 3],
positions: &[[f32; 3]; 4],
face: D,
cull: Option<D>,
full: bool,
) -> [i32; 3] {
match cull {
Some(d) => step(pos, d),
None if shape(positions, face, full).cubic => step(pos, face),
None => pos,
}
}
/// `prepareQuadAmbientOcclusion`: per-vertex AO brightness (before `ARGB.gray`)
/// and smooth light coords, by vertex index.
@@ -398,6 +421,39 @@ mod tests {
assert_eq!(max_coords(packed(3, 9), packed(7, 2)), packed(7, 9));
}
#[test]
fn flat_light_reads_the_neighbour_a_face_looks_at() {
let pos = [4, 64, -2];
let full_top = [
[0.0, 1.0, 0.0],
[0.0, 1.0, 1.0],
[1.0, 1.0, 1.0],
[1.0, 1.0, 0.0],
];
// A cull face samples its neighbour, whatever the quad's shape.
let inset = full_top.map(|[x, _, z]| [x, 0.75, z]);
assert_eq!(
flat_position(pos, &inset, D::Up, Some(D::Up), false),
[4, 65, -2]
);
// A flush quad without a cull face samples its neighbour too: a block
// drawn at a cell that is now opaque (a moving block, #3002) still
// takes the light around it.
assert_eq!(
flat_position(pos, &full_top, D::Up, None, false),
[4, 65, -2]
);
let west = [
[0.0, 0.0, 0.0],
[0.0, 0.0, 1.0],
[0.0, 1.0, 1.0],
[0.0, 1.0, 0.0],
];
assert_eq!(flat_position(pos, &west, D::West, None, false), [3, 64, -2]);
// An inset quad (a piston head's arm) takes the block's own light.
assert_eq!(flat_position(pos, &inset, D::Up, None, false), pos);
assert_eq!(flat_position(pos, &inset, D::Up, None, true), [4, 65, -2]);
}
#[test]
fn quad_shape_classifies_partial_and_cubic_faces() {
let full_top = [
[0.0, 1.0, 0.0],
@@ -54,3 +54,40 @@ fn terrain_particles_use_the_particle_sprite_and_vanilla_tint_sources() {
assert_eq!(tint("minecraft:pink_petals"), None);
assert_eq!(tint("minecraft:stone"), None);
}
/// Blocks drawn outside the chunk mesh (moved by a piston) take the chunk
/// mesher's per-layer tints: a grass block's top and side overlay are tinted
/// as in the world, not left gray like its particles.
#[test]
fn block_tints_match_the_chunk_mesh_for_grass_blocks() {
let root =
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../.local/horizon-client/26.3");
if !root.join("horizon-resources.json").exists() {
return;
}
let assets = Assets::load(&root).unwrap();
let render = assets.reload(&root).unwrap();
let facts = BlockFacts::load(&root, assets.registry).unwrap();
let visibility = crate::models::load_visibility(&root, assets.registry).unwrap();
let tables = MeshTables::new(&assets, &render, &facts, visibility).unwrap();
let biomes = Biomes::load(&root).unwrap();
let state = |name| assets.registry.default_state(name).unwrap();
let center = Chunk::empty(0, 0);
let mut region = [[None; 3]; 3];
region[1][1] = Some(&center);
let tints = |name| {
tables.block_tints(
state(name),
[5, 70, 9],
&region,
&biomes,
BiomeZoom::new(0),
2,
)
};
let grass = tints("minecraft:grass_block");
assert!(grass(0).is_some());
assert_eq!(grass(-1), None);
assert_eq!(tints("minecraft:spruce_leaves")(0), Some(0x61_9961));
assert_eq!(tints("minecraft:stone")(0), None);
}
@@ -77,6 +77,7 @@ fn info(solid: bool) -> Info {
permeable: false,
shade: 1.0,
emission: 0,
tier: 0,
emissive: false,
fluid: FluidState::EMPTY,
model: false,
+1 -5
View File
@@ -36,11 +36,8 @@ pub struct Installed {
pub description: String,
/// Why it is not running, when it failed.
pub problem: Option<String>,
/// Loaded with `--dev-mod`: always on, reloaded when rebuilt.
/// Loaded with `--dev-mod`: always on.
pub dev: bool,
/// The running mod set a page of widgets (capability `ui`): its row
/// gains a Settings button.
pub has_settings: bool,
/// A native mod (`game/native_hooks.rs`): built into the game, on until
/// the player turns it off, and its settings page.
pub native: Option<NativePage>,
@@ -178,7 +175,6 @@ mod tests {
description: String::new(),
problem: None,
dev,
has_settings: false,
native: None,
};
let mut list = ModList {
@@ -87,6 +87,9 @@ const MODEL_PARTS: [&str; 7] = [
];
const GRAPHICS_BACKEND: &[&str] = &["default", "opengl", "vulkan"];
const GRAPHICS_PRESET: &[&str] = &["fast", "fancy", "fabulous", "custom"];
/// Horizon's render distance cap (vanilla stops at 32): the server protocol's
/// view distance tops out at 127 anyway.
pub const MAX_RENDER_DISTANCE: i32 = 128;
const INACTIVITY: &[&str] = &["minimized", "afk"];
const CLOUDS: &[&str] = &["false", "fast", "true"];
const MAIN_HAND: &[&str] = &["left", "right"];
@@ -159,7 +162,7 @@ static TABLE: LazyLock<Vec<Def>> = LazyLock::new(|| {
b("reducedDebugInfo", false),
def("renderClouds", O(Clouds), V::Int(2)),
i("cloudRange", 2, 128, 128),
i("renderDistance", 2, 64, 12),
i("renderDistance", 2, MAX_RENDER_DISTANCE, 12),
i("simulationDistance", 5, 32, 12),
u("screenEffectScale", 1.0),
def("soundDevice", O(AnyStr), V::Str(String::new())),
+258 -115
View File
@@ -84,6 +84,27 @@ pub(crate) const ACCESSIBILITY_OPTIONS: &[Opt] = &[
Opt::HighContrastBlockOutlines,
];
/// `ControlsScreen.options`: the controls added by Java Edition 26.3, in
/// their vanilla order. The macOS-only Control-click switch is omitted on
/// other platforms just as `InputQuirks.EMULATE_RIGHT_CLICK_WITH_CTRL_KEY`
/// does in Java.
pub(crate) fn vanilla_control_options() -> Vec<Opt> {
let mut options = vec![
Opt::ToggleCrouch,
Opt::ToggleSprint,
Opt::ToggleAttack,
Opt::ToggleUse,
Opt::AutoJump,
Opt::SprintWindow,
Opt::QuitShortcuts,
];
if cfg!(target_os = "macos") {
options.push(Opt::CtrlClickEmulatesRightClick);
}
options.push(Opt::OperatorItemsTab);
options
}
/// `MouseSettingsScreen.options` in Java Edition 26.3, in vanilla order.
pub(crate) const MOUSE_OPTIONS: &[Opt] = &[
Opt::Sensitivity,
@@ -120,6 +141,11 @@ impl Opt {
| Opt::ChunkFade
| Opt::ImprovedTransparency
| Opt::ChunkBuilder
// Saved, but the creative tabs, the quit keys and the mouse
// don't read them yet.
| Opt::OperatorItemsTab
| Opt::QuitShortcuts
| Opt::CtrlClickEmulatesRightClick
)
}
fn file_key(self) -> Option<&'static str> {
@@ -144,6 +170,10 @@ impl Opt {
Opt::RotateWithMinecarts => "rotateWithMinecart",
Opt::HighContrastBlockOutlines => "highContrastBlockOutline",
Opt::MusicFrequency => "musicFrequency",
Opt::SoundDevice => "soundDevice",
Opt::OperatorItemsTab => "operatorItemsTab",
Opt::QuitShortcuts => "quitShortcuts",
Opt::CtrlClickEmulatesRightClick => "ctrlClickEmulatesRightClick",
Opt::ModelPart(i) => MODEL_PART_KEYS[i],
Opt::AllowServerListing => "allowServerListing",
Opt::RealmsNotifications => "realmsNotifications",
@@ -167,6 +197,7 @@ impl Opt {
Opt::DiscreteScroll => "options.discrete_mouse_scroll",
Opt::AllowCursorChanges => "options.allowCursorChanges",
Opt::Volume(_) => "soundCategory.master",
Opt::SoundDevice => "options.audioDevice",
Opt::GuiScale => "options.guiScale",
Opt::ViewBobbing => "options.viewBobbing",
Opt::Gamma => "options.gamma",
@@ -194,7 +225,13 @@ impl Opt {
Opt::MenuBlur => "options.accessibility.menu_background_blurriness",
Opt::ToggleCrouch => "key.sneak",
Opt::ToggleSprint => "key.sprint",
Opt::ToggleAttack => "key.attack",
Opt::ToggleUse => "key.use",
Opt::AutoJump => "options.autoJump",
Opt::SprintWindow => "options.sprintWindow",
Opt::QuitShortcuts => "options.quitShortcuts",
Opt::CtrlClickEmulatesRightClick => "options.ctrlClickEmulatesRightClick",
Opt::OperatorItemsTab => "options.operatorItemsTab",
Opt::ChatVisibility => "options.chat.visibility",
Opt::ChatColors => "options.chat.color",
Opt::ChatLinks => "options.chat.links",
@@ -217,7 +254,7 @@ impl Opt {
Opt::NotificationDisplayTime => "options.notifications.display_time",
Opt::Narrator => "options.narrator",
Opt::TextBackground => "options.accessibility.text_background",
Opt::ChatDelay => "Chat Delay",
Opt::ChatDelay => "horizon.options.chat_delay",
Opt::DistortionEffects => "options.screenEffectScale",
Opt::FovEffects => "options.fovEffectScale",
Opt::DarknessPulsing => "options.darknessEffectScale",
@@ -278,7 +315,11 @@ impl Opt {
| Opt::GlintStrength
| Opt::PanoramaSpeed => Kind::Slider(s.file.double(self.file_key().unwrap()) as f32),
Opt::Fov => unit(s.fov, 30.0, 110.0),
Opt::RenderDistance => unit(s.render_distance as f32, 2.0, 64.0),
Opt::RenderDistance => unit(
s.render_distance as f32,
2.0,
crate::options_file::MAX_RENDER_DISTANCE as f32,
),
Opt::SimulationDistance => unit(s.simulation_distance as f32, 5.0, 32.0),
Opt::Sensitivity => Kind::Slider(s.sensitivity),
Opt::WheelSensitivity => unit(s.mouse_wheel_sensitivity, 0.01, 10.0),
@@ -308,6 +349,7 @@ impl Opt {
Opt::PadLookSensitivity => Kind::Slider(s.controller.look_sensitivity),
Opt::PadMoveDeadzone => unit(s.controller.move_deadzone, 0.0, 0.5),
Opt::PadLookDeadzone => unit(s.controller.look_deadzone, 0.0, 0.5),
Opt::SprintWindow => unit(s.sprint_window as f32, 0.0, 10.0),
_ => Kind::Cycle,
}
}
@@ -356,6 +398,14 @@ impl Opt {
}
}
Opt::PanoramaSpeed => percent(c, s.file.double("panoramaScrollSpeed") as f32),
Opt::SprintWindow => {
let value = if s.sprint_window == 0 {
tr("options.off")
} else {
Text::tr("options.value", &[&s.sprint_window])
};
generic(c, value)
}
Opt::Fov => match s.fov as i32 {
70 => generic(c, tr("options.fov.min")),
110 => generic(c, tr("options.fov.max")),
@@ -573,7 +623,20 @@ impl Opt {
} else {
"options.key.hold"
}),
Opt::ToggleAttack => tr(if s.toggle_attack {
"options.key.toggle"
} else {
"options.key.hold"
}),
Opt::ToggleUse => tr(if s.toggle_use {
"options.key.toggle"
} else {
"options.key.hold"
}),
Opt::AutoJump => on_off(s.auto_jump),
Opt::QuitShortcuts => on_off(s.quit_shortcuts),
Opt::CtrlClickEmulatesRightClick => on_off(s.ctrl_click_emulates_right_click),
Opt::OperatorItemsTab => on_off(s.operator_items_tab),
Opt::ChatVisibility => tr([
"options.chat.visibility.full",
"options.chat.visibility.system",
@@ -586,6 +649,13 @@ impl Opt {
Opt::ReducedDebugInfo => on_off(s.reduced_debug_info),
Opt::HighContrast => on_off(s.high_contrast),
Opt::ShowSubtitles => on_off(s.show_subtitles),
Opt::SoundDevice => {
if s.sound_device.is_empty() {
tr("options.audioDevice.default")
} else {
Text::literal(&s.sound_device)
}
}
Opt::MusicFrequency => tr([
"options.music_frequency.default",
"options.music_frequency.frequent",
@@ -603,25 +673,25 @@ impl Opt {
} else {
"options.key.hold"
}),
Opt::PadLightbar => Text::literal(match s.controller.lightbar {
gamepad::Lightbar::Health => "Health",
gamepad::Lightbar::Dimension => "Dimension",
gamepad::Lightbar::Off => "Off",
Opt::PadLightbar => tr(match s.controller.lightbar {
gamepad::Lightbar::Health => "horizon.options.lightbar.health",
gamepad::Lightbar::Dimension => "horizon.options.lightbar.dimension",
gamepad::Lightbar::Off => "options.off",
}),
Opt::PadGlyphs => Text::literal(match s.controller.glyphs {
GlyphStyle::Auto => "Auto",
GlyphStyle::Xbox => "Xbox",
GlyphStyle::PlayStation => "PlayStation",
GlyphStyle::Nintendo => "Nintendo",
Opt::PadGlyphs => tr(match s.controller.glyphs {
GlyphStyle::Auto => "horizon.options.glyphs.auto",
GlyphStyle::Xbox => "horizon.options.glyphs.xbox",
GlyphStyle::PlayStation => "horizon.options.glyphs.playstation",
GlyphStyle::Nintendo => "horizon.options.glyphs.nintendo",
}),
Opt::PadMenuNav => Text::literal(match s.controller.menu_nav {
gamepad::MenuNav::Focus => "Focus",
gamepad::MenuNav::Pointer => "Pointer",
Opt::PadMenuNav => tr(match s.controller.menu_nav {
gamepad::MenuNav::Focus => "horizon.options.menu_focus",
gamepad::MenuNav::Pointer => "horizon.options.menu_pointer",
}),
Opt::PadKeyboard => Text::literal(match s.controller.keyboard {
gamepad::TextKeyboard::Auto => "Auto",
gamepad::TextKeyboard::Horizon => "Horizon",
gamepad::TextKeyboard::Steam => "Steam",
Opt::PadKeyboard => tr(match s.controller.keyboard {
gamepad::TextKeyboard::Auto => "horizon.options.keyboard.auto",
gamepad::TextKeyboard::Horizon => "horizon.options.keyboard.horizon",
gamepad::TextKeyboard::Steam => "horizon.options.keyboard.steam",
}),
_ => Text::empty(),
}
@@ -647,7 +717,10 @@ impl Opt {
crate::options_file::V::Double(f64::from(v)),
),
Opt::Fov => s.fov = step(30.0, 110.0),
Opt::RenderDistance => s.render_distance = step(2.0, 64.0) as i32,
Opt::RenderDistance => {
s.render_distance =
step(2.0, crate::options_file::MAX_RENDER_DISTANCE as f32) as i32
}
Opt::SimulationDistance => s.simulation_distance = step(5.0, 32.0) as i32,
Opt::Sensitivity => s.sensitivity = v,
Opt::WheelSensitivity => s.mouse_wheel_sensitivity = 0.01 + v * 9.99,
@@ -674,6 +747,7 @@ impl Opt {
Opt::PadLookSensitivity => s.controller.look_sensitivity = v,
Opt::PadMoveDeadzone => s.controller.move_deadzone = step(0.0, 50.0) / 100.0,
Opt::PadLookDeadzone => s.controller.look_deadzone = step(0.0, 50.0) / 100.0,
Opt::SprintWindow => s.sprint_window = step(0.0, 10.0) as i32,
_ => {}
}
if self.affects_preset() {
@@ -770,7 +844,14 @@ impl Opt {
Opt::ChunkBuilder => s.prioritize_chunk_updates = next(s.prioritize_chunk_updates, 3),
Opt::ToggleCrouch => s.toggle_crouch = !s.toggle_crouch,
Opt::ToggleSprint => s.toggle_sprint = !s.toggle_sprint,
Opt::ToggleAttack => s.toggle_attack = !s.toggle_attack,
Opt::ToggleUse => s.toggle_use = !s.toggle_use,
Opt::AutoJump => s.auto_jump = !s.auto_jump,
Opt::QuitShortcuts => s.quit_shortcuts = !s.quit_shortcuts,
Opt::CtrlClickEmulatesRightClick => {
s.ctrl_click_emulates_right_click = !s.ctrl_click_emulates_right_click
}
Opt::OperatorItemsTab => s.operator_items_tab = !s.operator_items_tab,
Opt::ChatVisibility => s.chat_visibility = next(s.chat_visibility, 3),
Opt::ChatColors => s.chat_colors = !s.chat_colors,
Opt::ChatLinks => s.chat_links = !s.chat_links,
@@ -779,6 +860,23 @@ impl Opt {
Opt::ReducedDebugInfo => s.reduced_debug_info = !s.reduced_debug_info,
Opt::HighContrast => s.high_contrast = !s.high_contrast,
Opt::ShowSubtitles => s.show_subtitles = !s.show_subtitles,
Opt::SoundDevice => {
let devices = crate::audio::output_devices();
let mut all = vec![String::new()];
for d in devices {
if !all.contains(&d) {
all.push(d);
}
}
let current_idx = all.iter().position(|d| d == &s.sound_device).unwrap_or(0);
let count = all.len();
let next_idx = if back {
(current_idx + count - 1) % count
} else {
(current_idx + 1) % count
};
s.sound_device = all[next_idx].clone();
}
Opt::MusicFrequency => {
let n = next(s.file.int("musicFrequency").clamp(0, 2) as u8, 3);
s.file
@@ -921,6 +1019,7 @@ pub enum Page {
/// Horizon's graphics settings (`graphics_ui`).
Graphics,
GraphicsAdvanced,
GraphicsHdr,
/// Horizon's controller settings and button binds.
Controller,
PadBinds,
@@ -955,8 +1054,7 @@ enum Act {
/// Pick `s.shader_packs[i]`, or turn it off when it is the pick.
PickShaderPack(usize),
OpenShaderPackFolder,
/// The installed mod's own settings page (capability `ui`), shown as a
/// screen: `Event::ModSettings` has the game open it.
/// The installed native mod's settings page.
ModSettings(usize),
/// A native mod's key (mod, key) waits for a key; a button of its page.
NativeKey(usize, usize),
@@ -1023,9 +1121,6 @@ pub enum Event {
/// A shader pack was picked or turned off: save and apply it.
ShaderPack,
OpenShaderPackFolder,
/// Open the mod's own settings page (capability `ui`): the installed
/// mod's index in `mods.installed`.
ModSettings(usize),
/// Native mod `.0`'s page opened: the game asks the mod for its labels
/// again (they were made with the list, maybe in another language).
NativeSettings(usize),
@@ -1178,22 +1273,14 @@ fn option_widget(s: &Settings, opt: Opt, x: f32, y: f32, w: f32) -> Widget {
/// What a button opening one of Horizon's pages says on hover.
fn horizon_page_tooltip(page: Page) -> Option<&'static str> {
Some(match page {
Page::Horizon => "Horizon's own settings: graphics, controllers, extras and mods.",
Page::Graphics => "Graphics, simply: each setting says what it costs.",
Page::GraphicsAdvanced => {
"Every graphics setting, Horizon's included, with what each costs."
}
Page::Controller => "Controllers: their buttons, sticks, vibration and menu navigation.",
Page::Extras => {
"Optional additions, all off by default: the game stays vanilla until you turn one on."
}
Page::Agent => "Let your own AI agent play: horizon-ctl and MCP clients on this computer.",
Page::ShaderPacks => {
"Shader packs from the shaderpacks folder: Horizon's own and those in the shaders/ format. One runs at a time."
}
Page::Mods => {
"Mods made for Horizon, from the horizon-mods folder. Each runs in a sandbox and stays off until you turn it on."
}
Page::Horizon => "horizon.pages.settings.tooltip",
Page::Graphics => "horizon.pages.graphics.tooltip",
Page::GraphicsAdvanced => "horizon.pages.graphics_advanced.tooltip",
Page::Controller => "horizon.pages.controller.tooltip",
Page::Extras => "horizon.pages.extras.tooltip",
Page::Agent => "horizon.pages.agent.tooltip",
Page::ShaderPacks => "horizon.pages.shader_packs.tooltip",
Page::Mods => "horizon.pages.mods.tooltip",
_ => return None,
})
}
@@ -1295,7 +1382,10 @@ impl OptionsUi {
if page == Page::Packs {
self.pack_screen = None;
}
if matches!(page, Page::Graphics | Page::GraphicsAdvanced) {
if matches!(
page,
Page::Graphics | Page::GraphicsAdvanced | Page::GraphicsHdr
) {
self.graphics.reset();
}
}
@@ -1304,6 +1394,7 @@ impl OptionsUi {
match page {
Page::Graphics => Some(crate::graphics_ui::Level::Simple),
Page::GraphicsAdvanced => Some(crate::graphics_ui::Level::Advanced),
Page::GraphicsHdr => Some(crate::graphics_ui::Level::Hdr),
_ => None,
}
}
@@ -1322,16 +1413,16 @@ impl OptionsUi {
Page::Language => "options.language.title",
Page::Font => "options.font.title",
Page::Packs => "resourcePack.title",
Page::Mods => "Mods",
Page::Mods => "horizon.pages.mods",
Page::NativeMod(_) => "Mod Settings",
Page::ShaderPacks => "Shader Packs",
Page::Graphics | Page::GraphicsAdvanced => "options.videoTitle",
Page::Controller => "Controller Settings",
Page::PadBinds => "Controller Binds",
Page::Extras => "Horizon Extras",
Page::ShaderPacks => "horizon.pages.shader_packs",
Page::Graphics | Page::GraphicsAdvanced | Page::GraphicsHdr => "options.videoTitle",
Page::Controller => "horizon.pages.controller",
Page::PadBinds => "horizon.pages.controller_binds",
Page::Extras => "horizon.pages.extras",
Page::Credits => "credits_and_attribution.screen.title",
Page::Horizon => "Horizon Settings",
Page::Agent => "AI Agent",
Page::Horizon => "horizon.pages.settings",
Page::Agent => "horizon.pages.agent",
}
}
fn scrollbar_x(&self, width: f32) -> f32 {
@@ -1469,21 +1560,23 @@ impl OptionsUi {
Page::Horizon => {
// Two to a row, as vanilla's OptionsList.addSmall lays out
// buttons; each opens a page built from vanilla's widgets.
let open = |label: &str, page: Page| {
button(0.0, 0.0, 150.0, Text::literal(label), Act::Push(page))
};
let open =
|key: &str, page: Page| button(0.0, 0.0, 150.0, tr(key), Act::Push(page));
vec![
Row::Buttons(
open("Graphics...", Page::Graphics),
Some(open("Advanced Graphics...", Page::GraphicsAdvanced)),
open("horizon.pages.open.graphics", Page::Graphics),
Some(open(
"horizon.pages.open.graphics_advanced",
Page::GraphicsAdvanced,
)),
),
Row::Buttons(
open("Shader Packs...", Page::ShaderPacks),
Some(open("Controller Settings...", Page::Controller)),
open("horizon.pages.open.shader_packs", Page::ShaderPacks),
Some(open("horizon.pages.open.controller", Page::Controller)),
),
Row::Buttons(
open("Extras...", Page::Extras),
Some(open("Mods...", Page::Mods)),
open("horizon.pages.open.extras", Page::Extras),
Some(open("horizon.pages.open.mods", Page::Mods)),
),
// No AI hides the AI Agent page and everything AI.
Row::Buttons(
@@ -1491,7 +1584,7 @@ impl OptionsUi {
Some(if s.extras.no_ai {
self.extra_button(s, crate::extras::Item::NoAi)
} else {
open("AI Agent...", Page::Agent)
open("horizon.pages.open.agent", Page::Agent)
}),
),
]
@@ -1510,7 +1603,7 @@ impl OptionsUi {
0.0,
0.0,
150.0,
Text::literal("Copy Connection Details"),
tr("horizon.agent.copy_details"),
Act::CopyAgentDetails,
)),
)];
@@ -1524,14 +1617,14 @@ impl OptionsUi {
0.0,
0.0,
150.0,
Text::literal("Horizon Graphics..."),
tr("horizon.pages.open.horizon_graphics"),
Act::Push(Page::Graphics),
),
Some(button(
0.0,
0.0,
150.0,
Text::literal("Advanced Graphics..."),
tr("horizon.pages.open.graphics_advanced"),
Act::Push(Page::GraphicsAdvanced),
)),
)];
@@ -1580,6 +1673,7 @@ impl OptionsUi {
rows.extend(pairs(&others));
rows.extend(pairs(&[Opt::ShowSubtitles, Opt::DirectionalAudio]));
rows.extend(pairs(&[Opt::MusicFrequency, Opt::MusicToast]));
rows.push(Row::Big(Opt::SoundDevice));
rows
}
Page::Skin => {
@@ -1615,18 +1709,15 @@ impl OptionsUi {
Act::Push(Page::KeyBinds),
)),
)];
rows.extend(pairs(&[
Opt::ToggleCrouch,
Opt::ToggleSprint,
Opt::AutoJump,
]));
let controls = vanilla_control_options();
rows.extend(pairs(&controls));
// Horizon's controller page, after vanilla's rows.
rows.push(Row::Buttons(
button(
0.0,
0.0,
310.0,
Text::literal("Controller Settings..."),
tr("horizon.pages.open.controller"),
Act::Push(Page::Controller),
),
None,
@@ -1639,7 +1730,7 @@ impl OptionsUi {
0.0,
0.0,
310.0,
Text::literal("Controller Binds..."),
tr("horizon.pages.open.controller_binds"),
Act::Push(Page::PadBinds),
),
None,
@@ -1697,9 +1788,7 @@ impl OptionsUi {
Page::Font => pairs(&[Opt::ForceUnicode, Opt::JapaneseGlyphs]),
Page::Mods => {
if s.mods.installed.is_empty() {
return vec![Row::Header(
"No mods: put .hmod files in the horizon-mods folder",
)];
return vec![Row::Header("horizon.mods.empty")];
}
s.mods
.installed
@@ -1709,20 +1798,20 @@ impl OptionsUi {
let mut label = Text::literal(format!("{}: ", installed.title))
.append(on_off(s.mods.is_on(i)));
if installed.problem.is_some() {
label = label.append(" (Stopped)");
label = label.append(tr("horizon.mods.stopped"));
} else if installed.dev {
label = label.append(" (Dev)");
label = label.append(tr("horizon.mods.dev"));
}
let dead = installed.dev || installed.id.is_empty();
// A running mod with a settings page (capability
// `ui`) gets its own button beside the toggle.
if installed.has_settings || installed.native.is_some() {
// A native mod gets its settings page's button beside
// the toggle.
if installed.native.is_some() {
let toggle = button(0.0, 0.0, 150.0, label, Act::ToggleMod(i));
let settings = button(
0.0,
0.0,
150.0,
Text::literal("Settings"),
tr("horizon.mods.settings"),
Act::ModSettings(i),
);
Row::Buttons(
@@ -1739,17 +1828,17 @@ impl OptionsUi {
Page::NativeMod(i) => self.native_rows(s, i),
Page::ShaderPacks => {
if s.shader_packs.is_empty() {
return vec![Row::Header(
"No shader packs: put them in the shaderpacks folder",
)];
return vec![Row::Header("horizon.shader_packs.empty")];
}
s.shader_packs
.iter()
.enumerate()
.map(|(i, pack)| {
let kind = match pack.kind {
crate::shader_pack_list::Kind::Horizon => " (Horizon)",
crate::shader_pack_list::Kind::Classic => "",
crate::shader_pack_list::Kind::Horizon => {
lang::get("horizon.shader_packs.kind_horizon")
}
crate::shader_pack_list::Kind::Classic => String::new(),
};
let label = Text::literal(format!("{}{kind}: ", pack.title))
.append(on_off(s.horizon.shader_pack == pack.file));
@@ -1809,8 +1898,8 @@ impl OptionsUi {
// Vanilla's Telemetry Data and Credits slots: Horizon's
// settings and its mods, as a mod adds its button here.
// Credits & Attribution (WinScreen's credits) follows them.
("Horizon Settings...", Some(Page::Horizon)),
("Mods...", Some(Page::Mods)),
("horizon.pages.open.horizon_settings", Some(Page::Horizon)),
("horizon.pages.open.mods", Some(Page::Mods)),
("options.credits_and_attribution", Some(Page::Credits)),
];
let rows = entries.len().div_ceil(2) as f32;
@@ -2243,18 +2332,12 @@ impl OptionsUi {
content = y + self.scroll - top + 2.0;
out[list_start..].iter_mut().for_each(|w| w.list = true);
let folder = match page {
Page::Mods => Some(("Open Mod Folder", Act::OpenModFolder)),
Page::ShaderPacks => Some(("Open Pack Folder", Act::OpenShaderPackFolder)),
Page::Mods => Some(("horizon.mods.open_folder", Act::OpenModFolder)),
Page::ShaderPacks => Some(("pack.openFolder", Act::OpenShaderPackFolder)),
_ => None,
};
if let Some((label, act)) = folder {
out.push(button(
cx - 154.0,
h - 27.0,
150.0,
Text::literal(label),
act,
));
out.push(button(cx - 154.0, h - 27.0, 150.0, tr(label), act));
out.push(button(cx + 4.0, h - 27.0, 150.0, tr("gui.done"), Act::Done));
} else {
out.push(done(h - 27.0));
@@ -2367,7 +2450,7 @@ impl OptionsUi {
} = &wd.kind
&& let Some(text) = horizon_page_tooltip(*page)
{
tooltip = Some(atlas.font.split(&Text::literal(text), 170.0));
tooltip = Some(atlas.font.split(&tr(text), 170.0));
}
let key = match &wd.kind {
WKind::Button {
@@ -2394,10 +2477,7 @@ impl OptionsUi {
} = &wd.kind
&& !opt.available()
{
tooltip = Some(atlas.font.split(
&Text::literal("This feature is not yet available in Horizon."),
170.0,
));
tooltip = Some(atlas.font.split(&tr("horizon.options.unavailable"), 170.0));
}
if let WKind::Button {
act: Act::ToggleMod(i),
@@ -2538,9 +2618,7 @@ impl OptionsUi {
if search.value.is_empty() && !search.focused {
batch.text_styled(
atlas,
&Text::literal("Search settings...")
.with_color(text::GRAY)
.italic(),
&tr("horizon.options.search").with_color(text::GRAY).italic(),
search.text_x(),
search.text_y(),
u32::MAX,
@@ -2587,6 +2665,7 @@ impl OptionsUi {
Action::Changed => events.push(Event::Changed),
Action::SliderDrag => events.push(Event::SliderDrag),
Action::OpenAdvanced => self.push(Page::GraphicsAdvanced),
Action::OpenHdr => self.push(Page::GraphicsHdr),
Action::OpenSimple => {
if self.stack.len() >= 2
&& self.stack[self.stack.len() - 2] == Page::Graphics
@@ -2772,12 +2851,8 @@ impl OptionsUi {
}
Act::ModSettings(i) => {
events.push(Event::ButtonClick);
if s.mods.installed.get(i).is_some_and(|m| m.native.is_some()) {
self.push(Page::NativeMod(i));
events.push(Event::NativeSettings(i));
} else {
events.push(Event::ModSettings(i));
}
self.push(Page::NativeMod(i));
events.push(Event::NativeSettings(i));
}
Act::NativeKey(i, k) => {
self.selected_key = Some(NATIVE_KEY);
@@ -3223,10 +3298,48 @@ mod tests {
)));
}
#[test]
fn controls_match_java_26_3_option_order() {
let mut ui = OptionsUi::default();
ui.show(Page::Controls);
let options =
ui.list_rows(&Settings::default())
.into_iter()
.fold(Vec::new(), |mut options, row| {
match row {
Row::Big(opt) => options.push(opt),
Row::Pair(a, b) => {
options.push(a);
options.extend(b);
}
_ => {}
}
options
});
assert_eq!(options, vanilla_control_options());
}
#[test]
fn sprint_window_and_new_control_switches_round_trip() {
let mut settings = Settings::default();
set_slider(Opt::SprintWindow, &mut settings, 0.5);
settings.operator_items_tab = true;
settings.quit_shortcuts = false;
settings.ctrl_click_emulates_right_click = true;
settings.toggle_attack = true;
settings.toggle_use = true;
let loaded = Settings::parse(&settings.serialize());
assert_eq!(loaded.sprint_window, 5);
assert!(loaded.operator_items_tab);
assert!(!loaded.quit_shortcuts);
assert!(loaded.ctrl_click_emulates_right_click);
assert!(loaded.toggle_attack && loaded.toggle_use);
}
#[test]
fn render_distance_slider_round_trips_through_extended_options_range() {
let mut s = Settings::default();
for (position, distance) in [(0.0, 2), (0.5, 33), (1.0, 64)] {
for (position, distance) in [(0.0, 2), (0.5, 65), (1.0, 128)] {
set_slider(Opt::RenderDistance, &mut s, position);
assert_eq!(s.render_distance, distance);
let reloaded = Settings::parse(&s.serialize());
@@ -3237,10 +3350,10 @@ mod tests {
);
}
let mut file = crate::options_file::OptionsFile::default();
file.set_text("renderDistance", "64").unwrap();
assert!(file.set_text("renderDistance", "65").is_err());
file.set_text("renderDistance", "128").unwrap();
assert!(file.set_text("renderDistance", "129").is_err());
assert!(file.set_text("renderDistance", "1").is_err());
assert_eq!(file.int("renderDistance"), 64);
assert_eq!(file.int("renderDistance"), 128);
}
fn atlas() -> HudAtlas {
HudAtlas::build(std::path::Path::new("/nonexistent/horizon-test-assets")).unwrap()
@@ -3314,8 +3427,15 @@ mod tests {
ui.show(Page::Mods);
let empty = ui.layout(&s, size.0, size.1).0;
assert!(empty.iter().any(|w| {
matches!(&w.kind, WKind::Text { text, .. } if text.plain().contains(".hmod"))
matches!(&w.kind, WKind::Text { text, .. } if text.plain() == "horizon.mods.empty")
}));
// The empty page points at the bundled key; the English line behind it
// is what a player reads, so both halves are checked here.
let en = crate::lang::Language::load(&[], "en_us");
assert!(
en.get("horizon.mods.empty")
.is_some_and(|line| line.contains(".hmod"))
);
let open_folder = empty
.iter()
.find(|w| {
@@ -3348,7 +3468,6 @@ mod tests {
description: "May show HUD panels.".into(),
problem: id.is_empty().then(|| "Invalid package".into()),
dev,
has_settings: false,
native: None,
});
}
@@ -3416,7 +3535,6 @@ mod tests {
description: String::new(),
problem: None,
dev: false,
has_settings: false,
native: Some(crate::mod_list::NativePage::default()),
});
ui.show(Page::Mods);
@@ -4490,4 +4608,29 @@ mod tests {
ui.stack = vec![Page::Main, Page::Controls];
assert!(pushes(&mut ui).contains(&Page::Controller));
}
#[test]
fn sound_device_button_in_sound_page_and_cycles() {
let mut s = Settings::default();
let mut ui = OptionsUi::default();
ui.show(Page::Sound);
let rows = ui.list_rows(&s);
assert!(
rows.iter().any(|r| matches!(r, Row::Big(Opt::SoundDevice))),
"Sound page should contain Row::Big(Opt::SoundDevice)"
);
assert_eq!(s.sound_device, "");
let devices = crate::audio::output_devices();
let count = 1 + devices.len();
// Cycling forward 'count' times wraps around back to the initial selection ("")
for _ in 0..count {
Opt::SoundDevice.cycle(&mut s, false, [854, 480]);
}
assert_eq!(s.sound_device, "");
// Cycling backward 'count' times also wraps around back to ""
for _ in 0..count {
Opt::SoundDevice.cycle(&mut s, true, [854, 480]);
}
assert_eq!(s.sound_device, "");
}
}
+9 -1
View File
@@ -581,7 +581,15 @@ fn build_overlay_uncached(
Some(p) => link(p, &target)?,
None => {
if let Some(Some(z)) = zips.get(*owner) {
fs::write(&target, z.read(inner)?)?;
// One unreadable entry costs that file, not the
// pack: an error here failed the build and the
// reload dropped every server pack in the stack.
match z.read(inner) {
Ok(bytes) => fs::write(&target, bytes)?,
Err(e) => log::warn!(
"Horizon: skipping unreadable pack entry {inner}: {e}"
),
}
}
}
}
@@ -3,7 +3,6 @@
use super::{Pack, Source, build_overlay_uncached};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
#[cfg(any(test, not(unix)))]
use std::io::Read;
use std::{collections::BTreeSet, fs, io, path::Path, path::PathBuf};
@@ -60,7 +59,6 @@ fn field(hash: &mut Sha256, bytes: &[u8]) {
hash.update((bytes.len() as u64).to_le_bytes());
hash.update(bytes);
}
#[cfg(any(test, not(unix)))]
fn content(hash: &mut Sha256, path: &Path) -> io::Result<()> {
let mut file = fs::File::open(path)?;
hash.update(file.metadata()?.len().to_le_bytes());
@@ -238,7 +236,10 @@ fn pack_signature(packs: &[Pack]) -> io::Result<String> {
}
Source::Zip(path) => {
field(&mut hash, absolute(path)?.as_os_str().as_encoded_bytes());
stamp(&mut hash, path)?;
// By its bytes, not its stamp: a server pack found in the download cache is
// marked used on every join (`HttpUtil.updateModificationTime`), and a new
// time rebuilt the whole overlay each time. One file reads in a moment.
content(&mut hash, path)?;
}
}
}
@@ -669,6 +670,38 @@ mod tests {
);
}
/// A ZIP whose bytes are the same keeps its overlay when only its time changes: a server
/// pack found in the download cache is marked used on every join
/// (`HttpUtil.updateModificationTime`), and rebuilding the overlay took most of a minute.
#[test]
fn a_zip_touched_but_unchanged_keeps_its_overlay() {
let f = Fixture::new();
f.write("vanilla/assets/minecraft/textures/b", b"vanilla");
let files = vec![
(
"pack.mcmeta",
br#"{"pack":{"description":"test","pack_format":97}}"#.to_vec(),
),
("assets/minecraft/textures/a", b"aaa".to_vec()),
];
f.write("pack.zip", &archive(&files, false));
let zip = f.0.join("pack.zip");
let pack = packs::pack("zip".into(), Text::empty(), Source::Zip(zip.clone())).unwrap();
let (vanilla, out) = (f.0.join("vanilla"), f.0.join("cached"));
packs::build_overlay(&vanilla, std::slice::from_ref(&pack), &out).unwrap();
// A rebuild empties the output first: this file survives a cache hit only.
fs::write(out.join("kept"), b"").unwrap();
let later = std::time::SystemTime::now() + std::time::Duration::from_secs(60);
fs::OpenOptions::new()
.append(true)
.open(&zip)
.unwrap()
.set_modified(later)
.unwrap();
packs::build_overlay(&vanilla, std::slice::from_ref(&pack), &out).unwrap();
assert!(out.join("kept").exists(), "the overlay was rebuilt");
}
#[cfg(unix)]
#[test]
fn unrelated_directory_pack_symlink_cycle_disables_reuse() {
+22 -1
View File
@@ -3818,7 +3818,7 @@ impl ParticleEngine {
layer: if layer {
crate::gfx::TRANSLUCENT_PARTICLE_OVERLAY
} else {
0
u32::from(hdr_tier(p.ty)) << 16
},
color,
light,
@@ -3830,6 +3830,27 @@ impl ParticleEngine {
}
}
}
/// How brightly particle `ty` shines in HDR output, as `mesher::hdr_tier`: flames and
/// glowing drops, not their smoke. Only opaque-layer particles are marked.
fn hdr_tier(ty: ParticleType) -> u8 {
use ParticleType as P;
match ty {
P::Flame
| P::SoulFireFlame
| P::CopperFireFlame
| P::SmallFlame
| P::Lava
| P::DrippingLava
| P::FallingLava
| P::LandingLava
| P::DrippingDripstoneLava
| P::FallingDripstoneLava => crate::mesher::INCANDESCENT,
P::DrippingObsidianTear | P::FallingObsidianTear | P::LandingObsidianTear => {
crate::mesher::LOW
}
_ => 0,
}
}
/// `Particle.LifetimeAlpha.currentAlphaForAge`.
fn lifetime_alpha(a: [f32; 4], age: i32, lifetime: i32, partial: f32) -> f32 {
if (a[0] - a[1]).abs() < 1.0e-5 {
@@ -101,6 +101,8 @@ pub struct Settings {
pub master_volume: f32,
/// Volumes of every other sound source, indexed like SOUND_SOURCES.
pub volumes: [f32; 11],
/// Empty for system default, or a specific audio device name.
pub sound_device: String,
/// Requested scale (0 = auto); rendering caps it to the current framebuffer.
pub gui_scale: u32,
pub view_bobbing: bool,
@@ -174,6 +176,12 @@ pub struct Settings {
pub toggle_use: bool,
/// Taps of the sprint key counted as double taps (sprintWindow).
pub sprint_window: i32,
/// Show the operator items tab in a creative inventory.
pub operator_items_tab: bool,
/// Allow platform quit shortcuts to close the game window.
pub quit_shortcuts: bool,
/// On macOS, turn Control-click into a right click.
pub ctrl_click_emulates_right_click: bool,
/// Horizon's own graphics settings (`horizon-graphics.txt`, not options.txt).
pub horizon: crate::graphics::Graphics,
/// Controller settings (`horizon-controller.txt`, Horizon's own).
@@ -195,6 +203,7 @@ impl Default for Settings {
sensitivity: 0.5,
master_volume: 1.0,
volumes: [1.0; 11],
sound_device: String::new(),
gui_scale: 0,
view_bobbing: true,
gamma: 0.5,
@@ -256,6 +265,9 @@ impl Default for Settings {
toggle_attack: false,
toggle_use: false,
sprint_window: 7,
operator_items_tab: false,
quit_shortcuts: true,
ctrl_click_emulates_right_click: false,
horizon: Default::default(),
controller: Default::default(),
extras: Default::default(),
@@ -383,6 +395,7 @@ impl Settings {
sensitivity: f.double("mouseSensitivity") as f32,
master_volume: volumes[0],
volumes,
sound_device: f.string("soundDevice"),
gui_scale: f.int("guiScale").max(0) as u32,
view_bobbing: f.bool("bobView"),
gamma: f.double("gamma") as f32,
@@ -418,6 +431,9 @@ impl Settings {
toggle_attack: f.bool("toggleAttack"),
toggle_use: f.bool("toggleUse"),
sprint_window: f.int("sprintWindow"),
operator_items_tab: f.bool("operatorItemsTab"),
quit_shortcuts: f.bool("quitShortcuts"),
ctrl_click_emulates_right_click: f.bool("ctrlClickEmulatesRightClick"),
horizon: Default::default(),
controller: Default::default(),
extras: Default::default(),
@@ -475,6 +491,7 @@ impl Settings {
};
put_f(&mut f, &format!("soundCategory_{source}"), v);
}
put_s(&mut f, "soundDevice", &self.sound_device);
put_i(&mut f, "guiScale", self.gui_scale as i32);
put_b(&mut f, "bobView", self.view_bobbing);
put_f(&mut f, "gamma", self.gamma);
@@ -518,6 +535,13 @@ impl Settings {
put_b(&mut f, "toggleAttack", self.toggle_attack);
put_b(&mut f, "toggleUse", self.toggle_use);
put_i(&mut f, "sprintWindow", self.sprint_window);
put_b(&mut f, "operatorItemsTab", self.operator_items_tab);
put_b(&mut f, "quitShortcuts", self.quit_shortcuts);
put_b(
&mut f,
"ctrlClickEmulatesRightClick",
self.ctrl_click_emulates_right_click,
);
put_b(&mut f, "autoJump", self.auto_jump);
put_s(&mut f, "lang", &self.language);
put_b(&mut f, "forceUnicodeFont", self.force_unicode);
@@ -637,6 +661,7 @@ pub enum Opt {
InvertY,
AllowCursorChanges,
Volume(usize),
SoundDevice,
GuiScale,
ViewBobbing,
Gamma,
@@ -664,7 +689,13 @@ pub enum Opt {
MenuBlur,
ToggleCrouch,
ToggleSprint,
ToggleAttack,
ToggleUse,
AutoJump,
SprintWindow,
QuitShortcuts,
CtrlClickEmulatesRightClick,
OperatorItemsTab,
ChatVisibility,
ChatColors,
ChatLinks,
@@ -820,4 +851,15 @@ mod tests {
1.0
);
}
#[test]
fn sound_device_option() {
let s = Settings::parse("soundDevice:\"Headphones\"\n");
assert_eq!(s.sound_device, "Headphones");
let serialized = s.serialize();
assert!(serialized.contains("soundDevice:\"Headphones\""));
let again = Settings::parse(&serialized);
assert_eq!(again.sound_device, "Headphones");
let empty = Settings::parse("");
assert_eq!(empty.sound_device, "");
}
}
+135 -1
View File
@@ -468,7 +468,10 @@ pub fn head_skin(
{
let location = skin_location(&hash);
match downloads.state(Kind::Skin, &url) {
Download::Ready if atlas(&location) => return location,
Download::Ready if atlas(&location) => {
downloads.drawn(&location);
return location;
}
// Its slot was evicted: offer the image again.
Download::Ready => {
downloads.again(&location);
@@ -681,6 +684,8 @@ pub struct Downloader {
/// atlas uploads again (`again`); the disk cache backs anything dropped.
images: std::collections::HashMap<String, Vec<u8>>,
image_order: std::collections::VecDeque<String>,
/// Skins and capes drawn from the atlas since `take_drawn`.
drawn: std::collections::HashSet<String>,
}
/// `images` is bounded only so a long session on a busy server does not grow
/// without end; the disk cache holds everything it drops.
@@ -697,6 +702,7 @@ impl Downloader {
queued: Default::default(),
images: Default::default(),
image_order: Default::default(),
drawn: Default::default(),
}
}
/// How the download of `url` stands, starting it the first time.
@@ -801,6 +807,77 @@ impl Downloader {
}
std::mem::take(&mut self.ready)
}
/// A skin or cape in the atlas drawn this frame: `SkinSlots` keeps its slot.
pub fn drawn(&mut self, location: &str) {
if !self.drawn.contains(location) {
self.drawn.insert(location.to_owned());
}
}
/// The skins and capes drawn since the last call.
pub fn take_drawn(&mut self) -> std::collections::HashSet<String> {
std::mem::take(&mut self.drawn)
}
}
/// Which downloaded skins and capes hold the atlas's skin slots
/// (`hud::SKIN_SLOTS`). Vanilla's `SkinManager.TextureCache.getOrLoad`
/// registers a texture per skin and keeps it. Here the slots are shared: a
/// new skin takes a free slot, else the least recently drawn skin's, but
/// never one drawn in the last frame. With every slot drawn, the new skin
/// waits (its player keeps the default skin) instead of pushing out one in
/// use, which then came back by pushing out another, so players vanished
/// for a frame and heads swapped textures as the slots went round.
pub struct SkinSlots {
capacity: usize,
/// Stored locations and their slot, the least recently drawn first.
slots: Vec<(String, usize)>,
/// Drawn in the last frame, or stored since: kept from eviction.
kept: std::collections::HashSet<String>,
}
impl SkinSlots {
pub fn new(capacity: usize) -> Self {
Self {
capacity,
slots: Vec::new(),
kept: Default::default(),
}
}
/// Whether `location` is in a slot.
pub fn contains(&self, location: &str) -> bool {
self.slots.iter().any(|(l, _)| l == location)
}
/// A new frame: the skins `drawn` in the last one
/// (`Downloader::take_drawn`) become the most recently drawn, and keep
/// their slots through this frame's `place`s.
pub fn next_frame(&mut self, drawn: std::collections::HashSet<String>) {
let (recent, old): (Vec<_>, Vec<_>) = std::mem::take(&mut self.slots)
.into_iter()
.partition(|(l, _)| drawn.contains(l));
self.slots = old;
self.slots.extend(recent);
self.kept = drawn;
}
/// A slot for `location`: its own, a free one, else the least recently
/// drawn one not kept, with the location it held. `None` while every
/// slot is kept.
pub fn place(&mut self, location: String) -> Option<(usize, Option<String>)> {
if let Some((_, slot)) = self.slots.iter().find(|(l, _)| *l == location) {
return Some((*slot, None));
}
let (slot, evicted) = if self.slots.len() < self.capacity {
(self.slots.len(), None)
} else {
let i = self
.slots
.iter()
.position(|(l, _)| !self.kept.contains(l))?;
let (old, slot) = self.slots.remove(i);
(slot, Some(old))
};
self.kept.insert(location.clone());
self.slots.push((location, slot));
Some((slot, evicted))
}
}
/// A GET over HTTP or HTTPS (textures.minecraft.net serves both; vanilla
@@ -999,6 +1076,63 @@ mod tests {
assert!(super::resolve_profile(&super::NameOrId::Name("Notch".into()), &doc).is_none());
}
/// More skins drawn every frame than there are slots (#3829): a frame's
/// uploads used to push out skins drawn the frame before, which then
/// uploaded again and pushed out others, so the drawn skins went missing
/// in turn. Those drawn keep their slots; the rest wait.
#[test]
fn skins_drawn_last_frame_keep_their_slots() {
let mut d = super::Downloader::new(std::env::temp_dir().join("horizon-test-skin-slots"));
let skins: Vec<String> = (0..6).map(|i| format!("skins/{i}")).collect();
for (i, s) in skins.iter().enumerate() {
d.remember(s.clone(), vec![i as u8]);
}
let mut slots = super::SkinSlots::new(4);
let mut last: Option<Vec<String>> = None;
for frame in 0..8 {
// draw_tab_list: the downloads that finished go to the atlas.
slots.next_frame(d.take_drawn());
let mut uploads = 0;
for (location, _) in d.take_ready() {
if slots.place(location).is_some() {
uploads += 1;
}
}
// update_remote_skins: every skin is drawn, from its slot, or
// asked for again while it has none.
let mut shown = Vec::new();
for s in &skins {
if slots.contains(s) {
d.drawn(s);
shown.push(s.clone());
} else {
d.again(s);
}
}
if frame >= 2 {
assert_eq!(uploads, 0, "frame {frame} uploaded again");
assert_eq!(Some(&shown), last.as_ref(), "frame {frame} lost a skin");
}
// The first frame asks for the skins; from the next, four show.
assert_eq!(shown.len(), if frame == 0 { 0 } else { 4 });
last = Some(shown);
}
// A skin no longer drawn gives up its slot to a waiting one.
let mut slots = super::SkinSlots::new(2);
slots.next_frame(Default::default());
assert_eq!(slots.place("skins/a".into()), Some((0, None)));
assert_eq!(slots.place("skins/b".into()), Some((1, None)));
assert_eq!(slots.place("skins/c".into()), None);
slots.next_frame(["skins/b".to_owned()].into());
assert_eq!(
slots.place("skins/c".into()),
Some((0, Some("skins/a".into())))
);
// Stored this frame, kept through it.
assert_eq!(slots.place("skins/d".into()), None);
assert!(slots.contains("skins/b") && slots.contains("skins/c"));
}
/// A skin evicted from the atlas re-uploads (`again`), once per ask.
#[test]
fn evicted_skins_upload_again() {
+205 -37
View File
@@ -13,11 +13,10 @@ use horizon_core::biome::BiomeZoom;
use horizon_world::block_render::FaceVisibility;
use horizon_world::worldgen::decoration_order::DecorationOrder;
use std::{
collections::{BinaryHeap, HashSet},
collections::{BinaryHeap, HashSet, VecDeque},
sync::{
Arc, Condvar, Mutex, RwLock,
Arc, Condvar, Mutex, MutexGuard, RwLock, TryLockError,
atomic::{AtomicBool, Ordering},
mpsc,
},
thread,
};
@@ -220,11 +219,52 @@ struct Shared {
/// What meshing reads (block models and sprites), swapped when resource
/// packs reload; each mesh job takes the current one.
context: RwLock<Arc<WorkerContext>>,
done: Mailbox,
}
/// Finished jobs, from the workers to the game thread, in the order they
/// finished. Not an `mpsc` channel: its receiver waits for a sender it finds
/// half way through a send, and a worker descheduled there on a busy machine
/// held the frame for a scheduler quantum (10 to 30 ms, the RD 32 ocean
/// flight's 1% lows). The frame takes what is there without waiting; while a
/// worker holds the lock, it takes it the next frame.
#[derive(Default)]
struct Mailbox {
done: Mutex<VecDeque<Done>>,
arrived: Condvar,
}
impl Mailbox {
fn send(&self, done: Done) {
self.done.lock().unwrap().push_back(done);
self.arrived.notify_one();
}
fn try_lock(&self) -> Option<MutexGuard<'_, VecDeque<Done>>> {
match self.done.try_lock() {
Ok(done) => Some(done),
Err(TryLockError::WouldBlock) => None,
Err(TryLockError::Poisoned(e)) => panic!("{e}"),
}
}
/// Every finished job, or none while a worker is sending.
fn take(&self) -> VecDeque<Done> {
self.try_lock()
.map(|mut done| std::mem::take(&mut *done))
.unwrap_or_default()
}
fn try_recv(&self) -> Option<Done> {
self.try_lock()?.pop_front()
}
fn recv_timeout(&self, timeout: std::time::Duration) -> Option<Done> {
let done = self.done.lock().unwrap();
let (mut done, _) = self
.arrived
.wait_timeout_while(done, timeout, |done| done.is_empty())
.unwrap();
done.pop_front()
}
}
pub struct Streaming {
shared: Arc<Shared>,
light_view: Arc<dyn Fn(&Proto) -> Option<Chunk> + Send + Sync>,
results: mpsc::Receiver<Done>,
threads: Vec<thread::JoinHandle<()>>,
order: u64,
/// Ticks since the columns and protos that fell out of range were swept.
@@ -235,6 +275,11 @@ pub struct Streaming {
generation_scan: GenerationScan,
/// Proto chunks: `None` while checked out to a decoration or save job.
protos: FxHashMap<(i32, i32), Option<Proto>>,
/// The light jobs' views of proto chunks past the area (`light_region`),
/// built once per proto: each is a neighbour of up to three columns and
/// waits in failed regions until the last one loads. Dropped whenever
/// the proto is checked out, replaced or removed.
light_views: FxHashMap<(i32, i32), Arc<Chunk>>,
decorated: FxHashSet<(i32, i32)>,
decorating: usize,
/// Save jobs in flight.
@@ -404,8 +449,14 @@ impl GenerationScan {
wanted.push((input.ranges.priority(x, z, input.loading), x, z));
}
}
// Forced chunks and their generation neighbourhood.
let reach = margin - 1;
// Forced chunks and their generation neighbourhood: the ring that
// is decorated (or finished, without decoration) and the one its
// 3x3 needs.
let reach = if input.decorates {
FORCED_TERRAIN_RING
} else {
FORCED_FEATURES_RING
};
for &(fx, fz) in input.ranges.forced {
for dz in -reach..=reach {
for dx in -reach..=reach {
@@ -461,14 +512,13 @@ impl Streaming {
ready: Condvar::new(),
stop: AtomicBool::new(false),
context: RwLock::new(context.clone()),
done: Mailbox::default(),
});
let (send, results) = mpsc::channel();
let light_tables = context.light.clone();
let saves: Arc<dyn Saves> = generator.clone();
let threads = (0..threads.max(1))
.map(|i| {
let shared = shared.clone();
let send = send.clone();
let generator = generator.clone();
let light = context.light.clone();
thread::Builder::new()
@@ -551,9 +601,7 @@ impl Streaming {
Done::Meshed(x, sy, z, mesh, seq)
}
};
if send.send(done).is_err() {
return;
}
shared.done.send(done);
}
})
.expect("spawn worker")
@@ -566,7 +614,6 @@ impl Streaming {
Self {
shared,
light_view,
results,
threads,
order: 0,
scan_age: 0,
@@ -574,6 +621,7 @@ impl Streaming {
generating: Default::default(),
generation_scan: GenerationScan::default(),
protos: Default::default(),
light_views: Default::default(),
decorated: Default::default(),
decorating: 0,
saving: 0,
@@ -734,6 +782,7 @@ impl Streaming {
self.decorated.insert((x, z));
for (i, proto) in region.into_iter().enumerate() {
let key = (x + (i % 3) as i32 - 1, z + (i / 3) as i32 - 1);
self.light_views.remove(&key);
self.protos.insert(key, Some(proto));
self.written.insert(key);
}
@@ -744,6 +793,7 @@ impl Streaming {
self.generation_scan.exhausted = false;
self.saving -= 1;
for (key, proto) in protos {
self.light_views.remove(&key);
self.protos.insert(key, Some(proto));
}
}
@@ -751,7 +801,11 @@ impl Streaming {
fn save_checked_out(&mut self, keys: impl IntoIterator<Item = (i32, i32)>) {
let protos: Vec<((i32, i32), Proto)> = keys
.into_iter()
.filter_map(|k| Some((k, self.protos.get_mut(&k)?.take()?)))
.filter_map(|k| {
let proto = self.protos.get_mut(&k)?.take()?;
self.light_views.remove(&k);
Some((k, proto))
})
.collect();
if !protos.is_empty() {
self.saving += 1;
@@ -820,7 +874,7 @@ impl Streaming {
/// The 3x3 a light job reads: the finished columns, and for a neighbour
/// that will not finish (past the area), its decorated proto chunk, as
/// vanilla lights its last FULL ring against neighbours at FEATURES.
fn light_region(&self, world: &World, x: i32, z: i32) -> Option<Box<Region>> {
fn light_region(&mut self, world: &World, x: i32, z: i32) -> Option<Box<Region>> {
let mut region: Region = Default::default();
for dz in 0..3 {
for dx in 0..3 {
@@ -828,8 +882,14 @@ impl Streaming {
region[dz][dx] = Some(match world.chunks.get(&n) {
Some(chunk) => chunk.clone(),
None if !self.in_range(n.0, n.1, 1) && self.decorated.contains(&n) => {
let proto = self.protos.get(&n)?.as_ref()?;
Arc::new((self.light_view)(proto)?)
if let Some(view) = self.light_views.get(&n) {
view.clone()
} else {
let proto = self.protos.get(&n)?.as_ref()?;
let view = Arc::new((self.light_view)(proto)?);
self.light_views.insert(n, view.clone());
view
}
}
None => return None,
});
@@ -930,15 +990,16 @@ impl Streaming {
self.requeue();
self.relight_edits(world);
let deferred = std::mem::take(&mut self.deferred);
let received = std::iter::from_fn(|| self.results.try_recv().ok());
let received = self.shared.done.take();
// Meshes made stale meanwhile, freed with the uploaded ones (`free`).
let mut stale = Vec::new();
for done in deferred.into_iter().chain(received.collect::<Vec<_>>()) {
for done in deferred.into_iter().chain(received) {
match done {
Done::Proto(x, z, proto, decorated) => {
self.generation_scan.exhausted = false;
self.generating.remove(&(x, z));
if self.in_range(x, z, 4) || self.near_finished(x, z, 2) {
self.light_views.remove(&(x, z));
self.protos.insert((x, z), Some(proto));
if decorated || !self.decorates {
self.decorated.insert((x, z));
@@ -1047,10 +1108,10 @@ impl Streaming {
&& i64::from(x - center.0).pow(2) + i64::from(z - center.1).pow(2) > r4 * r4
&& !ranges.in_extra(x, z, 4)
&& !ranges.in_band(x, z, 4)
&& !ranges
.forced
.iter()
.any(|f| (x - f.0).abs() <= 3 && (z - f.1).abs() <= 3)
&& !ranges.forced.iter().any(|f| {
(x - f.0).abs() <= FORCED_TERRAIN_RING
&& (z - f.1).abs() <= FORCED_TERRAIN_RING
})
})
.map(|(k, _)| *k)
.collect();
@@ -1065,6 +1126,7 @@ impl Streaming {
written.push(key);
} else {
self.protos.remove(&key);
self.light_views.remove(&key);
}
}
self.save_checked_out(written);
@@ -1094,6 +1156,7 @@ impl Streaming {
.remove(&(x, z))
.flatten()
.expect("present proto");
self.light_views.remove(&(x, z));
self.finishing.insert((x, z));
self.written.remove(&(x, z));
self.submit(Some((x, z)), 0, Job::Finish(proto));
@@ -1192,6 +1255,7 @@ impl Streaming {
let region: Vec<Proto> = keys
.iter()
.map(|k| {
self.light_views.remove(k);
self.protos
.insert(*k, None)
.flatten()
@@ -1444,12 +1508,12 @@ impl Streaming {
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
while self.decorating > 0 || !self.finishing.is_empty() || self.saving > 0 {
let left = deadline.saturating_duration_since(std::time::Instant::now());
match self.results.recv_timeout(left) {
Ok(Done::Decorated(x, z, region)) => self.receive_decorated(x, z, *region),
Ok(Done::Generated(chunk)) => self.receive_generated(chunk, world),
Ok(Done::Saved(protos)) => self.receive_saved(protos),
Ok(other) => self.deferred.push(other),
Err(_) => {
match self.shared.done.recv_timeout(left) {
Some(Done::Decorated(x, z, region)) => self.receive_decorated(x, z, *region),
Some(Done::Generated(chunk)) => self.receive_generated(chunk, world),
Some(Done::Saved(protos)) => self.receive_saved(protos),
Some(other) => self.deferred.push(other),
None => {
log::warn!("Horizon: chunk generation did not finish before saving");
break;
}
@@ -1471,7 +1535,7 @@ impl Streaming {
/// in `save(true)`, so older generation cannot overwrite the final chunks.
pub fn poll_save(&mut self, world: &mut World) -> bool {
let started = std::time::Instant::now();
while let Ok(done) = self.results.try_recv() {
while let Some(done) = self.shared.done.try_recv() {
match done {
Done::Decorated(x, z, region) => self.receive_decorated(x, z, *region),
Done::Generated(chunk) => self.receive_generated(chunk, world),
@@ -1556,7 +1620,7 @@ impl Streaming {
/// Wait up to `timeout` for a worker to finish a job; the next `update`
/// takes it. A server pumps generation with this between its ticks.
pub fn wait(&mut self, timeout: std::time::Duration) {
if let Ok(done) = self.results.recv_timeout(timeout) {
if let Some(done) = self.shared.done.recv_timeout(timeout) {
self.deferred.push(done);
}
}
@@ -1570,6 +1634,14 @@ pub fn in_view_distance(center: (i32, i32), radius: i32, x: i32, z: i32) -> bool
let (dx, dz) = (gap(x - center.0), gap(z - center.1));
dx * dx + dz * dz < i64::from(radius) * i64::from(radius)
}
/// The rings around a `/forceload`ed chunk, in chunks. Vanilla's FORCED
/// ticket is level 31 (`ChunkMap.FORCED_TICKET_LEVEL`), the level rises one
/// per ring (`ChunkTracker.computeLevelFromNeighbor`), and the level picks
/// the column's status (`ChunkLevel.generationStatus`): full (33) two rings
/// out, features (34) at three, terrain (35) at four.
const FORCED_FULL_RING: i32 = 2;
const FORCED_FEATURES_RING: i32 = 3;
const FORCED_TERRAIN_RING: i32 = 4;
/// The areas kept loaded: circles around the player and `extra`, and forced chunks.
struct Ranges<'a> {
center: (i32, i32),
@@ -1825,7 +1897,16 @@ impl Ranges<'_> {
if dx * dx + dz * dz <= r * r {
return true;
}
let reach = (margin - 1).max(0);
// A forced chunk holds a FORCED ticket at level 31
// (`ChunkMap.FORCED_TICKET_LEVEL`), and the level rises one per ring
// (`ChunkTracker.computeLevelFromNeighbor`): the margin is the
// vanilla status ring the caller asks for.
let reach = match margin {
1 => FORCED_FULL_RING,
2 => FORCED_FEATURES_RING,
3 => FORCED_TERRAIN_RING,
_ => margin + 1,
};
self.forced
.iter()
.any(|f| (x - f.0).abs() <= reach && (z - f.1).abs() <= reach)
@@ -1837,10 +1918,10 @@ impl Drop for Streaming {
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
while self.saving > 0 {
let left = deadline.saturating_duration_since(std::time::Instant::now());
match self.results.recv_timeout(left) {
Ok(Done::Saved(_)) => self.saving -= 1,
Ok(_) => {}
Err(_) => break,
match self.shared.done.recv_timeout(left) {
Some(Done::Saved(_)) => self.saving -= 1,
Some(_) => {}
None => break,
}
}
self.shared.stop.store(true, Ordering::Relaxed);
@@ -1878,6 +1959,37 @@ fn rank_again(queue: &mut BinaryHeap<Queued>, distance: impl Fn(i32, i32) -> i64
mod tests {
use super::*;
/// The frame never waits for a worker: while one holds the mailbox, the
/// frame takes nothing and finds the results, in order, the next time.
#[test]
fn the_frame_takes_finished_jobs_without_waiting() {
let mailbox = Mailbox::default();
let lit = |seq| Done::Lit(0, 0, Vec::new(), seq);
mailbox.send(lit(1));
mailbox.send(lit(2));
let sending = mailbox.done.lock().unwrap();
assert!(mailbox.take().is_empty());
assert!(mailbox.try_recv().is_none());
drop(sending);
let seqs: Vec<u64> = mailbox
.take()
.into_iter()
.map(|done| match done {
Done::Lit(_, _, _, seq) => seq,
_ => unreachable!(),
})
.collect();
assert_eq!(seqs, [1, 2]);
assert!(mailbox.take().is_empty());
let waiting = std::time::Instant::now();
assert!(
mailbox
.recv_timeout(std::time::Duration::from_millis(10))
.is_none()
);
assert!(waiting.elapsed() >= std::time::Duration::from_millis(10));
}
#[test]
fn queued_jobs_follow_the_centre_and_the_loading_screen() {
let forced = HashSet::new();
@@ -2063,9 +2175,14 @@ mod tests {
}
}
}
let forced_reach = if input.decorates {
FORCED_TERRAIN_RING
} else {
FORCED_FEATURES_RING
};
for &(fx, fz) in input.ranges.forced {
for dz in -(margin - 1)..=(margin - 1) {
for dx in -(margin - 1)..=(margin - 1) {
for dz in -forced_reach..=forced_reach {
for dx in -forced_reach..=forced_reach {
let (x, z) = (fx + dx, fz + dz);
if missing(x, z) {
wanted.push((input.ranges.distance(x, z), x, z));
@@ -2467,6 +2584,57 @@ mod tests {
}
}
}
/// A `/forceload`ed chunk holds a FORCED ticket at level 31
/// (`ChunkMap.FORCED_TICKET_LEVEL`): the level rises one per ring
/// (`ChunkTracker.computeLevelFromNeighbor`) and picks the column's
/// status (`ChunkLevel.generationStatus`). So vanilla finishes the two
/// rings around it (33), gives the third its features (34) and the
/// fourth its terrain (35), and the rings past that are left alone.
#[test]
fn forced_chunks_reach_vanillas_rings() {
let forced: HashSet<(i32, i32)> = [(40, -40)].into();
let r = Ranges {
// The player's own area, far away.
center: (-400, 300),
radius: 2,
extra: &[],
forced: &forced,
cover: None,
band: None,
};
let level = |x: i32, z: i32| 31 + (x - 40).abs().max((z + 40).abs());
for x in 40 - 6..=40 + 6 {
for z in -40 - 6..=-40 + 6 {
// The full ring: the column becomes a level chunk.
assert_eq!(r.in_range(x, z, 1), level(x, z) <= 33, "({x}, {z})");
// The features ring, and the terrain ring.
assert_eq!(r.in_range(x, z, 2), level(x, z) <= 34, "({x}, {z})");
assert_eq!(r.in_range(x, z, 3), level(x, z) <= 35, "({x}, {z})");
assert_eq!(r.requested(x, z, true), level(x, z) <= 35, "({x}, {z})");
assert_eq!(r.chunk_kept(x, z, true), level(x, z) <= 35, "({x}, {z})");
assert_eq!(r.unloaded(x, z, true), level(x, z) > 35, "({x}, {z})");
}
}
// Generation reaches the terrain ring: the features ring is
// decorated from it.
let mut fixture = GenerationFixture::new(2, true);
fixture.center = (-400, 300);
fixture.forced = forced;
let candidates = GenerationScan::default()
.candidates(fixture.input(), usize::MAX)
.expect("a scan");
for (dx, dz) in [(-4, 0), (4, 0), (0, -4), (0, 4), (3, 3)] {
assert!(
candidates
.iter()
.any(|&(_, x, z)| (x, z) == (40 + dx, -40 + dz)),
"the terrain ring at ({}, {}) is not generated",
40 + dx,
-40 + dz
);
}
}
}
#[cfg(test)]
@@ -146,9 +146,10 @@ fn apply_fog(color: vec4<f32>, spherical: f32, cylindrical: f32) -> vec4<f32> {
// Bit 15 of the layer marks opaque leaves (forceOpaque): no alpha cutout.
let color = sprite(in.uv, in.layer & 0x7fffu) * in.color;
if color.a < 0.5 && (in.layer & 0x8000u) == 0u { discard; }
// Alpha 0 tells HDR output (hdr.wgsl) a light-emitting block's bright pixels
// may shine. Nothing else reads the frame's alpha.
let emissive = select(1.0, 0.0, in.emission > 0u);
// Below 0.75, alpha tells HDR output (hdr.wgsl) a light source's bright
// pixels may shine, and at which tier (mesher::hdr_tier): 0.525 at the
// lowest, 0 at the brightest. Nothing else reads the frame's alpha.
let emissive = select(1.0, 0.7 * f32(4u - min(in.emission, 4u)) / 4.0, in.emission > 0u);
return apply_fog(vec4(color.rgb, emissive), in.spherical, in.cylindrical);
}
@fragment fn fs_translucent(in: TerrainOut) -> @location(0) vec4<f32> {
+85 -9
View File
@@ -77,19 +77,31 @@ impl Zip {
// A clone shares the bytes and the central directory: reading needs `&mut`.
let mut archive = self.archive.clone();
let mut entry = archive.by_index_raw(index).map_err(invalid)?;
let (method, size) = (entry.compression(), entry.size() as usize);
let method = entry.compression();
let mut raw = Vec::with_capacity((entry.compressed_size() as usize).min(self.len));
entry.read_to_end(&mut raw)?;
match method {
CompressionMethod::Stored => Ok(raw),
CompressionMethod::Deflated => miniz_oxide::inflate::decompress_to_vec_with_limit(
&raw,
size.max(1)
.saturating_mul(2)
.saturating_add(1024)
.min(MAX_ENTRY),
)
.map_err(|_| invalid("corrupt deflate stream")),
CompressionMethod::Deflated => {
// Java's ZipFile trusts neither size an entry declares: it
// inflates the stream to its end and keeps what comes out.
// Packs in the wild (a Nexo-generated one served through
// Dropbox, for one) carry central-directory sizes that point
// at other files, and miniz_oxide, which refuses input past
// the stream's end, failed whole entries with "corrupt
// deflate stream" and with them the whole pack. Only the
// size limit stands between the archive and a zip bomb.
let decoder = flate2::read::DeflateDecoder::new(raw.as_slice());
let mut decoded = Vec::with_capacity(raw.len().min(MAX_ENTRY));
decoder
.take(MAX_ENTRY as u64 + 1)
.read_to_end(&mut decoded)
.map_err(|_| invalid("corrupt deflate stream"))?;
if decoded.len() > MAX_ENTRY {
return Err(invalid("entry inflates past the size limit"));
}
Ok(decoded)
}
_ => Err(invalid("unsupported zip compression")),
}
}
@@ -214,6 +226,70 @@ mod tests {
assert!(zip.read("missing").is_err());
}
/// The offset of the central-directory header of `name`.
fn central_header(bytes: &[u8], name: &str) -> usize {
let mut at = 0;
while let Some(i) = bytes[at..]
.windows(4)
.position(|s| s == [0x50, 0x4b, 0x01, 0x02])
{
let p = at + i;
let nlen = u16::from_le_bytes(bytes[p + 28..p + 30].try_into().unwrap()) as usize;
if &bytes[p + 46..p + 46 + nlen] == name.as_bytes() {
return p;
}
at = p + 4;
}
panic!("no central header for {name}");
}
/// Java's `ZipFile` inflates the stream and keeps what comes out, so a
/// central directory that names a size other than the stream's (the
/// Nexo pack Dropbox serves for one server does) changes nothing.
#[test]
fn reads_a_deflate_stream_larger_than_its_declared_size() {
use std::io::Write;
use zip::write::SimpleFileOptions;
let mut writer = zip::ZipWriter::new(Cursor::new(Vec::new()));
writer
.start_file(
"large.txt",
SimpleFileOptions::default().compression_method(CompressionMethod::Deflated),
)
.unwrap();
writer.write_all(&vec![b'x'; 4096]).unwrap();
let mut bytes = writer.finish().unwrap().into_inner();
let central = central_header(&bytes, "large.txt");
bytes[central + 24..central + 28].copy_from_slice(&1u32.to_le_bytes());
let zip = Zip::from_bytes(bytes).unwrap();
assert_eq!(zip.read("large.txt").unwrap(), vec![b'x'; 4096]);
}
/// Some pack writers leave an entry's compressed size longer than its
/// deflate stream, so the bytes past the stream's end are part of it.
/// Java inflates to the stream's end and ignores them; miniz_oxide
/// refused the input and failed the entry, and with it the whole pack.
#[test]
fn reads_past_trailing_bytes_after_the_deflate_stream() {
let mut bytes = written();
let name = "Pack-main/assets/minecraft/lang/en_us.json";
let central = central_header(&bytes, name);
let local =
u32::from_le_bytes(bytes[central + 42..central + 46].try_into().unwrap()) as usize;
for at in [central + 20, local + 18] {
let csize = u32::from_le_bytes(bytes[at..at + 4].try_into().unwrap());
bytes[at..at + 4].copy_from_slice(&(csize + 8).to_le_bytes());
}
let zip = Zip::from_bytes(bytes).unwrap();
assert_eq!(
zip.read(name).unwrap(),
"{\"a\":\"b\"}".repeat(100).as_bytes()
);
}
/// Loka's server pack (#3413) names its metadata `pack.mcmeta/`.
/// `ZipFile.getEntry("pack.mcmeta")` falls back to that folder entry and
/// vanilla reads its bytes; a file of the exact name still comes first.
-3
View File
@@ -91,9 +91,6 @@ default = ["audio", "controllers"]
audio = ["horizon-client-render/audio"]
# Controllers: gilrs, and the DualSense's own output. Without it only `horizon-ctl pad`'s virtual pad.
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"]
# Records the call sites of mob random draws (`mob_ai_probe` traces).
rng-trace = ["horizon-core/rng-trace"]
@@ -11,5 +11,57 @@
"horizon.options.look_deadzone": "Look Deadzone",
"horizon.options.button_glyphs": "Button Glyphs",
"horizon.options.menu_navigation": "Menu Navigation",
"horizon.options.on_screen_keyboard": "On-Screen Keyboard"
"horizon.options.on_screen_keyboard": "On-Screen Keyboard",
"horizon.options.chat_delay": "Chat Delay",
"horizon.options.lightbar.health": "Health",
"horizon.options.lightbar.dimension": "Dimension",
"horizon.options.glyphs.auto": "Auto",
"horizon.options.glyphs.xbox": "Xbox",
"horizon.options.glyphs.playstation": "PlayStation",
"horizon.options.glyphs.nintendo": "Nintendo",
"horizon.options.menu_focus": "Focus",
"horizon.options.menu_pointer": "Pointer",
"horizon.options.keyboard.auto": "Auto",
"horizon.options.keyboard.horizon": "Horizon",
"horizon.options.keyboard.steam": "Steam",
"horizon.options.unavailable": "This feature is not yet available in Horizon.",
"horizon.options.search": "Search settings...",
"horizon.pages.mods": "Mods",
"horizon.pages.shader_packs": "Shader Packs",
"horizon.pages.controller": "Controller Settings",
"horizon.pages.controller_binds": "Controller Binds",
"horizon.pages.extras": "Horizon Extras",
"horizon.pages.settings": "Horizon Settings",
"horizon.pages.agent": "AI Agent",
"horizon.pages.settings.tooltip": "Horizon's own settings: graphics, controllers, extras and mods.",
"horizon.pages.graphics.tooltip": "Graphics, simply: each setting says what it costs.",
"horizon.pages.graphics_advanced.tooltip": "Every graphics setting, Horizon's included, with what each costs.",
"horizon.pages.controller.tooltip": "Controllers: their buttons, sticks, vibration and menu navigation.",
"horizon.pages.extras.tooltip": "Optional additions, all off by default: the game stays vanilla until you turn one on.",
"horizon.pages.agent.tooltip": "Let your own AI agent play: horizon-ctl and MCP clients on this computer.",
"horizon.pages.shader_packs.tooltip": "Shader packs from the shaderpacks folder: Horizon's own and those in the shaders/ format. One runs at a time.",
"horizon.pages.mods.tooltip": "Mods made for Horizon, from the horizon-mods folder. Each runs in a sandbox and stays off until you turn it on.",
"horizon.pages.open.graphics": "Graphics...",
"horizon.pages.open.graphics_advanced": "Advanced Graphics...",
"horizon.pages.open.shader_packs": "Shader Packs...",
"horizon.pages.open.controller": "Controller Settings...",
"horizon.pages.open.extras": "Extras...",
"horizon.pages.open.mods": "Mods...",
"horizon.pages.open.agent": "AI Agent...",
"horizon.pages.open.horizon_graphics": "Horizon Graphics...",
"horizon.pages.open.controller_binds": "Controller Binds...",
"horizon.pages.open.horizon_settings": "Horizon Settings...",
"horizon.mods.empty": "No mods: put .hmod files in the horizon-mods folder",
"horizon.mods.stopped": " (Stopped)",
"horizon.mods.dev": " (Dev)",
"horizon.mods.settings": "Settings",
"horizon.mods.open_folder": "Open Mod Folder",
"horizon.shader_packs.empty": "No shader packs: put them in the shaderpacks folder",
"horizon.shader_packs.kind_horizon": " (Horizon)",
"horizon.agent.copy_details": "Copy Connection Details",
"horizon.multiplayer.join": "Join",
"horizon.multiplayer.cancel_reconnecting": "Cancel Reconnecting",
"horizon.multiplayer.join_friend": "Join a Friend",
"horizon.multiplayer.invite_code": "Invite Code",
"horizon.multiplayer.relay": "Relay (optional)"
}
@@ -0,0 +1,15 @@
{
"horizon.options.controller": "Kontroler",
"horizon.options.vibration": "Wibracje",
"horizon.options.adaptive_triggers": "Adaptacyjne spusty",
"horizon.options.lightbar": "Pasek świetlny",
"horizon.options.look_sensitivity": "Czułość rozglądania",
"horizon.options.invert_x": "Odwróć oś X rozglądania",
"horizon.options.invert_y": "Odwróć oś Y rozglądania",
"horizon.options.sneak_button": "Przycisk skradania",
"horizon.options.move_deadzone": "Martwa strefa ruchu",
"horizon.options.look_deadzone": "Martwa strefa rozglądania",
"horizon.options.button_glyphs": "Symbole przycisków",
"horizon.options.menu_navigation": "Nawigacja po menu",
"horizon.options.on_screen_keyboard": "Klawiatura ekranowa"
}
@@ -11,5 +11,57 @@
"horizon.options.look_deadzone": "Мёртвая зона обзора",
"horizon.options.button_glyphs": "Символы кнопок",
"horizon.options.menu_navigation": "Навигация по меню",
"horizon.options.on_screen_keyboard": "Экранная клавиатура"
"horizon.options.on_screen_keyboard": "Экранная клавиатура",
"horizon.options.chat_delay": "Задержка чата",
"horizon.options.lightbar.health": "Здоровье",
"horizon.options.lightbar.dimension": "Измерение",
"horizon.options.glyphs.auto": "Авто",
"horizon.options.glyphs.xbox": "Xbox",
"horizon.options.glyphs.playstation": "PlayStation",
"horizon.options.glyphs.nintendo": "Nintendo",
"horizon.options.menu_focus": "Фокус",
"horizon.options.menu_pointer": "Указатель",
"horizon.options.keyboard.auto": "Авто",
"horizon.options.keyboard.horizon": "Horizon",
"horizon.options.keyboard.steam": "Steam",
"horizon.options.unavailable": "Эта возможность в Horizon пока недоступна.",
"horizon.options.search": "Поиск настроек...",
"horizon.pages.mods": "Моды",
"horizon.pages.shader_packs": "Шейдерпаки",
"horizon.pages.controller": "Настройки контроллера",
"horizon.pages.controller_binds": "Назначения кнопок контроллера",
"horizon.pages.extras": "Дополнения Horizon",
"horizon.pages.settings": "Настройки Horizon",
"horizon.pages.agent": "ИИ-агент",
"horizon.pages.settings.tooltip": "Собственные настройки Horizon: графика, контроллеры, дополнения и моды.",
"horizon.pages.graphics.tooltip": "Графика без лишнего: у каждой настройки написано, чего она стоит.",
"horizon.pages.graphics_advanced.tooltip": "Все графические настройки, включая собственные настройки Horizon, и их цена.",
"horizon.pages.controller.tooltip": "Контроллеры: кнопки, стики, вибрация и навигация по меню.",
"horizon.pages.extras.tooltip": "Дополнительные возможности, все выключены по умолчанию: игра остаётся ванильной, пока вы не включите что-то.",
"horizon.pages.agent.tooltip": "Пусть играет ваш ИИ-агент: horizon-ctl и MCP-клиенты на этом компьютере.",
"horizon.pages.shader_packs.tooltip": "Шейдерпаки из папки shaderpacks: собственные паки Horizon и паки в формате shaders/. Одновременно работает один.",
"horizon.pages.mods.tooltip": "Моды для Horizon из папки horizon-mods. Каждый работает в песочнице и выключен, пока вы его не включите.",
"horizon.pages.open.graphics": "Графика...",
"horizon.pages.open.graphics_advanced": "Продвинутая графика...",
"horizon.pages.open.shader_packs": "Шейдерпаки...",
"horizon.pages.open.controller": "Настройки контроллера...",
"horizon.pages.open.extras": "Дополнения...",
"horizon.pages.open.mods": "Моды...",
"horizon.pages.open.agent": "ИИ-агент...",
"horizon.pages.open.horizon_graphics": "Графика Horizon...",
"horizon.pages.open.controller_binds": "Назначения кнопок...",
"horizon.pages.open.horizon_settings": "Настройки Horizon...",
"horizon.mods.empty": "Модов нет: положите файлы .hmod в папку horizon-mods",
"horizon.mods.stopped": " (остановлен)",
"horizon.mods.dev": " (разработка)",
"horizon.mods.settings": "Настройки",
"horizon.mods.open_folder": "Открыть папку модов",
"horizon.shader_packs.empty": "Шейдерпаков нет: положите их в папку shaderpacks",
"horizon.shader_packs.kind_horizon": " (Horizon)",
"horizon.agent.copy_details": "Скопировать данные подключения",
"horizon.multiplayer.join": "Подключиться",
"horizon.multiplayer.cancel_reconnecting": "Отменить переподключение",
"horizon.multiplayer.join_friend": "Играть с другом",
"horizon.multiplayer.invite_code": "Код приглашения",
"horizon.multiplayer.relay": "Ретранслятор (необязательно)"
}
@@ -0,0 +1,37 @@
//! Block-model tints without a level, including blank and invalid tint indices.
use horizon_client::{
assets::Assets, blocks::BlockFacts, item_render::ItemRenderer, mesher::MeshTables, models,
};
use horizon_world::registry::BlockStateId;
use serde_json::Value;
use std::{
fs,
io::{BufWriter, Write},
path::Path,
};
pub fn main() -> Result<(), Box<dyn std::error::Error>> {
let args: Vec<_> = crate::args().collect();
let input: Value = serde_json::from_slice(&fs::read(&args[1])?)?;
let root = Path::new(input["resources"].as_str().unwrap());
let assets = Assets::load(root)?;
let render = assets.reload(root)?;
let facts = BlockFacts::load(root, assets.registry)?;
let tables = MeshTables::new(
&assets,
&render,
&facts,
models::load_visibility(root, assets.registry)?,
)?;
let grass = ItemRenderer::load(root)?.grass_color(0.5, 1.0) as u32;
let mut output = BufWriter::new(fs::File::create(&args[2])?);
for row in input["cases"].as_array().unwrap() {
let state = row[0].as_u64().unwrap() as u32;
let index = row[1].as_i64().unwrap() as i32;
let color = tables
.default_tint(BlockStateId(state), index, grass)
.map_or(-1, |rgb| (0xFF00_0000 | rgb) as i32);
writeln!(output, "{state} {index} {color}")?;
}
Ok(())
}
@@ -150,7 +150,7 @@ fn entity_state(mob: &mut Mob, species: Species, case: &Value) {
mob.extra.crouching = flag("isCrouching");
mob.extra.passenger = flag("isPassenger");
if flag("isHoldingBow") {
mob.extra.main_hand = Some("minecraft:bow".into());
mob.extra.set_main_hand(Some("minecraft:bow".into()));
}
}
Species::IronGolem => {
@@ -298,7 +298,7 @@ fn entity_state(mob: &mut Mob, species: Species, case: &Value) {
}
}
Species::Witch if flag("isHoldingItem") => {
mob.extra.main_hand = Some("minecraft:potion".into());
mob.extra.set_main_hand(Some("minecraft:potion".into()));
}
_ => {}
}
@@ -386,7 +386,7 @@ pub fn main() -> horizon_client::Result<()> {
.cloned()
.unwrap_or_else(|| kind.model.clone());
let mut mob = Mob::new(7, kind, Vec3::new(0.0, 64.0, 0.0), 0.0);
mob.extra.main_hand = None;
mob.extra.set_main_hand(None);
let case = &v["case"];
let f = |k: &str| case[k].as_f64().unwrap_or(0.0) as f32;
if baby {
@@ -134,7 +134,7 @@ pub fn main() -> horizon_client::Result<()> {
let item: Option<Arc<str>> = Some(Arc::from(s.item.as_str()));
let index = match *slot {
"mainhand" => {
m.extra.main_hand = item;
m.extra.set_main_hand(item);
Some(gear::MAIN)
}
"offhand" => {
@@ -19,6 +19,7 @@ mod block_entity_anim_fixture;
mod block_entity_render_fixture;
mod block_entity_tickers_fixture;
mod block_particles_fixture;
mod block_tint_probe;
mod block_transform_fixture;
mod breaking_fixture;
mod camera_probe;
@@ -88,6 +89,7 @@ mod sound_event_fixture;
mod spawn_rules_fixture;
mod sprint_probe;
mod squid_client_probe;
mod storage_menu_fixture;
mod subtitle_fixture;
mod tab_list_fixture;
mod team_rules_fixture;
@@ -112,6 +114,7 @@ probes! {
block_particles_fixture,
block_transform_fixture,
breaking_fixture,
block_tint_probe,
camera_probe,
squid_client_probe,
creative_mining_probe,
@@ -179,6 +182,7 @@ probes! {
sound_event_fixture,
spawn_rules_fixture,
sprint_probe,
storage_menu_fixture,
subtitle_fixture,
tab_list_fixture,
team_rules_fixture,
@@ -198,6 +198,15 @@ fn goal_name(m: &Mob, name: &str) -> String {
"Float" => "FloatGoal",
"Stroll" => "WaterAvoidingRandomStrollGoal",
"RandomStroll" => "RandomStrollGoal",
// AbstractFish's private subclass of RandomSwimmingGoal.
"RandomSwimming"
if matches!(
m.species(),
Species::Cod | Species::Salmon | Species::TropicalFish | Species::Pufferfish
) =>
{
"FishSwimGoal"
}
"RandomSwimming" => "RandomSwimmingGoal",
"LookAtPlayer" => "LookAtPlayerGoal",
"RandomLookAround" => "RandomLookAroundGoal",
@@ -234,6 +243,8 @@ fn goal_name(m: &Mob, name: &str) -> String {
"FreezeWhenLookedAt" => "EndermanFreezeWhenLookedAt",
"LookForPlayer" => "EndermanLookForPlayerGoal",
"HealRaiders" => "NearestHealableRaiderTargetGoal",
"MountPanic" => "MountPanicGoal",
"LlamaHurtBy" => "LlamaHurtByTargetGoal",
other => return other.to_owned(),
};
vanilla.to_owned()
@@ -0,0 +1,37 @@
//! Storage block menus for tools/test_storage_menu_parity.py: reads one
//! block id per line and prints the menu type the server opens it with
//! (`horizon_client::containers::block_menu_type`, as `lan_open_container`
//! sends it) and whether that menu's slots take a shulker box: the server
//! refuses them in a `minecraft:shulker_box` menu (`ShulkerBoxSlot.mayPlace`,
//! `lan::containers`).
//!
//! usage: storage_menu_fixture INPUT OUTPUT
use horizon_client::containers::block_menu_type;
use std::fs;
fn case(block: &str) -> Option<String> {
let menu = block_menu_type(block.trim())?;
let accepts = if menu == "minecraft:shulker_box" {
"refuses"
} else {
"accepts"
};
Some(format!("{menu} {accepts} shulker_box"))
}
pub fn main() -> horizon_client::Result<()> {
let args: Vec<String> = crate::args().collect();
let input = fs::read_to_string(
args.get(1)
.ok_or("usage: storage_menu_fixture INPUT OUTPUT")?,
)?;
let lines: Vec<String> = input
.lines()
.map(|line| case(line).unwrap_or_else(|| "error".to_owned()))
.collect();
fs::write(
args.get(2).ok_or("missing OUTPUT")?,
lines.join("\n") + "\n",
)?;
Ok(())
}
@@ -7,6 +7,36 @@ pub fn main() -> Result<(), Box<dyn Error>> {
let args: Vec<String> = crate::args().collect();
let mut output = String::new();
for line in std::fs::read_to_string(&args[1])?.lines() {
if let Some(name) = line.strip_prefix("name\t") {
let components = horizon_client::item_data::command_components(&format!(
"[minecraft:custom_name={name}]"
))?;
let (_, extra) = horizon_client::item_data::decode_components(Some(&components));
let stack = ItemStack {
extra,
..ItemStack::new("minecraft:stick", 1)
};
let text = horizon_client::item_tooltip::styled_name(&stack, || "unused".into());
output.push_str("name\t");
for span in text.spans {
for c in span.text.chars() {
let style = &span.style;
write!(
output,
"{},{},{},{},{},{},{}|",
c as u32,
style.color.unwrap_or(horizon_client::text::WHITE),
style.is_bold(),
style.is_italic(),
style.underlined == Some(true),
style.strikethrough == Some(true),
style.obfuscated == Some(true)
)?;
}
}
output.push('\n');
continue;
}
let fields: Vec<&str> = line.split('\t').collect();
let hide_tooltip: bool = fields[0].parse()?;
let creative: bool = fields[1].parse()?;
+8
View File
@@ -164,6 +164,8 @@ pub struct ArrowState {
/// `AbstractArrow.firedFromWeapon`: the crossbow or bow it was shot from.
pub fired_from_weapon: Option<crate::items::ItemStack>,
pub no_physics: bool,
/// `Arrow.ID_EFFECT_COLOR` as a client has it from its server (-1: none).
pub effect_color: i32,
/// `AbstractArrow.getPierceLevel`.
pub pierce: u8,
/// `piercingIgnoreEntityIds`: the entities a piercing arrow has gone
@@ -214,6 +216,7 @@ impl ArrowState {
pickup_item: crate::items::ItemStack::new("minecraft:arrow", 1),
fired_from_weapon: None,
no_physics: false,
effect_color: -1,
pierce: 0,
pierced: Vec::new(),
fire_ticks: 0,
@@ -458,6 +461,10 @@ pub enum EntityHit {
Refused,
/// The entity turned it first (`Entity.deflection`: breezes).
Deflected,
/// The target teleported away from it (`EnderMan.hurtServer` returns true,
/// `onHitEntity` returns before the hit sound and the discard): the arrow
/// flies on and falls to the ground.
Dodged,
}
/// The rest of the tick of an arrow that hit an entity: whether to keep it.
pub fn finish_entity_hit(p: &mut Projectile, scene: &ArrowScene, outcome: EntityHit) -> bool {
@@ -480,6 +487,7 @@ pub fn finish_entity_hit(p: &mut Projectile, scene: &ArrowScene, outcome: Entity
}
}
EntityHit::Refused => ticker.turn_back(),
EntityHit::Dodged => {}
EntityHit::Deflected => {
// The deflection comes before `onHitEntity` adds the entity to
// `piercingIgnoreEntityIds`.
+2 -1
View File
@@ -263,7 +263,8 @@ mod tests {
crate::item_data::OMINOUS_LAYERS.len()
);
assert!(tooltip_layers(&banner).is_empty());
banner.data_mut().custom_name = Some("Captain's Standard".into());
banner.data_mut().custom_name =
Some(horizon_world::nbt::Tag::String("Captain's Standard".into()));
assert!(tooltip_layers(&banner).is_empty());
banner.data_mut().ominous_banner = false;
assert_eq!(tooltip_layers(&banner), layers(&banner));
@@ -146,6 +146,7 @@ const PAGES: Ty = Ty::Enum(&[
"shader-packs",
"graphics",
"graphics-advanced",
"graphics-hdr",
"extras",
"horizon",
"agent",
@@ -425,7 +426,7 @@ pub const COMMANDS: &[Cmd] = &[
&[BOX[0], BOX[1], BOX[2], BOX[3], BOX[4], BOX[5], p("block", Ty::Str, "block state")], mcp: true, local: false),
cmd!("container", World, "container X Y Z [ITEM COUNT]", "list a container's items, or add items to it", "", ["horizon-ctl container 3 64 1", "horizon-ctl container 3 64 1 diamond 3"],
&[BLOCK_XYZ[0], BLOCK_XYZ[1], BLOCK_XYZ[2], opt("item", Ty::Str, "item to add"), opt("count", Ty::Int, "how many")], mcp: true, local: false),
cmd!("render-distance", World, "render-distance N", "chunks, 2..64", "", ["horizon-ctl render-distance 8"], &[p("chunks", Ty::Int, "render distance")], mcp: false, local: false),
cmd!("render-distance", World, "render-distance N", "chunks, 2..128", "", ["horizon-ctl render-distance 8"], &[p("chunks", Ty::Int, "render distance")], mcp: false, local: false),
cmd!("wait", World, "wait TICKS", "wait until TICKS game ticks have passed (20 per second)",
"Keys and mouse buttons held with key ... down or mouse ... down stay held while it waits. Release them with key ... up, release or stop.",
["horizon-ctl wait 40", "horizon-ctl key w down && horizon-ctl wait 40 && horizon-ctl key w up"],
@@ -54,6 +54,28 @@ pub fn spawn_egg_op_warning(stack: &crate::items::ItemStack) -> bool {
)
}
/// `SpawnEggItem.useOn` on a spawner: the type it sets (`getType`: the egg's
/// entity data id, else the egg's own type) and, for an op
/// (`PlayerList.isOp`), that entity data for `Spawner.setEntityData`; anyone
/// else sets only the type (`setEntityId`).
pub fn spawn_egg_on_spawner(
stack: &crate::items::ItemStack,
op: bool,
) -> (String, Option<Compound>) {
let data = match stack.data().and_then(|d| d.other("minecraft:entity_data")) {
Some(Tag::Compound(c)) => Some(c),
_ => None,
};
let id = match data.and_then(|c| c.get(&JavaString::from("id"))) {
Some(Tag::String(id)) => id.to_string_lossy(),
_ => {
let kind = stack.item.trim_end_matches("_spawn_egg");
format!("minecraft:{}", kind.trim_start_matches("minecraft:"))
}
};
(id, data.filter(|_| op).cloned())
}
/// `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.
@@ -466,6 +488,35 @@ mod tests {
assert_eq!(place(&chest, &patch, true), chest);
}
#[test]
fn a_spawn_egg_sets_its_data_type_and_an_ops_data() {
let egg = |entity_data: Option<&str>| {
let mut stack = crate::items::ItemStack::new("minecraft:zombie_spawn_egg", 1);
if let Some(text) = entity_data {
let components = snbt(&format!("{{\"minecraft:entity_data\":{text}}}"));
(stack.damage, stack.extra) =
crate::item_data::decode_components(Some(&components));
}
stack
};
// A plain egg: its own type, no data, op or not.
assert_eq!(
spawn_egg_on_spawner(&egg(None), true),
("minecraft:zombie".into(), None)
);
// Entity data: its type for anyone (getType), its data only for an op.
let custom = egg(Some("{id:\"minecraft:skeleton\",CustomName:\"Guard\"}"));
let (id, data) = spawn_egg_on_spawner(&custom, false);
assert_eq!((id.as_str(), data), ("minecraft:skeleton", None));
let (id, data) = spawn_egg_on_spawner(&custom, true);
assert_eq!(id, "minecraft:skeleton");
let data = data.expect("an op's egg carries its data");
assert_eq!(
data.get(&JavaString::from("CustomName")),
Some(&Tag::String("Guard".into()))
);
}
#[test]
fn item_data_names_its_spawn_and_op_only_types() {
let item = |text: &str| {
+262
View File
@@ -0,0 +1,262 @@
//! `BlockPattern`: a grid of block predicates found in the world in any of
//! its 24 orientations (upright, upside down, lying flat, turned sideways).
//! The wither is built from these.
use horizon_core::geometry::Direction;
/// A grid of predicate keys: `aisles[z][y]` is a row whose chars are the `x`
/// cells (`BlockPatternBuilder.aisle`). A space matches anything.
#[derive(Clone, Copy, Debug)]
pub struct BlockPattern {
pub aisles: &'static [&'static [&'static str]],
}
/// `WitherSkullBlock.getOrCreateWitherFull`: '^' a wither skeleton skull,
/// '#' a wither summon base block, '~' air.
pub const WITHER_FULL: BlockPattern = BlockPattern {
aisles: &[&["^^^", "###", "~#~"]],
};
/// `WitherSkullBlock.getOrCreateWitherBase`.
pub const WITHER_BASE: BlockPattern = BlockPattern {
aisles: &[&[" ", "###", "~#~"]],
};
/// `BlockPattern.BlockPatternMatch`: where a pattern was found and how it is turned.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct PatternMatch {
pub front_top_left: [i32; 3],
pub forwards: Direction,
pub up: Direction,
pub width: i32,
pub height: i32,
pub depth: i32,
}
impl PatternMatch {
/// `BlockPatternMatch.getBlock(x, y, z)`'s position.
pub fn block(&self, x: i32, y: i32, z: i32) -> [i32; 3] {
translate_and_rotate(self.front_top_left, self.forwards, self.up, x, y, z)
}
/// The front layer, column by column (`CarvedPumpkinBlock.clearPatternBlocks`
/// and `updatePatternBlocks`).
pub fn front_cells(&self) -> Vec<[i32; 3]> {
(0..self.width)
.flat_map(|x| (0..self.height).map(move |y| (x, y)))
.map(|(x, y)| self.block(x, y, 0))
.collect()
}
}
/// `BlockPattern.translateAndRotate`: `x` runs along forwards × up, `y` down
/// against up, `z` along forwards.
pub fn translate_and_rotate(
pos: [i32; 3],
forwards: Direction,
up: Direction,
x: i32,
y: i32,
z: i32,
) -> [i32; 3] {
let f = forwards.normal();
let u = up.normal();
let right = [
f[1] * u[2] - f[2] * u[1],
f[2] * u[0] - f[0] * u[2],
f[0] * u[1] - f[1] * u[0],
];
std::array::from_fn(|i| pos[i] - u[i] * y + right[i] * x + f[i] * z)
}
impl BlockPattern {
pub fn width(&self) -> i32 {
self.aisles
.first()
.and_then(|a| a.first())
.map_or(0, |row| row.len() as i32)
}
pub fn height(&self) -> i32 {
self.aisles.first().map_or(0, |a| a.len() as i32)
}
pub fn depth(&self) -> i32 {
self.aisles.len() as i32
}
/// `BlockPattern.matches`: every cell of the pattern with its front top
/// left at `corner`, turned by `forwards` and `up`, passes `test(key, pos)`.
pub fn matches(
&self,
corner: [i32; 3],
forwards: Direction,
up: Direction,
test: &mut impl FnMut(char, [i32; 3]) -> bool,
) -> Option<PatternMatch> {
for x in 0..self.width() {
for y in 0..self.height() {
for z in 0..self.depth() {
let key = self.aisles[z as usize][y as usize]
.as_bytes()
.get(x as usize)
.map_or(' ', |&b| b as char);
if key != ' ' && !test(key, translate_and_rotate(corner, forwards, up, x, y, z))
{
return None;
}
}
}
}
Some(PatternMatch {
front_top_left: corner,
forwards,
up,
width: self.width(),
height: self.height(),
depth: self.depth(),
})
}
/// `BlockPattern.find`: the first match whose front top left corner lies
/// in the cube from `pos` to `pos + size - 1` (x fastest, then y, then z),
/// trying every forwards and up pair (`Direction.values()` order) that is
/// not along one axis.
pub fn find(
&self,
pos: [i32; 3],
mut test: impl FnMut(char, [i32; 3]) -> bool,
) -> Option<PatternMatch> {
let size = self.width().max(self.height()).max(self.depth());
for dz in 0..size {
for dy in 0..size {
for dx in 0..size {
let corner = [pos[0] + dx, pos[1] + dy, pos[2] + dz];
for forwards in Direction::ALL {
for up in Direction::ALL {
if up == forwards || up == forwards.opposite() {
continue;
}
if let Some(m) = self.matches(corner, forwards, up, &mut test) {
return Some(m);
}
}
}
}
}
}
None
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
/// A world of named blocks; anything not listed is air.
fn world(blocks: &[([i32; 3], char)]) -> HashMap<[i32; 3], char> {
blocks.iter().copied().collect()
}
/// Each key tests for its own char in the fixture, '~' for air.
fn finder(w: &HashMap<[i32; 3], char>) -> impl FnMut(char, [i32; 3]) -> bool + '_ {
move |key, p| match key {
'~' => !w.contains_key(&p),
k => w.get(&p) == Some(&k),
}
}
/// The wither T laid out along `right` and `down` from `top_left`.
fn wither(top_left: [i32; 3], right: [i32; 3], down: [i32; 3]) -> Vec<([i32; 3], char)> {
let at = |x: i32, y: i32| std::array::from_fn(|i| top_left[i] + right[i] * x + down[i] * y);
let mut v: Vec<_> = (0..3).map(|x| (at(x, 0), '^')).collect();
v.extend((0..3).map(|x| (at(x, 1), '#')));
v.push((at(1, 2), '#'));
v
}
fn found(blocks: &[([i32; 3], char)], placed: [i32; 3]) -> Option<PatternMatch> {
let w = world(blocks);
WITHER_FULL.find(placed, finder(&w))
}
#[test]
fn every_axis_of_rotation_is_tried() {
// Each forwards/up pair puts x, y and z along three different axes.
for f in Direction::ALL {
for u in Direction::ALL {
if u == f || u == f.opposite() {
continue;
}
let cells: Vec<_> = (0..2)
.flat_map(|x| (0..2).flat_map(move |y| (0..2).map(move |z| (x, y, z))))
.map(|(x, y, z)| translate_and_rotate([0; 3], f, u, x, y, z))
.collect();
let mut unique = cells.clone();
unique.sort();
unique.dedup();
assert_eq!(unique.len(), 8, "{f:?} {u:?}");
}
}
}
#[test]
fn wither_builds_in_any_orientation() {
let x = [1, 0, 0];
let y = [0, 1, 0];
let z = [0, 0, 1];
let neg = |v: [i32; 3]| v.map(|c| -c);
// (right, down) for each layout: upright along X and Z, upside
// down, lying flat both ways, and the T turned on its side.
let layouts = [
("upright along x", x, neg(y)),
("upright along z", z, neg(y)),
("upside down", x, y),
("flat, skulls north", x, z),
("flat, skulls east", z, neg(x)),
("sideways, skulls west", y, x),
("sideways, skulls south", y, neg(z)),
];
for (name, right, down) in layouts {
let top_left = [10, 64, -5];
let blocks = wither(top_left, right, down);
for skull in 0..3 {
let placed: [i32; 3] = std::array::from_fn(|i| top_left[i] + right[i] * skull);
let m = found(&blocks, placed).unwrap_or_else(|| panic!("{name}, skull {skull}"));
// The wither stands on the middle of the bottom row.
let middle: [i32; 3] =
std::array::from_fn(|i| top_left[i] + right[i] + down[i] * 2);
assert_eq!(m.block(1, 2, 0), middle, "{name}");
let mut cells = m.front_cells();
cells.sort();
let mut expected: Vec<[i32; 3]> = (0..3)
.flat_map(|c| (0..3).map(move |r| (c, r)))
.map(|(c, r)| std::array::from_fn(|i| top_left[i] + right[i] * c + down[i] * r))
.collect();
expected.sort();
assert_eq!(cells, expected, "{name}");
}
}
}
#[test]
fn wither_faces_along_the_pattern_normal() {
// WitherSkullBlock.checkSpawn turns the wither by forwards' axis,
// the normal of the T: an upright T along X faces along Z.
let along_x = wither([0, 66, 0], [1, 0, 0], [0, -1, 0]);
let m = found(&along_x, [0, 66, 0]).unwrap();
assert!(matches!(m.forwards, Direction::North | Direction::South));
let along_z = wither([0, 66, 0], [0, 0, 1], [0, -1, 0]);
let m = found(&along_z, [0, 66, 0]).unwrap();
assert!(matches!(m.forwards, Direction::West | Direction::East));
}
#[test]
fn wither_needs_air_beside_the_leg() {
let mut blocks = wither([0, 66, 0], [1, 0, 0], [0, -1, 0]);
blocks.push(([0, 64, 0], 's'));
assert_eq!(found(&blocks, [1, 66, 0]), None);
}
#[test]
fn the_base_pattern_ignores_the_skull_row() {
let base = world(&[
([0, 64, 0], '#'),
([1, 64, 0], '#'),
([2, 64, 0], '#'),
([1, 63, 0], '#'),
]);
assert!(WITHER_BASE.find([0, 65, 0], finder(&base)).is_some());
assert!(WITHER_FULL.find([0, 65, 0], finder(&base)).is_none());
}
}
@@ -0,0 +1,70 @@
//! Identity of the ordered block-state registry used by chunk packets.
use sha2::{Digest, Sha256};
use std::{
collections::HashMap,
path::{Path, PathBuf},
sync::{Mutex, OnceLock},
};
pub const CHANNEL: &str = "horizon:block_registry";
pub const MARKER: &str = "horizon-content-blocks";
pub const MISMATCH: &str =
"Content block registry mismatch: install the same content blocks as the server.";
/// Hash the network order and each state's name and properties, not its gameplay facts.
pub fn identity(root: &Path) -> Result<[u8; 32], String> {
static IDENTITIES: OnceLock<Mutex<HashMap<PathBuf, [u8; 32]>>> = OnceLock::new();
let mut identities = IDENTITIES.get_or_init(Default::default).lock().unwrap();
if let Some(hash) = identities.get(root) {
return Ok(*hash);
}
let bytes = std::fs::read(root.join("facts.json")).map_err(|e| e.to_string())?;
let hash = hash_facts(&bytes)?;
identities.insert(root.to_owned(), hash);
Ok(hash)
}
fn hash_facts(bytes: &[u8]) -> Result<[u8; 32], String> {
let facts: serde_json::Value = serde_json::from_slice(bytes).map_err(|e| e.to_string())?;
let states = facts
.get("states")
.and_then(|s| s.as_array())
.ok_or("facts.json has no block states")?;
let mut hash = Sha256::new();
for row in states {
let state = row
.as_array()
.and_then(|r| r.get(4))
.ok_or("facts.json has an invalid block state")?;
let encoded = serde_json::to_vec(state).map_err(|e| e.to_string())?;
hash.update((encoded.len() as u64).to_le_bytes());
hash.update(encoded);
}
Ok(hash.finalize().into())
}
#[cfg(test)]
mod tests {
use super::hash_facts;
#[test]
fn identity_tracks_order_and_properties_not_gameplay_data() {
let facts = |a: &str, b: &str| format!(r#"{{"states":[["x",0,0,0,{a}],["y",0,0,0,{b}]]}}"#);
let a = r#"{"id":"demo:a","properties":{"facing":"north"}}"#;
let b = r#"{"id":"demo:b"}"#;
let first = hash_facts(facts(a, b).as_bytes()).unwrap();
assert_eq!(first, hash_facts(facts(a, b).as_bytes()).unwrap());
assert_ne!(first, hash_facts(facts(b, a).as_bytes()).unwrap());
assert_ne!(first, hash_facts(facts(a, a).as_bytes()).unwrap());
assert_ne!(
first,
hash_facts(facts(&a.replace("north", "south"), b).as_bytes()).unwrap()
);
assert_ne!(
first,
hash_facts(format!(r#"{{"states":[["x",0,0,0,{a}]]}}"#).as_bytes()).unwrap()
);
assert!(hash_facts(br#"{"states":[[0,0,0,0,"minecraft:stone"]]}"#).is_ok());
assert!(hash_facts(br#"{"states":[[]]}"#).is_err());
}
}
+34 -1
View File
@@ -93,6 +93,18 @@ pub struct Template {
pub damage: u32,
pub extra: Option<Box<StackData>>,
}
/// `ItemStackTemplate.CODEC` count: absent means 1, present must be an
/// integer in `1..=99` (`ExtraCodecs.intRange(1, 99)`), else the recipe is
/// not loaded.
pub(crate) fn template_count(v: &Value) -> Option<u32> {
match v.get("count") {
None => Some(1),
Some(c) => c
.as_u64()
.filter(|n| (1..=99).contains(n))
.map(|n| n as u32),
}
}
impl Template {
fn parse(v: &Value) -> Option<Self> {
let (damage, extra) = match v.get("components") {
@@ -101,7 +113,7 @@ impl Template {
};
Some(Self {
item: v["id"].as_str().map(Arc::from),
count: v["count"].as_u64().unwrap_or(1) as u32,
count: template_count(v)?,
damage,
extra,
})
@@ -1640,6 +1652,27 @@ mod tests {
use super::*;
use crate::banner::BannerLayer;
#[test]
fn result_count_is_bounded_like_vanilla() {
let parse = |s: &str| Template::parse(&serde_json::from_str(s).unwrap());
assert_eq!(parse(r#"{"id":"minecraft:stone"}"#).unwrap().count, 1);
let top = parse(r#"{"id":"minecraft:stone","count":99}"#);
assert_eq!(top.unwrap().count, 99);
for bad in [
"0",
"100",
"2147483648",
"4294967295",
"4294967296",
"-1",
"1.5",
"\"2\"",
] {
let json = format!(r#"{{"id":"minecraft:stone","count":{bad}}}"#);
assert!(parse(&json).is_none(), "count {bad} must be rejected");
}
}
fn ingredient(items: &[&str]) -> Ingredient {
Ingredient::of(items.iter().map(|s| (*s).to_owned()).collect())
}
+159
View File
@@ -0,0 +1,159 @@
//! End gateway travel for the entities that step into one:
//! `EndGatewayBlock.entityInside` (the gateway takes the entity in and starts
//! its 40 tick cooldown), `Entity.setAsInsidePortal` and `Entity.handlePortal`
//! (the entity goes through on the next tick), and
//! `TheEndGatewayBlockEntity.getPortalPosition` (where it lands).
use crate::nether_portal::{ENTITY_DELAY, PortalState};
use horizon_core::{geometry::Vec3, position::BlockPos};
/// The End gateway travel of one entity.
#[derive(Clone, Copy, Debug, Default)]
pub struct Traveller {
/// The entity's portal process and cooldown (`Entity.portalProcess`).
state: PortalState,
}
impl Traveller {
/// `Entity.setAsInsidePortal`: the entity is in the gateway and goes
/// through it on the next tick.
pub fn enter(&mut self, gateway: BlockPos) {
self.state.set_inside(gateway, ENTITY_DELAY);
}
/// `Entity.handlePortal`: the entity goes through the gateway it entered
/// (the End gateway has no transition time), or stays where it is.
pub fn travel(&mut self, allowed: bool) -> Option<BlockPos> {
self.state.handle(allowed, 0, ENTITY_DELAY)
}
/// Whether the entity's travel is over: it has no portal process left and
/// its portal cooldown has run out.
pub fn is_done(&self) -> bool {
self.state.cooldown == 0 && self.state.process.is_none()
}
/// The entity's portal cooldown, the ticks it stays out of a portal.
pub fn cooldown(&self) -> i32 {
self.state.cooldown
}
}
/// The End gateway block an entity's box swept through
/// (`Entity.checkInsideBlocks`, which calls `EndGatewayBlock.entityInside`).
pub fn swept_gateway(
from: Vec3,
to: Vec3,
width: f32,
height: f32,
mut is_gateway: impl FnMut(BlockPos) -> bool,
) -> Option<BlockPos> {
let half = f64::from(width) / 2.0;
let bounds = horizon_core::geometry::Aabb::new(
Vec3::new(to.x - half, to.y, to.z - half),
Vec3::new(to.x + half, to.y + f64::from(height), to.z + half),
)
.deflate(Vec3::new(1.0e-5, 1.0e-5, 1.0e-5));
let mut found = None;
let _ =
horizon_core::geometry::sweep_intersected_blocks(from, to, bounds, 1_000_000, |p, _| {
if is_gateway(p) {
found = Some(p);
return false;
}
true
});
found
}
/// The bottom centre of a block (`Vec3.atBottomCenterOf`): where a traveller
/// lands at a gateway's exit.
pub fn bottom_center(p: [i32; 3]) -> Vec3 {
Vec3::new(
f64::from(p[0]) + 0.5,
f64::from(p[1]),
f64::from(p[2]) + 0.5,
)
}
#[cfg(test)]
mod tests {
use super::*;
/// The gateway of the issue: a block at [702, 100, 0] in the End.
const GATEWAY: [i32; 3] = [702, 100, 0];
fn at(x: f64, y: f64, z: f64) -> Vec3 {
Vec3::new(x, y, z)
}
fn is_gateway(p: BlockPos) -> bool {
[p.x, p.y, p.z] == GATEWAY
}
#[test]
fn an_item_and_a_mob_in_a_gateway_are_found_in_it() {
// A dropped item, 0.25 wide, standing in the gateway block.
let item = at(702.5, 100.5, 0.5);
assert_eq!(
swept_gateway(item, item, 0.25, 0.25, is_gateway),
Some(BlockPos::new(702, 100, 0))
);
// A cow, 0.9 wide and 1.4 tall, in the same block.
let cow = at(702.5, 100.0, 0.5);
assert_eq!(
swept_gateway(cow, cow, 0.9, 1.4, is_gateway),
Some(BlockPos::new(702, 100, 0))
);
}
#[test]
fn an_entity_out_of_the_gateway_is_not_in_it() {
// Two blocks above the gateway: nothing to travel through.
let above = at(702.5, 102.5, 0.5);
assert_eq!(swept_gateway(above, above, 0.25, 0.25, is_gateway), None);
// Beside the gateway, outside its block.
let beside = at(704.5, 100.5, 0.5);
assert_eq!(swept_gateway(beside, beside, 0.9, 1.4, is_gateway), None);
}
#[test]
fn an_entity_falling_through_the_gateway_goes_through_it() {
// A cow falling past the gateway block passes through it
// (Entity.checkInsideBlocks visits the blocks along the way).
let from = at(702.5, 103.0, 0.5);
let to = at(702.5, 99.0, 0.5);
assert_eq!(
swept_gateway(from, to, 0.9, 1.4, is_gateway),
Some(BlockPos::new(702, 100, 0))
);
}
#[test]
fn an_entity_goes_through_the_gateway_on_the_tick_after_it_entered() {
let gateway = BlockPos::new(702, 100, 0);
let mut traveller = Traveller::default();
// Nothing happens while the entity is outside the gateway.
assert_eq!(traveller.travel(true), None);
// It enters the gateway, and goes through on the next tick.
traveller.enter(gateway);
assert_eq!(traveller.travel(true), Some(gateway));
// Its portal cooldown keeps it from going back at once.
assert_eq!(traveller.cooldown(), ENTITY_DELAY);
assert!(!traveller.is_done());
}
#[test]
fn an_entity_that_may_not_use_portals_stays_in_the_gateway() {
let gateway = BlockPos::new(702, 100, 0);
let mut traveller = Traveller::default();
traveller.enter(gateway);
// A mob that cannot use a portal does not go through.
assert_eq!(traveller.travel(false), None);
}
#[test]
fn a_traveller_lands_at_the_bottom_centre_of_the_exit() {
// TheEndGatewayBlockEntity.getPortalPosition: the exit's bottom
// centre, the same spot the player arrives at.
assert_eq!(bottom_center([8, 100, 0]), at(8.5, 100.0, 0.5));
}
}
+55 -6
View File
@@ -872,15 +872,48 @@ impl ArmorStand {
Vec3::new(p.x + w, p.y + h, p.z + w),
)
}
/// LivingEntity.travelInAir (armor stands have no other movement): move
/// by the velocity (a landing stops the fall,
/// `Block.updateEntityMovementAfterFallOn`), then gravity 0.08 and the
/// 0.98 drag, so a stand at rest on the ground keeps -0.0784 as vanilla's.
pub fn tick(&mut self, scene: &mut crate::collisions::SceneCollisions) {
/// `isInWater`: some water block of the stand's box has its surface
/// (`FluidState.getHeight`, a full block under more water) at or above the
/// box's bottom, as `Entity.updateFluidHeightAndDoFluidPushing` counts it.
pub fn in_water(&self, world: &World, fluids: &crate::motion::FluidResources) -> bool {
let (lo, hi) = (self.bounds().min_position(), self.bounds().max_position());
let water = |x: i32, y: i32, z: i32| {
let state = fluids.fluid(world.block(x, y, z));
(state.kind() == horizon_world::fluid::FluidKind::Water).then_some(state)
};
(lo.x.floor() as i32..=hi.x.floor() as i32).any(|x| {
(lo.y.floor() as i32..hi.y.ceil() as i32).any(|y| {
(lo.z.floor() as i32..=hi.z.floor() as i32).any(|z| {
water(x, y, z).is_some_and(|state| {
let height = if water(x, y + 1, z).is_some() {
1.0
} else {
state.own_height()
};
f64::from(y) + f64::from(height) >= lo.y
})
})
})
})
}
/// LivingEntity.travel (armor stands have no other movement): move by
/// the velocity (a landing stops the fall,
/// `Block.updateEntityMovementAfterFallOn`), let the blocks it is inside
/// push it (`push`: a bubble column), then in water the 0.8 slowdown and
/// `getFluidFallingAdjustedMovement`, in air gravity 0.08 and the 0.98
/// drag, so a stand at rest on the ground keeps -0.0784 as vanilla's.
pub fn tick(
&mut self,
scene: &mut crate::collisions::SceneCollisions,
in_water: bool,
push: &mut dyn FnMut(Aabb, &mut Vec3),
) {
if self.marker {
return;
}
let wanted = self.velocity;
// `travelInFluid` reads `isFalling` before the move and any push.
let falling = wanted.y <= 0.0;
let mut y = wanted.y;
if let Ok(moved) = scene.resolve(self.bounds(), wanted, 0.0, self.on_ground) {
self.position = Vec3::new(
@@ -893,7 +926,23 @@ impl ArmorStand {
y = 0.0;
}
}
self.velocity = Vec3::new(0.0, (y - 0.08) * f64::from(0.98f32), 0.0);
let mut velocity = Vec3::new(0.0, y, 0.0);
push(self.bounds(), &mut velocity);
self.velocity = if in_water {
// travelInWater: slowDown 0.8, then `getFluidFallingAdjustedMovement`
// with gravity / 16 (the stand is falling while its fall is not
// already nearly stopped).
let y = velocity.y * f64::from(0.8f32);
let adjusted =
if falling && (y - 0.005).abs() >= 0.003 && (y - 0.08 / 16.0).abs() < 0.003 {
-0.003
} else {
y - 0.08 / 16.0
};
Vec3::new(0.0, adjusted, 0.0)
} else {
Vec3::new(0.0, (velocity.y - 0.08) * f64::from(0.98f32), 0.0)
};
}
}
+79 -13
View File
@@ -759,6 +759,17 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
.into_iter()
.enumerate()
{
if slot == "mainhand"
&& let Some(stack) = m.extra.main_hand_stack()
{
let mut stack = stack.clone();
if let Some(g) = crate::mobs::gear::gear(m) {
stack.data_mut().enchantments = g.slots[index].clone();
stack.data_mut().trim = g.trims[index].clone();
}
equipment.insert(slot.into(), crate::save::item_to_nbt(&stack));
continue;
}
// Enchanted or trimmed gear is saved with its components.
if let (Some(id), Some(g)) = (item, crate::mobs::gear::gear(m))
&& (!g.slots[index].is_empty() || g.trims[index].is_some())
@@ -782,10 +793,11 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
}
match item {
Some(id) => {
let keep = matches!(
equipment.get(&slot.into()).and_then(|e| e.get("id")),
Some(Tag::String(s)) if s.to_string_lossy() == **id
);
let keep = slot != "mainhand"
&& matches!(
equipment.get(&slot.into()).and_then(|e| e.get("id")),
Some(Tag::String(s)) if s.to_string_lossy() == **id
);
if slot == "head"
&& &**id == crate::mobs::raider::BANNER
&& crate::mobs::raider::is_raider(m.species())
@@ -989,10 +1001,8 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
m.hurt_time = number(tag.get("HurtTime")).unwrap_or(0) as i32;
// What it holds (equipment.mainhand / offhand), when saved.
if let Some(eq) = tag.get("equipment") {
m.extra.main_hand = eq
.get("mainhand")
.and_then(|i| text(i.get("id")))
.map(Into::into);
m.extra
.set_main_hand_stack(eq.get("mainhand").and_then(crate::save::item_from_nbt));
m.extra.off_hand = eq
.get("offhand")
.and_then(|i| text(i.get("id")))
@@ -1019,7 +1029,7 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
} else {
// Registration hands out default weapons (finalizeSpawn does in
// vanilla); a loaded mob holds only what its file lists.
m.extra.main_hand = None;
m.extra.set_main_hand(None);
}
m.death_time = number(tag.get("DeathTime")).unwrap_or(0) as i32;
// Mob.readAdditionalSaveData overrides what the constructor set (copper
@@ -2605,7 +2615,10 @@ pub fn bucket_data(m: &Mob) -> Tag {
}
pub fn bucket_components(m: &Mob) -> crate::item_data::StackData {
let mut data = crate::item_data::StackData {
custom_name: m.custom_name.clone(),
custom_name: m
.custom_name
.as_ref()
.map(|name| Tag::String(name.as_str().into())),
..Default::default()
};
data.set_other("minecraft:bucket_entity_data", Some(bucket_data(m)));
@@ -2644,8 +2657,8 @@ pub fn load_bucket_data(m: &mut Mob, stack: &crate::items::ItemStack) {
let Some(components) = stack.data() else {
return;
};
if let Some(name) = &components.custom_name {
m.custom_name = Some(name.clone());
if let Some(name) = stack.custom_name() {
m.custom_name = Some(name);
}
if let Some(data) = components.other("minecraft:bucket_entity_data") {
if let Some(v) = number(data.get("Age")) {
@@ -2817,7 +2830,7 @@ mod axolotl_nbt_tests {
assert_eq!(restored.health, 5.0);
m.glowing = false;
m.add_effect("glowing", 200, 0);
assert!(m.has_effect("glowing"));
assert!(m.has_effect("minecraft:glowing"));
assert!(bucket_data(&m).get("Glowing").is_none());
m.glowing = true;
assert_eq!(number(bucket_data(&m).get("Glowing")), Some(1));
@@ -3133,3 +3146,56 @@ mod neutral_anger_tests {
assert_eq!(crate::mobs::anger_left(3000, 4000), 0);
}
}
#[cfg(test)]
mod held_item_name_tests {
use super::*;
#[test]
fn replacing_a_named_item_does_not_restore_its_old_name_on_save() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client")
.join(horizon_core::VANILLA_VERSION);
if !root.join("items.json").exists() {
return;
}
let data = crate::mobs::MobTypes::load(&root).unwrap();
let types: HashMap<_, _> = data
.types
.into_iter()
.map(|(id, kind)| (id, Arc::new(kind)))
.collect();
let mut mob = Mob::new(1, Arc::clone(&types["minecraft:zombie"]), Vec3::ZERO, 0.0);
let mut sword = crate::items::ItemStack::new("minecraft:diamond_sword", 1);
sword.data_mut().custom_name = Some(horizon_world::nbt::Tag::String("Old Sword".into()));
mob.extra.set_main_hand_stack(Some(sword));
let raw = mob_to_nbt(&mob, [1, 2, 3, 4], None);
let mut loaded = mob_from_nbt(&raw, &types, 2).unwrap();
assert_eq!(
loaded
.extra
.main_hand_stack()
.unwrap()
.custom_name()
.as_deref(),
Some("Old Sword")
);
loaded
.extra
.set_main_hand(Some("minecraft:diamond_sword".into()));
let saved = mob_to_nbt(&loaded, [1, 2, 3, 4], Some(&raw));
let restored = mob_from_nbt(&saved, &types, 3).unwrap();
assert_eq!(
restored.extra.main_hand.as_deref(),
Some("minecraft:diamond_sword")
);
assert!(
restored
.extra
.main_hand_stack()
.unwrap()
.custom_name()
.is_none()
);
}
}
File diff suppressed because it is too large. Load diff
+5 -3
View File
@@ -424,7 +424,6 @@ impl Game<'_> {
Screen::DialogWarning => "dialog-warning",
Screen::CommandBlock => "command-block",
Screen::Win => "win",
Screen::Mod => "mod",
}
}
pub(super) fn inventory_counts(&self) -> BTreeMap<String, i64> {
@@ -566,7 +565,7 @@ impl Game<'_> {
"hardcore": options.hardcore,
"difficulty": horizon_client::title::DIFFICULTIES[options.difficulty()],
"allow_commands": options.commands(*creative),
"superflat": options.flat,
"world_type": options.world_type.id(),
"generate_structures": options.structures,
"bonus_chest": options.bonus_chest(),
}),
@@ -579,7 +578,7 @@ impl Game<'_> {
} => json!({
"mode": "customize-flat",
"fields": { "name": name, "seed": seed },
"superflat": options.flat,
"superflat": options.world_type == horizon_client::save::WorldPreset::Flat,
"biome": flat.settings().biome,
"layers": flat.settings().layers.iter().map(|layer| json!({
"block": layer.block,
@@ -611,6 +610,9 @@ impl Game<'_> {
json!({ "mode": "multiplayer", "disconnected": reason })
}
Mode::Multiplayer(_) => json!({ "mode": "multiplayer" }),
Mode::MultiplayerWarning { check } => {
json!({ "mode": "multiplayer-warning", "check": check })
}
}
}
+1 -1
View File
@@ -219,7 +219,7 @@ impl Game<'_> {
let holds = m.extra.main_hand.is_some();
if !holds && !item.is_empty() {
let m = &mut self.mobs[index];
m.extra.main_hand = Some(item.into());
m.extra.set_main_hand(Some(item.into()));
m.extra.tame = true;
// `Allay.mobInteract` remembers LIKED_PLAYER by UUID.
m.extra.owner = Some(acting);
+49 -6
View File
@@ -65,8 +65,13 @@ impl ArmorStandAssets {
}
}
}
if let Some(part) = load("minecraft:elytra#main") {
player.insert("elytra", part);
for (key, name) in [
("minecraft:elytra#main", "elytra"),
("minecraft:elytra_baby#main", "elytra_baby"),
] {
if let Some(part) = load(key) {
player.insert(name, part);
}
}
let mut equipment = HashMap::new();
let mut trim_overrides = HashMap::new();
@@ -315,9 +320,12 @@ impl Game<'_> {
}
}
/// `ArmorStandItem.useOn`.
pub(super) fn place_armor_stand(&mut self, hit: &super::Hit, stack: &ItemStack) -> bool {
if &*stack.item != "minecraft:armor_stand" || hit.face == [0, -1, 0] {
return false;
/// `ArmorStandItem.useOn` up to its `FAIL`s, which a client checks too:
/// not on a bottom face, and nothing in the new stand's box. The block it
/// stands in and the stand.
pub(super) fn armor_stand_spawn(&self, hit: &super::Hit) -> Option<([i32; 3], ArmorStand)> {
if hit.face == [0, -1, 0] {
return None;
}
// BlockPlaceContext: a replaceable clicked block is replaced.
let clicked = self
@@ -356,8 +364,18 @@ impl Game<'_> {
.iter()
.any(|o| overlaps(o.bounds(), bounds))
{
return None;
}
Some((pos, stand))
}
pub(super) fn place_armor_stand(&mut self, hit: &super::Hit, stack: &ItemStack) -> bool {
if &*stack.item != "minecraft:armor_stand" {
return false;
}
let Some((pos, stand)) = self.armor_stand_spawn(hit) else {
return false;
};
let foot = stand.position;
self.next_entity += 1;
self.audio.play(
"minecraft:entity.armor_stand.place",
@@ -370,6 +388,8 @@ impl Game<'_> {
if !self.creative {
self.consume_held();
}
// ArmorStandItem.useOn returns SUCCESS: the hand swings.
self.swing_arm();
true
}
/// The armor stand under the crosshair, its distance and the hit point.
@@ -584,7 +604,18 @@ impl Game<'_> {
if !ticking(a.position.x.floor() as i32, a.position.z.floor() as i32) {
continue;
}
a.tick(&mut scene);
let in_water = a.in_water(&self.world, &self.res.motion().fluids);
let (world, registry, facts, collision) = (
&self.world,
self.pack.assets.registry,
self.res.facts(),
self.collision,
);
a.tick(&mut scene, in_water, &mut |bounds, velocity| {
horizon_client::motion::bubble_column_push(
world, registry, facts, collision, bounds, velocity,
);
});
if !a.environment_tick(&self.world, self.res.facts(), self.pack.assets.registry) {
dead.push(i);
}
@@ -779,6 +810,18 @@ impl Game<'_> {
);
}
}
// WingsLayer: ArmorStandRenderer adds it after the armour layers. A stand
// never crouches or glides, so its wings rest folded.
self.wings_geometry(
a.equipment[4].as_ref().map(|s| &*s.item),
None,
a.small,
base,
horizon_client::mobs::ElytraAnimation::RESTING,
false,
packed,
out,
);
self.cull_entity_vertices(out, entity_start);
}
}
@@ -586,6 +586,11 @@ impl Game<'_> {
at.z.floor() as i32,
);
let up = BlockPos::new(p.x, top.floor() as i32, p.z);
// A server's client has no live biome features to ask: it answers from
// the chunks and the weather it was sent.
if self.remote.is_some() {
return self.raining_at(p) || self.raining_at(up);
}
self.with_level(|b, level, _| b.is_raining_at(level, p) || b.is_raining_at(level, up))
}
/// `ConduitBlockEntity.updateAndAttackTarget`: while the conduit hunts
@@ -782,6 +787,7 @@ impl Game<'_> {
let mut downloads = self.skin_downloads.borrow_mut();
if self.gui.atlas.sprites.contains_key(&location) {
let _ = downloads.state(skins::Kind::Skin, &url);
downloads.drawn(&location);
return Some(location);
}
// Its slot was evicted: offer the image again.
@@ -153,5 +153,6 @@ impl Game<'_> {
};
}
self.world.block_entities.insert(key, entity);
self.update_spawner_display(key);
}
}
+1
View File
@@ -801,6 +801,7 @@ impl Game<'_> {
self.screen = super::Screen::Inventory;
self.capture(false);
self.right = false;
self.swing_arm();
return true;
}
if sneaking
+94 -6
View File
@@ -1,8 +1,11 @@
//! Brushing suspicious sand and gravel: `BrushItem.onUseTick` and
//! `BrushableBlockEntity`. The block's loot table stays in its saved block
//! entity (like a decorated pot's) until the first counted stroke rolls it.
use super::{Game, Hit, decorated_pot, spill, view_vector};
use super::{Game, Hit, decorated_pot, view_vector};
use horizon_client::{
block_entity_render::vertex_light,
containers::BlockEntity,
mesher::Vertex,
particles::{ParticleOptions, ParticleType},
save,
};
@@ -114,6 +117,20 @@ impl Game<'_> {
let time = self.world.game_time;
let mut brush = self.brushing.remove(&pos).unwrap_or_default();
brush.hit_face.get_or_insert(face);
let direction = match brush.hit_face.unwrap() {
[0, -1, 0] => 0,
[0, 1, 0] => 1,
[0, 0, -1] => 2,
[0, 0, 1] => 3,
[-1, 0, 0] => 4,
[1, 0, 0] => 5,
_ => return false,
};
if let Some(BlockEntity::Brushable(b)) =
self.world.block_entities.get_mut(&(pos[0], pos[1], pos[2]))
{
b.hit_direction = Some(direction);
}
brush.resets_at = time + 40;
if time < brush.cooldown_ends_at {
self.brushing.insert(pos, brush);
@@ -139,13 +156,19 @@ impl Game<'_> {
/// `BrushableBlockEntity.unpackLootTable`: the table's roll becomes the
/// block entity's item.
fn unpack_brush_loot(&mut self, pos: [i32; 3]) {
let Some(mut tag) = spill::saved_entity(&self.world, pos) else {
let key = (pos[0], pos[1], pos[2]);
let Some(BlockEntity::Brushable(b)) = self.world.block_entities.get(&key) else {
return;
};
let Some(horizon_world::nbt::Tag::String(table)) = tag.get("LootTable") else {
let Some(horizon_world::nbt::Tag::String(table)) = &b.loot_table else {
return;
};
let table = table.to_string_lossy();
let mut tag = save::encode_block_entity(
key,
&BlockEntity::Brushable(b.clone()),
"minecraft:brushable_block",
);
self.trigger_loot_table(&table);
let loot = horizon_client::structure_blocks::loot_data(&self.options.resources);
decorated_pot::unpack_loot(
@@ -155,7 +178,12 @@ impl Game<'_> {
&mut self.rand_sequences,
self.level.seed,
);
spill::put_saved_entity(&mut self.world, pos, tag);
if let Some(BlockEntity::Brushable(b)) = self.world.block_entities.get_mut(&key) {
b.loot_table = tag.get("LootTable").cloned();
b.loot_seed = tag.get("LootTableSeed").cloned();
b.item = tag.get("item").and_then(save::item_from_nbt);
}
self.world.modified.insert((pos[0] >> 4, pos[2] >> 4));
}
/// `BrushableBlockEntity.brushingCompleted`: the item drops out of the
@@ -168,8 +196,10 @@ impl Game<'_> {
_ => "minecraft:air",
};
// dropContent: the item at the middle of the block beside that face.
let item = spill::saved_entity(&self.world, pos)
.and_then(|tag| tag.get("item").and_then(save::item_from_nbt));
let item = match self.world.block_entities.get(&(pos[0], pos[1], pos[2])) {
Some(BlockEntity::Brushable(b)) => b.item.clone(),
_ => None,
};
if let Some(mut item) = item {
let face = face.unwrap_or([0, 1, 0]);
let take = 10 + self.world.random.next_int_bound(21).unwrap_or(0);
@@ -223,12 +253,70 @@ impl Game<'_> {
if brush.count != 0 {
self.brushing.insert(pos, brush);
self.schedule_brushable_tick(pos);
} else if let Some(BlockEntity::Brushable(b)) =
self.world.block_entities.get_mut(&(pos[0], pos[1], pos[2]))
{
b.hit_direction = None;
}
if before != after {
self.set_dusted(pos, after);
}
}
/// `BrushableBlockRenderer`: reveal the item from the first brushed face.
pub(super) fn brushable_geometry(&self, camera: glam::DVec3, out: &mut Vec<Vertex>) {
use horizon_assets::item_model::DisplayContext;
for (key, entity) in &self.world.block_entities {
let BlockEntity::Brushable(b) = entity else {
continue;
};
let (Some(item), Some(face)) = (&b.item, b.hit_direction) else {
continue;
};
let at = glam::DVec3::new(f64::from(key.0), f64::from(key.1), f64::from(key.2));
if at.distance_squared(camera) >= 64.0 * 64.0 {
continue;
}
let state = self.world.block(key.0, key.1, key.2);
let progress = self
.pack
.assets
.registry
.state(state)
.and_then(|s| s.properties.get("dusted"))
.and_then(|s| s.parse::<u8>().ok())
.unwrap_or(0);
if progress == 0 {
continue;
}
let offset = f32::from(progress) * 0.075;
let (translation, adjacent, east_west) = match face {
0 => (glam::Vec3::new(0.5, -0.23 - offset, 0.5), [0, -1, 0], false),
1 => (glam::Vec3::new(0.5, 0.25 + offset, 0.5), [0, 1, 0], false),
2 => (glam::Vec3::new(0.5, 0.0, 0.25 - offset), [0, 0, -1], false),
3 => (glam::Vec3::new(0.5, 0.0, 0.73 + offset), [0, 0, 1], false),
4 => (glam::Vec3::new(0.25 - offset, 0.0, 0.5), [-1, 0, 0], true),
5 => (glam::Vec3::new(0.73 + offset, 0.0, 0.5), [1, 0, 0], true),
_ => continue,
};
let item = self.resolve_item(item, DisplayContext::Fixed, None, 0);
if item.is_empty() {
continue;
}
let pose = glam::Mat4::from_translation(
(at - camera).as_vec3() + glam::Vec3::new(0.0, 0.5, 0.0) + translation,
) * glam::Mat4::from_rotation_y(
(86.0_f32 + if east_west { 90.0 } else { 0.0 }).to_radians(),
) * glam::Mat4::from_scale(glam::Vec3::splat(0.5));
let light = self.world.light(
key.0 + adjacent[0],
key.1 + adjacent[1],
key.2 + adjacent[2],
);
self.submit_item_world(&item, pose, vertex_light(light), out);
}
}
/// Level event 3008 (`LevelEventHandler`): the finished block's sound
/// and break particles.
pub(super) fn brush_complete_event(&mut self, pos: [i32; 3], data: i32) {
+34 -27
View File
@@ -20,13 +20,12 @@ use std::{
/// The tree the chat parses and completes with: a server's, once it has
/// sent one (ClientPacketListener.commands), else the game's own.
fn chat_tree<'a>(
remote: &'a Option<super::remote::Remote>,
mods: &'a super::mods::ModsState,
) -> &'a horizon_client::command::Dispatcher {
pub(super) fn chat_tree(
remote: &Option<super::remote::Remote>,
) -> &horizon_client::command::Dispatcher {
match remote.as_ref().and_then(|r| r.commands.as_ref()) {
Some(tree) => mods.remote_dispatcher().unwrap_or(tree),
None => mods.dispatcher(),
Some(tree) => tree,
None => horizon_client::command::vanilla::game_dispatcher(),
}
}
use winit::{
@@ -479,7 +478,7 @@ impl Game<'_> {
let b = HudBatch::new(size);
let m = Measure(&self.gui.atlas.font);
let c = Commands {
dispatcher: chat_tree(&self.remote, &self.mods),
dispatcher: chat_tree(&self.remote),
registries: &self.ui.registries,
};
let mut screen = ChatScreen::new(
@@ -579,13 +578,12 @@ impl Game<'_> {
}
}
/// A line on the window's own chat when the Chat option lets `source`
/// through; running mods hear it.
/// through.
fn local_chat_line(&mut self, text: Text, indicator: Option<u32>, source: ChatSource) {
if !self.ui.chat.shows(source) {
return;
}
let focused = self.screen == Screen::Chat;
let heard = self.mods.running().then(|| text.plain());
self.ui.chat.add(
&self.gui.atlas,
text,
@@ -594,16 +592,13 @@ impl Game<'_> {
self.ticks,
focused,
);
if let Some(line) = heard {
self.mods_chat(&line);
}
}
/// Keys while the chat screen is open.
pub(super) fn chat_key(&mut self, event: &KeyEvent) {
if let Some(shortcut) = self.edit_shortcut(event) {
let m = Measure(&self.gui.atlas.font);
let c = Commands {
dispatcher: chat_tree(&self.remote, &self.mods),
dispatcher: chat_tree(&self.remote),
registries: &self.ui.registries,
};
if let Some(screen) = self.ui.chat_screen.as_mut() {
@@ -649,7 +644,7 @@ impl Game<'_> {
};
let m = Measure(&self.gui.atlas.font);
let c = Commands {
dispatcher: chat_tree(&self.remote, &self.mods),
dispatcher: chat_tree(&self.remote),
registries: &self.ui.registries,
};
match screen.key(&m, &c, &self.ui.chat.recent, key, shift, ctrl) {
@@ -689,7 +684,7 @@ impl Game<'_> {
) {
let m = Measure(&self.gui.atlas.font);
let c = Commands {
dispatcher: chat_tree(&self.remote, &self.mods),
dispatcher: chat_tree(&self.remote),
registries: &self.ui.registries,
};
if let Some(screen) = self.ui.chat_screen.as_mut() {
@@ -700,7 +695,7 @@ impl Game<'_> {
pub(super) fn chat_type(&mut self, text: &str) {
let m = Measure(&self.gui.atlas.font);
let c = Commands {
dispatcher: chat_tree(&self.remote, &self.mods),
dispatcher: chat_tree(&self.remote),
registries: &self.ui.registries,
};
if let Some(screen) = self.ui.chat_screen.as_mut() {
@@ -727,8 +722,8 @@ impl Game<'_> {
return;
}
if let Some(command) = message.strip_prefix('/') {
// A client mod's command first, then a server mod's, as on a server.
if !self.client_mod_command(command) && !self.local_server_mod_command(command) {
// A native mod's command first.
if !self.client_mod_command(command) {
self.run_command(command);
}
} else {
@@ -737,7 +732,6 @@ impl Game<'_> {
&[self.player_display_name(), Text::literal(message)],
);
let focused = self.screen == Screen::Chat;
let heard = self.mods.running().then(|| line.plain());
self.ui.chat.add(
&self.gui.atlas,
line,
@@ -746,11 +740,7 @@ impl Game<'_> {
self.ticks,
focused,
);
if let Some(line) = heard {
self.mods_chat(&line);
}
self.lan_host_chat(message);
self.mod_player_chat(super::server_mods::Who::Local, message);
}
}
pub(super) fn chat_scroll(&mut self, amount: f32) {
@@ -777,7 +767,7 @@ impl Game<'_> {
});
let m = Measure(&self.gui.atlas.font);
let c = Commands {
dispatcher: chat_tree(&self.remote, &self.mods),
dispatcher: chat_tree(&self.remote),
registries: &self.ui.registries,
};
if self.ui.in_bed_chat && self.sleeping.is_some() {
@@ -922,7 +912,7 @@ impl Game<'_> {
}
if self.remote.is_some() {
self.send_chat(&format!("/{command}"));
} else if !self.local_server_mod_command(command) {
} else {
self.run_command(command);
}
}
@@ -942,7 +932,7 @@ impl Game<'_> {
};
let m = Measure(&self.gui.atlas.font);
let c = Commands {
dispatcher: chat_tree(&self.remote, &self.mods),
dispatcher: chat_tree(&self.remote),
registries: &self.ui.registries,
};
screen.resize(&m, &c, batch.width as i32, batch.height as i32);
@@ -1203,9 +1193,26 @@ impl Game<'_> {
return;
};
let path = dir.join(name);
renderer.screenshot = Some(path.clone());
renderer.screenshot = Some(horizon_client::gfx::ScreenshotRequest {
path: path.clone(),
size: None,
});
self.ui.screenshot_message = Some(path);
}
/// `Minecraft.handleGlobalKeyPress`'s screenshot branch: the
/// `key.screenshot` mapping (F2 by default) takes the picture before a
/// screen or a text box sees the key, and the debug modifier suppresses it.
pub(super) fn screenshot_shortcut(&mut self, key: KeyCode, repeat: bool) -> bool {
if repeat || self.debug_keys.modifier_down {
return false;
}
if horizon_client::keybinds::key_name(key) != Some(self.settings.keys.get("key.screenshot"))
{
return false;
}
self.take_screenshot();
true
}
/// After a frame: report a finished F2 screenshot in chat.
pub(super) fn finish_screenshot(&mut self, result: &std::result::Result<[u32; 2], String>) {
let Some(path) = self.ui.screenshot_message.take() else {
+20 -2
View File
@@ -304,6 +304,16 @@ impl Game<'_> {
None
}
}
/// The display component of a custom-named held stack.
fn mob_main_hand_name(&self, id: u32) -> Option<Text> {
let m = self.mobs.iter().find(|m| m.id == id)?;
let mut stack = m.extra.main_hand_stack()?.clone();
stack.custom_name()?;
if let Some(g) = horizon_client::mobs::gear::gear(m) {
stack.data_mut().enchantments = g.slots[horizon_client::mobs::gear::MAIN].clone();
}
Some(self.stack_display_name(&stack))
}
/// `CombatTracker.getDeathMessage`.
pub(super) fn combat_death_message(&mut self) -> Text {
self.combat_death_message_for(Text::literal(PLAYER_NAME))
@@ -357,8 +367,16 @@ impl Game<'_> {
}
// `getLocalizedDeathMessage`: the causing entity's name, or the
// direct entity's when nothing caused the damage.
if let Some((_, name)) = killer {
return Text::translate(&key, &[me, name]);
if let Some((id, name)) = killer {
// DamageSource.getLocalizedDeathMessage: the cause's item with a
// custom name gets the `<id>.item` message.
let (key, args) = horizon_client::scoreboard::death_message_args(
&key,
&me,
&name,
self.mob_main_hand_name(id).as_ref(),
);
return Text::translate(&key, &args);
}
if let Some(name) = direct {
return Text::translate(&key, &[me, name]);
Loaded 100 of 493 files, more files were not shown because too many files have changed in this diff. Show more