Compare commits

...
Author SHA1 Message Date
levzzz ac2f223719 Merge pull request #6280 from levzzz5154/mods/entity-tick
Native mods: an entity's tick, EntityTickEvent.Pre / Post
2026-11-02 01:05:36 +05:00
levzzz 80dfb5f357 Merge pull request #6359 from levzzz5154/mods/native-blocks-items
Native mods: blocks and items declared in code, DeferredRegister
2026-11-02 01:02:01 +05:00
levzzz 2a9d2b57db Merge pull request #6299 from levzzz5154/mods/living-breathe
Native mods: breathing, LivingBreatheEvent
2026-11-02 01:01:50 +05:00
Ludovic Marie 2c9b58e72a Merge pull request #6277 from some-du6e/packs/sprite-prefetch
🤖 Agent: Reviewed by Ludovic's AI agent (Claude): Sprites::prefetch decodes block and CTM textures on every thread, inserts keep their order (test: same layers/pixels with and without); load-time only, no per-frame cost; includes #6254. verify green on 5543bffed.
2026-11-01 20:57:44 +01:00
Ludovic Marie fac351e3b3 Merge pull request #6254 from some-du6e/packs/overlay-parallel
🤖 Agent: Reviewed by Ludovic's AI agent (Claude): overlay build walks each folder's run of the sorted map and inflates zip entries on every thread (1686→691 ms cold); test checks every file of a 3-pack stack; pack-bench.yml is ubuntu-latest, contents: read, manual/push only. verify green on e0eb57399.
2026-11-01 20:57:39 +01:00
Ludovic Marie 95abca8807 Merge pull request #6189 from MSedgeMC/fix/villager-ambient
🤖 Agent: Reviewed by Ludovic's AI agent (Claude): Villager.getAmbientSound sleeping/trading branches; main merged, test modules kept whole. verify green on 736fe7c62.
2026-11-01 20:57:34 +01:00
levzzz cfbae5bd0d Merge remote-tracking branch 'hr/main' into mods/entity-tick
# Conflicts:
#	crates/horizon-client/src/game/native_hooks.rs
2026-11-02 00:55:08 +05:00
levzzz 8101f23df4 Merge pull request #6332 from levzzz5154/mods/data-maps
Native mods: data maps, RegisterDataMapTypesEvent
2026-11-02 00:50:15 +05:00
levzzz ae0eed976d Merge pull request #6330 from levzzz5154/mods/block-energy
Native mods: energy in a mod block, Capabilities.Energy.BLOCK
2026-11-02 00:49:59 +05:00
Ludovic Marie 1ae31db400 Merge pull request #6212 from arslanarm/shaderpacks/center-depth-smooth
🤖 Agent: Reviewed by Ludovic's AI agent (Claude): centerDepthSmooth; main merged (union with #6211's uniforms). verify green on e5aa08936.
2026-11-01 20:49:47 +01:00
levzzz 294ae20e48 Merge pull request #6297 from levzzz5154/mods/player-tracking
Native mods: a player starting and stopping tracking an entity, PlayerEvent.StartTracking
2026-11-02 00:49:38 +05:00
levzzz 648a748dc1 Merge pull request #6292 from levzzz5154/mods/native-map-decorations
Native mods: their own icons on maps, the client half of MapDecorationType
2026-11-02 00:49:28 +05:00
levzzz ce7aa310ae Merge pull request #6290 from levzzz5154/mods/native-gui-layers
Native mods: drawing between the HUD's own layers, RegisterGuiLayersEvent
2026-11-02 00:49:17 +05:00
levzzz fdd70c76fa Merge pull request #6289 from levzzz5154/mods/native-interaction-key
Native mods: the attack, use and pick keys, InputEvent.InteractionKeyMappingTriggered
2026-11-02 00:49:12 +05:00
levzzz 09865ff96a Merge pull request #6281 from levzzz5154/mods/entity-mount
Native mods: an entity mounting or dismounting, EntityMountEvent
2026-11-02 00:49:01 +05:00
levzzz 850cfc5448 Merge pull request #6279 from levzzz5154/mods/projectile-impact
Native mods: a projectile's impact, ProjectileImpactEvent
2026-11-02 00:48:11 +05:00
Ludovic Marie 5706ce95c7 Merge main into #6299
# Conflicts:
#	crates/horizon-client/src/lib.rs
2026-11-01 20:46:24 +01:00
Ludovic Marie e139ab2a32 Merge pull request #6246 from some-du6e/optifine/emf-model-fixes
🤖 Agent: Reviewed by Ludovic's AI agent (Claude): CEM texture fixes: a .jem texture paints its own layer only, atlas-missing texture falls back to vanilla, EMF folder precedence; no-pack path does less work (no texture clone); docs-only sources. verify green on 8a7c9259f.
2026-11-01 20:38:52 +01:00
Ludovic Marie 4e0492a050 Merge pull request #6251 from senjinthedragon/hdr-png-icc
🤖 Agent: Reviewed by Ludovic's AI agent (Claude): HDR screenshot gains an ICC v2 profile (BT.2020, PQ to 203 nits) beside cICP; screenshot path only, no per-frame cost; unit tests on the profile. verify green on 4fa1a55b0.
2026-11-01 20:37:47 +01:00
Ludovic MarieandClaude Opus 5.5 fff651702e Native mods: an item merged before its tick hears no Post; a minecart's Post after it is stored
Review points from #6280 (arismoko): an item merged into an earlier one
never ticked, so it is taken out of the round; a minecart's Post now
comes once the ticked cart is written back, so a mod reads and changes
the cart as it is. Unit test for the round.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 20:37:45 +01:00
Ludovic Marie d57b4fcb5b Merge pull request #6239 from MSedgeMC/fix/merchant-scroll
🤖 Agent: Reviewed by Ludovic's AI agent (Claude): MerchantScreen.mouseScrolled: (int)(scrollOff - scrollY), clamped. verify green on 8801bf499.
2026-11-01 20:37:43 +01:00
Ludovic Marie 5554d04638 Merge pull request #6236 from HrafnaValkyrja/fix/armor-stand-fall
🤖 Agent: Reviewed by Ludovic's AI agent (Claude): three stale tests fixed (armor stand first tick, TNT water and up bubble column); test-only. verify green on 9cabc6278.
2026-11-01 20:37:39 +01:00
Ludovic Marie d7645f1972 Merge pull request #6274 from MSedgeMC/fix/gp-boat-momentum
🤖 Agent: Reviewed by Ludovic's AI agent (Claude): AbstractBoat.tick non-authoritative branch zeroes motion and deltaRotation: the boat stops on dismount. verify green on 9425bb9ac.
2026-11-01 20:37:35 +01:00
Ludovic Marie 4fe36748e0 Merge pull request #6262 from MSedgeMC/fix/items-ignore-keep-blast
🤖 Agent: Reviewed by Ludovic's AI agent (Claude): ItemEntity.ignoreExplosion via ServerExplosion.shouldAffectBlocklikeEntities (javap checked: griefing off + KEEP ignores items; no wind charges reach server_explode). verify green on 37fc39e48.
2026-11-01 20:37:30 +01:00
Ludovic Marie 263781cd8f Merge pull request #6240 from MSedgeMC/fix/recipe-magnifier
🤖 Agent: Reviewed by Ludovic's AI agent (Claude): magnifier hit box = RecipeBookComponent's ScreenRectangle.of(xOrigin+8, width searchBox.getX()-xOrigin) (javap checked). verify green.
2026-11-01 20:37:27 +01:00
Ludovic MarieandClaude Opus 5.5 84a5b49f98 Native mods: tell entities' ticks only while a mod listens (native_entity_tick_listened)
On every server, each tick, the mob and item rounds cloned each ticking
mob's type into a list and each boat built its type name, with no mod to
tell. A mod that hooks native_entity_tick_pre/post now also hooks
native_entity_tick_listened and sets it; otherwise no round is kept.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 20:35:19 +01:00
levzzzandClaude Opus 5.5 bed9c5fd86 Native mods: blocks and items declared in code, DeferredRegister / RegisterEvent
native_blocks(blocks) and native_items(items), asked once at start on every
side, take horizon_mods::content::Block and Item: the declarations a content
mod's data/<id>/horizon/block/ and item/ files hold. They join the content
plan, inside the content mod whose id is their namespace when it is on, else
as a content mod with no files of its own (Content::declared), and pass the
files' checks (Content::declare, shared check_block and check_item), so their
state and network ids, saves and the joining client's content check are the
files'. A namespace whose declarations are refused is logged and left out.
The content root's stamp counts the declarations. API from a fragment.

Step 1 of #6300 (the issue stays open for its later steps).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 00:33:47 +05:00
Ludovic Marie 62bdd29b23 Merge pull request #6234 from Ragbie/fix/capture-test-windows
🤖 Agent: Reviewed by Ludovic's AI agent (Claude): test fixture expects Windows' extra warp before the grab; test-only. verify green on f7301189a.
2026-11-01 20:32:01 +01:00
Ludovic Marie 10e3b7cdd7 Merge pull request #6261 from CocofireHD/fix/sheep-grazing-mob-griefing
🤖 Agent: Reviewed by Ludovic's AI agent (Claude): EatBlockGoal.tick: destroyBlock and grass->dirt behind MOB_GRIEFING, Mob.ate outside (javap checked); two goal-driven tests. verify green on 7d7a73e84.
2026-11-01 20:28:50 +01:00
Ludovic Marie 59259f6d5a Merge pull request #6258 from MSedgeMC/fix/md-crossbow
🤖 Agent: Reviewed by Ludovic's AI agent (Claude): CrossbowItem.onUseTick start/mid flags (reset under 0.2, each plays once): a charge jumped by a mod still plays both; unit test. verify green on 00c7de376.
2026-11-01 20:28:46 +01:00
Ludovic Marie 8f27fb01be Merge pull request #6275 from MSedgeMC/fix/piglin-conversion-offhand
🤖 Agent: Reviewed by Ludovic's AI agent (Claude): Piglin.finishConversion runs PiglinAi.cancelAdmiring first: the admired off-hand item drops; same ADMIRING_ITEM test as the activity picker. verify green on 7671485c3.
2026-11-01 20:28:39 +01:00
Ludovic Marie e2e54fd5b8 Merge pull request #6313 from horizon-rust/fix/vehicle-floating-kick
🤖 Agent: proof: vanilla's floating-vehicle kick (handleMoveVehicle clientVehicleIsFloating + tickPlayer aboveGroundVehicleTickCount, getMaximumFlyingTicks: boat 160, horse 80) ported; lan_bot::a_boat_floating_too_long_kicks_its_driver passes with real resources (160 ok, 161st kicks, reset on a non-floating move), all 16 lan_bot pass, fmt + clippy pass, CI verify pass.
2026-11-01 20:28:35 +01:00
Ludovic MarieandClaude Opus 5.5 2166052a66 Projectile impacts: a mod asks to hear them, so the tick copies nothing otherwise
native_hears_projectile_impacts (same fragment): without a mod setting it,
tick_projectiles keeps no copy of each flying projectile (an arrow's clone is
a Box, two item stacks and a Vec) and the impact hook is not called, so a
server without such a mod pays nothing per tick.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 20:28:31 +01:00
Ludovic MarieandClaude Opus 5.5 736fe7c62e Merge main into pr-6189
mobs/ai.rs: #6218 and #6228 landed test modules at the same place; each
module kept whole beside main's.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 20:23:51 +01:00
Ludovic Marie aef5284bbe Merge pull request #6193 from MSedgeMC/fix/nautilus-leash
🤖 Agent: Reviewed by Ludovic's AI agent (Claude): nautilus canBeLeashed: not while aggravated. verify green on 9685f3a12.
2026-11-01 20:23:41 +01:00
Ludovic Marie 8c0a52ca4d Merge pull request #6190 from MSedgeMC/fix/trader-save
🤖 Agent: Reviewed by Ludovic's AI agent (Claude): wandering trader DespawnDelay and wander_target saved and loaded. verify green on 802ab62f2.
2026-11-01 20:23:37 +01:00
Ludovic Marie 13a0e7b176 Merge pull request #6186 from MSedgeMC/fix/evoker-fang-sturdy
🤖 Agent: Reviewed by Ludovic's AI agent (Claude): evoker fangs test isFaceSturdy(UP), not a full cube. verify green on 177d2c9f5.
2026-11-01 20:23:33 +01:00
Ludovic Marie ebcee9d1b0 Merge pull request #6174 from MSedgeMC/fix/zombie-nautilus-variant
🤖 Agent: Reviewed by Ludovic's AI agent (Claude): zombie nautilus egg applies its variant component. verify green on f58c0ae1b.
2026-11-01 20:23:29 +01:00
Ludovic Marie 27fe715312 Merge pull request #6172 from MSedgeMC/fix/drowned-uncertainty
🤖 Agent: Reviewed by Ludovic's AI agent (Claude): drowned trident uncertainty from difficulty (RangedAttackMob; Drowned field is -1). verify green on 3217c6759.
2026-11-01 20:23:25 +01:00
Ludovic Marie 519899f762 Merge pull request #6171 from MSedgeMC/fix/creeper-max-fall
🤖 Agent: Reviewed by Ludovic's AI agent (Claude): Creeper.getMaxFallDistance health-1 branch (javap checked); test skips without vanilla data. verify green on 4d31db99b.
2026-11-01 20:23:21 +01:00
Ludovic Marie f1d2b825e7 Merge pull request #6158 from breachato/fix/6125-spawn-egg-on-vines
🤖 Agent: Reviewed by Ludovic's AI agent (Claude): SpawnEggItem.useOn: a clicked block without collision takes the mob (bytecode checked). verify green on cbb7efe89.
2026-11-01 20:23:17 +01:00
some-du6eandClaude Opus 5.5 8a7c9259f0 OptiFine entity models: say which random textures follow a .jem's texture
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 13:22:10 -06:00
levzzzandClaude Sonnet 5.5 168eb9ff04 Native mods: data maps (RegisterDataMapTypesEvent), first step
native_data_map_types registers a mod's data map types for items and blocks.
Their values come from data/<mod>/data_maps/<registry>/<name>.json in the
world's data packs and from files a mod pushes through native_data_pack_files,
merged each time the server's tables are built: replace, values (entries and
#tags) and remove, a newer value overriding an older. native_data_map and
native_data_map_entries read them. Fixes #6312.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 00:19:39 +05:00
Ludovic Marie 904ef97e51 Merge pull request #6228 from MSedgeMC/fix/witch-ignore-raiders
🤖 Agent: Reviewed by Ludovic's AI agent (Claude): Witch HurtByTargetGoal(Raider.class). verify green on 215771565.
2026-11-01 20:18:05 +01:00
Ludovic Marie da31739ea5 Merge pull request #6218 from MSedgeMC/fix/endermite-goals
🤖 Agent: Reviewed by Ludovic's AI agent (Claude): Endermite.registerGoals (javap checked: priorities, alert others). verify green on 38f7febdc.
2026-11-01 20:18:00 +01:00
Ludovic Marie ac4eb02342 Merge pull request #6204 from MSedgeMC/fix/bat-flap
🤖 Agent: Reviewed by Ludovic's AI agent (Claude): Bat.isFlapping: awake, every tenth tick, sculk FLAP. verify green on e35d09582.
2026-11-01 20:17:55 +01:00
Ludovic Marie 8163dfde6f Merge pull request #6184 from xFedeT/fix/carrot-stick-swing
🤖 Agent: Reviewed by Ludovic's AI agent (Claude): FoodOnAStickItem.use passes on a client: no predicted swing. verify green on aba7fe0ba.
2026-11-01 20:17:51 +01:00
Ludovic Marie 327a5b1706 Merge pull request #6178 from xFedeT/fix/spear-damage-indicator
🤖 Agent: Reviewed by Ludovic's AI agent (Claude): spear stab damage indicators sent to served players, as attack_mob. verify green on 473941651.
2026-11-01 20:17:46 +01:00
Ludovic Marie e105b176c9 Merge pull request #6160 from xFedeT/fix/camel-dash-release
🤖 Agent: Reviewed by Ludovic's AI agent (Claude): camel keeps a pending dash until it lands (AbstractHorse.tickRidden). verify green on 5fe9fc298.
2026-11-01 20:17:43 +01:00
Ludovic MarieandClaude Opus 5.5 e5aa08936c Merge main into shader-packs centerDepthSmooth
#6211's status and End flash uniforms landed in the same list and match:
both sides kept.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 20:15:51 +01:00
Ludovic Marie ff8e46848d Merge pull request #5446 from Xmilon/native/api-mods
🤖 Agent: queued 18211933b: main merged, package selections fixed (old_world_e2e, dragon_render, launch_api docs), pr_ready --quick green except 2 TNT tests that fail on main too; dragon_render + arrow harnesses pass.
2026-11-01 20:12:23 +01:00
Ludovic Marie 473cd6da7e Merge pull request #6230 from Ragbie/fix/friends-deadline-windows
🤖 Agent: Reviewed by Ludovic's AI agent (Claude): horizon-friends deadline test ends on Windows; test-only. verify green on 89bd321b4.
2026-11-01 20:12:19 +01:00
Ludovic Marie 3a6ef0ffa2 Merge pull request #6224 from levzzz5154/mods/owner-console-native-commands
🤖 Agent: Reviewed by Ludovic's AI agent (Claude): owner console reaches native registered commands; Python hook tests pass merged with main. verify green on c73e087d7.
2026-11-01 20:12:15 +01:00
levzzz e5a4324ca3 Merge pull request #6263 from Xmilon/fix/registry-lockfile
Mod registry: refresh its Cargo.lock for horizon-mods' png dependency
2026-11-02 00:10:38 +05:00
Ludovic Marie 0e609939ce Merge pull request #6211 from arslanarm/shaderpacks/status-end-flash-uniforms
🤖 Agent: Reviewed by Ludovic's AI agent (Claude): status and End flash uniforms for shader packs, from the public uniform reference. verify green on 7745fddd2.
2026-11-01 20:07:26 +01:00
Ludovic Marie 2fea6f90ee Merge pull request #6210 from arslanarm/shaderpacks/sky-binds-ssbos
🤖 Agent: Reviewed by Ludovic's AI agent (Claude): sky pass binds pack SSBOs (crash fix). verify green on 4fbbee3d1.
2026-11-01 20:07:21 +01:00
Ludovic Marie 6949060eb9 Merge pull request #6201 from levzzz5154/mods/native-client-level-events
🤖 Agent: Reviewed by Ludovic's AI agent (Claude): client LevelEvent hooks, empty bodies; Python hook tests pass merged with main. verify green on ba04f5032.
2026-11-01 20:07:16 +01:00
Ludovic Marie 22f6fad2a0 Merge pull request #6198 from some-du6e/optifine/ctm-draw
🤖 Agent: Reviewed by Ludovic's AI agent (Claude): OptiFine CTM drawn in the mesher; nothing changes without CTM files. verify green on 82c95b710.
2026-11-01 20:07:10 +01:00
Ludovic Marie 92641832ea Merge pull request #6194 from Domanffe/roadmap/benchmark-example-link
🤖 Agent: Reviewed by Ludovic's AI agent (Claude): roadmap link fix. verify green on 2d1d81794.
2026-11-01 20:07:05 +01:00
Ludovic Marie df5526d993 Merge pull request #6192 from Domanffe/fix/playerdata-save-errors-2
🤖 Agent: Reviewed by Ludovic's AI agent (Claude): failed playerdata writes are logged. verify green on 152617ece.
2026-11-01 20:07:00 +01:00
Ludovic Marie 5e14f850d9 Merge pull request #6181 from NerfPhaselock/perf/translucent-bind-used
🤖 Agent: Reviewed by Ludovic's AI agent (Claude): translucent batching pages bound up to their last range; off by default. verify green on 18ac513e8.
2026-11-01 20:06:55 +01:00
Ludovic Marie 55bacdf91f Merge pull request #6177 from arslanarm/shaderpacks/translator-fixes
🤖 Agent: Reviewed by Ludovic's AI agent (Claude): four shader translator fixes (bool-shadowing part removed: LGPL source); horizon-shaders tests in verify. verify green on 6e554aaa1.
2026-11-01 20:06:51 +01:00
Ludovic Marie 23f75271bf Merge pull request #6173 from Joansitoh/fix/6111-dragon-head-anim
🤖 Agent: Reviewed by Ludovic's AI agent (Claude): worn dragon head jaw follows wornHeadAnimationPos incl. the vehicle's positionScale; supersedes #6166. verify green on f5161d9eb.
2026-11-01 20:06:44 +01:00
Ludovic Marie 7f23246f55 Merge pull request #6170 from some-du6e/fix/beacon-beam-remote
🤖 Agent: Reviewed by Ludovic's AI agent (Claude): beacon update tag keeps the client's beam (loadAdditional reads only powers); unit test. verify green on 2d5ee61a5.
2026-11-01 20:06:34 +01:00
Ludovic Marie d146862edc Merge pull request #6168 from xFedeT/fix/arm-cpu-name
🤖 Agent: Reviewed by Ludovic's AI agent (Claude): ARM Linux CPU name from implementer/part; unit tests on real cpuinfo shapes. verify green on ce02c0329.
2026-11-01 20:06:25 +01:00
Ludovic Marie 8d38e82f97 Merge pull request #6167 from Xmilon/native/mod-random-ticks
🤖 Agent: Reviewed by Ludovic's AI agent (Claude): random_ticks for content blocks; registry lockfile refresh; hook/mod Python tests pass merged with main. verify green on 5d08602df.
2026-11-01 20:06:13 +01:00
Ludovic Marie b714abcda2 Merge pull request #6165 from Xmilon/native/content-state-order
🤖 Agent: Reviewed by Ludovic's AI agent (Claude): mod block state property sorting before the template's is refused with a clear error (message whitespace fixed). verify green on dc1597e11.
2026-11-01 20:06:05 +01:00
Ludovic Marie 53adac781f Merge pull request #6163 from levzzz5154/mods/native-level-events
🤖 Agent: Reviewed by Ludovic's AI agent (Claude): server LevelEvent load/unload hooks, empty bodies, one unload per load; hook/mod Python tests pass on the head merged with main. verify green on c60b4c8d4.
2026-11-01 20:06:00 +01:00
Ludovic Marie 2cc2172fb5 Merge pull request #6161 from Joansitoh/fix/6107-superflat-grass-tint
🤖 Agent: Reviewed by Ludovic's AI agent (Claude): Superflat grass icon uses the grass colormap (GrassEnv), as the inventory does. verify green on b3384f9bd.
2026-11-01 20:05:54 +01:00
some-du6eandClaude Opus 5.5 6415af6dfc OptiFine entity models: a broken baby file gives way to the adult's, docs name the animations
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 13:04:08 -06:00
CocofireHDandHermes Agent 7d7a73e84c Sheep grazing: the eat goal only changes the world with mobGriefing on
`EatBlockGoal.tick` gates both the `destroyBlock` of an edible block and the
grass block's `levelEvent(2001)` + `setBlock(DIRT)` behind `MOB_GRIEFING`, and
keeps `Mob.ate` outside that gate. Horizon pushed the two `MobEvent`s
unconditionally, so a sheep on a grass block with the rule off turned it to
dirt: measured in a running client, 0 -> 1 dirt blocks at 41.6 s of a 150 s
window.

`MobWorld.mob_griefing` already carries the rule (it gates an allay's pickup,
the dragon's block breaking, blasts and a mob's projectiles); this goal was the
outlier. The test world gains `with_built_test_world_rules` so the flag is
reachable from a test, and the new cases tick a sheep for 6000 ticks through
`mobs::ai::tick` with the rule on and off, one per half of the gate: dirt
events 4 on and 0 off with `EntityEvent` 10 in both, and the short grass under
its feet destroyed on, untouched off. Inverting the gate fails both.

Co-Authored-By: Hermes Agent (deepseek-v4.1-flash) <[email protected]>
2026-11-01 19:58:38 +01:00
levzzzandClaude Sonnet 5.5 38f320860a Native mods: breathing, LivingBreatheEvent
native_living_breathe(entity, kind, can_breathe, consume, refill) is called
every tick for a player (on the server's copy) and for a mob with the
generic air logic. Horizon's own answers come in as the defaults and a mod
may change them; the air arithmetic moves to a new breathing module, which
is a free function hook like the loot and menu ones, because the player's
motion and the mob tick have no Game.

Fixes #6248.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 23:56:45 +05:00
some-du6eandClaude Opus 5.5 96f70dc451 OptiFine entity models: the render test's spider wears the spider texture again
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 12:55:13 -06:00
levzzzandClaude Sonnet 5.5 4155022685 Native mods: a mod's own icons on filled maps (MapDecorationType, client half)
native_map_decorations(map, decorations) is asked each time a map is drawn
(held, in an item frame, on the cartography table's screen); a mod pushes
NativeMapDecoration icons that are drawn after the vanilla ones with the same
pose. Typed decorations saved in the map need code-side registration and are
the later step.

Fixes #6269.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 23:52:59 +05:00
Ludovic MarieandClaude Opus 5.5 18211933bf Gameplay tests run from horizon-client's lib target: dragon render harness, launch_api docs
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 19:51:15 +01:00
levzzzandClaude Sonnet 5.5 b8dc376f86 Native mods: energy handler on blocks (Capabilities.Energy.BLOCK)
native_block_entity_energy declares a mod block's energy buffer as
SimpleEnergyHandler is built: capacity, the most one insert and one extract
moves, and the faces a fill may come through and a drain may leave by. The
amount is kept in the block entity's saved compound as the int `energy`.

native_energy reads it through a side, native_energy_insert and
native_energy_extract move energy (with `simulate`), and native_move_energy
is EnergyHandlerUtil.move between two blocks. Fixes #6227.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 23:46:54 +05:00
MSedgeMC 7671485c32 Drop the item a piglin was admiring when it zombifies
Piglin.finishConversion calls PiglinAi.cancelAdmiring before it converts, which spawns the
off-hand item the piglin was admiring and clears the hand. Horizon copied the whole off hand
to the new zombified piglin, so the item that should have been on the ground travelled with it.
2026-11-01 18:46:32 +00:00
levzzzandClaude Sonnet 5.5 0b4fdee290 Native mods: an entity mounting or dismounting a vehicle, EntityMountEvent
Add native_entity_mount, asked before a player or a mob gets on or off a
boat, minecart, cushion or mount (including /ride, stabs, bucks, teleports),
on the server thread and a dedicated server. A mod can cancel it.

Fixes #6273.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 23:45:47 +05:00
MSedgeMC 9425bb9acf A boat stops when its rider gets out
AbstractBoat.tick drops a boat's cached motion on the side that does not drive
it, so the level takes a boat over at a standstill when its rider leaves.
Horizon kept the velocity of the ride, and the empty boat went on gliding.
2026-11-01 18:45:41 +00:00
some-du6eandClaude Opus 5.5 07bdf9af4e OptiFine entity models: armour cracks keep their texture, and a broken file gives way
- A layer takes its .jem's texture only when it is the first layer drawn on that model
  (and not on the body's): the wolf armour does, the cracks over it on wolf_armor#main
  keep theirs. One helper picks the .jem texture when the atlas has it, for the body
  and the layers, without cloning a String first.
- Fallbacks follow chains up to three steps, babies included: cat_baby#collar takes
  cat.jem's geometry (not its texture) through cat_collar.
- jem_files keeps every file of a name, EMF's first; each layer tries its candidates in
  order (its own names by name, then its fallbacks, nearest first) and a file that fails
  to read or parse gives way to the next. Each file is parsed at most once, and only
  when a layer tries it.
- Warn once at load when a .jem's texture names a file missing from the pack root.
- Drop the `over` test wrapper and the bool of fallback_names (a fallback onto another
  layer is geometry-only); LAYERS and FALLBACKS are slices; the "first by name" rule is
  written once.
- Tests: a sheep's wool draws in its sheep_wool.jem texture; a wolf armour .jem paints
  the armour but not its cracks; cat_baby#collar from cat.jem; a broken llama_decor.jem
  lets trader_llama_decor.jem apply, a broken EMF file lets OptiFine's. The ignored
  real-data test also checks both columns of FALLBACKS and every baby layer, and says
  its part names are a sample.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 12:41:39 -06:00
Ludovic Marie dea6dea98e Merge pull request #6226 from horizon-rust/fix/anticheat-level-entity-states
🤖 Agent: proof: the official server's boat false positives (1357 claims at y 63.0857639 = a floating boat's top, isInferno banned twice) come from LanHost.states being the last level's entities when moves are read; states now park with their level. pr_ready --quick: fmt, clippy pass; the one lib test failure (armor stand, outside this change) passed in CI verify.
2026-11-01 19:37:39 +01:00
[email protected]andClaude Opus 5.5 ce0f4ad290 Mod registry: refresh its Cargo.lock for horizon-mods' png dependency
The registry is its own workspace, and its workflow runs on any change under
crates/horizon-mods. Its lockfile lacks png (and fdeflate, miniz_oxide), which
horizon-mods needs, so `cargo clippy --locked` refuses it and `check` fails on
every PR that touches horizon-mods.

Fixes #6196

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 19:37:02 +01:00
levzzzandClaude Sonnet 5.5 a6a386723a Native mods: entities ticking, with a Pre that can cancel (EntityTickEvent)
native_entity_tick_pre and native_entity_tick_post for the mobs, item
entities, minecarts and boats that tick in an entity-ticking chunk, on a
server. Set canceled in the Pre and the entity does not tick this tick, and
its Post is not told. Fixes #6249.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 23:35:54 +05:00
some-du6eandClaude Opus 5.5 5543bffed5 Sprites: the prefetch test inserts its tile after the names
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 12:35:25 -06:00
MSedgeMC 37fc39e48a Leave dropped items alone when a mob's blast keeps blocks
ItemEntity.ignoreExplosion asks ServerExplosion.shouldAffectBlocklikeEntities, so with
mobGriefing off a blast that keeps blocks (a creeper's or a ghast's) never touches a
dropped item. Horizon hurt and pushed every item inside the blast whatever the rule.
2026-11-01 18:33:10 +00:00
MSedgeMC 00c7de3765 Crossbow: a tick that jumps the charge still plays each loading sound
CrossbowItem.onUseTick keeps a flag for the start and the middle sound and
plays each once the charge reaches its point, so a charge a mod's tick moved
past both points in one tick plays both. Horizon compared the charge with the
tick before it (`before < 0.2 && now >= 0.2`), so the sounds a jump passed
were skipped; only the end sound played.

The charge's two flags now live on the game beside `using` and are cleared
when a use starts and while the charge is under the first point, which is
vanilla's rule. `charge_sounds` holds it as a function of its own, with a test
for the jump, the ordinary crossing, a second charge and Quick Charge.
2026-11-01 18:32:10 +00:00
levzzzandClaude Sonnet 5.5 caea1cd292 Native mods: HUD layers around vanilla's (RenderGuiLayerEvent, RegisterGuiLayersEvent)
native_gui_layer_pre(layer, batch, canceled) and native_gui_layer_post(layer,
batch) fire around each vanilla HUD layer Horizon draws, so a mod draws below
or above a layer, or hides it. The jump/locator/experience bar is drawn as one
layer, and the mount's jump bar and the title and action bar are split out of
the passes that drew them together.

Fixes #6243.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 23:29:59 +05:00
Ludovic MarieandClaude Opus 5.5 1a39a7b3ca Kick the driver of a vehicle that floats too long
ServerGamePacketListenerImpl's clientVehicleIsFloating and
aboveGroundVehicleTickCount: a vehicle move that doesn't go down, by a vehicle
that hadn't come down onto something, with nothing but air around it (water
holds a boat up), floats; a driver whose vehicle floats for more than
getMaximumFlyingTicks of the vehicle (160 ticks for a boat, 80 for a horse) is
kicked with "multiplayer.disconnect.flying", unless the server allows flight.
The server had no check on vehicle moves beyond moved too quickly and moved
wrongly, so a boat flying over land was never stopped.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 19:27:32 +01:00
levzzzandClaude Sonnet 5.5 0fd8c97066 Native mods: a player starting and stopping tracking an entity (PlayerEvent)
native_player_start_tracking and native_player_stop_tracking: the hub's
per-client Tracker keeps what started and stopped, with the entity's ID, UUID
and type, from every place that changes the set (the tick's range checks, a
forgotten or cleared set, a leaving player), and the game tells the mods each
tick. Fixes #6231.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 23:25:18 +05:00
Ludovic MarieandClaude Opus 5.5 4d31db99b5 Skip the creeper fall test without the vanilla data
CI's verify has no extracted data, so with_test_world doesn't run the
closure there; the other mob tests ignore its result the same way.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 19:25:09 +01:00
levzzzandClaude Sonnet 5.5 de0b65b26b Native mods: a projectile's impact a mod can cancel (ProjectileImpactEvent)
native_projectile_impact for each impact tick_projectiles finds, with the
projectile's id, type and owner and the entity or block it hit. A canceled
impact flies the tick again through what it hit (Projectile::tick_through,
arrows::tick_through): nothing of the hit happens and the projectile flies on.
Fixes #6203.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 23:24:38 +05:00
some-du6eandClaude Opus 5.5 82c95b7102 OptiFine CTM: compact cuts shared by both axes; one biome lookup
Cutting both axes at every crossing keeps the pieces on the quad's
diagonal square, so a split face shades exactly as the whole one does.
Tints::get and the biome rules share Tints::biome_id.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 12:23:18 -06:00
some-du6eandClaude Opus 5.5 e44848cf9b OptiFine entity models: a .jem's texture leaves overlays alone, and EMF's files win
- A .jem's texture paints only its own layer; overlays drawn on the body's
  model (collars, eyes, villager type/profession, armour cracks) keep theirs.
- A .jem texture the atlas lacks falls back to the vanilla one instead of
  hiding the mob; a texture outside assets/minecraft/textures/ is logged.
- emf/cem beats optifine/cem in both forms, the flat file beats the folder
  form, a directory named x.jem is no file; scan is sorted.
- Two names of one layer (llama decor, undead horse armor): first by name.
- Fallbacks salmon_small/large -> salmon, cat_collar -> cat (geometry only),
  player_slim -> player; nautilus_baby gets none.
- Small pufferfish fins and rabbit hind feet map to Horizon's part names.
- Files no layer uses aren't parsed; no CEM files: no vanilla map built.
- Reuse render::find_part; docs list custom entity models and mobs/cem.rs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 12:22:44 -06:00
Ludovic Marie 455b6916b4 Merge pull request #6238 from horizon-rust/perf/block-entity-scans-arrivals
🤖 Agent: server-tps-1: creaking heart scan follows chunk arrivals; tick_block_entities −0.45 ms/tick on the 50-bot bench (A/B alternated), test passes; merging once CI is green.
2026-11-01 19:21:16 +01:00
MSedgeMC 927f2f5e71 Widen the recipe book's magnifier hit box to the search box
RecipeBookComponent puts the magnifier icon's clickable rectangle from eight pixels past the
book's left edge to the search box's own left edge, 25 pixels wide. Horizon used 17, so the
last eight pixels of the magnifier did not focus the search box.
2026-11-01 18:20:27 +00:00
levzzzandClaude Sonnet 5.5 3cad89933a Native mods: interaction key presses (InputEvent.InteractionKeyMappingTriggered)
native_interaction_key(button, key_mapping, hand, swing_hand, canceled) fires
before the attack, use and pick-block keys act on the crosshair, for a single
player's window and a server's player. A cancel skips the action; a cancelled
attack or use still swings unless the mod clears swing_hand.

Fixes #6213.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 23:20:22 +05:00
MSedgeMC 8801bf4991 Scroll the merchant's offers by the wheel delta, not its sign
MerchantScreen.mouseScrolled moves scrollOff by (int)(scrollOff - scrollY), so one event
with a two-notch delta moves two rows. Horizon moved exactly one row per event whatever
the delta was.
2026-11-01 18:20:22 +00:00
HrafnaValkyrjaandClaude Opus 5.5 9cabc62783 Fix three stale tests that CI skips
They only run with prepared resources, so they went stale unseen. The armor stand test expects a fall on tick 1, where a summoned stand starts moving on tick 2; the two TNT tests set up the wrong scene.

Fixes #6235

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018zuDTsnyHkP28bzn3u7S3m
2026-11-01 13:19:25 -05:00
some-du6eandClaude Opus 5.5 c999da7473 OptiFine CTM: compact quarters meet where the sprite's halves do
Cut a ctm_compact face where its UVs cross 0.5, not at its middle, so a
dirt path's side or a slab's gets its quarters in the right place (a slab
side isn't cut across at all); no allocation per face. The blend test now
uses corners only the triangle split gets right.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 12:17:56 -06:00
Ludovic MarieandClaude Opus 5.5 6e554aaa1f Drop the bool-uniform shadowing fix
It came from reading an LGPL pack's source (Pixel Perfect), which
CONTRIBUTING puts off limits even to read. The other four fixes rest on
the GLSL specification and an MIT pack, and stay.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 19:16:07 +01:00
MSedgeMC 2157715650 Stop a witch from turning on the raider that hit it
Witch.registerGoals adds HurtByTargetGoal(this, Raider.class), so a raider that damages a
witch is never taken as its target; Horizon used a plain HurtByTarget, so raid friendly fire
made the witch fight the raid.
2026-11-01 18:15:00 +00:00
Ludovic MarieandClaude Opus 5.5 dc1597e116 Join the state-order error's two lines with a continuation
The message carried the source's indentation between "the" and "game";
the test now checks the block's id and the single spaces.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 19:12:15 +01:00
SenjinandClaude Opus 5.5 4fa1a55b08 HDR: screenshots carry an ICC profile, for programs that don't read cICP
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 19:12:06 +01:00
levzzzandClaude Sonnet 5.5 c73e087d70 Native mods: a registered command run from a single player's owner console
The owner console (horizon-ctl command, the MCP command tool) ran every
line through the vanilla dispatcher, so a command a native mod registered
answered "Unknown or incomplete command". It now offers a registered name
to native_perform_command first, as the owner at level 4, the way the
owner's own chat does. The dedicated console was done in #5815.

Fixes #5682.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 23:11:35 +05:00
MSedgeMC 38f7febdc4 Give an endermite its own goal list
Endermite.registerGoals is the silverfish-style monster list: powder-snow climbing, a
melee attack, a stroll at 1.0, and a HurtByTargetGoal that alerts the others. Horizon
never put the endermite in the hostile family, so it fell back to the generic monster
block: stroll at 0.8, no powder-snow climb, and a hurt-by-target goal that never alerts.
2026-11-01 18:11:10 +00:00
Evrim CicekandClaude Opus 5.5 f7301189ab test_world_input_capture: expect Windows' extra warp before the grab
#6126 warps the pointer before the Locked grab on Windows (#6076), so a
focused grab warps twice there. The fixture still expected one warp and
failed on Windows (CI's Linux run kept the old count). It now expects two
on Windows, one elsewhere, which also pins #6126's warp.

Fixes #6214

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 21:09:39 +03:00
JoansitohandClaude Sonnet 5.5 f5161d9eba Resolve the local player's mount and test the worn head's real vehicle lookup
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 19:07:38 +01:00
MSedgeMC e35d09582c Make a bat flap the sculk sensors while it is awake
Entity.processFlappingMovement emits a flap game event when the entity flaps over air;
Bee and Bat each override isFlapping, a bee every other tick while flying and a bat every
tenth tick while it is not resting. Horizon only modelled the bee, so a bat flying around
was silent to sculk sensors and a warden.
2026-11-01 18:07:37 +00:00
levzzzandClaude Opus 5.5 ba04f50325 Native mods: the client's level made and dropped (LevelEvent on the client)
native_client_level_load when the client makes a level (joining a server, a
dimension change), native_client_level_unload when it drops one (replaced,
told after the new one's load as Minecraft.setLevel does, or leaving the
server). The client half of #6154.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 23:06:24 +05:00
Ludovic MarieandClaude Opus 5.5 3dceebf36b Build horizon-ctl and the client examples from horizon-client-lib
old_world_e2e asked horizon-client-lib for the horizon-client binary, which
only horizon-client has; three e2e docstrings, the agent-control guide and
xtask's comment still built horizon-ctl from horizon-client.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 19:06:12 +01:00
some-du6e e23b8c8eee Merge remote-tracking branch 'origin/main' into optifine/ctm-draw
# Conflicts:
#	crates/horizon-client-render/src/mesher.rs
2026-11-01 12:06:11 -06:00
JoansitohandClaude Sonnet 5.5 c61ce6401e Apply the baby walk scale to a worn head's vehicle position
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 19:05:30 +01:00
JoansitohandClaude Sonnet 5.5 b0be23cd59 Animate worn Ender Dragon heads like vanilla's wornHeadAnimationPos
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 19:05:30 +01:00
Ludovic MarieandClaude Opus 5.5 0ae22962bc Cargo.lock: horizon-client-lib depends on horizon-optifine
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 19:05:09 +01:00
Alexander KrylovandHermes Agent 2d1d81794b Roadmap: the benchmark item's example issue is not one of its links
Co-Authored-By: Hermes Agent (Nous Research) <[email protected]>
2026-11-01 21:04:18 +03:00
Ludovic Marie aea589b3b9 Merge main into the client library split 2026-11-01 19:03:54 +01:00
Ludovic MarieandClaude Opus 5.5 a1e81ddf66 Merge the author's main merge (8421d34) into the client library split
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 19:03:54 +01:00
MSedgeMC 9685f3a127 Refuse the lead while a nautilus is angry
Nautilus.canBeLeashed and ZombieNautilus.canBeLeashed return false while
AbstractNautilus.isAggravated, which is the brain holding ANGRY_AT or ATTACK_TARGET.
Horizon let a nautilus be leashed at any time, so a lead could drag off a nautilus in
the middle of a charge.
2026-11-01 18:03:41 +00:00
[email protected]andClaude Opus 5.5 5d08602df3 Mod registry: refresh its Cargo.lock for horizon-mods' png dependency
The registry is its own workspace, and its workflow runs on any change under
crates/horizon-mods. Its lockfile on main lacks png (and fdeflate, miniz_oxide),
which horizon-mods now needs, so `cargo clippy --locked` refuses it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 19:03:23 +01:00
Ludovic MarieandClaude Opus 5.5 dea8e3b54c Merge main into the client library split
Ports main's lib.rs (block_edit, cit, lod_net, ram_limit, social_screen) into
client_lib.rs, main.rs's --player-dir logging and ram_limit into entry.rs, and
the horizon-optifine dependency into horizon-client-lib.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 19:02:57 +01:00
Alexander KrylovandHermes Agent 152617ece7 Player data: a failed save is logged instead of dropped
Co-Authored-By: Hermes Agent (Nous Research) <[email protected]>
2026-11-01 21:02:51 +03:00
Ludovic Marie 1bd24e4042 Merge pull request #6151 from horizon-rust/fix/xp-orb-command-exec
🤖 Agent: test-only fix for command_exec on main: the boxed-in orb case (#5646) depended on vanilla's unseeded orb random; a stone ring keeps the top the only free side. verify + parity (command_exec PASS on the merge) green.
2026-11-01 19:02:25 +01:00
MSedgeMC 63d612913b Name the trading closure instead of splitting it 2026-11-01 18:02:20 +00:00
MSedgeMC f58c0ae1bf Fit the new match arm on one line 2026-11-01 18:02:19 +00:00
MSedgeMC 802ab62f2b Save a wandering trader's visit timer and target
WanderingTrader.addAdditionalSaveData writes DespawnDelay and the wander target; Horizon
wrote neither, so a trader reloaded mid-visit forgot its countdown and started over, and
came back with nothing to walk to.
2026-11-01 18:01:12 +00:00
MSedgeMC 4cc8041d8b Keep a villager quiet in bed and loud at the trade table
Villager.getAmbientSound returns null while the villager sleeps and the trade sound
while a player has its menu open. Horizon always played the plain ambient, so a sleeping
villager talked in its bed and a villager in a trade screen never made the trade sound.
2026-11-01 18:01:07 +00:00
Ludovic Marie 343d6f96f3 Merge pull request #6137 from xFedeT/fix/sulfur-extra-knockback
🤖 Agent: Sulfur cube extra knockback; main merged in, #6146's resistance test now passes launch's effect scale (1.0).
2026-11-01 19:00:08 +01:00
Ludovic Marie accbe7865e Merge pull request #6155 from horizon-rust/fix/native-build-ascii-root
🤖 Agent: fixes #6009, native builds use a real ASCII folder in ProgramData instead of the 8.3 short name that rustc expands back to the Cyrillic path; Cyrillic unit test, clippy for x86_64-pc-windows-gnu and pr_ready --quick green. Not run on a real Windows machine.
2026-11-01 19:00:04 +01:00
Ludovic Marie a58ee066ec Merge pull request #6141 from horizon-rust/fix/launcher-github-rate-limit
🤖 Agent: launcher release reads survive GitHub's anonymous rate limit (5-min disk cache, ETag, Kira mirror fallback, clear error); launcher fmt/clippy/tests + pr_ready --quick green, live-tested in isolated dirs. The mirror answers once horizon-rust/kira#109 is deployed.
2026-11-01 19:00:00 +01:00
Evrim CicekandClaude Opus 5.5 89bd321b4a horizon-friends: the deadline test reads after the limit, so it ends on Windows
The test blocked in a read with no timeout and waited for the deadline's
shutdown to wake it. Linux wakes a blocked reader on shutdown; Windows does
not, so the test never finished there (#6183). It now reads once the limit
has passed, and a new test checks the case the deadline exists for: a peer
that sends one byte at a time, under the read timeout the callers set.

Fixes #6183

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 20:57:25 +03:00
Ludovic Marie 9f683428a6 Merge pull request #6142 from Qwerner88880/hooks-loot
🤖 Agent: Requeued with main merged in (the branch predated #6143's test fix).
2026-11-01 18:57:17 +01:00
Ludovic MarieandClaude Opus 5.5 3c2fca6a60 Merge main into #6137 (#6146's resistance test passes launch's new scale)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 18:57:12 +01:00
MSedgeMC 177d2c9f55 Test the sturdy up face when placing evoker fangs
EvokerAttackSpellGoal.createSpellEntity walks down from the top of the target until it
finds a block whose up face is sturdy, which is BlockState.isFaceSturdy(.., UP) and not
"is a full cube". Horizon required a full collision shape, so on a soul sand, mud or
full snow layer floor the fang search skipped the block under the evoker and stood the
fang on that block's own collision top, two sixteenths inside it.
2026-11-01 17:56:51 +00:00
Arslan ArmanulyandClaude Opus 5.5 e266d70800 Shader packs: centerDepthSmooth
depthtex0 at the screen's centre, smoothed with OptiFine's default centerDepthHalflife (1 tick):
a depth texture can only be copied whole, so a one-pixel pass loads the texel into a 1x1
R32Float target, copied into one of three small buffers and read back frames later without
waiting for the GPU (as timing.rs reads its timestamps). Packs focus depth of field with it;
it was zero.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 22:56:41 +05:00
Arslan ArmanulyandClaude Opus 5.5 7745fddd20 Shader packs: Iris's player status uniforms and the End flash
- currentPlayerHealth/Hunger/Air/Armor (fractions of their maximum) and maxPlayerHealth/
  Hunger/Air/Armor, -1 in creative and spectator where the shaders.properties reference says
  so; isSpectator, isRiding, feetInWater, inSwimmingAnimation, is_burning, is_hurt,
  is_invisible (spectators too), is_sneaking, is_sprinting, read as the HUD reads them.
- endFlashIntensity and endFlashPosition (view space, length 100): the End flash state that
  draws the flash sprite, 0 outside the End.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 22:56:41 +05:00
Arslan ArmanulyandClaude Opus 5.5 4fbbee3d13 Shader packs: the sky pass binds the pack's storage buffers
A sky program that reads the pack's SSBOs (Noble's gbuffers_textured, the fallback of
gbuffers_skytextured) was drawn without bind group 4: wgpu refused the frame ("expects a
BindGroup to be set at index 4") and the game panicked. The sky pass now binds them as the
other passes do.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 22:56:41 +05:00
Federico TaglianiandClaude Sonnet 5.5 aba7fe0ba0 A carrot or warped fungus on a stick swings only when the server boosts
FoodOnAStickItem.use passes on a client, and the server answers SUCCESS_SERVER (which swings the
hand) only when the mount can be boosted. The client counted both sticks as items whose use ends
the click, so every right click swung the hand, boost cooling down or not.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 18:56:25 +01:00
some-du6eandClaude Opus 5.5 2d5ee61a5c Name handleBlockEntityData in keep_client_state's doc
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 11:56:20 -06:00
Ludovic Marie 7311b1e178 Merge pull request #6135 from k3yh/fix/more-selectors
🤖 Agent: Requeued with main merged in (the branch predated #6143's test fix).
2026-11-01 18:55:28 +01:00
some-du6eandClaude Opus 5.5 a819b8f81c Test the beacon carry-over; drop the border filter
The update tag's carry-over of client state (a vault's and a trial
spawner's spin, a beacon's beam) is now keep_client_state, tested with a
real decoded beacon update tag: the beam stays, the powers and levels are
the tag's. Beacon::keep_beam and its test go.

The world border filter on the beacon tick goes to its own change: the
other remote block entity ticks skip the border too.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 11:53:16 -06:00
Ludovic MarieandClaude Opus 5.5 02bc0cad54 Keep each level's tracked entities with its level
LanHost.states, the entities a level's moves are checked against before its
tick describes them again (the anticheat's ground check, move collisions, the
prediction's excuses), was one buffer that every level pass refilled. On a
server with players in several dimensions, the Overworld's moves met the
Nether's or the End's entities: a player standing on a boat at sea had no boat
under them, and the ground check kicked and banned players for it on the
official server (claims at y 63.086, a floating boat's top).

The states now park with their level (Parked.lan_states, swapped in
enter_level), like the level's other entity state.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 18:53:09 +01:00
Federico TaglianiandClaude Sonnet 5.5 473941651f A spear stab sends its damage indicator hearts to the players the server serves
Player.damageStatsAndHearts sends DAMAGE_INDICATOR particles to the level. Player.attack's path
also queued them for the served players (the window of the server thread, LAN guests); the
stab's own copy only drew them on the server's side, so the window never saw them.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 18:52:29 +01:00
MSedgeMC c7286edfbe Give a zombie nautilus spawn egg its variant
ZombieNautilus.applyImplicitComponents applies the zombie_nautilus/variant component,
so a spawn egg or a bucket can pick the coral variant. Horizon applied the chicken, cow
and pig variants from the same components but had no arm for the nautilus, so the egg
always left the biome's temperate variant in place.
2026-11-01 17:51:51 +00:00
Arslan ArmanulyandClaude Opus 5.5 0d171868af Shader packs: five translation fixes (textureGatherOffsets, scalar dot, returns, bool uniforms)
- textureGatherOffsets (GLSL 4.60, 8.9.4), which naga lacks: a prelude helper gathers each
  footprint at P moved by its offset over the texture size, for float, int and uint samplers,
  a component and the depth compare form (Mellow's deferred1).
- dot(float, float) and normalize(float), GLSL genType overloads naga's vector-only built-ins
  lack: scalar overloads in the prelude unless the pack defines its own.
- discard and return guarded in braces, `{ if (true) discard; }`: the old `if (true) discard`
  took the pack's `else` (`if (c) discard; else x = 1.0;` lost its else). A return with a value
  followed by code in its block is guarded too (Miniature's hand program failed validation).
- A returned value converts to the function's type (`return 1;` in a float function), as GLSL
  does and naga does not: 73 more composite programs translate over 40 packs.
- A parameter or local named like a bool uniform hides it in its function instead of becoming
  `(hz_bool_name != 0)` in a declaration (Pixel Perfect's fogify).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 22:51:44 +05:00
MSedgeMC 3217c6759b Read the difficulty for a drowned's trident spread
RangedAttackMob.rangedAttackUncertainty is 14 - difficulty.getId() * 4, so a drowned
throws straighter the harder the world is. Horizon hard-coded the normal-difficulty 6,
which made every drowned as accurate as a hard-mode one on easy and peaceful.
2026-11-01 17:50:43 +00:00
Ludovic MarieandClaude Opus 5.5 082ad987a6 Merge main into #6142 (the mod block entity test fix, #6143)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 18:49:52 +01:00
Ludovic MarieandClaude Opus 5.5 4aaba7056d Merge main into #6137 (the mod block entity test fix, #6143)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 18:49:27 +01:00
MSedgeMC 097b9781ad Let a creeper drop by its own health while it is chasing
Creeper.getMaxFallDistance is
getTarget() == null ? getComfortableFallDistance(0) : getComfortableFallDistance(getHealth() - 1),
so a creeper that has a target accepts a drop as deep as its health allows. Horizon only
had Mob's shared health-minus-a-third formula, so a creeper would not path down a drop
that vanilla walks off.

The same helper is now used for the base case too: getComfortableFallDistance is
Mth.floor(f + 3.0F), which is what the hard-coded "+ 3" was.
2026-11-01 17:49:26 +00:00
Ludovic MarieandClaude Opus 5.5 e61a51dd3b Merge main into #6135 (the mod block entity test fix, #6143)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 18:48:52 +01:00
Federico TaglianiandClaude Sonnet 5.5 ce02c03296 Linux on ARM: name the CPU from its implementer and part
An arm64 kernel prints no model name in /proc/cpuinfo, only CPU implementer and CPU part, so
telemetry sent an empty cpu, the benchmark report had no hardware.cpu (and the server then
refused it), and the crash report said aarch64. linux_cpu_name reads model name, else maps the
implementer and part as lscpu does (ARM Neoverse-N1, Apple, Ampere...), else the board's Model.
The three readers share it.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 18:48:10 +01:00
some-du6eandClaude Opus 5.5 7359afe6e1 Keep a server client's beacon beam through update tags
A host sends a beacon's update tag when a block there changes or its
powers are set, and the client replaced the whole block entity with each
one: its beam sections were empty until the next tick walked them again,
so the beam blinked out on every update. BeaconBlockEntity.loadAdditional
reads only the powers; the beam the client walked now stays, as a
vault's spin does.

The client's beacon tick also follows LevelChunk.isTicking: only inside
the world border.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 11:48:09 -06:00
Ludovic MarieandClaude Opus 5.5 6a6e34e1ec Launcher: release lists past GitHub's rate limit (disk cache, Kira mirror)
Players behind a shared address spend GitHub's 60 anonymous API calls an
hour together and then can't install or update ("GitHub's rate limit was
reached"). The launcher's API reads (release list, launcher tags, commits)
now go through launcher/src/github.rs:

- each answer is saved in <data>/cache/github/ and used again for 5 minutes
  without asking (three starts in a row spend one call, measured), then
  revalidated with its ETag (an anonymous 304 still counts, measured; it
  saves the download);
- a 429, or a 403 with GitHub's rate-limit headers, or no connection: Horizon's
  own repository is read through Kira's read-only mirror
  (mcp.playhorizon.dev/github/public, kira#109);
- both failing: the last saved answer, else an error naming both.

A plain 403 is no longer called a rate limit. Release files still come from
their browser_download_url (github.com -> release-assets.githubusercontent.com),
which the API limit doesn't cover.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 18:46:42 +01:00
[email protected]andClaude Opus 5.5 576fb98696 fix(mods): refuse a declared state property that sorts before the template's
A content block's states are its template's states times the declared
combinations, the template's properties varying slowest. StateTable::new
reads a state table as vanilla's StateDefinition does, sorted by property
name with the first name slowest, so a declared name that sorts before one of
the template's (`bites` before `facing` on carved_pumpkin) stopped the game at
load with "registry state order differs from vanilla's state definition".

Such a block is now refused when the plan is built, with its id and the
property to rename, so the mod's row says why and the game starts.

Fixes #6139

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 18:46:41 +01:00
Ludovic Marie 41501a243a Merge pull request #6119 from Sticky-Bug/feat/ender-dragon-hurt-flash
🤖 Agent: Dragon damage events reach clients (hurt flash, parts' ids); the hurt sound stays the server's; rustfmt; verify passes.
2026-11-01 18:46:31 +01:00
Ludovic Marie 7cb2a7c122 Merge pull request #6153 from xFedeT/fix/ctrl-word-navigation
🤖 Agent: Command block, block editors and debug options read Ctrl and Shift from either side (hasControlDown/hasShiftDown).
2026-11-01 18:46:25 +01:00
Ludovic Marie d2d3bda9c7 Merge pull request #6150 from Akirabane/feat/6108-gs4-query
🤖 Agent: horizon-server serves vanilla's GS4 query over UDP when enable-query is on (off by default, query.port).
2026-11-01 18:46:21 +01:00
Ludovic Marie 70fd95333e Merge pull request #6149 from breachato/fix/6122-orb-pickup-sound
🤖 Agent: Each experience orb taken plays its pickup sound at the orb (ClientPacketListener.handleTakeItemEntity).
2026-11-01 18:46:16 +01:00
Ludovic Marie 8a199ab3c2 Merge pull request #6146 from Joansitoh/fix/6113-sulfur-knockback-resistance
🤖 Agent: Sulfur cube knockback keeps negative knockback resistance (clamped to -2..1) as vanilla.
2026-11-01 18:46:11 +01:00
Ludovic Marie d4370c5ffa Merge pull request #6132 from Domanffe/fix/container-hotbar-keybinds
🤖 Agent: In containers the off hand and hotbar keys follow the controls (checkHotbarKeyPressed with the bound keys).
2026-11-01 18:46:07 +01:00
Ludovic Marie c5d7ff74fc Merge pull request #6130 from xFedeT/fix/mob-melee-knockback
🤖 Agent: A mob's melee knockback uses attack_knockback plus the weapon's, along its yaw (Mob.doHurtTarget), through the target's knockback resistance.
2026-11-01 18:46:03 +01:00
Ludovic Marie d2e0233e37 Merge pull request #6128 from masa-masa-0314/l10n/zh-complete
🤖 Agent: Chinese (zh_cn) strings for Horizon's newer pages.
2026-11-01 18:45:59 +01:00
Ludovic Marie c4a76f4cb7 Merge pull request #6127 from readerkid/fix/cauldron-prediction
🤖 Agent: A cauldron click on a server predicts the cauldron's reaction (CauldronInteraction), not the held bucket's own use.
2026-11-01 18:45:55 +01:00
Ludovic Marie dabcb464a3 Merge pull request #6126 from xFedeT/fix/windows-cursor-warp-before-grab
🤖 Agent: On Windows the pointer warps to the window's centre before the Locked grab (ClipCursor 1x1), so it is not left frozen elsewhere.
2026-11-01 18:45:49 +01:00
JoansitohandClaude Sonnet 5.5 b3384f9bd2 Share the layer icon resolve with its test; move GrassEnv above slot docs
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 18:45:05 +01:00
JoansitohandClaude Sonnet 5.5 80ad7fac9b Format flat.rs
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 18:45:05 +01:00
JoansitohandClaude Sonnet 5.5 d46f1a6831 Tint the Superflat layer grass block icon with the grass colormap
Fixes #6107

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 18:45:05 +01:00
Federico TaglianiandClaude Sonnet 5.5 5fe9fc298f A camel's pending dash waits for the ground instead of being dropped
AbstractHorse keeps a pending jump until the mount is on the ground (and not still jumping); only
Camel.onPlayerJump refuses it at the start (cooling down or off the ground). Horizon cleared the
rider's pending jump on every tick, dash or not, so a release on a tick the camel was not on
the ground (a bounce after landing, a slope) dashed sometimes and not other times.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 18:43:22 +01:00
[email protected]andClaude Opus 5.5 9697811a90 feat(mods): a content block's random_ticks property
A mod block only got random ticks when its template ticks, so a crop,
sapling or soil on a template that never ticks (dirt, stone) never grew.
`properties.random_ticks` is `true` or `false` for every state, or
`{ "property", "values" }` for the states whose own state property is one of
the values. It sets the state's `random_ticks` flag, which
`isRandomlyTicking`, the sections' random tick counts and
`native_random_tick` already read.

Fixes #6138

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 18:43:07 +01:00
levzzzandClaude Opus 5.5 c60b4c8d46 Native mods: a server's levels loading and unloading (LevelEvent)
native_level_load when a level opens, before its chunks; native_level_unload
once it has closed, after its last save: a level idle for 300 ticks, and every
level still open when the server stops or fails. Fixes #6154.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 22:37:20 +05:00
Ludovic MarieandClaude Opus 5.5 936453136f Native mods: build in a real ASCII folder, not a short name (#6009)
0.2.33 handed MinGW-w64 the data folder's short (8.3) name when the user
name is Cyrillic, and linking still failed: "No such file or directory" on
C:/Users/\xca\xe8... A short name is only another spelling of the same
folder, and rustc canonicalizes its own path to find its sysroot, which
spells the long name again for the linker.

build_dir now uses a real folder of ProgramData's
(ProgramData\horizon-launcher-<id>, one per data folder) whenever the data
folder's path isn't ASCII; the short name is kept only as the fallback when
that folder can't be created. A unit test covers a Cyrillic data folder.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 18:35:36 +01:00
Federico TaglianiandClaude Sonnet 5.5 77ede7fc60 Command block, structure/jigsaw block editors and debug options read Shift and Ctrl from the live modifiers
These screens asked self.keys for Ctrl and Shift, which only gameplay and the chat fill, so
Ctrl+arrows (word by word), Ctrl+Backspace and Shift+arrows (selection) did nothing in them.
They read InputWithModifiers.hasControlDown / hasShiftDown now, as dialogs and chat do (Cmd
instead of Ctrl on macOS, hasControlDownWithQuirk).

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 18:32:36 +01:00
Ludovic MarieandClaude Opus 5.5 e03987888d Command-exec parity: the boxed-in orb's outcome no longer depends on its random
The "experience orb stuck" case (#5646) assumed the push out of a block only
changed how many ticks the escape takes. It does not: once the orb's middle
rises past y 216 with its box still in the 3x3 block, Entity.moveTowardsClosestSpace
finds north, south, west and east free with the same 0.5 distance, picks north
(the first), and depending on the draws the orb slides off the block (z 23.25,
22.x, or down to y 215 on the floor). Vanilla's orb random is unseeded
(RandomSource.create()), so both sides land on different outcomes from run to
run: the harness failed on main with an orb at y 215 against vanilla's 216.

A ring of stone one block higher keeps the top the only free side all the way
out; the case still pins the push (without it the orb stays at y 215).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 18:30:05 +01:00
Ludovic MarieandClaude Opus 5.5 e0cb84d361 Merge main into #6119 (the mod block entity test fix)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 18:29:53 +01:00
Ludovic Marie 975c07d915 Merge pull request #6106 from horizon-rust/feat/native-inventories
🤖 Agent: queued for merge: pr_ready green on 6deec70 (block above); native helpers for the SMP's /invsee and market (fragment, numbered at stamping).
2026-11-01 18:29:11 +01:00
JoansitohandClaude Sonnet 5.5 d73a40748d Sulfur cube: compute the player push factor in double, with a tested helper
Part of #6113

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 18:27:59 +01:00
JoansitohandClaude Sonnet 5.5 2bdc10aa27 Sulfur cube: keep negative knockback resistance (-2..1) like vanilla
Part of #6113

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 18:27:58 +01:00
Ludovic Marie f1900a0b1a Merge pull request #6143 from horizon-rust/fix/mod-be-test-has-mods
🤖 Agent: Fixes main's verify: the mod block entity test's stub gets BlockEntities::has_mods (#6117). Verified: the test passes locally.
2026-11-01 18:27:53 +01:00
Ludovic Marie 5afd7d35cd Merge pull request #6123 from breachato/fix/5773-glider-components
🤖 Agent: Gliding asks canGlideUsing for every slot: a stack's own glider and equippable components (or a server's patch) count; main's wear kept; no allocation for stacks without components.
2026-11-01 18:26:52 +01:00
Ludovic MarieandClaude Opus 5.5 830cdd2647 Mod block entity test: the stub has has_mods, as BlockEntities does since #6117
has_mod_block_entity asks BlockEntities::has_mods first; the fixture's stub
lacked it, so the Python test (and verify on every PR) failed on main.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 18:25:57 +01:00
some-du6eandClaude Opus 5.5 88cde780fb Resource packs: decode block sprites and OptiFine tiles on every thread
Block model baking and connected textures added their textures one at a time,
reading and decoding each PNG in turn. They now hand the whole list to
Sprites::prefetch first, which decodes it with par_map, then insert in their
own order as before.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 11:24:28 -06:00
breachatoandClaude Opus 5.5 841a0adca0 Taking an experience orb always plays its pickup sound, at the orb
ClientPacketListener.handleTakeItemEntity plays entity.experience_orb.pickup
at the orb for every orb taken; the server sends no sound (ExperienceOrb
.playerTouch plays none). On a server Horizon played nothing, expecting the
server's sound, so collecting a furnace's experience was silent. In a local
world it played once at the eyes, and not at all when mending took the
points. Each orb taken now sounds at the orb in both.

Fixes #6122

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 18:24:17 +01:00
some-du6eandClaude Opus 5.5 e0eb57399b Pack bench: a bench the base can't build says so
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 11:23:58 -06:00
Ludovic Marie f081099e51 Merge pull request #5905 from reservedbytes/feat/native-item-use-tick-release
🤖 Agent: verified with main + #5866 + #5905: pr_ready --quick green (1756 tests), 18 item-use and menu harnesses pass on the 26.3 jars.
2026-11-01 18:23:34 +01:00
Ludovic Marie e576d02cd1 Merge pull request #5866 from reservedbytes/feat/mod-menu-engine
🤖 Agent: verified with main + #5866 + #5905: pr_ready --quick green (1756 tests), 18 item-use and menu harnesses pass on the 26.3 jars.
2026-11-01 18:23:30 +01:00
Federico TaglianiandClaude Sonnet 5.5 f27a64e873 A sulfur cube with a block takes a blow's extra knockback through SulfurCube.knockback
SulfurCube.hurtServer answers true for the blows it only knocks about, so Player.attack goes on
with causeExtraKnockback (sprint, the Knockback enchantment), which reaches the cube's own
knockback with comesFromEffect: the launch scaled by power * 0.25 on top of sqrt(damage).
Horizon answered false, so attack_mob skipped the whole hit's aftermath, and the extra push
went through the generic Mob::knockback. The answer is now true for the tag, and the player's
extra push goes through Mob::knockback_effect.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 18:21:30 +01:00
some-du6eandClaude Opus 5.5 3ffb7b0663 Resource packs: build a big pack's overlay on every thread
The overlay walk looked at every overridden file once per folder it visited, and
inflated and wrote a zip pack's files one at a time.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 11:18:41 -06:00
some-du6eandClaude Opus 5.5 6bd9098e3b Resource packs: a benchmark of loading big packs, on generated files
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 11:16:23 -06:00
Ludovic Marie 1e02316c27 Merge pull request #5940 from TiggerTakkyTTT/fix/server-management-settings
🤖 Agent: MOTD also stored in server.properties and server.toml as vanilla's DedicatedServer.setMotd; main merged (difficulty kept); fmt, clippy, horizon-server tests, management_protocol + dedicated_server harnesses pass at 87e8e3122.
2026-11-01 18:16:18 +01:00
breachatoandClaude Opus 5.5 cbb7efe894 Spawn eggs used on vines spawn the mob in the vines
SpawnEggItem.useOn spawns in the clicked block itself when its collision
shape is empty (vines, grass, flowers) and only otherwise in the block on the
clicked face. Horizon always took the face's block, so an egg used on vines
put the mob a block out in the air, where it fell instead of climbing.

Fixes #6125

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 18:15:41 +01:00
Akirabane 153dca4120 Merge remote-tracking branch 'origin/main' into feat/6108-gs4-query 2026-11-01 18:15:10 +01:00
Ludovic Marie d01e5a10f7 Merge pull request #6120 from josegil1909/docs/agents-i18n-rule
🤖 Agent: AGENTS.md: user-facing strings go through the translation helpers.
2026-11-01 18:14:48 +01:00
Ludovic Marie 9acb5c683f Merge pull request #6118 from Joansitoh/fix/arrow-deflect-wind-charge
🤖 Agent: Arrows hit wind charges and deflect off them (Projectile.isPickable, deflect REVERSE 0.2), no extra cost without wind charges.
2026-11-01 18:14:45 +01:00
Ludovic Marie 83b55c8782 Merge pull request #6117 from Xmilon/native/mod-block-entity-any-template
🤖 Agent: Native block entities on any template; per-chunk list for drawing, and block changes look one up only once one exists (no cost without mods).
2026-11-01 18:14:40 +01:00
Ludovic Marie dd1ba6ea31 Merge pull request #6115 from josegil1909/fix/launcher-servers-and-layout-i18n
🤖 Agent: Launcher Servers tab strings and buttons go through tr/trf with translations; layout fixes.
2026-11-01 18:14:36 +01:00
Ludovic Marie b69ff595d2 Merge pull request #6114 from 4404c/secure-chat/confirm-command
🤖 Agent: Commands the player didn't type go through ClientPacketListener.sendUnattendedCommand's checks: confirm on parse errors or elevated permissions, never send a message argument unseen.
2026-11-01 18:14:32 +01:00
Ludovic Marie 050a5de2c2 Merge pull request #6102 from breachato/fix/5858-turtle-egg-trample
🤖 Agent: A player trampling turtle eggs sends the egg's break sound and particles (decreaseEggs level events), heard by guests too.
2026-11-01 18:14:27 +01:00
Ludovic MarieandClaude Opus 5.5 dc77793cdd rustfmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 18:14:26 +01:00
Ludovic Marie 1225f2ec53 Merge pull request #6101 from Domanffe/fix/windows-version-from-registry
🤖 Agent: Telemetry and crash reports read the Windows build from the registry instead of the localized cmd /C ver.
2026-11-01 18:14:23 +01:00
Ludovic Marie ca6f6978bd Merge pull request #6100 from HrafnaValkyrja/fix/lava-and-rose
🤖 Agent: Fire and lava hit mobs after baseTick's timers (applyEffectsFromBlocks in aiStep), effects tick after them, and wither roses use the world difficulty.
2026-11-01 18:14:17 +01:00
Ludovic MarieandClaude Opus 5.5 06c4662593 Creaking hearts: scan only the chunks that arrived, not every chunk each tick
scan_for_hearts read every resident chunk each tick (is it lit, was it
scanned) and then checked every scanned key against the chunk map: with
the 17,000 chunks of a 54-player server that is two hash lookups and a
cache miss per chunk, every tick. The scan now follows the chunk map's
membership changes: a chunk that arrives waits in a small set until it
is lit and scanned, one that leaves is forgotten. A chunk gone and back
between two ticks stays scanned, as before; a map that can't replay its
changes (cleared, or too many of them) is rebuilt from its keys. Ready
chunks are scanned in key order.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 18:13:19 +01:00
Ludovic MarieandClaude Opus 5.5 40b712232e Merge main into #6123: gliding_pieces (main's wear and armor walk) asks can_glide_using, the hands count too; a stack without components answers from its item
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 18:11:46 +01:00
k3yhandClaude Opus 5.5 0d342f6c72 Selectors find falling blocks and thrown items
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 12:11:36 -05:00
AkirabaneandClaude Sonnet 5.5 bfb5b219ff horizon-server: GS4 query, vanilla's UDP status port (enable-query, query.port)
The supervisor serves vanilla's GS4 query as QueryThreadGs4 does: the handshake (type 9, a token
below 2^24 as text), then the basic and the full stat to a request that carries the token, each
byte for byte what a vanilla 26.3 server sends for the same settings (checked on the wire). The
players come from the game's server-status, read at most once a second; the full stat is kept
5 seconds as in vanilla. Off by default; an invalid port leaves it off with vanilla's warning.

Refs #6108

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 18:11:14 +01:00
Alexander KrylovandHermes Agent 4842287372 Inventory: the swap-offhand and hotbar keys follow the controls
Co-Authored-By: Hermes Agent (Nous Research) <[email protected]>
2026-11-01 20:10:38 +03:00
Federico TaglianiandClaude Sonnet 5.5 914c15d349 A mob's melee push is LivingEntity.knockback along its facing, with attack_knockback
Mob.doHurtTarget calls causeExtraKnockback(target, getKnockback): (attack_knockback attribute plus
the Knockback enchantment) / 2, pushed along the attacker's yaw through LivingEntity.knockback,
which scales it by 1 - the target's knockback resistance, and slows the attacker by 0.6. Horizon
added a fixed velocity along the line to the target, with no resistance, no slowdown and no
attack_knockback. The attribute is now read (type default, or the mob's own from NBT).

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 18:08:46 +01:00
Ludovic MarieandClaude Opus 5.5 feee25c88c Mod block entities: a block change looks one up only once one exists
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 18:07:54 +01:00
Ludovic MarieandClaude Opus 5.5 6deec70473 Server stacks carry their lore to clients
A stack's minecraft:lore (as loaded, or as a server mod wrote it into
StackData.other) is now sent in its component patch, so a native mod's
menu items show lines under their name (prices, changes) in a stock
client.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 18:07:34 +01:00
[email protected]andClaude Sonnet 5.5 f2b9a3c6d4 perf(mods): per-chunk list of mod block entities for the renderer
BlockEntities keeps the positions of its Mod block entities by chunk, so the
renderer reads only those within view distance instead of scanning every
loaded block entity each frame.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 18:07:09 +01:00
NerfPhaselockandClaude Opus 5.5 18ac513e8c Renderer: bind the large translucent pages up to their last range in use
wgpu clears what a vertex or index binding covers that nothing has written
yet, on the GPU, before the pass runs. Draws bound Translucent Batching's
large pages whole, so a page that had just grown had its new tail, up to
256 MiB, cleared in the next frame. Arena::binding gives a page's buffer up
to the end of its last range in use (a grain at least: wgpu refuses an empty
slice), and draw_sections binds the translucent arena's and the sorted
indices' pages through it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 12:07:04 -05:00
Ludovic Marie 3adcfcbb36 Merge pull request #6093 from breachato/fix/6028-tab-offhand-in-containers
🤖 Agent: A container screen still checks its hotbar keys after Tab or an arrow moves the focus (AbstractContainerScreen.keyPressed), so Tab bound to the off hand swaps.
2026-11-01 18:04:44 +01:00
masa-masa-0314andGLM 5.3 f53460278d Horizon strings: the Chinese bundle catches up with the new pages
The splitscreen, friends, extras, HDR calibration, on-screen keyboard and
splitscreen-profile keys landed in en_us after zh_cn's last pass; a
Chinese player saw English on those pages. The bundle is complete again
(308 keys of 308), with the terms the earlier translations set: 分屏,
档案 for a profile, 与好友一起玩 for Play with Friends.

Co-Authored-By: GLM 5.3 (ZCode) <[email protected]>
2026-11-02 01:03:01 +08:00
Ludovic MarieandClaude Opus 5.5 7dbda4f4f5 Dragon damage event: the hurt flash only; the hurt sound stays the server's
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 18:02:18 +01:00
Federico TaglianiandClaude Sonnet 5.5 97a3f78a3e Windows: warp the pointer to the window's centre before the Locked grab
winit's Locked on Windows is ClipCursor to a 1x1 rectangle at the pointer's current spot, and
SetCursorPos stays inside the clip, so the warp that followed the grab could not move it: the
pointer froze wherever it was (over another window or monitor). InputConstants.grabMouse warps to
the centre first; macOS already did, and now Windows does too.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 18:01:12 +01:00
Ludovic Marie c86e861455 Merge pull request #5696 from spidercraft/fsr-frame-generation-setting
🤖 Agent: main merged again (submit thread, leaf and entity culling). With frame generation on, a frame is submitted in place so its present thread presents it, and the hand stays out of the world pass so it draws over the real and the made frame; frameGeneration sits beside leafCulling in the settings format. verify green on 39737da; on playhorizon cargo check -p horizon-client --all-targets and the graphics (33), frame_gen (9) and gfx (176) tests pass. Thanks @spidercraft.
2026-11-01 17:59:39 +01:00
Ludovic Marie 11427c2960 Merge pull request #6097 from Akirabane/fix/6066-6067-command-block-feedback
🤖 Agent: Command block and command minecart admin lines go to operators through broadcastToAdmins (host and console included); Done's confirmation reaches only the editing player.
2026-11-01 17:55:43 +01:00
Ludovic Marie 1600ce6fa9 Merge pull request #6096 from k3yh/fix/tnt-selectors
🤖 Agent: Primed TNT is an entity for selectors, /tag, /data, /tp and /kill; it gets its entity id when primed.
2026-11-01 17:55:39 +01:00
Ludovic Marie 9ddc5e7498 Merge pull request #6095 from xFedeT/fix/singleplayer-owner
🤖 Agent: A world without singleplayer_uuid gives its owner their own (or a new) player file, never the newest other player's (PlayerList.loadPlayerData).
2026-11-01 17:55:35 +01:00
Ludovic Marie ff518b90c1 Merge pull request #6090 from HrafnaValkyrja/fix/mob-effects
🤖 Agent: Mobs act on instant health/damage, Resistance, Absorption (saved as AbsorptionAmount) and Slow Falling, as LivingEntity does.
2026-11-01 17:55:31 +01:00
Ludovic Marie 46ba16b36a Merge pull request #6089 from Xmilon/native/xp-orb-merge
🤖 Agent: Native hooks experience_orb_can_merge / experience_orb_merge around ExperienceOrb.scanForMerges and the spawn merge; no-op stubs without native mods.
2026-11-01 17:55:27 +01:00
Ludovic Marie 74672ea0e9 Merge pull request #6103 from horizon-rust/perf/lazy-block-tags
🤖 Agent: measured on playhorizon (alternated, THP off): idle anonymous −4.2 MB on every start; Done and 20-bot MSPT unchanged (3 rounds). pr_ready ✅ (368 horizon-world tests); clippy -D warnings and fmt clean on the Mac.
2026-11-01 17:55:23 +01:00
Ludovic Marie ac18068674 Merge pull request #5859 from doodlyyousuf/fix/5720-atlas-custom-directory
🤖 Agent: Review by Ludovic's AI agent (Claude). Extra atlas folders from blocks.json are packed (one walked list, 42e6769). Verify green. The parity failure was the runner's dead .horizon-prepare.lock link (2 s, before any harness), not this change; parity rerun.
2026-11-01 17:55:19 +01:00
Ludovic Marie ab0c403b75 Merge pull request #5737 from Stink-O/native/read-columns
🤖 Agent: Review by Ludovic's AI agent (Claude). Native column and surface-height reads on #5735, bounded by MAX_READ. Verify green on the whole stack. The parity failure was the runner's dead .horizon-prepare.lock link (2 s, before any harness), not this change; parity rerun.
2026-11-01 17:55:15 +01:00
Ludovic Marie 816ebe9925 Merge pull request #5735 from Stink-O/native/edit-blocks
🤖 Agent: Review by Ludovic's AI agent (Claude). Native bulk block edit API on #5733 (merged); each new block gets its own block entity (65ef100, tested). Verify green. The parity failure was the runner's dead .horizon-prepare.lock link (2 s, before any harness), not this change; parity rerun.
2026-11-01 17:55:11 +01:00
Jose Gil 196076a67c docs: require translation-ready UI strings in AGENTS.md 2026-11-01 12:54:11 -04:00
JoansitohandClaude Sonnet 5.5 f25c0dd45a Arrows bounce off a wind charge instead of vanishing
AbstractArrow.onHitEntity: the charge cannot be hurt, so the arrow takes
deflect(REVERSE, 0.2) and falls. The harness now also checks the arrow is
still in the world a second after the shot.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 17:53:22 +01:00
JoansitohandClaude Sonnet 5.5 f97f250f7e Arrows strike wind charges and turn them along the shooter's look
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 17:53:22 +01:00
[email protected]andClaude Sonnet 5.5 7abfbbea71 feat(mods): native block entities on any template
new_block_entity no longer needs the block's template to have a block entity.
The level asks the game whether a mod block entity lives at a position
(LevelAccess::has_mod_block_entity) to remove it with its block and to keep
pistons from moving it, and the renderer lists mod block entities the
template's flag leaves out.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 17:53:20 +01:00
Sticky-BugandAntigravity ddd0860591 Broadcast damage event when Ender Dragon is hurt (#5999)
Co-Authored-By: Antigravity <[email protected]>
2026-11-01 22:23:13 +05:30
Jose Gil d799220b1f feat(launcher): localize Servers tabs, action buttons, and layout fixes 2026-11-01 12:52:06 -04:00
Ludovic Marie 58eaf93f13 Merge pull request #6086 from HrafnaValkyrja/fix/minecart-mob-rider
🤖 Agent: A cart with any passenger (mob, guest or the player) uses the occupied slowdown and speed scale (AbstractMinecart.getSlowdownFactor keys on isVehicle).
2026-11-01 17:50:16 +01:00
Ludovic Marie e076162fb3 Merge pull request #5811 from ASOwnerYT/fix/cauldron-bucket
🤖 Agent: Review by Ludovic's AI agent (Claude). A bucket used on a cauldron fills or empties the cauldron only, as vanilla's CauldronInteraction consumes the use; kacpherek found nothing more. Main merged in (lan_e2e check list: both sides kept, cauldron included). Verify green.
2026-11-01 17:50:11 +01:00
Ludovic Marie 9b391d7c80 Merge pull request #5645 from mariuslozo-boop/i18n/horizon-strings-c
🤖 Agent: #5644 merged in. pl_pl and uk_ua merged key by key with main's newer shader-option and split-profile strings (main's wording kept where both had one). All 9 lang files valid, no repeated keys, no key en_us lacks, placeholders match. On playhorizon: cargo check -p horizon-client --all-targets, lang/extras/options_ui tests pass. verify green on 8726619. Thanks @mariuslozo-boop.
2026-11-01 17:50:06 +01:00
Ludovic Marie eea324d489 Merge pull request #5644 from mariuslozo-boop/i18n/horizon-strings-b
🤖 Agent: #5643 (main merged) merged in; en_us.json union, valid JSON, no repeated keys.
2026-11-01 17:50:02 +01:00
Ludovic Marie 983eec0256 Merge pull request #5643 from mariuslozo-boop/i18n/horizon-strings-a
🤖 Agent: main merged again: its new Chat rows in Extras (Smooth chat, Message indicator and their header) read the language table too, en_us keys added. verify green on 79b92a3.
2026-11-01 17:49:58 +01:00
Ludovic Marie 88d3609268 Merge pull request #5708 from navinor/fix/attack-strength-on-attack
🤖 Agent: Review by Ludovic's AI agent (Claude). Attacking a boat, minecart or armor stand resets the attack strength, as Player.attack does for every entity. Main merged in; verify green.
2026-11-01 17:49:54 +01:00
Ludovic MarieandClaude Opus 5.5 d1bc26f7af Native mods: other players' inventories and drawn maps (fragment)
native_inventory(uuid, kind) reads a player's inventory (Player: 41 slots,
main + armour + off hand; Ender: 27), live when they are on the server,
else from players/data/<uuid>.dat; never while they log in, are dead or
swapped in. native_move_stack moves one stack, checked against what the
mod showed, where it fits whole, or nothing moves; at most one side is a
file, written before the live side changes. native_send_map sends a
128x128 map picture to a player. For the official server's /invsee,
/endersee and the market's price charts.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 17:49:47 +01:00
Alexander KrylovandHermes Agent f3b1922606 Telemetry: the Windows version comes from the registry, not cmd /C ver
Co-Authored-By: Hermes Agent (Nous Research) <[email protected]>
2026-11-01 19:48:15 +03:00
HrafnaValkyrjaandClaude Opus 5.5 ea7c0c4856 Lava and fire hit mobs on time, and wither roses wither them
Lava and fire now apply after the mob's timers count down, as vanilla's order has it. The wither rose checks world difficulty and invulnerability only.

Fixes #6099

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018zuDTsnyHkP28bzn3u7S3m
2026-11-01 11:47:48 -05:00
NazaandClaude Opus 5.5 38c0c6dbaf horizon-ctl: the command confirmation takes the keys
`key esc` went past it and closed the chat behind, unlike the game's own
key handling, where Escape answers no.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 13:45:38 -03:00
k3yhandClaude Opus 5.5 2b01586564 Selectors find primed TNT
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 11:44:44 -05:00
Federico TaglianiandClaude Sonnet 5.5 be35f0cd18 A world without singleplayer_uuid gives its owner a new player, not another player's file
Level::read took the newest file of players/data when level.dat had no singleplayer_uuid, so
a world copied from a server gave the owner someone else's inventory and position, and the
owner's save then wrote into that player's file and named them as the world's owner.
PlayerList.loadPlayerData has no such lookup: the owner loads their own file, else starts new.
The fallback is gone, and save_owner names the owner's own profile in singleplayer_uuid
(MinecraftServer.saveAllChunks writes the integrated server's profile).

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

Fixes #6066
Fixes #6067

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 17:41:14 +01:00
Ludovic Marie 07c41af9db Merge pull request #6055 from JustKarkat/fix/ime-text-input
🤖 Agent: IME: the options searches, game rule boxes and block editor fields allow the input method, and the chat TextField reports its caret so the composition and candidate window show (TextInputManager.setTextInputArea).
2026-11-01 17:40:00 +01:00
Ludovic Marie 2691d2eb82 Merge pull request #6081 from doodlyyousuf/fix/5837-armor-stand-powder-snow
🤖 Agent: Leather-boot armor stands walk on powder snow; main merged in (main already lerps stands, its interpolation kept); verify passes.
2026-11-01 17:39:57 +01:00
Ludovic Marie 3dc0154745 Merge pull request #5733 from Stink-O/native/bulk-block-edits
🤖 Agent: Review by Ludovic's AI agent (Claude). Bulk block edits write whole sections; a resent column keeps its unchanged sections, takes the packet's light (arismoko rechecked it in game) and remeshes biome-only changes with all neighbours (kacpherek's point, aec1d41). Main merged in. Verify green.
2026-11-01 17:39:53 +01:00
NazaandClaude Opus 5.5 ad5de29f1a Clicked commands ask first, as sendUnattendedCommand does
A command the player didn't type went straight to the server. Now
verifyCommand decides: a parse error or a restricted command asks before
it goes, and one with a message argument only reaches the chat box, or
the clipboard over a screen, for the player to send. Refs #6080.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 13:37:25 -03:00
HrafnaValkyrjaandClaude Opus 5.5 e5d3a0ec6c Mobs feel instant health and damage, Resistance, Absorption and Slow Falling
The mob effect tick and damage path never read the effect list. They now do, matching the real server.

Fixes #6087

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

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 17:37:04 +01:00
huiandZCode AI agent (GLM-5.3) f7bff20187 Native mods: a loot table's function and condition of a mod's own kind
A loot table's function or condition that names a type in a namespace
outside minecraft: used to be silently skipped (functions) or failed
(conditions) at the roll. It now reaches free functions in loot.rs —
native_loot_function changes the stack, native_loot_condition says
whether it holds — which mixin bundles hook, as they do the game's own
hook points. The context brings the entity, the damage, the player who
killed it, the tool, the block, the luck, the biome and a seed from the
roll's random, drawn only when a mod's kind is in the table.

The tables the items engine rolls (block drops, entity drops,
loot_table, interact_loot) answer the condition hook with the tool and
block in the context; the function hook there follows when the stack
types converge. A bare type name is minecraft's own; an unclaimed kind
leaves the stack or fails the condition, as unknown kinds do.

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

Fixes #6085

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

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

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

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

Fixes #6077

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

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

# Conflicts:
#	crates/horizon-client/src/lib.rs
2026-11-01 17:13:40 +01:00
Ludovic Marie fcbfd000ee Merge pull request #5790 from kacpherek/fix/glider-any-slot
🤖 Agent: Review by Ludovic's AI agent (Claude). Dispensing an elytra onto a mob now picks the slot with the dispenser's own random, as vanilla's Util.getRandom does; the level random was used before (fixed on the branch). The earlier parity failure was command_exec (failing on main since #5646, experience orb case) plus lunge_motion (a known flake), neither touched here. Verify green on this head.
2026-11-01 17:12:57 +01:00
Ludovic Marie 6bfd219e30 Merge pull request #6059 from oskal675-design/fix/boat-ice-reboard-spin
🤖 Agent: Boat copy the local player does not drive drops its cached velocity and spin (AbstractBoat.tick); main merged in again (lan_e2e list).
2026-11-01 17:12:53 +01:00
Ludovic Marie 79e9d1b044 Merge pull request #6075 from xFedeT/fix/tnt-portal-travel
🤖 Agent: Primed TNT goes through nether portals like other entities (Entity.handlePortal), saved with its fuse and portal cooldown.
2026-11-01 17:12:49 +01:00
Ludovic Marie d6ce584b6a Merge pull request #6070 from HrafnaValkyrja/fix/explosions-push-all
🤖 Agent: Explosions break and push boats and push falling blocks (VehicleEntity.hurtServer, pushFromExplosion); a mob's blast spares vehicles with mob griefing off; wind charges take no push.
2026-11-01 17:12:45 +01:00
breachatoandClaude Opus 5.5 19dfc97138 A player trampling turtle eggs plays their break sound and particles
TurtleEggBlock.decreaseEggs plays block.turtle_egg.break at the block and
sends level event 2001 for the eggs left; the last egg goes through
Level.destroyBlock, which sends 2001 too. The player's landing events reached
the game, but its block sounds were dropped (only the two fall sounds were
played) and its level events ignored, so a trampled egg vanished silently.
Block sounds (the egg, a shelf mushroom's bounce) now play at the block for
the local player and LAN guests, and the level events go out.

Part of #5858 (the player trampling eggs).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 17:11:49 +01:00
Ludovic MarieandClaude Opus 5.5 516927b342 Merge main into #6059 (lan_e2e: boat_ice_reboard beside piglin_offspring)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 17:11:22 +01:00
Ludovic Marie 21936c6298 Merge pull request #6045 from HrafnaValkyrja/fix/wind-pushes-all
🤖 Agent: Wind bursts push orbs, falling blocks, boats and minecarts as vanilla; main merged in again (lan_e2e beside lightning-box).
2026-11-01 17:11:06 +01:00
Ludovic Marie 6d586e2b23 Merge pull request #5800 from 1038april/feat/ram-limit-game
🤖 Agent: Review by Ludovic's AI agent (Claude). Opt-in RAM limit; off, it costs one atomic store per frame. kacpherek's grace-period point is fixed and tested. I merged main again (ram_limit next to random_textures, render_distance_in_use next to the leaf-culling remesh). Verify green on this head.
2026-11-01 17:11:02 +01:00
Ludovic Marie 7101e9fd45 Merge pull request #6033 from elythepreciouscat/feat/async-entity-culling
🤖 Agent: Asynchronous entity culling mode, off by default; the main merge now builds (EntityCulling arm closed, one field in round_trips).
2026-11-01 17:10:58 +01:00
Ludovic MarieandClaude Opus 5.5 87266190f4 Merge #5644 (main merged) into #5645: main's Polish and Ukrainian shader-option strings kept
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 17:09:14 +01:00
Ludovic MarieandClaude Opus 5.5 f4283f7c82 Merge main into #6045 (lan_e2e: wind-bystanders beside lightning-box)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 17:08:55 +01:00
Ludovic MarieandClaude Opus 5.5 75efcb360a Merge #5643 (main merged) into #5644
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 17:08:50 +01:00
Federico TaglianiandClaude Sonnet 5.5 c25499d504 Primed TNT goes through nether portals
Entity.handlePortal applies to any entity that canUsePortal; primed TNT was not in the list of
what Horizon sends through (items, mobs, boats, minecarts). It now is: swept through the
portal box (0.98 across), saved as its tag (fuse, motion), and loaded at the exit with its
PortalCooldown, which the TNT loader now remembers so it does not go straight back. A TNT that
carries a mob stays.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 17:08:44 +01:00
Ludovic Marie 54cbbadbb7 Merge #5920's branch (main after #5900) into shader/vertex 2026-11-01 17:08:38 +01:00
Ludovic Marie 6472b1af4c Merge main into shader/material-blocks (after #5900) 2026-11-01 17:08:38 +01:00
Ludovic MarieandClaude Opus 5.5 79b92a3f8f Merge main into #5643: the new Chat rows read the language table
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 17:08:35 +01:00
Ludovic Marie c9d474dd91 Merge pull request #6053 from HrafnaValkyrja/fix/lightning-hits-all
🤖 Agent: Lightning tests every entity's box and destroys boats, carts and orbs as vanilla; main merged in (lan_e2e).
2026-11-01 17:07:56 +01:00
Ludovic Marie 308ef65272 Merge pull request #6064 from xFedeT/fix/tnt-minecart-collision
🤖 Agent: A minecart's move collides with the entities AbstractMinecart.canCollideWith accepts, so a TNT minecart explodes on another cart; costs nothing without carts.
2026-11-01 17:03:24 +01:00
Ludovic MarieandClaude Opus 5.5 3073eed985 Merge main into #5696: frame generation beside the submit thread
Frames with frame generation are submitted in place (its present thread
presents them), and the hand stays out of the world pass so it draws over
the real and the made frame.

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

Fixes #6069

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018zuDTsnyHkP28bzn3u7S3m
2026-11-01 11:02:12 -05:00
Ludovic Marie 5e59e2f4ff Merge pull request #5900 from Teiwazik/shader/surface
🤖 Agent: shader API stack after main's #6005 and #5926: fmt, clippy -D warnings, client-render + shader-api tests green on the stack's last head 31d53c8b4; offscreen pack proof earlier on the same features.
2026-11-01 17:01:06 +01:00
ElyTheCatandOpenAI Codex 5472e113b2 Fix graphics culling merge errors
Remove the stale boolean assignment from the graphics round-trip fixture and close the Entity Culling UI match arm.

Co-Authored-By: OpenAI Codex <[email protected]>
2026-11-01 19:28:46 +03:30
Ludovic MarieandClaude Opus 5.5 a4742538b2 Merge main into #5790
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 16:58:21 +01:00
Ludovic Marie f122a023af Merge pull request #6065 from horizon-rust/perf/world-prepare-overlap
🤖 Agent: measured on playhorizon (alternated, THP off, 2 series): Done −144 / −157 ms median, 11/11 starts faster; idle +1.2-2.5 MiB; 20-bot MSPT unchanged (5 rounds). pr_ready ✅ (1691 horizon-client tests); clippy -D warnings and fmt clean on the Mac.
2026-11-01 16:58:11 +01:00
Ludovic Marie 4bcfd21e9a Merge main into native item use tick and release
# Conflicts:
#	crates/horizon-client/src/game/remote.rs
2026-11-01 16:57:58 +01:00
Ludovic Marie df47724728 Merge pull request #5801 from 1038april/feat/ram-limit-launcher
🤖 Agent: Review by Ludovic's AI agent (Claude). Maximum memory row; default changed to no limit so big worlds and modpacks are never capped unasked. Launcher verify green on this head.
2026-11-01 16:57:39 +01:00
Ludovic Marie 1660c823e8 Merge pull request #5771 from masa-masa-0314/fix/shader-options-i18n
🤖 Agent: Review by Ludovic's AI agent (Claude). Translations of six existing Horizon strings in six languages; keys match en_us. I merged main; verify green.
2026-11-01 16:57:02 +01:00
Ludovic Marie 88ab49d369 Merge pull request #5717 from hasanbkaylan/patch-1
🤖 Agent: Review by Ludovic's AI agent (Claude). Turkish launcher strings only; checked the six fixes (typos, a stray Georgian letter). Verify green.
2026-11-01 16:56:53 +01:00
Ludovic MarieandClaude Opus 5.5 5501103883 Bind each feature block tag the first time it is read
FeatureBlockBindings::bind_tags built every block tag's state set up
front: vanilla's 300 block tags hold 406,779 states (blocks_motion and
mineable/pickaxe ~30,000 each), and world generation reads a few dozen of
them. BoundTags keeps each tag's block names and builds its set the first
time it is read (OnceLock); the blocks are still checked when the tags are
bound, so an unknown block fails as before and a later read cannot.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 16:56:35 +01:00
Ludovic MarieandClaude Opus 5.5 c2ea13fc7c Merge main into #6045 (lan_e2e: wind-bystanders beside boat_submerged)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 16:55:47 +01:00
Ludovic Marie 1f629b560d Merge main into #5800
Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	crates/horizon-client/src/game.rs
2026-11-01 16:55:26 +01:00
Ludovic MarieandClaude Opus 5.5 fee0fee89f Merge main into #6053 (lan_e2e: lightning-box beside boat_submerged)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 16:54:42 +01:00
Ludovic Marie d1e32b4cae Merge pull request #6049 from Xmilon/container-open-hook
🤖 Agent: Mod container viewer counts; new head: a spectator is not a viewer (BarrelBlockEntity.startOpen).
2026-11-01 16:54:08 +01:00
Ludovic Marie ceec693555 Merge pull request #6019 from Xmilon/native/screen-mouse-dragged
🤖 Agent: Screen mouse drag pre event; new head: any button release ends the drag as MouseHandler.onButton clears activeButton, main merged.
2026-11-01 16:54:02 +01:00
Ludovic Marie e42d07a271 Merge pull request #6060 from pikammmmm/fix/lazy-chunk-datafix
🤖 Agent: Older chunk, entity and POI records of a current world upgrade as they are read (SimpleRegionStorage.upgradeChunkTag), reusing the ported datafixes; old_chunk_bits still reads the raw record.
2026-11-01 16:53:58 +01:00
Ludovic Marie 16a40a720a Merge pull request #5918 from loicpiller/fix/5867-piglin-bartering-offhand
🤖 Agent: Baby piglin equipment and gold pickups as vanilla; main merged in again (lan_e2e: piglin_offspring beside blast-projectiles).
2026-11-01 16:53:52 +01:00
Ludovic Marie b6f5c163de Merge pull request #6037 from horizon-rust/fix/ac-sprint-restart
🤖 Agent: reviewed: the record takes the client's own START/STOP (vanilla: causeExtraKnockback stops the server's sprint, LocalPlayer.aiStep restarts it silently with the key held); a sprint kept without the key or a +0.05 speed still counts (tests); Horizon's toggle sprint reports the key as held like vanilla's ToggleKeyMapping. e2e: honest cow-hit sprint never flagged (8 flags before), cheat bot 13; pr_ready --quick green (1713 tests).
2026-11-01 16:53:46 +01:00
Ludovic Marie 1a575997d6 Merge pull request #5952 from xFedeT/fix/dedicated-crash-save
🤖 Agent: A panic in the dedicated server's tick (console, control socket and players' turns included) still saves the world; crash fixture gated so the tick pays one OnceLock load (fix pushed).
2026-11-01 16:53:41 +01:00
Ludovic Marie b42d248a0a Merge pull request #6029 from oskal675-design/fix/submerged-boat-ejection
🤖 Agent: Submerged boats eject their player after 60 underwater ticks even when the client drives the boat (AbstractBoat.tick on the server), the request kept until every rider left; harness against a vanilla server; main merged in (lan_e2e).
2026-11-01 16:53:35 +01:00
Ludovic Marie fba4b16003 Merge pull request #6043 from xFedeT/feat/social-interactions
🤖 Agent: Social Interactions screen (P, pause icon): hide a player's chat locally as Minecraft.isBlocked in ChatListener.showMessageToPlayer, vanilla layout and sorting; no Mojang services touched.
2026-11-01 16:53:30 +01:00
Ludovic Marie 8863f5a229 Merge pull request #5779 from rdbtCVS/perf/encoder-finish
🤖 Agent: Review by Ludovic's AI agent (Claude). Measured on macOS (M4 Pro, Metal, same binary A/B): with the thread on, 44–293 FPS with 20–180 ms p99 frames, against 296–549 FPS off. So I made it the default on Linux only, where it measured +32%, and opt-in elsewhere (HORIZON_SUBMIT_THREAD=1). Merged main (OIT conflict; the hand still draws after the composite). Verify green.
2026-11-01 16:53:25 +01:00
Ludovic Marie 5a11614d8e Merge pull request #6026 from oskal675-design/fix/inventory-slot-hover
🤖 Agent: Creative grid and hotbar hover use the padded slot target (AbstractContainerScreen.getHoveredSlot) for highlight and tooltip, as clicks already did; oracle-backed e2e.
2026-11-01 16:53:21 +01:00
Ludovic Marie 74a2fddd2f Merge pull request #5932 from oskal675-design/fix/armor-stand-frost-walker
🤖 Agent: Frost Walker's location effect runs for armor stands as vanilla's LivingEntity.tick; reviewed, verify green.
2026-11-01 16:53:16 +01:00
Federico TaglianiandClaude Sonnet 5.5 09bb7254ae A minecart runs into other carts, boats, mobs and players, so a TNT minecart sets off on them
Entity.collide takes the entities AbstractMinecart.canCollideWith (AbstractBoat.canVehicleCollide:
canBeCollidedWith or isPushable, but not a passenger of the same vehicle). Horizon's cart move
only saw blocks, so two carts never had a horizontal collision and a TNT minecart never exploded
on another one.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 16:51:45 +01:00
Ludovic MarieandClaude Opus 5.5 52da60975d Merge main into #6059 (lan_e2e: boat_ice_reboard beside blast-projectiles)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 16:50:26 +01:00
ElyTheCat 4a9fc00315 Merge branch 'main' into feat/async-entity-culling 2026-11-01 19:19:58 +03:30
Ludovic Marie 31d53c8b47 Merge #5920's branch (main after #5926) into shader/vertex 2026-11-01 16:49:03 +01:00
Ludovic Marie b0fee0d30d Merge main into shader/material-blocks (after #5926)
# Conflicts:
#	crates/horizon-client-render/src/mesher.rs
#	crates/horizon-client-render/src/mesher/reuse_tests.rs
#	crates/horizon-client-render/src/streaming.rs
#	crates/horizon-client/examples/chunk_load_bench.rs
#	crates/horizon-client/examples/probe/section_mesh_fixture.rs
#	crates/horizon-client/tests/streaming_meshes.rs
#	crates/horizon-client/tests/tall_column.rs
2026-11-01 16:49:02 +01:00
Ludovic Marie 183b53f0eb Merge pull request #6057 from HrafnaValkyrja/fix/mob-fall-on-blocks
🤖 Agent: Mobs run each block's fallOn through the landing registry (hay, honey, slime, powder snow, beds, dripstone...), the farmland roll on the mob's random as before; powder snow's entityInside forgets the fall.
2026-11-01 16:46:28 +01:00
Ludovic Marie 66e12814bd Merge pull request #6051 from HrafnaValkyrja/fix/projectile-land-event
🤖 Agent: PROJECTILE_LAND for thrown tridents, potions and fishing hooks (Projectile.onHit), so sculk sensors hear them; lan_e2e projectile_land check.
2026-11-01 16:46:24 +01:00
Ludovic Marie 7ba3d7ade7 Merge pull request #6061 from horizon-rust/perf/lazy-advancement-criteria
🤖 Agent: measured on playhorizon (alternated, THP off): idle anonymous −4.1 MB against #6041 on every start; Done and 20-bot MSPT unchanged. pr_ready ✅ (2099 tests + 21 content root); clippy -D warnings and fmt clean on the Mac.
2026-11-01 16:46:20 +01:00
Ludovic Marie 2801c8ac63 Merge pull request #6027 from Xmilon/native/mouse-scrolling
🤖 Agent: Two-axis gameplay scroll hook (fragment): fires on whole accumulated steps behind the existing gameplay gate; no-op without a mod.
2026-11-01 16:46:16 +01:00
Ludovic Marie 375fa93fe6 Merge pull request #6023 from Xmilon/native/input-key-events
🤖 Agent: Window keyboard input hook (fragment): the original winit KeyEvent after handling, real window events only; no-op without a mod.
2026-11-01 16:46:12 +01:00
Ludovic Marie 0796fa9963 Merge pull request #6058 from 4404c/docs/chat-validation-note
🤖 Agent: Docs only: microsoft-account.md now says other players' chat is validated (#5149); skip-release.
2026-11-01 16:46:06 +01:00
breachatoandClaude Opus 5.5 70c7040530 Any equipped item with glider and a matching equippable slot glides
LivingEntity.canGlide checks every equipment slot with canGlideUsing: the
stack has glider, its equippable slot is the one it is in, and its next damage
won't break it. Horizon only looked at the chest slot and the item's own
components, so a chestplate a server (or /give) gave glider and an equippable
couldn't glide. The client and the LAN host's replay now share one check that
reads the saved components and the server's patch, which can add or remove
either one; the patch's equippable isn't kept otherwise, so its slot is read
from the patch.

Fixes #5773

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 16:46:04 +01:00
Ludovic Marie f27399371d Merge pull request #6031 from Sticky-Bug/feat/flower-pot-hand-animation
🤖 Agent: A CONSUME use (empty flower pot with nothing to plant) no longer swings the arm: InteractionResult.shouldSwing is SUCCESS only, in use_hand and the remote prediction; level test covers it.
2026-11-01 16:46:01 +01:00
Ludovic Marie b938147a8e Merge pull request #5749 from some-du6e/optifine/cit-match
🤖 Agent: OptiFine series step verified (licence gate, gated to packs shipping the files, per-mob cost measured, verify green on 212746390). Thanks @some-du6e.
2026-11-01 16:45:57 +01:00
Ludovic Marie 8220ea6c65 Merge pull request #5712 from some-du6e/optifine/cit-rules
🤖 Agent: OptiFine series step verified (licence gate, gated to packs shipping the files, per-mob cost measured, verify green on e3ab4bd42). Thanks @some-du6e.
2026-11-01 16:45:53 +01:00
Ludovic Marie 7e05e90750 Merge pull request #5746 from some-du6e/optifine/ctm-mesh
🤖 Agent: OptiFine series step verified (licence gate, gated to packs shipping the files, per-mob cost measured, verify green on 936795944). Thanks @some-du6e.
2026-11-01 16:45:49 +01:00
Ludovic Marie 2bcace83be Merge pull request #5718 from some-du6e/optifine/ctm-connect
🤖 Agent: OptiFine series step verified (licence gate, gated to packs shipping the files, per-mob cost measured, verify green on 43b76c4a7). Thanks @some-du6e.
2026-11-01 16:45:45 +01:00
Ludovic Marie dde3ae3ce9 Merge pull request #5949 from Sylviromi/recipe-sync
🤖 Agent: Recipe sync hooks: arismoko's three points handled by the author (empty load resets clients, last part within its cap, per-load docs); main merged in by the author.
2026-11-01 16:45:41 +01:00
Ludovic Marie c7b4af6824 Merge pull request #5937 from xFedeT/fix/happy-ghast
🤖 Agent: requeued: kacpherek's review asked about per-tick cost; answered in the thread (a handful of obstacles, AABB tests only).
2026-11-01 16:45:37 +01:00
Ludovic Marie db90618377 Merge pull request #5998 from xFedeT/chore/port-map-triggers
🤖 Agent: Port map only: 16 advancement-trigger rows point at the triggers that fire on main; skip-release.
2026-11-01 16:45:33 +01:00
Ludovic Marie cb51db13a2 Merge pull request #5913 from xFedeT/fix/frog-animations
🤖 Agent: Frog eating a magma cube: drawn in and removed, and with the fix pushed here (mob_died runs on RemoveKilled) the froglight and XP drop as vanilla.
2026-11-01 16:45:27 +01:00
Ludovic MarieandClaude Opus 5.5 b84758065f Merge main into #5696: Frame Generation beside main's Leaf Culling setting
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 16:43:52 +01:00
CorbonatandCodex gpt-6.1-sol ac5f51226b Fix persistent enchantment level decoding and names
Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-01 18:42:25 +03:00
doodlyyousufandhorizon-agent 07002b127b docs(launcher): say the NixOS shell covers native mod builds
Co-Authored-By: horizon-agent (GLM 5.2) <[email protected]>
2026-11-01 15:41:57 +00:00
doodlyyousufandhorizon-agent 79e783ec84 fix(launcher): name the NixOS shell instead of a package manager
On NixOS pkg-config only sees packages listed by a shell's setup hooks, so
installing them in environment.systemPackages leaves the check failing, and
the APT advice it fell through to did not apply. Point at the launcher's own
shell.nix, which now carries those packages.

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

Fixes #5248

Co-Authored-By: horizon-agent (GLM 5.2) <[email protected]>
2026-11-01 15:41:50 +00:00
[email protected]andClaude Opus 5.5 1ffa50bf87 Merge upstream/main and end a drag on any button release
Review on #6019: vanilla's MouseHandler.onButton clears activeButton on any
release, so left down, right down, left up sends no drag while right is still
held. track_drag_button now does the same and the test pins vanilla's
behaviour.

The merge keeps both blocks in game.rs: the drag button tracking and the
screen key release hook (#6014).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 16:41:45 +01:00
[email protected]andClaude Sonnet 5.5 4dccd4cf01 fix(mods): a spectator does not count as a container viewer
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 16:41:36 +01:00
Ludovic MarieandClaude Opus 5.5 9beedd8463 Merge main into the piglin branch (lan_e2e: piglin_offspring beside blast-projectiles)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 16:41:22 +01:00
HrafnaValkyrjaandClaude Opus 5.5 5c9bc076ac Merge origin/main
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018zuDTsnyHkP28bzn3u7S3m
2026-11-01 10:40:28 -05:00
Ludovic MarieandClaude Opus 5.5 010239ced7 Crash fixture: scan the guests only when HORIZON_CRASH_FIXTURE names the point
as_guest runs for every guest action; the fixture's guest scan now waits
behind the OnceLock check, so a normal tick pays one load.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 16:39:06 +01:00
Ludovic Marie 690f4ca210 Merge pull request #5711 from some-du6e/optifine/ctm-rules
🤖 Agent: OptiFine series step verified (licence gate, gated to packs shipping the files, per-mob cost measured, verify green on 8881fc76f). Thanks @some-du6e.
2026-11-01 16:37:35 +01:00
Ludovic Marie 48798e24fb Merge pull request #5941 from HrafnaValkyrja/fix/explosions-push-projectiles
🤖 Agent: Explosions push projectiles, now thrown tridents too (fix pushed for kacpherek's review).
2026-11-01 16:37:30 +01:00
Ludovic Marie 2220e41e14 Merge pull request #5744 from some-du6e/optifine/etf-game
🤖 Agent: OptiFine series step verified (licence gate, gated to packs shipping the files, per-mob cost measured, verify green on a4bda5e9e). Thanks @some-du6e.
2026-11-01 16:37:25 +01:00
Ludovic Marie 6009612677 Merge pull request #5926 from elythepreciouscat/feat/leaf-culling
🤖 Agent: Leaf culling: the default stays vanilla's, server packs now turn it off like local packs (author's cf2c59e1b); main merged in (leafCulling beside translucentBatching).
2026-11-01 16:37:20 +01:00
Ludovic Marie a5093dae37 Merge pull request #5716 from some-du6e/optifine/etf-pack
🤖 Agent: OptiFine series step verified (licence gate, gated to packs shipping the files, per-mob cost measured, verify green on 19996b09c). Thanks @some-du6e.
2026-11-01 16:37:14 +01:00
Ludovic Marie aa33a8303c Merge pull request #5898 from Vaspyyy/feat/native-music
🤖 Agent: native_music hooks: main merged in, camera_distance kept and shader_params keeps its own doc.
2026-11-01 16:37:08 +01:00
Ludovic Marie f3548dd3b3 Merge pull request #5892 from Domanffe/feat/chat-animation
🤖 Agent: Smooth chat (off by default): main merged in, extras.rs keeps hideBlockParticles beside chatAnimation/chatIndicator.
2026-11-01 16:37:01 +01:00
Ludovic Marie dedc3bbcfb Merge pull request #5710 from some-du6e/optifine/etf-rules
🤖 Agent: OptiFine series step verified (licence gate, gated to packs shipping the files, per-mob cost measured, verify green on f2b47b87b). Thanks @some-du6e.
2026-11-01 16:36:56 +01:00
Ludovic Marie 4c9e871c0f Merge pull request #5665 from some-du6e/optifine/emf-animations
🤖 Agent: OptiFine series step verified (licence gate, gated to packs shipping the files, per-mob cost measured, verify green on 84bf62028). Thanks @some-du6e.
2026-11-01 16:36:50 +01:00
Ludovic Marie 3a5046218a Merge pull request #6014 from Xmilon/native/screen-key-released
🤖 Agent: Screen key release pre hook: the new head only isolates its test from the adjacent input hooks; no-op without a mod.
2026-11-01 16:36:42 +01:00
Ludovic Marie 653af80c4a Merge pull request #6003 from Xmilon/native/render-fog
🤖 Agent: native_render_fog hook: main merged by the author, frame_fog runs native_compute_fog_color then native_render_fog (NeoForge's ComputeFogColor then RenderFog); no-op without a mod.
2026-11-01 16:36:34 +01:00
Ludovic Marie e7f49e3695 Merge pull request #5925 from finalpantasy/fix/launcher-native-mod-refresh
🤖 Agent: requeued: kacpherek's review found no issue.
2026-11-01 16:36:27 +01:00
Ludovic Marie f49c54ba7f Merge pull request #5942 from oskal675-design/fix/spawn-eggs-on-source-fluids
🤖 Agent: requeued: parity failed only lunge_motion, a known flaky harness on the loaded Mac mini runner that this PR doesn't touch; rerunning.
2026-11-01 16:36:21 +01:00
Ludovic Marie 23fb690cff Merge pull request #5756 from finalpantasy/fix/native-sibling-lock-dependencies
🤖 Agent: Review by Ludovic's AI agent (Claude). kacpherek's review found nothing to change. The sibling-path check accepts only `../<id>` manifests of the same staged mod, and I added the underscore dependency tables, with a test. Launcher verify green.
2026-11-01 16:36:15 +01:00
Ludovic MarieandClaude Opus 5.5 bf890dc48e Merge main into lightning boxes (lan_e2e: lightning-box beside shulker_lift)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 16:36:11 +01:00
Ludovic Marie 47547980e4 Merge pull request #5731 from Stink-O/native/claimed-items
🤖 Agent: Review by Ludovic's AI agent (Claude). levzzz5154's review and his off-hand fix are in; uses an API fragment as docs/native-mods.md asks. I merged main (clean); verify green on this head.
2026-11-01 16:36:09 +01:00
Federico TaglianiandClaude Sonnet 5.5 8eff24080b Dedicated server: test that a panic in a player's turn or with the hub out saves the world and no player file
HORIZON_CRASH_FIXTURE=guest|hub panics the tick during a placed guest's turn or in a console
line while a player is in. tools/test_dedicated_crash_save.py joins a real client, crashes the
server, and checks the player's files are byte for byte what the last clean stop wrote and a
block placed before the panic is there on the next start. The console lines and the control
socket now run under the same catch_unwind as the tick: a panic there ended the process with
no save.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 16:34:52 +01:00
breachatoandClaude Opus 5.5 d07fd00f94 Containers still check the off hand key after Tab moves the focus
Screen.keyPressed moves the focus on Tab or an arrow and returns false, so
AbstractContainerScreen.keyPressed goes on to checkHotbarKeyPressed: with
key.swapOffhand bound to Tab, vanilla both focuses the recipe book button and
swaps the hovered slot with the off hand. Horizon's key focus kept the key,
so the inventory never saw it. In container screens, Tab and the arrows now
move the focus and pass the key on.

Fixes #6028

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 16:33:39 +01:00
Ludovic MarieandClaude Opus 5.5 9e58983bb7 Merge main into submerged boats (lan_e2e: boat_submerged beside shulker_lift)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 16:33:24 +01:00
Ludovic MarieandClaude Opus 5.5 e37e2f163f Device recovery: pass translucent batching to the rebuilt renderer
build_renderer takes the Translucent Batching setting and the picked pack's
flat drawing since #5984.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 16:31:23 +01:00
Ludovic Marie b773fa45c1 Merge remote-tracking branch 'origin/main' into HEAD 2026-11-01 16:31:05 +01:00
TeiwazikandClaude Opus 5.5 51643e956a Merge the branch below, with main
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01EBqm1v5zTsDGwuZF6XKFjL
2026-11-01 16:28:04 +01:00
HrafnaValkyrjaandClaude Opus 5.5 2b6ceab8a8 Mobs follow each block's landing rule
Mobs took the whole fall on every block. They now use the block's own fallOn behaviour, and powder snow makes them forget the fall.

Fixes #6056

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018zuDTsnyHkP28bzn3u7S3m
2026-11-01 10:25:26 -05:00
NazaandClaude Opus 5.5 6c577dcf69 Docs: other players' chat is validated since #5149
The chat signing section still said their signatures weren't verified.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 12:25:24 -03:00
ElyTheCat d66271131c Merge branch 'main' into feat/leaf-culling 2026-11-01 18:55:12 +03:30
Ludovic MarieandClaude Opus 5.5 d9ad8b2601 Merge main into native music (camera_distance hook kept; shader_params keeps its own doc)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 16:24:11 +01:00
JustKarkatandJustKarkat's AI agent c481ffedf2 IME: the chat box, the options searches and the remaining boxes take composed text
#4347 let the input method in, but Game::text_field_focused left four fields
out of the window's gate, so the OS was never asked for an input method while
they had the focus: the language list's search, the settings search, the
resource pack screen's search and a game master block's boxes. The title
screens' game-rule boxes were out too: focused_field is None while the rules
screen is open, and has_caret_field asks it.

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

Co-Authored-By: JustKarkat's AI agent (ZCode) <[email protected]>
2026-11-01 09:24:06 -06:00
Ludovic MarieandClaude Opus 5.5 295f6b408e Management protocol: a MOTD set is stored in server.properties, as DedicatedServer.setMotd
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 16:24:02 +01:00
Ludovic MarieandClaude Opus 5.5 83927e425b Merge main into chat-animation (extras: hideBlockParticles beside the chat extras)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 16:23:13 +01:00
oskal675-designandCodex GPT-6 326c36879b Clear cached boat motion when the client stops driving
Match AbstractBoat.tick by resetting velocity and angular velocity on non-authoritative client copies. Cover dismount/reboard ownership transitions with a real vanilla tick oracle and the actual headless Game ticker, plus ice reboarding for host and guest.

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 16:22:40 +01:00
HrafnaValkyrjaandClaude Opus 5.5 5b14688cb4 Lightning hits boats, minecarts and orbs, by box
The bolt tested entity feet and a short list of kinds. It now tests each entity's box, as LightningBolt.tick does, and destroys boats, minecarts and orbs.

Fixes #6052

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018zuDTsnyHkP28bzn3u7S3m
2026-11-01 10:22:02 -05:00
Ludovic MarieandClaude Opus 5.5 672fe27345 Merge main into #5800; a RAM cap turned off or changed gets its whole grace again
Resolves far_terrain_choice with main's server far terrain (#lod_net): the RAM
limit's far_off still wins. kacpherek's review: the watcher kept the Over timer
while the cap was off, so turning it back on while over could end the game at
once. Over now follows the cap and restarts its grace when it changes; tested.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 16:21:44 +01:00
HrafnaValkyrjaandClaude Opus 5.5 131c146a59 Sculk sensors hear tridents, potions and fishing hooks land
Horizon raised projectile_land only for the creatures list. Tridents, thrown potions and hooks now raise it too, as Projectile.onHit does.

Fixes #6050

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018zuDTsnyHkP28bzn3u7S3m
2026-11-01 10:21:25 -05:00
Ludovic MarieandClaude Opus 5.5 6d09cbb10f Explosions push thrown tridents too
Player-thrown tridents live in Game::tridents, apart from
creatures.projectiles, so the blast skipped them (kacpherek's review).
Same push as the other arrows: from the eyes (0.13), not in the ground.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 16:21:06 +01:00
ElyTheCatandCodex cf2c59e1bd Leaf culling: account for active server packs
Include server resource-pack overlays in leaf and entity culling safety checks, and restore the selected leaf mode when a server pack is removed.

Co-Authored-By: Codex <[email protected]>
2026-11-01 18:51:01 +03:30
[email protected]andClaude Sonnet 5.5 9e90c9c9b6 feat(mods): hear a mod container's viewers change
Fixes #6047

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 16:20:11 +01:00
Federico TaglianiandClaude Sonnet 5.5 3d6b8c6d18 Dropped items look only at the vehicles near them
The obstacle list was scanned in full for every moving item. A grid of 8-block cells hands each
item the boxes around where it can reach this tick. 5000 drops against 1000 spread-out vehicles:
12.6 ms of box checks become 0.45 ms.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 16:18:40 +01:00
Ludovic MarieandClaude Opus 5.5 743a051f37 Merge main into the piglin branch (corpus: piglin pickups beside experience orb stuck)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 16:18:38 +01:00
Ludovic Marie 5e9745363f Merge main into #5779
Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	crates/horizon-client-render/src/gfx.rs
#	crates/horizon-client/src/game/mods.rs
2026-11-01 16:18:32 +01:00
SylviromiandClaude Opus 5.5 cd6a6e1c89 Merge main into recipe-sync (the recipe filters run inside Recipes::sources)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 18:17:45 +03:00
TeiwazikandClaude Opus 5.5 c80ab3bbcd Merge the branch below, with main
shader_packs.rs: main's `picked_pack`, owned here since `set_translucent_layers` borrows the game.

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

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

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

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

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

Fixes #6044

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018zuDTsnyHkP28bzn3u7S3m
2026-11-01 10:15:34 -05:00
pikammmmm f4d50e3122 Saves: upgrade older chunk, entity and POI records as they are read
Vanilla upgrades every record it reads from a region file
(SimpleRegionStorage.upgradeChunkTag, from ChunkMap.upgradeChunkTag,
EntityStorage and SectionStorage.PackedChunk.parse), so a world 26.3 has
opened keeps the chunks nobody loaded since an older version in that
version's format. Horizon upgraded region files only when level.dat itself
was older and decoded those records raw: a 1.18 to 26.2 chunk, whose palette
names its states with `Name` (26.x reads `id`), loaded as an all-air column.

SaveDir's region reads (read_record, load_far, lod_surfaces, the End gateway
migration and the POI claims merge) now run the chunk, entity-chunk and POI
fixes on older records, from vanilla's defaults (-1, POI 1945) when a record
has no DataVersion, with the dimension from its folder and the generator type
from world_gen_settings.dat, found once per SaveDir. old_chunk_bits keeps
reading the record as saved, as IOWorker.isOldChunk does.

Chunks of before 1.18 come out of the fixes with `below_zero_retrogen`, which
the client's chunk loading does not handle yet: they are still generated
again, as before.
2026-11-01 16:14:42 +01:00
Muhammad AsifandOhMyAgent f6dc590d3e Lift primed TNT in soul sand bubble columns
PrimedTnt.tick updated its fluid immersion but never ran
Entity.applyEffectsFromBlocks, so BubbleColumnBlock.entityInside never pushed
it (handleOnInsideBubbleColumn) and a primed TNT dropped into a soul sand
column sank.

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

Closes #6008.

Co-Authored-By: OhMyAgent (grok-4.6) <[email protected]>
2026-11-01 20:13:42 +05:00
Federico TaglianiandClaude Sonnet 5.5 42c338167d Add the Social Interactions screen: hide players' chat
SocialInteractionsScreen (P, or the pause menu's player reporting icon): the All, Hidden and
Blocked tabs, a search box, a row per player with the hide/show chat button and vanilla's
disabled report button. Hiding a player drops their chat (Minecraft.isBlocked in
ChatListener) and says so in the chat line. Blocking and reporting are Mojang's services, so
Blocked stays empty and reporting stays disabled.

Part of #5954.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 16:13:06 +01:00
TeiwazikandClaude Opus 5.5 63250fd4e8 Merge main into the surface branch
gfx.rs: the split around the occlusion test keeps main's pass timing and its list of passes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01EBqm1v5zTsDGwuZF6XKFjL
2026-11-01 16:11:50 +01:00
SylviromiandClaude Opus 5.5 7268233fce Native mods: a load that asks for no recipe types puts the clients back on their own; the type list counts against the last part's cap
After a load that sent recipes, a later one whose mods ask for none sends
one last part with no types, so a joined client drops the server's old
recipes and hears of it (native_recipes_received with no types). The
last part's type list now counts against the payload cap: when it
doesn't fit, the recipes go in an earlier part. The sync hook's docs say
it is asked per load, the same for every player, where NeoForge asks per
joining player.

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

Co-Authored-By: Codex GPT-6.1 <[email protected]>
2026-11-01 20:08:40 +05:00
Ludovic Marie e111cb947a Merge #5644 (main merged) into #5645 2026-11-01 16:07:25 +01:00
Ludovic Marie e4bbe6055b Merge #5643 (main merged) into #5644 2026-11-01 16:07:16 +01:00
Ludovic MarieandClaude Opus 5.5 5d01df7504 Merge main into #5643: Hide block-break particles, UI sound reserve and their headers read the language table
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 16:07:15 +01:00
[email protected]andClaude Opus 5.5 8421d34846 Merge upstream/main into native/api-mods
Upstream added `pub mod lod_net;` to the old lib.rs module list, which this PR
moved to client_lib.rs: add it there. Upstream's new horizon-optifine
dependency is used by mobs/cem.rs, a reusable-client module, so it goes in
horizon-client-lib's manifest instead of horizon-client's.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 16:03:03 +01:00
[email protected]andClaude Opus 5.5 027d02a8b0 Merge upstream/main into native/input-key-events
Keep both: the window key hook and the merged raw mouse button hooks (#6010)
sit side by side in native_hooks.rs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 16:00:42 +01:00
Ludovic MarieandClaude Opus 5.5 b65e532430 Anticheat prediction: the record's sprint is the client's own, not the server's player's
A sprinting hit stops the server player's sprint (Player.causeExtraKnockback); the
client's own attack on a mob doesn't, and once the server's flag reaches it the
client starts the sprint again with the sprint key held, without a START_SPRINTING
(wasSprinting never changed). The record took the server's flag, so the replay
walked while the client sprinted: the live 'ground/air water, keys forward sprint'
flags. The record now takes the client's sprint commands; a sprint the server
stopped is played on only if vanilla's client could start it again (the sprint key,
or forward pressed again).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 16:00:41 +01:00
[email protected]andClaude Opus 5.5 2b878588bd Merge upstream/main into native/render-fog
Keep both fog hooks: the merged computed-color hook (#6000) runs first,
then the whole-fog hook sees the result and can change distances too.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 16:00:23 +01:00
Muhammad AsifandOhMyAgent a2ddc9f681 Lift primed TNT in soul sand bubble columns
PrimedTnt.tick updated its fluid immersion but never ran
Entity.applyEffectsFromBlocks, so a bubble column's entityInside push never
reached it. A primed TNT dropped into a soul sand column sank.

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

Closes #6008.

Co-Authored-By: OhMyAgent (grok-4.6) <[email protected]>
2026-11-01 19:59:55 +05:00
Ludovic Marie 2127463906 Merge main into optifine/cit-match 2026-11-01 15:59:12 +01:00
Ludovic Marie e3ab4bd423 Merge main into optifine/cit-rules 2026-11-01 15:59:10 +01:00
Ludovic Marie 9367959444 Merge main into optifine/ctm-mesh 2026-11-01 15:59:09 +01:00
Ludovic Marie 43b76c4a74 Merge main into optifine/ctm-connect 2026-11-01 15:59:08 +01:00
Ludovic Marie 8881fc76f0 Merge main into optifine/ctm-rules 2026-11-01 15:59:07 +01:00
Ludovic Marie a4bda5e9e1 Merge main into optifine/etf-game 2026-11-01 15:59:06 +01:00
Ludovic Marie 19996b09c9 Merge main into optifine/etf-pack 2026-11-01 15:59:04 +01:00
Ludovic Marie f2b47b87b4 Merge main into optifine/etf-rules 2026-11-01 15:59:04 +01:00
Ludovic Marie 84bf62028b Merge main into optifine/emf-animations 2026-11-01 15:59:03 +01:00
Ludovic MarieandClaude Opus 5.5 4ea4cd559b Frame generation: its post draws pass no pass timing (main's finish_with takes one)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 15:56:05 +01:00
Loïc PILLERandCodex GPT-6.1 2fd0193da5 docs: add piglin offspring PR screenshot
Record the empty-handed baby produced by the guest spawn-egg regression, for the before/after comparison in the pull request.

Co-Authored-By: Codex GPT-6.1 <[email protected]>
2026-11-01 15:55:57 +01:00
Loïc PILLERandCodex GPT-6.1 724ea7929a fix(mobs): let baby piglins pick up and keep gold
Match PiglinAi.wantsToPickup and stopHoldingOffHandItem: respect the baby ignored-item tag, admire picked-up gold, and equip it or preserve the previous loved item instead of bartering. Babies have no preferred weapon tag.

Compare 31 observations with vanilla through the integrated-server command harness, including adult bartering, occupied baby hands and ignored leather. Extend the hand-rendering regression with a crossbow and a gold ingot. Refs #5867.

Co-Authored-By: Codex GPT-6.1 <[email protected]>
2026-11-01 15:55:57 +01:00
Ludovic MarieandClaude Opus 5.5 fea41f8676 Submit thread: on by default on Linux only, where it was measured faster
On the Mac mini (M4 Pro, Metal, offscreen, render distance 12) the same
binary ran 549/537/450 FPS without the thread and 44/51/293 with it, with
p99 frames of 178/160/21 ms against 4 ms. HORIZON_SUBMIT_THREAD=1 still
turns it on anywhere, =0 off; Windows is not measured yet.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 15:55:37 +01:00
Loïc PILLERandCodex GPT-6.1 efe1b716c7 fix(mobs): spawn baby piglins without adult equipment
Follow SpawnEggItem.spawnOffspringFromSpawnEgg: construct the piglin and mark it as a baby without running adult spawn finalization. Keep items picked up later intact.

Add a server-thread LAN regression that uses a spawn egg from the guest and checks that both clients see the same empty-handed baby. Refs #5867.

Co-Authored-By: Codex GPT-6.1 <[email protected]>
2026-11-01 15:55:23 +01:00
ElyTheCatandOpenAI Codex f68f5def32 Entity culling: add asynchronous occlusion mode
Co-Authored-By: OpenAI Codex <[email protected]>
2026-11-01 18:24:59 +03:30
Ludovic MarieandClaude Opus 5.5 c12490337f Keep advancement criteria's conditions as JSON, read on first test
The ~1,300 recipe advancements' criteria held ~7,000 JSON objects as
serde_json Values (~4 MB of a server's idle heap), most never tested in a
game. Criterion.conditions is now a LazyJson: read into a Value the first
time a criterion is tested, then kept. The recipe and inventory indexes
read each criterion once without keeping it.

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

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01RodDbPWCByh96WKcbiEQpB
2026-11-01 15:50:55 +01:00
Sticky-BugandAntigravity a254e5ea31 Do not play hand animation when block interaction returns Consume (#5989)
In vanilla Minecraft, right-clicking an empty flower pot with an invalid item or empty hand consumes the action (InteractionResult::Consume) but does not swing the arm. In Horizon, use_hand unconditionally called swing_arm() whenever the interaction consumed the action, which also triggered swing_packet to LAN guests.

Gate swing_arm on InteractionResult::should_swing(), which only swings on Success.

Co-Authored-By: Antigravity <[email protected]>
2026-11-01 20:10:50 +05:30
[email protected]andCodex 202a24c553 test(mods): isolate release dispatch from adjacent input hooks
Co-Authored-By: Codex <[email protected]>
2026-11-01 15:40:48 +01:00
Ludovic MarieandClaude Opus 5.5 84bd812ecd docs(mods): a shift-click tops up a slot without asking may_place, as vanilla
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 15:40:29 +01:00
oskal675-designandCodex GPT-6 4647a2b257 Eject every player from boats submerged too long
Count consecutive underwater ticks on the server for client-driven boats and use the existing rider-ejection path. Keep the request visible to all passengers until they have left. Compare real vanilla and Horizon passenger outcomes and cover host and guest immersion on the integrated server.

Co-Authored-By: Codex GPT-6 <[email protected]>
2026-11-01 19:38:23 +05:00
[email protected]andCodex 76a6eed29c feat(mods): two-axis gameplay mouse scrolling event
Co-Authored-By: Codex <[email protected]>
2026-11-01 15:36:38 +01:00
Ludovic MarieandClaude Opus 5.5 03c7c60808 Merge main into #5696: draw_overlay keeps #5887's pass timings (the real frame's only)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 15:34:59 +01:00
[email protected]andCodex 906567c48d feat(mods): complete window keyboard input event
Co-Authored-By: Codex <[email protected]>
2026-11-01 15:32:11 +01:00
oskal675-designandCodex GPT-6.1 003985afa4 Keep Creative item tooltips continuous across slot gaps
Reuse the padded container slot target for Creative grid and hotbar highlights and tooltips. Compare 50 rendered tooltip samples against actual vanilla slot hit-testing, including quarter-pixel boundary and Survival controls.

Co-Authored-By: Codex GPT-6.1 <[email protected]>
2026-11-01 19:30:18 +05:00
Ludovic Marie 7791063dfe Merge optifine/cit-match 2026-11-01 15:29:50 +01:00
Ludovic Marie e44c119bc0 Merge the connected-textures clippy fix 2026-11-01 15:29:49 +01:00
Ludovic MarieandClaude Opus 5.5 49aa71cc5e Sprites::insert_file: clippy's needless borrow (decode_file takes a &Path now)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 15:29:47 +01:00
[email protected]andCodex 55d67aaa4d feat(mods): screen mouse drag pre event
Co-Authored-By: Codex <[email protected]>
2026-11-01 15:28:09 +01:00
[email protected]andCodex b88289edd1 test(mods): exercise actual mouse mapping release gate
Co-Authored-By: Codex <[email protected]>
2026-11-01 15:24:56 +01:00
[email protected]andCodex f8350d2240 feat(mods): screen key release pre event
Co-Authored-By: Codex <[email protected]>
2026-11-01 15:22:20 +01:00
Ludovic MarieandClaude Opus 5.5 1dd14c4cd2 Merge main into recipe-sync (server_data_loaded hook kept beside the recipe sync hooks)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 15:21:55 +01:00
KacpherekandCodex GPT-6 c92f0357c9 Merge upstream main into GPU recovery fix
Co-Authored-By: Codex GPT-6 <[email protected]>
2026-11-01 15:19:25 +01:00
Ludovic MarieandClaude Opus 5.5 01ae0bd791 Merge main into #5800
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 15:16:04 +01:00
Ludovic MarieandClaude Opus 5.5 97615443fe Launcher memory row: no limit until the player sets one
A 2048 MB default would give every player who updates a RAM limit they
never chose. On macOS and Linux the game starts stepping the render
distance down near 1843 MB, and big worlds or modpacks can pass that and
reach the last-resort crash. The row now starts at no limit, the end of
the slider; a typed or slid value is kept as before.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 15:14:09 +01:00
[email protected]andCodex e0c64b5cc8 feat(mods): adjust final world fog parameters
Co-Authored-By: Codex <[email protected]>
2026-11-01 15:13:38 +01:00
Ludovic MarieandClaude Opus 5.5 c486c3a7a6 Merge main into #5801
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 15:13:34 +01:00
Ludovic Marie 3d9197a87a Merge optifine/cit-match 2026-11-01 15:12:22 +01:00
Ludovic Marie 20815f9448 Merge the connected-textures BlockProperties fix 2026-11-01 15:12:21 +01:00
Ludovic MarieandClaude Opus 5.5 edee85f5d0 Connected textures: block state properties are main's BlockProperties
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 15:12:21 +01:00
Ludovic MarieandClaude Opus 5.5 70acb20025 Merge main into #5779
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 15:11:38 +01:00
Federico TaglianiandClaude Sonnet 5.5 48ab95a550 Port map: AmethystBlock's projectile chime is ported
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 15:10:48 +01:00
Ludovic MarieandClaude Opus 5.5 652981076f Native crates: read dev_dependencies and build_dependencies too
Cargo still accepts the underscore spellings before edition 2024, so a
dependency there skipped the lock check (levzzz5154's note on #5756).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 15:09:51 +01:00
Ludovic MarieandClaude Opus 5.5 4c69eab447 Merge main into feat/native-music (beside native_biome_colors)
The shader params doc comment goes back on native_shader_params: #5897 put
native_biome_colors between that comment and its function.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 15:09:24 +01:00
Ludovic MarieandClaude Opus 5.5 18a272eb7d Merge main into #5756
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 15:09:15 +01:00
Federico TaglianiandClaude Sonnet 5.5 c61f82e61e Port map: 16 advancement-trigger rows that fire already say port, not missing
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 15:07:09 +01:00
Ludovic MarieandClaude Opus 5.5 b618b02a68 Merge main into #5731
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 15:05:11 +01:00
Ludovic MarieandClaude Opus 5.5 4f6522cbeb Merge main into feat/chat-animation (chat extras beside splitscreen and uiSoundReserve)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 15:04:49 +01:00
Ludovic MarieandClaude Opus 5.5 a02d561d49 Merge main into #5791
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 14:59:51 +01:00
Federico TaglianiandClaude Sonnet 5.5 3f6221b268 An ender dragon summoned outside the End is drawn and flies
The dragon was drawn and ticked only in the End (its fight), so /summon ender_dragon in the
overworld put an invisible, motionless dragon there that only made sounds. EnderDragon.aiStep
does not depend on a fight (ServerLevel.getDragonFight is null outside the End): draw the
level's dragon in any dimension, and tick it outside the End without the fight.

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 14:48:51 +01:00
Ludovic MarieandClaude Opus 5.5 d4da2469cc Merge main into #5733
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 14:48:12 +01:00
KacpherekandCodex GPT-6 d3fdb8d19c Recover the renderer after graphics device loss
Co-Authored-By: Codex GPT-6 <[email protected]>
2026-11-01 14:47:11 +01:00
Ludovic MarieandClaude Opus 5.5 2b50470645 Chat: vanilla's fade keeps whole ticks while Smooth chat is off
ChatComponent's 200-tick fade reads Gui.getGuiTicks, a whole number, so it
steps 20 times a second. Passing the frame fraction made every player's fade
smoother than vanilla's; it now applies only with the Smooth chat extra on.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 14:46:19 +01:00
Ludovic MarieandClaude Opus 5.5 0c69153b31 Frog: a mob eaten by a frog still drops its loot
RemoveKilled marked the dead target removed before the next tick's deaths
ran, and those skip removed mobs: a magma cube eaten by a frog dropped no
froglight and no experience. LivingEntity.die runs at the killing bite in
vanilla, before eatEntity removes it, so the handler runs mob_died first.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 14:41:39 +01:00
[email protected]andCodex 9e18079acc fix(parity): migrate broad saved client target selections too
Co-Authored-By: Codex <[email protected]>
2026-11-01 14:41:20 +01:00
Ludovic MarieandClaude Opus 5.5 1382a8f1c6 horizon-client-render: horizon-optifine listed once
Random entity textures, connected textures and CIT each added the dependency.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 14:41:18 +01:00
Ludovic Marie 50f30b133f Merge remote-tracking branch 'du6e/optifine/cit-match' into of-union 2026-11-01 14:40:59 +01:00
Ludovic MarieandClaude Opus 5.5 7e30d60e9e Merge optifine/cit-rules: CIT beside CEM, random entities and CTM
paths: one valid() test shared by resolve, in_root (CTM) and CIT. THIRD_PARTY_NOTICES.md
credits CIT Resewn Continuation (MIT), whose conditions cit.rs follows.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 14:40:52 +01:00
Ludovic MarieandClaude Opus 5.5 75835557ff Merge optifine/ctm-mesh: connected textures beside main's LabPBR maps
RenderAssets keeps both the PBR maps and the connected-texture table; Sprites::insert_file
passes the root to main's register (a resource path has no namespace, so no PBR maps).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 14:40:51 +01:00
Ludovic MarieandClaude Opus 5.5 45161095d6 Merge main into fix/5836-powder-snow-walk-particles
main's spread() already applies RandomizationType ALTERNATIVE in
handleParticleEvent, so this branch's copy of it is dropped.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 14:40:35 +01:00
Ludovic MarieandClaude Opus 5.5 596b47850d Merge main into fix/5842-shulker-bullet-trail
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 14:39:24 +01:00
Ludovic Marie 5f38815063 Merge remote-tracking branch 'du6e/optifine/ctm-connect' into of-union 2026-11-01 14:39:05 +01:00
Ludovic Marie 9c12b9b065 Merge remote-tracking branch 'du6e/optifine/ctm-rules' into of-union
# Conflicts:
#	Cargo.lock
#	crates/horizon-optifine/Cargo.toml
#	crates/horizon-optifine/src/lib.rs
#	crates/horizon-optifine/src/paths.rs
2026-11-01 14:39:05 +01:00
Ludovic MarieandClaude Opus 5.5 fc292b22d1 Merge optifine/etf-game: random textures beside the custom entity models
Both sides of MobData, the pack reload and render_visible kept: a .jem's texture is
picked first, then its random variant. horizon-optifine listed once in horizon-client's
dependencies, and render_visible holds the CEM read guard instead of cloning its Arc.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 14:38:44 +01:00
Ludovic Marie b2b7de9edf Merge remote-tracking branch 'du6e/optifine/etf-pack' into of-union 2026-11-01 14:38:01 +01:00
Ludovic Marie ad5e50cbe6 Merge remote-tracking branch 'du6e/optifine/etf-rules' into of-union 2026-11-01 14:38:01 +01:00
Ludovic Marie b1b722682a Merge main into the OptiFine series 2026-11-01 14:38:00 +01:00
Ludovic MarieandClaude Opus 5.5 a94150a97c Merge main into #5696: frameGeneration beside main's texture budget and dithered mips
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 14:32:37 +01:00
finalpantasyandCodex ff45f545ce Merge upstream main to include its existing CI lint correction
Preserve the Home Mods refresh fix while incorporating upstream's correction
to BlockProperties::from_shared. The pull request remains one launcher file
relative to the pinned main revision.

Co-authored-by: Codex <[email protected]>
2026-11-01 08:30:55 -05:00
Federico TaglianiandClaude Sonnet 5.5 b183f74257 Dedicated server: a panic in the tick still saves the world, as runServer's finally does
MinecraftServer.runServer catches what the tick throws, writes the crash report, then
stops the server. A panic on the dedicated tick thread unwound out of run_dedicated, so
nothing since the last autosave was written. The tick is now under catch_unwind; a
panic ends the loop and saves the levels and the rest of the world state.

What the panic interrupted is not trusted: the guest whose turn (as_guest) it was has
older data in its record than the game holds swapped in, so it keeps its last saved file;
a hub taken out for the network tick (self.lan.take()) is gone with the unwind, so no
player is saved then.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 14:27:39 +01:00
Ludovic Marie 47b5a77bce Merge #5928 into the shader API union 2026-11-01 14:27:09 +01:00
Ludovic Marie 2947ff96b2 Merge #5920 into the shader API union 2026-11-01 14:27:09 +01:00
Ludovic Marie 90f08d3fbb Merge #5900 into the shader API union 2026-11-01 14:27:09 +01:00
Ludovic MarieandClaude Opus 5.5 6530425ace Load loot and live features beside the world as it opens
WorldRequest::prepare loaded the loot data (~40 ms on 4 CPUs) before the
world and installed the live features (bone meal's and saplings', ~46 ms)
after it, while the generator's decoration (~205 ms) kept one core busy.
Neither needs the world: they now run on scoped threads beside it and are
joined before prepare returns, so the loot data is still loaded before any
chunk is read. With a single worker thread they run in order as before.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 14:22:52 +01:00
TeiwazikandClaude Opus 5.5 9f3a88a41d Merge main: the lint fix
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01EBqm1v5zTsDGwuZF6XKFjL
2026-11-01 14:18:33 +01:00
TeiwazikandClaude Opus 5.5 c41d1ee7b4 Merge main: the lint fix
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01EBqm1v5zTsDGwuZF6XKFjL
2026-11-01 14:08:44 +01:00
HrafnaValkyrjaandClaude Opus 5.5 43ec4c8319 Explosions push projectiles
Arrows, tridents, snowballs, fire charges and rockets never felt a blast,
so TNT minecart arrow launchers did nothing. They now take vanilla's
push from their eye position, with each type's real eye height (0.13 for
arrows and tridents, 0 for wind charges). An arrow in the ground stays.

Fixes #5908

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018zuDTsnyHkP28bzn3u7S3m
2026-11-01 08:07:20 -05:00
SylviromiandClaude Opus 5.5 34ba8585ed Recipe book: a namespaced recipe keeps its id
The book's model and the native recipe list made every crafting and
cooking id `minecraft:<name>`, so a content mod's or a data pack's
`mymod:thing` became `minecraft:mymod:thing`. Only a bare vanilla name
takes the prefix now (crafting::recipe_id), and the native list reads
cooking times from the recipes in use, not the content root's alone.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 16:07:10 +03:00
SylviromiandClaude Opus 5.5 45480f7b1b Native mods: forget the recipe book's model through its own module
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 16:01:49 +03:00
SylviromiandClaude Opus 5.5 08fd8f8a1f Native mods: the synced recipes reach the recipe book's list, and the mods hear of them once the player is there
The window's recipe book model, which native_recipes lists the vanilla
kinds from, is made again from the server's recipes when they arrive.
The recipes of a join come before the window has its player, when the
helpers answer nothing, so native_recipes_received waits for it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 16:01:14 +03:00
Federico TaglianiandClaude Sonnet 5.5 8773007a60 A dropped item rests on a boat, a minecart, a shulker or a held-still happy ghast
Entity.collide stops a moving entity at the entities whose canBeCollidedWith(it) is
true. An item only looked at blocks, so one dropped onto the back of a happy ghast
(held still while a player stands there) fell through it, as it did through a boat.
The server's item tick now gets the boxes of boats, minecarts, living shulkers and
happy ghasts on a still timeout.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 14:00:04 +01:00
reservedbytes 57bac61271 feat(mods): native mods act on an item's use tick and release 2026-11-01 11:59:38 -01:00
TiggerTakkyTTTandCodex 4980a15644 Add live MOTD methods to the server management protocol
Read and set the dedicated server message through vanilla management
requests, updating the value used by server-list pings immediately.

Part of #5147

Co-Authored-By: Codex (GPT-6) <[email protected]>
2026-11-01 12:49:46 +00:00
DoodlyyousufandGrok 4.6 47f85029fe Send powder-snow walk snowflakes from the server
PowderSnowBlock.entityInside's SNOWFLAKE particles only ran on the
in-frame Game. The server copy skipped them, and guest ticks dropped
the event, so the window never drew them.

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

Fixes #5836

Co-Authored-By: Grok 4.6 <[email protected]>
2026-11-01 12:49:27 +00:00
SylviromiandClaude Opus 5.5 c79b897210 Native mods: send a server's recipes to its Horizon clients
A server's native mods name the recipe types their client halves need
(native_datapack_sync, NeoForge's OnDatapackSyncEvent.sendRecipes). The
server sends those types' final recipes, data packs merged and load
conditions held, to every Horizon client on a join and after each
/reload, on horizon:recipe_content in payloads within the clientbound
cap. The client's tables take them in place of its own for those types,
so native_recipes and native_recipes_of_type match the server, single
player included, and native_recipes_received (RecipesReceivedEvent) says
they came. With no type asked nothing is sent; vanilla clients are sent
nothing new.

Recipes::load is split into sources and from_sources so the server and
the client build from the same list.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 15:49:11 +03:00
ElyTheCat 5c8a4156ff Merge remote-tracking branch 'origin/main' into feat/leaf-culling 2026-11-01 16:17:51 +03:30
oskal675-designandCodex GPT-6.1 16a9935c6a Run armor stand location enchantments on equipment and movement
Apply the existing replace-disk evaluator for armor stand block changes,
landings and changed equipment slots. Keep piston support flags current
and verify the behavior against actual vanilla servers.

Remove a needless borrow in the upstream properties sort for Clippy.

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01EBqm1v5zTsDGwuZF6XKFjL
2026-11-01 13:39:42 +01:00
ElyTheCatandCodex 2a9a6c1e66 Graphics: capture leaf culling modes
Co-Authored-By: Codex <[email protected]>
2026-11-01 16:08:55 +03:30
finalpantasyandCodex f5014f4060 Fix Home Mods refresh after native files change during a read
Carry the fingerprint captured at read start into asynchronous completion. Keep an older result stale when a newer fingerprint was observed, allowing the next render to load the updated list.

Add a deterministic regression using owned temporary native-mod files and real discovery and fingerprints. It fails with the original completion behavior and verifies both files after the follow-up read.

Fixes #5907.

Co-authored-by: Codex <[email protected]>
2026-11-01 12:33:10 +00:00
TeiwazikandClaude Opus 5.5 dc4a9f2494 Shader API: [[materials]] blocks and translucent
An id can name blocks as well as sprites, and can move its sprites into the translucent pass. A
sprite pattern beats a block on the same sprite. Sections mesh again when the list changes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01EBqm1v5zTsDGwuZF6XKFjL
2026-11-01 13:18:39 +01:00
Federico TaglianiandClaude Sonnet 5.5 b45e1c9dd3 Frog: the tongue sets its target's velocity and a killed target vanishes at once
ShootTongue.tick sets the target's delta movement (setDeltaMovement, not an add) toward
the frog, and eatEntity removes a target that died (remove(KILLED)): the magma cube is
drawn into the mouth and disappears, with no death animation next to the frog.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 13:15:49 +01:00
[email protected]andClaude Opus 5.5 f2b508c4f2 test(client): read the loot hook source independent of line endings
loot_table_load_stays_hookable searched for "\n}\n", which a CRLF checkout
(core.autocrlf=true on Windows) never contains. Normalize as the other
source-reading test in this file does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 13:11:46 +01:00
ElyTheCatandAntigravity 81862bbc39 Merge origin/main into feat/leaf-culling; add benchmark screenshots
Resolve graphics.rs serialize conflict: insert leafCulling between
entityCulling and chunkUpdates, keeping main's textureBudgetMiB and
ditherCutoutMips additions intact.

Add four-mode benchmark screenshots for the PR.

Co-Authored-By: Antigravity <[email protected]>
2026-11-01 15:41:21 +03:30
Federico Tagliani c03ef707ed Merge remote-tracking branch 'origin/main' into fix/fox-item-pickup 2026-11-01 12:47:41 +01:00
oskal675-designandCodex GPT-6.1 278a1093ee Spawn eggs from source fluid item uses
Use the source-only fluid ray for SpawnEggItem.use, preserve packet-supplied block hits for useOn, and compare both paths against vanilla.

Co-Authored-By: Codex GPT-6.1 <[email protected]>
2026-11-01 16:42:07 +05:00
Alexander KrylovandHermes Agent 20f68001d8 Tests: build the smooth chat test's Chat in one go
Clippy's field_reassign_with_default on the new test.

Co-Authored-By: Hermes Agent (Nous Research) <[email protected]>
2026-11-01 14:39:28 +03:00
TeiwazikandClaude Opus 5.5 e2850986c9 Shader API: set_surface and surface_at, per-pixel data for the post stage
A material stage stores a normal, a smoothness and a value of its own for solid terrain; a post
pass reads them, where that terrain still shows. Only a stage that calls set_surface pays for the
target and its passes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01EBqm1v5zTsDGwuZF6XKFjL
2026-11-01 12:34:46 +01:00
Alexander KrylovandHermes Agent e1babb9132 Chat: a new line slides in and fades in (Smooth chat extra)
The ChatAnimation mod's idea, not its code: a line slides in from the left
while it fades in over 5 ticks instead of appearing whole, and the indicator
bar can be turned off. Both are Extras, off (the bar: on) by default, so a
vanilla client draws exactly what it drew before.

Chat::layout takes the fractional tick now, so the slide moves every frame
and vanilla's own fade does too. Only the unfocused HUD chat animates: with
the chat screen open the lines are drawn where click_at maps them.

Co-Authored-By: Hermes Agent (Nous Research) <[email protected]>
2026-11-01 14:23:56 +03:00
[email protected]andCodex 452910ffce fix(parity): keep saved probe selections compatible with the library split
Co-Authored-By: Codex <[email protected]>
2026-11-01 12:17:53 +01:00
DoodlyyousufandGrok 4.6 bcf52293d9 Spawn a shulker bullet's END_ROD trail on the client
Vanilla ShulkerBullet.tick draws two END_ROD particles in its 0.3125
box every client tick. Horizon moved the bullet and drew the model,
but never spawned the trail, so the sparkle behind it was missing.

Fixes #5842

Co-Authored-By: Grok 4.6 <[email protected]>
2026-11-01 11:08:18 +00:00
levzzz fab6784831 Merge remote-tracking branch 'hr/main' into pr-5731
# Conflicts:
#	docs/native-mods.md
2026-11-01 16:06:56 +05:00
[email protected]andCodex 42a5981e94 refactor(client): isolate gameplay from the reusable client and probes
Keep native hook targets and source paths stable. Run gameplay tests in the gameplay library, and build ctl/probes from horizon-client-lib without gameplay. Update build/test selection and the fixed mixin-tool pin.

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 10:43:03 +00:00
VaspyyyandClaude Opus 5.5 76ba2858e9 Native mods: music a client mod chooses and layers (native_music, native_music_layers)
`native_music(music, volume)` changes the track the music manager plays
and its volume each tick (Minecraft.getSituationalMusic and
getMusicVolume; NeoForge SelectMusicEvent). `native_music_layers(layers)`
lists looping tracks played over it on the music source, each easing to
its own volume by 0.025 a tick; layers new in the same tick start
together, so a piece's stems stay in step and can grow fuller.

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

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

Fixes #5720

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

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

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 10:24:43 +01:00
[email protected]andCodex a303d7d105 Merge upstream/main into the client library split
Retain upstream block_contact and the split entry/game exports with test isolation.

Co-Authored-By: Codex <[email protected]>
2026-11-01 10:15:44 +01:00
simonandClaude Opus 5.5 048a9276d8 The game can keep under a RAM limit
HORIZON_MEMORY_LIMIT_MB and /ramlimit cap the RAM the game holds. On
Windows a hard working set limit enforces it, so a low limit costs speed
instead of crashing. Elsewhere, and when the game is slow and faulting
hard, far terrain and the render distance in use step down and come back.
The game crashes with a report only if it can't run under the limit.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 19:05:48 +10:00
simonandClaude Opus 5.5 3ca72c5088 Launcher: a Maximum memory row on Home
A slider and a typed box, saved per instance. Horizon's limit defaults to
2048 MB and reaches the game as HORIZON_MEMORY_LIMIT_MB; no limit is a
choice of its own. A Java instance's row sets -Xmx.

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

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 08:26:19 +00:00
Stink-O 78d6f69e90 Merge branch 'native/bulk-block-edits' into native/edit-blocks 2026-11-01 08:24:18 +00:00
Stink-OandClaude Opus 5.5 24067ef56f fix(mods): end a claimed item's use wherever the game resets the use
A respawn, a join, the hand's item changing or the server stopping the use
reset the player's use without the mod use knowing, so the next vanilla
use was taken for the mod's and cancelled. The mod use now records the
slot and ticks it runs at, only that use is the mod's, and every reset
sends the mod its UseStop. native_held_item goes (native_held gives the
held id; the use events carry the custom name and data), as do the unused
screen events and the fork's wording; docs/native-mods.md gets a section.

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

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

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

Fixes #5294.

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

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

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

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

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

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

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

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

Refs #1927

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

Refs #1927

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

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

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

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

Refs #1927

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

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

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

Refs #1927

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

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

Refs #1927

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 08:40:07 +01:00
rdbtCVSandClaude Opus 5.5 c9614815f4 Renderer: finish and submit frames on a submit thread; the hand in the world pass
wgpu encodes a frame's commands in CommandEncoder::finish (~85 µs) and
submits them in ~25 µs, which held the window's thread at uncapped frame
rates. The frame now goes to a "Render submit" thread that finishes,
submits and presents it while the main thread builds the next one. Every
use of the queue waits for that frame first, so writes and submissions
keep their order. Frames with a screenshot readback, error scopes, VSync,
a reconfigure or the first frame's log stay in place. Culling's readback
starts on the submit thread right after the submission.
HORIZON_SUBMIT_THREAD=0 turns the thread off.

The first-person hand now draws at the end of the world pass, after a
depth reset, when nothing runs between them: one render pass fewer.

Closes #5777

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 06:55:23 +00:00
masa-masa-0314andGLM 5.3 320b94aaea Horizon strings: the shader options and split profiles speak every bundled language
The shader options page and the split-profile prompt added six keys to
`en_us` only; zh_cn, ru_ru, es_es, pl_pl, uk_ua and tr_tr showed the
English text through the fallback, and a language's own words were
missing where the page is most visible. All six bundles carry them now.

Co-Authored-By: GLM 5.3 (ZCode) <[email protected]>
2026-11-01 14:32:00 +08:00
finalpantasyandOpenAI Codex 5515c06762 Fix launcher validation of bundled sibling crate dependencies
Validate direct same-mod staged crate paths before excluding them from
registry requirements. Preserve lock checks and propagate discovery errors.

Co-Authored-By: OpenAI Codex <[email protected]>
2026-11-01 01:41:48 -04:00
some-du6eandClaude Opus 5.5 d728e15b38 OptiFine CIT: zero book generations and flights read as 0; Latin-1 rule files load
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 23:12:14 -06:00
some-du6eandClaude Opus 5.5 1a50677e6b OptiFine CIT: match stacks against the rules, indexed by item and cached per stack
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 23:11:23 -06:00
some-du6eandClaude Opus 5.5 a9f96fe589 OptiFine CIT: load the enabled packs' rules
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 23:11:23 -06:00
some-du6eandClaude Opus 5.5 86a346540e OptiFine CIT: bucket, painting and 1.20.2 potion tags; anchored regexes; escaped emoji; linear key reads
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 23:10:52 -06:00
Stink-O 3d685f468f Clippy: the changed cells' rows through as_chunks, not chunks_exact (Rust 1.99's lint)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
(cherry picked from commit 1606f86e4c)
2026-11-01 05:07:30 +00:00
Stink-O ace8c981f5 Clippy: the changed cells' rows through as_chunks, not chunks_exact (Rust 1.99's lint)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
(cherry picked from commit 1606f86e4c)
2026-11-01 05:07:30 +00:00
Stink-OandClaude Opus 5.5 1606f86e4c Clippy: the changed cells' rows through as_chunks, not chunks_exact (Rust 1.99's lint)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 05:07:22 +00:00
some-du6e 0c8d593d3a Merge branch 'optifine/ctm-mesh' into optifine/ctm-draw 2026-10-31 23:07:01 -06:00
some-du6eandClaude Opus 5.5 e0e002d5c2 OptiFine CTM picks: compile fixes (model Direction, disjoint borrows)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 23:06:31 -06:00
some-du6eandClaude Opus 5.5 7f0f3cba92 OptiFine CTM: compact quarters shade like the whole face
Blend within the quad's two triangles, as the GPU does, rather than
bilinearly.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 23:05:21 -06:00
some-du6e 4c8d6b3595 Merge optifine/emf-geometry into optifine/emf-animations 2026-10-31 23:01:46 -06:00
some-du6eandClaude Opus 5.5 9c56a91d82 OptiFine CTM: connected textures drawn in the world
The mesher asks ConnectedTextures for each face's sprite: a tile swaps the
quad's layer (in the pass its alpha needs), and ctm_compact's quarters
split the quad in four, its light and colour blended across them. Rules
that test biomes get the block's biome. Nothing changes without CTM files.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 23:00:39 -06:00
some-du6eandClaude Opus 5.5 5f9545fb9c OptiFine CTM picks: the test's rule order
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 22:57:30 -06:00
some-du6eandClaude Opus 5.5 c1e854dfcb OptiFine CTM picks: review fixes
A rule naming blocks and tiles needs both; block rules are indexed by
block, so a state tests only its own; textures/ tiles reuse their sprite;
overlay and custom-name rules load no tiles yet; top passes the top and
bottom faces on; tests for connect=tile, repeat, h+v, skips, defaults and
insert_file. Also fixes a double borrow.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 22:56:03 -06:00
some-du6eandClaude Opus 5.5 5d7f965b72 OptiFine random entities: mobs wear their pack's texture variants
The entity atlas packs the variants horizon_optifine::random_textures finds; each mob
chooses its variants when this window first sees it (biome, height, blocks, moon,
time and weather fixed then) and again once a second (name, profession, colour, baby,
health, size); render_visible draws the chosen variant's sprite for the body and
each layer. Layers without rules follow the body's variant (sheep wool), villager
layers choose their own. A pack reload makes every mob choose again.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 22:52:37 -06:00
some-du6eandClaude Opus 5.5 35f2791025 OptiFine random entities: map, not and_then, for the always-found rules id
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 22:52:05 -06:00
some-du6eandClaude Opus 5.5 719c862c93 OptiFine CIT: block entity and head tags that moved drop the rule; display keys stay in custom data
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 22:50:50 -06:00
some-du6eandClaude Opus 5.5 e713f1401e OptiFine CTM: tiles in the block sprites, and each face's rule picks one
Connected-texture tiles load as sprite layers (Sprites::insert_file reads
a texture outside textures/), and ConnectedTextures keeps each block
state's and sprite's rule list, built once at load. pick() walks them in
rule order for a face: ctm, ctm_compact (quarters and ctm.N), horizontal,
vertical, h+v, v+h, top, random (weights, loops, symmetry, linked),
repeat and fixed, with faces, heights, biomes, connect=block/state/tile,
innerSeams and the axis of logs. Without CTM files nothing is built.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 22:48:11 -06:00
some-du6eandClaude Opus 5.5 1c63577e62 OptiFine random entities: an oversized or unreadable rules file keeps the default
Review round 3: such a file now holds no rules (every mob wears the texture itself)
instead of falling back to an even choice; the cap is 64 KiB, and a test pins it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 22:46:20 -06:00
some-du6eandClaude Opus 5.5 8bdc00f39d OptiFine random entities: no leading-zero variants, and a size cap on rules files
Review round 2 fixes: creeper02.png is no variant 2; a .properties over 256 KiB is not
read; files() says it lists every variant; tests pin Latin-1 in a value that decides,
and the texture's own folder coming last.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 22:43:04 -06:00
some-du6eandClaude Opus 5.5 51f50aaeba OptiFine CTM geometry: clippy's identity_op in a test
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 22:42:39 -06:00
some-du6eandClaude Opus 5.5 d760738fb1 OptiFine CIT: tags that became components, Resewn's key names, and keys with a bare colon
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 22:42:34 -06:00
some-du6eandClaude Opus 5.5 cfdc34710f OptiFine random entities: one walk for every folder, and rules that stay in their folder
Review round 1 fixes: the texture's own folder is walked like the others (no more
probing, no gap cut where rules name a variant, inherited rules there too); inherited
rules only cut the file name, never a folder; variant 0 and names ending in _ are no
variants; Latin-1 .properties keep their rules; files() hands the atlas each variant's
path and name.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 22:41:48 -06:00
some-du6eandClaude Opus 5.5 d4b6fa4862 OptiFine random entities: find each texture's variants and rules in the packs
horizon_optifine::random_textures scans etf/random, optifine/random, optifine/mob,
mcpatcher/mob and (with a .properties beside it) textures/entity for numbered
variants and their rules, keyed by the entity atlas's texture names; textures
without their own rules use the name they extend (wolf_tame -> wolf).
paths::files_under walks a folder for files of given endings.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 22:41:48 -06:00
some-du6eandClaude Opus 5.5 db3b3dfb96 OptiFine random entities: a file holds at most 1024 rules
Each rule reads every entry, so a file of 200,000 rules took minutes; past the first
1024 rule numbers they are dropped (found reviewing the next PR, which reads them on
every pack load).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 22:40:53 -06:00
some-du6e 5ac3d9b85c Merge branch 'optifine/ctm-rules' into optifine/ctm-connect 2026-10-31 22:40:31 -06:00
some-du6eandClaude Opus 5.5 337e02a71e OptiFine CTM rules: the tile cap holds before every tile
Names were pushed before the final count check, so a 10 MB list built
hundreds of MB of paths first. matchTiles and connectTiles stop at the cap.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 22:39:49 -06:00
some-du6eandClaude Opus 5.5 9bffd28a6a OptiFine CTM geometry: test symmetry, covered corners and every turned face
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 22:35:08 -06:00
some-du6eandClaude Opus 5.5 32ee28a84c OptiFine CTM geometry: no fixed point at the origin, picks stay in range
The random hash steps by the golden ratio so the origin's down face isn't
stuck at mix's zero; weights that don't fit the tile count pick every tile
alike; dot products widen to i64; tests pin hand-computed values.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 22:32:09 -06:00
Stink-OandClaude Opus 5.5 baabab598f feat(mods): native server mods can read surface heights and columns
`native_surface_heights(dimension, columns, heightmap)` and `native_read_columns(dimension, columns, heightmap, (below, above))` for server mods, in the level being ticked: a column's heightmap height, or the blocks round it as a palette and indexes with the biome's grass, foliage and water tints, thousands of columns a call. Ported from Stink-O/horizon cbceb3f0e, a3ba97c94 and 20a8a553c.

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

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 04:31:46 +00:00
Stink-OandClaude Opus 5.5 b5c8018c47 feat(mods): native mods can raycast across the distant terrain and own an item's use
`native_raycast(distance)` and `native_view_hit()` (the first block along the view, up to 1024 blocks, across the distant terrain past the loaded chunks), `native_held_item()` (the main hand's stack, its custom name and data as SNBT), and `native_claim_items(mod_id, ids)`: a right-click with one of those items is the mod's alone (the server never hears of it), and `native_poll(mod_id)` gives `UseStart` and `UseStop` with the ticks it was held, for a held-to-charge item. Tests for the claims and the event queue. Ported from Stink-O/horizon ae531730c and 896853699.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 04:31:39 +00:00
some-du6eandClaude Opus 5.5 763a448985 OptiFine random entities: say what empty Facts.blocks means
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 22:29:11 -06:00
some-du6eandClaude Opus 5.5 90f5650007 OptiFine random entities: a blocks rule fails when no blocks are known
Round 6 review: with no block facts, '!stone' no longer holds (as a biome rule with no
biome already fails).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 22:27:41 -06:00
some-du6eandClaude Opus 5.5 df3306556b OptiFine CTM: the tile each method picks from a face's neighbours
horizon_optifine::ctm::connect: face texture directions (vanilla cube and
the axis-turned cube column), the 8-neighbour mask with innerSeams, and
the tile tables of OptiFine's templates: ctm's 47, ctm_compact's
quarters, horizontal, vertical, h+v, v+h and overlay's 17, plus repeat's
pattern place and a fixed, weighted random pick per position and face.
Shared with other connected-texture formats.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 22:26:39 -06:00
some-du6eandClaude Opus 5.5 e7c5e9d1ab OptiFine CIT: a missing named asset drops the rule; Resewn's hand spellings; ~ opens a segment
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 22:26:11 -06:00
hasanbkaylan 65c21a8463 Update tr.json 2026-11-01 07:24:29 +03:00
some-du6eandClaude Opus 5.5 2e80781c76 OptiFine random entities: the bad-condition test uses facts a dropped entry would pass
Round 5 review: run the never-match cases on a named mob in plains, so a lone '!' kept
in biomes or names turns the test red; the crate doc names ETF's docs too.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 22:23:47 -06:00
some-du6eandClaude Opus 5.5 bb23a1ac37 OptiFine CTM rules: an empty namespace is minecraft; docs match the code
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 22:22:40 -06:00
some-du6eandClaude Opus 5.5 733c361874 OptiFine random entities: a lone ! or an empty weights line, and one key scan
Round 4 review fixes: '!' with nothing after it skips the rule; an empty weights line
is an even choice instead of dropping the rule; one pass over the rule's keys; biome
patterns through Name::parse; the cap test through max_texture and select.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 22:20:33 -06:00
some-du6eandClaude Opus 5.5 a83bb47af4 OptiFine CTM rules: namespaced sprites in textures/, refused tiles fail the file
matchTiles=ns:a/b now names ns:textures/a/b, as Continuity's tiles do; a
tile outside the resource root fails the file rather than shifting the
weights after it; weights past Integer.MAX_VALUE count 1.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 22:20:04 -06:00
some-du6eandClaude Opus 5.5 a8a1d74d15 OptiFine random entities: biome patterns, and empty conditions skip the rule
Round 3 review fixes: biomes read ETF's pattern:/regex: forms (matched with or without
the namespace) and their negation; an empty condition never matches; drop a no-op
check and two fixture rules that tested nothing.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 22:16:36 -06:00
some-du6eandClaude Opus 5.5 b916c9a224 OptiFine CTM rules: assets/minecraft/ tiles stay in minecraft
A tile written as a full assets/minecraft/ path in another namespace's
file kept that file's namespace.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 22:14:19 -06:00
some-du6eandClaude Opus 5.5 7a2b81f86f OptiFine CTM rules: one bound on tile ranges
The range check can't underflow, so the per-token check was redundant.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 22:14:03 -06:00
some-du6eandClaude Opus 5.5 4bef210597 OptiFine CIT: bad ids and masks drop the rule, paths keep to the pack, and Resewn's lookups
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 22:13:51 -06:00
some-du6eandClaude Opus 5.5 9375dab632 OptiFine random entities: nbt list paths, seedSource and exact health percent
Round 2 review fixes: an nbt path ending in a list index no longer disables another
rule; seedSource=entity is accepted (vehicle skips the rule); health percent no
longer loses a point to f32 rounding; an unnamed entity passes '!name' lists; tests
check the even choice through select instead of private fields.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 22:11:59 -06:00
some-du6eandClaude Opus 5.5 c76b2a249a OptiFine CTM rules: close the tile cap, refuse backslashes and bad heights
The tile cap is checked before every token, so names before a range can't
underflow it; a path with a backslash is refused (Windows reads it as a
separator); a height bound that isn't a number fails the rule instead of
opening the range.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 22:10:20 -06:00
some-du6eandClaude Opus 5.5 ffb010b543 OptiFine random entities: a rule with a bad or unsupported condition never matches
Review fixes: conditions that don't parse (or nbt/teams/... that aren't read yet) skip
the rule instead of widening it to every entity; negated name lists exclude; an
unnamed entity fails name rules; variant indices stop at 1024; ETF's bare negative
ranges (-64--30) parse; more tests for each condition's failing side.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 22:07:58 -06:00
some-du6eandClaude Opus 5.5 943b1c7bc1 OptiFine CTM rules: bound tile ranges, reject bad names, read Latin-1
Review fixes: a tile range or repeat pattern can't ask for billions of
tiles; a name= that doesn't parse drops the rule instead of matching every
block; weights keep their positions; flags read as Boolean.parseBoolean;
files that aren't UTF-8 are read as ISO-8859-1, as Java does; the path
check is shared with paths::resolve. Tests for load, heights and the
broken values.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 22:06:56 -06:00
some-du6eandClaude Opus 5.5 68f749df47 OptiFine CIT: read the rules of optifine/cit and their component tests
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 22:05:14 -06:00
some-du6eandClaude Opus 5.5 f51a81bbed OptiFine packs: read connected-texture rules (optifine/ctm)
Parse optifine/ctm/**/*.properties into ordered rules: every method and
its tile count, matchTiles/matchBlocks (and the file-name forms), tiles
(ranges, names, paths, <skip>, <default>, Continuity's namespaced
sprites), faces, biomes, heights (with Continuity's a..b), name, connect,
connectTiles/connectBlocks, innerSeams, weights, randomLoops, symmetry,
linked, width/height, ctm.N, tintIndex/tintBlock, layer, and Continuity's
orient, prioritize and optifineOnly.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 22:02:41 -06:00
some-du6eandClaude Opus 5.5 6049010105 OptiFine random entities: read the rules that pick an entity's texture variant
horizon_optifine::random parses optifine/random .properties rules (textures/skins,
weights, seedOffset and the biome, height, name, profession, colour, baby, health,
moon, time, weather, size and block conditions) and picks a variant from an
entity's UUID-derived seed.

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

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

Fixes #5631

Written with Claude Opus 5.5 in OpenCode.
2026-10-31 23:59:42 -04:00
ElyTheCatandAntigravity 4ca447371b Merge origin/main into feat/leaf-culling
Co-Authored-By: Antigravity <[email protected]>
2026-11-01 07:00:49 +03:30
ElyTheCatandAntigravity 4af3055742 Add configurable leaf culling (Hollow, Solid, SolidAggressive)
Co-Authored-By: Antigravity <[email protected]>
2026-11-01 06:59:23 +03:30
spiderandClaude Opus 5.5 a9c0d90d2d Frame Generation setting: FSR 3.1, its present thread and the hand
Options > Graphics > Display: Frame Generation, Off or FSR 3.1 (off by default,
frameGeneration in horizon-graphics.txt). The world is kept at the screen's size
without the hand and HUD, the optical flow and interpolation make the frame
halfway (with mobs' and dropped items' own motion vectors), the hand (at its
halfway pose), outlines, post effects, screen effects and HUD are drawn over it,
and a present thread shows it half a real frame before the real one. Off under
shader packs, HDR, the title screen and blurred menus.

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 04:28:26 +01:00
some-du6eandClaude Opus 5.5 a6fcdfaf84 OptiFine CEM: custom entity models animate
A .jem's animations compile with its model when the packs load and run
per mob per frame after the vanilla animation: part transforms and
visibility (this, part, part names, bone ids, a:b:c paths), per-entity
var.* and varb.*, and the entity, render and frame parameters of
OptiFine's and EMF's animation docs.

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

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

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 02:07:08 +01:00
[email protected]andGPT-6 cd85269d59 docs: correct parity selection costs after the client split
Document refreshed probe dep-info, conservative selection growth, and existing reachability narrowing without promising the old harness counts.

Co-Authored-By: GPT-6 <[email protected]>
2026-10-31 23:31:27 +01:00
[email protected]andGPT-6 022306f5d8 refactor(client): keep the library split without the separate mod API
Co-Authored-By: GPT-6 <[email protected]>
2026-10-31 23:03:46 +01:00
rdbtCVSandClaude Opus 5.5 60851159b2 Merge main into #5446
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 21:50:15 +00:00
readerkid 34f8c670f5 Cauldron clicks predict the cauldron, not the held bucket's use
Clicking a cauldron predicted nothing: the remote useItemOn dispatch had no
cauldron arm, so the click fell through to Item.use while the hand still held
the water bucket. BucketItem.use then emptied the bucket into the face that was
aimed at, which is the water block that showed for a couple of frames before
the server's cauldron state arrived, and the water left in the air when a full
cauldron was emptied into the bucket.

remote_cauldron_reacts now runs AbstractCauldronBlock.useItemOn/CauldronInteractions
on this client's level through a dry copy of the hand, like the host's
use_on_cauldron: the cauldron's new state and the sound it made are predicted
when the item is one the cauldron takes, and a Pass still leaves Item.use its
turn. Cauldrons are also hand operated blocks, so a guest's predicted cauldron
use forwards its sound to the other players the way doors and levers do.

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <[email protected]>
2026-10-31 14:06:31 -07:00
[email protected]andClaude Opus 5.5 1e0fe9bb79 Merge upstream/main; the game's unit tests keep their own process
docs/native-mods.md keeps main's API 58 notes, then the API mods section.

With the game module in the horizon-client library, its unit tests ran in the
library's test process and shared process-wide state with the library's tests
(health::HURT_SERVER, the sulfur cube archetypes): six tests failed. The game
module now builds into the library only outside tests; its unit tests stay in the
executable's test build, as before the move.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 21:36:47 +01:00
[email protected]andClaude Opus 5.5 af806fd1c3 Merge upstream/main into native/api-mods
Conflicts: game.rs keeps the api_mods export beside main's narrator module;
horizon-threads takes main's windows-sys features (the same Windows fix);
native-mods.md keeps main's API 47-57 notes, then the API mods section.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 21:09:44 +01:00
[email protected] 0fb25759e1 boot_paint: AddFontMemResourceEx only reads the font count
Clippy (unnecessary_mut_passed) now that boot_paint compiles on Windows.
2026-10-31 20:25:15 +01:00
[email protected] 3888ac4613 Native mods without mixins: API mods that never rebuild horizon-client
A native bundle with [api] (crate and entry) and no [[injection]] is an API mod: its
crate implements horizon_mod_api::Mod and the game asks it right after the item and
block hook points it is named after, once the mixin mods there are done.

- horizon-mod-api: the Mod trait (use_item, use_item_on, right_click_block,
  left_click_block, destroy_block, place_block, tooltip), Action and Host.
- horizon-client: the game module moves into the library and the executable only adds
  the allocator, so a build can link the game from another package; game/api_mods.rs
  dispatches, and a mod turned off in Options > Mods is skipped with no build.
- launcher: API mods skip horizon-mixins; their crate and a generated native-game
  package build the executable, so horizon-client never depends on a mod.
- horizon-mods: the manifest's optional api field and its checks.
- docs: native-mods.md "API mods (no mixins)", stability.md; example in
  launcher/tests/fixtures/native-api-demo.
2026-10-31 20:25:15 +01:00
[email protected] e2d852088b Windows build: the windows-sys features boot_paint uses
boot_paint.rs draws the boot screen with GDI (Win32_Graphics_Gdi) and reads the
window's size (Win32_UI_WindowsAndMessaging), which horizon-threads didn't turn on.
2026-10-31 19:47:15 +01:00
552 changed files with 42183 additions and 2742 deletions

No files matched your search

+1 -1
View File
@@ -150,7 +150,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
run: cargo build --release --locked ${{ matrix.target && format('--target {0}', matrix.target) || '' }} -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 ${{ matrix.target && format('--target {0}', matrix.target) || '' }} -p horizon-client -p horizon-client-lib --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
+61
View File
@@ -0,0 +1,61 @@
name: pack load bench
# How long resource packs take to load: crates/horizon-client-render/examples/pack_load_bench.rs on generated packs
# (no game files), on the branch and then on `base` with the branch's copy of the bench, on the same runner, so a pull
# request can show its numbers next to main's. Started by hand, or by a push to a `pack-bench/*` branch (a fork can't
# start a workflow its main lacks), against horizon-rust/horizon's main.
on:
push:
branches: ['pack-bench/**']
workflow_dispatch:
inputs:
base:
description: Ref to compare against
default: main
scale:
description: How many times the default pack sizes
default: '1'
permissions:
contents: read
env:
HORIZON_TELEMETRY_URL: "off"
CARGO_TERM_COLOR: never
jobs:
bench:
runs-on: ubuntu-latest
timeout-minutes: 90
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install Rust and the ALSA and udev headers
run: |
sudo apt-get update && sudo apt-get install -y --no-install-recommends libasound2-dev libudev-dev
rustup toolchain install stable --profile minimal
- uses: Swatinem/rust-cache@v2
with:
shared-key: pack-bench
- name: Bench the branch
env:
SCALE: ${{ inputs.scale || '1' }}
run: |
cargo run --profile fast -p horizon-client-render --example pack_load_bench -- --scale "$SCALE" > head.md
- name: Bench the base, with the branch's bench
env:
BASE: ${{ inputs.base || 'main' }}
SCALE: ${{ inputs.scale || '1' }}
run: |
git fetch https://github.com/horizon-rust/horizon.git "$BASE"
git worktree add ../base FETCH_HEAD
cp crates/horizon-client-render/examples/pack_load_bench.rs ../base/crates/horizon-client-render/examples/
# A bench row that times a function the base lacks keeps it from building there: then
# the branch's own rows compare the two ways.
(cd ../base && CARGO_TARGET_DIR="$GITHUB_WORKSPACE/target" cargo run --profile fast -p horizon-client-render \
--example pack_load_bench -- --scale "$SCALE") > base.md \
|| echo "The bench does not build on the base." > base.md
- name: Summary
run: |
{
echo "### Branch ($(git rev-parse --short HEAD))"; cat head.md
echo; echo "### Base (${{ inputs.base || 'main' }})"; cat base.md
} | tee -a "$GITHUB_STEP_SUMMARY"
+17 -9
View File
@@ -30,6 +30,7 @@ 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.
- **No hardcoded user-facing strings.** Keep UI translation-ready from the start. In the launcher (`launcher/`), wrap visible text with `tr("...")` or `trf("... {arg} ...", &[("arg", &val)])` from `crate::i18n` instead of raw string literals or `format!`. In the game, use translation keys (`Text::tr`, `lang::get`), never hardcoded English strings.
- **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.
@@ -39,7 +40,8 @@ Game workspace (root `Cargo.toml`); `launcher/` and `crates/horizon-telemetry-se
| Crate | Owns |
|---|---|
| `horizon-client` | The game: a lib (`src/lib.rs`, ~100 flat modules: gameplay, networking, title screens) and the bin (`src/main.rs` → `game.rs` + `game/`), plus `horizon-ctl` (`src/bin/horizon-ctl/`). Also the dedicated server (`--dedicated`) |
| `horizon-client` | Gameplay library (`src/lib.rs`: `game.rs` + `game/`, `entry.rs`) and thin executable (`src/main.rs`). Also the dedicated server (`--dedicated`). Re-exports `horizon-client-lib` for existing mod paths. |
| `horizon-client-lib` | Reusable client systems, `horizon-ctl`, probes and their tests. Its manifest points to the existing sources under `crates/horizon-client/`; the library root is `src/client_lib.rs`. It never depends on gameplay. |
| `horizon-items` | Blocks and items below gameplay: `blocks.rs` (block facts), `items.rs`, `item_data.rs` (components), `enchant.rs`, `cooldowns.rs`, `bundle.rs`, `item_nbt.rs`. Re-exported as `horizon_client::items`... |
| `horizon-command` | Command parsing (Brigadier): the vanilla tree, argument types, selectors, suggestions, the tree's network form. Re-exported as `horizon_client::command` |
| `horizon-client-core` | The client lib's bottom layer: `text.rs`, `lang.rs`, `snbt.rs`, `nbt_path.rs`, `component.rs`, `scoreboard.rs`, `bossbar.rs`, `gamerules.rs`, `block_entity_nbt.rs`, registry/particle/sound tables. Re-exported under the old paths (`horizon_client::text`...) |
@@ -84,8 +86,8 @@ reads `README.md`, `launcher/links.json`, `docs/api/`, `roadmap/roadmap.json` an
```sh
python3 tools/pr_ready.py # the self-check: fmt, clippy -D warnings, touched crates' tests, harnesses; paste its block in the PR
python3 tools/pr_ready.py --quick # same without harnesses
cargo test -p horizon-client --lib --bins --tests # skips the 104 examples (pr_ready does this); `--doc` separately
cargo test -p horizon-client --lib mod_content::tests:: -- --ignored # the 5 slow content-root tests, #[ignore]d; pr_ready runs them when mod content, assets, blocks or items change
cargo test -p horizon-client -p horizon-client-lib --lib --bins --tests # skips the 104 examples (pr_ready does this); `--doc` separately
cargo test -p horizon-client-lib --lib mod_content::tests:: -- --ignored # the 5 slow content-root tests, #[ignore]d; pr_ready runs them when mod content, assets, blocks or items change
cargo build --profile fast -p horizon-client # optimized, quick to link: use it to play or run lan_e2e
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
@@ -102,8 +104,11 @@ python3 tools/agent_control_e2e.py # horizon-ctl / MCP end to end
or the game binary with a fixture env var (`game/dispense_fixture.rs`, `game/sculk_fixture.rs`,
`game/command_harness.rs`, `game/net_harness.rs`). Copy a nearby harness for a new one.
- Probes build into `.local/parity-target/`; harnesses find binaries via `lab.cargo_target()`, never `target/`.
- What a change reruns (of 329 harnesses, measured 2026-10-23): a file of the game binary (`game.rs`, `game/`)
~25, a file of the client lib ~103, of `horizon-world` ~103–109, of `horizon-core` ~119. Plan for it.
- Harness selection uses Cargo dep-info and reachable-code narrowing (`tools/parity_reach.py`).
`horizon-client-lib` builds the control tool and client probes without gameplay; their dep-info
excludes `game.rs` and `game/`, so gameplay edits do not rebuild or select those probes.
Source paths remain in `crates/horizon-client/`. Measure actual harness selection on refreshed
records with `tools/parity.py --changed-files paths.txt --list`; historical counts are not a guarantee.
- Rust integration tests: `crates/horizon-client/tests/` (`lan_bot.rs`, `generation_saves.rs`, `streaming_meshes.rs`).
- Linux needs `libasound2-dev libudev-dev`; `--no-default-features` drops audio and controllers (the server build).
- Run and drive the game (starts the `horizon-client` beside `horizon-ctl`; other arguments go to the game):
@@ -128,8 +133,11 @@ python3 tools/agent_control_e2e.py # horizon-ctl / MCP end to end
## horizon-client: where things live
`L:` = lib (`crates/horizon-client/src/`, or `crates/horizon-client-render/src/` for the modules that crate holds, under
the same `horizon_client::<module>` paths), `G:` = bin (`crates/horizon-client/src/game/`). The lib can't see
`Game`; bin code reaches the lib as `horizon_client::<module>`.
the same `horizon_client::<module>` paths), `G:` = gameplay (`crates/horizon-client/src/game/`), also in the lib in normal builds.
Gameplay keeps its `horizon_client::<module>` imports through a self-crate alias. The executable
contains only the allocator, mod API marker and a call to `entry::main`. For unit tests, the lib
excludes `entry` and `game`, and the executable test target owns `game` so its process-wide test
state stays separate from the library tests. This compiles gameplay twice for executable tests.
| Subsystem | Files |
|---|---|
@@ -151,7 +159,7 @@ the same `horizon_client::<module>` paths), `G:` = bin (`crates/horizon-client/s
| Save and load | L: `save.rs` (level.dat, regions, playerdata), `entity_nbt.rs`, `options_file.rs`; `horizon-client-core/src/block_entity_nbt.rs`; G: `saving.rs`, `persist.rs` (entities); `crates/horizon-world/src/{region.rs,nbt.rs,datafix/}` |
| Input | game.rs `fn window_event`, `fn key_pressed`, `fn play_mouse`; crate `horizon-client-input` (`keybinds.rs`, `keymapping.rs`, `key_names.rs`, `mouse.rs`, `input.rs`, `interaction_input.rs`, `gamepad.rs`, `clipboard.rs`); L: `osk.rs`; G: `pad.rs`, `pad_menu.rs` |
| Audio | L: `audio.rs` (mixer), `sound_engine.rs`, `sound_events.rs`, `music.rs`, `ambience.rs`; G: `music.rs`, `subtitle_hud.rs` |
| Resource packs | L: `packs.rs` + `packs/`, `pack_screen.rs`, `atlas_sources.rs`, `sprites.rs`, `mipmap.rs`, `model_library.rs`, `font.rs`; `horizon-client-core/src/lang.rs`; crate `horizon-assets`; G: `packs.rs` |
| Resource packs | L: `packs.rs` + `packs/`, `pack_screen.rs`, `atlas_sources.rs`, `sprites.rs`, `mipmap.rs`, `model_library.rs`, `font.rs`, `mobs/cem.rs` + `mobs/cem_anim.rs` (OptiFine/EMF entity models and their animations); `horizon-client-core/src/lang.rs`; crates `horizon-assets`, `horizon-optifine`; G: `packs.rs` |
| Agent control | L: `control.rs` (socket), `bin/horizon-ctl/`; G: `control.rs`, `agent.rs`, `events.rs`, `ui_control.rs` ([docs/agent-control.md](docs/agent-control.md)) |
| Mods | crate `horizon-mods`; G: `mods.rs`; L: `mod_content.rs`, `mod_list.rs` |
| Horizon extras, settings | L: `extras.rs`, `graphics.rs`, `settings.rs`, `minimap.rs`; G: `extras.rs`, `tweaks.rs`, `options.rs` |
@@ -160,7 +168,7 @@ the same `horizon_client::<module>` paths), `G:` = bin (`crates/horizon-client/s
- `struct Game` (game.rs, ~290 fields) holds everything. Its methods are spread over ~180 files: game.rs keeps
the core (tick, render, input, inventory, world open/close) and each `game/<feature>.rs` adds an
`impl Game<'_>` block for one feature, with its own state types. Fields are private to the bin.
`impl Game<'_>` block for one feature, with its own state types. Fields stay private to the gameplay module and its descendants. Gameplay tests run in the gameplay library, separate from reusable-library tests.
- Find a feature's code: `grep -rn "fn tick_<thing>\|fn <verb>_<thing>" crates/horizon-client/src/game*`;
the file is usually `game/<thing>s.rs`. Who calls it: grep the name in game.rs's `fn tick` / `fn tick_world`.
- Fields that say which mode runs: `remote` (this window is a client of a server: multiplayer, LAN guest, or
Generated
+59
View File
@@ -1314,6 +1314,7 @@ dependencies = [
"horizon-chat-filter",
"horizon-client-core",
"horizon-client-input",
"horizon-client-lib",
"horizon-client-render",
"horizon-command",
"horizon-core",
@@ -1374,6 +1375,62 @@ dependencies = [
"winit",
]
[[package]]
name = "horizon-client-lib"
version = "0.1.0"
dependencies = [
"ab_glyph",
"bytemuck",
"chrono",
"flate2",
"fs4",
"gilrs",
"glam",
"horizon-anticheat",
"horizon-assets",
"horizon-bench",
"horizon-chat-filter",
"horizon-client-core",
"horizon-client-input",
"horizon-client-render",
"horizon-command",
"horizon-core",
"horizon-dualsense",
"horizon-friends",
"horizon-hdr",
"horizon-items",
"horizon-mods",
"horizon-narrator",
"horizon-optifine",
"horizon-protocol",
"horizon-server-files",
"horizon-shader-api",
"horizon-shaders",
"horizon-telemetry",
"horizon-theme",
"horizon-threads",
"horizon-world",
"log",
"miniz_oxide 0.8.9",
"naga",
"objc2 0.6.5",
"objc2-app-kit 0.3.2",
"objc2-foundation 0.3.2",
"open",
"png",
"pollster",
"rustix 1.1.5",
"serde",
"serde_json",
"sha1",
"sha2",
"unicode-bidi",
"ureq",
"wgpu",
"winit",
"zip",
]
[[package]]
name = "horizon-client-render"
version = "0.1.0"
@@ -1395,6 +1452,7 @@ dependencies = [
"horizon-hdr",
"horizon-items",
"horizon-narrator",
"horizon-optifine",
"horizon-protocol",
"horizon-shader-api",
"horizon-shaders",
@@ -1516,6 +1574,7 @@ name = "horizon-optifine"
version = "0.1.0"
dependencies = [
"horizon-bench",
"log",
"regex",
]
+1
View File
@@ -8,6 +8,7 @@ members = [
"crates/horizon-hdr",
"crates/horizon-world",
"crates/horizon-client",
"crates/horizon-client-lib",
"crates/horizon-client-core",
"crates/horizon-client-input",
"crates/horizon-client-render",
+25
View File
@@ -66,3 +66,28 @@ with Microsoft accounts
The four-square Microsoft symbol in `launcher/src/microsoft.rs` reproduces the squares, positions and
colours of Microsoft's official `ms-symbollockup_mssymbol_19.svg` from that page, unmodified. Microsoft
and its logo are trademarks of the Microsoft group of companies; Horizon is not endorsed by Microsoft.
## CIT Resewn
`crates/horizon-optifine/src/cit.rs` and `crates/horizon-client-render/src/cit.rs` follow the
condition semantics of [CIT Resewn Continuation](https://github.com/EthanVisagie/CIT-Resewn-Continuation-1.21.11)
(at `b792f9a`), a continuation of [CIT Resewn](https://github.com/SHsuperCM/CITResewn) by SHsuperCM: the
1.21 component paths, enchantment levels, percent damage, text matching and rule ordering. It is
licensed under the MIT license:
> Copyright (c) 2021 SHsuperCM
>
> Permission is hereby granted, free of charge, to any person obtaining a copy of this software
> and associated documentation files (the "Software"), to deal in the Software without
> restriction, including without limitation the rights to use, copy, modify, merge, publish,
> distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the
> Software is furnished to do so, subject to the following conditions:
>
> The above copyright notice and this permission notice shall be included in all copies or
> substantial portions of the Software.
>
> THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING
> BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
> NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
> DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
> OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+7
View File
@@ -281,6 +281,13 @@ pub struct Player {
pub ground: Option<(u64, f64)>,
/// The client ticks the prediction plays again (only while it runs).
pub record: Option<Box<prediction::Record>>,
/// Sprinting as the client's own commands left it (ServerboundPlayerCommandPacket's
/// START_SPRINTING and STOP_SPRINTING: LocalPlayer.wasSprinting), which the record's ticks take.
/// Not the server's player: a hit with extra knockback stops its sprint
/// (Player.causeExtraKnockback), the client's own `Player.attack` doesn't (a mob's `hurtClient`
/// is false), and once the server's flag reaches it LocalPlayer.aiStep starts the sprint again
/// while the sprint key is held, with nothing to say: wasSprinting never changed.
pub sprinting: bool,
/// The attacks of the client tick being heard.
pub attacks: combat::Attacks,
}
+16 -6
View File
@@ -202,10 +202,8 @@ fn os_version() -> Option<String> {
#[cfg(target_os = "linux")]
fn cpu() -> Option<String> {
let info = std::fs::read_to_string("/proc/cpuinfo").ok()?;
// x86 names the CPU per core; ARM boards name the machine once.
key_value(&info, "model name", ':')
.or_else(|| key_value(&info, "Model", ':'))
.map(str::to_owned)
// x86 names the CPU per core; arm64 only gives the core's implementer and part.
horizon_telemetry::system::linux_cpu_name(&info)
}
#[cfg(target_os = "linux")]
@@ -227,12 +225,12 @@ fn key_value<'a>(text: &'a str, key: &str, sep: char) -> Option<&'a str> {
#[cfg(target_os = "windows")]
fn os_version() -> Option<String> {
output("cmd", &["/C", "ver"])
horizon_telemetry::system::windows_version()
}
#[cfg(target_os = "windows")]
fn cpu() -> Option<String> {
std::env::var("PROCESSOR_IDENTIFIER").ok()
horizon_telemetry::system::windows_cpu()
}
#[cfg(target_os = "windows")]
@@ -281,4 +279,16 @@ mod tests {
assert!(cpu_seconds(std::process::id()).unwrap() >= 0.0);
}
}
/// `hardware.cpu` is the brand name, not the family and model `PROCESSOR_IDENTIFIER` holds.
#[cfg(windows)]
#[test]
fn windows_reports_the_cpu_brand() {
let cpu = Hardware::detect().cpu.expect("a CPU name");
assert!(
!cpu.is_empty() && !cpu.contains(" Family ") && !cpu.contains("Stepping"),
"family and model, not a name: {cpu:?}"
);
assert_eq!(cpu, cpu.trim());
}
}
+41 -4
View File
@@ -73,8 +73,11 @@ horizon-bots: load-test bots for a Minecraft Java Edition 26.3 server (offline m
--attack NAME attack the player NAME twice a second, from wherever the bot is (a reach test),
swinging after each attack as vanilla's client does. NAME,NAME...: each of them
in the same tick (a multi-target kill aura, for the anticheat's tests);
the report gives each bot's last health
the report gives each bot's last health. A UUID instead of a NAME: the
entity with that UUID (a boat summoned with it, say)
--attack-after S start attacking S seconds after joining; default 5
--attack-rounds N stop after N rounds of attacks (each hits every target seen so far once);
default no limit
--no-swing attack without swinging (a kill aura hiding its swing, for the anticheat's tests)
--no-loaded never say the level is loaded (ServerboundPlayerLoadedPacket): a vanilla server
ignores the player's moves and actions for its first 3 seconds
@@ -153,9 +156,11 @@ struct Options {
seed: u64,
explore_speed: f64,
pvp: bool,
/// The player to attack, and how long after joining to start.
/// The players or entity UUIDs to attack, how long after joining to start,
/// and how many rounds.
attack: Option<String>,
attack_after: f64,
attack_rounds: u32,
no_swing: bool,
flood: usize,
no_read: bool,
@@ -190,6 +195,7 @@ fn options() -> Result<Options, String> {
pvp: true,
attack: None,
attack_after: 5.0,
attack_rounds: u32::MAX,
no_swing: false,
flood: 100,
no_read: false,
@@ -247,6 +253,7 @@ fn options() -> Result<Options, String> {
"--flood" => o.flood = value.parse().map_err(|_| number("packets a tick"))?,
"--attack" => o.attack = Some(value),
"--attack-after" => o.attack_after = value.parse().map_err(|_| number("seconds"))?,
"--attack-rounds" => o.attack_rounds = value.parse().map_err(|_| number("a number"))?,
"--name" => o.name = value,
"--report" => o.report = Some(value),
"--brand" => o.brand = Some(value),
@@ -401,6 +408,16 @@ fn join_summary(mut times: Vec<u64>) -> serde_json::Value {
})
}
/// An `--attack` target's UUID: a UUID as written (`8-4-4-4-12` hex), else
/// the offline UUID of the player with that name (a name has no `-`).
fn target_uuid(name: &str) -> u128 {
let hex: String = name.chars().filter(|&c| c != '-').collect();
match u128::from_str_radix(&hex, 16) {
Ok(uuid) if name.contains('-') && hex.len() == 32 => uuid,
_ => offline_uuid(name),
}
}
/// A small deterministic generator (SplitMix64), so every run moves the bots the same way.
struct Random(u64);
impl Random {
@@ -984,7 +1001,7 @@ fn run_bot(
.attack
.iter()
.flat_map(|names| names.split(','))
.map(|name| (offline_uuid(name), None))
.map(|name| (target_uuid(name), None))
.collect();
bot.online.store(true, Ordering::SeqCst);
{
@@ -1039,6 +1056,7 @@ fn run_bot(
send(play::Serverbound::SetCarriedItem(play::SetCarriedItem { slot: 0 }).encode());
}
let mut sequence = 0;
let mut rounds = 0;
let mut random = Random(o.seed ^ (index as u64).wrapping_mul(0x2545_F491_4F6C_DD1D));
let mut pos = spawn;
// Fight bots stay at their spawn height; the others climb to fly above the terrain.
@@ -1202,8 +1220,13 @@ fn run_bot(
send(body);
}
}
if joined.elapsed().as_secs_f64() >= o.attack_after && tick % 10 == 0 {
if joined.elapsed().as_secs_f64() >= o.attack_after
&& tick % 10 == 0
&& rounds < o.attack_rounds
{
let targets: Vec<i32> = lock(&bot.target).iter().filter_map(|t| t.1).collect();
// A round counts once a target is known.
rounds += u32::from(!targets.is_empty());
for id in targets {
send(play::Serverbound::Attack(play::Attack { id }).encode());
// Minecraft.startAttack swings right after the attack.
@@ -1446,6 +1469,20 @@ mod tests {
})
}
/// `--attack`: a UUID names that entity, anything else a player.
#[test]
fn attack_targets_a_uuid_or_a_player() {
assert_eq!(target_uuid("00000000-0000-0000-0000-00000000000a"), 10);
assert_eq!(
target_uuid("0000000a-0000-0001-0000-000000000002"),
0x0000000a_0000_0001_0000_000000000002
);
assert_eq!(target_uuid("V0"), offline_uuid("V0"));
// A name of 32 hex digits is a name: a UUID is written with dashes.
let name = "0123456789abcdef0123456789abcdef";
assert_eq!(target_uuid(name), offline_uuid(name));
}
#[test]
fn tracks_living_entities_only() {
let mut e = Entities::default();
+169
View File
@@ -72,6 +72,19 @@ impl ResourceSource for Bundled {
"assets/horizon/lang/tr_tr.json" => {
include_str!("../../horizon-client/assets/horizon/lang/tr_tr.json")
}
"assets/horizon/lang/de_de.json" | "assets/horizon/lang/de_at.json" => {
include_str!("../../horizon-client/assets/horizon/lang/de_de.json")
}
// Swiss German writes "ss" for "ß", as vanilla's de_ch does.
"assets/horizon/lang/de_ch.json" => {
let de = include_str!("../../horizon-client/assets/horizon/lang/de_de.json");
return Some(de.replace('ß', "ss").into_bytes());
}
"assets/horizon/lang/fr_fr.json"
| "assets/horizon/lang/fr_ca.json"
| "assets/horizon/lang/fr_ch.json" => {
include_str!("../../horizon-client/assets/horizon/lang/fr_fr.json")
}
"assets/horizon/lang/es_es.json"
| "assets/horizon/lang/es_ar.json"
| "assets/horizon/lang/es_cl.json"
@@ -316,6 +329,20 @@ mod tests {
assert_eq!(tr.get("horizon.options.controller"), Some("Kontrolcü"));
let es = Language::load(&[&empty], "es_es");
assert_eq!(es.get("horizon.options.controller"), Some("Mando"));
// German and French, and their regional variants on the same file
// (Swiss German without "ß").
let de = Language::load(&[&empty], "de_de");
assert_eq!(de.get("horizon.osk.close"), Some("Schließen"));
let fr = Language::load(&[&empty], "fr_fr");
assert_eq!(fr.get("horizon.osk.close"), Some("Fermer"));
for (code, base) in [("de_at", &de), ("fr_ca", &fr), ("fr_ch", &fr)] {
let variant = Language::load(&[&empty], code);
assert_eq!(variant.map(), base.map(), "{code}");
}
let de_ch = Language::load(&[&empty], "de_ch");
assert_eq!(de_ch.get("horizon.osk.close"), Some("Schliessen"));
assert!(de_ch.map().values().all(|v| !v.contains('ß')));
assert_eq!(de_ch.len(), de.len());
let es_ve = Language::load(&[&empty], "es_ve");
assert_eq!(
es_ve.get("horizon.options.on_screen_keyboard"),
@@ -323,6 +350,148 @@ mod tests {
);
}
/// `crates/horizon-client/assets/horizon/lang`, where Horizon's own strings live.
fn bundled_dir() -> std::path::PathBuf {
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../horizon-client/assets/horizon/lang")
}
/// The keys of a flat bundled file in file order, duplicates included (a map
/// would keep only the last of two equal keys). One `"key": "value"` per line.
fn file_keys(body: &str) -> Vec<String> {
body.lines()
.filter(|line| line.starts_with(" \""))
.map(|line| {
serde_json::Deserializer::from_str(line.trim_start())
.into_iter::<String>()
.next()
.and_then(Result::ok)
.unwrap_or_else(|| panic!("no key on line {line}"))
})
.collect()
}
/// The fields the code fills in (`%s`, `%d`, `%1$s`, `%.2f`, `%%`), sorted. A
/// percent sign that starts none of them ("100% is") is plain text.
fn placeholders(text: &str) -> Vec<String> {
let mut found = Vec::new();
let mut rest = text;
while let Some(at) = rest.find('%') {
rest = &rest[at + 1..];
let spec = rest
.find(|c: char| !(c.is_ascii_digit() || c == '$' || c == '.'))
.unwrap_or(rest.len());
let after = rest[spec..].chars().next();
if after.is_some_and(|c| matches!(c, 's' | 'd' | 'f' | '%')) {
let end = spec + 1;
found.push(format!("%{}", &rest[..end]));
rest = &rest[end..];
}
}
found.sort();
found
}
#[test]
fn bundled_files_are_served_and_have_no_stale_or_repeated_keys() {
let dir = bundled_dir();
let en_body = std::fs::read_to_string(dir.join("en_us.json")).unwrap();
let en: HashMap<String, String> = serde_json::from_str(&en_body).unwrap();
let mut seen = 0;
for entry in std::fs::read_dir(&dir).unwrap().flatten() {
let name = entry.file_name().into_string().unwrap();
let Some(code) = name.strip_suffix(".json") else {
continue;
};
seen += 1;
// A file that Bundled::read does not name is never loaded.
let path = format!("assets/horizon/lang/{name}");
assert!(
Bundled.read(&path).is_some(),
"{name} is not served by Bundled"
);
let body = std::fs::read_to_string(entry.path()).unwrap();
let map: HashMap<String, String> = serde_json::from_str(&body)
.unwrap_or_else(|e| panic!("{name} is not a flat string map: {e}"));
let keys = file_keys(&body);
assert_eq!(
keys.len(),
map.len(),
"{name} repeats a key or has a multi-line entry"
);
for (key, value) in &map {
// Stale: the code no longer asks for it, or it was renamed in en_us only.
let english = en
.get(key)
.unwrap_or_else(|| panic!("{code}: {key} is not in en_us.json"));
// A translation keeps the fields the code fills in.
assert_eq!(
placeholders(value),
placeholders(english),
"{code}: {key} changes the placeholders of the English text"
);
assert!(!value.trim().is_empty(), "{code}: {key} is empty");
}
}
assert!(
seen >= 7,
"found {seen} bundled lang files in {}",
dir.display()
);
}
#[test]
fn every_horizon_key_the_code_names_is_in_en_us() {
fn rust_files(dir: &std::path::Path, out: &mut Vec<std::path::PathBuf>) {
for entry in std::fs::read_dir(dir).into_iter().flatten().flatten() {
let path = entry.path();
if path.is_dir() {
rust_files(&path, out);
} else if path.extension().is_some_and(|e| e == "rs") {
out.push(path);
}
}
}
let en: HashMap<String, String> = serde_json::from_str(
&std::fs::read_to_string(bundled_dir().join("en_us.json")).unwrap(),
)
.unwrap();
let crates = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("..");
let mut files = Vec::new();
for krate in [
"horizon-client",
"horizon-client-render",
"horizon-client-core",
] {
rust_files(&crates.join(krate).join("src"), &mut files);
}
assert!(files.len() > 100, "found only {} sources", files.len());
let mut found = 0;
for file in files {
let text = std::fs::read_to_string(&file).unwrap();
for (at, _) in text.match_indices("\"horizon.") {
let rest = &text[at + 1..];
let Some(end) = rest.find('"') else { continue };
let key = &rest[..end];
// A key is `horizon.<group>.<name>`: lower-case letters, digits and `_`
// between the dots. A key built at run time has a brace; a file name
// has one dot; the server addresses that look like keys are listed.
const NOT_KEYS: &[&str] = &["horizon.bunchy7s.dev"];
let key_like = key
.bytes()
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b"_.".contains(&b));
if key.matches('.').count() < 2 || !key_like || NOT_KEYS.contains(&key) {
continue;
}
found += 1;
assert!(
en.contains_key(key),
"{} names {key}, which is not in en_us.json",
file.display()
);
}
}
assert!(found > 50, "found only {found} key names");
}
#[test]
fn layers_selected_language_over_english() {
let base = Mem(HashMap::from([
@@ -483,6 +483,21 @@ impl KeyBinds {
.find(|mapping| self.get(mapping) == name)
.and_then(|mapping| key_code(Self::default_of(mapping)))
}
/// `AbstractContainerScreen.checkHotbarKeyPressed`: the off hand and the
/// hotbar slots, as the controls bind them now. Answers the key the menu
/// acts on (`KeyF` for the off hand, `DigitN` for a slot), so a rebound key
/// swaps instead of reaching the screen.
pub fn container_hotbar_key(&self, key: KeyCode) -> Option<KeyCode> {
let name = key_name(key)?;
if self.get("key.swapOffhand") == name {
return Some(KeyCode::KeyF);
}
MAPPINGS
.iter()
.filter(|m| m.0.starts_with("key.hotbar."))
.find(|m| self.get(m.0) == name)
.and_then(|m| key_code(Self::default_of(m.0)))
}
}
#[cfg(test)]
@@ -519,6 +534,29 @@ mod tests {
assert_eq!(keys.container_mouse_key(MouseButton::Forward, false), None);
}
#[test]
fn container_hotkeys_follow_the_bound_keys() {
let mut keys = KeyBinds::default();
assert_eq!(
keys.container_hotbar_key(KeyCode::KeyF),
Some(KeyCode::KeyF)
);
assert_eq!(
keys.container_hotbar_key(KeyCode::Digit3),
Some(KeyCode::Digit3)
);
assert_eq!(keys.container_hotbar_key(KeyCode::Tab), None);
// The report's key: bound to the off hand, the menu takes it first.
keys.set("key.swapOffhand", "key.keyboard.tab");
assert_eq!(keys.container_hotbar_key(KeyCode::Tab), Some(KeyCode::KeyF));
assert_eq!(keys.container_hotbar_key(KeyCode::KeyF), None);
keys.set("key.hotbar.3", "key.keyboard.t");
assert_eq!(
keys.container_hotbar_key(KeyCode::KeyT),
Some(KeyCode::Digit3)
);
}
#[test]
fn rebinding_translates_to_defaults() {
let mut k = KeyBinds::default();
@@ -549,6 +549,7 @@ pub fn fullscreen_request(
/// it from the `horizon.desktop` entry, macOS from the app bundle). `None` if it can't be read.
pub fn icon() -> Option<winit::window::Icon> {
let mut decoder = png::Decoder::new(std::io::Cursor::new(horizon_theme::ICON_PNG));
decoder.ignore_checksums(true);
decoder.set_transformations(png::Transformations::EXPAND | png::Transformations::STRIP_16);
let mut reader = decoder.read_info().ok()?;
let mut bytes = vec![0; reader.output_buffer_size()?];
+221
View File
@@ -0,0 +1,221 @@
[package]
name = "horizon-client-lib"
autobins = false
autoexamples = false
autotests = false
version = "0.1.0"
edition.workspace = true
license.workspace = true
rust-version = "1.90"
description = "Reusable client systems, control tool and parity probes"
[lib]
path = "../horizon-client/src/client_lib.rs"
[dependencies]
horizon-assets = { path = "../horizon-assets" }
horizon-client-core = { path = "../horizon-client-core" }
horizon-client-input = { path = "../horizon-client-input" }
horizon-client-render = { path = "../horizon-client-render" }
horizon-command = { path = "../horizon-command" }
horizon-items = { path = "../horizon-items" }
horizon-core = { path = "../horizon-core" }
# The server thread ahead of the workers (MinecraftServer.spin's Thread.setPriority).
horizon-optifine = { path = "../horizon-optifine" }
horizon-threads = { path = "../horizon-threads" }
horizon-hdr = { path = "../horizon-hdr" }
horizon-world = { path = "../horizon-world" }
horizon-protocol = { path = "../horizon-protocol" }
# server.properties, eula.txt and the user lists, shared with horizon-server.
horizon-server-files = { path = "../horizon-server-files" }
# The dedicated server's anticheat, off unless server.toml's [anticheat] turns it on (docs/anticheat.md).
horizon-anticheat = { path = "../horizon-anticheat" }
# The dedicated server's chat filter: [chat_filter], the slur list, mutes (docs/chat-filter.md).
horizon-chat-filter = { path = "../horizon-chat-filter" }
# Shader packs: reading them and turning their GLSL into naga modules.
horizon-shaders = { path = "../horizon-shaders" }
# Horizon's own shader packs: the format, the stages and the check (docs/shaders.md).
horizon-shader-api = { path = "../horizon-shader-api" }
horizon-friends = { path = "../horizon-friends" }
# The narrator (GameNarrator): text-to-speech of chat, system messages and screens.
horizon-narrator = { path = "../horizon-narrator" }
# Horizon mods: sandboxed WebAssembly components, off unless installed and turned on (docs/mods.md).
# Their runtime is the `mods` feature.
horizon-mods = { path = "../horizon-mods", default-features = false, features = ["index"] }
wgpu = { version = "30.0.1", default-features = false, features = ["std", "parking_lot", "vulkan", "metal", "wgsl", "naga-ir"] }
winit = "0.30.13"
pollster = "0.4"
bytemuck = { version = "1.24", features = ["derive"] }
glam = "0.30"
png = "0.18"
sha2 = "0.10.9"
# Server resource packs are checked and cached by their SHA-1, as vanilla does.
sha1 = "0.10"
serde = { version = "1", features = ["derive"] }
serde_json = { version = "1.0.151", features = ["raw_value"] }
# hlsl-out: the gfx.rs tests translate every shader as wgpu's DirectX 12 backend does. A normal feature,
# not a dev-dependency's, so builds, tests and examples share one build of naga, wgpu and this crate.
naga = { version = "30.0.1", features = ["wgsl-in", "hlsl-out"] }
miniz_oxide = "0.8"
ab_glyph = "0.2.32"
log = "0.4"
chrono = { version = "0.4.42", default-features = false, features = ["clock", "std"] }
flate2 = { version = "1.1.10", default-features = false, features = ["zlib-rs", "runtime_detection"] }
horizon-theme = { path = "../horizon-theme" }
# Opt-in data collection: the game keeps, the launcher sends (docs/telemetry.md); `live` is
# the anonymous "I'm playing" ping the game sends itself (`heartbeat.rs`).
horizon-telemetry = { path = "../horizon-telemetry", default-features = false, features = ["live"] }
horizon-bench = { path = "../horizon-bench" }
zip = { version = "9.1.3", default-features = false, features = ["deflate"] }
# Controllers (evdev + libudev on Linux, IOKit on macOS, Windows.Gaming.Input); the `controllers` feature.
gilrs = { version = "0.11", optional = true }
# The DualSense's rumble, adaptive triggers and lights over HID (gilrs only reads it).
horizon-dualsense = { path = "../horizon-dualsense", optional = true }
# HTTPS to the Minecraft services (online-mode session join).
ureq = { version = "2", default-features = false, features = ["tls"] }
unicode-bidi = "0.3.18"
# `LevelStorageAccess.estimateDiskSpace`: usable disk space, without unsafe code on Windows.
fs4 = { version = "1.1.0", default-features = false, features = ["sync"] }
[target.'cfg(unix)'.dependencies]
# The server thread's CPU time per tick (`HORIZON_SPIKES`, spans.rs).
rustix = { version = "1", default-features = false, features = ["std", "time", "process", "fs"] }
[target.'cfg(windows)'.dependencies]
# Use the registered URI handler without routing links through Explorer/cmd.
open = { version = "5.4.5", features = ["shellexecute-on-windows"] }
# DirectX 12, where Vulkan has no adapter or device, or by choice (`graphicsApi`, graphics.rs).
# Shaders compile with FXC (d3dcompiler_47.dll, part of Windows), or DXC when dxcompiler.dll is
# on the PATH.
wgpu = { version = "30.0.1", default-features = false, features = ["dx12"] }
[target.'cfg(target_os = "macos")'.dependencies]
# Splitscreen: a window filling a monitor goes over its menu bar (game/split/windows.rs
# `above_menu_bar`); all already in the tree through winit and wgpu.
objc2 = "0.6"
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSApplication", "NSResponder", "NSWindow", "NSScreen"] }
objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSArray", "NSString"] }
[target.'cfg(not(target_os = "macos"))'.dependencies]
# OpenGL (WGL on Windows, EGL on Linux), the last fallback, for GPUs with no Vulkan or
# DirectX 12 driver (`graphicsApi`, graphics.rs).
wgpu = { version = "30.0.1", default-features = false, features = ["gles"] }
[features]
default = ["audio", "controllers"]
# Sound output. Without it (and `controllers`) the program links neither libasound nor libudev on
# Linux: the server build (`--no-default-features`, released as horizon-engine; docs/server.md).
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"]
# Records the call sites of mob random draws (`mob_ai_probe` traces).
rng-trace = ["horizon-core/rng-trace"]
# Compatibility with earlier experiment commands: main already uses mimalloc.
windows-mimalloc = []
# Opt-in Windows heap experiment; see docs/windows-allocators.md.
windows-segment-heap = []
[lints]
workspace = true
[dev-dependencies]
horizon-client-render = { path = "../horizon-client-render", features = ["test-support"] }
horizon-items = { path = "../horizon-items", features = ["test-support"] }
# Every WGSL shader translated as wgpu's DirectX 12 backend does (gfx.rs tests).
naga = { version = "30.0.1", features = ["wgsl-in", "hlsl-out", "msl-out", "spv-out"] }
[[bin]]
name = "horizon-ctl"
path = "../horizon-client/src/bin/horizon-ctl/main.rs"
[[example]]
name = "chunk_load_bench"
path = "../horizon-client/examples/chunk_load_bench.rs"
[[example]]
name = "gameplay_decode_profile"
path = "../horizon-client/examples/gameplay_decode_profile.rs"
[[example]]
name = "json_load_bench"
path = "../horizon-client/examples/json_load_bench.rs"
[[example]]
name = "mod_port_probe"
path = "../horizon-client/examples/mod_port_probe.rs"
[[example]]
name = "model_library_bench"
path = "../horizon-client/examples/model_library_bench.rs"
[[example]]
name = "model_states_bench"
path = "../horizon-client/examples/model_states_bench.rs"
[[example]]
name = "old_world_fixture"
path = "../horizon-client/examples/old_world_fixture.rs"
[[example]]
name = "portal_scan_bench"
path = "../horizon-client/examples/portal_scan_bench.rs"
[[example]]
name = "random_tick_eligibility_bench"
path = "../horizon-client/examples/random_tick_eligibility_bench.rs"
[[example]]
name = "resource_memory"
path = "../horizon-client/examples/resource_memory.rs"
[[example]]
name = "save_batch_bench"
path = "../horizon-client/examples/save_batch_bench.rs"
[[example]]
name = "shared_json_startup_profile"
path = "../horizon-client/examples/shared_json_startup_profile.rs"
[[example]]
name = "sprite_cache_parity"
path = "../horizon-client/examples/sprite_cache_parity.rs"
[[example]]
name = "client_probe"
path = "../horizon-client/examples/probe/main.rs"
[[test]]
name = "custom_component_api"
path = "../horizon-client/tests/custom_component_api.rs"
[[test]]
name = "custom_component_unregistered"
path = "../horizon-client/tests/custom_component_unregistered.rs"
[[test]]
name = "custom_component_wire"
path = "../horizon-client/tests/custom_component_wire.rs"
[[test]]
name = "forced_chunks"
path = "../horizon-client/tests/forced_chunks.rs"
[[test]]
name = "generation_saves"
path = "../horizon-client/tests/generation_saves.rs"
[[test]]
name = "lan_bot"
path = "../horizon-client/tests/lan_bot.rs"
[[test]]
name = "streaming_meshes"
path = "../horizon-client/tests/streaming_meshes.rs"
[[test]]
name = "tall_column"
path = "../horizon-client/tests/tall_column.rs"
[[test]]
name = "wide_states"
path = "../horizon-client/tests/wide_states.rs"
+3
View File
@@ -17,6 +17,9 @@ horizon-hdr = { path = "../horizon-hdr" }
horizon-items = { path = "../horizon-items" }
# The narrator option's status and whether the narrator is there (Options.narrator).
horizon-narrator = { path = "../horizon-narrator" }
# OptiFine-format pack files: random entity textures (hud.rs), connected textures
# (connected.rs) and custom item texture rules (cit.rs).
horizon-optifine = { path = "../horizon-optifine" }
horizon-protocol = { path = "../horizon-protocol" }
horizon-shader-api = { path = "../horizon-shader-api" }
horizon-shaders = { path = "../horizon-shaders" }
@@ -0,0 +1,436 @@
//! How long a big resource pack takes to load and reload, on generated files only (no game
//! resources): a stand-in vanilla root, a server-style zip pack of many namespaces, and a
//! high-resolution pack over vanilla's own textures. Each stage runs a few times; the median
//! is printed as a Markdown table.
//! `cargo run --profile fast -p horizon-client-render --example pack_load_bench [-- --scale 1]`
//! (`.github/workflows/pack-bench.yml` runs it on a branch and on its base, side by side).
use horizon_client_render::{
hud::HudAtlas,
model_library::ParsedModels,
packs::{self, Pack},
sprites::Sprites,
};
use std::{
fs,
path::{Path, PathBuf},
time::Instant,
};
/// A small deterministic generator: the same files on every run and every branch.
struct Noise(u64);
impl Noise {
fn next(&mut self) -> u64 {
self.0 ^= self.0 << 13;
self.0 ^= self.0 >> 7;
self.0 ^= self.0 << 17;
self.0
}
}
/// A `side`x`side` RGBA PNG (`frames` tall when animated) of blotchy pixels, which compress
/// about as well as real textures do.
fn png(noise: &mut Noise, side: u32, frames: u32, alpha: bool) -> Vec<u8> {
let mut out = Vec::new();
{
let mut encoder = png::Encoder::new(&mut out, side, side * frames);
encoder.set_color(png::ColorType::Rgba);
encoder.set_depth(png::BitDepth::Eight);
let mut writer = encoder.write_header().unwrap();
let mut pixels = Vec::with_capacity((side * side * frames * 4) as usize);
let mut colour = [0u8; 4];
for i in 0..side * side * frames {
if i % 3 == 0 {
let n = noise.next();
colour = [n as u8, (n >> 8) as u8, (n >> 16) as u8, 255];
if alpha && n >> 40 & 3 == 0 {
colour[3] = 0;
}
}
pixels.extend_from_slice(&colour);
}
writer.write_image_data(&pixels).unwrap();
}
out
}
fn model(noise: &mut Noise, namespace: &str, texture: &str) -> Vec<u8> {
let n = noise.next() % 4;
format!(
r##"{{"parent":"minecraft:block/cube_all","textures":{{"all":"{namespace}:{texture}"}},"elements":[{{"from":[0,0,0],"to":[16,{h},16],"faces":{{"north":{{"texture":"#all","uv":[0,0,16,16]}},"up":{{"texture":"#all"}}}}}}]}}"##,
h = 4 + n * 4
)
.into_bytes()
}
fn write(root: &Path, name: &str, bytes: &[u8]) {
let path = root.join(name);
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(path, bytes).unwrap();
}
/// A deflated zip written by hand (the reader skips CRCs, so they stay zero), at most 65535
/// entries.
#[derive(Default)]
struct ZipOut {
out: Vec<u8>,
central: Vec<u8>,
count: u16,
}
impl ZipOut {
fn put(&mut self, name: &str, body: &[u8]) {
let data = miniz_oxide::deflate::compress_to_vec(body, 6);
let offset = u32::try_from(self.out.len()).unwrap();
let sizes = [data.len() as u32, body.len() as u32];
self.out.extend(0x0403_4b50u32.to_le_bytes());
self.out.extend([20, 0, 0, 0, 8, 0]);
self.out.extend([0; 8]);
sizes.iter().for_each(|s| self.out.extend(s.to_le_bytes()));
self.out.extend((name.len() as u16).to_le_bytes());
self.out.extend([0; 2]);
self.out.extend(name.as_bytes());
self.out.extend(&data);
self.central.extend(0x0201_4b50u32.to_le_bytes());
self.central.extend([20, 0, 20, 0, 0, 0, 8, 0]);
self.central.extend([0; 8]);
sizes
.iter()
.for_each(|s| self.central.extend(s.to_le_bytes()));
self.central.extend((name.len() as u16).to_le_bytes());
self.central.extend([0; 12]);
self.central.extend(offset.to_le_bytes());
self.central.extend(name.as_bytes());
self.count = self.count.checked_add(1).expect("too many zip entries");
}
fn finish(mut self, path: &Path) {
let start = self.out.len() as u32;
self.out.extend(&self.central);
self.out.extend(0x0605_4b50u32.to_le_bytes());
self.out.extend([0; 4]);
self.out.extend(self.count.to_le_bytes());
self.out.extend(self.count.to_le_bytes());
self.out.extend((self.central.len() as u32).to_le_bytes());
self.out.extend(start.to_le_bytes());
self.out.extend([0; 2]);
fs::write(path, self.out).unwrap();
}
}
const META: &[u8] = br#"{"pack":{"description":"bench","min_format":[69,0],"max_format":[999,0]}}"#;
/// The stand-in game resources: vanilla-sized texture folders, models and the stacked JSON
/// files (lang, sounds, fonts, atlases) every reload merges.
fn vanilla(root: &Path, scale: usize, noise: &mut Noise) -> Vec<String> {
let mut blocks = Vec::new();
for i in 0..1100 * scale {
let name = format!("block/b{i}");
let frames = if i % 50 == 0 { 8 } else { 1 };
write(
root,
&format!("assets/minecraft/textures/{name}.png"),
&png(noise, 16, frames, i % 7 == 0),
);
if frames > 1 {
write(
root,
&format!("assets/minecraft/textures/{name}.png.mcmeta"),
br#"{"animation":{"frametime":2}}"#,
);
}
write(
root,
&format!("assets/minecraft/models/block/b{i}.json"),
&model(noise, "minecraft", &name),
);
blocks.push(format!("minecraft:{name}"));
}
for (folder, count, side) in [("item", 1500, 16), ("entity", 900, 64), ("gui", 800, 16)] {
for i in 0..count * scale {
let sub = if folder == "gui" { "sprites/" } else { "" };
write(
root,
&format!(
"assets/minecraft/textures/{folder}/{sub}{}/t{i}.png",
i % 20
),
&png(noise, side, 1, true),
);
}
}
for i in 0..1500 * scale {
write(
root,
&format!("assets/minecraft/models/item/i{i}.json"),
&model(noise, "minecraft", &format!("item/{}/t{i}", i % 20)),
);
}
let lang: String = (0..4000)
.map(|i| format!("\"key.{i}\":\"Value {i}\""))
.collect::<Vec<_>>()
.join(",");
write(
root,
"assets/minecraft/lang/en_us.json",
format!("{{{lang}}}").as_bytes(),
);
write(
root,
"assets/minecraft/sounds.json",
br#"{"a":{"sounds":["a"]}}"#,
);
write(
root,
"assets/minecraft/atlases/blocks.json",
br#"{"sources":[{"type":"directory","source":"block","prefix":"block/"}]}"#,
);
write(root, "pack.mcmeta", META);
blocks
}
/// A server pack the way ItemsAdder-style plugins make them: one zip of many namespaces, each
/// with its own items' textures and models, its sounds and its lang files.
fn server_zip(path: &Path, scale: usize, noise: &mut Noise) {
let mut zip = ZipOut::default();
zip.put("pack.mcmeta", META);
for n in 0..190 {
let ns = format!("plugin{n}");
for i in 0..50 * scale {
let texture = format!("item/{}/t{i}", i % 5);
zip.put(
&format!("assets/{ns}/textures/{texture}.png"),
&png(noise, 32, 1, true),
);
zip.put(
&format!("assets/{ns}/models/item/{}/m{i}.json", i % 5),
&model(noise, &ns, &texture),
);
if i % 10 == 0 {
zip.put(&format!("assets/{ns}/sounds/s{i}.ogg"), &[0; 2048]);
}
}
zip.put(
&format!("assets/{ns}/lang/en_us.json"),
br#"{"item.custom":"Custom"}"#,
);
}
let lang: String = (0..2000)
.map(|i| format!("\"server.{i}\":\"Server {i}\""))
.collect::<Vec<_>>()
.join(",");
zip.put(
"assets/minecraft/lang/en_us.json",
format!("{{{lang}}}").as_bytes(),
);
zip.finish(path);
}
/// A 64x pack over vanilla's own block, item and entity textures, with OptiFine CTM-style
/// tiles beside them: what the big OptiFine packs look like on disk.
fn hd_zip(path: &Path, scale: usize, noise: &mut Noise) {
let mut zip = ZipOut::default();
zip.put("pack.mcmeta", META);
for i in 0..1100 * scale {
zip.put(
&format!("assets/minecraft/textures/block/b{i}.png"),
&png(noise, 64, 1, i % 7 == 0),
);
if i % 4 == 0 {
for tile in 0..12 {
zip.put(
&format!("assets/minecraft/optifine/ctm/b{i}/{tile}.png"),
&png(noise, 64, 1, false),
);
}
zip.put(
&format!("assets/minecraft/optifine/ctm/b{i}/b{i}.properties"),
format!("matchTiles=b{i}\nmethod=ctm\ntiles=0-11\n").as_bytes(),
);
}
}
for i in 0..750 * scale {
zip.put(
&format!("assets/minecraft/textures/item/{}/t{i}.png", i % 20),
&png(noise, 64, 1, true),
);
}
zip.finish(path);
}
fn median(mut ms: Vec<f64>) -> f64 {
ms.sort_by(f64::total_cmp);
ms[ms.len() / 2]
}
/// `f`'s median time over `runs`, with `before` (not timed) ahead of each run.
fn time(runs: usize, mut before: impl FnMut(), mut f: impl FnMut()) -> f64 {
median(
(0..runs)
.map(|_| {
before();
let started = Instant::now();
f();
started.elapsed().as_secs_f64() * 1000.0
})
.collect(),
)
}
fn zip_pack(id: &str, path: PathBuf) -> Pack {
// Read the way `packs::folder` reads a `resourcepacks/` zip.
let dir = path.parent().unwrap().join(format!("{id}-dir"));
fs::create_dir_all(&dir).unwrap();
let at = dir.join(path.file_name().unwrap());
fs::rename(&path, &at).unwrap();
packs::folder(&dir, packs::RESOURCE_FORMAT)
.into_iter()
.next()
.unwrap_or_else(|| panic!("{id} is not a pack"))
}
fn main() {
let args: Vec<String> = std::env::args().collect();
let scale = args
.iter()
.position(|a| a == "--scale")
.and_then(|i| args.get(i + 1))
.map_or(1, |s| s.parse().unwrap());
let runs = 3;
let scratch = std::env::temp_dir().join(format!("horizon-pack-bench-{}", std::process::id()));
let _ = fs::remove_dir_all(&scratch);
fs::create_dir_all(&scratch).unwrap();
let mut noise = Noise(0x9e37_79b9_7f4a_7c15);
let vanilla_root = scratch.join("vanilla");
let blocks = vanilla(&vanilla_root, scale, &mut noise);
server_zip(&scratch.join("server.zip"), scale, &mut noise);
hd_zip(&scratch.join("hd.zip"), scale, &mut noise);
let server = zip_pack("server", scratch.join("server.zip"));
let hd = zip_pack("hd", scratch.join("hd.zip"));
let cache = scratch.join("cache");
let both = [hd.clone(), server.clone()];
let mut rows: Vec<(String, f64)> = Vec::new();
let mut row = |name: &str, ms: f64| {
eprintln!("{name}: {ms:.1} ms");
rows.push((name.to_owned(), ms));
};
let fresh = || {
let _ = fs::remove_dir_all(&cache);
};
row(
"overlay: build, both packs (cold)",
time(runs, fresh, || {
packs::resource_root(&vanilla_root, &both, &cache).unwrap();
}),
);
let root = packs::resource_root(&vanilla_root, &both, &cache).unwrap();
row(
"overlay: reuse, both packs (cache hit)",
time(
runs,
|| {},
|| {
packs::resource_root(&vanilla_root, &both, &cache).unwrap();
},
),
);
// The pack screen: one pack turned off, then on again.
row(
"overlay: toggle the server pack off (new selection)",
time(
runs,
|| {
let only =
packs::resource_root(&vanilla_root, std::slice::from_ref(&hd), &cache).unwrap();
fs::remove_dir_all(&only).unwrap();
},
|| {
packs::resource_root(&vanilla_root, std::slice::from_ref(&hd), &cache).unwrap();
},
),
);
row(
"models: parse every model",
time(
runs,
|| {},
|| {
std::hint::black_box(ParsedModels::load(&root));
},
),
);
let sprite_cache = root.join("horizon-sprites.cache");
// OptiFine connected-texture tiles, inserted by their path as `ConnectedTextures` does.
let tiles: Vec<String> = (0..1100 * scale)
.step_by(4)
.flat_map(|i| (0..12).map(move |t| format!("assets/minecraft/optifine/ctm/b{i}/{t}.png")))
.collect();
let sprites = |cold: bool, prefetch: bool, names: &[String], files: bool| {
time(
runs,
|| {
if cold {
let _ = fs::remove_file(&sprite_cache);
}
},
|| {
let mut sprites = Sprites::with_disk_cache(&root);
if prefetch {
sprites.prefetch(&root, names.iter().map(String::as_str));
}
for name in names {
if files {
sprites.insert_file(&root, name).unwrap();
} else {
sprites.insert(&root, name).unwrap();
}
}
sprites.save_disk_cache();
sprites.finish();
std::hint::black_box(&sprites.pixels);
},
)
};
row(
"block sprites: one by one (no cache)",
sprites(true, false, &blocks, false),
);
row(
"block sprites: prefetched (no cache)",
sprites(true, true, &blocks, false),
);
row(
"block sprites: from the disk cache",
sprites(false, false, &blocks, false),
);
row("CTM tiles: one by one", sprites(false, false, &tiles, true));
row("CTM tiles: prefetched", sprites(false, true, &tiles, true));
row(
"GUI atlas: decode and stitch, both packs",
time(
runs,
|| {},
|| {
if let Err(e) = HudAtlas::build_over(&root, &vanilla_root) {
eprintln!("GUI atlas failed: {e}");
}
},
),
);
row(
"GUI atlas: vanilla alone",
time(
runs,
|| {},
|| {
let _ = HudAtlas::build(&vanilla_root);
},
),
);
let cores = std::thread::available_parallelism().map_or(1, |n| n.get());
println!("| Stage (scale {scale}, {cores} threads, median of {runs}) | ms |");
println!("|---|---:|");
for (name, ms) in &rows {
println!("| {name} | {ms:.0} |");
}
let _ = fs::remove_dir_all(&scratch);
}
+30 -2
View File
@@ -101,6 +101,8 @@ pub struct RenderAssets {
pub pbr_normals: Vec<u8>,
pub pbr_specular: Vec<u8>,
pub pbr_size: u32,
/// OptiFine connected textures (empty without `optifine/ctm` files).
pub connected: crate::connected::ConnectedTextures,
}
fn invalid(message: &str) -> io::Error {
io::Error::new(io::ErrorKind::InvalidData, message)
@@ -240,9 +242,34 @@ impl RenderAssets {
for stage in 0..10 {
sprites.insert(root, &format!("minecraft:block/destroy_stage_{stage}"))?;
}
if draws {
let connected = if draws {
crate::native_emissive::load(root, &mut sprites);
}
// A side's sprite, for rules that connect by tile: the first quad facing it.
let catalog = &models.catalog;
let mut found = Vec::new();
crate::connected::ConnectedTextures::load(
root,
registry,
&mut sprites,
|state, face| {
let side = horizon_assets::model::Direction::ALL[face as usize];
catalog
.models_at(
state,
horizon_core::position::BlockPos::new(0, 0, 0),
&mut found,
)
.ok()?;
found
.iter()
.flat_map(|m| &m.faces)
.find(|f| f.quad.facing == side)
.map(|f| f.layer)
},
)
} else {
Default::default()
};
sprites.save_disk_cache();
if draws {
sprites.finish();
@@ -267,6 +294,7 @@ impl RenderAssets {
pbr_normals: sprites.normals,
pbr_specular: sprites.specular,
pbr_size: sprites.pbr_size,
connected,
})
}
}
@@ -172,6 +172,7 @@ fn color(v: &Value) -> Option<u32> {
fn colormap(root: &Path, name: &str) -> Result<Vec<u32>> {
let path = root.join(format!("assets/minecraft/textures/colormap/{name}.png"));
let mut decoder = png::Decoder::new(std::io::BufReader::new(fs::File::open(path)?));
decoder.ignore_checksums(true);
decoder.set_transformations(png::Transformations::EXPAND | png::Transformations::STRIP_16);
let mut reader = decoder.read_info()?;
let mut bytes = vec![0; reader.output_buffer_size().ok_or("colormap too large")?];
@@ -0,0 +1,306 @@
//! Bulk block edits: runs of blocks up columns written straight into the
//! resident sections, a section unpacked and packed once however many of its
//! blocks change, instead of a `World::set` per block. Nothing round them is
//! told (as `fill ... strict`); the light of each chunk changed is worked out
//! again whole, off the game thread, rather than block by block, and each
//! section changed is remeshed once.
use crate::chunk::index;
use crate::fast_hash::FxHashMap;
use crate::world::World;
/// A run of one column, resolved: blocks `y0..=y1` of column (x, z) set to
/// `state` where filter `only` (if any) passes the block there.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Run {
pub x: i32,
pub z: i32,
pub y0: i32,
pub y1: i32,
pub state: u32,
pub only: Option<u16>,
}
/// What an edit changed.
#[derive(Debug, Default)]
pub struct Outcome {
/// Blocks changed.
pub changed: u64,
/// Chunks with a change.
pub chunks: usize,
/// Changes of the states `tracked` asked for (block entities, watched
/// blocks): position, previous and new state.
pub tracked: Vec<((i32, i32, i32), u32, u32)>,
}
/// Per state answers, worked out once each.
struct Memo(Vec<u8>);
impl Memo {
#[inline]
fn get(&mut self, state: u32, ask: impl FnOnce(u32) -> bool) -> bool {
let i = state as usize;
if i >= self.0.len() {
self.0.resize(i + 1, 0);
}
if self.0[i] == 0 {
self.0[i] = 1 + u8::from(ask(state));
}
self.0[i] == 2
}
}
impl World {
/// Make `runs` in order, each seeing the changes of those before it.
/// `matches(filter, state)` says whether a filter passes a state, and
/// `tracked(state)` whether its changes are reported; each is asked once
/// per state. Runs in unloaded chunks and the parts of runs outside each
/// chunk's own column (`Chunk::layout`) are left out.
///
/// Unlike `World::set`, this records nothing per block: no
/// `block_entities.state_changed` (a caller adds or removes block
/// entities itself, from `Outcome::tracked`), no `watched_writes` (ask
/// for those states through `tracked`), no `block_log` entry (the chunk
/// goes in `bulk_log` instead, sent whole), no `path_writes` (mobs'
/// paths are not recomputed for these blocks) and no `light_edits` (the
/// chunk and its neighbours are lit again whole, through `dirty_light`).
pub fn edit_runs(
&mut self,
runs: &[Run],
mut matches: impl FnMut(u16, u32) -> bool,
mut tracked: impl FnMut(u32) -> bool,
) -> Outcome {
let mut out = Outcome::default();
// the runs of each chunk, in order, and the chunks in the order they were first named
let mut by_chunk: FxHashMap<(i32, i32), Vec<u32>> = FxHashMap::default();
let mut order = Vec::new();
for (i, r) in runs.iter().enumerate() {
if r.y1 < r.y0 {
continue;
}
let key = (r.x >> 4, r.z >> 4);
by_chunk
.entry(key)
.or_insert_with(|| {
order.push(key);
Vec::new()
})
.push(i as u32);
}
let mut filter_memo: Vec<Memo> = Vec::new();
let mut tracked_memo = Memo(Vec::new());
let mut values: Vec<Option<Box<[u32; 4096]>>> = Vec::new();
let mut changed: Vec<bool> = Vec::new();
for key in order {
let Some(chunk) = self.chunks.get(&key) else {
continue;
};
let (min_y, sections) = (chunk.min_y(), chunk.sections.len());
let max_y = chunk.max_y();
let list = &by_chunk[&key];
changed.clear();
changed.resize(sections, false);
values.clear();
values.resize_with(sections, || None);
let mut any = 0u64;
for &i in list {
let r = runs[i as usize];
let (lx, lz) = (r.x & 15, r.z & 15);
let (y0, y1) = (r.y0.max(min_y), r.y1.min(max_y - 1));
let mut y = y0;
while y <= y1 {
let s = ((y - min_y) >> 4) as usize;
let top = (min_y + (s as i32 + 1) * 16 - 1).min(y1);
let vals = values[s].get_or_insert_with(|| {
let mut v = Box::new([0u32; 4096]);
chunk.sections[s].unpack(&mut v);
v
});
for yy in y..=top {
let at = index(lx, yy, lz);
let old = vals[at];
if old == r.state {
continue;
}
if let Some(f) = r.only {
let f = f as usize;
if filter_memo.len() <= f {
filter_memo.resize_with(f + 1, || Memo(Vec::new()));
}
if !filter_memo[f].get(old, |s| matches(f as u16, s)) {
continue;
}
}
vals[at] = r.state;
changed[s] = true;
any += 1;
if tracked_memo.get(old, &mut tracked)
|| tracked_memo.get(r.state, &mut tracked)
{
out.tracked.push(((r.x, yy, r.z), old, r.state));
}
}
y = top + 1;
}
}
if any == 0 {
continue;
}
out.changed += any;
out.chunks += 1;
let chunk =
std::sync::Arc::make_mut(self.chunks.get_mut(&key).expect("looked up above"));
for s in 0..sections {
if changed[s]
&& let Some(v) = &values[s]
{
chunk.replace_blocks(s, v);
}
}
self.revision = self.revision.wrapping_add(1);
self.modified.insert(key);
if self.block_log.is_some() {
self.bulk_log.insert(key);
}
// remeshed once (with the neighbours whose faces and light it touches), lit again whole
for (s, _) in changed.iter().enumerate().filter(|c| *c.1) {
for ds in -1..=1 {
let sy = s as i32 + ds;
if !(0..sections as i32).contains(&sy) {
continue;
}
for dz in -1..=1 {
for dx in -1..=1 {
self.dirty_sections.insert((key.0 + dx, sy, key.1 + dz));
}
}
}
}
for dz in -1..=1 {
for dx in -1..=1 {
self.dirty_light.insert((key.0 + dx, key.1 + dz));
}
}
}
out
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::chunk::Chunk;
use horizon_world::registry::BlockStateId;
use std::sync::Arc;
fn world() -> World {
let mut w = World::new(BlockStateId(0), 0);
for cz in -1..=1 {
for cx in -1..=1 {
w.chunks.insert((cx, cz), Arc::new(Chunk::empty(cx, cz)));
}
}
// stone up to y 63
for z in -16..32 {
for x in -16..32 {
for y in -64..64 {
w.set(x, y, z, BlockStateId(1)).unwrap();
}
}
}
w.dirty_light.clear();
w.dirty_sections.clear();
w.block_log = Some(Vec::new());
w
}
fn run(x: i32, z: i32, y0: i32, y1: i32, state: u32, only: Option<u16>) -> Run {
Run {
x,
z,
y0,
y1,
state,
only,
}
}
#[test]
fn runs_change_their_blocks_in_order() {
let mut w = world();
let runs = [
run(3, 4, 10, 40, 0, None),
run(3, 4, 10, 12, 5, None),
// only where it is air: over the stone, and the three blocks of 5 stay
run(3, 4, 0, 80, 7, Some(0)),
run(-1, 20, -70, 400, 9, None),
];
let out = w.edit_runs(&runs, |_, s| s == 0, |_| false);
assert_eq!(w.block(3, 9, 4).0, 1);
assert_eq!(w.block(3, 11, 4).0, 5);
assert_eq!(w.block(3, 13, 4).0, 7);
assert_eq!(w.block(3, 70, 4).0, 7);
assert_eq!(w.block(3, 81, 4).0, 0);
assert_eq!(w.block(3, 0, 4).0, 1);
assert_eq!(w.block(-1, -64, 20).0, 9);
assert_eq!(w.block(-1, 319, 20).0, 9);
assert_eq!(w.block(0, 30, 20).0, 1);
// 31 dug, 3 set again, the air over them filled (28, and 17 over the stone), the whole of the other column
assert_eq!(out.changed, 31 + 3 + 45 + 384);
assert_eq!(out.chunks, 2);
assert!(w.bulk_log.contains(&(0, 0)) && w.bulk_log.contains(&(-1, 1)));
assert!(w.dirty_light.contains(&(1, 1)) && w.dirty_light.contains(&(-2, 2)));
assert!(w.dirty_sections.contains(&(1, 4, 1)));
// the per-block log is left to the blocks set one at a time
assert!(w.block_log.as_ref().unwrap().is_empty());
}
#[test]
fn unloaded_chunks_and_unchanged_blocks_are_left_out() {
let mut w = world();
let out = w.edit_runs(
&[run(100, 100, 0, 10, 0, None), run(0, 0, 0, 10, 1, None)],
|_, _| true,
|_| false,
);
assert_eq!(out.changed, 0);
assert_eq!(out.chunks, 0);
assert!(w.bulk_log.is_empty() && w.dirty_light.is_empty());
}
#[test]
fn tracked_states_are_reported() {
let mut w = world();
let out = w.edit_runs(&[run(1, 1, 60, 65, 3, None)], |_, _| true, |s| s == 3);
assert_eq!(out.tracked.len(), 6);
assert_eq!(out.tracked[0], ((1, 60, 1), 1, 3));
assert_eq!(out.tracked[5], ((1, 65, 1), 0, 3));
}
#[test]
fn heightmaps_follow_the_edit() {
let mut w = world();
let air = |s: u32| s == 0;
assert_eq!(w.world_surface(5, 5, |s| air(s.0)), 64);
w.edit_runs(&[run(5, 5, 20, 63, 0, None)], |_, _| true, |_| false);
assert_eq!(w.world_surface(5, 5, |s| air(s.0)), 20);
w.edit_runs(&[run(5, 5, 90, 90, 2, None)], |_, _| true, |_| false);
assert_eq!(w.world_surface(5, 5, |s| air(s.0)), 91);
}
#[test]
fn runs_follow_each_chunks_own_layout() {
use crate::chunk::ColumnLayout;
// a data pack's taller dimension: -128..=511
let tall = ColumnLayout::new(-128, 640).unwrap();
let mut w = World::new(BlockStateId(0), 0);
w.chunks
.insert((0, 0), Arc::new(Chunk::with_layout(0, 0, tall)));
let out = w.edit_runs(&[run(2, 3, -500, 900, 4, None)], |_, _| true, |_| false);
assert_eq!(out.changed, 640);
let chunk = &w.chunks[&(0, 0)];
assert_eq!(chunk.block(2, -128, 3).0, 4);
assert_eq!(chunk.block(2, -65, 3).0, 4);
assert_eq!(chunk.block(2, 320, 3).0, 4);
assert_eq!(chunk.block(2, 511, 3).0, 4);
assert!(w.dirty_sections.contains(&(0, 39, 0)));
assert!(!w.dirty_sections.contains(&(0, 40, 0)));
}
}
@@ -23,7 +23,7 @@
//! Which states tick is a table by state id, built by the game from the block
//! registry; whether a slot is made is decided when the slot is promoted at
//! the start of the next tick, from the block state then.
use crate::chunk::block_entity::{BlockEntity, ModContainer, ModFluidTanks};
use crate::chunk::block_entity::{BlockEntity, ModContainer, ModEnergyBuffer, ModFluidTanks};
use crate::fast_hash::{FxHashMap, FxHashSet};
use std::collections::HashMap;
use std::sync::Arc;
@@ -76,6 +76,29 @@ pub struct BlockEntities {
/// The fluid tanks of mod blocks' block entities, by block state id;
/// built with `states`, like the containers.
mod_fluids: FxHashMap<u32, Arc<ModFluidTanks>>,
/// The energy buffers of mod blocks' block entities, by block state id;
/// built with `states`, like the containers.
mod_energy: FxHashMap<u32, Arc<ModEnergyBuffer>>,
/// The positions of the mod block entities (`BlockEntity::Mod`) by chunk,
/// for the renderer: the mesher lists block entities by their template,
/// which leaves out a mod block whose template has none. One taken out
/// (`take`) stays listed until `put` or `remove`.
mods: FxHashMap<(i32, i32), FxHashSet<Pos>>,
}
fn chunk_of((x, _, z): Pos) -> (i32, i32) {
(x >> 4, z >> 4)
}
/// Takes `pos` out of its chunk's mod block entity set.
fn forget_mod(mods: &mut FxHashMap<(i32, i32), FxHashSet<Pos>>, pos: Pos) {
let chunk = chunk_of(pos);
if let Some(set) = mods.get_mut(&chunk) {
set.remove(&pos);
if set.is_empty() {
mods.remove(&chunk);
}
}
}
impl std::ops::Deref for BlockEntities {
@@ -172,6 +195,13 @@ impl BlockEntities {
pub fn mod_fluids(&self, state: u32) -> Option<&Arc<ModFluidTanks>> {
self.mod_fluids.get(&state)
}
pub fn set_mod_energy(&mut self, energy: FxHashMap<u32, Arc<ModEnergyBuffer>>) {
self.mod_energy = energy;
}
/// The energy buffer of a mod block's block entity in block state `state`.
pub fn mod_energy(&self, state: u32) -> Option<&Arc<ModEnergyBuffer>> {
self.mod_energy.get(&state)
}
fn next(&mut self) -> u32 {
let n = self.next_slot;
@@ -190,6 +220,23 @@ impl BlockEntities {
self.pending.remove(pos);
}
/// Whether any mod block entity is in the map: until one is, a block change
/// never looks one up (`LevelAccess::has_mod_block_entity`).
pub fn has_mods(&self) -> bool {
!self.mods.is_empty()
}
/// The mod block entities within `radius` blocks of `x`, `z` (by chunk, so
/// far ones are skipped whole; the caller checks each distance).
pub fn mods_near(&self, x: f64, z: f64, radius: f64) -> impl Iterator<Item = &Pos> {
let far = move |c: i32, at: f64| {
let (lo, hi) = (f64::from(c * 16), f64::from(c * 16 + 16));
at < lo - radius || at > hi + radius
};
self.mods
.iter()
.filter(move |((cx, cz), _)| !far(*cx, x) && !far(*cz, z))
.flat_map(|(_, set)| set)
}
pub fn get_mut(&mut self, pos: &Pos) -> Option<&mut BlockEntity> {
self.map.get_mut(pos)
}
@@ -202,10 +249,16 @@ impl BlockEntities {
/// `LevelChunk.setBlockEntity` (+ `addAndRegisterBlockEntity`): a new
/// block entity may get a slot; one replacing another keeps its slot.
pub fn insert(&mut self, pos: Pos, entity: BlockEntity) -> Option<BlockEntity> {
let new_is_mod = matches!(entity, BlockEntity::Mod(_));
let old = self.map.insert(pos, entity);
if old.is_none() {
self.add(pos);
}
if new_is_mod {
self.mods.entry(chunk_of(pos)).or_default().insert(pos);
} else if matches!(old, Some(BlockEntity::Mod(_))) {
forget_mod(&mut self.mods, pos);
}
old
}
/// The block entity at `pos`, made by `make` when there is none.
@@ -214,15 +267,24 @@ impl BlockEntities {
pos: Pos,
make: impl FnOnce() -> BlockEntity,
) -> &mut BlockEntity {
if !self.map.contains_key(&pos) {
let absent = !self.map.contains_key(&pos);
if absent {
self.add(pos);
}
self.map.entry(pos).or_insert_with(make)
let entity = self.map.entry(pos).or_insert_with(make);
if absent && matches!(entity, BlockEntity::Mod(_)) {
self.mods.entry(chunk_of(pos)).or_default().insert(pos);
}
entity
}
/// `LevelChunk.removeBlockEntity`: the block entity and its slot.
pub fn remove(&mut self, pos: &Pos) -> Option<BlockEntity> {
self.cancel(pos);
self.map.remove(pos)
let removed = self.map.remove(pos);
if matches!(removed, Some(BlockEntity::Mod(_))) {
forget_mod(&mut self.mods, *pos);
}
removed
}
/// The block entity at `pos`, out of the map for a while: its slot stays
/// for `put` to bring it back (a tick working on it).
@@ -236,6 +298,9 @@ impl BlockEntities {
/// Brings back what `take` took, in its slot.
pub fn put(&mut self, pos: Pos, entity: BlockEntity) {
self.out.remove(&pos);
if matches!(entity, BlockEntity::Mod(_)) {
self.mods.entry(chunk_of(pos)).or_default().insert(pos);
}
if self.map.insert(pos, entity).is_none()
&& !self.live.contains_key(&pos)
&& !self.pending.contains_key(&pos)
@@ -247,17 +312,20 @@ impl BlockEntities {
/// (`clearAllBlockEntities` of unloaded chunks).
pub fn retain(&mut self, mut keep: impl FnMut(&Pos, &mut BlockEntity) -> bool) {
let (live, pending, freed) = (&mut self.live, &mut self.pending, &mut self.freed);
let mods = &mut self.mods;
self.map.retain(|p, e| {
let kept = keep(p, e);
if !kept {
*freed |= live.remove(p).is_some();
pending.remove(p);
forget_mod(mods, *p);
}
kept
});
}
pub fn clear(&mut self) {
self.map.clear();
self.mods.clear();
self.tickers = Arc::default();
self.live.clear();
self.added.clear();
@@ -274,9 +342,16 @@ impl BlockEntities {
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 {
if self.map.insert(p, e).is_some() {
let new_is_mod = matches!(e, BlockEntity::Mod(_));
let old = self.map.insert(p, e);
if old.is_some() {
self.replaced.insert(p);
}
if new_is_mod {
self.mods.entry(chunk_of(p)).or_default().insert(p);
} else if matches!(old, Some(BlockEntity::Mod(_))) {
forget_mod(&mut self.mods, p);
}
}
for p in order {
self.add(p);
@@ -369,6 +444,38 @@ mod tests {
.collect()
}
fn near(b: &BlockEntities, x: f64, z: f64, radius: f64) -> Vec<Pos> {
let mut found: Vec<Pos> = b.mods_near(x, z, radius).copied().collect();
found.sort();
found
}
#[test]
fn mod_block_entities_are_listed_by_chunk_until_they_go() {
let mut b = level();
let modded = || BlockEntity::Mod(Box::default());
b.insert((1, 64, 1), modded());
b.insert((200, 64, 1), modded());
b.insert((2, 64, 2), hopper());
b.get_or_insert_with((3, 64, 3), modded);
b.load_chunk(vec![((4, 64, 4), modded()), ((5, 64, 5), hopper())]);
// Only mods, and only chunks within reach.
assert_eq!(
near(&b, 0.0, 0.0, 64.0),
[(1, 64, 1), (3, 64, 3), (4, 64, 4)]
);
assert_eq!(near(&b, 200.0, 0.0, 8.0), [(200, 64, 1)]);
// Taken out for a tick, still listed; removed or replaced, gone.
let taken = b.take(&(1, 64, 1)).unwrap();
assert_eq!(near(&b, 0.0, 0.0, 64.0).len(), 3);
b.put((1, 64, 1), taken);
b.remove(&(3, 64, 3));
b.insert((4, 64, 4), hopper());
assert_eq!(near(&b, 0.0, 0.0, 64.0), [(1, 64, 1)]);
b.retain(|p, _| p.0 != 1);
assert!(near(&b, 0.0, 0.0, 64.0).is_empty());
b.clear();
assert!(near(&b, 200.0, 0.0, 8.0).is_empty());
}
#[test]
fn slots_follow_vanilla_lifecycle() {
let mut b = level();
+50 -17
View File
@@ -29,6 +29,10 @@ pub type Signature = horizon_protocol::secure_chat::Signature;
/// How long a message shows before a deletion replaces it
/// (`ChatComponent.deleteMessageOrDelay`: `addedTime + 60`).
const DELETION_DELAY: u64 = 60;
/// The smooth chat extra: the ticks a new line takes to slide in and fade in,
/// and how far left of its place it starts (GUI pixels).
const INTRO_TICKS: f32 = 5.0;
const INTRO_WIDTH: f32 = 12.0;
/// `ChatVisiblity`: the Chat option's three values.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -233,14 +237,10 @@ impl Tag {
/// `ChatComponent.AlphaCalculator.timeBased`: opaque for 180 ticks, then
/// fading out quadratically over 20.
fn time_alpha(ticks: u64, added: u64) -> f32 {
let delta = ticks as i64 - added as i64;
let mut t = delta as f64 / 200.0;
t = 1.0 - t;
t *= 10.0;
t = t.clamp(0.0, 1.0);
t *= t;
t as f32
fn time_alpha(ticks: f32, added: u64) -> f32 {
let mut t = 1.0 - (ticks - added as f32) / 200.0;
t = (t * 10.0).clamp(0.0, 1.0);
t * t
}
#[derive(Default)]
pub struct Chat {
@@ -256,6 +256,11 @@ pub struct Chat {
scroll: usize,
new_since_scroll: bool,
pub options: ChatOptions,
/// The smooth chat extra: a new line slides in from the left while it
/// fades in, instead of appearing whole.
pub smooth: bool,
/// The smooth chat extra: no indicator bar at the left of a line.
pub hide_indicator: bool,
/// Messages printed to stdout as `[CHAT]` (vanilla logs them).
pub log: bool,
/// Messages added since the start, for the control socket's events.
@@ -555,7 +560,7 @@ impl Chat {
}
/// `ChatComponent.extractRenderState` (background mode unless `focused`)
/// on a screen `height` GUI pixels tall at `Hud.tickCount` `ticks`.
pub fn layout(&self, height: i32, ticks: u64, focused: bool) -> Vec<GuiDraw> {
pub fn layout(&self, height: i32, ticks: f32, focused: bool) -> Vec<GuiDraw> {
let total = self.lines.len();
if total == 0 {
return Vec::new();
@@ -569,28 +574,47 @@ impl Chat {
let entry_height = o.line_height();
let bottom_to_text =
(8.0 * (o.line_spacing + 1.0) - 4.0 * o.line_spacing + 0.5).floor() as i32;
// Vanilla's 200-tick fade reads the whole GUI ticks
// (`Gui.getGuiTicks`); only the smooth chat extra eases it per frame.
let fade_ticks = if self.smooth { ticks } else { ticks.floor() };
let alpha_of = |line: &Line| {
if focused {
1.0
} else {
time_alpha(ticks, line.added)
time_alpha(fade_ticks, line.added)
}
};
// The smooth chat extra: how far into its intro a line is, 0 at the
// tick it was added and 1 once it has arrived, as (alpha, offset x).
let intro_of = |line: &Line| {
if focused || !self.smooth {
return (1.0, 0.0);
}
let t = ((ticks - line.added as f32) / INTRO_TICKS).clamp(0.0, 1.0);
let eased = 1.0 - (1.0 - t) * (1.0 - t);
(eased, (eased - 1.0) * INTRO_WIDTH)
};
let visible = total
.saturating_sub(self.scroll)
.min(self.lines_per_page(focused));
let lines: Vec<(usize, &Line, f32)> = (0..visible)
let lines: Vec<(usize, &Line, f32, f32)> = (0..visible)
.rev()
.map(|i| (i, &self.lines[i + self.scroll]))
.map(|(i, line)| (i, line, alpha_of(line)))
.filter(|(_, _, alpha)| *alpha > 1.0e-5)
.map(|(i, line)| {
let (eased, slide) = intro_of(line);
(i, line, alpha_of(line) * eased, slide)
})
.filter(|(_, _, alpha, _)| *alpha > 1.0e-5)
.collect();
let mut out = vec![GuiDraw::Push(vec![
PoseOp::Scale(scale, scale),
PoseOp::Translate(4.0, 0.0),
])];
for &(i, _, alpha) in &lines {
for &(i, _, alpha, slide) in &lines {
let bottom = chat_bottom - i as i32 * entry_height;
if slide != 0.0 {
out.push(GuiDraw::Push(vec![PoseOp::Translate(slide, 0.0)]));
}
out.push(hud_layout::fill(
-4,
bottom - entry_height,
@@ -598,9 +622,15 @@ impl Chat {
bottom,
hud_layout::as8(alpha * background_opacity) << 24,
));
if slide != 0.0 {
out.push(GuiDraw::Pop);
}
}
for &(i, line, alpha) in &lines {
for &(i, line, alpha, slide) in &lines {
let bottom = chat_bottom - i as i32 * entry_height;
if slide != 0.0 {
out.push(GuiDraw::Push(vec![PoseOp::Translate(slide, 0.0)]));
}
let opacity = alpha * text_opacity;
let color = hud_layout::white(opacity);
if color >> 24 != 0 {
@@ -612,7 +642,7 @@ impl Chat {
shadow: true,
});
}
if let Some(tag) = &line.tag {
if let Some(tag) = line.tag.as_ref().filter(|_| !self.hide_indicator) {
let color = hud_layout::as8(opacity) << 24 | tag.color & 0xFF_FFFF;
out.push(hud_layout::fill(
-4,
@@ -622,6 +652,9 @@ impl Chat {
color,
));
}
if slide != 0.0 {
out.push(GuiDraw::Pop);
}
}
if focused {
let count = lines.len() as i32;
@@ -658,7 +691,7 @@ impl Chat {
}
/// Draws [`Chat::layout`] into the HUD batch, and with the chat screen
/// open the restricted prompt above the lines.
pub fn draw(&self, batch: &mut HudBatch, atlas: &HudAtlas, ticks: u64, focused: bool) {
pub fn draw(&self, batch: &mut HudBatch, atlas: &HudAtlas, ticks: f32, focused: bool) {
let draws = self.layout(batch.height as i32, ticks, focused);
hud_layout::paint(batch, atlas, &draws);
if focused && self.abilities.any_restriction() {
+56 -1
View File
@@ -39,6 +39,61 @@ fn add(chat: &mut Chat, font: &Font, content: Text) {
}
}
#[test]
fn smooth_chat_slides_a_line_in_and_can_hide_the_indicator() {
let mut chat = Chat {
smooth: true,
..Chat::default()
};
chat.lines.push(Line {
added: 0,
content: Text::literal("hi"),
tag: Some(std::sync::Arc::new(Tag::system_single_player(
SYSTEM_INDICATOR,
))),
});
let text_alpha = |draws: &[GuiDraw]| {
draws.iter().find_map(|d| match d {
GuiDraw::Text { color, .. } => Some(color >> 24),
_ => None,
})
};
// The slide is the negative translate the line carries while it moves.
let slide = |draws: &[GuiDraw]| {
draws.iter().find_map(|d| match d {
GuiDraw::Push(ops) => ops.iter().find_map(|op| match op {
PoseOp::Translate(x, _) if *x < 0.0 => Some(*x),
_ => None,
}),
_ => None,
})
};
// The tick it arrives: fully faded out, so nothing is drawn.
assert_eq!(text_alpha(&chat.layout(361, 0.0, false)), None);
// Halfway through the intro: left of its place and still faint.
let halfway = chat.layout(361, INTRO_TICKS / 2.0, false);
assert!(slide(&halfway).is_some_and(|x| (-INTRO_WIDTH..0.0).contains(&x)));
let faded = text_alpha(&halfway).expect("the line shows halfway through");
// Settled: in place and as opaque as vanilla's line.
let settled = chat.layout(361, INTRO_TICKS, false);
assert_eq!(slide(&settled), None);
assert!(text_alpha(&settled).expect("settled") > faded);
// The extra off draws the line whole on the tick it arrives, as vanilla.
chat.smooth = false;
let plain = chat.layout(361, 0.0, false);
assert_eq!(slide(&plain), None);
assert_eq!(text_alpha(&plain), text_alpha(&settled));
// The indicator bar is the -4 to -2 fill at the left of the line.
let bar = |draws: &[GuiDraw]| {
draws
.iter()
.any(|d| matches!(d, GuiDraw::Fill { x0: -4, x1: -2, .. }))
};
assert!(bar(&plain));
chat.hide_indicator = true;
assert!(!bar(&chat.layout(361, 0.0, false)));
}
#[test]
fn clicking_seed_copies_the_raw_value_only_inside_the_link() {
let font = font();
@@ -95,7 +150,7 @@ fn click_targets_match_rendered_spans_when_scaled_wrapped_and_scrolled() {
for scroll in [0, 3, 12] {
chat.scroll = scroll;
let mut links = 0;
for draw in chat.layout(361, 0, true) {
for draw in chat.layout(361, 0.0, true) {
if let GuiDraw::Text { text, x, y, .. } = draw {
let mut at = x as f64;
for (c, style) in text.chars() {
@@ -508,6 +508,15 @@ impl Chunk {
}
previous
}
/// Section `s`'s block states replaced whole (a bulk edit): its storage
/// compacted, and the heightmaps built again on their next read.
pub fn replace_blocks(&mut self, s: usize, values: &[u32; 4096]) {
let section = Arc::make_mut(&mut self.sections[s]);
section.blocks = Section::from_blocks(values);
section.compact();
self.surface = OnceLock::new();
self.motion_blocking = OnceLock::new();
}
/// `getHeight(Heightmap.Types.WORLD_SURFACE, x, z)` (`x`, `z` within the
/// chunk): one above the highest block that isn't `air`, or the column's
/// minimum Y.
@@ -135,6 +135,23 @@ pub struct ModFluidTanks {
/// The tanks a drain may take from (`canTakeItemThroughFace`).
pub outputs: Vec<usize>,
}
/// The energy buffer of a mod block's block entity (NeoForge's
/// `SimpleEnergyHandler`): its capacity, the most one insert and one extract
/// moves, and the faces a fill may come through and a drain may leave by.
/// The amount is not here: it lives in the entity's saved compound, under
/// `energy`, so it survives restarts and the block's own tick reads it.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct ModEnergyBuffer {
pub capacity: i32,
/// The most one insert moves; 0 takes none.
pub max_insert: i32,
/// The most one extract moves; 0 gives none.
pub max_extract: i32,
/// The faces, by `Direction` number, a fill may come through.
pub insert_faces: [bool; 6],
/// The faces a drain may leave by.
pub extract_faces: [bool; 6],
}
/// The vanilla menus a mod container can open, with the slot count each
/// shows: slot grids, and the furnaces' (input, fuel, result), whose four
/// data slots draw the flame (`data[0]` of `data[1]`) and the arrow
+662
View File
@@ -0,0 +1,662 @@
//! OptiFine's Custom Item Textures (and CIT Resewn's): the rules of the enabled packs'
//! `optifine/cit/**/*.properties`, read from the overlay resource root. The rules themselves are
//! `horizon_optifine::cit`'s.
pub use horizon_optifine::cit::{Hand, Kind, Rule};
use horizon_client_core::component::name_text;
use horizon_client_core::scoreboard::comp_text;
use horizon_items::item_data::{canon_tag, encode_components};
use horizon_items::items::ItemStack;
use horizon_optifine::cit::{ComponentTest, Source, ValueTest};
use horizon_optifine::{Properties, properties_files_in};
use horizon_world::nbt::{JavaString, Tag, parse_snbt};
use std::borrow::Cow;
use std::collections::HashMap;
use std::fs;
use std::hash::{DefaultHasher, Hash, Hasher};
use std::path::Path;
use std::sync::Mutex;
/// Every rule under the root's `optifine/cit/`, `mcpatcher/cit/` and CIT Resewn's
/// `citresewn/cit/` folders, the one chosen first when several match first (`Rule::priority`).
/// The pack's global `cit.properties` is not a rule.
pub fn load_rules(root: &Path) -> Vec<Rule> {
let tops = ["optifine", "mcpatcher", "citresewn"];
let mut rules: Vec<Rule> = properties_files_in(root, &tops, "cit")
.into_iter()
.filter(|path| !path.ends_with("/cit.properties"))
.filter_map(|path| {
// Java reads `.properties` as Latin-1: a pack saved that way still loads.
let bytes = fs::read(root.join(&path)).ok()?;
let text = String::from_utf8(bytes)
.unwrap_or_else(|e| e.into_bytes().iter().map(|&b| char::from(b)).collect());
let rule = Rule::parse(&path, &Properties::parse_equals_first(&text), |p| {
root.join(p).is_file()
});
if rule.is_none() {
log::warn!("custom item textures: {path} is not a rule Horizon can read");
}
rule
})
.collect();
rules.sort_by(Rule::priority);
rules
}
/// The rules with an index by item and a cache of what each stack matched.
#[derive(Debug, Default)]
pub struct CitRules {
pub rules: Vec<Rule>,
/// Per kind (`Kind as usize`), the rules naming each item, in priority order.
by_item: [HashMap<String, Vec<u32>>; 4],
/// Enchantment rules without `items`, in priority order.
any_item: Vec<u32>,
/// Per rule, per component test: its literal read as SNBT and as a JSON text component.
literals: Vec<Vec<Prepared>>,
selected: Mutex<HashMap<u64, Option<u32>>>,
enchanted: Mutex<HashMap<u64, Vec<u32>>>,
}
#[derive(Debug, Default)]
struct Prepared {
snbt: Option<Tag>,
json: Option<String>,
}
/// Cache entries kept before the cache starts over.
const CACHE_LIMIT: usize = 4096;
impl CitRules {
/// The enabled packs' rules under the overlay resource root.
pub fn load(root: &Path) -> Self {
Self::new(load_rules(root))
}
/// `rules` in priority order, the winner first.
pub fn new(rules: Vec<Rule>) -> Self {
let mut by_item: [HashMap<String, Vec<u32>>; 4] = Default::default();
let mut any_item = Vec::new();
for (i, rule) in rules.iter().enumerate() {
let i = i as u32;
if rule.kind == Kind::Enchantment && rule.items.is_empty() {
any_item.push(i);
}
for item in &rule.items {
let list = by_item[rule.kind as usize].entry(item.clone()).or_default();
if list.last() != Some(&i) {
list.push(i);
}
}
}
let literals = rules
.iter()
.map(|r| r.components.iter().map(prepare).collect())
.collect();
Self {
rules,
by_item,
any_item,
literals,
..Default::default()
}
}
pub fn is_empty(&self) -> bool {
self.rules.is_empty()
}
/// The winning rule of `kind` (Item, Armor or Elytra) for this stack, as an index into `rules`.
pub fn select(
&self,
kind: Kind,
stack: &ItemStack,
hand: Hand,
max_damage: u32,
) -> Option<usize> {
let candidates = self.by_item[kind as usize].get(&*stack.item)?;
let comps = encode_components(stack);
let key = cache_key((0, kind, hand), stack, max_damage, comps.as_ref());
if let Some(hit) = lock(&self.selected).get(&key) {
return hit.map(|i| i as usize);
}
let found = candidates
.iter()
.copied()
.find(|&i| self.matches(i, stack, Some(hand), max_damage, comps.as_ref()));
remember(&self.selected, key, found);
found.map(|i| i as usize)
}
/// Every matching Enchantment rule, in priority order (layers are the glint PR's business).
pub fn enchantments(&self, stack: &ItemStack, max_damage: u32) -> Vec<usize> {
let named = self.by_item[Kind::Enchantment as usize]
.get(&*stack.item)
.map_or(&[][..], Vec::as_slice);
if named.is_empty() && self.any_item.is_empty() {
return Vec::new();
}
let comps = encode_components(stack);
let key = cache_key(
(1, Kind::Enchantment, Hand::Any),
stack,
max_damage,
comps.as_ref(),
);
let hit = lock(&self.enchanted).get(&key).cloned();
let found = hit.unwrap_or_else(|| {
let mut all: Vec<u32> = named.iter().chain(&self.any_item).copied().collect();
all.sort_unstable();
all.retain(|&i| self.matches(i, stack, None, max_damage, comps.as_ref()));
remember(&self.enchanted, key, all.clone());
all
});
found.into_iter().map(|i| i as usize).collect()
}
/// Whether rule `i` matches; `hand` None skips the hand test (enchantment glints).
fn matches(
&self,
i: u32,
stack: &ItemStack,
hand: Option<Hand>,
max_damage: u32,
comps: Option<&Tag>,
) -> bool {
let rule = &self.rules[i as usize];
if hand.is_some_and(|h| rule.hand != Hand::Any && rule.hand != h) {
return false;
}
let count = i32::try_from(stack.count).unwrap_or(i32::MAX);
if rule.stack_size.as_ref().is_some_and(|r| !r.contains(count)) {
return false;
}
// CIT Resewn: only damageable items have damage.
let damage = if max_damage > 0 { stack.damage } else { 0 };
if rule
.damage
.as_ref()
.is_some_and(|d| !d.matches(damage, max_damage))
{
return false;
}
let enchantments = stack.crafting_enchantments();
let listed = |id: &str| rule.enchantments.iter().any(|e| e == id);
if !rule.enchantments.is_empty() && !enchantments.iter().any(|(id, _)| listed(id)) {
return false;
}
if let Some(levels) = &rule.enchantment_levels {
let level_in_range = enchantments.iter().any(|(id, level)| {
(rule.enchantments.is_empty() || listed(id))
&& levels.contains(i32::try_from(*level).unwrap_or(i32::MAX))
});
if !level_in_range {
return false;
}
}
let literals = &self.literals[i as usize];
rule.components
.iter()
.zip(literals)
.all(|(test, lit)| component_test(test, lit, stack, comps) != test.negate)
}
}
fn lock<T>(m: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
m.lock().unwrap_or_else(|e| e.into_inner())
}
fn remember<V>(cache: &Mutex<HashMap<u64, V>>, key: u64, value: V) {
let mut cache = lock(cache);
if cache.len() >= CACHE_LIMIT {
cache.clear();
}
cache.insert(key, value);
}
/// What a cached answer depends on; `query` tells `select` from `enchantments`.
fn cache_key(query: (u8, Kind, Hand), stack: &ItemStack, max: u32, comps: Option<&Tag>) -> u64 {
let mut h = DefaultHasher::new();
(query.0, query.1 as u8, query.2 as u8, &*stack.item).hash(&mut h);
(stack.count, stack.damage, max, comps).hash(&mut h);
h.finish()
}
fn prepare(test: &ComponentTest) -> Prepared {
let ValueTest::Literal(lit) = &test.value else {
return Prepared::default();
};
let text = lit.text.trim();
let snbt = match parse_snbt(text) {
Ok((tag, read)) if read == text.len() && text.starts_with(['{', '[']) => Some(tag),
_ => None,
};
let json = json_plain(text);
Prepared { snbt, json }
}
/// The plain text of `s` read as a JSON text component.
fn json_plain(s: &str) -> Option<String> {
if !s.starts_with(['{', '[', '"']) {
return None;
}
let value: serde_json::Value = serde_json::from_str(s).ok()?;
Some(comp_text(&value).plain())
}
/// Whether one test passes, before its `negate`.
fn component_test(
test: &ComponentTest,
lit: &Prepared,
stack: &ItemStack,
comps: Option<&Tag>,
) -> bool {
let legacy;
let (root, text_depth) = match &test.source {
Source::Component(id) => {
let depth = match id.as_str() {
"minecraft:custom_name" | "minecraft:item_name" => Some(0),
"minecraft:lore" => Some(1),
_ => None,
};
(comps.and_then(|c| c.get(id)), depth)
}
Source::LegacyEnchantments { stored } => {
let levels = if *stored {
stack.stored_enchantments()
} else {
stack.enchantments()
};
legacy = Tag::List(
levels
.iter()
.map(|(id, lvl)| {
let lvl = i16::try_from(*lvl).unwrap_or(i16::MAX);
Tag::compound([
("id", Tag::String((&**id).into())),
("lvl", Tag::Short(lvl)),
])
})
.collect(),
);
(Some(&legacy), None)
}
};
// Fields the component leaves out when 0, which pre-1.20.5 items always wrote.
let defaulted;
let root = match (&test.source, root, test.path.first()) {
(Source::Component(id), Some(Tag::Compound(map)), Some(field))
if matches!(
(id.as_str(), field.as_str()),
("minecraft:written_book_content", "generation")
| ("minecraft:fireworks", "flight_duration")
) && !map.contains_key(&JavaString::from(field.as_str())) =>
{
let mut map = map.clone();
map.insert(JavaString::from(field.as_str()), Tag::Int(0));
defaulted = Tag::Compound(map);
Some(&defaulted)
}
(_, root, _) => root,
};
let Some(root) = root else {
return matches!(test.value, ValueTest::Exists(false));
};
if let ValueTest::Exists(want) = test.value {
return walk(root, &test.path, 0, &mut |_, _| true) == want;
}
walk(root, &test.path, 0, &mut |tag, depth| {
value_test(&test.value, lit, tag, text_depth == Some(depth))
})
}
/// Visits what `path` reaches from `tag`, true once `visit` is.
fn walk(
tag: &Tag,
path: &[String],
depth: usize,
visit: &mut dyn FnMut(&Tag, usize) -> bool,
) -> bool {
let Some((seg, rest)) = path.split_first() else {
return visit(tag, depth);
};
let mut next = |t: &Tag| walk(t, rest, depth + 1, visit);
let len = match tag {
Tag::Compound(map) if seg == "*" => return map.values().any(&mut next),
Tag::Compound(map) => return map.get(&JavaString::from(seg.as_str())).is_some_and(next),
Tag::List(v) => v.len(),
Tag::ByteArray(v) => v.len(),
Tag::IntArray(v) => v.len(),
Tag::LongArray(v) => v.len(),
_ => return false,
};
let element = |i: usize| match tag {
Tag::List(v) => Cow::Borrowed(&v[i]),
Tag::ByteArray(v) => Cow::Owned(Tag::Byte(v[i] as i8)),
Tag::IntArray(v) => Cow::Owned(Tag::Int(v[i])),
Tag::LongArray(v) => Cow::Owned(Tag::Long(v[i])),
_ => Cow::Owned(Tag::End),
};
if seg == "count" {
return next(&Tag::Int(i32::try_from(len).unwrap_or(i32::MAX)));
}
if seg == "*" {
return (0..len).any(|i| next(&element(i)));
}
seg.parse::<usize>()
.ok()
.filter(|&i| i < len)
.is_some_and(|i| next(&element(i)))
}
fn int_value(tag: &Tag) -> Option<i64> {
match *tag {
Tag::Byte(v) => Some(v.into()),
Tag::Short(v) => Some(v.into()),
Tag::Int(v) => Some(v.into()),
Tag::Long(v) => Some(v),
_ => None,
}
}
/// A number as Java's `String.valueOf` writes it (`3`, `3.0`).
fn decimal(tag: &Tag) -> Option<String> {
let s = match *tag {
Tag::Float(b) => f32::from_bits(b).to_string(),
Tag::Double(b) => f64::from_bits(b).to_string(),
_ => return int_value(tag).map(|v| v.to_string()),
};
Some(if s.contains(['.', 'e', 'N', 'i']) {
s
} else {
s + ".0"
})
}
/// `text` against a string, its JSON text, or the plain text of a text component.
fn text_matches(tag: &Tag, is_text: bool, text: impl Fn(&str) -> bool) -> bool {
match tag {
Tag::String(s) => {
let s = s.to_string_lossy();
text(&s)
|| (is_text && text(&name_text(tag).plain()))
|| json_plain(&s).is_some_and(|p| text(&p))
}
Tag::Compound(_) | Tag::List(_) => is_text && text(&name_text(tag).plain()),
_ => false,
}
}
fn value_test(test: &ValueTest, lit: &Prepared, tag: &Tag, is_text: bool) -> bool {
match test {
ValueTest::Exists(_) => true,
ValueTest::Range(r) => {
int_value(tag).is_some_and(|v| i32::try_from(v).is_ok_and(|v| r.contains(v)))
}
ValueTest::Raw(name) => name.matches(&canon_tag(tag)),
ValueTest::Text(name) => match decimal(tag) {
Some(n) => name.matches(&n),
None => text_matches(tag, is_text, |s| name.matches(s)),
},
ValueTest::Literal(l) => match tag {
Tag::Float(b) => l.text.trim().parse::<f32>().ok() == Some(f32::from_bits(*b)),
Tag::Double(b) => l.float == Some(f64::from_bits(*b)),
Tag::Compound(_) | Tag::List(_)
if lit.snbt.as_ref().is_some_and(|e| contains(e, tag)) =>
{
true
}
_ => match int_value(tag) {
Some(v) => l.int == Some(v),
None => text_matches(tag, is_text, |s| {
s == l.text || lit.json.as_deref() == Some(s)
}),
},
},
}
}
/// `NbtUtils.compareNbt(expected, actual, true)`: every key and element of `expected` found in
/// `actual`.
fn contains(expected: &Tag, actual: &Tag) -> bool {
match (expected, actual) {
(Tag::Compound(e), Tag::Compound(a)) => e
.iter()
.all(|(k, v)| a.get(k).is_some_and(|a| contains(v, a))),
(Tag::List(e), Tag::List(a)) if e.is_empty() => a.is_empty(),
(Tag::List(e), Tag::List(a)) => e.iter().all(|e| a.iter().any(|a| contains(e, a))),
_ => expected == actual,
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Arc;
/// Rules from (file stem, properties), sorted as `load_rules` sorts them.
fn rules(files: &[(&str, &str)]) -> CitRules {
let mut rules: Vec<Rule> = files
.iter()
.map(|(stem, text)| {
let path = format!("assets/minecraft/optifine/cit/{stem}.properties");
let text = format!("texture=x\n{text}");
Rule::parse(&path, &Properties::parse_equals_first(&text), |_| true).unwrap()
})
.collect();
rules.sort_by(Rule::priority);
CitRules::new(rules)
}
fn stem(cit: &CitRules, i: Option<usize>) -> Option<&str> {
let path = &cit.rules[i?].path;
path.rsplit('/').next()?.strip_suffix(".properties")
}
fn item(cit: &CitRules, stack: ItemStack) -> Option<&str> {
stem(cit, cit.select(Kind::Item, &stack, Hand::Main, 0))
}
fn new(id: &str) -> ItemStack {
ItemStack::new(&format!("minecraft:{id}"), 1)
}
fn string(s: &str) -> Tag {
Tag::String(s.into())
}
fn with(id: &str, component: &str, tag: Tag) -> ItemStack {
let mut stack = new(id);
let component = format!("minecraft:{component}");
stack.data_mut().other.push((component, tag));
stack
}
fn enchanted(id: &str, levels: &[(&str, u32)]) -> ItemStack {
let mut stack = new(id);
let levels = levels
.iter()
.map(|(e, l)| (Arc::from(format!("minecraft:{e}")), *l));
stack.data_mut().enchantments = levels.collect();
stack
}
#[test]
fn heavier_rules_win_and_kinds_stay_apart() {
let cit = rules(&[
("plain", "items=stick"),
("heavy", "items=stick\nweight=2\nstackSize=2-"),
("offhand", "items=stick\nweight=5\nhand=off"),
("armor", "type=armor\nitems=stick"),
("wings", "type=elytra"),
]);
assert_eq!(item(&cit, new("stick")), Some("plain"));
assert_eq!(
item(&cit, ItemStack::new("minecraft:stick", 3)),
Some("heavy")
);
let pick = |kind, id, hand, max| stem(&cit, cit.select(kind, &new(id), hand, max));
assert_eq!(pick(Kind::Item, "stick", Hand::Off, 0), Some("offhand"));
assert_eq!(item(&cit, new("stone")), None);
assert_eq!(item(&cit, new("elytra")), None);
assert_eq!(pick(Kind::Elytra, "elytra", Hand::Main, 432), Some("wings"));
assert_eq!(pick(Kind::Armor, "stick", Hand::Main, 0), Some("armor"));
}
#[test]
fn names_match_by_their_plain_text() {
let cit = rules(&[
("glob", "items=stick\nnbt.display.Name=ipattern:*sword"),
("exact", "items=stone\ncomponents.custom_name=My Sword"),
("json", "items=dirt\nnbt.display.Name=[\"My \",\"Sword\"]"),
]);
let named = |id: &str, name: Tag| {
let mut stack = new(id);
stack.data_mut().custom_name = Some(name);
stack
};
let styled = Tag::compound([("text", string("My Sword")), ("color", string("red"))]);
assert_eq!(item(&cit, named("stick", styled.clone())), Some("glob"));
assert_eq!(item(&cit, named("stick", string("Blade"))), None);
assert_eq!(item(&cit, new("stick")), None);
assert_eq!(item(&cit, named("stone", styled.clone())), Some("exact"));
assert_eq!(item(&cit, named("dirt", styled)), Some("json"));
}
#[test]
fn lore_lines_and_list_counts() {
let cit = rules(&[
(
"any_line",
"items=stick\nnbt.display.Lore.*=ipattern:*legendary*",
),
("two_lines", "items=stone\ncomponents.lore.count=2"),
]);
let bold = Tag::compound([("text", string("A LEGENDARY item")), ("bold", Tag::Byte(1))]);
let lore = Tag::List(vec![string("Common"), bold]);
assert_eq!(
item(&cit, with("stick", "lore", lore.clone())),
Some("any_line")
);
assert_eq!(item(&cit, with("stone", "lore", lore)), Some("two_lines"));
let one = Tag::List(vec![string("A legendary item")]);
assert_eq!(item(&cit, with("stone", "lore", one)), None);
}
#[test]
fn custom_data_existence_and_negation() {
let cit = rules(&[
(
"aote",
"items=stick\nweight=2\nnbt.ExtraAttributes.id=ASPECT_OF_THE_END",
),
(
"not_hyp",
"items=stick\nweight=1\ncomponent.custom_data.ExtraAttributes.id=!HYP",
),
("unnamed", "items=stick\nnbt.display.Name=exists:false"),
]);
let data = |id: &str| {
let attributes =
Tag::compound([("ExtraAttributes", Tag::compound([("id", string(id))]))]);
with("stick", "custom_data", attributes)
};
assert_eq!(item(&cit, data("ASPECT_OF_THE_END")), Some("aote"));
assert_eq!(item(&cit, data("MIDAS")), Some("not_hyp"));
assert_eq!(item(&cit, data("HYP")), Some("unnamed"));
let mut named = data("HYP");
named.data_mut().custom_name = Some(string("Hyperion"));
assert_eq!(item(&cit, named), None);
}
#[test]
fn legacy_enchantment_lists_and_model_data() {
let cit = rules(&[
(
"sharp",
"items=stick\nnbt.Enchantments.*.id=minecraft:sharpness\nnbt.Enchantments.0.lvl=5",
),
("cmd", "items=stone\nnbt.CustomModelData=3"),
(
"cmd_snbt",
"items=dirt\ncomponents.custom_model_data={floats:[3.0f]}",
),
]);
assert_eq!(
item(&cit, enchanted("stick", &[("sharpness", 5)])),
Some("sharp")
);
assert_eq!(item(&cit, enchanted("stick", &[("sharpness", 4)])), None);
let model = |id: &str, f: f32| {
let floats = Tag::List(vec![Tag::Float(f.to_bits()), Tag::Float(7f32.to_bits())]);
with(id, "custom_model_data", Tag::compound([("floats", floats)]))
};
assert_eq!(item(&cit, model("stone", 3.0)), Some("cmd"));
assert_eq!(item(&cit, model("stone", 3.5)), None);
assert_eq!(item(&cit, model("dirt", 3.0)), Some("cmd_snbt"));
assert_eq!(item(&cit, model("dirt", 2.0)), None);
}
#[test]
fn fields_left_out_at_zero_read_as_zero() {
let cit = rules(&[("zero", "items=firework_rocket\nnbt.Fireworks.Flight=0")]);
let mut rocket = new("firework_rocket");
rocket.data_mut().fireworks = Some((0, Vec::new()));
assert_eq!(item(&cit, rocket.clone()), Some("zero"));
rocket.data_mut().fireworks = Some((2, Vec::new()));
assert_eq!(item(&cit, rocket), None);
}
#[test]
fn damage_counts_only_for_damageable_items() {
let cit = rules(&[
("worn", "items=diamond_sword\ndamage=50-100%"),
("new", "items=stick\ndamage=0"),
]);
let damaged = |id: &str, damage: u32, max: u32| {
let mut stack = new(id);
stack.damage = damage;
stem(&cit, cit.select(Kind::Item, &stack, Hand::Main, max))
};
// Half of 1561 is 780.5.
assert_eq!(damaged("diamond_sword", 780, 1561), None);
assert_eq!(damaged("diamond_sword", 781, 1561), Some("worn"));
assert_eq!(damaged("stick", 5, 0), Some("new"));
}
#[test]
fn enchantment_levels_and_rules_for_any_item() {
let cit = rules(&[
(
"sharp3",
"items=stick\nenchantments=sharpness\nenchantmentLevels=3-",
),
("any3", "items=stone\nenchantmentLevels=3-"),
("glint", "type=enchantment\nenchantments=sharpness"),
(
"sword_glint",
"type=enchantment\nitems=diamond_sword\nweight=2",
),
]);
let mixed = [("sharpness", 2), ("unbreaking", 3)];
assert_eq!(item(&cit, enchanted("stick", &mixed)), None);
assert_eq!(
item(&cit, enchanted("stick", &[("sharpness", 4)])),
Some("sharp3")
);
assert_eq!(item(&cit, enchanted("stone", &mixed)), Some("any3"));
let glints = |stack: ItemStack| -> Vec<&str> {
let found = cit.enchantments(&stack, 0).into_iter();
found.map(|i| stem(&cit, Some(i)).unwrap()).collect()
};
let sharp = [("sharpness", 1)];
assert_eq!(
glints(enchanted("diamond_sword", &sharp)),
["sword_glint", "glint"]
);
assert_eq!(glints(enchanted("stick", &sharp)), ["glint"]);
assert!(glints(new("stick")).is_empty());
// Glints are never an item's texture.
assert_eq!(item(&cit, enchanted("diamond_sword", &sharp)), None);
}
}
@@ -199,8 +199,9 @@ where
}
impl SceneCollisions<'_, '_> {
/// Position contexts permit geometric movement probes without inventing a
/// player, inventory, effects or fluid-standing behavior; a lava walker
/// collides as the living entity it is, empty-handed.
/// player, inventory, effects or fluid-standing behavior; a lava walker or
/// a living entity in leather boots collides as the living entity it is,
/// empty-handed.
pub fn resolve(
&mut self,
bounds: Aabb,
@@ -236,7 +237,10 @@ impl SceneCollisions<'_, '_> {
}
fn context(&self, bounds: Aabb) -> CollisionContext<'static> {
let y = bounds.min_position().y;
if !self.subject.is_some_and(|s| s.lava_walker) {
if !self
.subject
.is_some_and(|s| s.lava_walker || s.leather_boots)
{
return CollisionContext::position(y);
}
CollisionContext::for_entity(
@@ -0,0 +1,735 @@
//! OptiFine connected textures (`optifine/ctm`) at mesh time. The active packs' rules are read
//! once at load, their tiles become sprite layers, and each block state and sprite layer gets
//! the short list of rules that can touch it; meshing a face then walks only those lists
//! ([`ConnectedTextures::pick`]). Without CTM files there are no rules and nothing changes.
//!
//! The rules and the tile geometry are `horizon_optifine::ctm`'s.
use crate::sprites::Sprites;
use horizon_optifine::ctm::{
self, Connect, Method, Orient, Rule, Tile,
connect::{self as geometry, Axes, Axis, Face},
};
use horizon_world::block_state_properties::BlockProperties;
use horizon_world::registry::{BlockRegistry, BlockStateId};
use std::{collections::HashMap, path::Path};
/// A tile as the mesher draws it.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum TileLayer {
Layer {
layer: u16,
/// The sprite has fractional alpha (drawn in the translucent pass).
translucent: bool,
/// Every texel is opaque (drawn without an alpha test).
opaque: bool,
},
/// Try the next rule.
Skip,
/// The face's own sprite.
Default,
}
/// What a face draws.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Pick {
/// Its own sprite.
Keep,
Tile(TileLayer),
/// `ctm_compact`: a tile per quarter of the sprite (top-left, top-right, bottom-left,
/// bottom-right).
Quarters([TileLayer; 4]),
}
struct LoadedRule {
rule: Rule,
tiles: Vec<TileLayer>,
}
#[derive(Clone, Copy, Default)]
struct StateInfo {
/// Index of the state's block (`connect=block`).
block: u32,
axis: Option<Axis>,
/// `half=upper`: `linked` random tiles follow the lower half.
upper: bool,
}
#[derive(Default)]
pub struct ConnectedTextures {
rules: Vec<LoadedRule>,
/// By block state, then by sprite layer: ranges of `lists` (rule indices, in rule order).
by_state: Vec<(u32, u32)>,
by_layer: Vec<(u32, u32)>,
lists: Vec<u16>,
states: Vec<StateInfo>,
/// By block state: the sprite of its model's face on each side (`connect=tile`); empty
/// when no rule connects by tile.
face_layers: Vec<[u16; 6]>,
}
/// `list` appended to `lists`, as its range there.
fn push_list(lists: &mut Vec<u16>, list: &[u16]) -> (u32, u32) {
let start = lists.len() as u32;
lists.extend_from_slice(list);
(start, list.len() as u32)
}
/// The sprite name of a texture resource path: `ns:path` under `textures/`, else the path
/// itself (as [`Sprites::insert_file`] names a tile).
fn sprite_name(resource: &str) -> String {
let parts = resource
.strip_prefix("assets/")
.and_then(|p| p.split_once('/'))
.and_then(|(ns, p)| Some((ns, p.strip_prefix("textures/")?.strip_suffix(".png")?)));
match parts {
Some((ns, path)) => format!("{ns}:{path}"),
None => resource.to_owned(),
}
}
impl ConnectedTextures {
/// The rules under `root`, with their tiles inserted into `sprites` (before it finishes).
/// `face_layer` answers a block state's sprite on a side, for rules that connect by tile.
pub fn load(
root: &Path,
registry: &BlockRegistry,
sprites: &mut Sprites,
mut face_layer: impl FnMut(BlockStateId, Face) -> Option<u32>,
) -> Self {
let mut rules = ctm::load(root);
// Rules no face can use load no tiles: overlays (not drawn yet), and custom names (section
// meshes see no block entity).
rules.retain(|r| !r.method.is_overlay() && r.name.is_none());
// Rule indices are `u16`.
rules.truncate(usize::from(u16::MAX));
if rules.is_empty() {
return Self::default();
}
// The tiles decode side by side; the loop below inserts them in rule order, under the
// same names.
let tiles: Vec<String> = rules
.iter()
.flat_map(|rule| &rule.tiles)
.filter_map(|tile| match tile {
Tile::Texture(path) => Some(sprite_name(path)),
_ => None,
})
.collect();
sprites.prefetch(root, tiles.iter().map(String::as_str));
let loaded = rules
.into_iter()
.filter_map(|rule| {
let tiles = rule
.tiles
.iter()
.map(|tile| match tile {
Tile::Skip => Ok(TileLayer::Skip),
Tile::Default => Ok(TileLayer::Default),
Tile::Texture(path) => {
// A tile among the textures is that sprite, under its own name.
let layer = match sprite_name(path) {
name if name != *path => sprites.insert(root, &name)?,
_ => sprites.insert_file(root, path)?,
};
Ok(TileLayer::Layer {
layer: u16::try_from(layer)?,
translucent: sprites.translucent[layer as usize],
opaque: sprites.opaque[layer as usize],
})
}
})
.collect::<crate::Result<Vec<_>>>();
match tiles {
Ok(tiles) => Some(LoadedRule { rule, tiles }),
Err(e) => {
log::warn!("Horizon: connected textures {}: a tile: {e}", rule.path);
None
}
}
})
.collect();
let states = (0..registry.len() as u32).map(|id| {
registry
.state(BlockStateId(id))
.map(|s| (s.name.as_str(), &s.properties))
});
let mut out = Self::build(loaded, states, |name| sprites.layers.get(name).copied());
if out.rules.iter().any(|r| r.rule.connect == Connect::Tile) {
out.face_layers = (0..registry.len() as u32)
.map(|id| {
Face::ALL
.map(|f| face_layer(BlockStateId(id), f).map_or(u16::MAX, |l| l as u16))
})
.collect();
}
out
}
/// The lookups for `rules` (already ordered) over every block state (`None` for an id the
/// registry skips), `layer` answering a sprite name's layer.
fn build<'a>(
rules: Vec<LoadedRule>,
states: impl Iterator<Item = Option<(&'a str, &'a BlockProperties)>>,
layer: impl Fn(&str) -> Option<u32>,
) -> Self {
let mut out = Self {
rules,
..Self::default()
};
let mut blocks = HashMap::new();
let mut by_layer: Vec<Vec<u16>> = Vec::new();
// Block rules by the block each entry names, so a state tests only its own.
let mut by_block: HashMap<&str, Vec<u16>> = HashMap::new();
for (i, r) in out.rules.iter().enumerate() {
let i = i as u16;
for tile in &r.rule.match_tiles {
if let Some(l) = layer(&sprite_name(tile)) {
let l = l as usize;
if by_layer.len() <= l {
by_layer.resize(l + 1, Vec::new());
}
if by_layer[l].last() != Some(&i) {
by_layer[l].push(i);
}
}
}
for m in &r.rule.match_blocks {
let list = by_block.entry(m.id.as_str()).or_default();
if list.last() != Some(&i) {
list.push(i);
}
}
}
for list in by_layer {
let range = push_list(&mut out.lists, &list);
out.by_layer.push(range);
}
let mut list = Vec::new();
for state in states {
let Some((name, props)) = state else {
out.by_state.push((0, 0));
out.states.push(StateInfo::default());
continue;
};
let next = blocks.len() as u32;
let block = *blocks.entry(name).or_insert(next);
let prop = |k: &str| props.get(k).map(String::as_str);
out.states.push(StateInfo {
block,
axis: match prop("axis") {
Some("x") => Some(Axis::X),
Some("y") => Some(Axis::Y),
Some("z") => Some(Axis::Z),
_ => None,
},
upper: prop("half") == Some("upper"),
});
list.clear();
if let Some(candidates) = by_block.get(name) {
list.extend(
candidates
.iter()
.filter(|&&i| out.rules[usize::from(i)].rule.matches_block(name, prop)),
);
}
let range = push_list(&mut out.lists, &list);
out.by_state.push(range);
}
out
}
pub fn is_empty(&self) -> bool {
self.rules.is_empty()
}
fn list(&self, ranges: &[(u32, u32)], i: usize) -> &[u16] {
ranges.get(i).map_or(&[], |&(start, len)| {
&self.lists[start as usize..(start + len) as usize]
})
}
/// What the `face` of the block `state` at `pos` draws, for a quad with sprite `layer`.
/// `neighbour` answers the state at an offset from the block; `biome` its biome, asked only
/// by rules that test one.
pub fn pick<'b>(
&self,
state: u32,
layer: u16,
face: Face,
pos: [i32; 3],
neighbour: impl Fn([i32; 3]) -> u32,
biome: impl Fn() -> Option<&'b str>,
) -> Pick {
let (mut by_state, mut by_layer) = (
self.list(&self.by_state, state as usize),
self.list(&self.by_layer, layer as usize),
);
// Both lists are in rule order: walk them as one. A rule naming blocks and tiles needs
// both (it is in both lists); one naming either needs that one.
loop {
let (i, block, tile) = match (by_state.split_first(), by_layer.split_first()) {
(None, None) => return Pick::Keep,
(Some((&x, rest)), Some((&y, other))) if x == y => {
(by_state, by_layer) = (rest, other);
(x, true, true)
}
(Some((&x, rest)), Some((&y, _))) if x < y => {
by_state = rest;
(x, true, false)
}
(Some((&x, rest)), None) => {
by_state = rest;
(x, true, false)
}
(_, Some((&y, rest))) => {
by_layer = rest;
(y, false, true)
}
};
let r = &self.rules[usize::from(i)];
if (block || r.rule.match_blocks.is_empty())
&& (tile || r.rule.match_tiles.is_empty())
&& let Some(pick) = self.apply(r, state, layer, face, pos, &neighbour, &biome)
{
return pick;
}
}
}
/// One rule on a face: `None` when it doesn't apply or picks `<skip>`.
#[allow(clippy::too_many_arguments)]
fn apply<'b>(
&self,
r: &LoadedRule,
state: u32,
layer: u16,
face: Face,
pos: [i32; 3],
neighbour: &impl Fn([i32; 3]) -> u32,
biome: &impl Fn() -> Option<&'b str>,
) -> Option<Pick> {
let rule = &r.rule;
// `Rule::applies_at`, with the biome looked up only for rules that test one. Rules with
// a custom name are not loaded.
if rule.faces & face.bit() == 0
|| rule.heights.as_ref().is_some_and(|h| !h.contains(pos[1]))
{
return None;
}
if let Some(biomes) = &rule.biomes
&& !biome().is_some_and(|b| biomes.matches(b))
{
return None;
}
let info = self.states.get(state as usize).copied().unwrap_or_default();
let joins = |offset: [i32; 3]| {
let other = neighbour(offset);
match rule.connect {
Connect::Block => {
self.states.get(other as usize).map(|s| s.block) == Some(info.block)
}
Connect::State => other == state,
Connect::Tile => self
.face_layers
.get(other as usize)
.is_some_and(|f| f[face as usize] == layer),
}
};
let axes = |face| match (rule.orient, info.axis) {
(Orient::StateAxis, Some(axis)) => Axes::column(face, axis),
// `orient=texture` follows the quad's own UVs; vanilla cubes are what it is used on.
_ => Axes::cube(face),
};
let mask = || geometry::neighbours(axes(face), face.normal(), rule.inner_seams, joins);
let tile = |i: usize| r.tiles.get(i).copied().unwrap_or(TileLayer::Default);
let index = match rule.method {
Method::Ctm => geometry::ctm_index(mask()),
Method::CtmCompact => {
let mask = mask();
let case = geometry::ctm_index(mask) as u8;
match rule.compact_overrides.iter().find(|&&(c, _)| c == case) {
Some(&(_, t)) => t,
None => {
let quarters = geometry::compact_quarters(mask).map(|q| tile(q.into()));
return if quarters.contains(&TileLayer::Skip) {
None
} else {
Some(Pick::Quarters(quarters))
};
}
}
}
Method::Horizontal => geometry::horizontal(mask()),
Method::Vertical => geometry::vertical(mask()),
Method::HorizontalVertical => geometry::horizontal_vertical(mask()),
Method::VerticalHorizontal => geometry::vertical_horizontal(mask()),
Method::Top => {
// Only sides join the block above: the top and bottom go on to the next rule.
if matches!(face, Face::Up | Face::Down) {
return None;
}
if !joins([0, 1, 0]) {
return Some(Pick::Keep);
}
0
}
Method::Fixed => 0,
Method::Random => {
let face = geometry::symmetric_face(face, rule.symmetry);
let pos = if rule.linked && info.upper {
[pos[0], pos[1] - 1, pos[2]]
} else {
pos
};
geometry::random_index(pos, face, rule.random_loops, &rule.weights, r.tiles.len())
}
Method::Repeat => {
let face = geometry::symmetric_face(face, rule.symmetry);
geometry::repeat_index(pos, axes(face), rule.width, rule.height)
}
// Overlays are not loaded yet.
_ => return None,
};
match tile(index) {
TileLayer::Skip => None,
TileLayer::Default => Some(Pick::Keep),
t => Some(Pick::Tile(t)),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use horizon_optifine::Properties;
/// A tile whose layer says which tile of its rule it is: rule `r`'s tile `i` is layer
/// `1000 * (r + 1) + i`.
fn rules(files: &[(&str, &str)]) -> Vec<LoadedRule> {
let mut rules: Vec<Rule> = files
.iter()
.map(|(path, text)| {
Rule::parse(
&format!("assets/minecraft/optifine/ctm/{path}"),
&Properties::parse(text),
)
.unwrap()
.unwrap()
})
.collect();
ctm::sort(&mut rules);
rules
.into_iter()
.enumerate()
.map(|(n, rule)| LoadedRule {
tiles: rule
.tiles
.iter()
.enumerate()
.map(|(i, t)| match t {
Tile::Skip => TileLayer::Skip,
Tile::Default => TileLayer::Default,
Tile::Texture(_) => TileLayer::Layer {
layer: (1000 * (n + 1) + i) as u16,
translucent: false,
opaque: false,
},
})
.collect(),
rule,
})
.collect()
}
/// State 0 air, 1 glass, 2 stone, 3 and 4 oak logs (axis y, x), 5 and 6 a door (lower,
/// upper); the stone sprite is layer 7.
fn world(files: &[(&str, &str)]) -> ConnectedTextures {
let props = |pairs: &[(&str, &str)]| -> BlockProperties {
pairs
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect()
};
let states = [
("minecraft:air", props(&[])),
("minecraft:glass", props(&[])),
("minecraft:stone", props(&[])),
("minecraft:oak_log", props(&[("axis", "y")])),
("minecraft:oak_log", props(&[("axis", "x")])),
("minecraft:oak_door", props(&[("half", "lower")])),
("minecraft:oak_door", props(&[("half", "upper")])),
];
ConnectedTextures::build(
rules(files),
states.iter().map(|(n, p)| Some((*n, p))),
|name| (name == "minecraft:block/stone").then_some(7),
)
}
fn tile(layer: u16) -> Pick {
Pick::Tile(TileLayer::Layer {
layer,
translucent: false,
opaque: false,
})
}
/// `pick` on `face` of the block at the origin among `blocks` (offset, state).
fn pick_in(
ctm: &ConnectedTextures,
state: u32,
layer: u16,
face: Face,
blocks: &[([i32; 3], u32)],
) -> Pick {
let at = |o: [i32; 3]| blocks.iter().find(|(p, _)| *p == o).map_or(0, |b| b.1);
ctm.pick(state, layer, face, [0, 64, 0], at, || None)
}
const GLASS: &str = "matchBlocks=glass\nmethod=ctm\ntiles=0-46";
#[test]
fn glass_joins_its_neighbours() {
let ctm = world(&[("glass.properties", GLASS)]);
// Alone: tile 0. A north face's right is west: glass there is the left end of a row.
assert_eq!(pick_in(&ctm, 1, 3, Face::North, &[]), tile(1000));
assert_eq!(
pick_in(&ctm, 1, 3, Face::North, &[([-1, 0, 0], 1)]),
tile(1001)
);
// Glass all around in the face's plane: the middle tile, 26.
let ring: Vec<_> = [-1, 0, 1]
.into_iter()
.flat_map(|x| [-1, 0, 1].map(|y| ([x, y, 0], 1)))
.collect();
assert_eq!(pick_in(&ctm, 1, 3, Face::North, &ring), tile(1026));
// Stone beside it doesn't join; stone has no rule.
assert_eq!(
pick_in(&ctm, 1, 3, Face::North, &[([-1, 0, 0], 2)]),
tile(1000)
);
assert_eq!(pick_in(&ctm, 2, 7, Face::North, &[]), Pick::Keep);
}
#[test]
fn rule_order_faces_and_skips() {
let ctm = world(&[
(
"a_sides.properties",
"matchBlocks=stone\nmethod=fixed\ntiles=<skip>\nfaces=sides",
),
("b_glass.properties", GLASS),
("stone.properties", "method=fixed\ntiles=x\nfaces=top"),
("z.properties", "matchBlocks=stone\nmethod=fixed\ntiles=y"),
]);
// The tile rule (stone.properties, by its file name) comes first, on the top face only.
assert_eq!(pick_in(&ctm, 2, 7, Face::Up, &[]), tile(1000));
// Sides: a_sides skips, so z draws.
assert_eq!(pick_in(&ctm, 2, 7, Face::North, &[]), tile(4000));
// The tile rule follows the sprite, whatever the block: glass with the stone sprite.
assert_eq!(pick_in(&ctm, 1, 7, Face::Up, &[]), tile(1000));
}
#[test]
fn compact_quarters_and_overrides() {
let ctm = world(&[(
"glass.properties",
"matchBlocks=glass\nmethod=ctm_compact\ntiles=0-5\nctm.1=5",
)]);
let layer = |i: u16| TileLayer::Layer {
layer: 1000 + i,
translucent: false,
opaque: false,
};
assert_eq!(
pick_in(&ctm, 1, 3, Face::North, &[]),
Pick::Quarters([layer(0); 4])
);
// Joined right only is case 1, replaced whole by tile 5.
assert_eq!(
pick_in(&ctm, 1, 3, Face::North, &[([-1, 0, 0], 1)]),
tile(1005)
);
// Joined left only (east of a north face): quarters [3, 0, 3, 0].
assert_eq!(
pick_in(&ctm, 1, 3, Face::North, &[([1, 0, 0], 1)]),
Pick::Quarters([layer(3), layer(0), layer(3), layer(0)])
);
}
#[test]
fn logs_turn_with_their_axis() {
let ctm = world(&[(
"log.properties",
"matchBlocks=oak_log\nmethod=vertical\ntiles=0-3",
)]);
// An upright log with a log above: the bottom of a column.
assert_eq!(
pick_in(&ctm, 3, 3, Face::North, &[([0, 1, 0], 3)]),
tile(1000)
);
// A log lying along x: on its north face the texture's up points east, so a log to the
// east (same block, other axis state) is above it.
assert_eq!(
pick_in(&ctm, 4, 3, Face::North, &[([1, 0, 0], 4)]),
tile(1000)
);
assert_eq!(
pick_in(&ctm, 4, 3, Face::North, &[([0, 1, 0], 4)]),
tile(1003)
);
}
#[test]
fn top_and_connect_state() {
let ctm = world(&[(
"block_oak_log.properties",
"method=top\ntiles=0\nconnect=state",
)]);
assert_eq!(
pick_in(&ctm, 3, 3, Face::North, &[([0, 1, 0], 3)]),
tile(1000)
);
// The other axis is another state.
assert_eq!(
pick_in(&ctm, 3, 3, Face::North, &[([0, 1, 0], 4)]),
Pick::Keep
);
assert_eq!(pick_in(&ctm, 3, 3, Face::Up, &[([0, 1, 0], 3)]), Pick::Keep);
}
#[test]
fn linked_random_follows_the_lower_half() {
let ctm = world(&[(
"door.properties",
"matchBlocks=oak_door\nmethod=random\ntiles=0-7\nlinked=true",
)]);
let mut tiles = std::collections::HashSet::new();
for x in 0..20 {
let lower = ctm.pick(5, 3, Face::North, [x, 64, 0], |_| 0, || None);
let upper = ctm.pick(6, 3, Face::North, [x, 65, 0], |_| 0, || None);
assert_eq!(lower, upper, "{x}");
tiles.insert(format!("{lower:?}"));
}
assert!(tiles.len() > 3, "the doors are not all alike");
}
#[test]
fn heights_and_biomes() {
let ctm = world(&[(
"glass.properties",
"matchBlocks=glass\nmethod=fixed\ntiles=0\nheights=0-63\nbiomes=desert",
)]);
let at =
|y, biome: &'static str| ctm.pick(1, 3, Face::Up, [0, y, 0], |_| 0, || Some(biome));
assert_eq!(at(10, "minecraft:desert"), tile(1000));
assert_eq!(at(64, "minecraft:desert"), Pick::Keep);
assert_eq!(at(10, "minecraft:plains"), Pick::Keep);
}
#[test]
fn connect_by_tile_reads_the_neighbours_same_side() {
let mut ctm = world(&[(
"glass.properties",
"matchBlocks=glass\nmethod=horizontal\nconnect=tile\ntiles=0-3",
)]);
// Stone shows the glass sprite (3) on its north side; the door only on its south.
let mut faces = vec![[9u16; 6]; 7];
faces[1][Face::North as usize] = 3;
faces[2][Face::North as usize] = 3;
faces[5][Face::South as usize] = 3;
ctm.face_layers = faces;
assert_eq!(
pick_in(&ctm, 1, 3, Face::North, &[([-1, 0, 0], 2)]),
tile(1000)
);
assert_eq!(
pick_in(&ctm, 1, 3, Face::North, &[([-1, 0, 0], 5)]),
tile(1003)
);
}
#[test]
fn repeat_and_rows_then_columns() {
let ctm = world(&[(
"glass.properties",
"matchBlocks=glass\nmethod=repeat\nwidth=2\nheight=2\ntiles=0-3",
)]);
let at = |pos| ctm.pick(1, 3, Face::North, pos, |_| 0, || None);
assert_eq!(at([0, 64, 0]), tile(1000));
// The north face's right is west; rows run down.
assert_eq!(at([-1, 64, 0]), tile(1001));
assert_eq!(at([0, 63, 0]), tile(1002));
let hv = world(&[(
"glass.properties",
"matchBlocks=glass\nmethod=horizontal+vertical\ntiles=0-6",
)]);
// Alone in its row, glass above: the column's bottom, tile 4.
assert_eq!(
pick_in(&hv, 1, 3, Face::North, &[([0, 1, 0], 1)]),
tile(1004)
);
assert_eq!(
pick_in(&hv, 1, 3, Face::North, &[([-1, 0, 0], 1)]),
tile(1000)
);
}
#[test]
fn skips_defaults_and_narrowed_rules() {
let ctm = world(&[
(
"a.properties",
"matchBlocks=glass\nmethod=ctm_compact\ntiles=<skip> 1-4",
),
(
"b.properties",
"matchBlocks=glass\nmethod=fixed\ntiles=0\nfaces=sides",
),
(
"c.properties",
"matchBlocks=glass\nmethod=fixed\ntiles=<default>",
),
("d.properties", "matchBlocks=glass\nmethod=fixed\ntiles=0"),
(
"e.properties",
"matchBlocks=stone\nmatchTiles=glass_pane_top\nmethod=fixed\ntiles=0",
),
]);
// e names a tile, so it is tried first (tile 1000s), but needs a stone with the pane
// sprite. a's skipped quarter drops it; b (3000s) draws the sides; on top c draws the
// glass itself, which stops d.
assert_eq!(pick_in(&ctm, 1, 3, Face::North, &[]), tile(3000));
assert_eq!(pick_in(&ctm, 1, 3, Face::Up, &[]), Pick::Keep);
assert_eq!(pick_in(&ctm, 2, 7, Face::Up, &[]), Pick::Keep);
let both = world(&[(
"e.properties",
"matchBlocks=glass\nmatchTiles=stone\nmethod=fixed\ntiles=0",
)]);
assert_eq!(pick_in(&both, 1, 7, Face::Up, &[]), tile(1000));
assert_eq!(pick_in(&both, 1, 3, Face::Up, &[]), Pick::Keep);
assert_eq!(pick_in(&both, 2, 7, Face::Up, &[]), Pick::Keep);
// `top` passes the top face on.
let top = world(&[
("block_glass.properties", "method=top\ntiles=0"),
(
"z.properties",
"matchBlocks=glass\nmethod=fixed\ntiles=0\nfaces=top",
),
]);
assert_eq!(pick_in(&top, 1, 3, Face::Up, &[]), tile(2000));
}
#[test]
fn sprite_names() {
assert_eq!(
sprite_name("assets/minecraft/textures/block/dirt.png"),
"minecraft:block/dirt"
);
assert_eq!(
sprite_name("assets/minecraft/optifine/ctm/glass/0.png"),
"assets/minecraft/optifine/ctm/glass/0.png"
);
}
}
@@ -4,7 +4,7 @@
pub use crate::chunk::block_entity::{
Banner, Beacon, BlockEntity, Brushable, Campfire, ChiseledBookshelf, Crafter, CreakingHeart,
EndGateway, Furnace, FurnaceKind, Jukebox, Lectern, MOD_MENUS, ModBlockEntity, ModContainer,
ModFluidTanks, PotentSulfur, Shelf, Skull, SkullSkin,
ModEnergyBuffer, ModFluidTanks, PotentSulfur, Shelf, Skull, SkullSkin,
};
use crate::items::ItemStack;
use std::collections::HashMap;
+27 -42
View File
@@ -195,14 +195,6 @@ fn read(path: &str) -> Option<String> {
std::fs::read_to_string(path).ok()
}
fn windows_processor_name(query: &str) -> Option<String> {
query.lines().find_map(|line| {
let (_, value) = line.split_once("REG_SZ")?;
let value = value.trim();
(!value.is_empty()).then(|| value.to_owned())
})
}
/// `PRETTY_NAME` of /etc/os-release and the kernel, or the system's own version string.
fn operating_system() -> String {
let arch = std::env::consts::ARCH;
@@ -220,7 +212,7 @@ fn operating_system() -> String {
} else if cfg!(target_os = "macos") {
command_output("sw_vers", &["-productVersion"]).map(|v| format!("macOS {v}"))
} else if cfg!(windows) {
command_output("cmd", &["/C", "ver"])
horizon_telemetry::system::windows_version()
} else {
None
};
@@ -235,26 +227,11 @@ fn operating_system() -> String {
pub fn cpu() -> String {
let threads = std::thread::available_parallelism().map_or(0, |n| n.get());
let name = if cfg!(target_os = "linux") {
read("/proc/cpuinfo").and_then(|t| {
t.lines()
.find(|l| l.starts_with("model name"))
.and_then(|l| l.split_once(':'))
.map(|(_, v)| v.trim().to_owned())
})
read("/proc/cpuinfo").and_then(|t| horizon_telemetry::system::linux_cpu_name(&t))
} else if cfg!(target_os = "macos") {
command_output("sysctl", &["-n", "machdep.cpu.brand_string"])
} else if cfg!(windows) {
command_output(
"reg",
&[
"query",
r"HKLM\HARDWARE\DESCRIPTION\System\CentralProcessor\0",
"/v",
"ProcessorNameString",
],
)
.and_then(|query| windows_processor_name(&query))
.or_else(|| std::env::var("PROCESSOR_IDENTIFIER").ok())
horizon_telemetry::system::windows_cpu()
} else {
std::env::var("PROCESSOR_IDENTIFIER").ok()
};
@@ -407,6 +384,30 @@ pub fn report_watchdog(home: &Path, side: &'static str, message: String) -> Opti
)
}
/// The report for a game that went over the player's RAM limit (`ram_limit`), written by the
/// watcher thread: it says nothing about where the memory went, so it carries no stack. The player
/// chose this limit, so it is no bug to send (`telemetry::keep_crash`), and leaving that out ends
/// the game seconds sooner: it keeps running, over its limit, until the report is written.
pub fn report_memory_limit(home: &Path, side: &'static str, message: String) {
if REPORTED.swap(true, Ordering::SeqCst) {
return;
}
let report = Report {
time: Local::now(),
description: "RAM limit reached".into(),
error: message,
stack: String::new(),
thread: thread_name(side),
side,
details: system_details(side),
log: crate::logging::recent_lines(),
};
match report.save(home) {
Ok(path) => eprintln!("{SAVED_LINE}{}", path.display()),
Err(e) => eprintln!("Horizon: could not save a crash report: {e}"),
}
}
fn payload_text(payload: &(dyn std::any::Any + Send)) -> String {
payload
.downcast_ref::<&str>()
@@ -657,22 +658,6 @@ pub fn debug_crash_after() -> Option<std::time::Duration> {
mod tests {
use super::*;
#[test]
fn windows_registry_cpu_name_is_read_without_the_registry_columns() {
let query = r#"
HKEY_LOCAL_MACHINE\HARDWARE\DESCRIPTION\System\CentralProcessor\0
ProcessorNameString REG_SZ 13th Gen Intel(R) Core(TM) i7-13700F
"#;
assert_eq!(
windows_processor_name(query).as_deref(),
Some("13th Gen Intel(R) Core(TM) i7-13700F")
);
assert_eq!(
windows_processor_name("ProcessorNameString REG_SZ "),
None
);
}
#[test]
fn a_release_reports_its_built_in_version() {
// release.yml builds with HORIZON_VERSION (docs/versioning.md): `v0.2.3`, wherever it is installed.
+13 -3
View File
@@ -575,7 +575,7 @@ pub struct DataStore {
/// `item_modifier` files, for `/item modify`.
pub item_modifiers: BTreeMap<String, serde_json::Value>,
/// The content tables' raw files from the packs after the built-in one:
/// `recipe/`, `recipe_filter/`, `loot_table/`, `advancement/`, `dialog/` and `tags/…/` files, by
/// `recipe/`, `recipe_filter/`, `loot_table/`, `advancement/`, `dialog/`, `data_maps/` and `tags/…/` files, by
/// `<kind-dir>/<ns>:<path>`, each id's files in pack order. The tables
/// merge them over what they read from the resource root.
pub files: BTreeMap<String, Vec<Vec<u8>>>,
@@ -584,7 +584,7 @@ pub struct DataStore {
}
/// `<kind-dir>/<ns>:<path>` for a pack file the content tables read:
/// `recipe/`, `recipe_filter/`, `loot_table/`, `advancement/`, `trade_set/`,
/// `villager_trade/`, `dialog/` or `tags/<sub>/`, where the id keeps the sub
/// `villager_trade/`, `dialog/`, `data_maps/` or `tags/<sub>/`, where the id keeps the sub
/// path so a nested tag kind stays addressable.
fn content_id(file: &str) -> Option<String> {
let rest = file.strip_prefix("data/")?;
@@ -592,7 +592,7 @@ fn content_id(file: &str) -> Option<String> {
let (kind, rest) = rest.split_once('/')?;
let kind = match kind {
"recipe" | "recipe_filter" | "loot_table" | "advancement" | "trade_set"
| "villager_trade" | "dialog" => kind,
| "villager_trade" | "dialog" | "data_maps" => kind,
// A tag id is `{ns}:{path-under-tags}` (`tags/worldgen/biome/
// test:sub/mytag`), the whole path after the namespace, so nested
// registries keep their directory (`TagLoader`'s id shape).
@@ -623,6 +623,7 @@ impl DataStore {
let builtin = pack.id == "vanilla";
for f in pack.list_kinds(&[
"advancement",
"data_maps",
"dialog",
"function",
"item_modifier",
@@ -882,6 +883,15 @@ pub fn worldgen_overrides(
mod tests {
use super::*;
#[test]
fn data_map_files_are_content_files_keyed_by_map_namespace_and_registry() {
assert_eq!(
content_id("data/mymod/data_maps/item/fuel.json").as_deref(),
Some("data_maps/mymod:item/fuel")
);
assert_eq!(content_id("data/mymod/data_maps/item/fuel.txt"), None);
}
#[test]
fn enabled_packs_override_worldgen_in_order() {
let dir = std::env::temp_dir().join(format!("hz-datapack-worldgen-{}", std::process::id()));
+96 -12
View File
@@ -111,6 +111,10 @@ pub struct Extras {
pub sort: bool,
/// A vanilla key name: sorts the part under the cursor.
pub sort_key: String,
/// A new chat line slides in from the left while it fades in (`Chat::layout`).
pub chat_animation: bool,
/// The indicator bar at the left of a chat line (on by default, as vanilla).
pub chat_indicator: bool,
/// No AI: every AI feature hidden and off (`horizon_core::no_ai`; the launcher writes it too).
pub no_ai: bool,
/// Agents may control the game the player starts: it opens its control socket for
@@ -152,6 +156,8 @@ impl Default for Extras {
mouse_tweaks_wheel: true,
sort: false,
sort_key: "key.keyboard.unknown".into(),
chat_animation: false,
chat_indicator: true,
no_ai: false,
agent_control: false,
discord_presence: true,
@@ -230,6 +236,8 @@ impl Extras {
"mouseTweaksWheel" => e.mouse_tweaks_wheel = flag(e.mouse_tweaks_wheel),
"sort" => e.sort = flag(e.sort),
"sortKey" if value.starts_with("key.") => e.sort_key = value.to_owned(),
"chatAnimation" => e.chat_animation = flag(e.chat_animation),
"chatIndicator" => e.chat_indicator = flag(e.chat_indicator),
horizon_core::no_ai::KEY => e.no_ai = flag(e.no_ai),
"agentControl" => e.agent_control = flag(e.agent_control),
"discordPresence" => e.discord_presence = flag(e.discord_presence),
@@ -241,7 +249,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:{}\nuiSoundReserve:{}\nownNameTag:{}\nhideBlockParticles:{}\nmouseTweaks:{}\nmouseTweaksWheel:{}\nsort:{}\nsortKey:{}\n{}:{}\nagentControl:{}\ndiscordPresence:{}\nsplitscreen:{}\n",
"fullbright:{}\ndynamicLights:{}\nzoom:{}\nzoomKey:{}\nzoomLevel:{}\nzoomSmooth:{}\nzoomSensitivity:{}\nfps:{}\nfpsCorner:{}\nfpsLows:{}\nkeystrokes:{}\nkeystrokesCorner:{}\nkeystrokesCps:{}\nmovedToMods:{}\nminimap:{}\nminimapCorner:{}\nminimapSize:{}\nminimapShape:{}\nminimapZoom:{}\nminimapRotate:{}\nautoReconnect:{}\nuiSoundReserve:{}\nownNameTag:{}\nhideBlockParticles:{}\nmouseTweaks:{}\nmouseTweaksWheel:{}\nsort:{}\nsortKey:{}\nchatAnimation:{}\nchatIndicator:{}\n{}:{}\nagentControl:{}\ndiscordPresence:{}\nsplitscreen:{}\n",
bool_text(self.fullbright),
bool_text(self.dynamic_lights),
bool_text(self.zoom),
@@ -274,6 +282,8 @@ impl Extras {
bool_text(self.mouse_tweaks_wheel),
bool_text(self.sort),
self.sort_key,
bool_text(self.chat_animation),
bool_text(self.chat_indicator),
horizon_core::no_ai::KEY,
bool_text(self.no_ai),
bool_text(self.agent_control),
@@ -406,6 +416,10 @@ pub enum Item {
MouseTweaksWheel,
Sort,
SortKey,
/// A new chat line slides in and fades in (`Chat::layout`).
ChatAnimation,
/// The coloured bar at the left of a chat line.
ChatIndicator,
/// Not on the Extras page: Horizon Settings shows No AI, its AI Agent page Agent Control.
NoAi,
AgentControl,
@@ -414,29 +428,84 @@ pub enum Item {
/// On the Splitscreen settings page.
Splitscreen,
}
/// The Extras page: headers and items.
/// The Extras page: headers (language keys) and items.
pub fn rows() -> Vec<Result<Item, &'static str>> {
use Item::*;
vec![
Err("Lighting"),
Err("horizon.extras.header.lighting"),
Ok(Fullbright),
Ok(DynamicLights),
Err("Third person"),
Err("horizon.extras.header.third_person"),
Ok(OwnNameTag),
Err("Particles"),
Err("horizon.extras.header.particles"),
Ok(HideBlockParticles),
Err("Inventory"),
Err("horizon.extras.header.inventory"),
Ok(MouseTweaks),
Ok(MouseTweaksWheel),
Ok(Sort),
Ok(SortKey),
Err("Multiplayer"),
Err("horizon.extras.header.chat"),
Ok(ChatAnimation),
Ok(ChatIndicator),
Err("horizon.extras.header.multiplayer"),
Ok(AutoReconnect),
Err("Sound"),
Err("horizon.extras.header.sound"),
Ok(UiSoundReserve),
]
}
impl Item {
/// The language key of the caption (`horizon.*`, assets/horizon/lang).
pub fn caption_key(self) -> &'static str {
match self {
Item::Fullbright => "horizon.extras.fullbright",
Item::DynamicLights => "horizon.extras.dynamic_lights",
Item::OwnNameTag => "horizon.extras.own_name_tag",
Item::HideBlockParticles => "horizon.extras.hide_block_particles",
Item::AutoReconnect => "horizon.extras.auto_reconnect",
Item::UiSoundReserve => "horizon.extras.ui_sound_reserve",
Item::MouseTweaks => "horizon.extras.mouse_tweaks",
Item::MouseTweaksWheel => "horizon.extras.mouse_tweaks_wheel",
Item::Sort => "horizon.extras.sort",
Item::SortKey => "horizon.extras.sort_key",
Item::NoAi => "horizon.extras.no_ai",
Item::AgentControl => "horizon.extras.agent_control",
Item::DiscordPresence => "horizon.extras.discord_presence",
Item::Splitscreen => "horizon.extras.splitscreen",
Item::ChatAnimation => "horizon.extras.chat_animation",
Item::ChatIndicator => "horizon.extras.chat_indicator",
}
}
/// The language key of the description shown as the row's tooltip.
pub fn description_key(self) -> &'static str {
match self {
Item::Fullbright => "horizon.extras.fullbright.tooltip",
Item::DynamicLights => "horizon.extras.dynamic_lights.tooltip",
Item::OwnNameTag => "horizon.extras.own_name_tag.tooltip",
Item::HideBlockParticles => "horizon.extras.hide_block_particles.tooltip",
Item::AutoReconnect => "horizon.extras.auto_reconnect.tooltip",
Item::UiSoundReserve => "horizon.extras.ui_sound_reserve.tooltip",
Item::MouseTweaks => "horizon.extras.mouse_tweaks.tooltip",
Item::MouseTweaksWheel => "horizon.extras.mouse_tweaks_wheel.tooltip",
Item::Sort => "horizon.extras.sort.tooltip",
Item::SortKey => "horizon.extras.sort_key.tooltip",
Item::NoAi => "horizon.extras.no_ai.tooltip",
Item::AgentControl => "horizon.extras.agent_control.tooltip",
Item::DiscordPresence => "horizon.extras.discord_presence.tooltip",
Item::Splitscreen => "horizon.extras.splitscreen.tooltip",
Item::ChatAnimation => "horizon.extras.chat_animation.tooltip",
Item::ChatIndicator => "horizon.extras.chat_indicator.tooltip",
}
}
/// The caption in the current language (English when the key is missing).
pub fn caption_text(self) -> String {
crate::lang::get_or(self.caption_key(), self.caption())
}
/// The description in the current language (English when the key is missing).
pub fn description_text(self) -> String {
crate::lang::get_or(self.description_key(), self.description())
}
/// The English caption: the fallback of [`Item::caption_text`], and the text of
/// `horizon.extras.*` in en_us.json.
pub fn caption(self) -> &'static str {
match self {
Item::Fullbright => "Fullbright",
@@ -449,6 +518,8 @@ impl Item {
Item::MouseTweaksWheel => "Scroll wheel moves items",
Item::Sort => "Sort buttons",
Item::SortKey => "Sort key",
Item::ChatAnimation => "Smooth chat",
Item::ChatIndicator => "Message indicator",
Item::NoAi => "No AI",
Item::AgentControl => "Agent Control",
Item::DiscordPresence => "Discord Rich Presence",
@@ -462,13 +533,15 @@ impl Item {
UiSoundReserve => return None,
MouseTweaksWheel => MouseTweaks,
SortKey => Sort,
ChatIndicator => ChatAnimation,
Fullbright | DynamicLights | OwnNameTag | HideBlockParticles | MouseTweaks | Sort
| AutoReconnect | NoAi | AgentControl | DiscordPresence | Splitscreen => {
| AutoReconnect | NoAi | AgentControl | DiscordPresence | Splitscreen
| ChatAnimation => {
return None;
}
})
}
/// What the row says under its caption.
/// The English description: the fallback of [`Item::description_text`].
pub fn description(self) -> &'static str {
match self {
Item::Fullbright => "Show blocks and entities at full light, even in dark areas.",
@@ -499,6 +572,10 @@ impl Item {
Item::SortKey => {
"Sorts the part under the cursor. Click, then press a key; Escape unbinds it."
}
Item::ChatAnimation => {
"A new chat line slides in from the left while it fades in, instead of appearing whole."
}
Item::ChatIndicator => "The coloured bar at the left of a chat line.",
Item::NoAi => {
"Hides and turns off every AI feature: agent control, horizon-ctl and the MCP servers. The launcher's switch too."
}
@@ -520,6 +597,7 @@ impl Item {
Item::Fullbright => Some(Cost::None),
Item::UiSoundReserve => Some(Cost::None),
Item::DynamicLights => Some(Cost::Low),
Item::ChatAnimation => Some(Cost::None),
Item::OwnNameTag
| Item::HideBlockParticles
| Item::AutoReconnect
@@ -541,6 +619,8 @@ impl Item {
Item::MouseTweaks => e.mouse_tweaks,
Item::MouseTweaksWheel => e.mouse_tweaks_wheel,
Item::Sort => e.sort,
Item::ChatAnimation => e.chat_animation,
Item::ChatIndicator => e.chat_indicator,
Item::NoAi => e.no_ai,
Item::AgentControl => e.agent_control,
Item::DiscordPresence => e.discord_presence,
@@ -553,8 +633,8 @@ impl Item {
match self {
Item::SortKey => crate::keybinds::display_name(&e.sort_key).plain(),
other => match other.switch(e) {
Some(true) => "On".into(),
_ => "Off".into(),
Some(true) => crate::lang::get_or("On", "On"),
_ => crate::lang::get_or("Off", "Off"),
},
}
}
@@ -571,6 +651,8 @@ impl Item {
Item::MouseTweaks => e.mouse_tweaks = !e.mouse_tweaks,
Item::MouseTweaksWheel => e.mouse_tweaks_wheel = !e.mouse_tweaks_wheel,
Item::Sort => e.sort = !e.sort,
Item::ChatAnimation => e.chat_animation = !e.chat_animation,
Item::ChatIndicator => e.chat_indicator = !e.chat_indicator,
Item::NoAi => e.no_ai = !e.no_ai,
Item::AgentControl => e.agent_control = !e.agent_control,
Item::DiscordPresence => e.discord_presence = !e.discord_presence,
@@ -633,6 +715,8 @@ mod tests {
e.mouse_tweaks_wheel = false;
e.sort = true;
e.sort_key = "key.keyboard.r".into();
e.chat_animation = true;
e.chat_indicator = false;
e.discord_presence = false;
assert!(!e.splitscreen);
Item::Splitscreen.cycle(&mut e, false);
File diff suppressed because it is too large. Load diff
@@ -252,6 +252,18 @@ impl Arena {
pub fn page_vertices(&self) -> u64 {
self.page_limit
}
/// `page`'s buffer up to the end of its last range in use, as draws bind it:
/// wgpu clears what a binding covers and nothing wrote yet when the frame is
/// submitted, most of a page just made or grown.
pub fn binding(&self, page: u32) -> wgpu::BufferSlice<'_> {
let p = self.pages[page as usize].as_ref().expect("live arena page");
let end = match p.free.0.last_key_value() {
Some((&start, &len)) if start + len == p.capacity => start,
_ => p.capacity,
};
// A page a draw names holds a range; wgpu refuses an empty slice.
p.buffer.slice(..end.max(GRAIN) * self.stride)
}
pub fn has_page(&self, page: u32) -> bool {
self.pages.get(page as usize).is_some_and(Option::is_some)
}
@@ -784,6 +796,32 @@ mod tests {
}
}
/// Draws bind a large page up to the end of its last range in use, a grain at
/// least: wgpu clears what a binding covers and nothing wrote when the frame is
/// submitted, so a grown page bound whole had its new tail cleared on the GPU.
#[test]
fn large_pages_are_bound_up_to_their_last_range_in_use() {
let Some((device, queue)) = device() else {
eprintln!("no GPU adapter: skipped");
return;
};
let mut arena = Arena::translucent(&device, 24, GRAIN);
let ranges: Vec<_> = (0..3)
.map(|_| alloc(&mut arena, &device, &queue, GRAIN).unwrap())
.collect();
// The third grain grew the page to four.
assert_eq!(arena.buffer(0).size(), 4 * GRAIN * 24);
assert_eq!(arena.binding(0).size(), 3 * GRAIN * 24);
// A free range before the last in use stays bound, none after it; an empty
// page binds a grain (wgpu refuses an empty slice).
arena.release(0, ranges[1].1, GRAIN);
assert_eq!(arena.binding(0).size(), 3 * GRAIN * 24);
arena.release(0, ranges[2].1, GRAIN);
assert_eq!(arena.binding(0).size(), GRAIN * 24);
arena.release(0, ranges[0].1, GRAIN);
assert_eq!(arena.binding(0).size(), GRAIN * 24);
}
#[test]
fn empty_pages_are_reclaimed_without_moving_live_ranges() {
let Some((device, queue)) = device() else {
+15 -13
View File
@@ -585,23 +585,25 @@ impl Cull {
self.current = Some(self.next);
}
/// After the frame's submission: start reading its results back.
pub fn submitted(&mut self) {
let Some(i) = self.current.take() else {
return;
};
/// The frame's results to read back, started by calling it right after the frame's
/// submission (on the submit thread, when the frame goes there).
pub fn submitted(&mut self) -> Option<impl FnOnce() + Send + 'static> {
let i = self.current.take()?;
let r = &self.readbacks[i];
let state = r.state.clone();
state.store(PENDING, Ordering::Release);
r.buffer
.slice(..r.slots.len() as u64 * 4)
.map_async(wgpu::MapMode::Read, move |result| {
state.store(
if result.is_ok() { MAPPED } else { FREE },
Ordering::Release,
);
});
let (buffer, bytes) = (r.buffer.clone(), r.slots.len() as u64 * 4);
self.next = (i + 1) % READBACKS;
Some(move || {
buffer
.slice(..bytes)
.map_async(wgpu::MapMode::Read, move |result| {
state.store(
if result.is_ok() { MAPPED } else { FREE },
Ordering::Release,
);
});
})
}
fn make_group(
@@ -1,11 +1,572 @@
//! Frame generation: FSR 3.1's frame interpolation will show a frame halfway between each
//! two real ones. So far this holds its optical flow, its interpolation and the motion
//! vectors of what moves on its own; the setting that runs them comes next.
//! Frame generation (a Horizon setting, off by default): FSR 3.1's frame interpolation
//! makes a frame halfway between each two real ones. The world, without the hand and
//! HUD, is kept at the screen's size each frame; optical flow and the interpolation run
//! on it, the hand and HUD are drawn over the made frame as over the real one, and a
//! present thread shows the made frame, then the real one half a real frame later.
//!
//! The renderer draws into frames of its own while this is on; the present thread
//! copies each to the swapchain, which it alone acquires and presents.
// Unused until the renderer runs them.
#[allow(dead_code)]
mod interpolation;
#[allow(dead_code)]
mod motion;
#[allow(dead_code)]
mod optical_flow;
use glam::{DMat4, DVec3, Mat4};
use std::{
sync::{
Arc,
atomic::{AtomicBool, Ordering},
mpsc,
},
thread,
time::{Duration, Instant},
};
/// Frames to draw into: a pair being drawn, a pair waiting and a pair being shown.
const FRAMES: usize = 6;
/// `HORIZON_FRAMEGEN_DEBUG=generated`: show only made frames (AMD's debug view), so a
/// screenshot or an offscreen frame is the made one; `motion`: the same, each made frame
/// showing what moved on its own in blue (its motion vectors in red and green).
pub(super) fn generated_only() -> bool {
debug().is_some()
}
fn debug() -> Option<&'static str> {
static DEBUG: std::sync::OnceLock<Option<String>> = std::sync::OnceLock::new();
DEBUG
.get_or_init(|| {
std::env::var("HORIZON_FRAMEGEN_DEBUG")
.ok()
.filter(|v| v == "generated" || v == "motion")
})
.as_deref()
}
pub struct FrameGen {
pub on: bool,
/// The present thread failed to show a frame (or died): until the renderer has
/// acquired a frame itself, the normal path runs and handles the surface.
pub paused: bool,
gpu: Option<Gpu>,
presenter: Option<Presenter>,
/// When the last real frame was handed over, and the time between real frames.
last: Option<Instant>,
interval: Duration,
/// The last frame's unjittered `view_proj` and camera.
previous: Option<(DMat4, DVec3)>,
/// The last frame's hand vertices and the bob and view they were drawn with.
hand: Vec<crate::mesher::Vertex>,
hand_view: Mat4,
/// Offscreen, the frame the made frame is drawn into.
offscreen_generated: Option<wgpu::Texture>,
/// Motion vectors for what moves on its own, made on the first frame.
motion: Option<motion::ObjectMotion>,
/// Real frames and made ones handed to the screen since `shown_since`, and the share
/// of real frames that had a made frame over the last half second (the F3 screen's),
/// `None` while off.
real: u32,
made: u32,
shown_since: Instant,
pub made_share: Option<f32>,
}
/// What runs on the GPU for one screen size.
struct Gpu {
size: [u32; 2],
flow: optical_flow::OpticalFlow,
interpolation: interpolation::Interpolation,
/// This frame's world at the screen's size, and bindings that copy it or the
/// interpolation's output (`Post::finish_with`).
world: wgpu::TextureView,
world_group: wgpu::BindGroup,
output_group: wgpu::BindGroup,
/// The next interpolation has no previous frame.
reset: bool,
}
/// This frame's camera, for the interpolation.
pub struct Camera {
/// The world's projection this frame (with TAA's jitter) and without the jitter.
pub jittered: Mat4,
pub view_proj: Mat4,
pub depth: wgpu::TextureView,
pub render_size: [u32; 2],
pub jitter: [f32; 2],
/// This frame's unjittered clip space to the previous frame's, `None` without one.
pub reproject: Option<Mat4>,
pub near: f32,
pub far: f32,
pub fov_y: f32,
}
impl FrameGen {
pub fn new() -> Self {
Self {
on: false,
paused: false,
gpu: None,
presenter: None,
last: None,
interval: Duration::from_millis(16),
previous: None,
hand: Vec::new(),
hand_view: Mat4::IDENTITY,
offscreen_generated: None,
motion: None,
real: 0,
made: 0,
shown_since: Instant::now(),
made_share: None,
}
}
/// Ends frame generation's frames and present thread: before the surface is
/// reconfigured or the renderer acquires from it itself.
pub fn stop(&mut self) {
self.presenter = None;
self.made_share = None;
(self.real, self.made) = (0, 0);
self.last = None;
self.previous = None;
self.hand.clear();
if let Some(motion) = &mut self.motion {
motion.reset();
}
if let Some(gpu) = &mut self.gpu {
gpu.reset = true;
}
}
/// The hand for the made frame, appended to `out`: each vertex halfway between where
/// it was on screen last frame and where it is now (the hand swings and bobs on its
/// own, so the world's interpolation can't move it), in this frame's space
/// (`view`: this frame's bob and view). This frame's hand where the two differ in
/// shape (another item, the first frame).
pub fn halfway_hand(
&mut self,
hand: &[crate::mesher::Vertex],
view: Mat4,
out: &mut Vec<crate::mesher::Vertex>,
) {
let same = self.hand.len() == hand.len()
&& self
.hand
.iter()
.zip(hand)
.all(|(a, b)| a.layer == b.layer && a.uv == b.uv);
if same {
let back = view.inverse();
out.extend(self.hand.iter().zip(hand).map(|(before, now)| {
let then = self.hand_view.transform_point3(before.pos.into());
let mid = (then + view.transform_point3(now.pos.into())) * 0.5;
crate::mesher::Vertex {
pos: back.transform_point3(mid).into(),
..*now
}
}));
} else {
out.extend_from_slice(hand);
}
self.hand.clear();
self.hand.extend_from_slice(hand);
self.hand_view = view;
}
/// This frame's entity vertices (relative to `camera`) and the entity ids of their
/// spans (`Frame::entity_motion`), for their own motion vectors; `atlas`: the
/// entities' textures. Before `generate`, every frame.
pub fn track_entities(
&mut self,
device: &wgpu::Device,
atlas: &wgpu::TextureView,
entities: &[crate::mesher::Vertex],
spans: &[(u32, u64)],
camera: DVec3,
) {
self.motion
.get_or_insert_with(|| motion::ObjectMotion::new(device, atlas.clone()))
.track(entities, spans, camera);
}
/// This frame's unjittered clip space to the last frame's, from their `view_proj`s
/// (relative to `camera`); `None` on the first frame.
pub fn camera_motion(&mut self, view_proj: Mat4, camera: DVec3) -> Option<Mat4> {
let current = view_proj.as_dmat4();
let previous = self.previous.replace((current, camera));
previous.map(|(p, pc)| super::world_post::reprojection(p, pc, current, camera).as_mat4())
}
/// Frames to draw the real and the made frame into, from the present thread's
/// (started for this `config`'s surface) or, offscreen, the offscreen target and
/// one of its own. `None` once the present thread failed or died: it is stopped and
/// frame generation `paused`.
pub fn frames(
&mut self,
device: &wgpu::Device,
queue: &wgpu::Queue,
surface: Option<&Arc<wgpu::Surface<'static>>>,
offscreen: Option<&wgpu::Texture>,
window: &Arc<winit::window::Window>,
config: &wgpu::SurfaceConfiguration,
) -> Option<(wgpu::Texture, wgpu::Texture)> {
let Some(surface) = surface else {
let real = offscreen.cloned().unwrap_or_else(|| frame(device, config));
if self
.offscreen_generated
.as_ref()
.is_none_or(|t| t.size() != real.size())
{
self.offscreen_generated = Some(frame(device, config));
}
let generated = self.offscreen_generated.clone().expect("made above");
return Some((real, generated));
};
if self.presenter.as_ref().is_some_and(|p| p.config != *config) {
self.presenter = None;
}
let presenter = self.presenter.get_or_insert_with(|| {
Presenter::start(device, queue, surface.clone(), window.clone(), config)
});
// A closed channel: the thread died.
let pair = if presenter.failed() {
None
} else {
presenter.free.recv().ok().zip(presenter.free.recv().ok())
};
if pair.is_none() {
self.stop();
self.paused = true;
}
pair
}
/// The world's target at the screen's size this frame, where `Post::finish` draws.
pub fn world(
&mut self,
device: &wgpu::Device,
post: &super::world_post::Post,
config: &wgpu::SurfaceConfiguration,
) -> wgpu::TextureView {
let size = [config.width, config.height];
if self.gpu.as_ref().is_none_or(|g| g.size != size) {
let world = device
.create_texture(&wgpu::TextureDescriptor {
label: Some("Frame generation world"),
size: wgpu::Extent3d {
width: size[0],
height: size[1],
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: config.format.remove_srgb_suffix(),
// COPY_SRC: the interpolation keeps it as the previous frame.
usage: wgpu::TextureUsages::RENDER_ATTACHMENT
| wgpu::TextureUsages::TEXTURE_BINDING
| wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
})
.create_view(&Default::default());
let interpolation = interpolation::Interpolation::new(device, size);
self.gpu = Some(Gpu {
size,
flow: optical_flow::OpticalFlow::new(device, size),
world_group: post.bind(device, &world),
output_group: post.bind(device, interpolation.output()),
interpolation,
world,
reset: true,
});
}
self.gpu.as_ref().expect("made above").world.clone()
}
/// Copy the world (`world`) onto `screen` for the real frame, and draw the made
/// frame's world onto `generated`. Returns false when there is no made frame (the
/// first after a reset): `generated` then holds this frame's world too.
#[allow(clippy::too_many_arguments)]
pub fn generate(
&mut self,
device: &wgpu::Device,
queue: &wgpu::Queue,
encoder: &mut wgpu::CommandEncoder,
post: &super::world_post::Post,
screen: &wgpu::TextureView,
generated: &wgpu::TextureView,
camera: &Camera,
) -> bool {
let gpu = self.gpu.as_mut().expect("world() ran this frame");
post.finish_with(encoder, screen, &gpu.world_group, None);
let reset = gpu.reset || camera.reproject.is_none();
let reproject = camera.reproject.unwrap_or(Mat4::IDENTITY);
let object_motion = self
.motion
.as_mut()
.expect("track_entities() ran this frame")
.draw(
device,
queue,
encoder,
&camera.depth,
camera.render_size,
camera.jittered,
camera.view_proj,
reproject * camera.view_proj,
);
gpu.flow.dispatch(device, queue, encoder, &gpu.world, reset);
gpu.interpolation.dispatch(
device,
queue,
encoder,
&interpolation::Inputs {
color: &gpu.world,
depth: &camera.depth,
render_size: camera.render_size,
jitter: camera.jitter,
reproject,
object_motion: &object_motion,
near: camera.near,
far: camera.far,
fov_y: camera.fov_y,
reset,
flow_vectors: gpu.flow.vectors(),
scene_change: gpu.flow.scene_change(),
},
);
gpu.reset = false;
if debug() == Some("motion") {
post.finish_with(encoder, generated, &post.bind(device, &object_motion), None);
return true;
}
if reset {
post.finish_with(encoder, generated, &gpu.world_group, None);
} else {
post.finish_with(encoder, generated, &gpu.output_group, None);
}
!reset
}
/// Hand this frame's pair to the present thread, after the frame's submission: the
/// made frame (when `made`) goes first.
pub fn present(&mut self, real: wgpu::Texture, generated: wgpu::Texture, made: bool) {
let now = Instant::now();
if let Some(last) = self.last {
// A real frame's time, smoothed: the made frame shows for half of it.
let dt = (now - last).min(Duration::from_millis(250));
self.interval = self.interval.mul_f32(0.9) + dt.mul_f32(0.1);
}
self.last = Some(now);
self.real += 1;
self.made += u32::from(made && !generated_only());
if now - self.shown_since >= Duration::from_millis(500) {
self.made_share = Some(self.made as f32 / self.real as f32);
(self.real, self.made, self.shown_since) = (0, 0, now);
}
let Some(send) = self.presenter.as_ref().and_then(|p| p.send.as_ref()) else {
return;
};
let pair = if generated_only() {
Pair {
first: None,
second: generated,
spare: Some(real),
interval: self.interval,
}
} else if made {
Pair {
first: Some(generated),
second: real,
spare: None,
interval: self.interval,
}
} else {
Pair {
first: None,
second: real,
spare: Some(generated),
interval: self.interval,
}
};
let _ = send.send(pair);
}
}
/// A frame to draw into, like the swapchain's.
fn frame(device: &wgpu::Device, config: &wgpu::SurfaceConfiguration) -> wgpu::Texture {
device.create_texture(&wgpu::TextureDescriptor {
label: Some("Frame generation frame"),
size: wgpu::Extent3d {
width: config.width,
height: config.height,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: config.format,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT
| wgpu::TextureUsages::TEXTURE_BINDING
| wgpu::TextureUsages::COPY_SRC
| wgpu::TextureUsages::COPY_DST,
view_formats: &config.view_formats,
})
}
/// One real frame's frames for the present thread: `first` (the made frame) now,
/// `second` (the real one) half an `interval` later; `spare` goes straight back.
struct Pair {
first: Option<wgpu::Texture>,
second: wgpu::Texture,
spare: Option<wgpu::Texture>,
interval: Duration,
}
/// The thread that copies frames to the swapchain and presents them on time.
struct Presenter {
config: wgpu::SurfaceConfiguration,
send: Option<mpsc::SyncSender<Pair>>,
free: mpsc::Receiver<wgpu::Texture>,
/// The surface gave something other than a frame: the renderer takes it back.
failed: Arc<AtomicBool>,
thread: Option<thread::JoinHandle<()>>,
}
impl Presenter {
fn start(
device: &wgpu::Device,
queue: &wgpu::Queue,
surface: Arc<wgpu::Surface<'static>>,
window: Arc<winit::window::Window>,
config: &wgpu::SurfaceConfiguration,
) -> Self {
// One pair waiting at most: the renderer then waits as for a swapchain image.
let (send, receive) = mpsc::sync_channel::<Pair>(1);
let (give, free) = mpsc::channel();
for _ in 0..FRAMES {
let _ = give.send(frame(device, config));
}
let (device, queue) = (device.clone(), queue.clone());
let vsync = config.present_mode == wgpu::PresentMode::Fifo;
let failed = Arc::new(AtomicBool::new(false));
let failed_here = failed.clone();
let thread = thread::Builder::new()
.name("Frame generation present".into())
.spawn(move || {
let show = |texture: &wgpu::Texture| {
if failed_here.load(Ordering::Relaxed) {
return;
}
// Anything but a frame (outdated, lost, occluded...): the renderer
// stops this thread and acquires itself, handling it as it does
// without frame generation. A suboptimal frame still shows.
let target = match surface.get_current_texture() {
wgpu::CurrentSurfaceTexture::Success(t) => t,
wgpu::CurrentSurfaceTexture::Suboptimal(t) => {
failed_here.store(true, Ordering::Relaxed);
t
}
_ => {
failed_here.store(true, Ordering::Relaxed);
return;
}
};
let mut encoder = device.create_command_encoder(&Default::default());
encoder.copy_texture_to_texture(
texture.as_image_copy(),
target.texture.as_image_copy(),
target.texture.size(),
);
queue.submit([encoder.finish()]);
if vsync {
window.pre_present_notify();
}
queue.present(target);
};
while let Ok(pair) = receive.recv() {
if let Some(first) = pair.first {
let shown = Instant::now();
show(&first);
let _ = give.send(first);
// ponytail: sleep pacing; a spin for the last half millisecond
// if it shows uneven on some systems.
let due = shown + pair.interval / 2;
if let Some(wait) = due.checked_duration_since(Instant::now()) {
thread::sleep(wait);
}
}
show(&pair.second);
let _ = give.send(pair.second);
if let Some(spare) = pair.spare {
let _ = give.send(spare);
}
}
})
.expect("spawn the frame generation present thread");
Self {
config: config.clone(),
send: Some(send),
free,
failed,
thread: Some(thread),
}
}
/// The surface failed it or the thread died (a panic).
fn failed(&self) -> bool {
self.failed.load(Ordering::Relaxed) || self.thread.as_ref().is_none_or(|t| t.is_finished())
}
}
impl Drop for Presenter {
fn drop(&mut self) {
self.send = None;
if let Some(thread) = self.thread.take() {
let _ = thread.join();
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::mesher::Vertex;
use glam::Vec3;
fn vertex(pos: Vec3) -> Vertex {
Vertex {
pos: pos.into(),
..bytemuck::Zeroable::zeroed()
}
}
/// A swing lands halfway; a hand still on screen while the camera turns stays still;
/// another item shows as it is.
#[test]
fn the_made_frame_s_hand_is_halfway_on_screen() {
let mut fg = FrameGen::new();
let mut out = Vec::new();
fg.halfway_hand(
&[vertex(Vec3::new(0.0, 0.0, -1.0))],
Mat4::IDENTITY,
&mut out,
);
fg.halfway_hand(
&[vertex(Vec3::new(0.4, 0.0, -1.0))],
Mat4::IDENTITY,
&mut out,
);
assert!((Vec3::from(out[1].pos) - Vec3::new(0.2, 0.0, -1.0)).length() < 1e-6);
// The camera turned: the vertex turned with it, the same on screen.
let turned = Mat4::from_rotation_y(0.3);
let on_screen = Vec3::new(0.4, 0.0, -1.0);
let pos = turned.inverse().transform_point3(on_screen);
fg.halfway_hand(&[vertex(pos)], turned, &mut out);
assert!((Vec3::from(out[2].pos) - pos).length() < 1e-6);
let mut other = vertex(Vec3::ZERO);
other.uv = [0.5, 0.5];
fg.halfway_hand(&[other], turned, &mut out);
assert_eq!(out[3].pos, [0.0; 3]);
}
}
@@ -0,0 +1,159 @@
//! The ICC profile of HDR screenshots, for programs that don't read PNG's `cICP`: BT.2020
//! primaries and the PQ curve, with HDR reference white (203 nits) as the profile's white.
//! An ICC profile has no values above white, so those programs show a correct SDR picture
//! with its highlights clipped, where without it they took PQ's values for sRGB's and showed
//! them washed out. Programs that read `cICP` ignore this (PNG's third edition).
/// HDR reference white in nits (ITU-R BT.2408): where the curve reaches the profile's white.
const WHITE: f64 = 203.0;
/// PQ's values from 0 to 1, in the curve's steps.
const STEPS: usize = 4096;
/// BT.2020's primaries, adapted to the profile connection space's D50 (Bradford).
const RED: [f64; 3] = [0.67348, 0.27904, -0.00193];
const GREEN: [f64; 3] = [0.16567, 0.67534, 0.02998];
const BLUE: [f64; 3] = [0.12505, 0.04561, 0.79685];
const D50: [f64; 3] = [0.9642, 1.0, 0.8249];
/// PQ's EOTF (SMPTE ST 2084): a signal from 0 to 1 in nits.
fn pq_nits(signal: f64) -> f64 {
let (m1, m2) = (2610.0 / 16384.0, 2523.0 / 32.0);
let (c1, c2, c3) = (3424.0 / 4096.0, 2413.0 / 128.0, 2392.0 / 128.0);
let p = signal.powf(1.0 / m2);
10_000.0 * ((p - c1).max(0.0) / (c2 - c3 * p)).powf(1.0 / m1)
}
fn s15_fixed16(v: f64) -> [u8; 4] {
((v * 65536.0).round() as i32).to_be_bytes()
}
fn xyz(v: [f64; 3]) -> Vec<u8> {
let mut tag = b"XYZ \0\0\0\0".to_vec();
tag.extend(v.iter().flat_map(|&c| s15_fixed16(c)));
tag
}
fn curve() -> Vec<u8> {
let mut tag = b"curv\0\0\0\0".to_vec();
tag.extend((STEPS as u32).to_be_bytes());
for i in 0..STEPS {
let y = (pq_nits(i as f64 / (STEPS - 1) as f64) / WHITE).min(1.0);
tag.extend(((y * 65535.0).round() as u16).to_be_bytes());
}
tag
}
/// `textDescriptionType` (ICC v2), with its empty Unicode and ScriptCode parts.
fn description(text: &str) -> Vec<u8> {
let mut tag = b"desc\0\0\0\0".to_vec();
tag.extend((text.len() as u32 + 1).to_be_bytes());
tag.extend(text.as_bytes());
tag.extend([0; 1 + 4 + 4 + 2 + 1 + 67]);
tag
}
/// An ICC v2 matrix-shaper display profile.
pub(super) fn profile() -> Vec<u8> {
let trc = curve();
let copyright = b"text\0\0\0\0Public domain\0".to_vec();
let tags: [(&[u8; 4], &[u8]); 9] = [
(b"desc", &description("BT.2020 PQ, 203 nits = white")),
(b"cprt", &copyright),
(b"wtpt", &xyz(D50)),
(b"rXYZ", &xyz(RED)),
(b"gXYZ", &xyz(GREEN)),
(b"bXYZ", &xyz(BLUE)),
(b"rTRC", &trc),
(b"gTRC", &trc),
(b"bTRC", &trc),
];
let data_start = 128 + 4 + 12 * tags.len();
let mut table = (tags.len() as u32).to_be_bytes().to_vec();
let mut data = Vec::new();
let mut trc_at = None;
for (name, tag) in tags {
// The three channels share one curve.
let is_trc = tag == &trc[..];
let at = match trc_at.filter(|_| is_trc) {
Some(at) => at,
None => {
let at = data_start + data.len();
data.extend(tag);
data.resize(data.len().next_multiple_of(4), 0);
at
}
};
if is_trc {
trc_at = Some(at);
}
table.extend(name);
table.extend((at as u32).to_be_bytes());
table.extend((tag.len() as u32).to_be_bytes());
}
let mut out = vec![0; 128];
let size = data_start + data.len();
out[0..4].copy_from_slice(&(size as u32).to_be_bytes());
out[8..12].copy_from_slice(&[2, 0x40, 0, 0]);
out[12..16].copy_from_slice(b"mntr");
out[16..20].copy_from_slice(b"RGB ");
out[20..24].copy_from_slice(b"XYZ ");
out[36..40].copy_from_slice(b"acsp");
// Relative colorimetric, and the connection space's illuminant.
out[64..68].copy_from_slice(&1u32.to_be_bytes());
for (i, &c) in D50.iter().enumerate() {
out[68 + 4 * i..72 + 4 * i].copy_from_slice(&s15_fixed16(c));
}
out.extend(table);
out.extend(data);
out
}
#[cfg(test)]
mod tests {
use super::*;
fn tag<'a>(profile: &'a [u8], name: &[u8; 4]) -> &'a [u8] {
let u32_at = |i: usize| u32::from_be_bytes(profile[i..i + 4].try_into().unwrap()) as usize;
let at = (0..u32_at(128))
.map(|i| 132 + 12 * i)
.find(|&e| &profile[e..e + 4] == name)
.unwrap();
&profile[u32_at(at + 4)..u32_at(at + 4) + u32_at(at + 8)]
}
#[test]
fn the_profile_is_whole() {
let p = profile();
assert_eq!(
u32::from_be_bytes(p[0..4].try_into().unwrap()) as usize,
p.len()
);
assert_eq!(&p[36..40], b"acsp");
assert_eq!(p.len() % 4, 0);
assert_eq!(&tag(&p, b"desc")[0..4], b"desc");
// One curve, shared: the profile stays small.
assert_eq!(tag(&p, b"rTRC").as_ptr(), tag(&p, b"bTRC").as_ptr());
assert!(p.len() < 10_000, "{}", p.len());
}
#[test]
fn the_primaries_add_up_to_white() {
for i in 0..3 {
assert!((RED[i] + GREEN[i] + BLUE[i] - D50[i]).abs() < 0.0001);
}
}
#[test]
fn the_curve_reaches_white_at_reference_white_and_stays() {
let p = profile();
let trc = tag(&p, b"rTRC");
let value = |signal: f64| {
let i = 12 + 2 * (signal * (STEPS - 1) as f64).round() as usize;
u16::from_be_bytes([trc[i], trc[i + 1]])
};
assert_eq!(value(0.0), 0);
// 203 nits in PQ is a signal of 0.5807.
assert!(value(0.57) < 65535);
assert_eq!(value(0.59), 65535);
assert_eq!(value(1.0), 65535);
}
}
@@ -42,7 +42,7 @@ struct HorizonMaterialOut {
}
fn horizon_material_vertex(position: vec3<f32>, uv: vec2<f32>, layer: u32, color: vec4<f32>,
light: vec2<u32>, origin: vec3<f32>) -> HorizonMaterialOut {
let pos = position + origin;
let pos = horizon_material_displace(position + origin, uv, layer, color, light);
var out: HorizonMaterialOut;
out.clip = horizon_terrain_globals.view_proj * vec4(pos, 1.0);
out.uv = uv;
@@ -139,17 +139,32 @@ fn horizon_material_color(in: HorizonMaterialOut, translucent: bool) -> vec4<f32
// Water draws translucent and lava solid, so the pass resolves the fluid kind.
let fluid = select(0u, select(2u, 1u, translucent), in.fluid_quad != 0u);
let normal = horizon_material_normal(in.position);
horizon_material_face_normal = normal;
horizon_surface = vec4<f32>(0.0);
let tangent = horizon_material_tangent(in.position, in.uv, normal);
return material(MaterialInput(albedo, in.tint, in.light, in.lightmap, in.position, in.uv,
translucent, horizon_sprite_tags(in.layer & 0x7fffu),
horizon_sprite_material(in.layer & 0x7fffu), in.clip.xy / frame.resolution,
in.face, fluid, in.emission, normal, tangent)).color;
}
@fragment fn horizon_material_fs_solid(in: HorizonMaterialOut) -> @location(0) vec4<f32> {
var<private> horizon_material_face_normal: vec3<f32>;
struct HorizonMaterialSolid {
@location(0) color: vec4<f32>,
// The surface target's data: `set_surface`'s, then the fragment's depth, which the post
// stage's `surface_at` compares with the final depth.
@location(1) surface: vec4<f32>,
}
@fragment fn horizon_material_fs_solid(in: HorizonMaterialOut) -> HorizonMaterialSolid {
let color = horizon_material_color(in, false);
// Bit 15 of the layer marks opaque leaves (forceOpaque): no alpha cutout.
if color.a < 0.5 && (in.layer & 0x8000u) == 0u { discard; }
return horizon_material_fogged(vec4(color.rgb, 1.0), in.spherical, in.cylindrical);
if horizon_surface.w == 0.0 {
set_surface(horizon_material_face_normal, 0.0, 0.0);
}
var out: HorizonMaterialSolid;
out.color = horizon_material_fogged(vec4(color.rgb, 1.0), in.spherical, in.cylindrical);
out.surface = vec4(horizon_surface.xyz, in.clip.z);
return out;
}
@fragment fn horizon_material_fs_translucent(in: HorizonMaterialOut) -> @location(0) vec4<f32> {
let color = horizon_material_color(in, true);
@@ -189,6 +204,8 @@ pub struct NativeMaterial {
pub solid: wgpu::RenderPipeline,
pub translucent: wgpu::RenderPipeline,
pub flat: Option<(wgpu::RenderPipeline, wgpu::RenderPipeline)>,
/// The stage calls `set_surface`: its solid pipelines also write the surface target.
pub writes_surface: bool,
}
/// terrain.wgsl's terrain section (its globals, bindings, sampling, lighting and fog), with
@@ -203,7 +220,7 @@ fn vanilla_terrain() -> String {
/// The function `name(layer)`, the value of each sprite layer in `table` and 0 for the rest:
/// `horizon_sprite_tags` (`MaterialInput.tags`) and `horizon_sprite_material`
/// (`MaterialInput.material`).
fn layer_table(name: &str, table: &[(u32, u32)]) -> String {
pub(super) fn layer_table(name: &str, table: &[(u32, u32)]) -> String {
let mut out = format!("fn {name}(layer: u32) -> u32 {{\n switch layer {{\n");
let mut table = table.to_vec();
table.sort_unstable_by_key(|&(layer, value)| (value, layer));
@@ -218,12 +235,47 @@ fn layer_table(name: &str, table: &[(u32, u32)]) -> String {
out + " default: { return 0u; }\n }\n}\n"
}
/// Where a corner of terrain is drawn without a vertex stage.
pub(super) const DISPLACE_VANILLA: &str = "
fn horizon_material_displace(pos: vec3<f32>, uv: vec2<f32>, layer: u32, color: vec4<f32>,
light: vec2<u32>) -> vec3<f32> {
return pos;
}
";
/// The vertex stage's call, with its inputs from terrain's vertex.
pub(super) const DISPLACE_PACK: &str = "
fn horizon_material_displace(pos: vec3<f32>, uv: vec2<f32>, layer: u32, color: vec4<f32>,
light: vec2<u32>) -> vec3<f32> {
let flags = u32(round(color.a * 255.0));
let high = flags >> 4u;
let fluid = high >= 8u && (high & 7u) < 6u;
let lit = min(vec2(f32(light.y), f32(light.x)) / 240.0, vec2(1.0));
return vertex(VertexInput(pos, uv, horizon_sprite_material(layer & 0x7fffu), lit,
vec4(color.rgb, 1.0), select(0u, high & 7u, fluid), u32(fluid)));
}
";
/// The surface target (the post stage's `scene_surface`): x the normal (two snorm16 in its
/// bits), y smoothness, z the pack's data, w the fragment's depth.
pub const SURFACE_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba32Float;
/// The terrain shader for the pack's material stage `source`, with its sprite `tags` and
/// `materials`.
fn shader_source(source: &str, tags: &[(u32, u32)], materials: &[(u32, u32)]) -> String {
/// `materials`, and the vertex stage in its module when `vertex`.
fn shader_source(
source: &str,
tags: &[(u32, u32)],
materials: &[(u32, u32)],
vertex: bool,
) -> String {
format!(
"{source}\n// Horizon's terrain (terrain.wgsl)\n{}{}{}{ENTRIES}",
"{source}\n// Horizon's terrain (terrain.wgsl)\n{}{}{}{}{ENTRIES}",
vanilla_terrain(),
if vertex {
DISPLACE_PACK
} else {
DISPLACE_VANILLA
},
layer_table("horizon_sprite_tags", tags),
layer_table("horizon_sprite_material", materials)
)
@@ -234,8 +286,9 @@ fn module(
stage: &CheckedStage,
tags: &[(u32, u32)],
materials: &[(u32, u32)],
vertex: bool,
) -> Result<naga::Module, String> {
let text = shader_source(&stage.source, tags, materials);
let text = shader_source(&stage.source, tags, materials, vertex);
naga::front::wgsl::parse_str(&text)
.map_err(|e| format!("{}: {}", stage.file, e.emit_to_string(&text)))
}
@@ -249,8 +302,10 @@ impl NativeMaterial {
stage: &CheckedStage,
constants: &[(String, f64)],
fog: bool,
vertex: bool,
) -> Result<Self, String> {
let module = module(stage, shape.sprite_tags, shape.sprite_materials)?;
let writes_surface = horizon_shader_api::stages::calls_set_surface(&stage.source);
let module = module(stage, shape.sprite_tags, shape.sprite_materials, vertex)?;
let mut constants = present_constants(&module, constants);
let sprite_names = [
"horizon_terrain_SPRITE_ARRAYS",
@@ -296,6 +351,18 @@ impl NativeMaterial {
constants: &constants,
..Default::default()
};
let solid_targets = [
Some(wgpu::ColorTargetState {
format: shape.format,
blend,
write_mask: wgpu::ColorWrites::ALL,
}),
Some(wgpu::ColorTargetState {
format: SURFACE_FORMAT,
blend: None,
write_mask: wgpu::ColorWrites::ALL,
}),
];
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some(&stage.label),
layout: Some(layout),
@@ -321,11 +388,11 @@ impl NativeMaterial {
module: &module,
entry_point: Some(fragment),
compilation_options: options,
targets: &[Some(wgpu::ColorTargetState {
format: shape.format,
blend,
write_mask: wgpu::ColorWrites::ALL,
})],
targets: if fragment == "horizon_material_fs_solid" && writes_surface {
&solid_targets
} else {
&solid_targets[..1]
},
}),
multiview_mask: None,
cache: None,
@@ -370,6 +437,7 @@ impl NativeMaterial {
solid,
translucent,
flat,
writes_surface,
})
}
}
@@ -424,7 +492,7 @@ mod tests {
let tags = [(7, 1), (3, 1), (9, 2)];
let materials = [(5, 1000)];
assert!(layer_table("t", &tags).contains("case 3u, 7u: { return 1u; }"));
let module = module(&stage, &tags, &materials).unwrap();
let module = module(&stage, &tags, &materials, false).unwrap();
let info = naga::valid::Validator::new(
naga::valid::ValidationFlags::all(),
naga::valid::Capabilities::all(),
@@ -433,7 +501,7 @@ mod tests {
.unwrap_or_else(|e| {
panic!(
"{}",
e.emit_to_string(&shader_source(&stage.source, &tags, &materials))
e.emit_to_string(&shader_source(&stage.source, &tags, &materials, false))
)
});
let entries: Vec<&str> = module
@@ -48,7 +48,17 @@ impl NativePack {
let shadow = report
.shadow
.as_ref()
.map(|s| NativeShadow::new(device, terrain, &frame, &s.solid_materials))
.map(|s| {
let vertex = report.stages.iter().find(|s| s.kind == StageKind::Vertex);
NativeShadow::new(
device,
terrain,
&frame,
&s.solid_materials,
vertex,
constants,
)
})
.transpose()?;
// The mirror is drawn for a material that reads it, while `[reflection] option` is on.
let option = report.reflection.as_ref().and_then(|r| r.option.as_ref());
@@ -91,6 +101,7 @@ impl NativePack {
.iter()
.find(|s| s.kind == StageKind::Weather)
.map(|s| s.label.clone());
let has_vertex = report.stages.iter().any(|s| s.kind == StageKind::Vertex);
let (post, others): (Vec<_>, Vec<_>) = report
.stages
.into_iter()
@@ -103,7 +114,9 @@ impl NativePack {
let material = others
.iter()
.find(|s| s.kind == StageKind::Material)
.map(|stage| NativeMaterial::new(device, terrain, &frame, stage, constants, fog))
.map(|stage| {
NativeMaterial::new(device, terrain, &frame, stage, constants, fog, has_vertex)
})
.transpose()?;
let post = (!post.is_empty())
.then(|| NativePost::new(device, post, &report.manifest, constants, format, &frame))
@@ -14,7 +14,7 @@ use super::Frame;
const INTERMEDIATE: wgpu::TextureFormat = wgpu::TextureFormat::Rgba16Float;
/// The world's colour and depth views a set of bind groups was made for.
type WorldViews = (wgpu::TextureView, wgpu::TextureView);
type WorldViews = (wgpu::TextureView, wgpu::TextureView, wgpu::TextureView);
/// Where a post pass draws.
#[derive(Clone, Copy, PartialEq)]
@@ -103,6 +103,9 @@ pub struct NativePost {
/// The `frame` every stage of a pack reads (group 3, binding 0), written once a frame, and
/// what the material stage reads besides.
pub struct NativeFrame {
/// The solid terrain's second target, the post stage's `scene_surface`.
surface: wgpu::Texture,
pub surface_view: wgpu::TextureView,
pub(super) buffer: wgpu::Buffer,
/// Group 3 of the sky and material stages.
pub layout: wgpu::BindGroupLayout,
@@ -270,6 +273,8 @@ impl NativeFrame {
label: Some("Native shader pack frame"),
entries: &entries,
});
let surface = surface_target(device, [1, 1]);
let surface_view = surface.create_view(&Default::default());
let opaque_depth = depth_copy(device, [1, 1]);
let opaque_color = color_copy(device, [1, 1], wgpu::TextureFormat::Rgba8Unorm);
let blank = copy_target(
@@ -332,6 +337,8 @@ impl NativeFrame {
buffer,
layout,
group,
surface,
surface_view,
opaque_depth,
opaque_color,
reflection: blank.clone(),
@@ -468,6 +475,14 @@ impl NativeFrame {
self.regroup(device);
}
/// Sizes the surface target: the world's while a material stage writes it, else a blank.
pub fn fit_surface(&mut self, device: &wgpu::Device, size: [u32; 2]) {
if [self.surface.width(), self.surface.height()] != size {
self.surface = surface_target(device, size);
self.surface_view = self.surface.create_view(&Default::default());
}
}
/// Binds `view` as this frame's `reflection` (`None`: the blank). The mirror is drawn
/// before the stages read it.
pub fn set_reflection(&mut self, device: &wgpu::Device, view: Option<&wgpu::TextureView>) {
@@ -573,6 +588,24 @@ pub fn present_constants<'a>(
.collect()
}
/// The solid terrain's surface target of `size`, drawn and read.
fn surface_target(device: &wgpu::Device, size: [u32; 2]) -> wgpu::Texture {
device.create_texture(&wgpu::TextureDescriptor {
label: Some("Native shader pack surface"),
size: wgpu::Extent3d {
width: size[0],
height: size[1],
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: super::native_material::SURFACE_FORMAT,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[],
})
}
fn texture_entry(binding: u32, sample_type: wgpu::TextureSampleType) -> wgpu::BindGroupLayoutEntry {
wgpu::BindGroupLayoutEntry {
binding,
@@ -652,6 +685,7 @@ impl NativePost {
texture_entry(8, float),
texture_entry(9, wgpu::TextureSampleType::Depth),
texture_entry(10, float),
texture_entry(11, wgpu::TextureSampleType::Float { filterable: false }),
];
entries.extend(pack_texture_entries(frame.textures.len()));
entries
@@ -757,7 +791,7 @@ impl NativePost {
fn prepare(
&mut self,
device: &wgpu::Device,
world: (&wgpu::TextureView, &wgpu::TextureView),
world: (&wgpu::TextureView, &wgpu::TextureView, &wgpu::TextureView),
size: [u32; 2],
history_reset: bool,
) {
@@ -830,8 +864,11 @@ impl NativePost {
if self
.groups
.as_ref()
.is_some_and(|((color, depth), blank, _)| {
color == world.0 && depth == world.1 && *blank == blank_history
.is_some_and(|((color, depth, surface), blank, _)| {
color == world.0
&& depth == world.1
&& surface == world.2
&& *blank == blank_history
})
{
return;
@@ -893,6 +930,10 @@ impl NativePost {
binding: 9,
resource: wgpu::BindingResource::TextureView(&self.shadow.2),
},
wgpu::BindGroupEntry {
binding: 11,
resource: wgpu::BindingResource::TextureView(world.2),
},
wgpu::BindGroupEntry {
binding: 10,
resource: wgpu::BindingResource::TextureView(&self.shadow.3),
@@ -920,7 +961,11 @@ impl NativePost {
})
})
.collect();
self.groups = Some(((world.0.clone(), world.1.clone()), blank_history, groups));
self.groups = Some((
(world.0.clone(), world.1.clone(), world.2.clone()),
blank_history,
groups,
));
}
/// Runs the passes over the world target `world` (colour, depth) of `size`, the last onto
@@ -929,7 +974,7 @@ impl NativePost {
&mut self,
device: &wgpu::Device,
encoder: &mut wgpu::CommandEncoder,
world: (&wgpu::TextureView, &wgpu::TextureView),
world: (&wgpu::TextureView, &wgpu::TextureView, &wgpu::TextureView),
screen: &wgpu::TextureView,
size: [u32; 2],
history_reset: bool,
@@ -1349,7 +1394,7 @@ mod tests {
post.run(
&device,
&mut encoder,
(&color, &depth),
(&color, &depth, &frame.surface_view),
&view,
[4, 4],
true,
@@ -1456,7 +1501,7 @@ file = \"b.wgsl\"
post.run(
&device,
&mut encoder,
(&color, &depth),
(&color, &depth, &frame.surface_view),
&view,
[4, 4],
history_reset,
@@ -6,6 +6,19 @@
use super::Vertex;
use super::pack_draw::EntitySpan;
use glam::{DVec3, Mat4};
use std::sync::atomic::{AtomicBool, Ordering};
static MODS_BUILT_IN: AtomicBool = AtomicBool::new(false);
/// The game was built with native mods (the game sets it at start): one of them may draw in
/// [`native_world_drawn`], so the first-person hand keeps its own pass after it.
pub fn set_mods_built_in(on: bool) {
MODS_BUILT_IN.store(on, Ordering::Relaxed);
}
/// Whether [`native_world_drawn`] may draw ([`set_mods_built_in`]).
pub fn world_drawn_hooked() -> bool {
MODS_BUILT_IN.load(Ordering::Relaxed)
}
/// What a mod can draw with once the world is drawn: after the sky, terrain, entities,
/// particles, weather and the world post pass, before the first-person hand, the HUD and the
@@ -41,17 +41,21 @@ fn horizon_shadow_vertex(pos: vec3<f32>, uv: vec2<f32>, layer: u32, color: vec4<
}
@vertex fn horizon_shadow_vs(
@location(0) position: vec3<f32>, @location(1) uv: vec2<u32>,
@location(2) layer: u32, @location(3) color: vec4<f32>,
@location(2) layer: u32, @location(3) color: vec4<f32>, @location(4) light: vec2<u32>,
@location(5) origin: vec4<f32>) -> HorizonShadowOut {
return horizon_shadow_vertex(position + origin.xyz, horizon_terrain_terrain_uv(uv, origin.w), layer, color);
let atlas = horizon_terrain_terrain_uv(uv, origin.w);
let pos = horizon_material_displace(position + origin.xyz, atlas, layer, color, light);
return horizon_shadow_vertex(pos, atlas, layer, color);
}
@vertex fn horizon_shadow_vs_flat(
@builtin(vertex_index) index: u32,
@location(0) position: vec3<f32>, @location(1) uv: vec2<u32>, @location(2) layer: u32,
@location(3) color: vec4<f32>
@location(3) color: vec4<f32>, @location(4) light: vec2<u32>
) -> HorizonShadowOut {
let origin = horizon_terrain_slot_origins[horizon_terrain_block_slots[index >> 8u]];
return horizon_shadow_vertex(position + origin.xyz, horizon_terrain_terrain_uv(uv, origin.w), layer, color);
let atlas = horizon_terrain_terrain_uv(uv, origin.w);
let pos = horizon_material_displace(position + origin.xyz, atlas, layer, color, light);
return horizon_shadow_vertex(pos, atlas, layer, color);
}
@fragment fn horizon_shadow_fs(in: HorizonShadowOut) {
// Bit 15 of the layer marks opaque leaves (forceOpaque): no alpha cutout.
@@ -415,12 +419,24 @@ fn shadow_solid(materials: &[(u32, u32)], solid: &[u32]) -> String {
/// The shadow pass's shader: the frame, Horizon's terrain, and the entries, with the layers
/// `solid` of `shadow_solid`.
fn shader_source(solid: &str) -> String {
fn shader_source(solid: &str, vertex: Option<&str>, materials: &[(u32, u32)]) -> String {
let (head, displace) = match vertex {
Some(stage) => (
stage.to_owned()
+ &super::native_material::layer_table("horizon_sprite_material", materials),
super::native_material::DISPLACE_PACK,
),
None => (
format!(
"{}@group({GROUP}) @binding(0) var<uniform> frame: Frame;\n{}",
FrameInputs::WGSL.wgsl(),
horizon_shader_api::stages::SHADOW_HELPERS,
),
super::native_material::DISPLACE_VANILLA,
),
};
format!(
"{}@group({GROUP}) @binding(0) var<uniform> frame: Frame;\n{}\
// Horizon's terrain (terrain.wgsl)\n{}{solid}{ENTRIES}",
FrameInputs::WGSL.wgsl(),
horizon_shader_api::stages::SHADOW_HELPERS,
"{head}// Horizon's terrain (terrain.wgsl)\n{}{solid}{displace}{ENTRIES}",
prefixed(
section(TERRAIN, "struct Globals {", "// Sky (SkyRenderer)"),
PREFIX
@@ -434,14 +450,21 @@ impl NativeShadow {
shape: &TerrainShape<'_>,
frame: &super::native_post::NativeFrame,
solid_materials: &[u32],
vertex: Option<&horizon_shader_api::check::CheckedStage>,
constants: &[(String, f64)],
) -> Result<Self, String> {
let text = shader_source(&shadow_solid(shape.sprite_materials, solid_materials));
let text = shader_source(
&shadow_solid(shape.sprite_materials, solid_materials),
vertex.map(|s| s.source.as_str()),
shape.sprite_materials,
);
let module = naga::front::wgsl::parse_str(&text)
.map_err(|e| format!("shadow pass: {}", e.emit_to_string(&text)))?;
let constants = [
let mut constants = super::native_post::present_constants(&module, constants);
constants.extend([
("horizon_terrain_SPRITE_ARRAYS", shape.sprites[0]),
("horizon_terrain_SPRITE_SPLIT", shape.sprites[1]),
];
]);
let scope = device.push_error_scope(wgpu::ErrorFilter::Validation);
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Native shader pack shadow"),
@@ -593,7 +616,7 @@ mod tests {
#[test]
fn the_shadow_shader_validates() {
for materials in [&[][..], &[(3, 7), (4, 8), (9, 7)][..]] {
let text = shader_source(&shadow_solid(materials, &[7]));
let text = shader_source(&shadow_solid(materials, &[7]), None, materials);
let module = naga::front::wgsl::parse_str(&text).unwrap();
naga::valid::Validator::new(
naga::valid::ValidationFlags::all(),
@@ -0,0 +1,296 @@
//! `centerDepthSmooth`'s input: `depthtex0` at the centre of the screen. A depth texture can
//! only be copied whole, so each frame a one-pixel pass loads that texel into a 1x1 `R32Float`
//! target, copied to one of a few small buffers and read back frames later without waiting for
//! the GPU (as `timing.rs` reads its timestamps); `FrameUniforms` smooths the latest value.
use std::sync::Arc;
use std::sync::atomic::{AtomicU8, Ordering};
const SLOTS: usize = 3;
const FREE: u8 = 0;
/// Copied this frame: mapped once the frame is submitted.
const COPIED: u8 = 1;
const PENDING: u8 = 2;
const MAPPED: u8 = 3;
/// One row of a texture copy, the alignment wgpu asks of `bytes_per_row`.
const ROW: u32 = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT;
const SHADER: &str = "
@group(0) @binding(0) var depth: texture_depth_2d;
@vertex fn vs(@builtin(vertex_index) i: u32) -> @builtin(position) vec4<f32> {
let p = vec2<f32>(f32((i << 1u) & 2u), f32(i & 2u));
return vec4<f32>(p * 2.0 - 1.0, 0.0, 1.0);
}
@fragment fn fs() -> @location(0) vec4<f32> {
let centre = vec2<i32>(textureDimensions(depth) / 2u);
return vec4<f32>(textureLoad(depth, centre, 0), 0.0, 0.0, 1.0);
}
";
struct Slot {
buffer: wgpu::Buffer,
state: Arc<AtomicU8>,
}
pub struct CenterDepth {
slots: Vec<Slot>,
pipeline: wgpu::RenderPipeline,
layout: wgpu::BindGroupLayout,
/// The 1x1 target the centre texel is loaded into.
texel: wgpu::Texture,
texel_view: wgpu::TextureView,
/// The bind group of the depth texture it reads, made again when that texture changes.
group: Option<(wgpu::Texture, wgpu::BindGroup)>,
/// The depth last read back: GL window depth, 0..1.
latest: Option<f32>,
}
impl CenterDepth {
pub fn new(device: &wgpu::Device) -> Self {
let slots = (0..SLOTS)
.map(|_| Slot {
buffer: device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Shader pack centre depth"),
size: u64::from(ROW),
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
mapped_at_creation: false,
}),
state: Arc::new(AtomicU8::new(FREE)),
})
.collect();
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("Shader pack centre depth"),
entries: &[wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Depth,
view_dimension: wgpu::TextureViewDimension::D2,
multisampled: false,
},
count: None,
}],
});
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Shader pack centre depth"),
source: wgpu::ShaderSource::Wgsl(SHADER.into()),
});
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("Shader pack centre depth"),
bind_group_layouts: &[Some(&layout)],
immediate_size: 0,
});
let format = wgpu::TextureFormat::R32Float;
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("Shader pack centre depth"),
layout: Some(&pipeline_layout),
vertex: wgpu::VertexState {
module: &module,
entry_point: Some("vs"),
compilation_options: Default::default(),
buffers: &[],
},
primitive: Default::default(),
depth_stencil: None,
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &module,
entry_point: Some("fs"),
compilation_options: Default::default(),
targets: &[Some(format.into())],
}),
multiview_mask: None,
cache: None,
});
let texel = device.create_texture(&wgpu::TextureDescriptor {
label: Some("Shader pack centre depth"),
size: wgpu::Extent3d {
width: 1,
height: 1,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
let texel_view = texel.create_view(&Default::default());
Self {
slots,
pipeline,
layout,
texel,
texel_view,
group: None,
latest: None,
}
}
/// Loads the centre texel of `depth` (a `Depth32Float` texture that can be bound) and
/// copies it into a free slot; with none free (the GPU is behind), this frame is skipped.
pub fn copy(
&mut self,
device: &wgpu::Device,
encoder: &mut wgpu::CommandEncoder,
depth: &wgpu::Texture,
) {
let Some(slot) = self
.slots
.iter()
.find(|s| s.state.load(Ordering::Acquire) == FREE)
else {
return;
};
if self.group.as_ref().is_none_or(|(t, _)| t != depth) {
let view = depth.create_view(&Default::default());
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Shader pack centre depth"),
layout: &self.layout,
entries: &[wgpu::BindGroupEntry {
binding: 0,
resource: wgpu::BindingResource::TextureView(&view),
}],
});
self.group = Some((depth.clone(), group));
}
let Some((_, group)) = &self.group else {
return;
};
{
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Shader pack centre depth"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &self.texel_view,
depth_slice: None,
resolve_target: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::WHITE),
store: wgpu::StoreOp::Store,
},
})],
..Default::default()
});
pass.set_pipeline(&self.pipeline);
pass.set_bind_group(0, group, &[]);
pass.draw(0..3, 0..1);
}
encoder.copy_texture_to_buffer(
self.texel.as_image_copy(),
wgpu::TexelCopyBufferInfo {
buffer: &slot.buffer,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(ROW),
rows_per_image: Some(1),
},
},
self.texel.size(),
);
slot.state.store(COPIED, Ordering::Release);
}
/// After the frame's submission: start reading back what it copied.
pub fn submitted(&mut self) {
for slot in &self.slots {
if slot.state.load(Ordering::Acquire) != COPIED {
continue;
}
slot.state.store(PENDING, Ordering::Release);
let state = slot.state.clone();
slot.buffer
.slice(..)
.map_async(wgpu::MapMode::Read, move |result| {
let next = if result.is_ok() { MAPPED } else { FREE };
state.store(next, Ordering::Release);
});
}
}
/// The newest depth read back so far, `None` before the first arrives.
pub fn collect(&mut self, device: &wgpu::Device) -> Option<f32> {
let _ = device.poll(wgpu::PollType::Poll);
for slot in &self.slots {
if slot.state.load(Ordering::Acquire) != MAPPED {
continue;
}
let depth = slot
.buffer
.slice(..4)
.get_mapped_range()
.ok()
.map(|bytes| bytemuck::pod_read_unaligned::<f32>(&bytes[..4]));
slot.buffer.unmap();
slot.state.store(FREE, Ordering::Release);
if let Some(depth) = depth.filter(|d| d.is_finite()) {
self.latest = Some(depth.clamp(0.0, 1.0));
}
}
self.latest
}
}
#[cfg(test)]
mod tests {
use super::*;
fn test_device() -> Option<(wgpu::Device, wgpu::Queue)> {
let instance =
wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle().with_env());
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).ok()?;
pollster::block_on(adapter.request_device(&Default::default())).ok()
}
/// A depth texture cleared to a known depth comes back as that depth, a frame or more
/// later, without the frame waiting for it.
#[test]
fn the_centre_depth_comes_back() {
let Some((device, queue)) = test_device() else {
return;
};
let depth = device.create_texture(&wgpu::TextureDescriptor {
label: None,
size: wgpu::Extent3d {
width: 9,
height: 7,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Depth32Float,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[],
});
let view = depth.create_view(&Default::default());
let mut center = CenterDepth::new(&device);
assert_eq!(center.collect(&device), None);
let mut encoder = device.create_command_encoder(&Default::default());
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view: &view,
depth_ops: Some(wgpu::Operations {
load: wgpu::LoadOp::Clear(0.375),
store: wgpu::StoreOp::Store,
}),
stencil_ops: None,
}),
..Default::default()
});
center.copy(&device, &mut encoder, &depth);
queue.submit([encoder.finish()]);
center.submitted();
let _ = device.poll(wgpu::PollType::wait_indefinitely());
assert_eq!(center.collect(&device), Some(0.375));
// Its slot is free again: every slot can be used once more.
for _ in 0..SLOTS {
let mut encoder = device.create_command_encoder(&Default::default());
center.copy(&device, &mut encoder, &depth);
queue.submit([encoder.finish()]);
center.submitted();
}
let _ = device.poll(wgpu::PollType::wait_indefinitely());
assert_eq!(center.collect(&device), Some(0.375));
}
}
@@ -512,6 +512,8 @@ pub struct PackPost {
page_groups: HashMap<u32, (wgpu::Buffer, wgpu::BindGroup)>,
/// GPU time of each pass, while the renderer asks for it (`pack_timing.rs`).
pub timing: Option<super::pack_timing::PassTiming>,
/// `depthtex0` at the screen's centre, read back for `centerDepthSmooth`.
center_depth: Option<super::pack_center_depth::CenterDepth>,
/// `shaders.properties`' custom uniforms, after the frame's in `uniforms.values`.
custom: horizon_shaders::Custom,
atlas: Option<wgpu::TextureView>,
@@ -2242,6 +2244,7 @@ impl PackPost {
block_ids,
page_groups: HashMap::new(),
timing: None,
center_depth: None,
custom,
atlas: world.atlas.as_ref().map(|a| a.0.clone()),
lightmap: world.lightmap,
@@ -2740,6 +2743,13 @@ impl PackPost {
let rp = &mut open.as_mut().expect("open").0;
rp.set_pipeline(pass.render());
rp.set_bind_group(0, group, &[]);
// A sky program that reads the pack's SSBOs (Noble's `gbuffers_textured`, the
// sun's fallback) binds them, as the other passes do.
if pass.buffers
&& let Some(g) = &self.buffers.group
{
rp.set_bind_group(4, g, &[]);
}
sky.geometry.bind(rp, part.textured);
open.as_mut().expect("open").1 = i;
}
@@ -2896,6 +2906,13 @@ impl PackPost {
}
}
/// After the frame's submission: the centre depth it copied starts its way back.
pub fn submitted(&mut self) {
if let Some(center) = &mut self.center_depth {
center.submitted();
}
}
/// Whether the pack draws entities or block entities itself.
pub fn has_entities(&self) -> bool {
self.entities.is_some()
@@ -3613,6 +3630,10 @@ impl PackPost {
if let Some(t) = &mut self.timing {
t.begin(device, queue);
}
self.uniforms.center_depth_raw = self
.center_depth
.get_or_insert_with(|| super::pack_center_depth::CenterDepth::new(device))
.collect(device);
self.uniforms.update(frame, inputs, size);
// Until the camera's biome is known, `smooth` waits for the world's values.
if !inputs.biome_known {
@@ -3851,6 +3872,10 @@ impl PackPost {
self.draw_translucent(device, queue, encoder, size, terrain);
}
let targets = self.targets.as_ref().expect("resized");
// depthtex0 as the frame leaves it, for next frames' `centerDepthSmooth`.
if let Some(center) = &mut self.center_depth {
center.copy(device, encoder, &targets.depth_texture);
}
let view = |m: u16, i: usize, alt: bool| {
targets.color[i]
.as_ref()
@@ -51,6 +51,12 @@ pub struct PackInputs {
pub cloud_height: f32,
/// The values native mods hand their packs (`frame.mods`, `native_shader_params`).
pub mod_params: [[f32; 4]; 8],
/// The player's state, for Iris's status uniforms.
pub status: PlayerStatus,
/// The End flash: its intensity, 0..1 (0 outside the End), and its direction from the
/// camera in world space, of length 100 (`endFlashIntensity`, `endFlashPosition`).
pub end_flash: f32,
pub end_flash_direction: Vec3,
}
/// The `cloudHeight` uniform. Vanilla reads `visual/cloud_height` only while the cloud color
@@ -64,6 +70,35 @@ pub fn cloud_height(cloud_color_alpha: f32, height: f32) -> f32 {
}
}
/// The local player's state behind Iris's status uniforms (`currentPlayerHealth`,
/// `isSpectator`, `is_burning`...).
#[derive(Clone, Copy, Debug, Default)]
pub struct PlayerStatus {
/// Survival or adventure: the vitals are -1 in creative and spectator.
pub survival: bool,
pub health: f32,
pub max_health: f32,
pub food: f32,
/// Air supply ticks, of 300.
pub air: f32,
/// Armor points (the `armor` attribute).
pub armor: f32,
pub spectator: bool,
pub riding: bool,
pub feet_in_water: bool,
pub swimming: bool,
pub burning: bool,
pub hurt: bool,
pub invisible: bool,
pub sneaking: bool,
pub sprinting: bool,
}
/// `maxPlayerHunger`, `maxPlayerAir` and `maxPlayerArmor`: Minecraft's own maximums.
const MAX_HUNGER: f32 = 20.0;
const MAX_AIR: f32 = 300.0;
const MAX_ARMOR: f32 = 50.0;
/// One uniform's value: up to 16 floats or 3 integers.
#[derive(Clone, Copy, Debug)]
pub enum Value {
@@ -198,6 +233,26 @@ pub const NAMES: &[&str] = &[
"playerLookVector",
"playerBodyVector",
"firstPersonCamera",
"currentPlayerHealth",
"maxPlayerHealth",
"currentPlayerHunger",
"maxPlayerHunger",
"currentPlayerAir",
"maxPlayerAir",
"currentPlayerArmor",
"maxPlayerArmor",
"isSpectator",
"isRiding",
"feetInWater",
"inSwimmingAnimation",
"is_burning",
"is_hurt",
"is_invisible",
"is_sneaking",
"is_sprinting",
"endFlashIntensity",
"endFlashPosition",
"centerDepthSmooth",
"isRightHanded",
"is_on_ground",
"bedrockLevel",
@@ -220,6 +275,9 @@ pub struct FrameUniforms {
previous: Option<(glam::DVec3, Mat4, Mat4)>,
wetness: f32,
eye_smooth: [f32; 2],
/// `depthtex0` at the screen's centre as last read back, and `centerDepthSmooth`.
pub center_depth_raw: Option<f32>,
center_depth: Option<f32>,
/// The pack's shadow settings (7.1).
pub shadow: Shadow,
}
@@ -235,6 +293,8 @@ impl Default for FrameUniforms {
previous: None,
wetness: 0.0,
eye_smooth: [0.0; 2],
center_depth_raw: None,
center_depth: None,
shadow: Shadow::default(),
}
}
@@ -379,6 +439,15 @@ impl FrameUniforms {
for (smoothed, target) in self.eye_smooth.iter_mut().zip(eye) {
*smoothed = smooth(*smoothed, target, 10.0 / 20.0, dt);
}
// `centerDepthHalflife`'s default, 1 tick (OptiFine's shaders.txt); the first value
// read back is taken as it is.
if let Some(raw) = self.center_depth_raw {
self.center_depth = Some(match self.center_depth {
Some(v) => smooth(v, raw, 1.0 / 20.0, dt),
None => raw,
});
}
let center_depth = self.center_depth.unwrap_or(1.0);
// Vanilla fades by the stronger of two linear fogs; a pack gets one: the pair (start
// and end together) of the one that is complete nearer (OQ-36). Mixing the nearer start
// of one with the nearer end of the other made fog begin at the camera on clear days.
@@ -424,6 +493,8 @@ impl FrameUniforms {
"eyeAltitude" => Value::f(&[frame.camera.y as f32]),
"upPosition" => v3(to_view(Vec3::new(0.0, 100.0, 0.0))),
"sunPosition" => v3(sun),
"endFlashPosition" => v3(to_view(inputs.end_flash_direction)),
"centerDepthSmooth" => Value::f(&[center_depth]),
"moonPosition" => v3(moon),
"shadowLightPosition" => v3(if day { sun } else { moon }),
"sunAngle" => Value::f(&[sun_angle]),
@@ -483,7 +554,7 @@ impl FrameUniforms {
"isElytraFlying" => Value::i(&[inputs.elytra_flying as i32]),
"cloudHeight" => Value::f(&[inputs.cloud_height]),
// renderStage is NONE outside gbuffers draws.
_ => Value::i(&[0]),
_ => status_value(name, inputs).unwrap_or(Value::i(&[0])),
}
};
self.values.clear();
@@ -529,6 +600,46 @@ impl FrameUniforms {
}
}
/// Iris's player status and dimension uniforms, from `inputs`: the vitals are fractions of
/// their maximum, and -1 (with the maximums of health and air) in creative and spectator.
fn status_value(name: &str, inputs: &PackInputs) -> Option<Value> {
let status = inputs.status;
let vital = |v: f32| {
Value::f(&[if status.survival {
v.clamp(0.0, 1.0)
} else {
-1.0
}])
};
let flag = |b: bool| Value::i(&[i32::from(b)]);
Some(match name {
"currentPlayerHealth" => vital(status.health / status.max_health.max(1.0)),
"maxPlayerHealth" => Value::f(&[if status.survival {
status.max_health
} else {
-1.0
}]),
"currentPlayerHunger" => vital(status.food / MAX_HUNGER),
"maxPlayerHunger" => Value::f(&[MAX_HUNGER]),
"currentPlayerAir" => vital(status.air / MAX_AIR),
"maxPlayerAir" => Value::f(&[if status.survival { MAX_AIR } else { -1.0 }]),
"currentPlayerArmor" => vital(status.armor / MAX_ARMOR),
"maxPlayerArmor" => Value::f(&[MAX_ARMOR]),
"isSpectator" => flag(status.spectator),
"isRiding" => flag(status.riding),
"feetInWater" => flag(status.feet_in_water),
"inSwimmingAnimation" => flag(status.swimming),
"is_burning" => flag(status.burning),
"is_hurt" => flag(status.hurt),
// Spectators count as invisible.
"is_invisible" => flag(status.invisible || status.spectator),
"is_sneaking" => flag(status.sneaking),
"is_sprinting" => flag(status.sprinting),
"endFlashIntensity" => Value::f(&[inputs.end_flash]),
_ => return None,
})
}
/// How many components the uniform `name` has (16 for a matrix).
fn components(name: &str) -> usize {
match name {
@@ -547,7 +658,8 @@ fn components(name: &str) -> usize {
| "moonPosition"
| "shadowLightPosition"
| "fogColor"
| "skyColor" => 3,
| "skyColor"
| "endFlashPosition" => 3,
"eyeBrightness" | "eyeBrightnessSmooth" | "atlasSize" => 2,
n if n.starts_with("gbuffer") || n.starts_with("shadow") && n != "shadowAngle" => 16,
_ => 1,
@@ -576,6 +688,77 @@ mod tests {
assert_eq!(out[60..64], [0; 4], "a value past the block is dropped");
}
/// Iris's status uniforms as its documentation defines them (shaders.properties, "Status").
#[test]
fn status_uniforms_follow_iris() {
let float = |name: &str, inputs: &PackInputs| match status_value(name, inputs) {
Some(Value::F(f)) => f[0],
other => panic!("{name}: {other:?}"),
};
let int = |name: &str, inputs: &PackInputs| match status_value(name, inputs) {
Some(Value::I(i)) => i[0],
other => panic!("{name}: {other:?}"),
};
let mut inputs = PackInputs {
status: PlayerStatus {
survival: true,
health: 15.0,
max_health: 20.0,
food: 10.0,
air: 150.0,
armor: 20.0,
sneaking: true,
..Default::default()
},
..Default::default()
};
// The vitals are fractions of their maximums.
assert_eq!(float("currentPlayerHealth", &inputs), 0.75);
assert_eq!(float("maxPlayerHealth", &inputs), 20.0);
assert_eq!(float("currentPlayerHunger", &inputs), 0.5);
assert_eq!(float("maxPlayerHunger", &inputs), 20.0);
assert_eq!(float("currentPlayerAir", &inputs), 0.5);
assert_eq!(float("maxPlayerAir", &inputs), 300.0);
assert_eq!(float("currentPlayerArmor", &inputs), 0.4);
assert_eq!(float("maxPlayerArmor", &inputs), 50.0);
assert_eq!(int("is_sneaking", &inputs), 1);
assert_eq!(int("is_invisible", &inputs), 0);
// Creative and spectator: -1 for the vitals and the maximums of health and air;
// spectators are invisible.
inputs.status.survival = false;
inputs.status.spectator = true;
for name in [
"currentPlayerHealth",
"maxPlayerHealth",
"currentPlayerHunger",
"currentPlayerAir",
"maxPlayerAir",
"currentPlayerArmor",
] {
assert_eq!(float(name, &inputs), -1.0, "{name}");
}
assert_eq!(float("maxPlayerHunger", &inputs), 20.0);
assert_eq!(float("maxPlayerArmor", &inputs), 50.0);
assert_eq!(int("isSpectator", &inputs), 1);
assert_eq!(int("is_invisible", &inputs), 1);
// Each is provided, not left at zero for the pack.
for name in [
"currentPlayerHealth",
"isSpectator",
"is_sprinting",
"bedrockLevel",
] {
assert!(index(name).is_some(), "{name} is not provided");
}
assert!(
NAMES
.iter()
.filter(|n| status_value(n, &inputs).is_some())
.count()
== 18
);
}
#[test]
fn cloud_height_needs_a_cloud_color() {
assert_eq!(cloud_height(0.8, 192.33), 192.33);
@@ -0,0 +1,228 @@
//! The frame's commands finished, submitted and presented on a thread of their own.
//!
//! wgpu records a pass's commands lazily: `CommandEncoder::finish` validates and
//! encodes the whole frame (about 0.1 ms with the submit), work the window's thread
//! would otherwise wait for before building the next frame. The renderer hands the
//! frame's encoder to [`Queue::submit_later`] and goes on; every use of the queue
//! ([`Deref`]) first waits for that frame to be submitted, so writes, copies and
//! submissions keep the order they had on one thread.
use std::ops::Deref;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Mutex, mpsc};
/// Whether frames go to the submit thread, unless `HORIZON_SUBMIT_THREAD` says (`0` off, `1` on):
/// on Linux, where it was measured faster (+32% FPS in a CPU-bound view on Vulkan, #5779). On
/// macOS's Metal it stalled: offscreen at render distance 12, a tenth to a half of the frame rate
/// with 20-180 ms p99 frames, against the same binary without it. Windows is not measured yet.
pub fn wanted() -> bool {
match std::env::var("HORIZON_SUBMIT_THREAD").as_deref() {
Ok("0") => false,
Ok("1") => true,
_ => cfg!(target_os = "linux"),
}
}
/// Work to start right after a frame's submission (a readback's `map_async`).
pub type AfterSubmit = Box<dyn FnOnce() + Send>;
/// A frame for the submit thread: its commands and, in a window, the surface to present.
pub struct Job {
pub encoder: wgpu::CommandEncoder,
pub surface: Option<wgpu::SurfaceTexture>,
pub after: Option<AfterSubmit>,
}
struct Thread {
jobs: Option<mpsc::Sender<Job>>,
handle: Option<std::thread::JoinHandle<()>>,
}
/// What the submit thread sent back, once it is taken in by [`Queue::wait`].
struct Landing {
done: mpsc::Receiver<wgpu::SubmissionIndex>,
landed: Option<wgpu::SubmissionIndex>,
}
/// The device's queue, with frames submitted on the submit thread when it runs.
pub struct Queue {
queue: wgpu::Queue,
thread: Option<Thread>,
/// A frame went to the submit thread and has not been waited for.
pending: AtomicBool,
landing: Mutex<Option<Landing>>,
}
impl Queue {
/// `threaded`: frames may go to a submit thread (the renderer asks [`wanted`]).
pub fn new(queue: wgpu::Queue, threaded: bool) -> Self {
let mut this = Self {
queue,
thread: None,
pending: AtomicBool::new(false),
landing: Mutex::new(None),
};
if !threaded {
return this;
}
let (jobs, job_rx) = mpsc::channel::<Job>();
let (done_tx, done) = mpsc::channel();
let queue = this.queue.clone();
let spawned = std::thread::Builder::new()
.name("Render submit".into())
.spawn(move || {
for job in job_rx {
let commands = job.encoder.finish();
let submission = queue.submit([commands]);
if let Some(after) = job.after {
after();
}
if let Some(surface) = job.surface {
queue.present(surface);
}
if done_tx.send(submission).is_err() {
break;
}
}
});
match spawned {
Ok(handle) => {
this.thread = Some(Thread {
jobs: Some(jobs),
handle: Some(handle),
});
*this.lock() = Some(Landing { done, landed: None });
}
Err(e) => log::warn!("Horizon renderer: no submit thread ({e}); submitting in place"),
}
this
}
fn lock(&self) -> std::sync::MutexGuard<'_, Option<Landing>> {
self.landing
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
/// Whether frames can go to the submit thread.
pub fn threaded(&self) -> bool {
self.thread.is_some()
}
/// Finish, submit and present the frame on the submit thread, then run its `after`;
/// given back, with nothing done, when there is none.
pub fn submit_later(&self, job: Job) -> Result<(), Box<Job>> {
let Some(jobs) = self.thread.as_ref().and_then(|t| t.jobs.as_ref()) else {
return Err(Box::new(job));
};
self.wait();
match jobs.send(job) {
Ok(()) => {
self.pending.store(true, Ordering::Release);
Ok(())
}
Err(mpsc::SendError(job)) => Err(Box::new(job)),
}
}
/// Wait until the frame on the submit thread is submitted and presented.
pub fn wait(&self) {
if !self.pending.load(Ordering::Acquire) {
return;
}
let mut landing = self.lock();
if !self.pending.load(Ordering::Acquire) {
return;
}
if let Some(l) = landing.as_mut() {
// A thread that died (a wgpu panic on it) panics here, as it would have in place.
let submission = l
.done
.recv()
.expect("Horizon renderer: the submit thread stopped");
l.landed = Some(submission);
}
self.pending.store(false, Ordering::Release);
}
/// The submission of the frame [`submit_later`](Self::submit_later) took, once it is
/// made; `None` if it was taken already.
pub fn landed(&self) -> Option<wgpu::SubmissionIndex> {
self.wait();
self.lock().as_mut().and_then(|l| l.landed.take())
}
}
impl Deref for Queue {
type Target = wgpu::Queue;
fn deref(&self) -> &wgpu::Queue {
self.wait();
&self.queue
}
}
impl Drop for Queue {
fn drop(&mut self) {
self.wait();
if let Some(thread) = &mut self.thread {
thread.jobs.take();
if let Some(handle) = thread.handle.take() {
let _ = handle.join();
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn device() -> Option<(wgpu::Device, wgpu::Queue)> {
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).ok()?;
pollster::block_on(adapter.request_device(&Default::default())).ok()
}
fn buffer(device: &wgpu::Device, usage: wgpu::BufferUsages) -> wgpu::Buffer {
device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: 4,
usage,
mapped_at_creation: false,
})
}
#[test]
fn writes_after_a_frame_on_the_thread_land_after_it() {
let Some((device, queue)) = device() else {
return;
};
let queue = Queue::new(queue, true);
assert!(queue.threaded());
use wgpu::BufferUsages as U;
let source = buffer(&device, U::COPY_SRC | U::COPY_DST);
let copy = buffer(&device, U::COPY_SRC | U::COPY_DST);
let read = buffer(&device, U::MAP_READ | U::COPY_DST);
queue.write_buffer(&source, 0, &[1, 2, 3, 4]);
for frame in 0..3u8 {
// The frame copies `source`; the next frame's write must not reach that copy.
let mut encoder = device.create_command_encoder(&Default::default());
encoder.copy_buffer_to_buffer(&source, 0, &copy, 0, 4);
let job = Job {
encoder,
surface: None,
after: None,
};
assert!(queue.submit_later(job).is_ok());
queue.write_buffer(&source, 0, &[frame + 10; 4]);
assert!(queue.landed().is_some(), "frame {frame} submitted");
let mut encoder = device.create_command_encoder(&Default::default());
encoder.copy_buffer_to_buffer(&copy, 0, &read, 0, 4);
queue.submit([encoder.finish()]);
read.slice(..)
.map_async(wgpu::MapMode::Read, |r| r.unwrap());
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
let expected = if frame == 0 {
[1, 2, 3, 4]
} else {
[frame + 9; 4]
};
assert_eq!(*read.slice(..).get_mapped_range().unwrap(), expected);
read.unmap();
}
}
}
@@ -299,6 +299,16 @@ impl Post {
Mat4::from_translation(jitter.extend(0.0))
}
/// This frame's jitter in rendered pixels, y down (FSR's `jitterOffset`); zero
/// without TAA.
pub fn jitter_pixels(&self) -> [f32; 2] {
let Some(size) = self.size().filter(|_| self.taa_on) else {
return [0.0; 2];
};
let j = self.taa_this_frame.jitter;
[j[0] * size[0] as f32 * 0.5, -j[1] * size[1] as f32 * 0.5]
}
/// The world's colour and depth targets this frame, or `None` to draw
/// it straight to the screen.
/// `always`: a target even at scale 1 without anti-aliasing (a shader pack reads it).
@@ -507,7 +517,12 @@ impl Post {
/// This frame's clip space to the previous frame's, for camera-relative projections:
/// a point `camera - previous_camera` further from the previous camera than from this one.
fn reprojection(previous: DMat4, previous_camera: DVec3, current: DMat4, camera: DVec3) -> DMat4 {
pub(super) fn reprojection(
previous: DMat4,
previous_camera: DVec3,
current: DMat4,
camera: DVec3,
) -> DMat4 {
previous * DMat4::from_translation(camera - previous_camera) * current.inverse()
}
+288 -8
View File
@@ -79,6 +79,47 @@ impl Preset {
}
}
/// Entity occlusion strategy. The synchronous mode preserves the original
/// same-frame CPU test; asynchronous results only hide a mesh when their
/// camera, bounds and occluder evidence still match.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum EntityCullingMode {
#[default]
Off,
Synchronous,
Asynchronous,
}
impl EntityCullingMode {
pub const ALL: [EntityCullingMode; 3] = [
EntityCullingMode::Off,
EntityCullingMode::Synchronous,
EntityCullingMode::Asynchronous,
];
pub fn name(self) -> &'static str {
match self {
Self::Off => "off",
Self::Synchronous => "sync",
Self::Asynchronous => "async",
}
}
pub fn label(self) -> &'static str {
match self {
Self::Off => "Off",
Self::Synchronous => "Synchronous",
Self::Asynchronous => "Asynchronous",
}
}
fn parse(s: &str) -> Self {
match s {
// Keep graphics files written before the mode selector readable.
"true" | "synchronous" | "sync" => Self::Synchronous,
"false" | "off" => Self::Off,
"asynchronous" | "async" => Self::Asynchronous,
_ => Self::default(),
}
}
}
/// How the swapchain presents frames.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum PresentMode {
@@ -246,6 +287,91 @@ impl FarDetail {
}
}
/// Frame generation: a frame made between each two real ones.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum FrameGeneration {
#[default]
Off,
/// AMD FidelityFX Super Resolution 3.1's frame interpolation, ported to WGSL.
Fsr31,
}
impl FrameGeneration {
pub const ALL: [FrameGeneration; 2] = [FrameGeneration::Off, FrameGeneration::Fsr31];
pub fn name(self) -> &'static str {
match self {
FrameGeneration::Off => "off",
FrameGeneration::Fsr31 => "fsr3.1",
}
}
pub fn label(self) -> &'static str {
match self {
FrameGeneration::Off => "Off",
FrameGeneration::Fsr31 => "FSR 3.1",
}
}
fn parse(s: &str) -> Self {
Self::ALL
.into_iter()
.find(|f| f.name() == s)
.unwrap_or_default()
}
}
/// What the section mesher does with leaf faces inside a canopy. Terrain
/// geometry only: entities, particles and block entities are untouched.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum LeafCullingMode {
/// Every leaf face, as vanilla's fancy leaves draw them.
#[default]
Off,
/// Leaves surrounded on all six sides by leaves or full opaque cubes draw
/// in the opaque layer, so their cutouts no longer show the layers behind.
Solid,
/// Like `Solid`, but a leaf with one open side draws opaque too: the
/// exposed shell thins by a layer and the canopy looks chunkier.
SolidAggressive,
/// Drops the faces two leaves share. Faces toward air, cavities, partial
/// models, unknown neighbours and section boundaries stay.
Hollow,
}
impl LeafCullingMode {
pub const ALL: [LeafCullingMode; 4] = [
LeafCullingMode::Off,
LeafCullingMode::Solid,
LeafCullingMode::SolidAggressive,
LeafCullingMode::Hollow,
];
pub fn name(self) -> &'static str {
match self {
LeafCullingMode::Off => "off",
LeafCullingMode::Solid => "solid",
LeafCullingMode::SolidAggressive => "solid_aggressive",
LeafCullingMode::Hollow => "hollow",
}
}
pub fn label(self) -> &'static str {
match self {
LeafCullingMode::Off => "Off",
LeafCullingMode::Solid => "Solid",
LeafCullingMode::SolidAggressive => "Solid Aggressive",
LeafCullingMode::Hollow => "Hollow",
}
}
/// Whether this mode draws interior leaf faces in the solid layer.
pub fn is_interior(self) -> bool {
matches!(
self,
LeafCullingMode::Solid | LeafCullingMode::SolidAggressive
)
}
fn parse(s: &str) -> Self {
Self::ALL
.into_iter()
.find(|mode| mode.name() == s)
.unwrap_or_default()
}
}
/// TAA upscaling: the world renders this much smaller than the resolution scale's size,
/// and TAA builds the full-size picture from the jittered frames (FSR's and DLSS's ratios).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
@@ -361,6 +487,46 @@ impl Graphics {
pub fn lod_distance_on(&self, info: &wgpu::AdapterInfo) -> u32 {
lod_distance_capped(self.lod_distance, info)
}
/// Whether the selected packs leave vanilla's block models in place.
/// Horizon's occlusion assumptions (entity and leaf culling) hold only
/// for those: a pack can replace an opaque cube's model or a leaf's.
pub fn vanilla_models(&self, resource_packs: &[String]) -> bool {
resource_packs.iter().all(|id| id == "vanilla")
}
/// The same check including packs overlaid by the connected server.
/// Their ids are namespaced as `server/...`, so an active server pack
/// disables model-dependent occlusion assumptions.
pub fn vanilla_models_with_server_packs<'a>(
&self,
resource_packs: impl IntoIterator<Item = &'a str>,
server_packs: impl IntoIterator<Item = &'a str>,
) -> bool {
resource_packs
.into_iter()
.chain(server_packs)
.all(|id| id == "vanilla")
}
/// The leaf culling the mesher may apply. A pack that can replace leaf
/// models falls back to `Off`, which draws exactly vanilla's leaves.
pub fn leaf_culling_effective(&self, resource_packs: &[String]) -> LeafCullingMode {
if self.vanilla_models(resource_packs) {
self.leaf_culling
} else {
LeafCullingMode::Off
}
}
/// The effective mode when local and server resource packs are active.
pub fn leaf_culling_effective_with_server_packs<'a>(
&self,
resource_packs: impl IntoIterator<Item = &'a str>,
server_packs: impl IntoIterator<Item = &'a str>,
) -> LeafCullingMode {
if self.vanilla_models_with_server_packs(resource_packs, server_packs) {
self.leaf_culling
} else {
LeafCullingMode::Off
}
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct Graphics {
@@ -371,8 +537,12 @@ pub struct Graphics {
pub far_detail: FarDetail,
/// Skip sections hidden behind closed ones (vanilla's occlusion graph).
pub cave_culling: bool,
/// Skip drawing entities hidden behind opaque terrain.
pub entity_culling: bool,
/// Skip drawing entities hidden behind opaque terrain, synchronously or asynchronously.
pub entity_culling: EntityCullingMode,
/// Terrain leaf faces inside a canopy (see [`LeafCullingMode`]); `Off` draws
/// vanilla's fancy leaves. Ignored with a resource pack that may replace
/// the vanilla leaf models ([`Graphics::leaf_culling_effective`]).
pub leaf_culling: LeafCullingMode,
/// Meshed sections uploaded per frame; 0 uploads all that are ready.
pub chunk_updates: u32,
/// Section mesh bytes uploaded per frame, in KiB; 0 is unlimited.
@@ -402,6 +572,8 @@ pub struct Graphics {
pub upscaling: Upscaling,
/// F2 screenshots at this many times the game's resolution: 1, 2, 4 or 8.
pub screenshot_quality: u32,
/// Frame generation (Horizon setting), off by default.
pub frame_generation: FrameGeneration,
/// HDR output (HDR10, or scRGB), where the display path offers it.
pub hdr: bool,
/// HDR paper white (where SDR white sits), in nits; 0 is Auto: the desktop's SDR
@@ -445,7 +617,8 @@ impl Default for Graphics {
lod_distance: 0,
far_detail: FarDetail::Full,
cave_culling: true,
entity_culling: false,
entity_culling: EntityCullingMode::Off,
leaf_culling: LeafCullingMode::Off,
chunk_updates: 0,
upload_budget_kib: 0,
texture_budget_mib: 0,
@@ -463,6 +636,7 @@ impl Default for Graphics {
taa: false,
upscaling: Upscaling::Native,
screenshot_quality: 1,
frame_generation: FrameGeneration::Off,
hdr: false,
hdr_paper_white: 0,
hdr_peak: 0,
@@ -514,7 +688,8 @@ impl Graphics {
}
"farDetail" => g.far_detail = FarDetail::parse(value),
"caveCulling" => g.cave_culling = flag(g.cave_culling),
"entityCulling" => g.entity_culling = flag(g.entity_culling),
"entityCulling" => g.entity_culling = EntityCullingMode::parse(value),
"leafCulling" => g.leaf_culling = LeafCullingMode::parse(value),
"chunkUpdates" => g.chunk_updates = number(g.chunk_updates).min(512),
"uploadBudgetKiB" => g.upload_budget_kib = number(g.upload_budget_kib).min(65536),
"textureBudgetMiB" => {
@@ -543,6 +718,7 @@ impl Graphics {
.filter(|q| SCREENSHOT_QUALITIES.contains(q))
.unwrap_or(1)
}
"frameGeneration" => g.frame_generation = FrameGeneration::parse(value),
"hdr" => g.hdr = flag(g.hdr),
"hdrPaperWhite" => {
g.hdr_paper_white = match number(g.hdr_paper_white) {
@@ -580,12 +756,13 @@ impl Graphics {
}
pub fn serialize(&self) -> String {
format!(
"preset:{}\nlodDistance:{}\nfarDetail:{}\ncaveCulling:{}\nentityCulling:{}\nchunkUpdates:{}\nuploadBudgetKiB:{}\ntextureBudgetMiB:{}\nsky:{}\nfog:{}\nweather:{}\nanimatedTextures:{}\nditherCutoutMips:{}\npresentMode:{}\nframePacing:{}\nworkers:{}\noverlay:{}\nresolutionScale:{}\nfxaa:{}\ntaa:{}\nupscaling:{}\nscreenshotQuality:{}\nhdr:{}\nhdrPaperWhite:{}\nhdrPeak:{}\nhdrBlackLevel:{}\nhdrUiBrightness:{}\nhdrKey:{}\nbatteryMode:{}\nshadowCache:{}\ngraphicsApi:{}\npickedShaderPack:{}\ntranslucentBatching:{}\n",
"preset:{}\nlodDistance:{}\nfarDetail:{}\ncaveCulling:{}\nentityCulling:{}\nleafCulling:{}\nchunkUpdates:{}\nuploadBudgetKiB:{}\ntextureBudgetMiB:{}\nsky:{}\nfog:{}\nweather:{}\nanimatedTextures:{}\nditherCutoutMips:{}\npresentMode:{}\nframePacing:{}\nworkers:{}\noverlay:{}\nresolutionScale:{}\nfxaa:{}\ntaa:{}\nupscaling:{}\nscreenshotQuality:{}\nframeGeneration:{}\nhdr:{}\nhdrPaperWhite:{}\nhdrPeak:{}\nhdrBlackLevel:{}\nhdrUiBrightness:{}\nhdrKey:{}\nbatteryMode:{}\nshadowCache:{}\ngraphicsApi:{}\npickedShaderPack:{}\ntranslucentBatching:{}\n",
self.preset.name(),
self.lod_distance,
self.far_detail.name(),
bool_text(self.cave_culling),
bool_text(self.entity_culling),
self.entity_culling.name(),
self.leaf_culling.name(),
self.chunk_updates,
self.upload_budget_kib,
self.texture_budget_mib,
@@ -610,6 +787,7 @@ impl Graphics {
bool_text(self.taa),
self.upscaling.name(),
self.screenshot_quality,
self.frame_generation.name(),
bool_text(self.hdr),
self.hdr_paper_white,
self.hdr_peak,
@@ -741,7 +919,8 @@ mod tests {
lod_distance: 256,
far_detail: FarDetail::Reduced,
cave_culling: false,
entity_culling: true,
entity_culling: EntityCullingMode::Synchronous,
leaf_culling: LeafCullingMode::SolidAggressive,
chunk_updates: 12,
upload_budget_kib: 4096,
texture_budget_mib: 1536,
@@ -759,6 +938,7 @@ mod tests {
taa: true,
upscaling: Upscaling::Balanced,
screenshot_quality: 4,
frame_generation: FrameGeneration::Fsr31,
hdr: true,
hdr_paper_white: 240,
hdr_peak: 1400,
@@ -772,6 +952,21 @@ mod tests {
shader_pack: "Golden Hour".into(),
};
assert_eq!(Graphics::parse(&g.serialize()), g);
for mode in EntityCullingMode::ALL {
let graphics = Graphics {
entity_culling: mode,
..Graphics::default()
};
assert_eq!(Graphics::parse(&graphics.serialize()), graphics);
}
assert_eq!(
Graphics::parse("entityCulling:true\n").entity_culling,
EntityCullingMode::Synchronous
);
assert_eq!(
Graphics::parse("entityCulling:false\n").entity_culling,
EntityCullingMode::Off
);
assert_eq!(Graphics::parse(""), Graphics::default());
// Translucent Batching is opt-in for now: only the file turns it on.
assert!(!Graphics::default().translucent_batching);
@@ -801,11 +996,96 @@ mod tests {
Graphics::default()
);
assert_eq!(
Graphics::parse("workers:abc\nsky:maybe\ncullLeaves:true\nentityCulling:maybe\n"),
Graphics::parse(
"workers:abc\nsky:maybe\ncullLeaves:true\nentityCulling:maybe\nleafCulling:maybe\n"
),
Graphics::default()
);
}
#[test]
fn leaf_culling_round_trips_its_stable_names() {
assert_eq!(Graphics::default().leaf_culling, LeafCullingMode::Off);
for mode in LeafCullingMode::ALL {
let g = Graphics {
leaf_culling: mode,
..Graphics::default()
};
assert_eq!(Graphics::parse(&g.serialize()), g, "{mode:?}");
assert_eq!(
Graphics::parse(&format!("leafCulling:{}\n", mode.name())).leaf_culling,
mode
);
}
for text in [
"leafCulling:other\n",
"leafCulling:\n",
"leafCulling:SOLID\n",
] {
assert_eq!(
Graphics::parse(text).leaf_culling,
LeafCullingMode::Off,
"{text:?}"
);
}
}
#[test]
fn presets_keep_the_leaf_culling_choice() {
for mode in LeafCullingMode::ALL {
for preset in Preset::ALL {
let mut s = crate::settings::Settings::default();
s.horizon.leaf_culling = mode;
apply_preset(preset, &mut s);
assert_eq!(s.horizon.leaf_culling, mode, "{preset:?}");
assert_eq!(Graphics::parse(&s.horizon.serialize()), s.horizon);
}
}
}
#[test]
fn custom_packs_turn_leaf_culling_off() {
let g = Graphics {
leaf_culling: LeafCullingMode::Hollow,
..Graphics::default()
};
assert!(g.vanilla_models(&["vanilla".into()]));
assert_eq!(
g.leaf_culling_effective(&["vanilla".into()]),
LeafCullingMode::Hollow
);
assert!(!g.vanilla_models(&["vanilla".into(), "My Pack".into()]));
assert_eq!(
g.leaf_culling_effective(&["vanilla".into(), "My Pack".into()]),
LeafCullingMode::Off
);
assert_eq!(g.leaf_culling_effective(&[]), LeafCullingMode::Hollow);
}
#[test]
fn server_pack_apply_disables_leaf_culling_and_removal_restores_it() {
let g = Graphics {
leaf_culling: LeafCullingMode::Solid,
..Graphics::default()
};
let selected = ["vanilla"];
let applied_server_pack = ["server/0123456789abcdef/model-pack.zip"];
assert_eq!(
g.leaf_culling_effective_with_server_packs(selected, applied_server_pack),
LeafCullingMode::Off,
"an applied server pack can replace block models"
);
assert!(!g.vanilla_models_with_server_packs(selected, applied_server_pack));
assert_eq!(
g.leaf_culling_effective_with_server_packs(selected, []),
LeafCullingMode::Solid,
"removing the server pack restores the selected local mode"
);
assert!(g.vanilla_models_with_server_packs(selected, []));
}
#[test]
fn integrated_gpus_other_than_apple_cap_the_lod_distance() {
let adapter = |name: &str, device_type| wgpu::AdapterInfo {
+139 -20
View File
@@ -2,7 +2,10 @@
//! they show, in what groups, with what description and cost, and their
//! input. How they look comes from `graphics_style`.
use crate::{
graphics::{FarDetail, GraphicsApi, Overlay, Pacing, PresentMode, Preset, Upscaling},
graphics::{
EntityCullingMode, FarDetail, FrameGeneration, GraphicsApi, LeafCullingMode, Overlay,
Pacing, PresentMode, Preset, Upscaling,
},
graphics_style::{Cost, STYLE},
hud::{HudAtlas, HudBatch},
lang,
@@ -27,6 +30,7 @@ pub enum H {
FarDetail,
CaveCulling,
EntityCulling,
LeafCulling,
ChunkUpdates,
UploadBudget,
Sky,
@@ -42,6 +46,7 @@ pub enum H {
Antialiasing,
Upscaling,
ScreenshotQuality,
FrameGeneration,
Hdr,
/// Opens `Level::Hdr`.
HdrSettings,
@@ -187,6 +192,12 @@ pub fn rows(level: Level) -> Vec<Row> {
Medium,
"How far away mobs and items are drawn, as a share of normal.",
),
h(
H::ChunkUpdates,
Low,
"Meshed sections uploaded per frame. Lower is smoother while loading.",
),
Row::Header("Culling"),
h(
H::CaveCulling,
Saves,
@@ -195,12 +206,12 @@ pub fn rows(level: Level) -> Vec<Row> {
h(
H::EntityCulling,
None,
"Hides models behind opaque blocks. Entities update. Off with custom packs.",
"Hides models behind opaque blocks. Async checks off-thread; entities tick.",
),
h(
H::ChunkUpdates,
Low,
"Meshed sections uploaded per frame. Lower is smoother while loading.",
H::LeafCulling,
Saves,
"Draws or drops leaf faces inside canopies. Off keeps vanilla's fancy leaves.",
),
Row::Header("Quality"),
v(
@@ -291,6 +302,11 @@ pub fn rows(level: Level) -> Vec<Row> {
None,
"F2 screenshots at this many times the game's resolution. Higher takes longer.",
),
h(
H::FrameGeneration,
Medium,
"Shows a made-up frame between each two real ones: smoother, a little more lag.",
),
h(
H::HdrSettings,
None,
@@ -446,6 +462,16 @@ fn description(item: Item, s: &Settings, text: &'static str) -> &'static str {
(Item::Horizon(H::HdrPeak | H::HdrPaperWhite), Setup::Automatic | Setup::Changed) => {
"Set up for your display. Leave on Auto unless it looks wrong."
}
(Item::Horizon(H::LeafCulling), _) => match s.horizon.leaf_culling {
LeafCullingMode::Off => "Every leaf face. Vanilla's fancy leaves.",
LeafCullingMode::Solid => {
"Leaves fully inside a canopy draw solid: fewer see-through layers."
}
LeafCullingMode::SolidAggressive => {
"Leaves with one open side draw solid too: faster, chunkier edges."
}
LeafCullingMode::Hollow => "Drops faces between leaves: faster, but gaps look through.",
},
_ => text,
}
}
@@ -515,7 +541,8 @@ impl H {
},
H::FarDetail => labelled("Far Detail", g.far_detail.label()),
H::CaveCulling => labelled("Cave Culling", on_off(g.cave_culling)),
H::EntityCulling => labelled("Entity Culling", on_off(g.entity_culling)),
H::EntityCulling => labelled("Entity Culling", g.entity_culling.label()),
H::LeafCulling => labelled("Leaf Culling", g.leaf_culling.label()),
H::ChunkUpdates => match g.chunk_updates {
0 => labelled("Chunk Updates", "Unlimited"),
n => labelled("Chunk Updates", lang::tr("%s per frame", &[&n])),
@@ -560,6 +587,7 @@ impl H {
H::ScreenshotQuality => {
labelled("Screenshot Quality", format!("{}x", g.screenshot_quality))
}
H::FrameGeneration => labelled("Frame Generation", g.frame_generation.label()),
H::Hdr => labelled("HDR", on_off(g.hdr)),
H::HdrSettings => Text::literal(lang::get("HDR Settings...")),
H::HdrCalibrate => Text::literal(lang::get(match setup(s) {
@@ -590,7 +618,7 @@ impl H {
),
H::HdrUiBrightness => labelled("UI Brightness", format!("{}%", g.hdr_ui_brightness)),
H::HdrKey => {
Text::literal("Shortcut: ").append(crate::keybinds::display_name(&g.hdr_key))
Text::translate("Shortcut: %s", &[crate::keybinds::display_name(&g.hdr_key)])
}
H::BatteryMode => labelled("Battery Mode", on_off(g.battery)),
H::ShadowCache => labelled("Shadow Cache", on_off(g.shadow_cache)),
@@ -648,7 +676,12 @@ impl H {
g.far_detail = step(&[FarDetail::Full, FarDetail::Reduced], g.far_detail, back)
}
H::CaveCulling => g.cave_culling = !g.cave_culling,
H::EntityCulling => g.entity_culling = !g.entity_culling,
H::EntityCulling => {
g.entity_culling = step(&EntityCullingMode::ALL, g.entity_culling, back)
}
H::LeafCulling => {
g.leaf_culling = step(&LeafCullingMode::ALL, g.leaf_culling, back);
}
H::Sky => g.sky = !g.sky,
H::Fog => g.fog = !g.fog,
H::Weather => g.weather = !g.weather,
@@ -678,6 +711,9 @@ impl H {
back,
)
}
H::FrameGeneration => {
g.frame_generation = step(&FrameGeneration::ALL, g.frame_generation, back)
}
H::Hdr => g.hdr = !g.hdr,
H::BatteryMode => g.battery = !g.battery,
H::ShadowCache => g.shadow_cache = !g.shadow_cache,
@@ -736,7 +772,6 @@ impl Item {
let g = &s.horizon;
Some(match self {
Item::Horizon(H::CaveCulling) => g.cave_culling,
Item::Horizon(H::EntityCulling) => g.entity_culling,
Item::Horizon(H::Sky) => g.sky,
Item::Horizon(H::Fog) => g.fog,
Item::Horizon(H::Weather) => g.weather,
@@ -804,6 +839,7 @@ impl Item {
| H::Pacing
| H::PresentMode
| H::Workers
| H::FrameGeneration
| H::BatteryMode
| H::ShadowCache
| H::GraphicsApi
@@ -1039,9 +1075,12 @@ impl GraphicsUi {
let mut label = item.label(s);
if item == Item::Horizon(H::HdrKey) && self.binding.is_some() {
// KeyBindsScreen: "> key <" while it waits.
label = Text::literal("Shortcut: > ")
.append(crate::keybinds::display_name(&s.horizon.hdr_key))
.append(" <");
label = Text::translate(
"Shortcut: %s",
&[Text::literal("> ")
.append(crate::keybinds::display_name(&s.horizon.hdr_key))
.append(" <")],
);
}
let blocked = item.blocked(s);
let live = blocked.is_none();
@@ -1826,16 +1865,92 @@ mod tests {
}
#[test]
fn entity_culling_is_opt_in_and_toggles_from_advanced_graphics() {
fn leaf_culling_cycles_modes_in_the_advanced_culling_group() {
let mut s = Settings::default();
let item = Item::Horizon(H::LeafCulling);
assert_eq!(item.switch(&s), None);
assert_eq!(item.label(&s).plain(), "Leaf Culling: Off");
for label in ["Solid", "Solid Aggressive", "Hollow", "Off"] {
item.cycle(&mut s, false, [854, 480]);
assert_eq!(item.label(&s).plain(), format!("Leaf Culling: {label}"));
assert_eq!(
crate::graphics::Graphics::parse(&s.horizon.serialize()).leaf_culling,
s.horizon.leaf_culling
);
}
item.cycle(&mut s, true, [854, 480]);
assert_eq!(s.horizon.leaf_culling, LeafCullingMode::Hollow);
// The three culling settings share one group, below Rendering's chunk
// updates and above Quality.
let rows = rows(Level::Advanced);
let culling = rows
.iter()
.position(|row| matches!(row, Row::Header("Culling")))
.unwrap();
assert!(matches!(
rows.get(culling + 1),
Some(Row::Setting(setting)) if setting.item == Item::Horizon(H::CaveCulling)
));
assert!(matches!(
rows.get(culling + 2),
Some(Row::Setting(setting)) if setting.item == Item::Horizon(H::EntityCulling)
));
assert!(matches!(
rows.get(culling + 3),
Some(Row::Setting(setting)) if setting.item == item
));
assert!(matches!(
rows.get(culling + 4),
Some(Row::Header("Quality"))
));
// The description names what the picked mode does.
for mode in LeafCullingMode::ALL {
s.horizon.leaf_culling = mode;
let text = description(item, &s, "fallback");
assert_ne!(text, "fallback", "{mode:?}");
assert!(text.len() < 80, "{mode:?}: {text}");
}
// A click cycles it and leaves the preset, as the other quality
// settings do.
let mut ui = GraphicsUi::default();
let size = (480.0, 4000.0);
let (widgets, _, _) = ui.layout(Level::Advanced, &s, size.0, size.1);
let control = widgets
.iter()
.find(|w| matches!(w.target, Target::Item(i) if i == item))
.unwrap();
s.horizon.preset = Preset::Balanced;
s.horizon.leaf_culling = LeafCullingMode::Off;
assert_eq!(
ui.click(
Level::Advanced,
&mut s,
(control.x + 1.0, control.y + 1.0),
size,
[854, 480],
false,
false
),
vec![Action::ButtonClick, Action::Changed]
);
assert_eq!(s.horizon.leaf_culling, LeafCullingMode::Solid);
assert_eq!(s.horizon.preset, Preset::Custom);
}
#[test]
fn entity_culling_modes_are_opt_in_and_cycle_from_advanced_graphics() {
let mut s = Settings::default();
let item = Item::Horizon(H::EntityCulling);
assert_eq!(item.switch(&s), Some(false));
assert_eq!(s.horizon.entity_culling, EntityCullingMode::Off);
assert_eq!(item.label(&s).plain(), "Entity Culling: Off");
for enabled in [false, true] {
s.horizon.entity_culling = enabled;
for mode in EntityCullingMode::ALL {
s.horizon.entity_culling = mode;
for preset in Preset::ALL {
crate::graphics::apply_preset(preset, &mut s);
assert_eq!(s.horizon.entity_culling, enabled);
assert_eq!(s.horizon.entity_culling, mode);
}
}
@@ -1847,7 +1962,11 @@ mod tests {
.find(|w| matches!(w.target, Target::Item(i) if i == item))
.unwrap();
let point = (switch.x + 1.0, switch.y + 1.0);
for enabled in [false, true] {
for mode in [
EntityCullingMode::Off,
EntityCullingMode::Synchronous,
EntityCullingMode::Asynchronous,
] {
s.horizon.preset = Preset::Balanced;
assert_eq!(
ui.click(
@@ -1861,10 +1980,10 @@ mod tests {
),
vec![Action::ButtonClick, Action::Changed]
);
assert_eq!(item.switch(&s), Some(enabled));
assert_eq!(s.horizon.entity_culling, mode);
assert_eq!(s.horizon.preset, Preset::Custom);
}
assert_eq!(item.label(&s).plain(), "Entity Culling: On");
assert_eq!(item.label(&s).plain(), "Entity Culling: Asynchronous");
}
#[test]
@@ -18,6 +18,7 @@
//! or Escape leaves them as they were.
use crate::{
hud::{HudAtlas, HudBatch, rgba},
lang,
settings::Settings,
text::Text,
ui,
@@ -64,14 +65,19 @@ impl Step {
Step::PaperWhite => (10.0, 480.0, 10.0),
}
}
fn name(self) -> &'static str {
/// The English name, which is also its key in the language table.
fn name_key(self) -> &'static str {
match self {
Step::Black => "Black Level",
Step::Peak => "Peak Brightness",
Step::PaperWhite => "Paper White",
}
}
fn hint(self) -> &'static str {
fn name(self) -> String {
lang::get(self.name_key())
}
/// The English hint, which is also its key in the language table.
fn hint_key(self) -> &'static str {
match self {
Step::Black => "Lower it until the logo just disappears into the black.",
Step::Peak => "Raise it until the logo just disappears into the bright square.",
@@ -139,10 +145,16 @@ impl Calibration {
}
fn label(&self) -> Text {
let v = self.values[self.step];
Text::literal(match self.current() {
Step::Black => format!("Black Level: {v:.2} nits"),
step => format!("{}: {v:.0} nits", step.name()),
})
let step = self.current();
let nits = match step {
Step::Black => format!("{v:.2}"),
_ => format!("{v:.0}"),
};
Text::literal(format!(
"{}: {}",
step.name(),
lang::tr("%s nits", &[&nits])
))
}
/// The step, its nits and where the controls start (a share of the height), for
/// HDR output's test picture.
@@ -160,14 +172,12 @@ impl Calibration {
batch.fill(atlas, 0.0, 0.0, w, h, rgba(0, 0, 0, 255));
batch.fill(atlas, 0.0, l.top, w, h - l.top, rgba(24, 24, 24, 255));
let step = self.current();
let title = Text::literal(format!(
"HDR Calibration {}/{}: {}",
self.step + 1,
STEPS.len(),
step.name()
let title = Text::literal(lang::tr(
"HDR Calibration %s/%s: %s",
&[&(self.step + 1), &STEPS.len(), &step.name()],
));
ui::centered_text(batch, atlas, &title, 0.0, l.top + 6.0, w, u32::MAX);
let hint = Text::literal(step.hint());
let hint = Text::literal(lang::get(step.hint_key()));
ui::centered_text(
batch,
atlas,
@@ -192,10 +202,12 @@ impl Calibration {
self.dragging,
);
let last = self.step + 1 == STEPS.len();
let save = lang::get_or("selectWorld.edit.save", "Save");
let next = lang::get("Next");
for (rect, label, active) in [
(l.cancel, "Cancel", true),
(l.back, "Back", self.step > 0),
(l.next, if last { "Save" } else { "Next" }, true),
(l.cancel, lang::get_or("gui.cancel", "Cancel"), true),
(l.back, lang::get_or("gui.back", "Back"), self.step > 0),
(l.next, if last { save } else { next }, true),
] {
let hover = over(cursor, rect) && active;
ui::button(
+127 -28
View File
@@ -83,6 +83,8 @@ pub struct HudAtlas {
/// A resource pack's own `core/text` shader for the GUI's text, translated;
/// none draws the text natively.
pub text_shader: Option<std::sync::Arc<horizon_shaders::CoreProgram>>,
/// The packs' OptiFine random entity textures, whose variants this atlas packs.
pub random_textures: horizon_optifine::random_textures::RandomTextures,
white: Rect,
}
/// Atlas rows reserved for glyphs rasterized at run time.
@@ -118,6 +120,7 @@ pub fn decode(path: &Path) -> Result<(u32, u32, Vec<u8>)> {
/// A PNG in memory as RGBA pixels.
pub fn decode_bytes(data: &[u8]) -> Result<(u32, u32, Vec<u8>)> {
let mut decoder = png::Decoder::new(std::io::Cursor::new(data));
decoder.ignore_checksums(true);
decoder.set_transformations(png::Transformations::EXPAND | png::Transformations::STRIP_16);
let mut reader = decoder.read_info()?;
let mut bytes = vec![0; reader.output_buffer_size().ok_or("image too large")?];
@@ -212,6 +215,28 @@ fn wrap_border((name, w, h, data): &PackedImage) -> PackedImage {
}
(name.clone(), bw, bh, out)
}
/// Folders of `assets/minecraft/textures/` that [`HudAtlas::pack`] walks for
/// the full atlas. A pack may add others through `atlases/blocks.json`.
const WALKED_TEXTURE_FOLDERS: &[&str] = &[
"gui",
"item",
"block",
"font",
"environment",
"misc",
"painting",
"entity",
"particle",
"mob_effect",
"map",
];
fn walked_minecraft_texture(name: &str) -> bool {
WALKED_TEXTURE_FOLDERS
.iter()
.any(|folder| name.starts_with(folder) && name.as_bytes().get(folder.len()) == Some(&b'/'))
}
/// Sprites the atlas definitions (`atlases/*.json`) add beyond the texture
/// files already packed: generated ones (`paletted_permutations` such as the
/// armour trim sprites, `unstitch` regions) and files listed under another id,
@@ -239,11 +264,13 @@ fn atlas_sprites(root: &Path, packed: &[PackedImage]) -> (Vec<PackedImage>, Vec<
if have.contains(name.as_str()) {
continue;
}
// A vanilla file is packed under its own name already: only a
// renamed one is new. Other namespaces' files (content mods) are
// never in the walked directories.
// A file under a walked folder is packed under its own name already:
// only a renamed one is new. Other namespaces' files (content mods)
// are never in those folders, nor are extra directories a pack adds
// to the blocks atlas (`custom/...`).
if let SpriteOrigin::File(texture) = &origin
&& texture.strip_prefix("minecraft:") == Some(name.as_str())
&& walked_minecraft_texture(name.as_str())
{
continue;
}
@@ -346,24 +373,7 @@ impl HudAtlas {
/// The atlas of a resource root that layers packs over `defaults` (the
/// game's own resources), whose textures give the sprites' GUI sizes.
pub fn build_over(root: &Path, defaults: &Path) -> Result<Self> {
let mut atlas = Self::pack(
root,
defaults,
&[
"gui",
"item",
"block",
"font",
"environment",
"misc",
"painting",
"entity",
"particle",
"mob_effect",
"map",
],
true,
)?;
let mut atlas = Self::pack(root, defaults, WALKED_TEXTURE_FOLDERS, true)?;
atlas.text_shader = pack_text::load(root, defaults);
Ok(atlas)
}
@@ -479,6 +489,7 @@ impl HudAtlas {
glint_armor: (vec![0; 4], 1, 1),
white,
text_shader: None,
random_textures: Default::default(),
}
}
/// Shelf placements of `images` (tallest first) in the narrowest atlas
@@ -567,15 +578,41 @@ impl HudAtlas {
let s = p.to_string_lossy();
!s.contains("title/background") && !s.contains("realms") && !s.contains("presets")
});
// OptiFine random entity variants, under the names their scan gives them (those
// outside `textures/` are `<ns>:optifine/random/...`).
let random_textures = if folders.contains(&"entity") {
horizon_optifine::random_textures::RandomTextures::scan(root)
} else {
Default::default()
};
let mut named: HashMap<PathBuf, String> = HashMap::new();
for (file, name) in random_textures.files() {
named.insert(root.join(file), name.to_owned());
}
if !named.is_empty() {
// Those beside their texture are walked already.
let walked: HashSet<&PathBuf> = files.iter().collect();
let mut extra: Vec<PathBuf> = named
.keys()
.filter(|p| !walked.contains(p))
.cloned()
.collect();
extra.sort();
files.extend(extra);
}
type Decoded = (PackedImage, Option<TextureFlags>, Option<(u32, u32)>, bool);
let decode_one = |path: &PathBuf| -> Option<Decoded> {
let name = match path.strip_prefix(&textures) {
Ok(rel) => rel.with_extension("").to_string_lossy().replace('\\', "/"),
Err(_) => {
let rel = path.strip_prefix(&assets).ok()?.with_extension("");
let rel = rel.to_string_lossy().replace('\\', "/");
let (namespace, rest) = rel.split_once("/textures/")?;
format!("{namespace}:{rest}")
let name = if let Some(name) = named.get(path) {
name.clone()
} else {
match path.strip_prefix(&textures) {
Ok(rel) => rel.with_extension("").to_string_lossy().replace('\\', "/"),
Err(_) => {
let rel = path.strip_prefix(&assets).ok()?.with_extension("");
let rel = rel.to_string_lossy().replace('\\', "/");
let (namespace, rest) = rel.split_once("/textures/")?;
format!("{namespace}:{rest}")
}
}
};
// LabPBR's maps beside a texture are the world's (sprites.rs), not images to draw.
@@ -777,6 +814,7 @@ impl HudAtlas {
glint,
glint_armor,
text_shader: None,
random_textures,
font,
white,
})
@@ -2121,6 +2159,40 @@ impl HudRenderer {
mod tests {
use super::*;
#[test]
fn decode_bytes_ignores_crc_checksum_error() {
let mut encoded = Vec::new();
{
let mut encoder = png::Encoder::new(&mut encoded, 2, 2);
encoder.set_color(png::ColorType::Rgba);
encoder.set_depth(png::BitDepth::Eight);
let mut writer = encoder.write_header().unwrap();
writer
.write_image_data(&[
255, 0, 0, 255, 0, 255, 0, 255, 0, 0, 255, 255, 255, 255, 255, 255,
])
.unwrap();
writer.finish().unwrap();
}
let idat_pos = encoded
.windows(4)
.position(|w| w == b"IDAT")
.expect("IDAT chunk");
let len = u32::from_be_bytes([
encoded[idat_pos - 4],
encoded[idat_pos - 3],
encoded[idat_pos - 2],
encoded[idat_pos - 1],
]) as usize;
let crc_pos = idat_pos + 4 + len;
encoded[crc_pos] ^= 0xFF;
let (w, h, rgba) = decode_bytes(&encoded).expect("should decode despite bad CRC");
assert_eq!(w, 2);
assert_eq!(h, 2);
assert_eq!(rgba.len(), 16);
}
#[test]
fn clip_since_keeps_the_visible_part_of_a_straddling_triangle() {
let mut batch = HudBatch::new([320, 240]);
@@ -2333,6 +2405,33 @@ mod tests {
assert_eq!(texel_at(&atlas, rect, 0, 0), [0, 255, 0, 255]);
}
#[test]
fn a_pack_directory_outside_the_walked_folders_still_packs() {
let root =
std::env::temp_dir().join(format!("horizon-atlas-custom-{}", std::process::id()));
let _ = fs::remove_dir_all(&root);
let atlases = root.join("assets/minecraft/atlases");
let custom = root.join("assets/minecraft/textures/custom");
fs::create_dir_all(&atlases).unwrap();
fs::create_dir_all(&custom).unwrap();
fs::write(
atlases.join("blocks.json"),
r#"{"sources":[{"type":"directory","source":"custom","prefix":"custom/"}]}"#,
)
.unwrap();
write_strip(
&custom.join("test.png"),
16,
16,
&[[40, 180, 80, 255]],
None,
);
let atlas = HudAtlas::build(&root).unwrap();
let rect = atlas.sprites["custom/test"];
assert_eq!(rect.texels((atlas.width, atlas.height)), (16, 16));
assert_eq!(texel_at(&atlas, rect, 0, 0), [40, 180, 80, 255]);
let _ = fs::remove_dir_all(&root);
}
#[test]
fn another_namespaces_animated_item_texture_packs_its_first_frame_and_animates() {
let root =
std::env::temp_dir().join(format!("horizon-atlas-ns-anim-{}", std::process::id()));
+3
View File
@@ -8,6 +8,7 @@ pub mod auth;
pub mod ban_notice;
pub mod bidi;
pub mod biome;
pub mod block_edit;
pub mod block_entities;
pub mod block_entity_anim;
pub mod block_entity_render;
@@ -18,9 +19,11 @@ pub mod camera;
pub mod chat;
pub mod chunk;
pub mod chunk_map;
pub mod cit;
pub mod clouds;
pub mod collisions;
pub mod command_block;
pub mod connected;
pub mod containers;
pub mod crash;
pub mod datapack;
+60 -13
View File
@@ -592,18 +592,21 @@ pub fn apply_relighting(chunk: &mut Chunk, lights: Vec<Light>, changed: &mut Lig
changed.keyed(chunk.layout());
for (sy, (section, light)) in chunk.sections.iter_mut().zip(lights).enumerate() {
let old = &section.light;
if let (Light::Uniform(a), Light::Uniform(b)) = (old, &light)
&& a == b
{
continue;
}
let mut classes = 0;
for i in 0..4096 {
if old.get(i) != light.get(i) {
let (x, y, z) = (i as i32 & 15, (i >> 8) as i32, (i >> 4) as i32 & 15);
classes |= 1 << LightChanges::class(x, y, z);
let classes = match (old, &light) {
(Light::Uniform(a), Light::Uniform(b)) if a == b => continue,
// (most sections of a chunk relit for a neighbour's edit keep their light: one comparison of the whole)
(Light::Full(a), Light::Full(b)) => LightChanges::classes_differing(a, b),
_ => {
let mut classes = 0;
for i in 0..4096 {
if old.get(i) != light.get(i) {
let (x, y, z) = (i as i32 & 15, (i >> 8) as i32, (i >> 4) as i32 & 15);
classes |= 1 << LightChanges::class(x, y, z);
}
}
classes
}
}
};
if classes != 0 {
changed.add((chunk.x, sy as i32, chunk.z), classes);
Arc::make_mut(section).light = light;
@@ -847,7 +850,7 @@ pub struct LightChanges {
impl LightChanges {
/// Changes in chunks of `layout`: what the relighter or a light job adds
/// after this (they all come from one level).
fn keyed(&mut self, layout: ColumnLayout) {
pub fn keyed(&mut self, layout: ColumnLayout) {
debug_assert!(self.sections.is_empty() || self.layout == layout);
self.layout = layout;
}
@@ -858,7 +861,7 @@ impl LightChanges {
*self.sections.entry(key).or_default() |= 1 << Self::class(x, y, z);
}
/// Cells of section `key` (x, section index, z) in the given classes.
fn add(&mut self, key: Pos, classes: u32) {
pub fn add(&mut self, key: Pos, classes: u32) {
*self.sections.entry(key).or_default() |= classes;
}
/// The class of a cell in its section: per axis 0 on the low face, 1
@@ -871,6 +874,25 @@ impl LightChanges {
};
axis(x) + 3 * axis(y) + 9 * axis(z)
}
/// The classes (bit `class`) of the cells, in a section's index order (`y << 8 | z << 4 | x`), where `a` and
/// `b` differ.
pub fn classes_differing<T: PartialEq>(a: &[T; 4096], b: &[T; 4096]) -> u32 {
let mut classes = 0;
// (a row of 16 along x at a time: the rows alike, most of them, cost one comparison)
let (rows_a, rows_b) = (a.as_chunks::<16>().0, b.as_chunks::<16>().0);
for (row, (ra, rb)) in rows_a.iter().zip(rows_b).enumerate() {
if ra == rb {
continue;
}
let (y, z) = ((row >> 4) as i32, (row & 15) as i32);
for x in 0..16 {
if ra[x] != rb[x] {
classes |= 1 << Self::class(x as i32, y, z);
}
}
}
classes
}
pub fn is_empty(&self) -> bool {
self.sections.is_empty()
}
@@ -1156,6 +1178,31 @@ mod tests {
use super::*;
use crate::chunk::{HEIGHT, MIN_Y, SECTIONS};
#[test]
fn changed_cells_dirty_only_the_sections_within_a_block() {
let dirty = |cells: &[(usize, usize, usize)]| {
let a = [0u8; 4096];
let mut b = a;
for &(x, y, z) in cells {
b[y << 8 | z << 4 | x] = 1;
}
let mut changes = LightChanges::default();
changes.add((0, 5, 0), LightChanges::classes_differing(&a, &b));
let mut out = Vec::new();
changes.dirty_sections(|p| out.push(p));
out.sort_unstable();
out
};
assert_eq!(dirty(&[]), Vec::<Pos>::new());
assert_eq!(dirty(&[(5, 5, 5), (9, 1, 14)]), vec![(0, 5, 0)]);
assert_eq!(dirty(&[(0, 5, 5)]), vec![(-1, 5, 0), (0, 5, 0)]);
assert_eq!(
dirty(&[(15, 15, 5)]),
vec![(0, 5, 0), (0, 6, 0), (1, 5, 0), (1, 6, 0)]
);
assert_eq!(dirty(&[(0, 0, 0)]).len(), 8);
}
const AIR: u32 = 0;
const STONE: u32 = 1;
const TORCH: u32 = 2;
+329 -37
View File
@@ -4,18 +4,22 @@
//! cardinal shading, tint sources, block offsets; see `lighting`). Fluids
//! follow `FluidRenderer` (see `fluid`).
mod fluid;
mod leaf_culling;
mod lighting;
mod sides;
use crate::connected::{Pick, TileLayer};
use crate::{
assets::{Assets, RenderAssets},
biome::Biomes,
blocks::{BlockFacts, RenderShape},
chunk::{Chunk, MIN_Y},
graphics::LeafCullingMode,
models::catalog::BakedFace,
};
use horizon_core::{
biome::BiomeZoom, geometry::Direction, position::BlockPos, random::position_seed,
};
use horizon_optifine::ctm::connect::Face as CtmFace;
use horizon_world::{
block_render::FaceVisibility,
fluid::{FluidKind, FluidState},
@@ -470,6 +474,9 @@ struct Info {
overlay: bool,
/// `LeavesBlock`: with opaque leaves the faces between two leaf blocks are skipped.
leaves: bool,
/// A full opaque cube: `RenderShape::Model`, `can_occlude`, `solid_render`
/// and a full collision shape. The only block leaf culling treats as solid.
opaque_cube: bool,
blocks_flow: bool,
ice: bool,
sturdy: u8,
@@ -647,6 +654,12 @@ impl MeshTables {
legacy_solid: s.legacy_solid,
overlay: s.has_class("HalfTransparentBlock") || s.has_class("LeavesBlock"),
leaves: s.has_class("LeavesBlock"),
opaque_cube: leaf_culling::opaque_cube(
s.render_shape,
s.can_occlude,
s.solid_render,
s.full_collision,
),
blocks_flow: assets.blocks_fluid_flow.contains(&*s.name),
ice: s.has_class("IceBlock"),
sturdy: s.sturdy_full,
@@ -686,6 +699,14 @@ impl MeshTables {
tint_sources: i.tint_count,
})
}
/// Whether `state` is a full opaque cube: the only block leaf culling
/// treats as a solid neighbour. Glass, fluids, slabs, stairs and models
/// that only partly fill their block are not.
pub fn opaque_cube(&self, state: BlockStateId) -> bool {
self.info
.get(state.0 as usize)
.is_some_and(|i| i.opaque_cube)
}
/// `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`.
@@ -860,14 +881,18 @@ impl Padded {
}
}
/// Per-worker temporary arrays. They never hold resource-pack references or
/// geometry still owned by the renderer, and total about 88 KiB per worker.
/// geometry still owned by the renderer, and total about 150 KiB per worker
/// (the leaf mask adds 8,000 cells, depths and a queue).
#[derive(Default)]
pub(crate) struct MeshScratch {
padded: Padded,
own: Option<Box<[u32; 4096]>>,
visibility: Vec<usize>,
leaf: leaf_culling::LeafMask,
}
#[cfg(test)]
mod leaf_culling_tests;
#[cfg(test)]
mod reuse_tests;
/// Set on a terrain vertex's sprite layer when its texture alpha must be ignored.
pub const OPAQUE_LAYER: u16 = 1 << 15;
@@ -994,6 +1019,32 @@ impl<'a> Tints<'a> {
layers: Default::default(),
}
}
/// The biome of block `(x, y, z)` (section-local x/z, y in 0..16), as `BiomeManager.getBiome`
/// picks it.
fn biome(&self, x: i32, y: i32, z: i32) -> &'a str {
let id = Self::biome_id(&self.zoom, self.region, self.plains, self.origin, [x, y, z]);
&self.biomes.get(id).name
}
/// `BiomeManager.getBiome` over the loaded chunks (plains where there is none), for block
/// `(x, y, z)` of the section at `origin`. Takes the fields it reads, so `get` can call it
/// while it holds a layer.
fn biome_id(
zoom: &BiomeZoom,
region: &[[Option<&Chunk>; 3]; 3],
plains: u16,
[cx, cz, base_y]: [i32; 3],
[x, y, z]: [i32; 3],
) -> u16 {
let q = zoom.quart_at_block(BlockPos::new(cx * 16 + x, base_y + y, cz * 16 + z));
let (rx, rz) = ((q[0] >> 2) - cx + 1, (q[2] >> 2) - cz + 1);
if (0..3).contains(&rx) && (0..3).contains(&rz) {
region[rz as usize][rx as usize].map_or(plains, |c| {
c.biome((q[0] & 3) * 4, q[1] * 4, (q[2] & 3) * 4)
})
} else {
plains
}
}
/// The blended tint of block `(x, y, z)` (section-local x/z, y in 0..16).
fn get(&mut self, resolver: Resolver, x: i32, y: i32, z: i32) -> u32 {
let r = self.radius;
@@ -1009,27 +1060,21 @@ impl<'a> Tints<'a> {
if cached != UNSET {
return cached;
}
let [cx, cz, base_y] = self.origin;
let (bx, by, bz) = (cx * 16, base_y + y, cz * 16);
let [cx, cz, _] = self.origin;
let (bx, bz) = (cx * 16, cz * 16);
let (mut red, mut green, mut blue) = (0, 0, 0);
for dz in -r..=r {
for dx in -r..=r {
let cell = ((z + dz + r) as usize) * dim + (x + dx + r) as usize;
let mut biome = layer.zoomed[cell];
if biome == NO_BIOME {
// BiomeManager.getBiome over the loaded chunks (plains
// where there is none).
let q = self
.zoom
.quart_at_block(BlockPos::new(bx + x + dx, by, bz + z + dz));
let (rx, rz) = ((q[0] >> 2) - cx + 1, (q[2] >> 2) - cz + 1);
biome = if (0..3).contains(&rx) && (0..3).contains(&rz) {
self.region[rz as usize][rx as usize].map_or(self.plains, |c| {
c.biome((q[0] & 3) * 4, q[1] * 4, (q[2] & 3) * 4)
})
} else {
self.plains
};
biome = Self::biome_id(
&self.zoom,
self.region,
self.plains,
self.origin,
[x + dx, y, z + dz],
);
layer.zoomed[cell] = biome;
}
let c = match resolver {
@@ -1079,6 +1124,10 @@ pub struct Mesher<'a> {
pub smooth_lighting: bool,
/// The `cutoutLeaves` option (fancy graphics); off makes leaves opaque.
pub cutout_leaves: bool,
/// Sprite layers a shader pack moves into the translucent pass, by layer.
pub translucent_layers: &'a [bool],
/// What the worker does with leaf faces inside a canopy.
pub leaf_culling: LeafCullingMode,
}
impl Mesher<'_> {
/// `region[dz][dx]` are the chunks around the meshed column (centre 1,1).
@@ -1108,6 +1157,11 @@ impl Mesher<'_> {
let pad = &scratch.padded;
out.visibility = visibility_reusing(pad, &self.tables.info, &mut scratch.visibility);
let base_y = center.min_y() + sy * 16;
if self.leaf_culling.is_interior() {
scratch
.leaf
.prepare(self.leaf_culling, pad, &self.tables.info, region, base_y);
}
let mut tints = Tints::new(
region,
self.biomes,
@@ -1172,13 +1226,21 @@ impl Mesher<'_> {
None => [x as f32, y as f32, z as f32],
};
let mut visible = [false; 6];
let interior_leaf = state.leaves
&& self.leaf_culling.is_interior()
&& scratch.leaf.interior(self.leaf_culling, x, y, z);
for d in Direction::ALL {
let [dx, dy, dz] = d.normal();
let neighbor = pad.blocks[pi(x + dx, y + dy, z + dz)];
let other = &info[neighbor as usize];
// LeavesBlock.skipRendering with opaque leaves.
let opaque_leaves = !self.cutout_leaves && state.leaves && other.leaves;
let hollow_leaf = self.leaf_culling == LeafCullingMode::Hollow
&& state.leaves
&& other.leaves
&& leaf_culling::in_section(x + dx, y + dy, z + dz);
visible[d as usize] = !opaque_leaves
&& !hollow_leaf
&& self
.visibility
.should_render_face(
@@ -1202,7 +1264,9 @@ impl Mesher<'_> {
origin,
face,
ao,
state,
(id, state),
world,
interior_leaf,
);
}
}
@@ -1240,7 +1304,9 @@ impl Mesher<'_> {
origin: [f32; 3],
face: &BakedFace,
ao: bool,
state: &Info,
(id, state): (u32, &Info),
world: BlockPos,
interior_leaf: bool,
) {
let q = &face.quad;
// A quad's own light (a model's glowing part) may raise the tier; whether it
@@ -1267,39 +1333,265 @@ impl Mesher<'_> {
([lighting::gray(directional); 4], [coords; 4])
};
let tint = Self::tint(state, q.tint_index, tints, pos);
if self.tables.is_animated(face.layer as u16) {
out.note_sprite_layer(face.layer as u16);
}
let target = if face.translucent {
&mut out.translucent
// OptiFine connected textures: another sprite, or one per quarter.
let connected = &self.render.connected;
let pick = if connected.is_empty() {
Pick::Keep
} else {
&mut out.solid
let tints = &*tints;
connected.pick(
id,
face.layer as u16,
CtmFace::ALL[q.facing as usize],
[world.x, world.y, world.z],
|[dx, dy, dz]| pad.blocks[pi(pos[0] + dx, pos[1] + dy, pos[2] + dz)],
|| Some(tints.biome(pos[0], pos[1], pos[2])),
)
};
for i in 0..4 {
let mut corners = [(TerrainVertex::new([0.0; 3], [0.0; 2], 0, 0, 0), [0.0; 2]); 4];
for (i, corner) in corners.iter_mut().enumerate() {
let g = gray[i] as u32;
let rgb = match tint {
Some(t) => [16, 8, 0].map(|shift| g * (t >> shift & 255) / 255),
None => [g; 3],
};
let p = q.positions[i];
target.push(TerrainVertex::new(
[p[0] + origin[0], p[1] + origin[1], p[2] + origin[2]],
*corner = (
TerrainVertex::new(
[p[0] + origin[0], p[1] + origin[1], p[2] + origin[2]],
q.uv[i],
0,
emitting(pack_rgb(rgb), tier),
pack_coords(lighting::with_emission(coords[i], q.light_emission)),
),
q.uv[i],
// Opaque leaves (forceOpaque) and quads over opaque texels draw in the
// solid layer: no alpha cutout (a dirt path's side skips its clear top row).
if face.opaque && !face.translucent || !self.cutout_leaves && state.leaves {
face.layer as u16 | OPAQUE_LAYER
} else {
face.layer as u16
},
emitting(pack_rgb(rgb), tier),
pack_coords(lighting::with_emission(coords[i], q.light_emission)),
));
);
}
// Opaque leaves (forceOpaque) and leaves a leaf-culling mode proved internal draw in the
// solid layer whatever their sprite.
let solid_leaves = !self.cutout_leaves && state.leaves || interior_leaf;
let own = TileLayer::Layer {
layer: face.layer as u16,
translucent: face.translucent,
opaque: face.opaque,
};
// A tile keeps the quad's forced translucency; `<skip>` and `<default>` are its own.
let tile = |t: TileLayer| match t {
TileLayer::Layer {
layer,
translucent,
opaque,
} => TileLayer::Layer {
layer,
translucent: translucent || q.material.force_translucent,
opaque,
},
TileLayer::Skip | TileLayer::Default => own,
};
match pick {
Pick::Keep => self.quad(out, solid_leaves, own, corners.map(|c| c.0)),
Pick::Tile(t) => self.quad(out, solid_leaves, tile(t), corners.map(|c| c.0)),
Pick::Quarters(tiles) => {
let cuts = cuts(&corners);
for s in cuts.windows(2) {
for t in cuts.windows(2) {
let piece = [(s[0], t[0]), (s[1], t[0]), (s[1], t[1]), (s[0], t[1])]
.map(|(s, t)| bilinear(&corners, s, t));
let quarter = quarter_of(&piece);
self.quad(out, solid_leaves, tile(tiles[quarter]), piece.map(|c| c.0));
}
}
}
}
}
/// One terrain quad with sprite `tile`, in the pass its alpha needs (`solid_leaves`: no
/// alpha cutout whatever the sprite).
fn quad(
&self,
out: &mut SectionMesh,
solid_leaves: bool,
tile: TileLayer,
mut v: [TerrainVertex; 4],
) {
let TileLayer::Layer {
layer,
translucent,
opaque,
} = tile
else {
return;
};
if self.tables.is_animated(layer) {
out.note_sprite_layer(layer);
}
let translucent = translucent
|| self
.translucent_layers
.get(usize::from(layer))
.copied()
.unwrap_or(false);
// Quads over opaque texels draw in the solid layer: no alpha cutout (a dirt path's side
// skips its clear top row). All four vertices share the layer.
let layer = if opaque && !translucent || solid_leaves {
layer | OPAQUE_LAYER
} else {
layer
};
let target = if translucent {
&mut out.translucent
} else {
&mut out.solid
};
for vertex in &mut v {
vertex.layer = layer;
}
target.extend(v);
}
}
/// The cuts of a `ctm_compact` face, in quad parameters, without allocating: the first `.1`
/// of `.0`.
struct Cuts([f32; 4], usize);
impl std::ops::Deref for Cuts {
type Target = [f32];
fn deref(&self) -> &[f32] {
&self.0[..self.1]
}
}
/// Where a quad's edge from corner 0 to corner `to` (1 for `s`, 3 for `t`) crosses the sprite's
/// middle line, the line its quarters meet on; `None` for an edge that stays on one side (a
/// slab's side).
fn crossing(c: &[(TerrainVertex, [f32; 2]); 4], to: usize) -> Option<f32> {
let (a, b) = (c[0].1, c[to].1);
// The UV the edge runs along.
let k = usize::from((b[1] - a[1]).abs() > (b[0] - a[0]).abs());
let at = (0.5 - a[k]) / (b[k] - a[k]);
(at > 0.0 && at < 1.0).then_some(at)
}
/// The cuts of a `ctm_compact` face, the same along `s` and `t`: both edges' crossings. Pieces
/// on the quad's 0-2 diagonal are then square, so their own triangles lie on the quad's and a
/// split face shades exactly like the whole one; the others lie on one side of it.
fn cuts(c: &[(TerrainVertex, [f32; 2]); 4]) -> Cuts {
let mut out = Cuts([0.0; 4], 1);
let (mut s, mut t) = (crossing(c, 1), crossing(c, 3));
if s > t {
std::mem::swap(&mut s, &mut t);
}
for at in [s, t].into_iter().flatten().chain([1.0]) {
if out.0[out.1 - 1] < at {
out.0[out.1] = at;
out.1 += 1;
}
}
out
}
/// The quarter of the sprite (top-left, top-right, bottom-left, bottom-right) a piece of a
/// quad lies in, by its UV centre (v grows downward).
fn quarter_of(piece: &[(TerrainVertex, [f32; 2]); 4]) -> usize {
let [u, v] = [0, 1].map(|k| piece.iter().map(|c| c.1[k]).sum::<f32>() / 4.0);
usize::from(v >= 0.5) * 2 + usize::from(u >= 0.5)
}
/// The vertex at `(s, t)` across a quad's corners (`s` from corner 0 to 1, `t` from 0 to 3),
/// with its UV in sprite units: a quarter of a `ctm_compact` face. Blended within the quad's
/// two triangles (0-1-2 and 0-2-3), as the GPU blends the whole quad, so a split face shades
/// like an unsplit one.
fn bilinear(c: &[(TerrainVertex, [f32; 2]); 4], s: f32, t: f32) -> (TerrainVertex, [f32; 2]) {
let w = if s >= t {
[1.0 - s, s - t, t, 0.0]
} else {
[1.0 - t, 0.0, s, t - s]
};
let mix = |f: &dyn Fn(&(TerrainVertex, [f32; 2])) -> f32| {
(0..4).map(|i| w[i] * f(&c[i])).sum::<f32>()
};
// Packed bytes (colour, light) blend one by one.
let bytes = |field: fn(&TerrainVertex) -> u32, count: u32| {
(0..count)
.map(|k| (mix(&|c| (field(&c.0) >> (8 * k) & 255) as f32).round() as u32) << (8 * k))
.sum::<u32>()
};
let color = bytes(|v| v.color, 4);
let light = bytes(|v| u32::from(v.light), 2) as u16;
let pos = [0, 1, 2].map(|k| mix(&|c| c.0.pos[k]));
let uv = [0, 1].map(|k| mix(&|c| c.1[k]));
(TerrainVertex::new(pos, uv, 0, color, light), uv)
}
/// Snapshot of the 3x3 chunk neighbourhood used by a worker job.
pub type Region = [[Option<Arc<Chunk>>; 3]; 3];
pub fn borrow(region: &Region) -> [[Option<&Chunk>; 3]; 3] {
std::array::from_fn(|z| std::array::from_fn(|x| region[z][x].as_deref()))
}
#[cfg(test)]
mod quarter_tests {
use super::*;
fn corner(pos: [f32; 3], uv: [f32; 2], red: u32, light: u16) -> (TerrainVertex, [f32; 2]) {
(TerrainVertex::new(pos, uv, 0, 0xFF00_0000 | red, light), uv)
}
#[test]
fn quarters_blend_the_corners_by_triangle() {
// Only corner 2 is lit: the GPU's triangles (0-1-2, 0-2-3) put half of it in the
// middle, where a bilinear blend would put a quarter and the other diagonal none.
let c = [
corner([0.0, 0.0, 0.0], [0.0, 0.0], 0, 0),
corner([1.0, 0.0, 0.0], [1.0, 0.0], 0, 0),
corner([1.0, 1.0, 0.0], [1.0, 1.0], 200, 0x2010),
corner([0.0, 1.0, 0.0], [0.0, 1.0], 0, 0),
];
let (centre, uv) = bilinear(&c, 0.5, 0.5);
assert_eq!(centre.pos, [0.5, 0.5, 0.0]);
assert_eq!(uv, [0.5, 0.5]);
assert_eq!(centre.color, 0xFF00_0000 | 100);
assert_eq!(centre.light, 0x1008, "sky and block light blend apart");
// Off the diagonal, on the 0-1-2 triangle: corner 2 weighs t.
assert_eq!(bilinear(&c, 0.75, 0.25).0.color & 255, 50);
assert_eq!(bilinear(&c, 0.5, 0.0).0.pos, [0.5, 0.0, 0.0]);
}
#[test]
fn compact_faces_cut_where_the_sprite_does() {
// A full face: cut in the middle both ways.
let full = [
corner([0.0, 1.0, 0.0], [0.0, 0.0], 0, 0),
corner([1.0, 1.0, 0.0], [1.0, 0.0], 0, 0),
corner([1.0, 0.0, 0.0], [1.0, 1.0], 0, 0),
corner([0.0, 0.0, 0.0], [0.0, 1.0], 0, 0),
];
assert_eq!(*cuts(&full), [0.0, 0.5, 1.0]);
// A dirt path's side, v from 1/16: the sprite's middle is 7/15 of the way down; both
// directions are cut there and at the middle of u.
let mut path = full;
path[0].1 = [0.0, 1.0 / 16.0];
path[1].1 = [1.0, 1.0 / 16.0];
assert_eq!(
*cuts(&path),
[0.0, (0.5 - 1.0 / 16.0) / (15.0 / 16.0), 0.5, 1.0]
);
// A slab's lower side (v 0.5..1) crosses the middle only along u.
let mut slab = full;
slab[0].1 = [0.0, 0.5];
slab[1].1 = [1.0, 0.5];
assert_eq!(crossing(&slab, 3), None);
assert_eq!(*cuts(&slab), [0.0, 0.5, 1.0]);
// Quarters by UV centre: top-left, top-right, bottom-left, bottom-right.
let piece = |s0: f32, t0: f32| {
[
(s0, t0),
(s0 + 0.5, t0),
(s0 + 0.5, t0 + 0.5),
(s0, t0 + 0.5),
]
.map(|(s, t)| bilinear(&full, s, t))
};
assert_eq!(
[
piece(0.0, 0.0),
piece(0.5, 0.0),
piece(0.0, 0.5),
piece(0.5, 0.5)
]
.map(|p| quarter_of(&p)),
[0, 1, 2, 3]
);
}
}
@@ -0,0 +1,656 @@
//! Leaf culling: decides which leaf faces a section may draw solid or drop.
//!
//! The mask is built on the mesh worker from the same padded block snapshot the
//! rest of the mesher reads, so a decision never needs a chunk lookup on the
//! render thread. It classifies each padded cell as a leaf, a full opaque cube,
//! open (air, fluids, partial models, anything else) or unknown (a missing
//! chunk, a Y outside the column, or the mask's outer shell). Only leaves can
//! hide or solidify anything; unknown and open cells always expose a face.
//!
//! `Solid` performs an immediate 6-neighbour occlusion check, solidifying fully
//! enclosed leaves into the opaque layer without cutout testing. `Solid
//! Aggressive` runs a multi-source breadth-first search from leaves touching
//! openings, solidifying leaves with a single open face to thin the canopy shell
//! by one layer. `Hollow` directly drops faces shared between two leaves within
//! the section, preserving boundaries and cavity faces.
use super::{B, Info, P, Padded, pi};
use crate::{blocks::RenderShape, chunk::Chunk, graphics::LeafCullingMode};
/// A cell of the padded snapshot, as far as leaf culling cares.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) enum Cell {
/// Air, a fluid, a partial model, glass or any other non-occluding block.
Open,
/// `LeavesBlock`.
Leaf,
/// A full opaque cube (see [`opaque_cube`]).
Opaque,
/// Missing chunk, missing section or the mask's outer shell.
Unknown,
}
/// Whether a state is a full opaque cube: `RenderShape::Model`, `can_occlude`,
/// `solid_render` and a full collision shape. Glass, fluids, slabs, stairs,
/// chests and models that only partly fill their block are not.
pub(super) fn opaque_cube(
shape: RenderShape,
can_occlude: bool,
solid_render: bool,
full_collision: bool,
) -> bool {
shape == RenderShape::Model && can_occlude && solid_render && full_collision
}
const UNVISITED: u8 = u8::MAX;
const STRIDE_X: isize = 1;
const STRIDE_Z: isize = P as isize;
const STRIDE_Y: isize = (P * P) as isize;
const ADJACENT_STRIDES: [isize; 6] = [
STRIDE_X, -STRIDE_X, STRIDE_Z, -STRIDE_Z, STRIDE_Y, -STRIDE_Y,
];
/// Per-worker leaf mask and BFS scratch. The vectors keep their capacity
/// between sections and modes; `Off` does no work at all.
#[derive(Default)]
pub(super) struct LeafMask {
cells: Vec<Cell>,
depth: Vec<u8>,
queue: Vec<u16>,
active: bool,
}
impl LeafMask {
/// Classify the padded snapshot and, for the interior modes, measure how
/// deep each leaf sits below the canopy surface.
pub(super) fn prepare(
&mut self,
mode: LeafCullingMode,
pad: &Padded,
info: &[Info],
region: &[[Option<&Chunk>; 3]; 3],
base_y: i32,
) {
self.active = mode != LeafCullingMode::Off;
if !self.active {
return;
}
// Most sections hold no leaves at all; do not classify 8,000 cells to
// learn that. The centre is the only part this mesh can change.
let mut leaves = false;
'scan: for y in 0..16 {
for z in 0..16 {
for x in 0..16 {
if info
.get(pad.blocks[pi(x, y, z)] as usize)
.is_some_and(|state| state.leaves)
{
leaves = true;
break 'scan;
}
}
}
}
if !leaves {
self.active = false;
return;
}
if self.cells.len() != P * P * P {
self.cells.resize(P * P * P, Cell::Open);
}
for y in -B..16 + B {
for z in -B..16 + B {
for x in -B..16 + B {
let i = pi(x, y, z);
self.cells[i] = if !known(region, base_y, x, y, z) {
Cell::Unknown
} else {
match info.get(pad.blocks[i] as usize) {
None => Cell::Unknown,
Some(state) if state.leaves => Cell::Leaf,
Some(state) if state.opaque_cube => Cell::Opaque,
Some(_) => Cell::Open,
}
};
}
}
}
// Solid is a purely local six-neighbour test. Reserve the breadth-first
// search for Solid Aggressive, whose one-open-side rule needs to know
// whether a leaf is connected to the canopy surface through other
// leaves.
if mode == LeafCullingMode::SolidAggressive {
self.depths();
}
}
/// Multi-source BFS from leaves that touch an opening. Opaque neighbours do
/// not expose a leaf: a leaf fully enclosed by stone is interior too.
fn depths(&mut self) {
if self.depth.len() != self.cells.len() {
self.depth.resize(self.cells.len(), UNVISITED);
} else {
self.depth.fill(UNVISITED);
}
self.queue.clear();
for y in -B..16 + B {
for z in -B..16 + B {
for x in -B..16 + B {
let i = pi(x, y, z);
if self.cells[i] != Cell::Leaf {
continue;
}
let touches_open = ADJACENT_STRIDES.iter().any(|&stride| {
let j = (i as isize + stride) as usize;
matches!(self.cells[j], Cell::Open | Cell::Unknown)
});
if touches_open {
self.depth[i] = 0;
self.queue.push(i as u16);
}
}
}
}
let mut head = 0;
while head < self.queue.len() {
let i = self.queue[head] as usize;
head += 1;
let next_depth = self.depth[i].saturating_add(1);
for stride in ADJACENT_STRIDES {
let j = (i as isize + stride) as usize;
if self.cells[j] == Cell::Leaf && self.depth[j] == UNVISITED {
self.depth[j] = next_depth;
self.queue.push(j as u16);
}
}
}
}
/// Whether the leaf at `(x, y, z)` may draw in the opaque layer.
///
/// `Solid` wants a leaf with no open neighbour, which is exactly depth 1 or
/// more. `Solid Aggressive` also takes the next layer of the shell: a leaf
/// with a single open side joins the opaque core, so the cutout shell is
/// one layer thinner. Unknown neighbours never qualify, however many sides
/// they leave open. A leaf the search never reached sits inside a closed
/// volume: interior for both.
pub(super) fn interior(&self, mode: LeafCullingMode, x: i32, y: i32, z: i32) -> bool {
if !self.active {
return false;
}
let aggressive = match mode {
LeafCullingMode::Solid => false,
LeafCullingMode::SolidAggressive => true,
_ => return false,
};
// A section boundary is not proof of a closed volume: the neighbouring
// section may not be drawn yet, and its data is not ours to assume.
if !(1..15).contains(&x) || !(1..15).contains(&y) || !(1..15).contains(&z) {
return false;
}
let i = pi(x, y, z);
if self.cells.get(i) != Some(&Cell::Leaf) {
return false;
}
if !aggressive {
return ADJACENT_STRIDES.iter().all(|&stride| {
let j = (i as isize + stride) as usize;
matches!(self.cells[j], Cell::Leaf | Cell::Opaque)
});
}
let depth = self.depth[i];
if depth == UNVISITED || depth >= 1 {
return true;
}
let mut open = 0;
for stride in ADJACENT_STRIDES {
let j = (i as isize + stride) as usize;
match self.cells[j] {
Cell::Unknown => return false,
Cell::Open => {
open += 1;
if open > 1 {
return false;
}
}
Cell::Leaf | Cell::Opaque => {}
}
}
open == 1
}
}
/// The cells this section's mesh owns. A face on this boundary belongs to the
/// neighbouring section as much as to this one; leaf culling leaves it alone.
#[inline]
pub(super) fn in_section(x: i32, y: i32, z: i32) -> bool {
(0..16).contains(&x) && (0..16).contains(&y) && (0..16).contains(&z)
}
/// Whether the padded cell `(x, y, z)` has real block data. A missing chunk
/// does not count: `Padded::fill` copies the centre's edge column into it, which
/// would make a canopy look enclosed. A Y outside the column counts as unknown
/// for the same reason. The mask's outer shell is unknown too, so nothing is
/// inferred past the data the worker snapshotted.
fn known(region: &[[Option<&Chunk>; 3]; 3], base_y: i32, x: i32, y: i32, z: i32) -> bool {
if x <= -B || x >= 16 + B - 1 || y <= -B || y >= 16 + B - 1 || z <= -B || z >= 16 + B - 1 {
return false;
}
let chunk = region[((z + 16) >> 4) as usize][((x + 16) >> 4) as usize];
chunk.is_some_and(|chunk| chunk.section_index(base_y + y).is_some())
}
#[cfg(test)]
impl LeafMask {
/// Whether `Hollow` drops the face of the leaf at `(x, y, z)` toward `d`.
/// Only a known leaf on the other side counts: open cells, partial models,
/// missing neighbours and the mask's edge stay visible.
pub(super) fn hides_face(
&self,
mode: LeafCullingMode,
x: i32,
y: i32,
z: i32,
d: horizon_core::geometry::Direction,
) -> bool {
if !self.active || mode != LeafCullingMode::Hollow {
return false;
}
let [dx, dy, dz] = d.normal();
let (nx, ny, nz) = (x + dx, y + dy, z + dz);
if !in_section(x, y, z) || !in_section(nx, ny, nz) {
return false;
}
let i = pi(x, y, z);
let ni = pi(nx, ny, nz);
self.cells.get(i) == Some(&Cell::Leaf) && self.cells.get(ni) == Some(&Cell::Leaf)
}
#[inline]
fn cell(&self, x: i32, y: i32, z: i32) -> Cell {
checked(x, y, z)
.and_then(|i| self.cells.get(i))
.copied()
.unwrap_or(Cell::Unknown)
}
}
#[cfg(test)]
#[inline]
fn checked(x: i32, y: i32, z: i32) -> Option<usize> {
if (-B..16 + B).contains(&x) && (-B..16 + B).contains(&y) && (-B..16 + B).contains(&z) {
Some(pi(x, y, z))
} else {
None
}
}
#[cfg(test)]
#[inline]
fn unpi(i: usize) -> (i32, i32, i32) {
let x = (i % P) as i32 - B;
let z = (i / P % P) as i32 - B;
let y = (i / (P * P)) as i32 - B;
(x, y, z)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{blocks::RenderShape, graphics::LeafCullingMode};
use horizon_core::geometry::Direction;
use horizon_world::fluid::FluidState;
use std::time::Instant;
fn info(leaves: bool, opaque: bool) -> Info {
Info {
solid: opaque,
full: opaque,
permeable: false,
shade: 1.0,
emission: 0,
tier: 0,
emissive: false,
fluid: FluidState::EMPTY,
model: true,
block_entity: false,
tints: [super::super::Tint::None; 2],
tint_count: 0,
offset: None,
legacy_solid: false,
overlay: leaves,
leaves,
opaque_cube: opaque,
blocks_flow: false,
ice: false,
sturdy: 0,
face: [0; 6],
cover: [0.0; 6],
}
}
/// State 0 is air, 1 leaves, 2 a full opaque cube, 3 a partial model.
fn states() -> Vec<Info> {
vec![
info(false, false),
info(true, false),
info(false, true),
info(false, false),
]
}
fn padded(fill: impl Fn(i32, i32, i32) -> u32) -> Padded {
Padded {
blocks: (0..P * P * P)
.map(|i| {
let (x, y, z) = unpi(i);
fill(x, y, z)
})
.collect(),
light: vec![0; P * P * P],
}
}
fn cube(pad: &mut Padded, min: i32, max: i32, state: u32) {
for y in min..=max {
for z in min..=max {
for x in min..=max {
pad.blocks[pi(x, y, z)] = state;
}
}
}
}
fn loaded() -> Chunk {
Chunk::empty(0, 0)
}
fn region(chunk: &Chunk) -> [[Option<&Chunk>; 3]; 3] {
[[Some(chunk); 3]; 3]
}
fn built(mode: LeafCullingMode, pad: &Padded, states: &[Info], chunk: &Chunk) -> LeafMask {
let mut mask = LeafMask::default();
mask.prepare(mode, pad, states, &region(chunk), 0);
mask
}
#[test]
fn only_a_full_model_cube_is_opaque() {
let combinations = [
(RenderShape::Model, true, true, true),
(RenderShape::Model, true, true, false),
(RenderShape::Model, true, false, true),
(RenderShape::Model, false, true, true),
(RenderShape::Model, false, false, false),
(RenderShape::Invisible, true, true, true),
(RenderShape::Animated, true, true, true),
];
let (shape, can_occlude, solid_render, full_collision) = combinations[0];
assert!(opaque_cube(
shape,
can_occlude,
solid_render,
full_collision
));
for &(shape, can_occlude, solid_render, full_collision) in &combinations[1..] {
assert!(
!opaque_cube(shape, can_occlude, solid_render, full_collision),
"{shape:?} {can_occlude} {solid_render} {full_collision}"
);
}
}
#[test]
fn solid_marks_the_cell_inside_a_three_cube() {
let states = states();
let mut pad = padded(|_, _, _| 0);
cube(&mut pad, 7, 9, 1);
let chunk = loaded();
let solid = built(LeafCullingMode::Solid, &pad, &states, &chunk);
assert!(!solid.interior(LeafCullingMode::Solid, 7, 8, 8));
assert!(solid.interior(LeafCullingMode::Solid, 8, 8, 8));
assert!(!solid.interior(LeafCullingMode::Solid, 8, 9, 8));
// Aggressive also takes the six face-centre leaves, whose only open
// side faces out of the cube. Corners and edges keep their cutout.
let aggressive = built(LeafCullingMode::SolidAggressive, &pad, &states, &chunk);
assert!(aggressive.interior(LeafCullingMode::SolidAggressive, 8, 8, 8));
assert!(aggressive.interior(LeafCullingMode::SolidAggressive, 7, 8, 8));
assert!(aggressive.interior(LeafCullingMode::SolidAggressive, 8, 8, 9));
assert!(!aggressive.interior(LeafCullingMode::SolidAggressive, 7, 7, 8));
assert!(!aggressive.interior(LeafCullingMode::SolidAggressive, 7, 7, 7));
}
#[test]
fn aggressive_takes_the_next_shell_layer() {
let states = states();
let mut pad = padded(|_, _, _| 0);
cube(&mut pad, 6, 10, 1);
let chunk = loaded();
let solid = built(LeafCullingMode::Solid, &pad, &states, &chunk);
let aggressive = built(LeafCullingMode::SolidAggressive, &pad, &states, &chunk);
let surface = |v: i32| v == 6 || v == 10;
for [x, y, z] in [
[6, 8, 8],
[10, 8, 8],
[6, 6, 8],
[6, 6, 6],
[7, 8, 8],
[8, 8, 8],
[7, 7, 7],
[8, 8, 7],
] {
let expected = !surface(x) && !surface(y) && !surface(z);
assert_eq!(
solid.interior(LeafCullingMode::Solid, x, y, z),
expected,
"solid {x} {y} {z}"
);
let open = [x, y, z].iter().filter(|v| surface(**v)).count();
let expected = open <= 1;
assert_eq!(
aggressive.interior(LeafCullingMode::SolidAggressive, x, y, z),
expected,
"aggressive {x} {y} {z}"
);
}
}
#[test]
fn hollow_hides_only_leaf_to_leaf_faces() {
let states = states();
let mut pad = padded(|_, _, _| 0);
cube(&mut pad, 7, 9, 1);
// Glass (3, a partial model), water and a slab are open: their leaves
// must keep the face.
pad.blocks[pi(10, 8, 8)] = 3;
let chunk = loaded();
let hollow = built(LeafCullingMode::Hollow, &pad, &states, &chunk);
for d in Direction::ALL {
assert!(
hollow.hides_face(LeafCullingMode::Hollow, 8, 8, 8, d),
"{d:?}"
);
}
assert!(!hollow.hides_face(LeafCullingMode::Hollow, 9, 8, 8, Direction::East));
assert!(!hollow.hides_face(LeafCullingMode::Hollow, 7, 8, 8, Direction::West));
// The other modes never hide a quad.
for mode in [
LeafCullingMode::Off,
LeafCullingMode::Solid,
LeafCullingMode::SolidAggressive,
] {
let mask = built(mode, &pad, &states, &chunk);
assert!(!mask.hides_face(mode, 8, 8, 8, Direction::Up));
}
}
#[test]
fn a_cavity_keeps_its_faces_and_its_rim_visible() {
let states = states();
let mut pad = padded(|_, _, _| 0);
cube(&mut pad, 6, 10, 1);
pad.blocks[pi(8, 8, 8)] = 0;
let chunk = loaded();
let hollow = built(LeafCullingMode::Hollow, &pad, &states, &chunk);
assert!(!hollow.hides_face(LeafCullingMode::Hollow, 7, 8, 8, Direction::East));
assert!(!hollow.hides_face(LeafCullingMode::Hollow, 8, 8, 7, Direction::South));
let solid = built(LeafCullingMode::Solid, &pad, &states, &chunk);
assert!(!solid.interior(LeafCullingMode::Solid, 7, 8, 8));
assert!(!solid.interior(LeafCullingMode::Solid, 6, 8, 8));
// The corner away from the cavity is still two layers in.
assert!(solid.interior(LeafCullingMode::Solid, 9, 9, 9));
}
#[test]
fn an_opaque_neighbour_does_not_expose_a_leaf() {
let states = states();
let mut pad = padded(|_, _, _| 0);
for (dx, dy, dz) in [
(1, 0, 0),
(-1, 0, 0),
(0, 1, 0),
(0, -1, 0),
(0, 0, 1),
(0, 0, -1),
] {
pad.blocks[pi(8 + dx, 8 + dy, 8 + dz)] = 2;
}
pad.blocks[pi(8, 8, 8)] = 1;
let chunk = loaded();
for mode in [LeafCullingMode::Solid, LeafCullingMode::SolidAggressive] {
let mask = built(mode, &pad, &states, &chunk);
assert!(mask.interior(mode, 8, 8, 8), "{mode:?}");
}
let hollow = built(LeafCullingMode::Hollow, &pad, &states, &chunk);
for d in Direction::ALL {
assert!(
!hollow.hides_face(LeafCullingMode::Hollow, 8, 8, 8, d),
"{d:?}"
);
}
}
#[test]
fn a_leaf_inside_a_closed_volume_is_interior() {
let states = states();
// A leaf cube inside opaque rock: the search has no opening to start
// from, so every leaf stays unvisited and counts as interior.
let mut pad = padded(|_, _, _| 2);
cube(&mut pad, 7, 9, 1);
let chunk = loaded();
for mode in [LeafCullingMode::Solid, LeafCullingMode::SolidAggressive] {
let mask = built(mode, &pad, &states, &chunk);
if mode == LeafCullingMode::SolidAggressive {
assert_eq!(mask.depth[pi(8, 8, 8)], UNVISITED);
}
assert!(mask.interior(mode, 8, 8, 8), "{mode:?}");
}
}
#[test]
fn missing_chunks_and_sections_are_unknown() {
let states = states();
let pad = padded(|_, _, _| 1);
let chunk = loaded();
let mut around = [[Some(&chunk); 3]; 3];
around[1][2] = None; // east chunk
let mut mask = LeafMask::default();
mask.prepare(LeafCullingMode::Hollow, &pad, &states, &around, 0);
// The leaf at the edge keeps its face toward the missing chunk, even
// though `Padded::fill` copied the centre's edge leaf there.
assert!(!mask.hides_face(LeafCullingMode::Hollow, 15, 8, 8, Direction::East));
// A leaf one block inside is still between two known leaves.
assert!(mask.hides_face(LeafCullingMode::Hollow, 14, 8, 8, Direction::East));
// A section above the column is unknown too: the same leaf one
// section up has no data, so it can never be interior.
let short = Chunk::with_layout(0, 0, crate::chunk::ColumnLayout::new(0, 16).unwrap());
let mut mask = LeafMask::default();
mask.prepare(LeafCullingMode::Solid, &pad, &states, &region(&short), 16);
assert!(!mask.interior(LeafCullingMode::Solid, 8, 8, 8));
// The outer shell has no data beyond it.
let mut mask = LeafMask::default();
mask.prepare(LeafCullingMode::Hollow, &pad, &states, &region(&chunk), 0);
assert_eq!(mask.cell(-B, 0, 0), Cell::Unknown);
assert_eq!(mask.cell(16 + B - 1, 0, 0), Cell::Unknown);
assert_eq!(mask.cell(0, 0, 0), Cell::Leaf);
// A section boundary is never enclosed, even with every chunk loaded:
// the neighbouring section may not have been meshed yet.
let mut mask = LeafMask::default();
mask.prepare(LeafCullingMode::Solid, &pad, &states, &region(&chunk), 0);
assert!(!mask.interior(LeafCullingMode::Solid, 15, 8, 8));
assert!(mask.interior(LeafCullingMode::Solid, 14, 8, 8));
}
#[test]
fn off_does_no_work_and_hides_nothing() {
let states = states();
let mut pad = padded(|_, _, _| 0);
cube(&mut pad, 7, 9, 1);
let chunk = loaded();
let mut mask = LeafMask::default();
mask.prepare(LeafCullingMode::Off, &pad, &states, &region(&chunk), 0);
assert!(!mask.active);
assert!(mask.cells.is_empty());
assert!(!mask.interior(LeafCullingMode::Off, 8, 8, 8));
assert!(!mask.hides_face(LeafCullingMode::Off, 8, 8, 8, Direction::Up));
}
#[test]
fn a_reused_mask_reclassifies_every_cell() {
let states = states();
let chunk = loaded();
let mut mask = LeafMask::default();
let mut leaves = padded(|_, _, _| 0);
cube(&mut leaves, 7, 9, 1);
mask.prepare(
LeafCullingMode::Hollow,
&leaves,
&states,
&region(&chunk),
0,
);
assert_eq!(mask.cell(8, 8, 8), Cell::Leaf);
let mut other = padded(|_, _, _| 0);
cube(&mut other, 10, 12, 1);
mask.prepare(LeafCullingMode::Hollow, &other, &states, &region(&chunk), 0);
assert_eq!(mask.cell(8, 8, 8), Cell::Open);
assert_eq!(mask.cell(11, 11, 11), Cell::Leaf);
}
#[test]
#[ignore = "microbenchmark; run with --ignored --nocapture"]
fn mask_build_benchmark() {
let states = states();
let mut pad = padded(|x, y, z| {
// Alternating leaf slabs make the search visit most of the volume.
if (x / 2 + y / 2 + z / 2) % 3 == 0 {
1
} else {
0
}
});
cube(&mut pad, 6, 10, 1);
let chunk = loaded();
let region = region(&chunk);
let iterations = 1000;
for mode in [LeafCullingMode::Solid, LeafCullingMode::SolidAggressive] {
let mut mask = LeafMask::default();
let started = Instant::now();
for _ in 0..iterations {
mask.prepare(mode, &pad, &states, &region, 0);
}
let per = started.elapsed() / iterations;
println!("leaf mask {mode:?}: {per:?} per section ({iterations} iterations)");
assert!(mask.active);
if mode == LeafCullingMode::SolidAggressive {
assert_ne!(mask.depth[pi(8, 8, 8)], UNVISITED);
}
}
}
}
@@ -0,0 +1,294 @@
//! Leaf culling through the real mesher: face counts, the opaque layer and the
//! conservative cases (cavities, partial neighbours, missing chunks, column
//! ends). Needs prepared resources, like the other mesher geometry tests.
use super::*;
use crate::{
assets::{Assets, RenderAssets},
biome::Biomes,
blocks::BlockFacts,
chunk::{Chunk, ColumnLayout},
graphics::LeafCullingMode,
models,
};
struct Fixture {
assets: Assets,
biomes: Biomes,
render: RenderAssets,
visibility: horizon_world::block_render::FaceVisibility,
tables: MeshTables,
}
fn fixture() -> Fixture {
let root = std::env::var_os("HORIZON_MESH_TEST_RESOURCES")
.map(std::path::PathBuf::from)
.unwrap_or_else(|| {
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client/26.3")
});
let assets = Assets::load(&root).unwrap();
let facts = BlockFacts::load(&root, assets.registry).unwrap();
let biomes = Biomes::load(&root).unwrap();
let render = assets.reload(&root).unwrap();
let visibility = models::load_visibility(&root, assets.registry).unwrap();
let tables = MeshTables::new(&assets, &render, &facts, visibility.clone()).unwrap();
Fixture {
assets,
biomes,
render,
visibility,
tables,
}
}
fn state(f: &Fixture, name: &str) -> u32 {
f.assets.registry.default_state(name).unwrap().0
}
fn leaves_cube(chunk: &mut Chunk, min: i32, max: i32, y: i32, state: u32) {
for z in min..=max {
for x in min..=max {
chunk.set(x, y, z, state);
}
}
}
fn leaf_cube(f: &Fixture, min: i32, max: i32, base: i32, name: &str) -> Chunk {
let mut chunk = Chunk::empty(0, 0);
let state = state(f, name);
for y in base..base + (max - min + 1) {
leaves_cube(&mut chunk, min, max, y, state);
}
chunk
}
fn mesh(
f: &Fixture,
_chunk: &Chunk,
region: [[Option<&Chunk>; 3]; 3],
sy: i32,
mode: LeafCullingMode,
) -> SectionMesh {
let mut mesher = Mesher {
nether_shading: false,
shader_pack_lighting: false,
assets: &f.assets,
render: &f.render,
tables: &f.tables,
biomes: &f.biomes,
visibility: f.visibility.clone(),
zoom: BiomeZoom::new(0),
blend_radius: 0,
smooth_lighting: false,
cutout_leaves: true,
translucent_layers: &[],
leaf_culling: mode,
};
mesher.mesh(&region, sy)
}
fn around(chunk: &Chunk) -> [[Option<&Chunk>; 3]; 3] {
[[Some(chunk); 3]; 3]
}
/// (opaque, cutout) leaf quads, rejecting a quad whose four vertices disagree.
fn quads(mesh: &SectionMesh) -> (usize, usize) {
let mut opaque = 0;
let mut cutout = 0;
for quad in mesh.solid.as_chunks::<4>().0 {
let flags = quad
.iter()
.map(|v| v.layer & OPAQUE_LAYER != 0)
.collect::<Vec<_>>();
assert!(
flags.iter().all(|flag| *flag == flags[0]),
"a quad mixed opaque and cutout vertices: {flags:?}"
);
if flags[0] {
opaque += 1;
} else {
cutout += 1;
}
}
assert_eq!(mesh.solid.len() % 4, 0);
(opaque, cutout)
}
const BASE: i32 = MIN_Y + 4;
#[test]
#[ignore = "requires prepared 26.3 resources (HORIZON_MESH_TEST_RESOURCES or .local/horizon-client/26.3)"]
fn off_draws_every_fancy_leaf_face() {
let f = fixture();
let chunk = leaf_cube(&f, 6, 8, BASE, "minecraft:oak_leaves");
let mesh = mesh(&f, &chunk, around(&chunk), 0, LeafCullingMode::Off);
// 27 blocks, six quads each: vanilla's fancy leaves keep the inside.
assert_eq!(quads(&mesh), (0, 162));
assert_eq!(mesh.translucent.len(), 0);
}
#[test]
#[ignore = "requires prepared 26.3 resources (HORIZON_MESH_TEST_RESOURCES or .local/horizon-client/26.3)"]
fn solid_draws_the_fully_enclosed_leaf_opaque() {
let f = fixture();
let chunk = leaf_cube(&f, 6, 8, BASE, "minecraft:oak_leaves");
let solid = mesh(&f, &chunk, around(&chunk), 0, LeafCullingMode::Solid);
// Only the centre has all six sides covered: its six quads go opaque.
assert_eq!(quads(&solid), (6, 156));
let aggressive = mesh(
&f,
&chunk,
around(&chunk),
0,
LeafCullingMode::SolidAggressive,
);
// Aggressive also takes the six face-centre leaves: seven blocks opaque.
assert_eq!(quads(&aggressive), (42, 120));
}
#[test]
#[ignore = "requires prepared 26.3 resources (HORIZON_MESH_TEST_RESOURCES or .local/horizon-client/26.3)"]
fn aggressive_solidifies_the_next_shell_layer_of_a_five_cube() {
let f = fixture();
let chunk = leaf_cube(&f, 5, 9, BASE, "minecraft:oak_leaves");
let solid = mesh(&f, &chunk, around(&chunk), 0, LeafCullingMode::Solid);
// The 3x3x3 inside the surface: 27 blocks, six quads each.
assert_eq!(quads(&solid), (162, 588));
let aggressive = mesh(
&f,
&chunk,
around(&chunk),
0,
LeafCullingMode::SolidAggressive,
);
// The 3x3x3 inside plus the 3x3 face centre of each of the six sides.
assert_eq!(quads(&aggressive), (486, 264));
}
#[test]
#[ignore = "requires prepared 26.3 resources (HORIZON_MESH_TEST_RESOURCES or .local/horizon-client/26.3)"]
fn hollow_drops_only_the_faces_two_leaves_share() {
let f = fixture();
let chunk = leaf_cube(&f, 6, 8, BASE, "minecraft:oak_leaves");
let hollow = mesh(&f, &chunk, around(&chunk), 0, LeafCullingMode::Hollow);
// 162 quads minus the 54 shared pairs' 108 faces: the 3x3x3 shell.
assert_eq!(quads(&hollow), (0, 54));
}
#[test]
#[ignore = "requires prepared 26.3 resources (HORIZON_MESH_TEST_RESOURCES or .local/horizon-client/26.3)"]
fn mixed_species_count_as_one_leaf_volume() {
let f = fixture();
let oak = state(&f, "minecraft:oak_leaves");
let birch = state(&f, "minecraft:birch_leaves");
let mut chunk = Chunk::empty(0, 0);
for y in BASE..BASE + 3 {
for z in 6..=8 {
for x in 6..=8 {
chunk.set(x, y, z, if (x + y + z) % 2 == 0 { oak } else { birch });
}
}
}
let solid = mesh(&f, &chunk, around(&chunk), 0, LeafCullingMode::Solid);
assert_eq!(quads(&solid), (6, 156));
let hollow = mesh(&f, &chunk, around(&chunk), 0, LeafCullingMode::Hollow);
assert_eq!(quads(&hollow), (0, 54));
}
#[test]
#[ignore = "requires prepared 26.3 resources (HORIZON_MESH_TEST_RESOURCES or .local/horizon-client/26.3)"]
fn a_cavity_keeps_its_six_faces_and_its_rim_stays_cutout() {
let f = fixture();
let mut chunk = leaf_cube(&f, 5, 9, BASE, "minecraft:oak_leaves");
chunk.set(7, BASE + 2, 7, 0);
let hollow = mesh(&f, &chunk, around(&chunk), 0, LeafCullingMode::Hollow);
// Five-cube shell (150) plus the six faces around the one-block cavity.
assert_eq!(quads(&hollow), (0, 156));
let solid = mesh(&f, &chunk, around(&chunk), 0, LeafCullingMode::Solid);
// The six leaves around the cavity are exposed, so they stay cutout; the
// other 20 inner leaves draw solid. The cavity block itself has no quads.
assert_eq!(quads(&solid), (120, 624));
}
#[test]
#[ignore = "requires prepared 26.3 resources (HORIZON_MESH_TEST_RESOURCES or .local/horizon-client/26.3)"]
fn section_boundary_faces_stay_with_a_loaded_neighbour() {
let f = fixture();
let leaf = state(&f, "minecraft:oak_leaves");
let chunk = leaf_cube(&f, 12, 15, BASE, "minecraft:oak_leaves");
// A loaded east chunk whose x=0 column continues the canopy.
let mut east = Chunk::empty(1, 0);
for y in BASE..BASE + 4 {
for z in 12..=15 {
east.set(0, y, z, leaf);
}
}
let mut region = around(&chunk);
region[1][2] = Some(&east);
let joined = mesh(&f, &chunk, region, 0, LeafCullingMode::Hollow);
let mut region = around(&chunk);
region[1][2] = None;
let missing = mesh(&f, &chunk, region, 0, LeafCullingMode::Hollow);
// The east faces of the x=15 leaves are on the section boundary: a loaded
// neighbour does not let Hollow drop them, and a missing one does not
// change the mesh either.
assert_eq!(joined.solid.len(), missing.solid.len());
let off = mesh(&f, &chunk, around(&chunk), 0, LeafCullingMode::Off);
assert!(joined.solid.len() < off.solid.len());
}
#[test]
#[ignore = "requires prepared 26.3 resources (HORIZON_MESH_TEST_RESOURCES or .local/horizon-client/26.3)"]
fn the_top_of_a_one_section_column_stays_visible() {
let f = fixture();
let leaf = state(&f, "minecraft:oak_leaves");
let layout = ColumnLayout::new(0, 16).unwrap();
let mut chunk = Chunk::with_layout(0, 0, layout);
for y in 13..16 {
leaves_cube(&mut chunk, 5, 7, y, leaf);
}
let hollow = mesh(&f, &chunk, around(&chunk), 0, LeafCullingMode::Hollow);
let solid = mesh(&f, &chunk, around(&chunk), 0, LeafCullingMode::Solid);
// y=15 is the column's top: the faces toward y=16 are unknown, so they
// stay, and the y=15 leaves are not enclosed.
assert!(!hollow.solid.is_empty());
assert!(solid.solid.iter().any(|v| v.pos[1] == 16.0));
assert!(
solid
.solid
.iter()
.filter(|v| v.pos[1] == 16.0)
.all(|v| v.layer & OPAQUE_LAYER == 0)
);
}
#[test]
#[ignore = "requires prepared 26.3 resources (HORIZON_MESH_TEST_RESOURCES or .local/horizon-client/26.3)"]
fn a_world_without_leaves_meshes_identically_in_every_mode() {
let f = fixture();
let mut chunk = Chunk::empty(0, 0);
for y in BASE..BASE + 3 {
leaves_cube(&mut chunk, 6, 8, y, state(&f, "minecraft:stone"));
}
let off = mesh(&f, &chunk, around(&chunk), 0, LeafCullingMode::Off);
for mode in LeafCullingMode::ALL {
let other = mesh(&f, &chunk, around(&chunk), 0, mode);
assert_eq!(off.solid.len(), other.solid.len(), "{mode:?}");
assert_eq!(off.translucent.len(), other.translucent.len(), "{mode:?}");
for (a, b) in off.solid.iter().zip(&other.solid) {
assert_eq!(
(a.pos, a.uv, a.layer, a.light, a.color),
(b.pos, b.uv, b.layer, b.light, b.color),
"{mode:?}"
);
}
for (a, b) in off.translucent.iter().zip(&other.translucent) {
assert_eq!(
(a.pos, a.uv, a.layer, a.light, a.color),
(b.pos, b.uv, b.layer, b.light, b.color),
"{mode:?}"
);
}
}
}
@@ -81,6 +81,8 @@ fn a_shader_pack_lights_the_faces_itself() {
blend_radius: 0,
smooth_lighting: false,
cutout_leaves: true,
translucent_layers: &[],
leaf_culling: LeafCullingMode::Off,
};
let mut colours: Vec<u32> = mesher
.mesh(&region, 0)
@@ -156,6 +158,8 @@ fn reused_mesh_matches_fresh_geometry_across_sections_and_resource_reload() {
blend_radius: 2,
smooth_lighting: reload == 0,
cutout_leaves: reload == 0,
translucent_layers: &[],
leaf_culling: LeafCullingMode::Off,
};
for (case, (center, east)) in [
(&empty, None),
@@ -88,6 +88,7 @@ fn info(solid: bool) -> Info {
legacy_solid: false,
overlay: false,
leaves: false,
opaque_cube: solid,
blocks_flow: false,
ice: false,
sturdy: 0,
@@ -219,10 +219,24 @@ impl StateModels {
models.block_library_or_missing(root, names)?
}
};
let resolved: Vec<_> = variants
.iter()
.map(|(name, ..)| library.resolve(name))
.collect();
// Every texture the models name, decoded side by side before the bake adds them one
// by one in its own order.
sprites.prefetch(
root,
resolved
.iter()
.flatten()
.flat_map(|model| model.textures.values())
.map(|material| material.sprite.as_str()),
);
let mut baked = Vec::with_capacity(variants.len());
let mut index = BTreeMap::new();
for (name, turns, lock) in variants {
let model = library.resolve(&name)?;
for ((name, turns, lock), model) in variants.into_iter().zip(resolved) {
let model = model?;
let particle = model
.material("particle")?
.map(|material| sprites.insert(root, &material.sprite))
+21 -3
View File
@@ -1801,7 +1801,7 @@ fn tooltip_text(s: &Settings, wd: &Widget) -> Option<Text> {
};
let mut tip = None;
if let Act::Extra(item) = act {
tip = Some(Text::literal(item.description()));
tip = Some(Text::literal(item.description_text()));
}
if let Act::Push(page) = act
&& let Some(text) = horizon_page_tooltip(*page)
@@ -1976,7 +1976,7 @@ fn pairs(opts: &[Opt]) -> Vec<Row> {
impl OptionsUi {
/// A switch of `horizon-extras.txt` as the Extras page draws it: "Caption: On".
fn extra_button(&self, s: &Settings, item: crate::extras::Item) -> Widget {
let label = format!("{}: {}", item.caption(), item.value(&s.extras));
let label = format!("{}: {}", item.caption_text(), item.value(&s.extras));
button(0.0, 0.0, 150.0, Text::literal(label), Act::Extra(item))
}
fn settings_search_active(&self) -> bool {
@@ -2274,7 +2274,7 @@ impl OptionsUi {
// Each section's switches two to a row, as vanilla's
// OptionsList.addSmall lays out an options screen.
let extra = |item: crate::extras::Item| {
let value = format!("{}: {}", item.caption(), item.value(&s.extras));
let value = format!("{}: {}", item.caption_text(), item.value(&s.extras));
let label = if item.key_name().is_some() && self.selected_key == item.key_name()
{
Text::literal(format!("> {value} <")).with_color(text::YELLOW)
@@ -4941,6 +4941,24 @@ impl OptionsUi {
mod tests {
use super::*;
/// The window allows the input method only while `typing` says a search
/// box has the focus (`Game::text_field_focused`), so a page without one
/// must not claim the keys: the movement and menu keys keep working there.
#[test]
fn only_a_focused_search_box_takes_text() {
let mut ui = OptionsUi::default();
assert!(!ui.typing(), "the main page has no text box");
ui.show(Page::Language);
assert!(ui.typing(), "the language list's search takes the keys");
ui.show(Page::Video);
assert!(!ui.typing(), "the settings search is not focused");
ui.settings_search.focused = true;
assert!(ui.typing(), "the settings search, once focused, does");
ui.settings_search.focused = false;
ui.show(Page::Packs);
assert!(!ui.typing(), "the pack screen's search is not open yet");
}
#[test]
fn accessibility_sliders_and_toggles_survive_options_round_trip() {
let mut s = Settings::default();
+102 -20
View File
@@ -695,18 +695,20 @@ fn build_overlay_uncached(
merged.insert(name.clone(), bytes);
}
}
/// A zip entry to write: its pack, its name in the zip and where it goes.
type Extract<'a> = (usize, &'a str, PathBuf);
#[allow(clippy::too_many_arguments)]
fn mirror(
fn mirror<'a>(
rel: &str,
vanilla: &Path,
out: &Path,
touched: &BTreeSet<String>,
hidden: &BTreeSet<String>,
files: &BTreeMap<String, (usize, Provided)>,
files: &'a BTreeMap<String, (usize, Provided)>,
merged: &BTreeMap<String, Vec<u8>>,
filters: &[&Filter],
zips: &[Option<Zip>],
dependencies: &mut cache::Dependencies,
extract: &mut Vec<Extract<'a>>,
) -> io::Result<()> {
let src = if rel.is_empty() {
vanilla.to_path_buf()
@@ -733,7 +735,13 @@ fn build_overlay_uncached(
} else {
format!("{rel}/")
};
for f in files.keys().filter_map(|f| f.strip_prefix(&prefix)) {
// The files under this directory are one run of the sorted map: walking only that run
// keeps a pack of tens of thousands of files in thousands of folders from being
// scanned once per folder.
for f in files
.range(prefix.clone()..)
.map_while(|(f, _)| f.strip_prefix(&prefix))
{
names.insert(f.split('/').next().unwrap_or("").to_owned());
}
for name in names {
@@ -747,19 +755,8 @@ fn build_overlay_uncached(
let target = out.join(&child);
match path {
Some(p) => link(p, &target)?,
None => {
if let Some(Some(z)) = zips.get(*owner) {
// 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}"
),
}
}
}
// Inflated and written after the walk, on every thread.
None => extract.push((*owner, inner.as_str(), target)),
}
} else if filters.is_empty()
&& !touched.contains(&child)
@@ -783,8 +780,8 @@ fn build_overlay_uncached(
files,
merged,
filters,
zips,
dependencies,
extract,
)?;
} else if filters.iter().any(|f| f.blocks(&child)) {
// Hidden by a pack's filter.
@@ -798,6 +795,7 @@ fn build_overlay_uncached(
}
Ok(())
}
let mut extract = Vec::new();
mirror(
"",
vanilla,
@@ -807,9 +805,27 @@ fn build_overlay_uncached(
&files,
&merged,
&filters,
&zips,
dependencies,
)
&mut extract,
)?;
// Inflating and writing a big pack's files one by one was most of a cold build; its
// folders all exist by now.
crate::par::par_map(&extract, |(owner, inner, target)| {
let Some(Some(z)) = zips.get(*owner) else {
return Ok(());
};
// 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}");
Ok(())
}
}
})
.into_iter()
.collect()
}
/// Files vanilla loads from every pack in turn (`getResourceStack`) rather
/// than from the top pack only.
@@ -1489,6 +1505,72 @@ mod tests {
let _ = fs::remove_dir_all(base);
}
#[test]
fn every_file_of_a_big_stack_comes_from_its_top_pack() {
use std::io::Write as _;
let base = std::env::temp_dir().join(format!("horizon-pack-stack-{}", std::process::id()));
let _ = fs::remove_dir_all(&base);
let vanilla = base.join("vanilla");
// Folders whose names start alike (`item`, `item_extra`, `item2`) and files beside
// folders of almost their name: what a prefix lookup could mix up.
let names: Vec<String> = (0..400)
.map(|i| {
let folder = ["item", "item_extra", "item2", "item/sub", "block"][i % 5];
format!("assets/ns{}/textures/{folder}/f{i}.png", i % 3)
})
.chain(["assets/ns0/textures/item.png".to_owned()])
.collect();
let mut expected: BTreeMap<String, Vec<u8>> = BTreeMap::new();
for name in names.iter().step_by(4) {
let path = vanilla.join(name);
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(&path, format!("vanilla {name}")).unwrap();
expected.insert(name.clone(), format!("vanilla {name}").into_bytes());
}
let meta = br#"{"pack":{"description":"d","pack_format":97}}"#;
// Packs a and b are zips, c a folder; each provides a different part of the names.
fs::create_dir_all(base.join("packs/c")).unwrap();
fs::write(base.join("packs/c/pack.mcmeta"), meta).unwrap();
for (pack, every) in [("a", 2), ("b", 3)] {
let file = fs::File::create(base.join(format!("packs/{pack}.zip"))).unwrap();
let mut zip = zip::ZipWriter::new(file);
zip.start_file("pack.mcmeta", zip::write::SimpleFileOptions::default())
.unwrap();
zip.write_all(meta).unwrap();
for name in names.iter().step_by(every) {
zip.start_file(name, zip::write::SimpleFileOptions::default())
.unwrap();
zip.write_all(format!("{pack} {name}").as_bytes()).unwrap();
expected.insert(name.clone(), format!("{pack} {name}").into_bytes());
}
zip.finish().unwrap();
}
for name in names.iter().skip(1).step_by(5) {
let path = base.join("packs/c").join(name);
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(&path, format!("c {name}")).unwrap();
expected.insert(name.clone(), format!("c {name}").into_bytes());
}
let all = folder(&base.join("packs"), RESOURCE_FORMAT);
let ids = ["file/a.zip", "file/b.zip", "file/c"].map(String::from);
let out = base.join("overlay");
build_overlay(&vanilla, &enabled(&all, &ids), &out).unwrap();
fn walk(dir: &Path, root: &Path, out: &mut BTreeMap<String, Vec<u8>>) {
for entry in fs::read_dir(dir).unwrap().flatten() {
let path = entry.path();
if path.is_dir() {
walk(&path, root, out);
} else {
let name = path.strip_prefix(root).unwrap().to_string_lossy();
out.insert(name.replace('\\', "/"), fs::read(&path).unwrap());
}
}
}
let mut built = BTreeMap::new();
walk(&out.join("assets"), &out, &mut built);
assert_eq!(built, expected);
let _ = fs::remove_dir_all(base);
}
#[test]
fn zip_entries_never_leave_the_overlay() {
use std::io::Write as _;
let base = std::env::temp_dir().join(format!("horizon-pack-escape-{}", std::process::id()));
@@ -926,6 +926,8 @@ impl PostEffects {
let mut decoder = png::Decoder::new(std::io::BufReader::new(
std::fs::File::open(&file).map_err(|e| format!("{}: {e}", file.display()))?,
));
// As the other resource-pack images: stb_image never checks chunk CRCs.
decoder.ignore_checksums(true);
decoder.set_transformations(png::Transformations::EXPAND | png::Transformations::STRIP_16);
let mut reader = decoder.read_info().map_err(|e| e.to_string())?;
let mut data = vec![0; reader.output_buffer_size().ok_or("a huge image")?];
@@ -827,6 +827,14 @@ impl TextField {
);
}
}
if self.focused {
// `TextInputManager.setTextInputArea`, in GUI pixels: the caret
// the input method's composition and its candidate window go to.
// The chat and in-bed chat boxes and the pack screen's search are
// `TextField`s; the other fields are an `EditBox` or a multiline
// box, which report their own.
crate::preedit::report_caret(cursor_x as f32, ty as f32, 10.0);
}
}
}
@@ -68,6 +68,9 @@ pub enum Ticking {
/// `RidingEntitySoundInstance` for the player riding a happy ghast
/// (`underwater_sound` false) or a nautilus (true).
RidingEntity { entity: u32, underwater_sound: bool },
/// A native mod's music layer (`MusicLayers`), whose volume its owner
/// sets each tick.
MusicLayer,
}
/// What a minecart's sound instances read about it.
#[derive(Clone, Copy, Debug)]
@@ -233,6 +236,25 @@ impl SoundInstance {
true,
)
}
/// A native mod's music layer: a looping, unattenuated track on the music
/// source that starts silent (`MusicLayers` fades it in).
pub fn for_music_layer(id: &str, random: LegacyRandom) -> Self {
let mut s = Self::new(
id,
MUSIC,
0.0,
1.0,
random,
true,
0,
Attenuation::None,
[0.0; 3],
true,
);
s.can_start_silent = true;
s.ticking = Ticking::MusicLayer;
s
}
/// `SimpleSoundInstance.forJukeboxSong`.
pub fn for_jukebox_song(id: &str, at: [f64; 3], random: LegacyRandom) -> Self {
Self::new(
@@ -522,7 +544,7 @@ impl SoundInstance {
/// `TickableSoundInstance.tick`.
pub fn tick(&mut self, ctx: &TickContext) {
match &mut self.ticking {
Ticking::No => {}
Ticking::No | Ticking::MusicLayer => {}
Ticking::BiomeLoop { fade, direction } => {
let stop = *fade < 0;
*fade += *direction;
@@ -13,6 +13,11 @@ use crate::{
ui,
};
/// A string of this page: the language table's, or the English text.
fn tr(key: &str, english: &str) -> String {
lang::get_or(key, english)
}
/// Leave room for navigation, creation and cancellation in a 240-pixel menu.
const SHOWN: usize = 4;
pub mod store;
@@ -132,7 +137,12 @@ impl SplitProfiles {
};
if let Some(name) = &self.forgetting {
return vec![
button(120.0, "Forget".into(), self.editable(name), Target::Forget),
button(
120.0,
tr("horizon.splitscreen.profiles.forget", "Forget"),
self.editable(name),
Target::Forget,
),
button(
144.0,
lang::get_or("gui.cancel", "Cancel"),
@@ -147,7 +157,12 @@ impl SplitProfiles {
let ok = valid_name(name) && !self.names.iter().any(|n| n.eq_ignore_ascii_case(name));
let top = field_top(h);
return vec![
button(top + 25.0, "Create".into(), ok, Target::Create),
button(
top + 25.0,
tr("horizon.splitscreen.profiles.create", "Create"),
ok,
Target::Create,
),
button(
top + 50.0,
lang::get_or("gui.back", "Back"),
@@ -166,7 +181,7 @@ impl SplitProfiles {
{
let mut label = name.clone();
if self.current.as_ref() == Some(name) {
label.push_str(" - you");
label.push_str(&tr("horizon.splitscreen.profiles.you", " - you"));
}
let y = 44.0 + (i % SHOWN) as f32 * 24.0;
buttons.push((
@@ -177,7 +192,8 @@ impl SplitProfiles {
));
buttons.push((
[x + 180.0, y, 20.0, 20.0],
format!("Forget {name}"),
tr("horizon.splitscreen.profiles.forget_named", "Forget %s")
.replacen("%s", name, 1),
self.editable(name),
Target::Remove(i),
));
@@ -186,19 +202,24 @@ impl SplitProfiles {
if self.names.len() > SHOWN {
buttons.push((
[x, y, 98.0, 20.0],
"Previous".into(),
tr("horizon.splitscreen.profiles.previous", "Previous"),
self.page() > 0,
Target::Previous,
));
buttons.push((
[x + 102.0, y, 98.0, 20.0],
"Next".into(),
tr("horizon.splitscreen.profiles.next", "Next"),
(self.page() + 1) * SHOWN < self.names.len(),
Target::Next,
));
y += 24.0;
}
buttons.push(button(y, "New Profile...".into(), true, Target::New));
buttons.push(button(
y,
tr("horizon.splitscreen.profiles.new_button", "New Profile..."),
true,
Target::New,
));
let cancel = lang::get_or("gui.cancel", "Cancel");
buttons.push(button(y + 24.0, cancel, true, Target::Cancel));
buttons
@@ -220,30 +241,71 @@ impl SplitProfiles {
};
let buttons = self.buttons(w, h);
if let Some(name) = &self.forgetting {
STYLE.title(batch, atlas, &Text::literal("Forget profile?"));
STYLE.title(
batch,
atlas,
&Text::literal(tr(
"horizon.splitscreen.profiles.forget_title",
"Forget profile?",
)),
);
line(batch, name, 52.0, u32::MAX);
line(batch, "It leaves this instance's list.", 76.0, grey);
line(batch, "World progress and settings are kept.", 90.0, grey);
line(
batch,
&tr(
"horizon.splitscreen.profiles.forget_leaves",
"It leaves this instance's list.",
),
76.0,
grey,
);
line(
batch,
&tr(
"horizon.splitscreen.profiles.forget_kept",
"World progress and settings are kept.",
),
90.0,
grey,
);
if let Some(why) = &self.refused {
line(batch, why, 176.0, rgba(255, 85, 85, 255));
}
} else if let Some(field) = &mut self.new {
STYLE.title(batch, atlas, &Text::literal("New Profile"));
STYLE.title(
batch,
atlas,
&Text::literal(tr("horizon.splitscreen.profiles.new_title", "New Profile")),
);
let (x, top) = ((w / 2.0).floor() - 100.0, field_top(h));
let label = "Name (3 to 16 letters, digits, _)";
batch.text(atlas, label, x, top - 14.0, grey, true);
let label = tr(
"horizon.splitscreen.profiles.name_hint",
"Name (3 to 16 letters, digits, _)",
);
batch.text(atlas, &label, x, top - 14.0, grey, true);
(field.x, field.y, field.width, field.height) = (x, top, 200.0, 20.0);
field.draw(batch, atlas, None);
} else {
let title = match self.current {
Some(_) => "Switch profile",
None => "Who's playing?",
Some(_) => tr(
"horizon.splitscreen.profiles.switch_title",
"Switch profile",
),
None => tr("horizon.pages.split_profiles", "Who's playing?"),
};
STYLE.title(batch, atlas, &Text::literal(title));
self.draw_bumpers(batch, atlas, style);
// Under Cancel: where offline profiles live.
let y = buttons.last().map_or(0.0, |b| b.0[1] + 30.0);
line(batch, "Offline profiles belong to this instance", y, grey);
line(
batch,
&tr(
"horizon.splitscreen.profiles.offline_note",
"Offline profiles belong to this instance",
),
y,
grey,
);
// Why the last pick can't play, wrapped to the view (a quarter of a window is narrow).
let width = |text: &str| HudBatch::text_width(atlas, text).ceil();
let why = self.refused.as_deref().unwrap_or_default();
@@ -308,8 +370,12 @@ impl SplitProfiles {
fn draw_bumpers(&self, batch: &mut HudBatch, atlas: &HudAtlas, style: GlyphStyle) {
let width = |text: &str| HudBatch::text_width(atlas, text).ceil();
let words = match &self.place {
Some(place) => format!("Move where you play: {place}"),
None => "Move where you play".to_owned(),
Some(place) => tr(
"horizon.splitscreen.profiles.move_place",
"Move where you play: %s",
)
.replacen("%s", place, 1),
None => tr("horizon.splitscreen.profiles.move", "Move where you play"),
};
let keys = [PadButton::LeftBumper, PadButton::RightBumper];
let key_width = |b: PadButton| width(b.label(style)) + 4.0;
+161 -3
View File
@@ -99,6 +99,9 @@ pub struct Sprites {
still: std::collections::HashMap<(Vec<u8>, u64, u32, [u32; 2], [bool; 4]), u32>,
/// Decoded textures kept on disk between launches ([`Sprites::with_disk_cache`]).
disk: Option<crate::sprite_cache::SpriteCache>,
/// Textures [`Sprites::prefetch`] decoded ahead of their insert, by sprite name or
/// resource path.
ready: std::collections::HashMap<String, crate::sprite_cache::Decoded>,
}
/// Edge length of vanilla's block textures: the smallest array layer.
pub const SPRITE_SIZE: u32 = 16;
@@ -242,6 +245,8 @@ impl Sprites {
self.lay_out(false);
}
fn lay_out(&mut self, pixels: bool) {
// Prefetched textures nothing inserted.
self.ready = Default::default();
let layers = self.native.len().max(1) as u64;
let largest = self.native.iter().map(|n| n.0).max().unwrap_or(SPRITE_SIZE);
self.minimum_size = self.native.iter().map(|n| n.0).min().unwrap_or(SPRITE_SIZE);
@@ -335,12 +340,45 @@ impl Sprites {
}
}
}
/// Decode on every thread the textures about to be inserted: sprite names, or resource
/// paths outside `textures/` (as [`Sprites::insert_file`] takes them). A loader that adds
/// many (the block models, an OptiFine pack's tiles) calls this first and then inserts
/// them in its own order, so the layers are the same as without it. What is already a
/// layer or in the disk cache is skipped; what fails to decode is left for the insert,
/// which reports it.
pub fn prefetch<'a>(&mut self, root: &Path, names: impl IntoIterator<Item = &'a str>) {
let names: Vec<&str> = names
.into_iter()
.filter(|name| {
!self.layers.contains_key(*name)
&& !self.ready.contains_key(*name)
&& self.disk.as_ref().is_none_or(|d| d.get(name).is_none())
})
.collect::<std::collections::BTreeSet<_>>()
.into_iter()
.collect();
let decoded = crate::par::par_map(&names, |name| {
if name.contains(':') {
Self::decode(root, name).ok()
} else {
Self::decode_file(&root.join(name), name).ok()
}
});
for (name, decoded) in names.into_iter().zip(decoded) {
if let Some(decoded) = decoded {
self.ready.insert(name.to_owned(), decoded);
}
}
}
fn load(&mut self, root: &Path, name: &str) -> Result<u32> {
let cached = self.disk.as_ref().and_then(|d| d.get(name)).cloned();
let decoded = match cached {
Some(decoded) => decoded,
None => {
let decoded = Self::decode(root, name)?;
let decoded = match self.ready.remove(name) {
Some(decoded) => decoded,
None => Self::decode(root, name)?,
};
if let Some(disk) = &mut self.disk {
disk.insert(name, decoded.clone());
}
@@ -349,10 +387,29 @@ impl Sprites {
};
self.register(root, name, decoded)
}
/// The layer of a texture outside `textures/` (an OptiFine pack's connected-texture tile),
/// named by its resource path and added on first use. Unlike [`Sprites::insert`], a texture
/// that does not load is an error: the caller drops what needed it. Such files bypass the
/// disk cache, whose fingerprint covers only `textures/`.
pub fn insert_file(&mut self, root: &Path, resource: &str) -> Result<u32> {
if let Some(layer) = self.layers.get(resource) {
return Ok(*layer);
}
let decoded = match self.ready.remove(resource) {
Some(decoded) => decoded,
None => Self::decode_file(&root.join(resource), resource)?,
};
self.register(root, resource, decoded)
}
/// Read and decode one texture and its `.mcmeta`.
fn decode(root: &Path, name: &str) -> Result<crate::sprite_cache::Decoded> {
let (namespace, path) = name.split_once(':').ok_or("invalid sprite identifier")?;
let path = root.join(format!("assets/{namespace}/textures/{path}.png"));
Self::decode_file(
&root.join(format!("assets/{namespace}/textures/{path}.png")),
name,
)
}
fn decode_file(path: &Path, name: &str) -> Result<crate::sprite_cache::Decoded> {
let metadata = match fs::read_to_string(path.with_extension("png.mcmeta")) {
Ok(text) => Some(serde_json::from_str::<
BTreeMap<String, Box<serde_json::value::RawValue>>,
@@ -368,7 +425,7 @@ impl Sprites {
.as_deref()
.map(AnimationMetadata::from_json)
.transpose()?;
let (png_width, png_height, pixels) = read_rgba(&path)?;
let (png_width, png_height, pixels) = read_rgba(path)?;
let (width, height) = if let Some(animation) = &animation {
let [width, height] =
animation.frame_size(i32::try_from(png_width)?, i32::try_from(png_height)?);
@@ -512,6 +569,7 @@ impl Sprites {
/// A PNG's width, height and pixels as RGBA.
fn read_rgba(path: &Path) -> Result<(u32, u32, Vec<u8>)> {
let mut decoder = png::Decoder::new(BufReader::new(File::open(path)?));
decoder.ignore_checksums(true);
decoder.set_transformations(png::Transformations::EXPAND | png::Transformations::STRIP_16);
let mut reader = decoder.read_info()?;
let mut bytes = vec![
@@ -662,6 +720,106 @@ mod tests {
}
}
#[test]
fn files_outside_textures_load_by_path() {
let root = std::env::temp_dir().join(format!("horizon-sprite-file-{}", std::process::id()));
let tile = "assets/minecraft/optifine/ctm/glass/0.png";
fs::create_dir_all(root.join(tile).parent().unwrap()).unwrap();
let mut encoder = png::Encoder::new(File::create(root.join(tile)).unwrap(), 16, 16);
encoder.set_color(png::ColorType::Rgba);
encoder.set_depth(png::BitDepth::Eight);
let pixels: Vec<u8> = (0..256).flat_map(|i| [i as u8, 0, 0, 128]).collect();
encoder
.write_header()
.unwrap()
.write_image_data(&pixels)
.unwrap();
let mut sprites = Sprites::default();
let layer = sprites.insert_file(&root, tile).unwrap();
assert_eq!(sprites.insert_file(&root, tile).unwrap(), layer);
assert!(sprites.translucent[layer as usize]);
// A missing tile is an error, not the missing texture.
assert!(
sprites
.insert_file(&root, "assets/minecraft/optifine/ctm/glass/1.png")
.is_err()
);
assert_eq!(sprites.count(), 1);
fs::remove_dir_all(&root).unwrap();
}
#[test]
fn prefetched_textures_take_the_layers_inserting_alone_gives() {
let root =
std::env::temp_dir().join(format!("horizon-sprite-prefetch-{}", std::process::id()));
let png = |path: &str, side: u32, seed: u8| {
let file = root.join(path);
fs::create_dir_all(file.parent().unwrap()).unwrap();
let mut encoder = png::Encoder::new(File::create(file).unwrap(), side, side);
encoder.set_color(png::ColorType::Rgba);
encoder.set_depth(png::BitDepth::Eight);
let pixels: Vec<u8> = (0..side * side)
.flat_map(|i| {
[
seed,
i as u8,
(i >> 8) as u8,
if i % 3 == 0 { 0 } else { 255 },
]
})
.collect();
encoder
.write_header()
.unwrap()
.write_image_data(&pixels)
.unwrap();
};
for i in 0..40u8 {
png(
&format!("assets/minecraft/textures/block/b{i}.png"),
16 << (i % 3),
i,
);
}
// Two textures alike share a layer; a tile outside `textures/`, a broken file and a
// missing one take the paths they take without prefetching.
png("assets/minecraft/textures/block/same.png", 16, 3);
png("assets/minecraft/optifine/ctm/glass/0.png", 32, 77);
fs::write(
root.join("assets/minecraft/textures/block/broken.png"),
b"no png",
)
.unwrap();
let names: Vec<String> = (0..40)
.map(|i| format!("minecraft:block/b{}", (i * 7) % 40))
.chain(["minecraft:block/same", "minecraft:block/broken"].map(String::from))
.chain(["minecraft:block/missing", "minecraft:block/b3"].map(String::from))
.collect();
let tile = "assets/minecraft/optifine/ctm/glass/0.png";
let load = |prefetch: bool| {
let mut sprites = Sprites::default();
if prefetch {
sprites.prefetch(&root, names.iter().map(String::as_str).chain([tile]));
}
let mut layers: Vec<u32> = names
.iter()
.map(|name| sprites.insert(&root, name).unwrap())
.collect();
layers.push(sprites.insert_file(&root, tile).unwrap());
sprites.finish();
(
layers,
sprites.layers,
sprites.pixels,
sprites.translucent,
sprites.size,
)
};
let alone = load(false);
assert_eq!(load(true), alone);
// The textures alike and the broken one's missing texture: fewer layers than names.
assert!(alone.1.values().max().unwrap() + 1 < names.len() as u32);
fs::remove_dir_all(&root).unwrap();
}
#[test]
fn identical_still_textures_share_a_layer() {
let root =
std::env::temp_dir().join(format!("horizon-sprite-share-{}", std::process::id()));
+189 -2
View File
@@ -6,6 +6,7 @@ use crate::fast_hash::{FxHashMap, FxHashSet};
use crate::section_mask::SectionMask;
use crate::{
chunk::Chunk,
graphics::LeafCullingMode,
light::{LightChanges, LightTables, apply_lighting, apply_relighting, light_chunk},
mesher::{MeshScratch, MeshTables, Mesher, Region, SectionMesh, borrow},
world::World,
@@ -196,7 +197,7 @@ impl<G: ChunkGenerator> Saves for G {
}
}
/// Per-job mesher inputs that change with the dimension or the options.
#[derive(Clone, Copy)]
#[derive(Clone)]
struct MeshSettings {
nether: bool,
shader_pack_lighting: bool,
@@ -204,6 +205,8 @@ struct MeshSettings {
blend_radius: i32,
smooth_lighting: bool,
cutout_leaves: bool,
translucent_layers: Arc<[bool]>,
leaf_culling: LeafCullingMode,
}
enum Job {
Generate(i32, i32),
@@ -438,6 +441,11 @@ pub struct Streaming {
/// The `ao` and `cutoutLeaves` options.
pub smooth_lighting: bool,
pub cutout_leaves: bool,
/// Sprite layers a shader pack draws in the translucent pass (`[[materials]]
/// translucent`), by layer; empty without one.
pub translucent_layers: Arc<[bool]>,
/// What the mesher does with leaf faces inside a canopy (`LeafCullingMode`).
pub leaf_culling: LeafCullingMode,
/// Incremental relighting of block edits on lit chunks (see `light::relight`).
light_tables: Arc<LightTables>,
/// Applied edits by sequence, replayed onto light jobs snapshotted before them.
@@ -772,6 +780,8 @@ impl Streaming {
blend_radius: settings.blend_radius,
smooth_lighting: settings.smooth_lighting,
cutout_leaves: settings.cutout_leaves,
translucent_layers: &settings.translucent_layers,
leaf_culling: settings.leaf_culling,
assets: &context.assets,
render: &context.render,
tables: &context.tables,
@@ -853,6 +863,8 @@ impl Streaming {
biome_blend: 2,
smooth_lighting: true,
cutout_leaves: true,
translucent_layers: Arc::from([]),
leaf_culling: LeafCullingMode::Off,
light_tables,
light_log: Vec::new(),
light_edit_sequence: 0,
@@ -904,9 +916,56 @@ impl Streaming {
}
}
}
/// A resident column a server sent again whole (it changed it in bulk):
/// its new blocks taken, the sections that did not change kept as they
/// were, and its light and its neighbours' worked out again here, off
/// the game thread, as light is for the blocks a server changes one at a
/// time; the sections that changed remesh (with theirs) once lit. Until
/// then the sections that changed show the light the packet carried (a
/// Horizon server sends the light from before its relight, then the
/// new light; another server's is already right, which matters where
/// the neighbours this light needs aren't all here).
pub fn replace_remote(&mut self, world: &mut World, mut chunk: Chunk) {
let key = (chunk.x, chunk.z);
let Some(old) = world.chunks.get(&key).cloned() else {
return self.insert_remote(world, chunk);
};
if !self.lit.contains(&key) {
return self.insert_remote(world, chunk);
}
// The cells whose blocks changed, by section: they remesh with the neighbours within a block of them (as
// vanilla's `setBlocksDirty`), not every section round each section changed (which, for the few blocks a
// section often changes in a bulk edit, is mostly sections that did not).
let mut cells = crate::light::LightChanges::default();
cells.keyed(world.layout());
let kept = keep_unchanged_sections(&old, &mut chunk, &mut cells);
chunk.light_loaded = old.light_loaded;
chunk.lit = true;
world.chunks.insert(key, Arc::new(chunk));
world.revision = world.revision.wrapping_add(1);
// A section with new biomes and the same blocks remeshes now (its tint), with its neighbours, whose tint
// blends across the border: above and below too, as a block's tint can sample the biome quart of the
// section next to it (`BiomeManager.getBiome`'s zoom reaches a quart beyond the block).
Self::light_updated(world, key.0, key.1, &kept.biomes);
// A section with the same blocks and new light takes the packet's light (vanilla's
// `handleLevelChunkWithLight` always applies it) and remeshes as a light update would.
Self::light_updated(world, key.0, key.1, &kept.light);
if !kept.blocks {
return;
}
for dz in -1..=1 {
for dx in -1..=1 {
world.dirty_light.insert((key.0 + dx, key.1 + dz));
}
}
cells.dirty_sections(|section| {
world.dirty_sections.insert(section);
});
}
/// A server's light update for a resident column: the sections whose
/// light changed and their neighbours remesh
/// (`setSectionDirtyWithNeighbors`).
/// (`setSectionDirtyWithNeighbors`). A resent column's sections with new
/// biomes remesh the same way.
pub fn light_updated(world: &mut World, x: i32, z: i32, sections: &[usize]) {
for &sy in sections {
for dz in -1..=1 {
@@ -1712,6 +1771,8 @@ impl Streaming {
blend_radius: self.biome_blend,
smooth_lighting: self.smooth_lighting,
cutout_leaves: self.cutout_leaves,
translucent_layers: self.translucent_layers.clone(),
leaf_culling: self.leaf_culling,
},
),
);
@@ -2363,10 +2424,136 @@ fn nearest_first<T: Ord>(candidates: &mut Vec<T>, room: usize) {
}
candidates.sort_unstable();
}
/// What `keep_unchanged_sections` found changed.
#[derive(Debug, Default, PartialEq)]
struct Resent {
/// Any section's blocks changed (in a way light or the mesher sees).
blocks: bool,
/// The sections whose blocks did not change but whose biomes did.
biomes: Vec<usize>,
/// The sections whose blocks did not change but whose light the packet changes.
light: Vec<usize>,
}
/// `new`, a resident column `old` sent again, takes `old`'s sections whose
/// blocks it holds unchanged, with `new`'s biomes and light; the sections whose blocks
/// changed keep `old`'s light (until lit again) and their cells' classes go in
/// `cells`.
fn keep_unchanged_sections(
old: &Chunk,
new: &mut Chunk,
cells: &mut crate::light::LightChanges,
) -> Resent {
let (mut a, mut b) = (Box::new([0u32; 4096]), Box::new([0u32; 4096]));
let mut out = Resent::default();
let (x, z) = (new.x, new.z);
for s in 0..new.sections.len().min(old.sections.len()) {
// (sections stored alike hold the same blocks: most of a column sent again, found without unpacking)
let same = old.sections[s].blocks.same_storage(&new.sections[s].blocks) || {
old.sections[s].unpack(&mut a);
new.sections[s].unpack(&mut b);
let classes = crate::light::LightChanges::classes_differing(&a, &b);
if classes != 0 {
cells.add((x, s as i32, z), classes);
}
classes == 0
};
if !same {
out.blocks = true;
continue;
}
let biomes = new.sections[s].biomes.clone();
let changed = !same_values(&old.sections[s].biomes, &biomes);
let light = (!same_light(&old.sections[s].light, &new.sections[s].light))
.then(|| new.sections[s].light.clone());
new.sections[s] = old.sections[s].clone();
if changed {
Arc::make_mut(&mut new.sections[s]).biomes = biomes;
out.biomes.push(s);
}
if let Some(light) = light {
Arc::make_mut(&mut new.sections[s]).light = light;
out.light.push(s);
}
}
out
}
/// Whether two sections' packed light (4,096 cells) holds the same values.
fn same_light(a: &crate::chunk::Light, b: &crate::chunk::Light) -> bool {
use crate::chunk::Light;
match (a, b) {
(Light::Uniform(x), Light::Uniform(y)) => x == y,
(Light::Full(x), Light::Full(y)) => x == y,
(Light::Uniform(v), Light::Full(f)) | (Light::Full(f), Light::Uniform(v)) => {
f.iter().all(|c| c == v)
}
}
}
/// Whether two biome containers (64 cells) hold the same values.
fn same_values(
a: &horizon_core::paletted::PalettedContainer,
b: &horizon_core::paletted::PalettedContainer,
) -> bool {
if a.same_storage(b) {
return true;
}
let (mut x, mut y) = ([0u32; 64], [0u32; 64]);
a.unpack(&mut x);
b.unpack(&mut y);
x == y
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_resent_column_keeps_its_sections_but_takes_new_biomes() {
let mut old = Chunk::empty(2, -3);
old.set(1, 10, 1, 1);
old.set(1, 40, 1, 1);
old.set_biome_cell(0, 0, 0, 1);
let mut new = old.clone();
// a new biome under the same blocks (section 0), and a block changed (section 6)
new.set_biome_cell(0, 0, 0, 3);
new.set(1, 40, 1, 2);
let mut cells = crate::light::LightChanges::default();
cells.keyed(crate::chunk::ColumnLayout::VANILLA);
let kept = keep_unchanged_sections(&old, &mut new, &mut cells);
assert_eq!(
kept,
Resent {
blocks: true,
biomes: vec![0],
light: vec![],
}
);
assert_eq!(new.biome(0, -64, 0), 3);
assert_eq!(new.block(1, -54, 1).0, 0);
assert_eq!(new.block(1, 40, 1).0, 2);
// the sections alike are shared with the old column
assert!(Arc::ptr_eq(&new.sections[1], &old.sections[1]));
assert!(!Arc::ptr_eq(&new.sections[0], &old.sections[0]));
// nothing changed: nothing to do
let mut same = old.clone();
let kept = keep_unchanged_sections(&old, &mut same, &mut cells);
assert_eq!(kept, Resent::default());
assert!(Arc::ptr_eq(&same.sections[0], &old.sections[0]));
// the same blocks under new light: the packet's light, as vanilla applies it
let mut lit = old.clone();
Arc::make_mut(&mut lit.sections[2]).light = crate::chunk::Light::Uniform(0xF0);
Arc::make_mut(&mut old.sections[2]).light = crate::chunk::Light::Uniform(0x50);
let kept = keep_unchanged_sections(&old, &mut lit, &mut cells);
assert_eq!(
kept,
Resent {
light: vec![2],
..Resent::default()
}
);
assert_eq!(lit.sections[2].light.get(0), 0xF0);
assert!(Arc::ptr_eq(&lit.sections[1], &old.sections[1]));
}
#[test]
fn mesh_pool_reuses_capacity_with_clean_geometry_state() {
let mut mesh = SectionMesh::default();
@@ -591,6 +591,11 @@ fn sky_along_ray(d: vec3<f32>) -> vec3<f32> {
@fragment fn fs_sky(in: SkyOut) -> @location(0) vec4<f32> {
return vec4(sky_along_ray(view_ray(in.ndc)), 1.0);
}
// The first-person hand's depth clear inside the world pass: `vs_sky`'s triangle lies at the
// far plane, written over the whole depth target with no colour.
@fragment fn fs_depth_reset(in: SkyOut) -> @location(0) vec4<f32> {
return vec4(0.0);
}
@fragment fn fs_dark_disc(in: SkyOut) -> @location(0) vec4<f32> {
let d = view_ray(in.ndc);
let dist = disc_distance(d, -4.0);
@@ -100,6 +100,9 @@ pub struct World {
/// for `ServerLevel.sendBlockUpdated`'s path recomputing; kept only while
/// the mobs of this world tick (`game/creatures.rs`).
pub path_writes: Option<Vec<((i32, i32, i32), u32, u32)>>,
/// The chunks changed in bulk (`edit_runs`) since the log was last
/// drained, kept while `block_log` is: sent to the players whole.
pub bulk_log: FxHashSet<(i32, i32)>,
/// The sections whose light changed since the log was last drained, as
/// a bit per section of each column, kept while `block_log` is
/// (`ChunkHolder.sectionLightChanged`).
@@ -208,6 +211,7 @@ impl World {
weather: Weather::default(),
block_log: None,
path_writes: None,
bulk_log: Default::default(),
light_log: None,
moving_piston_fallback: Vec::new(),
moving_pistons: Vec::new(),
+16 -8
View File
@@ -6,8 +6,12 @@ edition.workspace = true
license.workspace = true
rust-version = "1.90"
description = "Horizon native development client"
autobins = false
autoexamples = false
autotests = false
[dependencies]
horizon-client-lib = { path = "../horizon-client-lib", default-features = false }
horizon-assets = { path = "../horizon-assets" }
horizon-client-core = { path = "../horizon-client-core" }
horizon-client-input = { path = "../horizon-client-input" }
@@ -15,8 +19,9 @@ horizon-client-render = { path = "../horizon-client-render" }
horizon-command = { path = "../horizon-command" }
horizon-items = { path = "../horizon-items" }
horizon-core = { path = "../horizon-core" }
# The server thread ahead of the workers (MinecraftServer.spin's Thread.setPriority).
# OptiFine-format pack files: custom entity models, random entity textures.
horizon-optifine = { path = "../horizon-optifine" }
# The server thread ahead of the workers (MinecraftServer.spin's Thread.setPriority).
horizon-threads = { path = "../horizon-threads" }
horizon-hdr = { path = "../horizon-hdr" }
horizon-world = { path = "../horizon-world" }
@@ -104,20 +109,23 @@ winresource = "0.1"
default = ["audio", "controllers"]
# Sound output. Without it (and `controllers`) the program links neither libasound nor libudev on
# Linux: the server build (`--no-default-features`, released as horizon-engine; docs/server.md).
audio = ["horizon-client-render/audio"]
audio = ["horizon-client-render/audio", "horizon-client-lib/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"]
controllers = ["horizon-client-lib/controllers", "dep:gilrs", "dep:horizon-dualsense", "horizon-client-input/controllers"]
# Records the call sites of mob random draws (`mob_ai_probe` traces).
rng-trace = ["horizon-core/rng-trace"]
rng-trace = ["horizon-core/rng-trace", "horizon-client-lib/rng-trace"]
# Compatibility with earlier experiment commands: main already uses mimalloc.
windows-mimalloc = []
# Opt-in Windows heap experiment; see docs/windows-allocators.md.
windows-segment-heap = []
# Its own name, not `probe`: Cargo copies every crate's examples into one target/<profile>/examples.
[[example]]
name = "client_probe"
path = "examples/probe/main.rs"
[[bin]]
name = "horizon-client"
path = "src/main.rs"
[[test]]
name = "launch_api"
path = "tests/launch_api.rs"
[lints]
workspace = true
@@ -0,0 +1,293 @@
{
"horizon.options.controller": "Controller",
"horizon.options.vibration": "Vibration",
"horizon.options.adaptive_triggers": "Adaptive Trigger",
"horizon.options.lightbar": "Lightbar",
"horizon.options.look_sensitivity": "Blickempfindlichkeit",
"horizon.options.invert_x": "Blick-X-Achse umkehren",
"horizon.options.invert_y": "Blick-Y-Achse umkehren",
"horizon.options.sneak_button": "Schleichtaste",
"horizon.options.move_deadzone": "Totzone der Bewegung",
"horizon.options.look_deadzone": "Totzone des Blicks",
"horizon.options.button_glyphs": "Tastensymbole",
"horizon.options.menu_navigation": "Menünavigation",
"horizon.options.on_screen_keyboard": "Bildschirmtastatur",
"horizon.options.chat_delay": "Chatverzögerung",
"horizon.options.lightbar.health": "Gesundheit",
"horizon.options.lightbar.dimension": "Dimension",
"horizon.options.glyphs.auto": "Auto",
"horizon.options.glyphs.xbox": "Xbox",
"horizon.options.glyphs.playstation": "PlayStation",
"horizon.options.glyphs.nintendo": "Nintendo",
"horizon.options.menu_focus": "Fokus",
"horizon.options.menu_pointer": "Zeiger",
"horizon.options.keyboard.auto": "Auto",
"horizon.options.keyboard.horizon": "Horizon",
"horizon.options.keyboard.steam": "Steam",
"horizon.options.unavailable": "Diese Funktion ist in Horizon noch nicht verfügbar.",
"horizon.options.search": "Einstellungen durchsuchen …",
"horizon.pages.mods": "Mods",
"horizon.pages.shader_packs": "Shader-Pakete",
"horizon.pages.shader_options": "Shader-Optionen",
"horizon.pages.controller": "Controller-Einstellungen",
"horizon.pages.controller_binds": "Controller-Tastenbelegung",
"horizon.pages.extras": "Horizon-Extras",
"horizon.pages.settings": "Horizon-Einstellungen",
"horizon.pages.agent": "KI-Agent",
"horizon.pages.split_profiles": "Wer spielt?",
"horizon.pages.settings.tooltip": "Horizons eigene Einstellungen: Grafik, Controller, Extras und Mods.",
"horizon.pages.graphics.tooltip": "Grafik, einfach: Jede Einstellung zeigt, was sie kostet.",
"horizon.pages.graphics_advanced.tooltip": "Alle Grafikeinstellungen, auch die von Horizon, mit den Kosten jeder einzelnen.",
"horizon.pages.controller.tooltip": "Controller: ihre Tasten, Sticks, Vibration und Menünavigation.",
"horizon.pages.extras.tooltip": "Optionale Zusätze, alle standardmäßig aus: Das Spiel bleibt Vanilla, bis du einen einschaltest.",
"horizon.pages.agent.tooltip": "Lass deinen eigenen KI-Agenten spielen: horizon-ctl und MCP-Clients auf diesem Computer.",
"horizon.pages.split_profiles.tooltip": "Wer spielt: ein Profil, das in diesem Spielordner gespeichert ist.",
"horizon.pages.shader_packs.tooltip": "Shader-Pakete aus dem Ordner shaderpacks: die von Horizon selbst und die im Format shaders/. Es läuft immer nur eines.",
"horizon.pages.mods.tooltip": "Mods für Horizon aus dem Ordner horizon-mods. Jede läuft in einer Sandbox und bleibt aus, bis du sie einschaltest.",
"horizon.pages.open.graphics": "Grafik …",
"horizon.pages.open.graphics_advanced": "Erweiterte Grafik …",
"horizon.pages.open.shader_packs": "Shader-Pakete …",
"horizon.pages.open.controller": "Controller-Einstellungen …",
"horizon.pages.open.extras": "Extras …",
"horizon.pages.open.mods": "Mods …",
"horizon.pages.open.agent": "KI-Agent …",
"horizon.pages.open.horizon_graphics": "Horizon-Grafik …",
"horizon.pages.open.controller_binds": "Controller-Belegung …",
"horizon.pages.open.horizon_settings": "Horizon-Einstellungen …",
"horizon.mods.empty": "Keine Mods: Lege .hmod-Dateien in den Ordner horizon-mods",
"horizon.mods.stopped": " (angehalten)",
"horizon.mods.not_built": " (nicht gebaut)",
"horizon.mods.dev": " (Entwicklung)",
"horizon.mods.settings": "Einstellungen",
"horizon.mods.open_folder": "Modordner öffnen",
"horizon.shader_packs.empty": "Keine Shader-Pakete: Lege sie in den Ordner shaderpacks",
"horizon.shader_packs.kind_horizon": " (Horizon)",
"horizon.shader_options.open": "Shader-Optionen …",
"horizon.shader_options.profile": "Profil",
"horizon.shader_options.loading": "Optionen des Shader-Pakets werden gelesen …",
"horizon.agent.copy_details": "Verbindungsdaten kopieren",
"horizon.multiplayer.join": "Beitreten",
"horizon.multiplayer.cancel_reconnecting": "Neuverbindung abbrechen",
"horizon.multiplayer.join_friend": "Freund beitreten",
"horizon.multiplayer.invite_code": "Einladungscode",
"horizon.multiplayer.relay": "Relay (optional)",
"Preset": "Preset",
"LOD Distance": "LOD-Weite",
"Far Detail": "Ferndetails",
"Cave Culling": "Höhlen-Culling",
"Entity Culling": "Objekt-Culling",
"Chunk Updates": "Chunk-Updates",
"Upload Budget": "Upload-Budget",
"Sky": "Himmel",
"Fog": "Nebel",
"Weather": "Wetter",
"Animated Textures": "Animierte Texturen",
"Present Mode": "Ausgabemodus",
"Frame Pacing": "Bildtakt",
"Worker Threads": "Arbeitsthreads",
"Resolution Scale": "Auflösungsskalierung",
"Anti-aliasing": "Kantenglättung",
"Upscaling": "Upscaling",
"HDR Settings...": "HDR-Einstellungen …",
"Paper White": "Papierweiß",
"Peak Brightness": "Spitzenhelligkeit",
"Black Level": "Schwarzwert",
"UI Brightness": "GUI-Helligkeit",
"Battery Mode": "Akkumodus",
"Graphics API": "Grafik-API",
"Performance Overlay": "Leistungsanzeige",
"Full Detail Distance": "Detailweite",
"On": "An",
"Off": "Aus",
"Unlimited": "Unbegrenzt",
"Auto": "Auto",
"Custom": "Benutzerdefiniert",
"Performance": "Leistung",
"Balanced": "Ausgewogen",
"Quality": "Qualität",
"Ultra": "Ultra",
"Ultra Performance": "Ultra-Leistung",
"Native": "Nativ",
"Full": "Voll",
"Reduced": "Reduziert",
"Efficient": "Sparsam",
"Precise": "Präzise",
"Auto (VSync)": "Auto (VSync)",
"FIFO": "FIFO",
"Mailbox": "Mailbox",
"Immediate": "Immediate",
"Vulkan": "Vulkan",
"DirectX 12": "DirectX 12",
"OpenGL": "OpenGL",
"FPS and Graph": "FPS und Diagramm",
"FPS": "FPS",
"TAA": "TAA",
"FXAA": "FXAA",
"No cost": "Keine Kosten",
"Low cost": "Geringe Kosten",
"Medium cost": "Mittlere Kosten",
"High cost": "Hohe Kosten",
"Saves time": "Spart Zeit",
"Rendering": "Rendering",
"Display": "Anzeige",
"Preferences": "Präferenzen",
"Overlay": "Einblendung",
"HDR": "HDR",
"Graphics": "Grafik",
"Graphics: Advanced": "Grafik: Erweitert",
"Graphics: HDR": "Grafik: HDR",
"Advanced...": "Erweitert …",
"Simple...": "Einfach …",
"Done": "Fertig",
"This feature is not yet available in Horizon.": "Diese Funktion ist in Horizon noch nicht verfügbar.",
"Your display or system doesn't offer HDR output.": "Dein Bildschirm oder System unterstützt keine HDR-Ausgabe.",
"Turn HDR on to use it.": "Schalte HDR ein, um es zu nutzen.",
"%s chunks": "%s Chunks",
"%s per frame": "%s pro Bild",
"%s MiB/frame": "%s MiB/Bild",
"%s KiB/frame": "%s KiB/Bild",
"%s nits": "%s Nits",
"Auto (%s)": "Auto (%s)",
"Auto (%s nits)": "Auto (%s Nits)",
"%.2f nits": "%.2f Nits",
"Full-detail terrain range. LOD Distance can extend local worlds farther.": "Gelände mit vollen Details. Die LOD-Entfernung reicht weiter.",
"Coarse distant terrain in local worlds. Integrated GPUs stop at 128 chunks.": "Grobes fernes Gelände in lokalen Welten. Integrierte GPUs: max. 128.",
"Reduced draws far terrain coarser: faster on this integrated GPU.": "Reduziert: fernes Gelände gröber, schneller auf dieser GPU.",
"Reduced draws far terrain coarser, for GPUs it slows down.": "Reduziert: fernes Gelände gröber, für GPUs, die sonst bremsen.",
"Field of view. Wider shows more terrain at once.": "Sichtfeld. Ein größeres zeigt mehr Gelände auf einmal.",
"Caps frames per second; a cap saves power and heat.": "Begrenzt die Bilder pro Sekunde; spart Strom und Wärme.",
"Waits for the display's refresh: no tearing, a little latency.": "Wartet auf den Bildaufbau: kein Tearing, etwas Latenz.",
"Size of menus and the HUD.": "Größe der Menüs und des HUDs.",
"Sets the options below together. Changing one makes it Custom.": "Stellt Optionen darunter ein. Änderung = „Benutzerdefiniert“.",
"Lava, fire and sunlight shine brighter than white. Needs an HDR display.": "Lava, Feuer, Sonne heller als Weiß. Braucht HDR-Bildschirm.",
"Your display's darkest black. 0 for OLED; LCDs glow a little.": "Das dunkelste Schwarz deines Bildschirms. 0 für OLED; LCDs leuchten ein wenig.",
"How bright highlights get. Auto: the peak your system reports.": "Wie hell Lichter werden. Auto: der Spitzenwert, den dein System meldet.",
"How bright white paper and menus look. Auto: your desktop's white.": "Wie hell weißes Papier und Menüs aussehen. Auto: das Weiß deines Desktops.",
"How bright the HUD and menus are, against paper white.": "Wie hell HUD und Menüs im Vergleich zu Papierweiß sind.",
"How far mobs, crops and machines keep working. CPU time.": "Wie weit Kreaturen, Pflanzen und Maschinen weiterarbeiten. Kostet CPU-Zeit.",
"How far away mobs and items are drawn, as a share of normal.": "Zeichenweite für Kreaturen und Gegenstände, anteilig zur Norm.",
"Skips terrain hidden behind closed sections. Off costs a lot.": "Überspringt Gelände hinter geschlossenen Abschnitten. Aus kostet viel.",
"Hides models behind opaque blocks. Entities update. Off with custom packs.": "Versteckt Modelle hinter Blöcken. Bei eigenen Paketen ausschalten.",
"Meshed sections uploaded per frame. Lower is smoother while loading.": "Mesh-Abschnitte pro Bild. Weniger: flüssigeres Laden.",
"Soft shadows in corners. Costs chunk building time only.": "Weiche Schatten in Ecken. Kostet nur Zeit beim Chunk-Aufbau.",
"Smaller copies of block textures for distant terrain: less shimmer.": "Kleinere Textur-Kopien für fernes Gelände: weniger Flimmern.",
"Blends grass and water colours across biomes when building chunks.": "Mischt Gras- und Wasserfarben über Biome hinweg beim Chunk-Aufbau.",
"Cloud layer: off, flat (fast) or 3D (fancy).": "Wolkenschicht: aus, flach (schnell) oder 3D (schön).",
"How far clouds reach, in chunks.": "Wie weit Wolken reichen, in Chunks.",
"Draws the sky, sun, moon and stars.": "Zeichnet Himmel, Sonne, Mond und Sterne.",
"Distance fog. Off shows the edge of the loaded world.": "Nebel in der Ferne. Aus zeigt den Rand der geladenen Welt.",
"Draws rain and snow.": "Zeichnet Regen und Schnee.",
"How far rain and snow fall around you.": "Wie weit um dich herum Regen und Schnee fallen.",
"How many particles effects spawn.": "Wie viele Partikel Effekte erzeugen.",
"Round shadows under mobs and items.": "Runde Schatten unter Kreaturen und Gegenständen.",
"Leaves with see-through gaps. Off draws them solid.": "Laub mit durchsichtigen Lücken. Aus zeichnet es massiv.",
"Sorts water and glass more exactly behind each other.": "Sortiert Wasser und Glas hintereinander genauer.",
"Which chunk rebuilds wait for the frame: none, nearby or yours.": "Welche Chunk-Neuaufbauten auf das Bild warten: keine, nahe oder deine.",
"Animates water, lava, fire and other moving textures.": "Animiert Wasser, Lava, Feuer und andere bewegte Texturen.",
"World pixels per screen pixel. Below 100% is faster on the GPU.": "Weltpixel pro Bildschirmpixel. Unter 100 % ist es auf der GPU schneller.",
"Smooths jagged edges. FXAA: softer. TAA: smoothest, moving things may trail.": "Glättet Kanten. FXAA: weicher. TAA: am glattesten, evtl. Schlieren.",
"Renders the world smaller and rebuilds it with TAA. Faster, softer.": "Rendert die Welt kleiner und baut sie mit TAA wieder auf. Schneller, weicher.",
"HDR output on an HDR display: lava, fire and sunlight shine brighter.": "HDR-Ausgabe auf HDR-Bildschirm: Lava, Feuer, Sonne heller.",
"How frames reach the screen while VSync is off.": "Wie Bilder auf den Bildschirm gelangen, wenn VSync aus ist.",
"Efficient sleeps until the next frame; Precise spins, like vanilla.": "Sparsam schläft bis zum nächsten Bild; Präzise wartet aktiv, wie in Vanilla.",
"Fills the screen (borderless).": "Füllt den Bildschirm (randlos).",
"Brightens dark places.": "Hellt dunkle Orte auf.",
"Lowers the frame rate when minimised, or idle too.": "Senkt die Bildrate bei Minimierung oder auch bei Inaktivität.",
"Sways the view while walking.": "Lässt die Ansicht beim Gehen schwanken.",
"Fades newly loaded chunks in.": "Blendet neu geladene Chunks ein.",
"Darkens the screen's edges.": "Dunkelt die Bildschirmränder ab.",
"Shows when the world saves.": "Zeigt an, wann die Welt gespeichert wird.",
"Shows attack cooldown.": "Zeigt die Angriffsabklingzeit an.",
"60 FPS cap (30 in menus or unfocused), half the workers: less power.": "60 FPS (30 im Menü/ohne Fokus), halb so viele Worker: spart Strom.",
"Threads that generate, light and mesh chunks. Next start.": "Threads für Erzeugung, Licht und Mesh der Chunks. Nach Neustart.",
"Vulkan, then DirectX 12 (Windows) or OpenGL (Linux) where it fails. Next start.": "Vulkan, sonst DirectX 12 (Windows) oder OpenGL (Linux). Nach Neustart.",
"Mesh data sent to the GPU per frame. Lower is smoother while loading.": "Pro Bild an die GPU gesendete Mesh-Daten. Weniger ist flüssiger beim Laden.",
"Frame rate and frame times over the game, with a graph.": "Bildrate und Bildzeiten über dem Spiel, mit Diagramm.",
"horizon.multiplayer.finding_friend": "Die Welt deines Freundes wird gesucht …",
"horizon.loading.opening_world": "Welt wird geöffnet",
"horizon.loading.cancelling_world": "Wird abgebrochen",
"horizon.title.not_affiliated": "Nicht mit Mojang oder Microsoft verbunden",
"horizon.extras.header.lighting": "Beleuchtung",
"horizon.extras.header.third_person": "Dritte Person",
"horizon.extras.header.inventory": "Inventar",
"horizon.extras.header.multiplayer": "Mehrspieler",
"horizon.extras.fullbright": "Maximale Helligkeit",
"horizon.extras.dynamic_lights": "Dynamisches Licht",
"horizon.extras.own_name_tag": "Eigenes Namensschild",
"horizon.extras.auto_reconnect": "Neu verbinden",
"horizon.extras.mouse_tweaks": "Mouse Tweaks",
"horizon.extras.mouse_tweaks_wheel": "Mausrad-Verschieben",
"horizon.extras.sort": "Sortier-Schaltflächen",
"horizon.extras.sort_key": "Sortiertaste",
"horizon.extras.no_ai": "Keine KI",
"horizon.extras.agent_control": "Agentensteuerung",
"horizon.extras.discord_presence": "Discord Rich Presence",
"horizon.extras.fullbright.tooltip": "Zeigt Blöcke und Objekte bei vollem Licht, selbst an dunklen Orten.",
"horizon.extras.dynamic_lights.tooltip": "Fackeln und andere Lichtquellen, die gehalten, fallen gelassen werden oder brennen, beleuchten die Blöcke um sich herum.",
"horizon.extras.own_name_tag.tooltip": "Zeigt deinen Namen in der dritten Person über deinem Kopf an, so wie andere Spieler ihn sehen. Versteckt, wenn der Server die Namen deines Teams verbirgt oder du unsichtbar bist.",
"horizon.extras.auto_reconnect.tooltip": "Verbindet nach einem Verbindungsabbruch oder Neustart erneut mit dem Server, nie nach einem Rauswurf oder Bann.",
"horizon.extras.mouse_tweaks.tooltip": "Mit Rechtsklick ziehen legt je einen pro Feld ab, mit Linksklick ziehen sammelt den gehaltenen Gegenstand, Umschalt + Linksklick ziehen verschiebt jedes Feld.",
"horizon.extras.mouse_tweaks_wheel.tooltip": "Nach unten schickt einen Gegenstand des Feldes auf die andere Seite, nach oben holt einen zurück.",
"horizon.extras.sort.tooltip": "Sortiert Inventar, Truhen, Fässer, Endertruhen und Shulker-Kisten in der Reihenfolge der Kreativ-Tabs und fasst Stapel zusammen.",
"horizon.extras.sort_key.tooltip": "Sortiert den Bereich unter dem Mauszeiger. Klicke, dann drücke eine Taste; Esc hebt die Belegung auf.",
"horizon.extras.no_ai.tooltip": "Blendet alle KI-Funktionen aus und schaltet sie ab: Agentensteuerung, horizon-ctl und die MCP-Server. Auch den Schalter des Launchers.",
"horizon.extras.agent_control.tooltip": "Dein eigener KI-Agent kann dieses Spiel über horizon-ctl oder einen MCP-Client spielen, nur auf diesem Computer.",
"horizon.extras.discord_presence.tooltip": "Zeigt „Spielt Horizon“ in deinem Discord-Profil an, solange Discord läuft: Menüs, Einzel- oder Mehrspielermodus, die Dimension und die Spielzeit. Nie den Namen einer Welt oder die Adresse eines Servers.",
"Calibrate...": "Kalibrieren …",
"Calibrate Anyway...": "Trotzdem kalibrieren …",
"Use Automatic Setup": "Automatische Einrichtung verwenden",
"Shortcut: %s": "Tastenkürzel: %s",
"Next": "Weiter",
"HDR Calibration %s/%s: %s": "HDR-Kalibrierung %s/%s: %s",
"Sets the three below full screen, one test picture at a time.": "Stellt die drei Werte darunter im Vollbild ein, jeweils mit einem Testbild.",
"A key that opens this screen from the game. Not bound at first.": "Eine Taste, die diesen Bildschirm im Spiel öffnet. Zunächst nicht belegt.",
"Your display didn't say how bright it gets: calibrate to set it up.": "Bildschirm meldete keine Helligkeit: zum Einrichten kalibrieren.",
"Already set up for your display. Only calibrate if HDR looks wrong.": "Bereits eingerichtet. Nur kalibrieren, wenn HDR falsch aussieht.",
"Puts Peak Brightness and Paper White back to your display's setup.": "Stellt Spitzenhelligkeit und Papierweiß des Bildschirms wieder her.",
"Set up for your display. Leave on Auto unless it looks wrong.": "Für den Bildschirm eingerichtet. Auto lassen, wenn es passt.",
"Lower it until the logo just disappears into the black.": "Verringere den Wert, bis das Logo gerade im Schwarz verschwindet.",
"Raise it until the logo just disappears into the bright square.": "Erhöhe den Wert, bis das Logo gerade im hellen Quadrat verschwindet.",
"Make the card a white that's comfortable to look at for a long time, in the light you play in.": "Wähle ein Weiß, das bei deinem Licht lange angenehm ist.",
"horizon.splitscreen.profiles.forget": "Entfernen",
"horizon.splitscreen.profiles.create": "Erstellen",
"horizon.splitscreen.profiles.you": " - du",
"horizon.splitscreen.profiles.forget_named": "%s entfernen",
"horizon.splitscreen.profiles.previous": "Zurück",
"horizon.splitscreen.profiles.next": "Weiter",
"horizon.splitscreen.profiles.new_button": "Neues Profil …",
"horizon.splitscreen.profiles.forget_title": "Profil entfernen?",
"horizon.splitscreen.profiles.forget_leaves": "Es verschwindet aus der Liste dieser Instanz.",
"horizon.splitscreen.profiles.forget_kept": "Weltfortschritt und Einstellungen bleiben erhalten.",
"horizon.splitscreen.profiles.new_title": "Neues Profil",
"horizon.splitscreen.profiles.name_hint": "Name (3 bis 16 Buchstaben, Ziffern, _)",
"horizon.splitscreen.profiles.switch_title": "Profil wechseln",
"horizon.splitscreen.profiles.offline_note": "Offline-Profile gehören zu dieser Instanz",
"horizon.splitscreen.profiles.move": "Spielort wechseln",
"horizon.splitscreen.profiles.move_place": "Spielort wechseln: %s",
"horizon.friends.title": "Mit Freunden spielen",
"horizon.friends.hint": "Freunde fügen den Code nach dem Freunde-Knopf des Titelbildschirms ein, bei „Direktverbindung“ oder auf der Spielen-Seite des Launchers.",
"horizon.friends.copied": "Kopiert",
"horizon.friends.copy_code": "Code kopieren",
"horizon.friends.stop_sharing": "Teilen beenden",
"horizon.friends.none_yet": "Noch keine Freunde hier.",
"horizon.friends.here_now": "Gerade hier: %s",
"horizon.friends.share_intro": "Teile diese Welt mit einem Einladungscode: Sie wird im LAN geöffnet, und Freunde im Internet treten mit dem Code bei. Es ist kein Server von uns beteiligt.",
"horizon.friends.start_sharing": "Teilen starten",
"horizon.friends.troubleshooting": "Fehlerbehebung …",
"horizon.friends.stopped": "Das Teilen des Einladungscodes wurde beendet.",
"horizon.friends.short_code_changed": "Jemand hat immer wieder einen falschen Kurzcode versucht, deshalb wurde er geändert.",
"horizon.friends.wrong_code": "Jemand hat einen falschen Code versucht.",
"horizon.friends.code_ready": "Dein Einladungscode ist bereit: Spielmenü, Mit Freunden spielen. Kopiere ihn und schicke ihn an deine Freunde.",
"horizon.friends.share_failed": "Die Welt konnte nicht geteilt werden: %s",
"horizon.friends.finding_route": "Es wird ermittelt, wie Freunde dich erreichen können …",
"horizon.osk.shift": "Umschalt",
"horizon.osk.space": "Leertaste",
"horizon.osk.delete": "Löschen",
"horizon.osk.type": "Eingeben",
"horizon.osk.symbols": "Symbole",
"horizon.osk.close": "Schließen",
"horizon.multiplayer.not_signed_in": "Dieser Server läuft im Online-Modus. Melde dich im Horizon-Launcher mit deinem Microsoft-Konto an, um ihm beizutreten.",
"horizon.multiplayer.invalid_address": "Ungültige Serveradresse: %s",
"horizon.multiplayer.connected_friend_direct": "Direkt mit deinem Freund verbunden …",
"horizon.multiplayer.connected_friend_relay": "Über das Relay mit deinem Freund verbunden …"
}
@@ -213,5 +213,98 @@
"horizon.multiplayer.not_signed_in": "This server is in online mode. Sign in with your Microsoft account in the Horizon launcher to join it.",
"horizon.multiplayer.invalid_address": "Invalid server address: %s",
"horizon.multiplayer.connected_friend_direct": "Connected straight to your friend...",
"horizon.multiplayer.connected_friend_relay": "Connected to your friend through the relay..."
"horizon.multiplayer.connected_friend_relay": "Connected to your friend through the relay...",
"horizon.title.not_affiliated": "Not affiliated with Mojang or Microsoft",
"horizon.extras.header.lighting": "Lighting",
"horizon.extras.header.third_person": "Third person",
"horizon.extras.header.particles": "Particles",
"horizon.extras.header.inventory": "Inventory",
"horizon.extras.header.chat": "Chat",
"horizon.extras.header.multiplayer": "Multiplayer",
"horizon.extras.header.sound": "Sound",
"horizon.extras.fullbright": "Fullbright",
"horizon.extras.dynamic_lights": "Dynamic lights",
"horizon.extras.own_name_tag": "Own name tag",
"horizon.extras.hide_block_particles": "Hide block-break particles",
"horizon.extras.auto_reconnect": "Auto-reconnect",
"horizon.extras.ui_sound_reserve": "Keep channels for UI sounds",
"horizon.extras.mouse_tweaks": "Mouse Tweaks controls",
"horizon.extras.mouse_tweaks_wheel": "Scroll wheel moves items",
"horizon.extras.sort": "Sort buttons",
"horizon.extras.sort_key": "Sort key",
"horizon.extras.no_ai": "No AI",
"horizon.extras.agent_control": "Agent Control",
"horizon.extras.discord_presence": "Discord Rich Presence",
"horizon.extras.splitscreen": "Splitscreen",
"horizon.extras.chat_animation": "Smooth chat",
"horizon.extras.chat_indicator": "Message indicator",
"horizon.extras.fullbright.tooltip": "Show blocks and entities at full light, even in dark areas.",
"horizon.extras.dynamic_lights.tooltip": "Torches and other light sources held, dropped or on fire light the blocks around them.",
"horizon.extras.own_name_tag.tooltip": "Shows your name above your head in third person, as other players see it. Hidden when the server hides your team's names or you're invisible.",
"horizon.extras.hide_block_particles.tooltip": "Hides the debris particles that burst from a broken block and crumble off the one you mine. Other particles stay.",
"horizon.extras.auto_reconnect.tooltip": "Rejoins a server after a lost connection or a restart, never after a kick or ban.",
"horizon.extras.ui_sound_reserve.tooltip": "Sounds in the world leave 16 of the 247 sound channels free, so a flood of them (a big explosion) can't silence menu clicks.",
"horizon.extras.mouse_tweaks.tooltip": "Right-drag places one per slot, left-drag gathers the held item, Shift + left-drag moves every slot.",
"horizon.extras.mouse_tweaks_wheel.tooltip": "Down sends one item of the slot to the other side, up brings one back.",
"horizon.extras.sort.tooltip": "Sorts the inventory, chests, barrels, ender chests and shulker boxes in creative tab order, merging stacks.",
"horizon.extras.sort_key.tooltip": "Sorts the part under the cursor. Click, then press a key; Escape unbinds it.",
"horizon.extras.no_ai.tooltip": "Hides and turns off every AI feature: agent control, horizon-ctl and the MCP servers. The launcher's switch too.",
"horizon.extras.agent_control.tooltip": "Your own AI agent can play this game through horizon-ctl or an MCP client, on this computer only.",
"horizon.extras.discord_presence.tooltip": "Shows Playing Horizon on your Discord profile while Discord runs: menus, single player or multiplayer, the dimension and the time played. Never a world's name or a server's address.",
"Calibrate...": "Calibrate...",
"Calibrate Anyway...": "Calibrate Anyway...",
"Use Automatic Setup": "Use Automatic Setup",
"Shortcut: %s": "Shortcut: %s",
"Next": "Next",
"HDR Calibration %s/%s: %s": "HDR Calibration %s/%s: %s",
"Sets the three below full screen, one test picture at a time.": "Sets the three below full screen, one test picture at a time.",
"A key that opens this screen from the game. Not bound at first.": "A key that opens this screen from the game. Not bound at first.",
"Your display didn't say how bright it gets: calibrate to set it up.": "Your display didn't say how bright it gets: calibrate to set it up.",
"Already set up for your display. Only calibrate if HDR looks wrong.": "Already set up for your display. Only calibrate if HDR looks wrong.",
"Puts Peak Brightness and Paper White back to your display's setup.": "Puts Peak Brightness and Paper White back to your display's setup.",
"Set up for your display. Leave on Auto unless it looks wrong.": "Set up for your display. Leave on Auto unless it looks wrong.",
"Lower it until the logo just disappears into the black.": "Lower it until the logo just disappears into the black.",
"Raise it until the logo just disappears into the bright square.": "Raise it until the logo just disappears into the bright square.",
"Make the card a white that's comfortable to look at for a long time, in the light you play in.": "Make the card a white that's comfortable to look at for a long time, in the light you play in.",
"horizon.splitscreen.profiles.forget": "Forget",
"horizon.splitscreen.profiles.create": "Create",
"horizon.splitscreen.profiles.you": " - you",
"horizon.splitscreen.profiles.forget_named": "Forget %s",
"horizon.splitscreen.profiles.previous": "Previous",
"horizon.splitscreen.profiles.next": "Next",
"horizon.splitscreen.profiles.new_button": "New Profile...",
"horizon.splitscreen.profiles.forget_title": "Forget profile?",
"horizon.splitscreen.profiles.forget_leaves": "It leaves this instance's list.",
"horizon.splitscreen.profiles.forget_kept": "World progress and settings are kept.",
"horizon.splitscreen.profiles.new_title": "New Profile",
"horizon.splitscreen.profiles.name_hint": "Name (3 to 16 letters, digits, _)",
"horizon.splitscreen.profiles.switch_title": "Switch profile",
"horizon.splitscreen.profiles.offline_note": "Offline profiles belong to this instance",
"horizon.splitscreen.profiles.move": "Move where you play",
"horizon.splitscreen.profiles.move_place": "Move where you play: %s",
"horizon.friends.title": "Play with Friends",
"horizon.friends.hint": "Friends paste the code after the title screen's Friends button, in Direct Connection, or on the launcher's Play page.",
"horizon.friends.copied": "Copied",
"horizon.friends.copy_code": "Copy Code",
"horizon.friends.stop_sharing": "Stop Sharing",
"horizon.friends.none_yet": "No friends here yet.",
"horizon.friends.here_now": "Here now: %s",
"horizon.friends.share_intro": "Share this world behind an invite code: it opens to LAN, and friends on the internet join with the code. No server of ours is involved.",
"horizon.friends.start_sharing": "Start Sharing",
"horizon.friends.troubleshooting": "Troubleshooting...",
"horizon.friends.stopped": "Stopped sharing the invite code.",
"horizon.friends.short_code_changed": "Someone kept trying a wrong short code, so it changed.",
"horizon.friends.wrong_code": "Someone tried a wrong code.",
"horizon.friends.code_ready": "Your invite code is ready: Game Menu, Play with Friends. Copy it and send it to your friends.",
"horizon.friends.share_failed": "Couldn't share the world: %s",
"horizon.friends.finding_route": "Finding how friends can reach you…",
"horizon.osk.shift": "Shift",
"horizon.osk.space": "Space",
"horizon.osk.delete": "Delete",
"horizon.osk.type": "Type",
"horizon.osk.symbols": "Symbols",
"horizon.osk.close": "Close",
"horizon.extras.chat_animation.tooltip": "A new chat line slides in from the left while it fades in, instead of appearing whole.",
"horizon.extras.chat_indicator.tooltip": "The coloured bar at the left of a chat line.",
"horizon.extras.splitscreen.tooltip": "In a world, another controller can join as a new player: press A when it asks, then pick a profile and where to play."
}
@@ -203,5 +203,11 @@
"horizon.multiplayer.not_signed_in": "Este servidor está en modo en línea. Inicia sesión con tu cuenta de Microsoft en el launcher de Horizon para unirte.",
"horizon.multiplayer.invalid_address": "Dirección de servidor no válida: %s",
"horizon.multiplayer.connected_friend_direct": "Conectado directamente con tu amigo…",
"horizon.multiplayer.connected_friend_relay": "Conectado con tu amigo a través del relé…"
"horizon.multiplayer.connected_friend_relay": "Conectado con tu amigo a través del relé…",
"horizon.pages.shader_options": "Opciones de sombreadores",
"horizon.pages.split_profiles": "¿Quién juega?",
"horizon.pages.split_profiles.tooltip": "Quién juega: un perfil guardado en esta carpeta del juego.",
"horizon.shader_options.open": "Opciones de sombreadores…",
"horizon.shader_options.profile": "Perfil",
"horizon.shader_options.loading": "Leyendo las opciones del paquete de sombreadores…"
}
@@ -0,0 +1,293 @@
{
"horizon.options.controller": "Manette",
"horizon.options.vibration": "Vibrations",
"horizon.options.adaptive_triggers": "Gâchettes adaptatives",
"horizon.options.lightbar": "Barre lumineuse",
"horizon.options.look_sensitivity": "Sensibilité de la vue",
"horizon.options.invert_x": "Inverser l'axe X de la vue",
"horizon.options.invert_y": "Inverser l'axe Y de la vue",
"horizon.options.sneak_button": "Bouton pour s'accroupir",
"horizon.options.move_deadzone": "Zone morte du déplacement",
"horizon.options.look_deadzone": "Zone morte de la vue",
"horizon.options.button_glyphs": "Symboles des boutons",
"horizon.options.menu_navigation": "Navigation dans les menus",
"horizon.options.on_screen_keyboard": "Clavier à l'écran",
"horizon.options.chat_delay": "Délai du tchat",
"horizon.options.lightbar.health": "Santé",
"horizon.options.lightbar.dimension": "Dimension",
"horizon.options.glyphs.auto": "Auto",
"horizon.options.glyphs.xbox": "Xbox",
"horizon.options.glyphs.playstation": "PlayStation",
"horizon.options.glyphs.nintendo": "Nintendo",
"horizon.options.menu_focus": "Focus",
"horizon.options.menu_pointer": "Pointeur",
"horizon.options.keyboard.auto": "Auto",
"horizon.options.keyboard.horizon": "Horizon",
"horizon.options.keyboard.steam": "Steam",
"horizon.options.unavailable": "Cette fonction n'est pas encore disponible dans Horizon.",
"horizon.options.search": "Rechercher une option...",
"horizon.pages.mods": "Mods",
"horizon.pages.shader_packs": "Packs de shaders",
"horizon.pages.shader_options": "Options des shaders",
"horizon.pages.controller": "Options de la manette",
"horizon.pages.controller_binds": "Assignation des boutons de la manette",
"horizon.pages.extras": "Extras Horizon",
"horizon.pages.settings": "Options Horizon",
"horizon.pages.agent": "Agent IA",
"horizon.pages.split_profiles": "Qui joue ?",
"horizon.pages.settings.tooltip": "Les options propres à Horizon : graphismes, manettes, extras et mods.",
"horizon.pages.graphics.tooltip": "Les graphismes, simplement : chaque réglage indique son coût.",
"horizon.pages.graphics_advanced.tooltip": "Tous les réglages graphiques, y compris ceux d'Horizon, avec le coût de chacun.",
"horizon.pages.controller.tooltip": "Les manettes : leurs boutons, leurs sticks, les vibrations et la navigation dans les menus.",
"horizon.pages.extras.tooltip": "Ajouts facultatifs, tous désactivés par défaut : le jeu reste vanilla tant que vous n'en activez pas un.",
"horizon.pages.agent.tooltip": "Laissez votre propre agent IA jouer : horizon-ctl et les clients MCP sur cet ordinateur.",
"horizon.pages.split_profiles.tooltip": "Qui joue : un profil conservé dans ce dossier de jeu.",
"horizon.pages.shader_packs.tooltip": "Packs de shaders du dossier shaderpacks : ceux d'Horizon et ceux au format shaders/. Un seul fonctionne à la fois.",
"horizon.pages.mods.tooltip": "Mods conçus pour Horizon, du dossier horizon-mods. Chacun s'exécute dans un bac à sable et reste désactivé tant que vous ne l'activez pas.",
"horizon.pages.open.graphics": "Graphismes...",
"horizon.pages.open.graphics_advanced": "Graphismes avancés...",
"horizon.pages.open.shader_packs": "Packs de shaders...",
"horizon.pages.open.controller": "Options de la manette...",
"horizon.pages.open.extras": "Extras...",
"horizon.pages.open.mods": "Mods...",
"horizon.pages.open.agent": "Agent IA...",
"horizon.pages.open.horizon_graphics": "Graphismes Horizon...",
"horizon.pages.open.controller_binds": "Boutons manette...",
"horizon.pages.open.horizon_settings": "Options Horizon...",
"horizon.mods.empty": "Aucun mod : placez des fichiers .hmod dans le dossier horizon-mods",
"horizon.mods.stopped": " (arrêté)",
"horizon.mods.not_built": " (non compilé)",
"horizon.mods.dev": " (dév.)",
"horizon.mods.settings": "Options",
"horizon.mods.open_folder": "Ouvrir le dossier des mods",
"horizon.shader_packs.empty": "Aucun pack de shaders : placez-les dans le dossier shaderpacks",
"horizon.shader_packs.kind_horizon": " (Horizon)",
"horizon.shader_options.open": "Options des shaders...",
"horizon.shader_options.profile": "Profil",
"horizon.shader_options.loading": "Lecture des options du pack de shaders...",
"horizon.agent.copy_details": "Copier les détails de connexion",
"horizon.multiplayer.join": "Rejoindre",
"horizon.multiplayer.cancel_reconnecting": "Annuler la reconnexion",
"horizon.multiplayer.join_friend": "Rejoindre un ami",
"horizon.multiplayer.invite_code": "Code d'invitation",
"horizon.multiplayer.relay": "Relais (facultatif)",
"Preset": "Préréglage",
"LOD Distance": "Distance LOD",
"Far Detail": "Détails lointains",
"Cave Culling": "Occlusion des grottes",
"Entity Culling": "Occlusion des entités",
"Chunk Updates": "MàJ chunks",
"Upload Budget": "Transfert",
"Sky": "Ciel",
"Fog": "Brouillard",
"Weather": "Météo",
"Animated Textures": "Textures animées",
"Present Mode": "Présentation",
"Frame Pacing": "Rythme des images",
"Worker Threads": "Threads",
"Resolution Scale": "Échelle de résolution",
"Anti-aliasing": "Anticrénelage",
"Upscaling": "Upscaling",
"HDR Settings...": "Options HDR...",
"Paper White": "Point blanc",
"Peak Brightness": "Luminosité max.",
"Black Level": "Niveau de noir",
"UI Brightness": "Luminosité UI",
"Battery Mode": "Mode batterie",
"Graphics API": "API graphique",
"Performance Overlay": "Infos de perfs",
"Full Detail Distance": "Dist. détail",
"On": "Oui",
"Off": "Non",
"Unlimited": "Illimité",
"Auto": "Auto",
"Custom": "Personnalisé",
"Performance": "Performances",
"Balanced": "Équilibré",
"Quality": "Qualité",
"Ultra": "Ultra",
"Ultra Performance": "Ultra perfs",
"Native": "Native",
"Full": "Complet",
"Reduced": "Réduit",
"Efficient": "Économe",
"Precise": "Précis",
"Auto (VSync)": "Auto (VSync)",
"FIFO": "FIFO",
"Mailbox": "Mailbox",
"Immediate": "Immediate",
"Vulkan": "Vulkan",
"DirectX 12": "DirectX 12",
"OpenGL": "OpenGL",
"FPS and Graph": "FPS et graphique",
"FPS": "FPS",
"TAA": "TAA",
"FXAA": "FXAA",
"No cost": "Aucun coût",
"Low cost": "Coût faible",
"Medium cost": "Coût moyen",
"High cost": "Coût élevé",
"Saves time": "Fait gagner du temps",
"Rendering": "Rendu",
"Display": "Affichage",
"Preferences": "Préférences",
"Overlay": "Surimpression",
"HDR": "HDR",
"Graphics": "Graphismes",
"Graphics: Advanced": "Graphismes : avancés",
"Graphics: HDR": "Graphismes : HDR",
"Advanced...": "Avancé...",
"Simple...": "Simple...",
"Done": "Terminé",
"This feature is not yet available in Horizon.": "Cette fonction n'est pas encore disponible dans Horizon.",
"Your display or system doesn't offer HDR output.": "Votre écran ou votre système ne propose pas de sortie HDR.",
"Turn HDR on to use it.": "Activez le HDR pour l'utiliser.",
"%s chunks": "%s chunks",
"%s per frame": "%s par image",
"%s MiB/frame": "%s MiB/image",
"%s KiB/frame": "%s KiB/image",
"%s nits": "%s nits",
"Auto (%s)": "Auto (%s)",
"Auto (%s nits)": "Auto (%s nits)",
"%.2f nits": "%.2f nits",
"Full-detail terrain range. LOD Distance can extend local worlds farther.": "Terrain en détail complet. La distance LOD va plus loin.",
"Coarse distant terrain in local worlds. Integrated GPUs stop at 128 chunks.": "Terrain lointain simplifié, mondes locaux. GPU intégrés : 128 max.",
"Reduced draws far terrain coarser: faster on this integrated GPU.": "Réduit : terrain lointain simplifié, plus rapide sur ce GPU.",
"Reduced draws far terrain coarser, for GPUs it slows down.": "Réduit : terrain simplifié, pour les GPU qui ralentissent.",
"Field of view. Wider shows more terrain at once.": "Champ de vision. Plus large, il montre plus de terrain à la fois.",
"Caps frames per second; a cap saves power and heat.": "Limite les images par seconde ; économise énergie et chaleur.",
"Waits for the display's refresh: no tearing, a little latency.": "Attend l'écran : pas de déchirure, un peu de latence.",
"Size of menus and the HUD.": "Taille des menus et de l'interface (HUD).",
"Sets the options below together. Changing one makes it Custom.": "Règle les options ci-dessous. Modifier une : « Personnalisé ».",
"Lava, fire and sunlight shine brighter than white. Needs an HDR display.": "Lave, feu et soleil plus vifs que le blanc. Écran HDR requis.",
"Your display's darkest black. 0 for OLED; LCDs glow a little.": "Noir le plus profond de l'écran. 0 pour OLED, les LCD brillent.",
"How bright highlights get. Auto: the peak your system reports.": "La luminosité des hautes lumières. Auto : le pic indiqué par votre système.",
"How bright white paper and menus look. Auto: your desktop's white.": "Luminosité du blanc et des menus. Auto : blanc du bureau.",
"How bright the HUD and menus are, against paper white.": "La luminosité du HUD et des menus, par rapport au blanc papier.",
"How far mobs, crops and machines keep working. CPU time.": "Portée d'activité des créatures, cultures, machines. Coût : CPU.",
"How far away mobs and items are drawn, as a share of normal.": "Distance des créatures et objets, en part de la normale.",
"Skips terrain hidden behind closed sections. Off costs a lot.": "Ignore le terrain caché. Désactiver coûte cher.",
"Hides models behind opaque blocks. Entities update. Off with custom packs.": "Masque les modèles derrière les blocs. Éteindre avec packs.",
"Meshed sections uploaded per frame. Lower is smoother while loading.": "Sections maillées par image. Moins : chargement plus fluide.",
"Soft shadows in corners. Costs chunk building time only.": "Ombres douces dans les coins. Coût : construction des chunks.",
"Smaller copies of block textures for distant terrain: less shimmer.": "Textures réduites au loin : moins de scintillement.",
"Blends grass and water colours across biomes when building chunks.": "Fond les couleurs d'herbe et d'eau entre les biomes.",
"Cloud layer: off, flat (fast) or 3D (fancy).": "Couche de nuages : désactivée, plate (rapide) ou 3D (détaillée).",
"How far clouds reach, in chunks.": "Jusqu'où s'étendent les nuages, en chunks.",
"Draws the sky, sun, moon and stars.": "Dessine le ciel, le soleil, la lune et les étoiles.",
"Distance fog. Off shows the edge of the loaded world.": "Brouillard lointain. Désactivé, il montre le bord du monde chargé.",
"Draws rain and snow.": "Dessine la pluie et la neige.",
"How far rain and snow fall around you.": "Jusqu'où la pluie et la neige tombent autour de vous.",
"How many particles effects spawn.": "Combien de particules les effets génèrent.",
"Round shadows under mobs and items.": "Ombres rondes sous les créatures et les objets.",
"Leaves with see-through gaps. Off draws them solid.": "Feuilles avec des trous transparents. Désactivé, elles sont pleines.",
"Sorts water and glass more exactly behind each other.": "Trie plus précisément l'eau et le verre les uns derrière les autres.",
"Which chunk rebuilds wait for the frame: none, nearby or yours.": "Quels chunks attendent l'image : aucun, proches ou les vôtres.",
"Animates water, lava, fire and other moving textures.": "Anime l'eau, la lave, le feu et les autres textures mobiles.",
"World pixels per screen pixel. Below 100% is faster on the GPU.": "Pixels du monde par pixel d'écran. Sous 100 %, GPU plus rapide.",
"Smooths jagged edges. FXAA: softer. TAA: smoothest, moving things may trail.": "Lisse les bords. FXAA : doux. TAA : lisse, traînées possibles.",
"Renders the world smaller and rebuilds it with TAA. Faster, softer.": "Rendu réduit, reconstruit par TAA. Plus rapide, plus doux.",
"HDR output on an HDR display: lava, fire and sunlight shine brighter.": "Sortie HDR sur écran HDR : lave, feu et soleil plus vifs.",
"How frames reach the screen while VSync is off.": "Comment les images arrivent à l'écran sans synchro verticale.",
"Efficient sleeps until the next frame; Precise spins, like vanilla.": "Économe attend l'image suivante ; Précis tourne en boucle.",
"Fills the screen (borderless).": "Remplit l'écran (sans bordure).",
"Brightens dark places.": "Éclaircit les endroits sombres.",
"Lowers the frame rate when minimised, or idle too.": "Réduit les FPS si la fenêtre est réduite, ou aussi inactive.",
"Sways the view while walking.": "Fait osciller la vue pendant la marche.",
"Fades newly loaded chunks in.": "Fait apparaître en fondu les chunks nouvellement chargés.",
"Darkens the screen's edges.": "Assombrit les bords de l'écran.",
"Shows when the world saves.": "Indique quand le monde est sauvegardé.",
"Shows attack cooldown.": "Affiche le délai de recharge de l'attaque.",
"60 FPS cap (30 in menus or unfocused), half the workers: less power.": "60 FPS max (30 en menu/sans focus), moitié de threads : économie.",
"Threads that generate, light and mesh chunks. Next start.": "Threads de génération, lumière et maillage. Au redémarrage.",
"Vulkan, then DirectX 12 (Windows) or OpenGL (Linux) where it fails. Next start.": "Vulkan, sinon DirectX 12 (Windows) ou OpenGL (Linux). Au redémarrage.",
"Mesh data sent to the GPU per frame. Lower is smoother while loading.": "Données de maillage vers le GPU par image. Moins : plus fluide.",
"Frame rate and frame times over the game, with a graph.": "Fréquence et temps d'image par-dessus le jeu, avec un graphique.",
"horizon.multiplayer.finding_friend": "Recherche du monde de votre ami...",
"horizon.loading.opening_world": "Ouverture du monde",
"horizon.loading.cancelling_world": "Annulation en cours",
"horizon.title.not_affiliated": "Non affilié à Mojang ou à Microsoft",
"horizon.extras.header.lighting": "Éclairage",
"horizon.extras.header.third_person": "Troisième personne",
"horizon.extras.header.inventory": "Inventaire",
"horizon.extras.header.multiplayer": "Multijoueur",
"horizon.extras.fullbright": "Pleine luminosité",
"horizon.extras.dynamic_lights": "Lumières dynamiques",
"horizon.extras.own_name_tag": "Mon pseudo",
"horizon.extras.auto_reconnect": "Reconnexion auto",
"horizon.extras.mouse_tweaks": "Mouse Tweaks",
"horizon.extras.mouse_tweaks_wheel": "Déplacer à la molette",
"horizon.extras.sort": "Boutons de tri",
"horizon.extras.sort_key": "Touche de tri",
"horizon.extras.no_ai": "Sans IA",
"horizon.extras.agent_control": "Contrôle par agent",
"horizon.extras.discord_presence": "Discord Rich Presence",
"horizon.extras.fullbright.tooltip": "Affiche les blocs et les entités en pleine lumière, même dans les zones sombres.",
"horizon.extras.dynamic_lights.tooltip": "Les torches et autres sources de lumière tenues en main, jetées ou en feu éclairent les blocs autour d'elles.",
"horizon.extras.own_name_tag.tooltip": "Affiche votre nom au-dessus de votre tête à la troisième personne, comme les autres joueurs le voient. Masqué quand le serveur masque les noms de votre équipe ou que vous êtes invisible.",
"horizon.extras.auto_reconnect.tooltip": "Rejoint de nouveau un serveur après une perte de connexion ou un redémarrage, jamais après une expulsion ou un bannissement.",
"horizon.extras.mouse_tweaks.tooltip": "Glisser avec le clic droit en place un par case, glisser avec le clic gauche rassemble l'objet tenu, Maj + glisser avec le clic gauche déplace chaque case.",
"horizon.extras.mouse_tweaks_wheel.tooltip": "Vers le bas, envoie un objet de la case de l'autre côté ; vers le haut, en ramène un.",
"horizon.extras.sort.tooltip": "Trie l'inventaire, les coffres, les tonneaux, les coffres de l'Ender et les boîtes de Shulker selon l'ordre des onglets du mode créatif, en fusionnant les piles.",
"horizon.extras.sort_key.tooltip": "Trie la zone sous le curseur. Cliquez, puis appuyez sur une touche ; Échap retire l'assignation.",
"horizon.extras.no_ai.tooltip": "Masque et désactive toutes les fonctions d'IA : contrôle par agent, horizon-ctl et les serveurs MCP. Le commutateur du lanceur aussi.",
"horizon.extras.agent_control.tooltip": "Votre propre agent IA peut jouer à ce jeu via horizon-ctl ou un client MCP, uniquement sur cet ordinateur.",
"horizon.extras.discord_presence.tooltip": "Affiche « Joue à Horizon » sur votre profil Discord tant que Discord fonctionne : menus, solo ou multijoueur, la dimension et le temps de jeu. Jamais le nom d'un monde ni l'adresse d'un serveur.",
"Calibrate...": "Calibrer...",
"Calibrate Anyway...": "Calibrer quand même...",
"Use Automatic Setup": "Utiliser la configuration automatique",
"Shortcut: %s": "Raccourci : %s",
"Next": "Suivant",
"HDR Calibration %s/%s: %s": "Calibrage HDR %s/%s : %s",
"Sets the three below full screen, one test picture at a time.": "Règle les trois valeurs ci-dessous en plein écran, une image test à la fois.",
"A key that opens this screen from the game. Not bound at first.": "Une touche qui ouvre cet écran depuis le jeu. Non assignée au départ.",
"Your display didn't say how bright it gets: calibrate to set it up.": "Votre écran n'a pas indiqué sa luminosité : calibrez-le.",
"Already set up for your display. Only calibrate if HDR looks wrong.": "Déjà configuré. Calibrez seulement si le HDR semble incorrect.",
"Puts Peak Brightness and Paper White back to your display's setup.": "Rétablit Luminosité max. et Point blanc de votre écran.",
"Set up for your display. Leave on Auto unless it looks wrong.": "Configuré pour votre écran. Laissez Auto si tout va bien.",
"Lower it until the logo just disappears into the black.": "Baissez-le jusqu'à ce que le logo disparaisse tout juste dans le noir.",
"Raise it until the logo just disappears into the bright square.": "Augmentez jusqu'à ce que le logo se fonde dans le carré clair.",
"Make the card a white that's comfortable to look at for a long time, in the light you play in.": "Un blanc agréable à regarder longtemps, selon votre éclairage.",
"horizon.splitscreen.profiles.forget": "Oublier",
"horizon.splitscreen.profiles.create": "Créer",
"horizon.splitscreen.profiles.you": " - vous",
"horizon.splitscreen.profiles.forget_named": "Oublier %s",
"horizon.splitscreen.profiles.previous": "Précédent",
"horizon.splitscreen.profiles.next": "Suivant",
"horizon.splitscreen.profiles.new_button": "Nouveau profil...",
"horizon.splitscreen.profiles.forget_title": "Oublier le profil ?",
"horizon.splitscreen.profiles.forget_leaves": "Il quitte la liste de cette instance.",
"horizon.splitscreen.profiles.forget_kept": "La progression des mondes et les options sont conservées.",
"horizon.splitscreen.profiles.new_title": "Nouveau profil",
"horizon.splitscreen.profiles.name_hint": "Nom (3 à 16 lettres, chiffres ou _)",
"horizon.splitscreen.profiles.switch_title": "Changer de profil",
"horizon.splitscreen.profiles.offline_note": "Les profils hors ligne appartiennent à cette instance",
"horizon.splitscreen.profiles.move": "Changer d'emplacement de jeu",
"horizon.splitscreen.profiles.move_place": "Changer d'emplacement de jeu : %s",
"horizon.friends.title": "Jouer avec des amis",
"horizon.friends.hint": "Vos amis collent le code après le bouton Amis de l'écran titre, dans « Connexion rapide » ou sur la page Jouer du lanceur.",
"horizon.friends.copied": "Copié",
"horizon.friends.copy_code": "Copier le code",
"horizon.friends.stop_sharing": "Arrêter le partage",
"horizon.friends.none_yet": "Aucun ami pour l'instant.",
"horizon.friends.here_now": "Présents : %s",
"horizon.friends.share_intro": "Partagez ce monde avec un code d'invitation : il s'ouvre au LAN, et vos amis sur internet le rejoignent avec le code. Aucun serveur de notre part n'est impliqué.",
"horizon.friends.start_sharing": "Commencer le partage",
"horizon.friends.troubleshooting": "Dépannage...",
"horizon.friends.stopped": "Le partage du code d'invitation est arrêté.",
"horizon.friends.short_code_changed": "Quelqu'un a essayé plusieurs fois un mauvais code court, il a donc été changé.",
"horizon.friends.wrong_code": "Quelqu'un a essayé un mauvais code.",
"horizon.friends.code_ready": "Votre code d'invitation est prêt : Menu du jeu, Jouer avec des amis. Copiez-le et envoyez-le à vos amis.",
"horizon.friends.share_failed": "Impossible de partager le monde : %s",
"horizon.friends.finding_route": "Recherche de la manière dont vos amis peuvent vous joindre…",
"horizon.osk.shift": "Maj.",
"horizon.osk.space": "Espace",
"horizon.osk.delete": "Effacer",
"horizon.osk.type": "Saisir",
"horizon.osk.symbols": "Symboles",
"horizon.osk.close": "Fermer",
"horizon.multiplayer.not_signed_in": "Ce serveur est en mode en ligne. Connectez-vous avec votre compte Microsoft dans le lanceur Horizon pour le rejoindre.",
"horizon.multiplayer.invalid_address": "Adresse de serveur invalide : %s",
"horizon.multiplayer.connected_friend_direct": "Connecté directement à votre ami...",
"horizon.multiplayer.connected_friend_relay": "Connecté à votre ami via le relais..."
}
@@ -12,11 +12,282 @@
"horizon.options.button_glyphs": "Symbole przycisków",
"horizon.options.menu_navigation": "Nawigacja po menu",
"horizon.options.on_screen_keyboard": "Klawiatura ekranowa",
"horizon.options.chat_delay": "Opóźnienie czatu",
"horizon.options.lightbar.health": "Zdrowie",
"horizon.options.lightbar.dimension": "Wymiar",
"horizon.options.glyphs.auto": "Auto",
"horizon.options.glyphs.xbox": "Xbox",
"horizon.options.glyphs.playstation": "PlayStation",
"horizon.options.glyphs.nintendo": "Nintendo",
"horizon.options.menu_focus": "Fokus",
"horizon.options.menu_pointer": "Wskaźnik",
"horizon.options.keyboard.auto": "Auto",
"horizon.options.keyboard.horizon": "Horizon",
"horizon.options.keyboard.steam": "Steam",
"horizon.options.unavailable": "Ta funkcja nie jest jeszcze dostępna w Horizon.",
"horizon.options.search": "Szukaj ustawień…",
"horizon.pages.mods": "Mody",
"horizon.pages.shader_packs": "Paczki shaderów",
"horizon.pages.shader_options": "Ustawienia shaderów",
"horizon.pages.controller": "Ustawienia kontrolera",
"horizon.pages.controller_binds": "Przypisane przyciski kontrolera",
"horizon.pages.extras": "Dodatki Horizon",
"horizon.pages.settings": "Ustawienia Horizon",
"horizon.pages.agent": "Agent AI",
"horizon.pages.split_profiles": "Kto gra?",
"horizon.pages.settings.tooltip": "Własne ustawienia Horizon: grafika, kontrolery, dodatki i mody.",
"horizon.pages.graphics.tooltip": "Grafika w prostej formie: każde ustawienie podaje swój koszt.",
"horizon.pages.graphics_advanced.tooltip": "Wszystkie ustawienia grafiki, także te z Horizon, wraz z kosztem każdego z nich.",
"horizon.pages.controller.tooltip": "Kontrolery: ich przyciski, drążki, wibracje i nawigacja po menu.",
"horizon.pages.extras.tooltip": "Opcjonalne dodatki, wszystkie domyślnie wyłączone: gra pozostaje vanilla, dopóki nie włączysz któregoś.",
"horizon.pages.agent.tooltip": "Pozwól własnemu agentowi AI grać: horizon-ctl i klienci MCP na tym komputerze.",
"horizon.pages.split_profiles.tooltip": "Kto gra: profil przechowywany w tym folderze gry.",
"horizon.pages.shader_packs.tooltip": "Paczki shaderów z folderu shaderpacks: własne paczki Horizon i te w formacie shaders/. Naraz działa jedna.",
"horizon.pages.mods.tooltip": "Mody stworzone dla Horizon, z folderu horizon-mods. Każdy działa w piaskownicy i pozostaje wyłączony, dopóki go nie włączysz.",
"horizon.pages.open.graphics": "Grafika…",
"horizon.pages.open.graphics_advanced": "Zaawansowana grafika…",
"horizon.pages.open.shader_packs": "Paczki shaderów…",
"horizon.pages.open.controller": "Ustawienia kontrolera…",
"horizon.pages.open.extras": "Dodatki…",
"horizon.pages.open.mods": "Mody…",
"horizon.pages.open.agent": "Agent AI…",
"horizon.pages.open.horizon_graphics": "Grafika Horizon…",
"horizon.pages.open.controller_binds": "Przyciski kontrolera…",
"horizon.pages.open.horizon_settings": "Ustawienia Horizon…",
"horizon.mods.empty": "Brak modów: umieść pliki .hmod w folderze horizon-mods",
"horizon.mods.stopped": " (zatrzymany)",
"horizon.mods.not_built": " (niezbudowany)",
"horizon.mods.dev": " (dev)",
"horizon.mods.settings": "Ustawienia",
"horizon.mods.open_folder": "Otwórz folder z modami",
"horizon.shader_packs.empty": "Brak paczek shaderów: umieść je w folderze shaderpacks",
"horizon.shader_packs.kind_horizon": " (Horizon)",
"horizon.shader_options.open": "Ustawienia shaderów…",
"horizon.shader_options.profile": "Profil",
"horizon.shader_options.loading": "Wczytywanie ustawień paczki shaderów…",
"horizon.agent.copy_details": "Kopiuj dane połączenia",
"horizon.multiplayer.join": "Dołącz",
"horizon.multiplayer.cancel_reconnecting": "Anuluj ponowne łączenie",
"horizon.multiplayer.join_friend": "Dołącz do znajomego",
"horizon.multiplayer.invite_code": "Kod zaproszenia",
"horizon.multiplayer.relay": "Przekaźnik (opcjonalnie)",
"Preset": "Profil",
"LOD Distance": "Zasięg LOD",
"Far Detail": "Dalekie detale",
"Cave Culling": "Pomijanie jaskiń",
"Entity Culling": "Pomijanie bytów",
"Chunk Updates": "Aktual. chunków",
"Upload Budget": "Wysyłka",
"Sky": "Niebo",
"Fog": "Mgła",
"Weather": "Pogoda",
"Animated Textures": "Animowane tekstury",
"Present Mode": "Prezentacja",
"Frame Pacing": "Tempo klatek",
"Worker Threads": "Wątki robocze",
"Resolution Scale": "Skala rozdzielczości",
"Anti-aliasing": "Antyaliasing",
"Upscaling": "Skalowanie",
"HDR Settings...": "Ustawienia HDR…",
"Paper White": "Punkt bieli",
"Peak Brightness": "Jasność maks.",
"Black Level": "Poziom czerni",
"UI Brightness": "Jasność interfejsu",
"Battery Mode": "Tryb baterii",
"Graphics API": "API grafiki",
"Performance Overlay": "Nakładka wydajności",
"Full Detail Distance": "Zasięg detali",
"On": "Wł.",
"Off": "Wył.",
"Unlimited": "Bez limitu",
"Auto": "Auto",
"Custom": "Własne",
"Performance": "Wydajność",
"Balanced": "Zrównoważone",
"Quality": "Jakość",
"Ultra": "Ultra",
"Ultra Performance": "Ultra wydajność",
"Native": "Natywna",
"Full": "Pełne",
"Reduced": "Ograniczone",
"Efficient": "Oszczędne",
"Precise": "Dokładne",
"Auto (VSync)": "Auto (VSync)",
"FIFO": "FIFO",
"Mailbox": "Mailbox",
"Immediate": "Immediate",
"Vulkan": "Vulkan",
"DirectX 12": "DirectX 12",
"OpenGL": "OpenGL",
"FPS and Graph": "FPS i wykres",
"FPS": "FPS",
"TAA": "TAA",
"FXAA": "FXAA",
"No cost": "Bez kosztu",
"Low cost": "Niski koszt",
"Medium cost": "Średni koszt",
"High cost": "Wysoki koszt",
"Saves time": "Oszczędza czas",
"Rendering": "Renderowanie",
"Display": "Wyświetlanie",
"Preferences": "Preferencje",
"Overlay": "Nakładka",
"HDR": "HDR",
"Graphics": "Grafika",
"Graphics: Advanced": "Grafika: zaawansowane",
"Graphics: HDR": "Grafika: HDR",
"Advanced...": "Zaawansowane…",
"Simple...": "Proste…",
"Done": "Gotowe",
"This feature is not yet available in Horizon.": "Ta funkcja nie jest jeszcze dostępna w Horizon.",
"Your display or system doesn't offer HDR output.": "Twój wyświetlacz lub system nie obsługuje wyjścia HDR.",
"Turn HDR on to use it.": "Włącz HDR, aby tego użyć.",
"%s chunks": "%s chunków",
"%s per frame": "%s na klatkę",
"%s MiB/frame": "%s MiB/klatka",
"%s KiB/frame": "%s KiB/klatka",
"%s nits": "%s nitów",
"Auto (%s)": "Auto (%s)",
"Auto (%s nits)": "Auto (%s nitów)",
"%.2f nits": "%.2f nitów",
"Full-detail terrain range. LOD Distance can extend local worlds farther.": "Zasięg terenu w pełnych szczegółach. Zasięg LOD sięga dalej.",
"Coarse distant terrain in local worlds. Integrated GPUs stop at 128 chunks.": "Uproszczony odległy teren w światach lokalnych. Zintegr. GPU: do 128.",
"Reduced draws far terrain coarser: faster on this integrated GPU.": "Ograniczone: odległy teren rysowany prościej, szybciej na tym GPU.",
"Reduced draws far terrain coarser, for GPUs it slows down.": "Ograniczone: odległy teren prościej, dla GPU, które zwalniają.",
"Field of view. Wider shows more terrain at once.": "Pole widzenia. Szersze pokazuje naraz więcej terenu.",
"Caps frames per second; a cap saves power and heat.": "Ogranicza klatki na sekundę; limit oszczędza energię i ciepło.",
"Waits for the display's refresh: no tearing, a little latency.": "Czeka na odświeżenie ekranu: bez rozrywania obrazu, z niewielkim opóźnieniem.",
"Size of menus and the HUD.": "Rozmiar menu i interfejsu HUD.",
"Sets the options below together. Changing one makes it Custom.": "Ustawia razem poniższe opcje. Zmiana jednej zmienia na „Własne”.",
"Lava, fire and sunlight shine brighter than white. Needs an HDR display.": "Lawa, ogień i słońce świecą jaśniej niż biel. Wymaga ekranu HDR.",
"Your display's darkest black. 0 for OLED; LCDs glow a little.": "Najciemniejsza czerń twojego wyświetlacza. 0 dla OLED; LCD trochę się świecą.",
"How bright highlights get. Auto: the peak your system reports.": "Jak jasne mogą być światła. Auto: szczyt zgłaszany przez system.",
"How bright white paper and menus look. Auto: your desktop's white.": "Jak jasny wygląda biały papier i menu. Auto: biel twojego pulpitu.",
"How bright the HUD and menus are, against paper white.": "Jak jasne są HUD i menu w stosunku do bieli papieru.",
"How far mobs, crops and machines keep working. CPU time.": "Jak daleko stworzenia, uprawy i maszyny nadal działają. Czas procesora.",
"How far away mobs and items are drawn, as a share of normal.": "Odległość rysowania stworzeń i przedmiotów, jako ułamek normy.",
"Skips terrain hidden behind closed sections. Off costs a lot.": "Pomija teren ukryty za zamkniętymi sekcjami. Wyłączone dużo kosztuje.",
"Hides models behind opaque blocks. Entities update. Off with custom packs.": "Ukrywa modele za pełnymi blokami. Wył. przy własnych paczkach.",
"Meshed sections uploaded per frame. Lower is smoother while loading.": "Liczba sekcji siatki wysyłanych na klatkę. Mniej to płynniej podczas ładowania.",
"Soft shadows in corners. Costs chunk building time only.": "Miękkie cienie w narożnikach. Kosztuje tylko czas budowania chunków.",
"Smaller copies of block textures for distant terrain: less shimmer.": "Mniejsze kopie tekstur bloków dla odległego terenu: mniej migotania.",
"Blends grass and water colours across biomes when building chunks.": "Przenika kolory trawy i wody między biomami podczas budowania chunków.",
"Cloud layer: off, flat (fast) or 3D (fancy).": "Warstwa chmur: wyłączona, płaska (szybka) lub 3D (dokładna).",
"How far clouds reach, in chunks.": "Jak daleko sięgają chmury, w chunkach.",
"Draws the sky, sun, moon and stars.": "Rysuje niebo, słońce, księżyc i gwiazdy.",
"Distance fog. Off shows the edge of the loaded world.": "Mgła w oddali. Wyłączona pokazuje krawędź załadowanego świata.",
"Draws rain and snow.": "Rysuje deszcz i śnieg.",
"How far rain and snow fall around you.": "Jak daleko wokół ciebie pada deszcz i śnieg.",
"How many particles effects spawn.": "Ile cząstek tworzą efekty.",
"Round shadows under mobs and items.": "Okrągłe cienie pod stworzeniami i przedmiotami.",
"Leaves with see-through gaps. Off draws them solid.": "Liście z prześwitami. Wyłączone rysuje je jako pełne.",
"Sorts water and glass more exactly behind each other.": "Dokładniej sortuje wodę i szkło jedno za drugim.",
"Which chunk rebuilds wait for the frame: none, nearby or yours.": "Które przebudowy chunków czekają na klatkę: żadne, pobliskie lub twoje.",
"Animates water, lava, fire and other moving textures.": "Animuje wodę, lawę, ogień i inne ruchome tekstury.",
"World pixels per screen pixel. Below 100% is faster on the GPU.": "Piksele świata na piksel ekranu. Poniżej 100% GPU działa szybciej.",
"Smooths jagged edges. FXAA: softer. TAA: smoothest, moving things may trail.": "Wygładza krawędzie. FXAA: miękko. TAA: najgładziej, możliwe smugi.",
"Renders the world smaller and rebuilds it with TAA. Faster, softer.": "Renderuje mniejszy świat, odtwarza przez TAA. Szybciej, miękko.",
"HDR output on an HDR display: lava, fire and sunlight shine brighter.": "Wyjście HDR na ekranie HDR: lawa, ogień i słońce świecą jaśniej.",
"How frames reach the screen while VSync is off.": "Jak klatki trafiają na ekran, gdy VSync jest wyłączony.",
"Efficient sleeps until the next frame; Precise spins, like vanilla.": "Oszczędne usypia do następnej klatki; Dokładne czeka aktywnie.",
"Fills the screen (borderless).": "Wypełnia ekran (bez ramki).",
"Brightens dark places.": "Rozjaśnia ciemne miejsca.",
"Lowers the frame rate when minimised, or idle too.": "Obniża liczbę klatek po zminimalizowaniu, a także podczas bezczynności.",
"Sways the view while walking.": "Kołysze widokiem podczas chodzenia.",
"Fades newly loaded chunks in.": "Płynnie pokazuje nowo załadowane chunki.",
"Darkens the screen's edges.": "Przyciemnia krawędzie ekranu.",
"Shows when the world saves.": "Pokazuje, kiedy świat się zapisuje.",
"Shows attack cooldown.": "Pokazuje czas odnowienia ataku.",
"60 FPS cap (30 in menus or unfocused), half the workers: less power.": "Limit 60 FPS (30 w menu/bez fokusu), połowa wątków: mniej energii.",
"Threads that generate, light and mesh chunks. Next start.": "Wątki generujące, oświetlające i meshujące chunki. Po restarcie.",
"Vulkan, then DirectX 12 (Windows) or OpenGL (Linux) where it fails. Next start.": "Vulkan, awaryjnie DirectX 12 (Windows) / OpenGL (Linux). Po restarcie.",
"Mesh data sent to the GPU per frame. Lower is smoother while loading.": "Dane siatki wysyłane do GPU na klatkę. Mniej to płynniej podczas ładowania.",
"Frame rate and frame times over the game, with a graph.": "Liczba klatek i czasy klatek nad grą, z wykresem.",
"horizon.multiplayer.finding_friend": "Szukanie świata znajomego…",
"horizon.loading.opening_world": "Otwieranie świata",
"horizon.loading.cancelling_world": "Anulowanie",
"horizon.multiplayer.not_signed_in": "Ten serwer działa w trybie online. Zaloguj się kontem Microsoft w launcherze Horizon, aby dołączyć.",
"horizon.multiplayer.invalid_address": "Nieprawidłowy adres serwera: %s",
"horizon.multiplayer.connected_friend_direct": "Połączono bezpośrednio ze znajomym…",
"horizon.multiplayer.connected_friend_relay": "Połączono ze znajomym przez przekaźnik…"
"horizon.multiplayer.connected_friend_relay": "Połączono ze znajomym przez przekaźnik…",
"horizon.title.not_affiliated": "Niepowiązane z Mojang ani Microsoft",
"horizon.extras.header.lighting": "Oświetlenie",
"horizon.extras.header.third_person": "Widok z trzeciej osoby",
"horizon.extras.header.inventory": "Ekwipunek",
"horizon.extras.header.multiplayer": "Tryb wieloosobowy",
"horizon.extras.fullbright": "Pełna jasność",
"horizon.extras.dynamic_lights": "Dynamiczne światła",
"horizon.extras.own_name_tag": "Własny nick",
"horizon.extras.auto_reconnect": "Ponowne łączenie",
"horizon.extras.mouse_tweaks": "Mouse Tweaks",
"horizon.extras.mouse_tweaks_wheel": "Przenoszenie kółkiem",
"horizon.extras.sort": "Przyciski sortowania",
"horizon.extras.sort_key": "Klawisz sort.",
"horizon.extras.no_ai": "Bez AI",
"horizon.extras.agent_control": "Sterowanie agentem",
"horizon.extras.discord_presence": "Discord Rich Presence",
"horizon.extras.fullbright.tooltip": "Pokazuje bloki i byty w pełnym oświetleniu, nawet w ciemnych miejscach.",
"horizon.extras.dynamic_lights.tooltip": "Pochodnie i inne źródła światła trzymane w dłoni, upuszczone lub płonące oświetlają otaczające bloki.",
"horizon.extras.own_name_tag.tooltip": "Pokazuje twój nick nad głową w widoku z trzeciej osoby, tak jak widzą go inni gracze. Ukryty, gdy serwer ukrywa nazwy twojej drużyny lub jesteś niewidzialny.",
"horizon.extras.auto_reconnect.tooltip": "Ponownie dołącza do serwera po utracie połączenia lub restarcie, nigdy po wyrzuceniu lub banie.",
"horizon.extras.mouse_tweaks.tooltip": "Przeciąganie prawym przyciskiem układa po jednym na pole, lewym zbiera trzymany przedmiot, Shift + lewy przycisk przenosi każde pole.",
"horizon.extras.mouse_tweaks_wheel.tooltip": "Przewinięcie w dół wysyła jeden przedmiot z pola na drugą stronę, w górę przywraca jeden.",
"horizon.extras.sort.tooltip": "Sortuje ekwipunek, skrzynie, beczki, skrzynie Endu i shulkerowe skrzynie w kolejności kart trybu kreatywnego, łącząc stosy.",
"horizon.extras.sort_key.tooltip": "Sortuje część pod kursorem. Kliknij, a potem naciśnij klawisz; Escape usuwa przypisanie.",
"horizon.extras.no_ai.tooltip": "Ukrywa i wyłącza wszystkie funkcje AI: sterowanie agentem, horizon-ctl i serwery MCP. Także przełącznik w launcherze.",
"horizon.extras.agent_control.tooltip": "Twój własny agent AI może grać w tę grę przez horizon-ctl lub klienta MCP, tylko na tym komputerze.",
"horizon.extras.discord_presence.tooltip": "Pokazuje „Gra w Horizon” w twoim profilu Discord, gdy Discord jest uruchomiony: menu, tryb jednoosobowy lub wieloosobowy, wymiar i czas gry. Nigdy nazwy świata ani adresu serwera.",
"Calibrate...": "Kalibruj…",
"Calibrate Anyway...": "Kalibruj mimo to…",
"Use Automatic Setup": "Użyj automatycznej konfiguracji",
"Shortcut: %s": "Skrót: %s",
"Next": "Dalej",
"HDR Calibration %s/%s: %s": "Kalibracja HDR %s/%s: %s",
"Sets the three below full screen, one test picture at a time.": "Ustawia trzy wartości poniżej na pełnym ekranie, na obrazie testowym.",
"A key that opens this screen from the game. Not bound at first.": "Klawisz otwierający ten ekran z poziomu gry. Na początku nieprzypisany.",
"Your display didn't say how bright it gets: calibrate to set it up.": "Wyświetlacz nie podał jasności: skalibruj go, aby go skonfigurować.",
"Already set up for your display. Only calibrate if HDR looks wrong.": "Już skonfigurowane. Kalibruj tylko, gdy HDR wygląda źle.",
"Puts Peak Brightness and Paper White back to your display's setup.": "Przywraca Jasność maks. i Punkt bieli do ustawień wyświetlacza.",
"Set up for your display. Leave on Auto unless it looks wrong.": "Skonfigurowane dla twojego wyświetlacza. Zostaw Auto, chyba że wygląda źle.",
"Lower it until the logo just disappears into the black.": "Zmniejszaj, aż logo właśnie zniknie w czerni.",
"Raise it until the logo just disappears into the bright square.": "Zwiększaj, aż logo właśnie zniknie w jasnym kwadracie.",
"Make the card a white that's comfortable to look at for a long time, in the light you play in.": "Ustaw biel, na którą wygodnie patrzeć długo, w twoim oświetleniu.",
"horizon.splitscreen.profiles.forget": "Zapomnij",
"horizon.splitscreen.profiles.create": "Utwórz",
"horizon.splitscreen.profiles.you": " - ty",
"horizon.splitscreen.profiles.forget_named": "Zapomnij %s",
"horizon.splitscreen.profiles.previous": "Poprzednia",
"horizon.splitscreen.profiles.next": "Dalej",
"horizon.splitscreen.profiles.new_button": "Nowy profil…",
"horizon.splitscreen.profiles.forget_title": "Zapomnieć profil?",
"horizon.splitscreen.profiles.forget_leaves": "Znika z listy tej instancji.",
"horizon.splitscreen.profiles.forget_kept": "Postępy w świecie i ustawienia zostają zachowane.",
"horizon.splitscreen.profiles.new_title": "Nowy profil",
"horizon.splitscreen.profiles.name_hint": "Nazwa (3–16 liter, cyfr lub _)",
"horizon.splitscreen.profiles.switch_title": "Zmień profil",
"horizon.splitscreen.profiles.offline_note": "Profile offline należą do tej instancji",
"horizon.splitscreen.profiles.move": "Zmień miejsce gry",
"horizon.splitscreen.profiles.move_place": "Zmień miejsce gry: %s",
"horizon.friends.title": "Graj ze znajomymi",
"horizon.friends.hint": "Znajomi wklejają kod po kliknięciu przycisku Znajomi na ekranie tytułowym, w Połączeniu bezpośrednim lub na stronie Graj w launcherze.",
"horizon.friends.copied": "Skopiowano",
"horizon.friends.copy_code": "Kopiuj kod",
"horizon.friends.stop_sharing": "Przestań udostępniać",
"horizon.friends.none_yet": "Nie ma jeszcze żadnych znajomych.",
"horizon.friends.here_now": "Obecnie: %s",
"horizon.friends.share_intro": "Udostępnij ten świat za pomocą kodu zaproszenia: otwiera się w sieci LAN, a znajomi przez internet dołączają za pomocą kodu. Nie korzystamy z żadnego naszego serwera.",
"horizon.friends.start_sharing": "Zacznij udostępniać",
"horizon.friends.troubleshooting": "Rozwiązywanie problemów…",
"horizon.friends.stopped": "Przestano udostępniać kod zaproszenia.",
"horizon.friends.short_code_changed": "Ktoś wciąż wpisywał zły krótki kod, więc został zmieniony.",
"horizon.friends.wrong_code": "Ktoś wpisał zły kod.",
"horizon.friends.code_ready": "Twój kod zaproszenia jest gotowy: Menu gry, Graj ze znajomymi. Skopiuj go i wyślij znajomym.",
"horizon.friends.share_failed": "Nie udało się udostępnić świata: %s",
"horizon.friends.finding_route": "Sprawdzanie, jak znajomi mogą się z tobą połączyć…",
"horizon.osk.shift": "Shift",
"horizon.osk.space": "Spacja",
"horizon.osk.delete": "Usuń",
"horizon.osk.type": "Wpisz",
"horizon.osk.symbols": "Symbole",
"horizon.osk.close": "Zamknij"
}
@@ -202,5 +202,11 @@
"horizon.multiplayer.not_signed_in": "Этот сервер работает в онлайн-режиме. Войдите с помощью аккаунта Microsoft в лаунчере Horizon, чтобы присоединиться.",
"horizon.multiplayer.invalid_address": "Недействительный адрес сервера: %s",
"horizon.multiplayer.connected_friend_direct": "Установлено прямое соединение с другом…",
"horizon.multiplayer.connected_friend_relay": "Соединение с другом через релей…"
"horizon.multiplayer.connected_friend_relay": "Соединение с другом через релей…",
"horizon.pages.shader_options": "Настройки шейдеров",
"horizon.pages.split_profiles": "Кто играет?",
"horizon.pages.split_profiles.tooltip": "Кто играет: профиль, хранимый в этой папке игры.",
"horizon.shader_options.open": "Настройки шейдеров…",
"horizon.shader_options.profile": "Профиль",
"horizon.shader_options.loading": "Чтение настроек набора шейдеров…"
}
@@ -196,5 +196,11 @@
"Threads that generate, light and mesh chunks. Next start.": "Parçaları oluşturan, aydınlatan ve ağ verilerini hazırlayan iş parçacıklarının sayısı. Değişiklikler sonraki başlatmada uygulanır.",
"Vulkan, then DirectX 12 (Windows) or OpenGL (Linux) where it fails. Next start.": "Önce Vulkan kullanılır. Çalışmazsa Windows'ta DirectX 12, Linux'ta OpenGL denenir. Değişiklikler sonraki başlatmada uygulanır.",
"Mesh data sent to the GPU per frame. Lower is smoother while loading.": "Her karede GPU'ya gönderilen ağ verisi miktarı. Daha düşük değerler yükleme sırasında daha akıcı bir deneyim sağlar.",
"Frame rate and frame times over the game, with a graph.": "Oyun ekranında FPS değerini ve kare sürelerini grafikle birlikte gösterir."
"Frame rate and frame times over the game, with a graph.": "Oyun ekranında FPS değerini ve kare sürelerini grafikle birlikte gösterir.",
"horizon.pages.shader_options": "Gölgelendirici Seçenekleri",
"horizon.pages.split_profiles": "Kim oynuyor?",
"horizon.pages.split_profiles.tooltip": "Kim oynuyor: bu oyun klasöründe tutulan bir profil.",
"horizon.shader_options.open": "Gölgelendirici Seçenekleri…",
"horizon.shader_options.profile": "Profil",
"horizon.shader_options.loading": "Gölgelendirici paketinin seçenekleri okunuyor…"
}
@@ -12,11 +12,282 @@
"horizon.options.button_glyphs": "Значки кнопок",
"horizon.options.menu_navigation": "Навігація в меню",
"horizon.options.on_screen_keyboard": "Екранна клавіатура",
"horizon.options.chat_delay": "Затримка чату",
"horizon.options.lightbar.health": "Здоров’я",
"horizon.options.lightbar.dimension": "Вимір",
"horizon.options.glyphs.auto": "Авто",
"horizon.options.glyphs.xbox": "Xbox",
"horizon.options.glyphs.playstation": "PlayStation",
"horizon.options.glyphs.nintendo": "Nintendo",
"horizon.options.menu_focus": "Фокус",
"horizon.options.menu_pointer": "Курсор",
"horizon.options.keyboard.auto": "Авто",
"horizon.options.keyboard.horizon": "Horizon",
"horizon.options.keyboard.steam": "Steam",
"horizon.options.unavailable": "Ця функція ще недоступна в Horizon.",
"horizon.options.search": "Шукати налаштування…",
"horizon.pages.mods": "Моди",
"horizon.pages.shader_packs": "Пакети шейдерів",
"horizon.pages.shader_options": "Налаштування шейдерів",
"horizon.pages.controller": "Налаштування геймпада",
"horizon.pages.controller_binds": "Призначення кнопок геймпада",
"horizon.pages.extras": "Додатки Horizon",
"horizon.pages.settings": "Налаштування Horizon",
"horizon.pages.agent": "ШІ-агент",
"horizon.pages.split_profiles": "Хто грає?",
"horizon.pages.settings.tooltip": "Власні налаштування Horizon: графіка, геймпади, додатки та моди.",
"horizon.pages.graphics.tooltip": "Графіка просто: кожен параметр показує, чого він коштує.",
"horizon.pages.graphics_advanced.tooltip": "Усі параметри графіки, зокрема з Horizon, із зазначенням витрат кожного.",
"horizon.pages.controller.tooltip": "Геймпади: їхні кнопки, стіки, вібрація та навігація в меню.",
"horizon.pages.extras.tooltip": "Необов’язкові додатки, усі типово вимкнені: гра лишається ванільною, доки ви не ввімкнете якийсь.",
"horizon.pages.agent.tooltip": "Дозвольте власному ШІ-агентові грати: horizon-ctl і MCP-клієнти на цьому комп’ютері.",
"horizon.pages.split_profiles.tooltip": "Хто грає: профіль, що зберігається в цій теці гри.",
"horizon.pages.shader_packs.tooltip": "Пакети шейдерів із теки shaderpacks: власні пакети Horizon і пакети у форматі shaders/. Одночасно працює один.",
"horizon.pages.mods.tooltip": "Моди, створені для Horizon, із теки horizon-mods. Кожен працює в ізольованому середовищі й залишається вимкненим, доки ви його не ввімкнете.",
"horizon.pages.open.graphics": "Графіка…",
"horizon.pages.open.graphics_advanced": "Розширена графіка…",
"horizon.pages.open.shader_packs": "Пакети шейдерів…",
"horizon.pages.open.controller": "Налаштування геймпада…",
"horizon.pages.open.extras": "Додатки…",
"horizon.pages.open.mods": "Моди…",
"horizon.pages.open.agent": "ШІ-агент…",
"horizon.pages.open.horizon_graphics": "Графіка Horizon…",
"horizon.pages.open.controller_binds": "Кнопки геймпада…",
"horizon.pages.open.horizon_settings": "Налаштування Horizon…",
"horizon.mods.empty": "Немає модів: покладіть файли .hmod у теку horizon-mods",
"horizon.mods.stopped": " (зупинено)",
"horizon.mods.not_built": " (не зібрано)",
"horizon.mods.dev": " (розробка)",
"horizon.mods.settings": "Налаштування",
"horizon.mods.open_folder": "Відкрити теку з модами",
"horizon.shader_packs.empty": "Немає пакетів шейдерів: покладіть їх у теку shaderpacks",
"horizon.shader_packs.kind_horizon": " (Horizon)",
"horizon.shader_options.open": "Налаштування шейдерів…",
"horizon.shader_options.profile": "Профіль",
"horizon.shader_options.loading": "Читання налаштувань набору шейдерів…",
"horizon.agent.copy_details": "Копіювати дані з’єднання",
"horizon.multiplayer.join": "Приєднатися",
"horizon.multiplayer.cancel_reconnecting": "Скасувати перепідключення",
"horizon.multiplayer.join_friend": "Приєднатися до друга",
"horizon.multiplayer.invite_code": "Код запрошення",
"horizon.multiplayer.relay": "Ретранслятор (необов’язково)",
"Preset": "Шаблон",
"LOD Distance": "Дальність LOD",
"Far Detail": "Деталі вдалині",
"Cave Culling": "Відсікання печер",
"Entity Culling": "Відсік. сутностей",
"Chunk Updates": "Оновл. чанків",
"Upload Budget": "Вивантаження",
"Sky": "Небо",
"Fog": "Туман",
"Weather": "Погода",
"Animated Textures": "Анімовані текстури",
"Present Mode": "Виведення",
"Frame Pacing": "Темп кадрів",
"Worker Threads": "Робочі потоки",
"Resolution Scale": "Масштаб зображення",
"Anti-aliasing": "Згладжування",
"Upscaling": "Апскейлінг",
"HDR Settings...": "Налаштування HDR…",
"Paper White": "Біла точка",
"Peak Brightness": "Пік яскравості",
"Black Level": "Рівень чорного",
"UI Brightness": "Яскравість UI",
"Battery Mode": "Режим батареї",
"Graphics API": "API графіки",
"Performance Overlay": "Накладка швидкодії",
"Full Detail Distance": "Дальність деталей",
"On": "Увімк.",
"Off": "Вимк.",
"Unlimited": "Без обмежень",
"Auto": "Авто",
"Custom": "Власний",
"Performance": "Продуктивність",
"Balanced": "Збалансований",
"Quality": "Якість",
"Ultra": "Ультра",
"Ultra Performance": "Ультрапродукт.",
"Native": "Нативна",
"Full": "Повна",
"Reduced": "Знижена",
"Efficient": "Економний",
"Precise": "Точний",
"Auto (VSync)": "Авто (VSync)",
"FIFO": "FIFO",
"Mailbox": "Mailbox",
"Immediate": "Immediate",
"Vulkan": "Vulkan",
"DirectX 12": "DirectX 12",
"OpenGL": "OpenGL",
"FPS and Graph": "FPS і графік",
"FPS": "FPS",
"TAA": "TAA",
"FXAA": "FXAA",
"No cost": "Без витрат",
"Low cost": "Низькі витрати",
"Medium cost": "Середні витрати",
"High cost": "Високі витрати",
"Saves time": "Заощаджує час",
"Rendering": "Промальовування",
"Display": "Дисплей",
"Preferences": "Уподобання",
"Overlay": "Накладка",
"HDR": "HDR",
"Graphics": "Графіка",
"Graphics: Advanced": "Графіка: розширені",
"Graphics: HDR": "Графіка: HDR",
"Advanced...": "Розширені…",
"Simple...": "Прості…",
"Done": "Готово",
"This feature is not yet available in Horizon.": "Ця функція ще недоступна в Horizon.",
"Your display or system doesn't offer HDR output.": "Ваш дисплей або система не підтримує вивід HDR.",
"Turn HDR on to use it.": "Увімкніть HDR, щоб користуватися цим.",
"%s chunks": "%s чанків",
"%s per frame": "%s на кадр",
"%s MiB/frame": "%s MiB/кадр",
"%s KiB/frame": "%s KiB/кадр",
"%s nits": "%s ніт",
"Auto (%s)": "Авто (%s)",
"Auto (%s nits)": "Авто (%s ніт)",
"%.2f nits": "%.2f ніт",
"Full-detail terrain range. LOD Distance can extend local worlds farther.": "Дальність повних деталей. Дальність LOD сягає далі.",
"Coarse distant terrain in local worlds. Integrated GPUs stop at 128 chunks.": "Спрощена далека місцевість локальних світів. Інтегр. GPU: до 128.",
"Reduced draws far terrain coarser: faster on this integrated GPU.": "Знижена: далека місцевість простіша, швидше на цьому GPU.",
"Reduced draws far terrain coarser, for GPUs it slows down.": "Знижена: простіша далека місцевість для повільних GPU.",
"Field of view. Wider shows more terrain at once.": "Поле зору. Ширше показує більше місцевості одночасно.",
"Caps frames per second; a cap saves power and heat.": "Обмежує к/с; обмеження заощаджує енергію й зменшує нагрів.",
"Waits for the display's refresh: no tearing, a little latency.": "Чекає на оновлення дисплея: без розривів, невелика затримка.",
"Size of menus and the HUD.": "Розмір меню та HUD.",
"Sets the options below together. Changing one makes it Custom.": "Задає параметри нижче разом. Зміна одного дає «Власний».",
"Lava, fire and sunlight shine brighter than white. Needs an HDR display.": "Лава, вогонь і сонце світліші за білий. Потрібен HDR.",
"Your display's darkest black. 0 for OLED; LCDs glow a little.": "Найтемніший чорний вашого дисплея. 0 для OLED; LCD трохи світяться.",
"How bright highlights get. Auto: the peak your system reports.": "Наскільки яскравими бувають відблиски. Авто: пік, який повідомляє система.",
"How bright white paper and menus look. Auto: your desktop's white.": "Яскравість білого паперу та меню. Авто: білий вашого стола.",
"How bright the HUD and menus are, against paper white.": "Наскільки яскраві HUD і меню відносно білого паперу.",
"How far mobs, crops and machines keep working. CPU time.": "Як далеко працюють моби, посіви й механізми. Витрачає CPU.",
"How far away mobs and items are drawn, as a share of normal.": "Відстань малювання мобів і предметів, у частці від норми.",
"Skips terrain hidden behind closed sections. Off costs a lot.": "Пропускає приховану місцевість. Вимкнення коштує дорого.",
"Hides models behind opaque blocks. Entities update. Off with custom packs.": "Ховає моделі за блоками. Вимкніть із власними пакетами.",
"Meshed sections uploaded per frame. Lower is smoother while loading.": "Секцій сітки за кадр. Менше — плавніше під час завантаження.",
"Soft shadows in corners. Costs chunk building time only.": "М’які тіні в кутах. Витрачає лише час на побудову чанків.",
"Smaller copies of block textures for distant terrain: less shimmer.": "Менші копії текстур блоків для віддаленої місцевості: менше мерехтіння.",
"Blends grass and water colours across biomes when building chunks.": "Змішує кольори трави та води між біомами під час побудови чанків.",
"Cloud layer: off, flat (fast) or 3D (fancy).": "Шар хмар: вимкнено, пласкі (швидкі) або 3D (красиві).",
"How far clouds reach, in chunks.": "Як далеко сягають хмари, у чанках.",
"Draws the sky, sun, moon and stars.": "Малює небо, сонце, місяць і зірки.",
"Distance fog. Off shows the edge of the loaded world.": "Туман на відстані. Вимкнений показує межу завантаженого світу.",
"Draws rain and snow.": "Малює дощ і сніг.",
"How far rain and snow fall around you.": "Як далеко навколо вас падають дощ і сніг.",
"How many particles effects spawn.": "Скільки частинок створюють ефекти.",
"Round shadows under mobs and items.": "Круглі тіні під мобами та предметами.",
"Leaves with see-through gaps. Off draws them solid.": "Листя з просвітами. Вимкнено — малюється суцільним.",
"Sorts water and glass more exactly behind each other.": "Точніше сортує воду та скло одне за одним.",
"Which chunk rebuilds wait for the frame: none, nearby or yours.": "Які перебудови чанків чекають на кадр: жодні, найближчі чи ваші.",
"Animates water, lava, fire and other moving textures.": "Анімує воду, лаву, вогонь та інші рухомі текстури.",
"World pixels per screen pixel. Below 100% is faster on the GPU.": "Пікселі світу на піксель екрана. Нижче 100% — швидше для GPU.",
"Smooths jagged edges. FXAA: softer. TAA: smoothest, moving things may trail.": "Згладжує краї. FXAA: м’якше. TAA: найгладше, можливі шлейфи.",
"Renders the world smaller and rebuilds it with TAA. Faster, softer.": "Менший рендер, відновлення через TAA. Швидше, м’якше.",
"HDR output on an HDR display: lava, fire and sunlight shine brighter.": "Вивід HDR на HDR-дисплеї: лава, вогонь і сонце яскравіші.",
"How frames reach the screen while VSync is off.": "Як кадри потрапляють на екран, коли VSync вимкнено.",
"Efficient sleeps until the next frame; Precise spins, like vanilla.": "«Економний» спить до наступного кадру; «Точний» чекає активно.",
"Fills the screen (borderless).": "Заповнює екран (без рамки).",
"Brightens dark places.": "Освітлює темні місця.",
"Lowers the frame rate when minimised, or idle too.": "Знижує частоту кадрів при згортанні, а також при бездіяльності.",
"Sways the view while walking.": "Розхитує вид під час ходьби.",
"Fades newly loaded chunks in.": "Плавно показує щойно завантажені чанки.",
"Darkens the screen's edges.": "Затемнює краї екрана.",
"Shows when the world saves.": "Показує, коли світ зберігається.",
"Shows attack cooldown.": "Показує перезарядку атаки.",
"60 FPS cap (30 in menus or unfocused), half the workers: less power.": "60 FPS (30 у меню/без фокуса), половина потоків: економія.",
"Threads that generate, light and mesh chunks. Next start.": "Потоки генерації, світла й сітки чанків. Після перезапуску.",
"Vulkan, then DirectX 12 (Windows) or OpenGL (Linux) where it fails. Next start.": "Vulkan, інакше DirectX 12 (Windows)/OpenGL (Linux). Після рестарту.",
"Mesh data sent to the GPU per frame. Lower is smoother while loading.": "Дані сітки до GPU за кадр. Менше — плавніше завантаження.",
"Frame rate and frame times over the game, with a graph.": "Частота та час кадрів поверх гри, з графіком.",
"horizon.multiplayer.finding_friend": "Шукаємо світ друга…",
"horizon.loading.opening_world": "Відкриття світу",
"horizon.loading.cancelling_world": "Скасування",
"horizon.multiplayer.not_signed_in": "Цей сервер працює в онлайн-режимі. Увійдіть за допомогою акаунта Microsoft у лаунчері Horizon, щоб приєднатися.",
"horizon.multiplayer.invalid_address": "Недійсна адреса сервера: %s",
"horizon.multiplayer.connected_friend_direct": "Пряме з'єднання з другом встановлено…",
"horizon.multiplayer.connected_friend_relay": "З'єднання з другом через ретранслятор…"
"horizon.multiplayer.connected_friend_relay": "З'єднання з другом через ретранслятор…",
"horizon.title.not_affiliated": "Не пов’язано з Mojang чи Microsoft",
"horizon.extras.header.lighting": "Освітлення",
"horizon.extras.header.third_person": "Вигляд від третьої особи",
"horizon.extras.header.inventory": "Інвентар",
"horizon.extras.header.multiplayer": "Гра в мережі",
"horizon.extras.fullbright": "Повна яскравість",
"horizon.extras.dynamic_lights": "Динамічне світло",
"horizon.extras.own_name_tag": "Власний нік",
"horizon.extras.auto_reconnect": "Перепідключення",
"horizon.extras.mouse_tweaks": "Mouse Tweaks",
"horizon.extras.mouse_tweaks_wheel": "Перенос коліщатком",
"horizon.extras.sort": "Кнопки сортування",
"horizon.extras.sort_key": "Сортувати",
"horizon.extras.no_ai": "Без ШІ",
"horizon.extras.agent_control": "Керування агентом",
"horizon.extras.discord_presence": "Discord Rich Presence",
"horizon.extras.fullbright.tooltip": "Показує блоки та сутності при повному освітленні, навіть у темних місцях.",
"horizon.extras.dynamic_lights.tooltip": "Смолоскипи та інші джерела світла, які тримають у руках, викинуті чи палаючі, освітлюють блоки навколо.",
"horizon.extras.own_name_tag.tooltip": "Показує ваше ім’я над головою від третьої особи, як його бачать інші гравці. Приховано, коли сервер ховає імена вашої команди або ви невидимі.",
"horizon.extras.auto_reconnect.tooltip": "Знову приєднується до сервера після втрати з’єднання чи перезапуску, але ніколи після вигнання чи блокування.",
"horizon.extras.mouse_tweaks.tooltip": "Перетягування ПКМ кладе по одному в комірку, ЛКМ збирає утримуваний предмет, Shift + ЛКМ переміщує кожну комірку.",
"horizon.extras.mouse_tweaks_wheel.tooltip": "Прокрутка вниз надсилає один предмет із комірки на інший бік, вгору — повертає один назад.",
"horizon.extras.sort.tooltip": "Сортує інвентар, скрині, діжки, скрині Енду та шалкерові коробки в порядку вкладок творчого режиму, об’єднуючи стеки.",
"horizon.extras.sort_key.tooltip": "Сортує частину під курсором. Натисніть, а потім клавішу; Escape скасовує призначення.",
"horizon.extras.no_ai.tooltip": "Приховує та вимикає всі функції ШІ: керування агентом, horizon-ctl і MCP-сервери. Також перемикач у лаунчері.",
"horizon.extras.agent_control.tooltip": "Ваш власний ШІ-агент може грати в цю гру через horizon-ctl або MCP-клієнт, лише на цьому комп’ютері.",
"horizon.extras.discord_presence.tooltip": "Показує «Грає в Horizon» у вашому профілі Discord, поки Discord запущено: меню, одиночна чи мережева гра, вимір і час гри. Ніколи не назву світу чи адресу сервера.",
"Calibrate...": "Калібрувати…",
"Calibrate Anyway...": "Все одно калібрувати…",
"Use Automatic Setup": "Використати автоналаштування",
"Shortcut: %s": "Гаряча клавіша: %s",
"Next": "Далі",
"HDR Calibration %s/%s: %s": "Калібрування HDR %s/%s: %s",
"Sets the three below full screen, one test picture at a time.": "Три значення нижче налаштовуються на повному екрані.",
"A key that opens this screen from the game. Not bound at first.": "Клавіша, що відкриває цей екран із гри. Спершу не призначена.",
"Your display didn't say how bright it gets: calibrate to set it up.": "Дисплей не повідомив яскравість: відкалібруйте його.",
"Already set up for your display. Only calibrate if HDR looks wrong.": "Уже налаштовано. Калібруйте, лише якщо HDR виглядає погано.",
"Puts Peak Brightness and Paper White back to your display's setup.": "Повертає Пік яскравості й Білу точку до налаштувань дисплея.",
"Set up for your display. Leave on Auto unless it looks wrong.": "Налаштовано для дисплея. Залишайте «Авто», якщо все гаразд.",
"Lower it until the logo just disappears into the black.": "Зменшуйте, доки логотип не зникне в чорному.",
"Raise it until the logo just disappears into the bright square.": "Збільшуйте, доки логотип не зникне в яскравому квадраті.",
"Make the card a white that's comfortable to look at for a long time, in the light you play in.": "Білий, на який комфортно довго дивитися за вашого світла.",
"horizon.splitscreen.profiles.forget": "Забути",
"horizon.splitscreen.profiles.create": "Створити",
"horizon.splitscreen.profiles.you": " - ви",
"horizon.splitscreen.profiles.forget_named": "Забути %s",
"horizon.splitscreen.profiles.previous": "Назад",
"horizon.splitscreen.profiles.next": "Далі",
"horizon.splitscreen.profiles.new_button": "Новий профіль…",
"horizon.splitscreen.profiles.forget_title": "Забути профіль?",
"horizon.splitscreen.profiles.forget_leaves": "Він зникне зі списку цього екземпляра.",
"horizon.splitscreen.profiles.forget_kept": "Прогрес у світі та налаштування зберігаються.",
"horizon.splitscreen.profiles.new_title": "Новий профіль",
"horizon.splitscreen.profiles.name_hint": "Ім’я (3–16 літер, цифр або _)",
"horizon.splitscreen.profiles.switch_title": "Змінити профіль",
"horizon.splitscreen.profiles.offline_note": "Офлайн-профілі належать цьому екземпляру",
"horizon.splitscreen.profiles.move": "Змінити місце гри",
"horizon.splitscreen.profiles.move_place": "Змінити місце гри: %s",
"horizon.friends.title": "Грати з друзями",
"horizon.friends.hint": "Друзі вставляють код після кнопки «Друзі» на головному екрані, у «Пряме з’єднання» або на сторінці «Грати» в лаунчері.",
"horizon.friends.copied": "Скопійовано",
"horizon.friends.copy_code": "Копіювати код",
"horizon.friends.stop_sharing": "Припинити поширення",
"horizon.friends.none_yet": "Друзів тут поки немає.",
"horizon.friends.here_now": "Зараз тут: %s",
"horizon.friends.share_intro": "Поділіться цим світом за допомогою коду запрошення: він відкривається для LAN, а друзі з інтернету приєднуються за кодом. Жоден наш сервер не залучається.",
"horizon.friends.start_sharing": "Почати поширення",
"horizon.friends.troubleshooting": "Усунення несправностей…",
"horizon.friends.stopped": "Поширення коду запрошення припинено.",
"horizon.friends.short_code_changed": "Хтось раз за разом вводив неправильний короткий код, тож його змінено.",
"horizon.friends.wrong_code": "Хтось ввів неправильний код.",
"horizon.friends.code_ready": "Ваш код запрошення готовий: Ігрове меню, Грати з друзями. Скопіюйте його та надішліть друзям.",
"horizon.friends.share_failed": "Не вдалося поділитися світом: %s",
"horizon.friends.finding_route": "З’ясовуємо, як друзі можуть до вас під’єднатися…",
"horizon.osk.shift": "Shift",
"horizon.osk.space": "Пробіл",
"horizon.osk.delete": "Видалити",
"horizon.osk.type": "Ввести",
"horizon.osk.symbols": "Символи",
"horizon.osk.close": "Закрити"
}
@@ -203,5 +203,108 @@
"horizon.multiplayer.not_signed_in": "该服务器为在线模式服务器。请先在 Horizon 启动器中登录 Microsoft 账户再加入。",
"horizon.multiplayer.invalid_address": "无效的服务器地址:%s",
"horizon.multiplayer.connected_friend_direct": "已直连好友的世界…",
"horizon.multiplayer.connected_friend_relay": "已通过中继连接好友的世界…"
"horizon.multiplayer.connected_friend_relay": "已通过中继连接好友的世界…",
"horizon.pages.shader_options": "光影选项",
"horizon.pages.split_profiles": "谁在玩?",
"horizon.pages.split_profiles.tooltip": "谁在玩:保存在此游戏文件夹中的一份档案。",
"horizon.shader_options.open": "光影选项…",
"horizon.shader_options.profile": "档案",
"horizon.shader_options.loading": "正在读取光影包的选项…",
"horizon.pages.split_settings": "分屏设置",
"horizon.pages.split_setup": "分屏",
"horizon.pages.split_setup.tooltip": "在本机游玩的玩家:手柄可以加入你的世界。",
"horizon.pages.open.split_setup": "分屏…",
"horizon.title.not_affiliated": "与 Mojang 或 Microsoft 无关",
"horizon.extras.header.lighting": "光照",
"horizon.extras.header.third_person": "第三人称",
"horizon.extras.header.particles": "粒子",
"horizon.extras.header.inventory": "物品栏",
"horizon.extras.header.chat": "聊天",
"horizon.extras.header.multiplayer": "多人游戏",
"horizon.extras.header.sound": "声音",
"horizon.extras.fullbright": "全亮",
"horizon.extras.dynamic_lights": "动态光源",
"horizon.extras.own_name_tag": "自己的名字标签",
"horizon.extras.hide_block_particles": "隐藏方块破坏粒子",
"horizon.extras.auto_reconnect": "自动重连",
"horizon.extras.ui_sound_reserve": "为界面音效保留声道",
"horizon.extras.mouse_tweaks": "鼠标调整控制",
"horizon.extras.mouse_tweaks_wheel": "滚轮移动物品",
"horizon.extras.sort": "排序按钮",
"horizon.extras.sort_key": "排序快捷键",
"horizon.extras.no_ai": "关闭 AI",
"horizon.extras.agent_control": "代理控制",
"horizon.extras.discord_presence": "Discord 动态状态",
"horizon.extras.splitscreen": "分屏",
"horizon.extras.chat_animation": "平滑聊天",
"horizon.extras.chat_indicator": "消息指示条",
"horizon.extras.fullbright.tooltip": "即使在黑暗区域也以全亮显示方块和实体。",
"horizon.extras.dynamic_lights.tooltip": "手持、掉落或燃烧中的火把等光源会照亮周围方块。",
"horizon.extras.own_name_tag.tooltip": "在第三人称视角中把名字显示在头顶,与其它玩家看到的一致。当服务器隐藏团队名字或你处于隐身状态时不显示。",
"horizon.extras.hide_block_particles.tooltip": "隐藏方块被破坏时迸出的碎屑粒子,以及挖掘时掉落的碎屑。其它粒子仍会显示。",
"horizon.extras.auto_reconnect.tooltip": "连接中断或服务器重启后自动重新加入,被踢出或封禁后不会。",
"horizon.extras.ui_sound_reserve.tooltip": "世界中的声音会为界面音效留出 247 个声道中的 16 个,这样大量声音(比如一次大爆炸)不会把菜单点击音挤掉。",
"horizon.extras.mouse_tweaks.tooltip": "右键拖动每次放一个物品,左键拖动合并手持物品,Shift + 左键拖动移动所有槽位。",
"horizon.extras.mouse_tweaks_wheel.tooltip": "向下把槽位中的一个物品送到另一侧,向上取回一个。",
"horizon.extras.sort.tooltip": "按创造模式标签页顺序排序物品栏、箱子、木桶、末影箱和潜影盒,并合并堆叠。",
"horizon.extras.sort_key.tooltip": "排序光标下的部分。点击后按一个键;按 Escape 取消绑定。",
"horizon.extras.no_ai.tooltip": "隐藏并关闭所有 AI 功能:代理控制、horizon-ctl 和 MCP 服务器。启动器中的开关也一样。",
"horizon.extras.agent_control.tooltip": "你自己的 AI 代理可以通过 horizon-ctl 或 MCP 客户端玩这个游戏,仅限本机。",
"horizon.extras.discord_presence.tooltip": "Discord 运行时在你的 Discord 资料上显示“正在玩 Horizon”:菜单、单人、多人、维度以及游玩时长。绝不显示世界名称或服务器地址。",
"Calibrate...": "校准…",
"Calibrate Anyway...": "仍要校准…",
"Use Automatic Setup": "使用自动设置",
"Shortcut: %s": "快捷键:%s",
"Next": "下一步",
"HDR Calibration %s/%s: %s": "HDR 校准 %s/%s:%s",
"Sets the three below full screen, one test picture at a time.": "以全屏方式逐张显示测试图,来设置下面三项。",
"A key that opens this screen from the game. Not bound at first.": "在游戏中打开此界面的按键。初始未绑定。",
"Your display didn't say how bright it gets: calibrate to set it up.": "显示器没有报告自身亮度:需要通过校准来设置。",
"Already set up for your display. Only calibrate if HDR looks wrong.": "已按你的显示器设置好。只有在 HDR 显示异常时才需要校准。",
"Puts Peak Brightness and Paper White back to your display's setup.": "将峰值亮度和纸白亮度恢复为你的显示器设置。",
"Set up for your display. Leave on Auto unless it looks wrong.": "已按你的显示器设置。除非显示异常,否则保持“自动”。",
"Lower it until the logo just disappears into the black.": "调低,直到标志刚好没入黑色背景。",
"Raise it until the logo just disappears into the bright square.": "调高,直到标志刚好没入亮色方块。",
"Make the card a white that's comfortable to look at for a long time, in the light you play in.": "在你游玩时的光照环境下,把卡片调成长时间观看也舒适的白色。",
"horizon.splitscreen.profiles.forget": "忘记",
"horizon.splitscreen.profiles.create": "创建",
"horizon.splitscreen.profiles.you": " - 你",
"horizon.splitscreen.profiles.forget_named": "忘记 %s",
"horizon.splitscreen.profiles.previous": "上一个",
"horizon.splitscreen.profiles.next": "下一个",
"horizon.splitscreen.profiles.new_button": "新建档案…",
"horizon.splitscreen.profiles.forget_title": "忘记档案?",
"horizon.splitscreen.profiles.forget_leaves": "该档案将从此实例的列表中移除。",
"horizon.splitscreen.profiles.forget_kept": "世界进度和设置会保留。",
"horizon.splitscreen.profiles.new_title": "新建档案",
"horizon.splitscreen.profiles.name_hint": "名称(3 到 16 个字母、数字或 _)",
"horizon.splitscreen.profiles.switch_title": "切换档案",
"horizon.splitscreen.profiles.offline_note": "离线档案属于此实例",
"horizon.splitscreen.profiles.move": "移动游玩位置",
"horizon.splitscreen.profiles.move_place": "把游玩位置移到:%s",
"horizon.friends.title": "与好友一起玩",
"horizon.friends.hint": "好友可以在标题画面的“好友”按钮后、直连界面或启动器的“开始游戏”页粘贴该代码。",
"horizon.friends.copied": "已复制",
"horizon.friends.copy_code": "复制代码",
"horizon.friends.stop_sharing": "停止共享",
"horizon.friends.none_yet": "还没有好友加入。",
"horizon.friends.here_now": "当前在线:%s",
"horizon.friends.share_intro": "通过邀请码共享这个世界:它会开放到局域网,互联网上的好友可以用代码加入。不经过我们的任何服务器。",
"horizon.friends.start_sharing": "开始共享",
"horizon.friends.troubleshooting": "疑难解答…",
"horizon.friends.stopped": "已停止共享邀请码。",
"horizon.friends.short_code_changed": "有人反复尝试错误的短代码,因此已更换。",
"horizon.friends.wrong_code": "有人尝试了错误的代码。",
"horizon.friends.code_ready": "你的邀请码已就绪:游戏菜单 → 与好友一起玩。复制后发给好友。",
"horizon.friends.share_failed": "无法共享世界:%s",
"horizon.friends.finding_route": "正在寻找好友连接你的方式…",
"horizon.osk.shift": "Shift",
"horizon.osk.space": "空格",
"horizon.osk.delete": "删除",
"horizon.osk.type": "输入",
"horizon.osk.symbols": "符号",
"horizon.osk.close": "关闭",
"horizon.extras.chat_animation.tooltip": "新聊天行从左侧滑入并淡入,而不是整行直接出现。",
"horizon.extras.chat_indicator.tooltip": "聊天行左侧的彩色竖条。",
"horizon.extras.splitscreen.tooltip": "在世界中,另一个手柄可以以新玩家身份加入:提示时按 A,然后选择档案和游玩位置。"
}
@@ -12,11 +12,13 @@
//! this thread's CPU time in `Mesher::mesh`, the best of `--rounds`, so other
//! work on the machine changes it less. The checksum of the meshes shows that an
//! A/B pair of builds made the same ones.
extern crate horizon_client_lib as horizon_client;
use horizon_client::{
assets::Assets,
biome::Biomes,
blocks::BlockFacts,
chunk::HEIGHT,
graphics::LeafCullingMode,
light::LightTables,
mesher::{MeshTables, Mesher, Region, borrow},
models,
@@ -139,6 +141,8 @@ fn main() -> Result<(), Error> {
blend_radius: 2,
smooth_lighting: true,
cutout_leaves: true,
translucent_layers: &[],
leaf_culling: LeafCullingMode::Off,
assets: &context.assets,
render: &context.render,
tables: &context.tables,
@@ -1,6 +1,7 @@
//! Profile the two mandatory passes over gameplay-facts.json independently.
//! Build with `cargo build --release -p horizon-client --example gameplay_decode_profile`.
//! Each invocation runs one phase in a separate process for CPU/RSS measurements.
extern crate horizon_client_lib as horizon_client;
#[allow(dead_code)]
#[path = "../../horizon-items/src/blocks/decode.rs"]
mod decode;
@@ -1,5 +1,6 @@
//! Compare the read + Value parse stages in BlockFacts::load and Assets::load.
//! Usage: json_load_bench RESOURCE_ROOT [REPETITIONS]; see tools/json_load_fixtures.py.
extern crate horizon_client_lib as horizon_client;
use serde_json::Value;
use std::{
fs::{self, File},
@@ -7,6 +7,7 @@
//!
//! WORK is a `horizon-port-pack` work folder: `oracle/facts.json` and
//! `mods/<id>/carry/`. The root is built in `WORK/horizon-root`.
extern crate horizon_client_lib as horizon_client;
use horizon_client::{
Result,
assets::Assets,
@@ -2,6 +2,7 @@
//! for live requested heap bytes; leave it unset for latency measurements.
//! cargo run --profile fast -p horizon-client --example model_library_bench -- ROOT [--parity]
//! Run fresh processes, warm up twice, then compare multiple alternating baseline/candidate runs.
extern crate horizon_client_lib as horizon_client;
use horizon_client::{assets::Assets, item_render::ItemRenderer};
use std::{
alloc::{GlobalAlloc, Layout, System},
@@ -1,5 +1,6 @@
//! Compare the old two-parse loading path with catalog construction from shared facts.
//! Run: cargo run -p horizon-client --profile fast --example model_states_bench -- 15
extern crate horizon_client_lib as horizon_client;
use horizon_client::{Result, models::StateModels, sprites::Sprites};
use horizon_core::position::BlockPos;
use horizon_world::{
@@ -5,6 +5,7 @@
//! generated around them blend with them (`Blender.of`).
//!
//! old_world_fixture <source world> <destination world> <x0,z0,x1,z1>
extern crate horizon_client_lib as horizon_client;
use horizon_core::position::ChunkPos;
use horizon_world::{
nbt::{Limits, Tag},
@@ -2,6 +2,7 @@
//! entity reaches, with the per-tick palette map against the resident cache.
//! Run with prepared client resources:
//! cargo run --profile fast -p horizon-client --example portal_scan_bench -- RESOURCES
extern crate horizon_client_lib as horizon_client;
use horizon_client::{
blocks::BlockFacts,
chunk::{Chunk, MIN_Y, SECTIONS, Section},
@@ -328,5 +328,5 @@ fn chat(c: &Value, h: i32, atlas: &HudAtlas) -> Vec<GuiDraw> {
if let Some(n) = c["scroll"].as_i64() {
chat.scroll_by(n as i32, focused);
}
chat.layout(h, c["now"].as_u64().unwrap_or(0), focused)
chat.layout(h, c["now"].as_f64().unwrap_or(0.0) as f32, focused)
}
@@ -4,6 +4,7 @@
//!
//! This file only lists the probes, so that adding one reruns no harness: tools/parity.py leaves it out of
//! their dependencies. How a probe runs is in dispatch.rs.
extern crate horizon_client_lib as horizon_client;
#[macro_use]
mod dispatch;
@@ -1182,6 +1182,7 @@ pub fn main() -> horizon_client::Result<()> {
thundering: s.thunder,
nether: false,
block_tags: &items.block_tags,
landing: &motion.landing,
biomes: &biomes,
mob_griefing: true,
universal_anger: false,
@@ -7,6 +7,7 @@ use horizon_client::{
biome::{BiomeInfo, Biomes, GrassModifier},
blocks::BlockFacts,
chunk::{Chunk, Light, MIN_Y, index},
graphics::LeafCullingMode,
mesher::{EMISSION_BITS, FLUID_FACE_BITS, MeshTables, Mesher, OPAQUE_LAYER, TerrainVertex},
models,
};
@@ -137,6 +138,8 @@ fn scene(
blend_radius,
smooth_lighting,
cutout_leaves,
translucent_layers: &[],
leaf_culling: LeafCullingMode::Off,
};
let section_y = int(&row["section_y"])?;
let started = Instant::now();
@@ -1,5 +1,6 @@
//! Selection cost only (not block simulation). Run with prepared client resources.
//! cargo run --profile fast -p horizon-client --example random_tick_eligibility_bench -- RESOURCES
extern crate horizon_client_lib as horizon_client;
use horizon_client::{
blocks::BlockFacts,
chunk::{Chunk, MIN_Y, Section},
@@ -9,6 +9,7 @@
//! and the process RSS (what the allocator holds on to). On macOS it ends
//! with `footprint`'s physical footprint, the number the OS charges: RSS also
//! counts freed pages the allocator marked reusable.
extern crate horizon_client_lib as horizon_client;
use horizon_client::{
assets::Assets, biome::Biomes, blocks::BlockFacts, collisions, crafting::Recipes, items::Items,
light::LightTables, mesher::MeshTables, models, motion::MotionResources,
@@ -2,6 +2,7 @@
//! full chunk NBT encoder. Run with `cargo run --profile fast -p horizon-client
//! --example save_batch_bench`. Add `-- --nbt` to also time serialization.
//! Exact NBT equality is always validated. No GPU, compression or disk I/O timed.
extern crate horizon_client_lib as horizon_client;
use horizon_client::{
biome::Biomes,
chunk::{Chunk, block_strategy},
@@ -2,6 +2,7 @@
//! Run each mode in a separate process to capture CPU usage and peak RSS.
//! The deferred mode evaluates both phases contiguously for fair loader comparison;
//! actual client startup performs other initialization between these phases.
extern crate horizon_client_lib as horizon_client;
use horizon_client::{assets::Assets, blocks::BlockFacts};
use horizon_world::level::Behaviours;
use std::{path::Path, time::Instant};
@@ -2,6 +2,7 @@
//! to the ones decoded from the PNGs. Deletes the cache file in ROOT, loads (decodes
//! and writes it), loads again (reads it) and compares the sprite arrays.
//! cargo run --profile fast -p horizon-client --example sprite_cache_parity -- ROOT
extern crate horizon_client_lib as horizon_client;
use horizon_client::assets::{Assets, RenderAssets};
use std::{
hash::{DefaultHasher, Hash, Hasher},
+11 -6
View File
@@ -2,6 +2,7 @@
//! progress (`PlayerAdvancements`) and the predicates criteria test
//! (`EntityPredicate`, `ItemPredicate`, `LocationPredicate`, the loot
//! conditions advancements use).
use horizon_world::lazy_json::LazyJson;
use serde_json::Value;
use std::{
collections::{BTreeMap, BTreeSet, HashMap},
@@ -30,7 +31,9 @@ pub struct Display {
pub struct Criterion {
pub name: String,
pub trigger: String,
pub conditions: Value,
/// Read on first test: ~1,300 recipe advancements' criteria, most never
/// tested in a game, took ~4 MB as `Value`s.
pub conditions: LazyJson,
}
#[derive(Clone, Debug, Default)]
pub struct Rewards {
@@ -154,7 +157,8 @@ impl Tree {
.into_iter()
.flatten()
{
match self.list[ai].criteria[ci].conditions["recipes"].as_str() {
// `read`: the index alone keeps none of the criteria read.
match self.list[ai].criteria[ci].conditions.read()["recipes"].as_str() {
Some(r) => self
.by_recipe
.entry(r.to_owned())
@@ -208,7 +212,8 @@ impl InventoryIndex {
return index;
};
for &(ai, ci) in list {
let items = &tree.list[ai].criteria[ci].conditions["items"];
let conditions = tree.list[ai].criteria[ci].conditions.read();
let items = &conditions["items"];
// `id_matches`: only a string or a list limits the items.
let limited = items.as_array().and_then(|a| {
a.iter()
@@ -266,7 +271,7 @@ fn parse(id: &str, v: &Value) -> Option<Advancement> {
.map(|(name, c)| Criterion {
name: name.clone(),
trigger: c["trigger"].as_str().unwrap_or("").to_owned(),
conditions: c["conditions"].clone(),
conditions: LazyJson::new(&c["conditions"]),
})
.collect();
let requirements = match v["requirements"].as_array() {
@@ -1606,7 +1611,7 @@ mod tests {
criteria: vec![Criterion {
name: "c".into(),
trigger: "minecraft:inventory_changed".into(),
conditions,
conditions: LazyJson::new(&conditions),
}],
requirements: vec![vec!["c".into()]],
rewards: Rewards::default(),
@@ -1655,7 +1660,7 @@ mod tests {
criteria: vec![Criterion {
name: "c".into(),
trigger: "minecraft:recipe_unlocked".into(),
conditions,
conditions: LazyJson::new(&conditions),
}],
requirements: vec![vec!["c".into()]],
rewards: Rewards::default(),
+37 -6
View File
@@ -32,8 +32,8 @@ use horizon_world::{
use std::{collections::HashSet, fmt};
/// `EntityType.ARROW` (and tipped/spectral arrows): 0.5 x 0.5, eyes at 0.13.
const WIDTH: f32 = 0.5;
const HEIGHT: f32 = 0.5;
pub const WIDTH: f32 = 0.5;
pub const HEIGHT: f32 = 0.5;
const EYE_HEIGHT: f32 = 0.13;
/// `AbstractArrow.Pickup`.
@@ -505,6 +505,24 @@ fn signum(v: f64) -> f64 {
/// entity stops there and is not kept: the game hands it to
/// `finish_entity_hit` once it has hurt the entity.
pub fn tick(p: &mut Projectile, scene: &ArrowScene, targets: &[(Who, Aabb)]) -> (bool, Hit) {
tick_with(p, scene, targets, true)
}
/// One arrow tick whose hit is canceled (`ProjectileImpactEvent`): the arrow
/// moves on to where it would have hit, as `AbstractArrow.stepMoveAndHit` leaves
/// it when the event is canceled, and flies on: not stuck, nothing hurt, no hit.
pub fn tick_through(
p: &mut Projectile,
scene: &ArrowScene,
targets: &[(Who, Aabb)],
) -> (bool, Hit) {
tick_with(p, scene, targets, false)
}
fn tick_with(
p: &mut Projectile,
scene: &ArrowScene,
targets: &[(Who, Aabb)],
impact: bool,
) -> (bool, Hit) {
let Some(mut st) = p.arrow.take() else {
return (false, Hit::None);
};
@@ -517,7 +535,7 @@ pub fn tick(p: &mut Projectile, scene: &ArrowScene, targets: &[(Who, Aabb)]) ->
st: &mut st,
scene,
}
.tick(targets);
.tick(targets, impact);
p.stuck = st.in_ground;
let keep = !st.removed && !matches!(hit, Hit::Entity { .. });
p.arrow = Some(st);
@@ -600,7 +618,7 @@ impl Ticker<'_, '_, '_, '_> {
fn trident(&self) -> bool {
matches!(self.p.kind, ProjectileKind::Trident)
}
fn tick(&mut self, targets: &[(Who, Aabb)]) -> Hit {
fn tick(&mut self, targets: &[(Who, Aabb)], impact: bool) -> Hit {
// ThrownTrident.tick.
if self.trident() && self.st.in_ground_time > 4 {
self.st.dealt_damage = true;
@@ -711,7 +729,7 @@ impl Ticker<'_, '_, '_, '_> {
original.z + movement.z,
);
if physics {
hit = self.step_move_and_hit(original, end, targets);
hit = self.step_move_and_hit(original, end, targets, impact);
if matches!(hit, Hit::Entity { .. }) {
return hit;
}
@@ -795,7 +813,13 @@ impl Ticker<'_, '_, '_, '_> {
/// `AbstractArrow.stepMoveAndHit`: the nearest entity on the way, else the
/// block. A piercing arrow that hits an entity here goes on from it next
/// tick (`finish_entity_hit`); vanilla's loop goes on within the same tick.
fn step_move_and_hit(&mut self, from: Vec3, to: Vec3, targets: &[(Who, Aabb)]) -> Hit {
fn step_move_and_hit(
&mut self,
from: Vec3,
to: Vec3,
targets: &[(Who, Aabb)],
impact: bool,
) -> Hit {
let block = self.scene.clip(from, to);
let end = block.map_or(to, |b| b.0);
// findHitEntities: ProjectileUtil.computeMargin and the search box.
@@ -828,6 +852,10 @@ impl Ticker<'_, '_, '_, '_> {
self.p.position = next;
self.apply_effects_from_blocks(from, next);
if let Some((who, at, _)) = best {
// A canceled impact ends the search, the arrow where it would have hit.
if !impact {
return Hit::None;
}
// AbstractArrow.onHitEntity: past pierce level + 1 entities the
// arrow is discarded before it hurts the next one.
if self.st.pierce > 0 {
@@ -849,6 +877,9 @@ impl Ticker<'_, '_, '_, '_> {
if self.scene.is_air(state) {
return Hit::None;
}
if !impact {
return Hit::None;
}
self.on_hit_block(pos, state);
Hit::Block {
pos: [pos.x, pos.y, pos.z],
@@ -1,6 +1,7 @@
//! horizon-ctl: play and script a running Horizon client through its control
//! socket, from a shell, a script or an AI agent (`horizon-ctl mcp`).
#![forbid(unsafe_code)]
extern crate horizon_client_lib as horizon_client;
mod commands;
mod launch;
mod mcp;
+130
View File
@@ -0,0 +1,130 @@
//! Breathing for native mods: `LivingEntity.baseTick`'s air supply, the part
//! that raises or lowers it. A player (`motion/environment.rs`) and a mob
//! (`mobs/ai.rs`) work out what vanilla does this tick, `breathe` lets the
//! mods change it (`native_living_breathe`, NeoForge's `LivingBreatheEvent`),
//! and the caller applies the answer. Servers only: the callers keep it off a
//! client's prediction.
/// One tick of breathing: whether the entity can breathe, how much air it
/// loses when it cannot and how much it gains when it can.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Breath {
pub can_breathe: bool,
/// `decreaseAirSupply`: 1, or 0 by the oxygen bonus's chance, for an
/// entity that drowns; 1 for one that would not.
pub consume: i32,
/// `increaseAirSupply`: 4 for one that can breathe, 0 for one that drowns
/// or is held under by the nautilus' breath.
pub refill: i32,
}
/// An entity's breathing this tick (`LivingBreatheEvent`, posted from
/// `LivingEntity.baseTick` every tick for a living entity with the generic
/// air logic): `entity` is its ID and `kind` its type (`minecraft:player`,
/// `minecraft:zombie`). `can_breathe`, `consume` and `refill` come in as
/// Horizon does it today (a mod-free game changes nothing); set them to
/// change this tick. With `can_breathe` the air rises by `refill`, up to its
/// maximum (300); without, it falls by `consume`, and at -20 it is set to 0
/// and the entity takes 2 drowning damage. Negative amounts count as 0.
/// Asked on servers for players and for mobs, except those with a
/// `WaterAnimal`'s or an axolotl's own air logic. API @API:living_breathe@.
#[inline(never)]
pub fn native_living_breathe(
entity: u32,
kind: &str,
can_breathe: &mut bool,
consume: &mut i32,
refill: &mut i32,
) {
let _ = (entity, kind, can_breathe, consume, refill);
}
/// `vanilla`'s breathing after the mods have had their say.
pub fn breathe(entity: u32, kind: &str, vanilla: Breath) -> Breath {
let mut now = vanilla;
native_living_breathe(
entity,
kind,
&mut now.can_breathe,
&mut now.consume,
&mut now.refill,
);
Breath {
can_breathe: now.can_breathe,
consume: now.consume.max(0),
refill: now.refill.max(0),
}
}
/// The air supply after `breath`: raised up to `max` when it can breathe,
/// lowered otherwise. The second is whether the entity now drowns
/// (`LivingEntity.shouldTakeDrowningDamage`: -20 or less), and its air is
/// then 0.
pub fn apply(air: i32, max: i32, breath: Breath) -> (i32, bool) {
if breath.can_breathe {
return (air.max((air + breath.refill).min(max)), false);
}
let air = air - breath.consume;
if air <= -20 { (0, true) } else { (air, false) }
}
#[cfg(test)]
mod tests {
use super::*;
const VANILLA: Breath = Breath {
can_breathe: true,
consume: 1,
refill: 4,
};
#[test]
fn air_refills_up_to_the_maximum() {
assert_eq!(apply(100, 300, VANILLA), (104, false));
assert_eq!(apply(299, 300, VANILLA), (300, false));
assert_eq!(apply(300, 300, VANILLA), (300, false));
// A refill of 0 leaves it, as does one that would pass the maximum.
let held = Breath {
refill: 0,
..VANILLA
};
assert_eq!(apply(120, 300, held), (120, false));
}
#[test]
fn air_runs_down_and_drowns_at_minus_twenty() {
let drowning = Breath {
can_breathe: false,
consume: 1,
refill: 0,
};
assert_eq!(apply(300, 300, drowning), (299, false));
assert_eq!(apply(-18, 300, drowning), (-19, false));
assert_eq!(apply(-19, 300, drowning), (0, true));
// The oxygen bonus can spare a tick, and a mod can take more.
let spared = Breath {
consume: 0,
..drowning
};
assert_eq!(apply(-19, 300, spared), (-19, false));
let gas = Breath {
consume: 30,
..drowning
};
assert_eq!(apply(20, 300, gas), (-10, false));
assert_eq!(apply(10, 300, gas), (0, true));
}
#[test]
fn nothing_changes_without_a_mod() {
assert_eq!(breathe(1, "minecraft:zombie", VANILLA), VANILLA);
let negative = Breath {
can_breathe: false,
consume: -3,
refill: -2,
};
// Mods only ever answer with their own values, so what comes back is clamped.
assert_eq!(breathe(1, "minecraft:player", negative).consume, 0);
assert_eq!(breathe(1, "minecraft:player", negative).refill, 0);
}
}
+63
View File
@@ -325,10 +325,73 @@ pub fn interact(
}
}
/// What `interact` would answer for `held`, changing nothing: a client's
/// `useItemOn`, where every interaction checks the same conditions and acts
/// only `if (!level.isClientSide())`.
pub fn outcome(
cauldron: &BlockState,
under_water: bool,
removes_dye: bool,
held: &ItemStack,
) -> Outcome {
struct Dry(Option<ItemStack>);
impl Player for Dry {
fn creative(&self) -> bool {
false
}
fn hand(&mut self) -> &mut Option<ItemStack> {
&mut self.0
}
fn add(&mut self, _: &mut ItemStack) -> bool {
true
}
fn contains(&self, _: &ItemStack) -> bool {
false
}
fn drop_stack(&mut self, _: ItemStack) {}
}
interact(
cauldron,
under_water,
removes_dye,
&mut Dry(Some(held.clone())),
)
.outcome
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_client_sees_the_bucket_taken_without_acting() {
let empty = state("minecraft:cauldron", None);
let water = |l| state("minecraft:water_cauldron", Some(l));
let stack = |id| ItemStack::new(id, 1);
let outcome = |c: &BlockState, under_water, id| outcome(c, under_water, false, &stack(id));
assert_eq!(
outcome(&empty, false, "minecraft:water_bucket"),
Outcome::Success
);
assert_eq!(
outcome(&empty, false, "minecraft:lava_bucket"),
Outcome::Success
);
// fillLavaInteraction under water: CONSUME, the bucket kept.
assert_eq!(
outcome(&empty, true, "minecraft:lava_bucket"),
Outcome::Consume
);
assert_eq!(
outcome(&water(3), false, "minecraft:bucket"),
Outcome::Success
);
// fillBucket needs a full cauldron.
assert_eq!(outcome(&water(2), false, "minecraft:bucket"), Outcome::Pass);
assert_eq!(outcome(&empty, false, "minecraft:bucket"), Outcome::Pass);
assert_eq!(outcome(&empty, false, "minecraft:stone"), Outcome::Pass);
}
#[test]
fn a_burning_entity_lowers_water_and_melts_powder_snow() {
let water = |l| state("minecraft:water_cauldron", Some(l));
+164
View File
@@ -0,0 +1,164 @@
//! Horizon native client: resources, world streaming, rendering and the
//! bindings between the validated simulation pieces and the game loop.
#![forbid(unsafe_code)]
extern crate self as horizon_client;
pub mod advancements;
pub mod ambience;
pub mod animate_tick;
pub mod arrows;
pub mod block_contact;
pub use horizon_client_render::{
assets, atlas_sources, audio, auth, ban_notice, bidi, biome, block_edit, block_entities,
block_entity_anim, block_entity_render, border_render, breaking, brewing, camera, chat, chunk,
chunk_map, cit, clouds, collisions, command_block, containers, crash, datapack, dynamic_light,
effects, environment, extras, fast_hash, fog, font, gfx, graphics, graphics_style, graphics_ui,
hdr_calibration, hud, hud_layout, hud_overlays, inventory_effects, java_text, jigsaw_block,
level_loading, light, lod, logging, menu_blur, mesher, minimap, mipmap, mod_list,
model_library, models, multiline_edit, native_emissive, options_file, options_fixes,
options_ui, overlay, pack_screen, packs, par, particles, poi_sections, post_chain,
potent_sulfur, preedit, providers, screen_draw, screen_effects, sculk, section_mask, settings,
shader_options, shader_pack_list, signs, skins, sound_engine, sound_events, sound_preview,
spans, split_layout, split_profiles, sprites, streaming, structure_block, tab_list, telemetry,
test_block, test_instance_block, testgen, title_edition, trims, ui, vault, weather, widgets,
world, worldborder, zip,
};
pub mod attack_strength;
pub mod attribute_updates;
pub mod auto_jump;
pub mod banner;
pub mod beehive;
pub mod benchmark;
pub mod block_entity_item;
pub mod block_pattern;
pub mod block_transform;
pub mod boss_overlay;
pub mod breathing;
pub mod cauldron;
pub mod chunk_io;
pub mod clock;
pub mod command_storage;
pub mod confirm_screen;
pub mod container_screen;
pub mod content_identity;
pub mod control;
pub mod crafting;
pub mod creative;
pub mod cushion;
pub mod data_cmd;
pub mod debug_chart;
pub mod debug_entries;
pub mod debug_keys;
pub mod debug_overlay;
pub mod debugworld;
pub mod dialog;
pub mod dialog_screen;
pub mod discord_presence;
pub mod end_crystal;
pub mod end_gateway;
pub mod entities;
pub mod entity_culling;
pub mod entity_nbt;
pub mod extras_hud;
pub mod falling_block;
pub mod fire_render;
pub mod first_person;
pub mod flatgen;
pub mod friends_screen;
pub mod furnace;
pub mod game_mode_switcher;
pub mod game_rules_screen;
pub mod gizmos;
pub mod gossip;
pub mod health;
pub mod heartbeat;
pub mod hotbar;
pub mod hunger;
pub mod inv_sort;
pub mod item_activation;
pub mod item_render;
pub mod item_sync;
pub mod item_tooltip;
pub mod lan;
pub mod landing;
pub mod locator_bar;
pub mod lod_net;
pub mod menu;
pub mod merchant;
pub mod mobs;
pub mod mod_content;
pub mod motion;
pub mod multiplayer;
pub mod music;
pub mod native_data;
pub mod net;
pub mod nether_portal;
pub mod no_resources;
pub mod offline_name;
pub mod old_world;
pub mod osk;
pub mod patrol;
pub mod perf_overlay;
pub mod pick_item;
pub mod player_sync;
pub mod portal_sections;
pub mod pose;
pub mod pregen;
pub mod projectiles;
pub mod raid;
pub mod ram_limit;
pub mod random_tick_sections;
pub mod recipe_book;
pub mod recipe_book_screen;
pub mod reconnect;
pub mod save;
pub mod scoreboard_cmd;
pub mod screen_layout;
pub mod screen_narrator;
pub mod social_screen;
pub use horizon_server_files as server;
pub mod server_packs;
pub mod shadows;
pub mod spear;
pub mod spectator_menu;
pub mod sprint;
pub mod stations;
pub mod stopwatches;
pub mod structure_blocks;
pub mod text_display;
pub mod tick_chunk_membership;
pub mod ticket_levels;
pub mod title;
pub mod toasts;
pub mod ui_theme;
pub mod vanillagen;
pub mod win_screen;
pub mod world_spawn;
pub use horizon_client_core::Result;
pub use horizon_client_core::block_entity_nbt;
pub use horizon_client_core::bossbar;
pub use horizon_client_core::component;
pub use horizon_client_core::fastutil_order;
pub use horizon_client_core::gamerules;
pub use horizon_client_core::lang;
pub use horizon_client_core::nbt_path;
pub use horizon_client_core::scoreboard;
pub use horizon_client_core::snbt;
pub use horizon_client_core::text;
pub use horizon_client_input::clipboard;
pub use horizon_client_input::gamepad;
pub use horizon_client_input::input;
pub use horizon_client_input::interaction_input;
pub use horizon_client_input::key_names;
pub use horizon_client_input::keybinds;
pub use horizon_client_input::keymapping;
pub use horizon_client_input::mouse;
pub use horizon_client_input::window;
pub use horizon_command as command;
pub use horizon_items::blocks;
pub use horizon_items::bundle;
pub use horizon_items::cooldowns;
pub use horizon_items::enchant;
pub use horizon_items::flowers;
pub use horizon_items::item_data;
pub use horizon_items::items;
pub use horizon_items::random_sequences;
Loaded 100 of 552 files, more files were not shown because too many files have changed in this diff. Show more