Compare commits

..
Author SHA1 Message Date
Kacper 37bee174e9 Merge pull request #14406 from rdbtCVS/perf/heightmap-columns
Heightmaps: a write marks its column, found again alone on its next read (split of #13644, part 1)
2026-11-11 21:31:24 +01:00
Kacper 34afc9aced Merge pull request #14389 from vnsmoreira/fix/comparator-setblock-output
A comparator placed with OutputSignal by /setblock keeps it
2026-11-11 21:31:14 +01:00
Kacper 1f31436c29 Merge pull request #14345 from MSedgeMC/fix/command-block-edit-mark
Editing a command block marks its chunk
2026-11-11 21:31:05 +01:00
Kacper 99d9a0b931 Merge pull request #14022 from MSedgeMC/fix/weapon-disable-blocking-component
Disable-blocking seconds come from a stack's weapon component
2026-11-11 21:30:09 +01:00
Kacper 3eeeb544f0 Merge pull request #13836 from NikitaMGrimm/projectile-uuid-draws
A projectile draws its UUID and its shot from its own random, as vanilla does
2026-11-11 21:30:02 +01:00
Kacper d5157fe7d6 Merge pull request #13832 from RINNECODER/up13/vehicle-below-world
Boats and minecarts more than 64 blocks under the world are discarded
2026-11-11 21:29:54 +01:00
Kacper f740fdae36 Merge pull request #14678 from horizon-rust/split/external-pipeline-hook
Renderer: the ExternalPipeline hook for shader packs (layer plan M6, step a)
2026-11-11 21:18:41 +01:00
Kacper 2a9ba00903 Merge pull request #15204 from senjinthedragon/oit-crack-size
Cracks keep their size with Improved Transparency and a pack's own destroy stages
2026-11-11 21:12:58 +01:00
Kacper 32d6154943 Merge pull request #15178 from angel2rider/tools/tests-the-two-ci-scripts-that-had-no-test
tests: the two CI scripts that had no test
2026-11-11 21:12:51 +01:00
Kacper 33e30ec6fc Merge pull request #15174 from angel2rider/tools/tools-roadmap-py-s-help-says-the-python-tests-ru
tools: roadmap.py's help says the Python tests run its checks, not CI
2026-11-11 21:12:43 +01:00
Kacper cb89d995c6 Merge pull request #15155 from MSedgeMC/fix/slider-resize-cursor
A dragged slider shows the horizontal resize cursor
2026-11-11 21:12:36 +01:00
Kacper 8a6cf18c34 Merge pull request #15119 from MSedgeMC/port_map/campfire-marked-updated
port_map: CampfireBlockEntity.markUpdated is covered on both paths
2026-11-11 21:12:27 +01:00
Kacper c49a0ebf5b Merge pull request #15117 from MSedgeMC/ledger/rows-2
port_map: the Language page draws the accuracy warning
2026-11-11 21:12:19 +01:00
Kacper 69b930572d Merge pull request #15116 from MSedgeMC/ledger/rows-1
port_map: the Direct Connection screen's Join button state and initial focus
2026-11-11 21:12:12 +01:00
Kacper b891b50a74 Merge pull request #15094 from angel2rider/tools/generate-lan-command-order-help
tools: generate_lan_command_order.py prints usage instead of running the generator on --help
2026-11-11 21:12:04 +01:00
Kacper d8441062e9 Merge pull request #15089 from angel2rider/tools/lang-coverage-show-keys
tools: lang_coverage.py no longer drops --show-keys when --summary is given
2026-11-11 21:11:56 +01:00
Kacper 014dab78fc Merge pull request #15087 from angel2rider/tools/anvil-dump-help
tools: anvil_dump.py asks for its file instead of crashing on --help, and gets a test
2026-11-11 21:11:49 +01:00
Kacper b99d24ae9e Merge pull request #15076 from senjinthedragon/pass-timing-names
Shader pack timing: passes of one name count as their sum, compute passes apart
2026-11-11 21:11:41 +01:00
Kacper e5ba661931 Merge pull request #15067 from MSedgeMC/fix/preset-slider-floor
The graphics preset slider's stops follow vanilla's floor mapping
2026-11-11 21:11:34 +01:00
Kacper 9a1340b14a Merge pull request #14803 from shawshanker18/fix/tests-sibling-imports
tests: import sibling test modules by their own name
2026-11-11 21:11:27 +01:00
Kacper 0fc29c6adb Merge pull request #13839 from NikitaMGrimm/hdr-shaders-pack-no-lift
HDR output: a shaders/ pack's alpha isn't lifted as light sources
2026-11-11 21:11:20 +01:00
Kacper 501446443c Merge pull request #12156 from RINNECODER/up2/metal-sprite-arrays
Block sprites: on Metal the arrays grow past 512 layers when the sprites need it
2026-11-11 21:10:42 +01:00
Kacper 084b20d5cf Merge pull request #15367 from AlfaRojo/fix/recipe-search-tooltip-only
Recipe book search without a colon matches tooltip lines only
2026-11-11 20:47:54 +01:00
Kacper 78b9b261e2 Merge pull request #15366 from AlfaRojo/fix/jukebox-placed-no-block-change
A jukebox placed with a record posts no block_change
2026-11-11 20:47:34 +01:00
Kacper fed1c51351 Merge pull request #15356 from ilguerro/fix/magma-cube-armor
Magma cubes take the damage their size's armor absorbs
2026-11-11 20:47:15 +01:00
Kacper 3dcdffb682 Merge pull request #15354 from ilguerro/fix/farm-milk-pitch-shear-event
Fix milk pitch and shear vibrations for farm animals
2026-11-11 20:46:55 +01:00
Kacper 0ac3ddb034 Merge pull request #15350 from masa-masa-0314/refactor/friends-list-updates
Friends: separate the existing list update handler
2026-11-11 20:46:35 +01:00
Kacper d54f748d38 Merge pull request #15311 from Akirabane/refactor/remove-seen-player-rep
mobs: remove Seen.player_rep, a field written for every villager and never read
2026-11-11 20:46:16 +01:00
Kacper 4deb873ce6 Merge pull request #15282 from Akirabane/fix/fall-location-trapdoors
A fall from a trapdoor says ladder, as vanilla's FallLocation does
2026-11-11 20:45:56 +01:00
Kacper 8235bf1380 Merge pull request #15308 from Akirabane/docs/port-map-game2-004-fixed-gaps
port map: three rows of game2-004.tsv no longer list gaps that are fixed
2026-11-11 20:22:32 +01:00
Kacper 8561fae7bb Merge pull request #15295 from Akirabane/fix/ladder-slide-flying-client
A flying client body does not stop sliding down a ladder by sneaking, as LocalPlayer does
2026-11-11 20:22:13 +01:00
Kacper 7c4bda6333 Merge pull request #15283 from masa-masa-0314/fix/realms-reset-generate-click
Realms: make Generate New World respond to mouse clicks
2026-11-11 20:21:53 +01:00
Kacper 2c7fe19a83 Merge pull request #15265 from Akirabane/fix/item-modify-list-operation
/item modify: a list operation that cannot be done leaves the list unchanged
2026-11-11 20:21:31 +01:00
Kacper f21b8ed58e Merge pull request #15238 from yahya-nusseibeh/fix/portmap-bounds
Friends: bound router answers, and keep a bad one from crashing or freezing the game
2026-11-11 20:21:11 +01:00
Kacper 904f1ddb00 Merge pull request #15059 from ilguerro/fix/goat-ram-feeding-shield
Goats stop ramming while fed or bred, and a shield halves a ram's push
2026-11-11 20:20:50 +01:00
Kacper b8d68e1848 Merge pull request #15049 from KBroneZ/claude/up-15039
A player's copper chest plays its own hinge sound
2026-11-11 20:20:29 +01:00
Kacper 3192c16c2c Merge pull request #14781 from Raymon82777/ios/threads-task-info
horizon-threads: resident memory on iOS through Mach task_info
2026-11-11 20:20:09 +01:00
Kacper e3dbea7830 Merge pull request #14639 from MSedgeMC/fix/trader-defend-spectator
A trader llama ignores a spectator attacker
2026-11-11 20:19:46 +01:00
Kacper 1b1fc9e4dd Merge pull request #14638 from MSedgeMC/fix/vex-dead-owner-target
A vex ignores a dead owner target
2026-11-11 20:19:26 +01:00
Kacper d8b9d1aee7 Merge pull request #14637 from NikitaMGrimm/parity/noai-tadpole-ages
A tadpole with NoAI still ages and grows into a frog
2026-11-11 20:19:06 +01:00
Kacper 93dc4ef3d1 Merge pull request #14165 from iseaprojects/fix/11374-raids-before-spawns
Tick raids before natural and custom spawning
2026-11-11 20:18:45 +01:00
Kacper f0b31a7bbe Merge pull request #13759 from DUzzL/fix/13732-unbreakable-tools
Unbreakable stacks take no tool wear
2026-11-11 20:18:24 +01:00
Kacper 6f3d762140 Merge pull request #13750 from MSedgeMC/fix/guide-equippable-swappable
A pad guide reads a stack's equippable slot and swappable flag
2026-11-11 20:17:53 +01:00
Kacper e142656a86 Merge pull request #13726 from MSedgeMC/fix/editbox-utf16-length
An edit box's maximum length counts UTF-16 code units, as Java's does
2026-11-11 20:17:17 +01:00
Kacper 0e7273581f Merge pull request #12488 from MSedgeMC/fix/breed-partner-panic
A breeding animal stops when its partner panics
2026-11-11 20:16:57 +01:00
Kacper a89f338f75 Merge pull request #14668 from readerkid/fix/io-pool-kick-test
Make the io_pool kick test say which packet is which, not which goes first
2026-11-11 19:45:00 +01:00
Kacper 40aa0acf24 Merge pull request #14657 from Ragbie/tests/world-border-scratch-world
levels world border tests start from a new scratch world, so they pass on a second run
2026-11-11 19:44:39 +01:00
Kacper 29afd2d73a Merge pull request #14216 from voduff/hidden-effect-promotion
Mobs: a promoted weaker Absorption clamps the amount to its maximum
2026-11-11 19:44:17 +01:00
Kacper 4062bda9c8 Merge pull request #14212 from iseaprojects/fix/11380-cart-pushable-by
Filter a moving cart's pushes by its teams' collision rules
2026-11-11 19:43:57 +01:00
Horizon agentandClaude Opus 5.5 62a2f69feb Merge main into the ExternalPipeline hook
gfx.rs: keep both imports (external's shadow helpers and main's section_fade).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 19:40:18 +01:00
AkirabaneandClaude Sonnet 5.5 356c6c1cb3 Merge origin/main into local/R04-fall-location-trapdoors
Keep both the trapdoor fall_location_of and its test module and main's
mob-hit impl block in game/combat.rs.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-11 19:31:48 +01:00
Kevin RamirezandClaude Opus 5.5 e664d20ec8 A jukebox placed with a record posts no block_change
JukeboxBlock.setPlacedBy sets has_record with level.setBlock alone;
only JukeboxBlockEntity.notifyItemChangedInJukebox posts the
block_change game event. Since #14900 set_has_record posted it for the
placement too, and use_item_on failed 79 cases on main.

Fixes #15320

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 12:30:54 -06:00
masa-masa-0314andZCode 7961f052d6 refactor(friends): separate the existing list update handler
Move list state, foreground scheduling and the single background updater
into a private list_updates module. Retain the public social paths and
move the original state-mapping test without changing its assertions.

This is R1 after merged #15182, reapplied independently to main at
b66b2f8372. Preserve shared state, lock scopes,
request order and existing cache, failure and timing behavior.

Refs #15183

Co-Authored-By: ZCode <[email protected]>
2026-11-12 01:45:34 +08:00
Kevin RamirezandClaude Opus 5.5 2a8da2bd76 Recipe book search without a colon matches tooltip lines only
FullTextSearchTree.searchPlainText searches the plain-text tree (the
results' tooltip lines) alone; the id path is only searched for a
namespace:path query. #14910 also matched the item id path, so
recipe_book_view failed on main ("light_blue_candle" found recipes).

Fixes #15321

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 11:10:49 -06:00
AkirabaneandClaude Sonnet 5.5 a77b71a4ed mobs: remove Seen.player_rep, which nothing reads
Mob::seen wrote a villager's reputation of the player into every Seen it
built, and no code reads the field (git grep finds only its definition and
that write; the trading code reads the gossip itself). Remove the field and
the lookup. The `player` parameter of Mob::seen is now unused and is named
_player: it has many callers, and a later change can drop it from them.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-11 16:12:02 +01:00
AkirabaneandClaude Sonnet 5.5 9d1b4c1b34 port_map: three rows of game2-004.tsv no longer list gaps
Piglin.holdInOffHand, PiglinAi.holdInOffhand and Drowned.rangedAttackUncertainty
are ported as vanilla's bytecode reads; the rows now say what the code does.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-11 16:02:34 +01:00
Riccardo Guerriero cf6ff9c340 Merge origin/main into fix/goat-ram-feeding-shield 2026-11-11 15:57:04 +01:00
masa-masa-0314andZCode 916764cc31 fix(realms): route reset tile mouse clicks through existing actions
Forward coordinate-hit Reset World tiles to actions 70-75 so Generate New
World reaches the existing CreateWorldRequest flow. Keep active tasks from
accepting either coordinate clicks or direct tile actions.

Follow Minecraft 26.3 RealmsResetWorldScreen.init/FrameButton and
RealmsCreateWorldFlow.createWorld. Extend the existing regression with
actual mouse coordinates, bounds, creation metadata and running-task gates.

Fixes #15273

Co-Authored-By: ZCode (new-provider/gpt-6.1-sol) <[email protected]>
2026-11-11 22:15:46 +08:00
yahya-nusseibehandClaude Opus 5.5 b62be5565d A router read that times out at the deadline is "too slow" on every platform
On Linux a socket read timeout is reported as WouldBlock, so a read cut short by the last part of
the deadline came back as that error instead of TimedOut (seen in CI's
a_router_trickling_its_answer_runs_out_of_time). Both kinds now end the request as TimedOut.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stK8iXVDXqoi7midcqd
2026-11-11 16:06:03 +02:00
AkirabaneandClaude Sonnet 5.5 16e97dca16 Motion: a flying client player keeps sliding down a ladder when it sneaks
LocalPlayer.isSuppressingSlidingDownLadder is !abilities.flying &&
super.isSuppressingSlidingDownLadder(), with LivingEntity's being
isShiftKeyDown() and ServerPlayer not overriding it. Horizon answered the
sneak for every body. A client body (client_side) that is flying no longer
suppresses the slide; a server body still looks at the sneak alone.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-11 14:52:33 +01:00
AkirabaneandClaude Sonnet 5.5 268553de31 Fall location: a trapdoor counts as a ladder, as vanilla does
FallLocation.blockToFallLocation returns LADDER for Blocks.LADDER or a
block in #minecraft:trapdoors; Horizon mapped only the ladder by name, so a
fall off a climbed trapdoor said other_climbable. Move the name match into
fall_location_of and give it whether the block is in the trapdoors tag.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-11 14:41:26 +01:00
AkirabaneandClaude Sonnet 5.5 b50a91cb67 /item modify: a list operation that cannot be done leaves the list unchanged
ListOperation.Append, Insert and ReplaceSection give the old list back when
the result would pass the limit (256 for lore) or the offset is past the end
of the list. The command cut every result to 256 entries and moved an offset
past the end to the end instead.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-11 14:27:24 +01:00
b23dd07fa9 Magma cubes take the damage their size's armor absorbs
MagmaCube.setSize (MagmaCube.java:60-66) writes size * 3 into
Attributes.ARMOR; LivingEntity.getArmorValue (LivingEntity.java:1883-1885)
reads that base back. Mob armor now reads the set attribute base instead of
only the type's armor default, so a summoned size-4 cube keeps its 12 armor.

Co-Authored-By: DeepSeek V4.1 Flash <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 14:22:11 +01:00
rdbtCVSandGPT 6.1 Sol dc35fac460 Fix weapon component formatting and extend regression coverage
Cover prototype inheritance, zero-default replacement, custom weapons, empty stacks, and persisted component removal without changing the contributor's gameplay fix.

Co-Authored-By: GPT 6.1 Sol <[email protected]>
2026-11-11 13:05:59 +00:00
SenjinandClaude Opus 5.5 57ffe914c0 Cracks keep their size with Improved Transparency and a pack's own destroy stages
With Improved Transparency the cracks draw vanilla's destroy_oit_stage_* sprites, but the
crumbling shaders took their size from destroy_stage_0. A resource pack that replaces only the
plain stages at a higher resolution made the crack window larger than its sprite, so it showed
neighbouring atlas sprites (#11164). crumbling_sprite now reads the set the cracks draw.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 13:03:54 +01:00
rdbtCVSandGPT 6.1 Sol 4cb7f5a348 Merge main into vehicle-below-world, preserving the moved cart lookup
Keep main's minecart index/display extraction and expose cart_at to the below-world discard helper. Vehicle discard behavior is unchanged.

Co-Authored-By: GPT 6.1 Sol <[email protected]>
2026-11-11 11:53:09 +00:00
yahya-nusseibehandClaude Opus 5.5 9a384a124b Friends: bound router answers, and keep a bad one from crashing or freezing the game
- dechunk sliced the device's chunk size into a from_utf8_lossy string: a chunk ending inside a
  character panicked the sharing thread, under the reach lock on renewal, and the poisoned lock
  then crashed the game at Host::stop. It now stops at such a chunk, and stop() and the renewal
  loop take the lock even when poisoned.
- A router's HTTP answer has one 10 s deadline (each read was limited, not the answer), SSDP
  discovery tries at most 8 gateways, and the mapping is renewed outside the reach lock, so
  stopping sharing no longer waits on a slow device.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stK8iXVDXqoi7midcqd
2026-11-11 13:50:34 +02:00
TheGTandOpenCode 3993570cb3 tests: the two CI scripts that had no test
tools/ci/legacy_releases.py writes the pinned SHA-256s of the releases
published before signing began, and tools/ci/resolve_reviewer_ids.py
fills in the numeric GitHub IDs the comment commands decide by. Twelve
of their thirteen siblings under tools/ci/ have a test; these two did
not, although both are pure functions over what GitHub returns.

Co-Authored-By: OpenCode <[email protected]>
2026-11-11 09:58:06 +00:00
12498072b0 Fix milk pitch and shear vibrations for farm animals
Milking a cow, goat or mooshroom plays at pitch 1.0 with no level-random draw, as AbstractCow.mobInteract, Goat.mobInteract and MushroomCow.mobInteract do.

Shearing a sheep or mooshroom by hand now posts GameEvent.SHEAR with the player, as Sheep.mobInteract and MushroomCow.mobInteract do.

Co-Authored-By: DeepSeek V4.1 Flash <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 10:57:36 +01:00
TheGTandOpenCode 8a1de3ebdb tools: roadmap.py's help says the Python tests run its checks, not CI
Its help marked "build --check" and "check --offline" as (CI), but no
workflow runs them: CI runs tests/test_roadmap.py, which checks the same
two things (the generated files match the toml, and the file keeps its
rules). The help pointed contributors at a command CI never runs.

Co-Authored-By: OpenCode <[email protected]>
2026-11-11 09:55:08 +00:00
MSedgeMC b29c0cc050 port_map: AbstractSliderButton.handleCursor's resize cursor 2026-11-11 09:05:21 +00:00
MSedgeMC a10e37401c test: a dragged slider asks for the horizontal resize cursor
Not hovered asks nothing; dragged resizes; hovered at rest points; inactive is
not allowed.
2026-11-11 09:05:21 +00:00
MSedgeMC 8943e13768 fix: a dragged slider shows the horizontal resize cursor
AbstractSliderButton.handleCursor asks for RESIZE_EW while dragging,
POINTING_HAND when hovered and NOT_ALLOWED when inactive. Horizon's
widget_cursor only knew the hand and the forbidden sign, so the shell's
sliders (their held state already known in RowView) never asked for the resize
cursor. widgets::slider_cursor adds it, the shell's slider rows call it, and
the client maps Cursor::ResizeEw to EwResize.
2026-11-11 09:05:00 +00:00
MSedgeMC d77a09e09f refactor: the preset module declares itself by mod graphics_tests
The #[path] mod preset block sat before use crate::{, the same anchor the
widgets and wheel PRs use. It and its pub(crate) use now sit just above the
test modules at the end of the file, and the preset test moved into the
module's own #[cfg(test)] mod tests, so it no longer appends to
options_ui_graphics_tests.rs either. No behaviour changes.
2026-11-11 08:38:35 +00:00
TheGT 78ca3e6736 Merge remote-tracking branch 'origin/main' into tools/generate-lan-command-order-help 2026-11-11 07:37:31 +00:00
TheGT 1d59235bc6 Merge remote-tracking branch 'origin/main' into tools/anvil-dump-help 2026-11-11 07:34:40 +00:00
TheGT 7faa273f45 Merge remote-tracking branch 'origin/main' into tools/lang-coverage-show-keys
# Conflicts:
#	tools/lang_coverage.py
2026-11-11 07:30:11 +00:00
readerkid 9fc6f9d529 Make the io_pool kick test say which packet is which, not which goes first
The packet queued with `served.send` and the one the probe sends from
`Session::tick` are framed by different paths: `Conn::serve` takes the first
from the outbox, `Conn::sweep` frames the second straight into `out`. Whichever
the worker thread reaches first is written first, and the sweep is on a timer,
so a CI run took the probe's packet (id 33) before the queued one and the test's
two `assert_eq!`s failed. The order is common, but not something the pool
promises.

The test now takes both packets and tells them apart, then checks what it is
about: the kick goes after the queued packet, `kicked` is reported, the
connection closes, and nothing follows the kick. The kick is still sent only
after both packets arrived, so that part stays ordered.

Fixes #14660

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <[email protected]>
2026-11-10 21:14:32 -08:00
MSedgeMC 0340e90e89 port_map: CampfireBlockEntity.markUpdated is covered on both paths
Placing food marks the block entity (chunk modified, clients synced) and
a moved cooking timer marks it in the tick; the row kept the old gap.
2026-11-11 05:02:08 +00:00
MSedgeMC 126a017432 port_map: the Language page draws the accuracy warning 2026-11-11 05:00:28 +00:00
MSedgeMC cf44da6903 port_map: DirectJoinServerScreen's Join button state and initial focus 2026-11-11 05:00:26 +00:00
Vinícius MoreiraandClaude Opus 5.5 f5bad28164 Comparator: each doc comment above its own function
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 01:47:37 -03:00
TheGTandOpenCode 6b855fe0c7 tools: generate_lan_command_order.py prints usage instead of running the generator on --help
Co-Authored-By: OpenCode <[email protected]>
2026-11-11 04:29:43 +00:00
TheGTandOpenCode 4ffda2343e tools: lang_coverage.py no longer drops --show-keys when --summary is given
Co-Authored-By: OpenCode <[email protected]>
2026-11-11 04:23:14 +00:00
TheGTandOpenCode dcddf81a20 tools: anvil_dump.py asks for its file instead of crashing on --help, and gets a test
Co-Authored-By: OpenCode <[email protected]>
2026-11-11 04:20:00 +00:00
SenjinandClaude Opus 5.5 d990e4f79c Shader pack timing: passes of one name count as their sum, compute passes apart
HORIZON_SHADER_PACK_TIMING pushed every pass's time into the window of its name, so two passes of
one name in a frame (a program drawn over several render passes, or deferred2.csh beside
deferred2.fsh) shared a window and its median was the time of neither. A frame's passes of one
name now add up to one sample, and a compute pass is timed as "<name> (compute)".

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 05:00:09 +01:00
MSedgeMC d7107e8f1b Merge remote-tracking branch 'upstream/main' into HEAD 2026-11-11 03:53:41 +00:00
MSedgeMC 4caa4a90c3 Merge remote-tracking branch 'upstream/main' into HEAD 2026-11-11 03:53:31 +00:00
MSedgeMC 451fb4ebd9 Merge remote-tracking branch 'upstream/main' into HEAD 2026-11-11 03:53:09 +00:00
MSedgeMC 6895c1dcc1 Merge remote-tracking branch 'upstream/main' into HEAD 2026-11-11 03:53:07 +00:00
KBroneZ f211a2ee96 Merge remote-tracking branch 'upstream/main' into claude/up-15039 2026-11-11 05:33:28 +02:00
Nikita Maximilian GrimmandClaude Fable 5.2 4f76f9b35d Merge remote-tracking branch 'origin/main' into projectile-uuid-draws
Co-Authored-By: Claude Fable 5.2 <[email protected]>
2026-11-11 04:25:26 +01:00
Nikita Maximilian GrimmandClaude Fable 5.2 619e45830a Merge remote-tracking branch 'origin/main' into parity/noai-tadpole-ages
Co-Authored-By: Claude Fable 5.2 <[email protected]>
2026-11-11 04:24:07 +01:00
Vinícius MoreiraandClaude Opus 5.5 f0a379cbc1 Merge main into fix/comparator-setblock-output: main compiles again (#15038)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 23:57:09 -03:00
Nikita Maximilian Grimm 41a9e808e1 Merge remote-tracking branch 'origin/main' into hdr-shaders-pack-no-lift 2026-11-11 03:40:32 +01:00
f6af16d3ef Goats stop ramming while fed or bred, and a shield halves a ram's push
GoatAi.initRamActivity wants TEMPTING_PLAYER and BREED_TARGET absent, and
GoatAi.updateActivity picks RAM before IDLE, so a goat being fed or bred only
follows; a player who starts feeding it during the prepare also ends the
activity (PrepareRamNearestTarget.stop starts the cooldown).

RamTarget.tick runs applyItemBlocking a second time for its mobAttack push and
scales it by blockingFactor (0.5 when the victim's raised item blocked part of
the hit). The push now names the damage the hit dealt so the host can do that,
and blockingFactor * speedFactor keeps vanilla's float-then-double order.

Co-Authored-By: DeepSeek V4.1 Flash <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 03:03:46 +01:00
MSedgeMC e6eeff4d10 port_map: SliderableEnum.fromSliderValue's floor mapping 2026-11-11 01:41:38 +00:00
MSedgeMC 811ea89472 test: the graphics preset slider's four stops
0.24, 0.25, 0.49, 0.5, 0.74, 0.75 and 1.0 pick Fast/Fancy/Fabulous/Custom by
the floor mapping, and the value setter moves the preset through them.
2026-11-11 01:41:38 +00:00
MSedgeMC 5f51a346c8 fix: the graphics preset slider uses vanilla's floor stops
OptionInstance.SliderableEnum.fromSliderValue maps a slider position by
floor(v * values.size()), with 1.0 just below, and clamps to the last value:
the four GraphicsPreset values change at 0.25, 0.5 and 0.75. Horizon rounded
v * 3, so dragging to 0.2 picked Fancy instead of Fast and 0.8 Fabulous
instead of Custom.
2026-11-11 01:41:38 +00:00
MSedgeMC 4919ef98dd Restore the weapon component test the merge dropped 2026-11-11 01:07:56 +00:00
MSedgeMC e723d9ea15 Merge main: keep weapon_damage beside weapon_disable_blocking 2026-11-11 01:07:39 +00:00
Vinícius MoreiraandClaude Opus 5.5 9c31a83fed A comparator placed with OutputSignal by /setblock keeps it
BlockInput.place loads the tag into the comparator's block entity, and
ComparatorBlockEntity.loadAdditional reads getIntOr(OutputSignal, 0).
Horizon keeps a comparator's output beside the block entities and only
/data wrote it; /setblock and /fill now load it through the same
load_comparator_output.

Fixes #13870

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 21:59:37 -03:00
MSedgeMC 1165a5c119 Merge main; keep both sides' rows 2026-11-11 00:59:13 +00:00
KBroneZandClaude Opus 5.5 c25d5e6d71 fix: a player's copper chest plays its own hinge sound
container_edge_sound sent every block named *chest* to block.chest.open
and close. Vanilla's ChestBlockEntity opener counter plays the block's
getOpenChestSound/getCloseChestSound, which a copper chest takes from
CopperChestBlock.getHingeSound by its weather state. The copper golem's
transport path already had that mapping; it moves to
transport_items::chest_hinge_sound and both paths use it.

Closes #15039

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 02:53:12 +02:00
MSedgeMC 59d9424230 Merge upstream/main into fix/command-block-edit-mark 2026-11-11 00:42:21 +00:00
MSedgeMC fe8bffe676 Merge remote-tracking branch 'upstream/main' into fix/vex-dead-owner-target
# Conflicts:
#	tools/port_map/game2-003.tsv
2026-11-11 00:41:48 +00:00
Nikita Maximilian GrimmandClaude Opus 5.5 6e036e8918 Merge origin/main into projectile-uuid-draws
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 00:52:29 +01:00
Nikita Maximilian Grimm 41e78cbe12 Merge remote-tracking branch 'origin/main' into hdr-shaders-pack-no-lift
# Conflicts:
#	docs/graphics.md
2026-11-11 00:40:08 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 b02f367216 Merge origin/main into projectile-uuid-draws
dispenser.rs: main (#14603) moved the firework rocket out of the match
into launch_angled_rocket, which makes the rocket's own random. This
branch only renamed the random argument in that arm, so main's version
stays and the arm goes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 23:58:45 +01:00
Nikita Maximilian Grimm 370af10365 Merge remote-tracking branch 'origin/main' into parity/noai-tadpole-ages
# Conflicts:
#	crates/horizon-client/src/mobs/ai.rs
#	crates/horizon-client/src/mobs/aquatic.rs
2026-11-10 23:51:39 +01:00
Nikita Maximilian Grimm ad3c08df6e Merge remote-tracking branch 'origin/main' into parity/noai-tadpole-ages
# Conflicts:
#	crates/horizon-client/src/mobs/ai.rs
2026-11-10 23:29:39 +01:00
d9ed797708 Heightmaps: clone stale bits before concurrently refreshed heights
A snapshot could copy an old cached height and then a stale bit another reader had just cleared, retaining the old height as clean. Acquire the stale-bit snapshot first: marked columns rescan, and cleared bits publish the refreshed height. A concurrent Chunk snapshot test covers both heightmaps and fails with the original ordering.

Co-Authored-By: Kacper <[email protected]>

Co-Authored-By: OpenCode GPT 6.1 Sol <[email protected]>
2026-11-10 22:03:40 +00:00
Shashanksaraswat123939 918e56994c ci: re-run checks (verify was cancelled when #14802 closed) 2026-11-10 21:33:19 +04:00
Shashanksaraswat123939 ffdd56a5e0 tests: import sibling test modules by their own name
test_item_model_property and test_port_ledger imported their helpers as
tests.test_world_input_capture and tests.test_class_index, while every
other test imports siblings by module name (unittest discover puts tests/
on sys.path). The tests. form resolves to any installed top-level tests
package first, so both modules failed to import wherever one exists.
2026-11-10 16:40:16 +04:00
Ray McDonaldandClaude Opus 5.5 c46a72196e horizon-threads: resident memory on iOS through Mach task_info
libc declares proc_pidinfo/proc_taskinfo for macOS only, so horizon-threads
didn't compile for aarch64-apple-ios. iOS reads MACH_TASK_BASIC_INFO's
resident size instead.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 06:12:10 -05:00
MSedgeMC c3121e9a5a Rebuild the weapon component read on current main without dropping attribute_modifiers 2026-11-10 09:59:59 +00:00
Horizon agentandClaude Opus 5.5 8642db5c99 Renderer: the ExternalPipeline hook, with the shadow request and its culling in gfx/external.rs
Step a of #14035 (layer plan M6). An external shader pipeline implements
ExternalPipeline; the renderer calls begin_frame and shadow_request on world
frames, resize and reset_history, and draws vanilla's way where it doesn't
answer. Nothing sets one yet: each point is one Option test.

ShadowView moves from pack_post (re-exported there) and ShadowCull,
shadow_box, shadow_runs and shadow_casters from gfx.rs, unchanged: the
engine's side of the shadow frame point.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 05:17:39 +01:00
Evrim CicekandClaude Opus 5.5 8392c05178 levels world border tests start from a new scratch world (#14654)
They opened HORIZON_HOME/h260 without removing it, so a second run found
the border the first had saved and its first worldborder set changed
nothing.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 06:00:05 +03:00
MSedgeMC 44b65a8226 port_map: map TraderLlamaDefendWanderingTraderGoal.canUse player validity 2026-11-10 01:57:41 +00:00
MSedgeMC 3dfc15e206 test: a trader llama does not defend against a spectator 2026-11-10 01:57:41 +00:00
MSedgeMC f55124aaf7 fix: a trader llama ignores a spectator attacker 2026-11-10 01:57:22 +00:00
MSedgeMC 63e9728c2e port_map: map VexCopyOwnerTargetGoal.canUse target validity 2026-11-10 01:56:57 +00:00
MSedgeMC 3955c94576 test: a vex does not copy a dead owner target 2026-11-10 01:56:57 +00:00
MSedgeMC 63a4c6f103 fix: a vex ignores a dead owner target 2026-11-10 01:56:39 +00:00
Nikita Maximilian GrimmandClaude Opus 5.5 e65ca98d9d A tadpole with NoAI still ages and grows into a frog
Tadpole.aiStep counts the age up, and NoAI only skips serverAiStep, so a
NoAI tadpole ages, shows its age lock's particles and grows up like any
other. The NoAI branch of the mob tick now runs the aquatic aiStep parts
that NoAI doesn't skip from one place in aquatic.rs.

Fixes #14615

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 02:33:24 +01:00
MSedgeMC 7b73baf589 Rebuild the weapon component read on current main 2026-11-09 23:45:27 +00:00
0xRinne 92125370de Merge branch 'main' into up2/metal-sprite-arrays 2026-11-09 16:16:54 -06:00
fd7b18c787 Heightmaps: test that a write finds its own column again
A write two below the height asks about no block on the next read; a write at or
above it is found again down that column alone, and the other columns keep theirs.

Co-Authored-By: Kacper <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 20:36:39 +00:00
5c7ea99917 Heightmaps: a write marks its column, found again alone on its next read
A block written at or above a column's height (grass spreading, a crop, a mob breaking a
block) used to drop the whole chunk's map, and the next spawn attempt there scanned all 256
columns again. The write now marks that column; its next read finds its height down that
column only. The heights are those a rebuild gives. Natural spawning on the bench: 4.8 ms a
tick to 4.0.

Co-Authored-By: Kacper <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 20:26:42 +00:00
RINNECODERandClaude Opus 5.5 d5bd80fcf7 Block sprites: the Metal warning lives in surface_setup, as gfx.rs may not grow
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 14:04:18 -06:00
Horizon agent fd85857547 Merge origin/main into fix/editbox-utf16-length 2026-11-09 20:57:20 +01:00
RINNECODER f5df9ca25a Merge main into #12156 2026-11-09 13:54:52 -06:00
MSedgeMC 9f6b9e408d port_map: the command block edit marks the chunk 2026-11-09 19:51:59 +00:00
MSedgeMC a81a3151c3 test: an edited command block dirties its chunk 2026-11-09 19:51:59 +00:00
MSedgeMC bc9c686b25 fix: editing a command block marks its chunk 2026-11-09 19:51:59 +00:00
Nikita Maximilian GrimmandClaude Opus 5.5 f11565b4ae Merge main into #13839; graphics.md's HDR paragraph is main's (with #14129) plus this PR's sentence
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 20:34:12 +01:00
RINNECODERandClaude Opus 5.5 790d1be209 Block sprites: warn when Metal arrays grow past 512 layers, with the layer count and GPU
So a texture-corruption report from a big pack can be tied to the lifted cap at once (#12155).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 12:57:33 -06:00
Horizon agent 073e8db61c Merge remote-tracking branch 'origin/main' into rpn4-14216
# Conflicts:
#	crates/horizon-client/src/mobs/ai.rs
2026-11-09 19:55:31 +01:00
voduffandClaude Opus 5.5 c93edbe286 Mobs: a promoted weaker Absorption clamps the amount to its maximum (#11384)
LivingEntity.onEffectUpdated(effect, true) runs when a hidden effect
instance is promoted, and its refreshDirtyAttributes clamps absorption
to the new max_absorption. Horizon clamped only when Absorption was
gone, so Absorption II's 8 survived the promotion of a hidden
Absorption I (max 4). tick_effects now clamps whenever the tick changed
max_absorption.

mob_absorption_max_corpus gains a promote case (494/494; main: 493/494).

Part of #11384

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:19:10 +00:00
Horizon agentandClaude Opus 5.5 0d6c974c99 Merge main into #13839; graphics.md's HDR paragraph is main's plus this PR's sentence
Main rewrote the UI Brightness part of the HDR paragraph; this PR only added
the sentence that a shaders/ pack's picture is not lifted. Main's paragraph is
kept with that sentence inserted where the PR put it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:09:06 +01:00
iseaprojectsandopencode 91f580a2a2 Filter a moving cart's pushes by its teams' collision rules
`OldMinecartBehavior.pushAndPickupEntities` asks
`EntitySelector.pushableBy(this.minecart)` for what a moving rideable cart
pushes, so the cart's own team's collision rule and the target's both decide;
Horizon passed `None` for the cart's team on mobs and never filtered carts.
A still or unrideable cart keeps vanilla's else branch, which pushes every
pushable minecart with no team filter.

The per-candidate answer comes from `Game::cart_push_allowed` beside the
other team lookups; `pickup_from_cart` gets the cart's team from the same
`team_with_owner`. Two ignored tests on the real server game fixture cover
every collision rule over cart-to-cart pushes, the stationary branch, and a
cow the cart may not board.

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-09 14:07:58 -04:00
MSedgeMC 4694211e8d Merge remote-tracking branch 'upstream/main' into fix/weapon-disable-blocking-component 2026-11-09 17:59:40 +00:00
Horizon agentandClaude Opus 5.5 7b74e2243b Merge main into #13726; ui.rs keeps both new edit box tests
insert_text auto-merged main's read-only guard with this PR's UTF-16 room;
main's read-only test and this PR's UTF-16 test were appended at the same
place, both kept whole.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:58:46 +01:00
MSedgeMC 0ff4404c9e Merge remote-tracking branch 'upstream/main' into fix/weapon-disable-blocking-component 2026-11-09 17:54:04 +00:00
iseaprojectsandopencode d413223251 Tick raids before natural and custom spawning
`ServerLevel.tick` runs `raids.tick` before `getChunkSource().tick`, so a
wave that lands in a tick joins before that tick's natural and custom
spawns and takes the lower entity ids. `tick_creatures` did the opposite:
natural spawning, then the custom spawners, then the raids.

The three phases move into `Game::tick_spawners`, in vanilla's order, each
with its own span; the LAN sync bracket still covers exactly them. A new
ignored test builds the real server game fixture, records spans and asserts
the phase order.

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-09 13:41:39 -04:00
MSedgeMC f6a6dcc007 fix: drop needless borrows in the weapon disable paths 2026-11-09 17:24:58 +00:00
Horizon agentandClaude Opus 5.5 3925efc44e adapter_requested_split: the same rule in the lines it had
gfx.rs is over 3000 lines and may not grow (tools/check_file_size.py).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:10:40 +01:00
Horizon agentandClaude Opus 5.5 fb7b0f4ca3 mobs.rs and goals.rs don't grow: two helpers move out
totem_death_effects goes to mobs/living.rs (LivingEntity.checkTotemDeathProtection), and
RandomPos.moveUpToAboveSolid to mobs/random_pos.rs. Pure moves; no behaviour change.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:03:47 +01:00
MSedgeMC f8599e2685 Disable-blocking seconds come from a stack's weapon component
LivingEntity.getSecondsToDisableBlocking reads
Weapon.disableBlockingForSeconds. Horizon read the prototype's value, so a
patched weapon disabled a shield for 0 seconds instead of its own.

Test: a diamond sword with disable_blocking_for_seconds 1.5 reports 1.5.
2026-11-09 16:00:10 +00:00
Horizon agentandClaude Opus 5.5 c80f0361be Projectiles: put shoot_own's doc comment back on shoot_own
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:49:12 +01:00
Horizon agentandClaude Opus 5.5 c08d2b301e Merge origin/main
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:49:12 +01:00
Horizon agentandClaude Opus 5.5 f3b08a0988 Merge main into #12156
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:27:21 +01:00
Horizon agentandClaude Opus 5.5 5171e9c09c Breed: a panicking partner is refused and stops the goal
The snapshot carried the panic state but Breed never read it:
getFreePartner skips a panicking partner and canContinueToUse ends the
goal when the partner panics. The test only skipped on CI (no prepared
resources), so it looked green without the check.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:23:17 +01:00
Horizon agent 871fb3a448 Merge main into the breed panic fix 2026-11-09 16:23:09 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 32c375e0ae HDR output: a shaders/ pack's alpha isn't lifted as light-source marks
With HDR on and a shaders/ (OptiFine/Iris format) pack running, the last
pass read the alpha the pack's final program leaves in the frame as
Horizon's light-source marks and lifted those pixels above paper white.
A pack that leaves alpha below 0.75 had most of its picture brightened as
if it were torches or lava, and HDR Lighting turned that on and off.

Horizon doesn't light the world under such a pack (HDR lighting is
already off there), so the lift is now off too: the pack is shown at
paper white, as a 0.2 pack is. The decision moves into a small pure
function in gfx/output.rs with a unit test. HDR off doesn't change.

Fixes #13807.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 13:11:02 +01:00
RINNECODERandClaude Opus 5.5 e31eeeb2bc Boats and minecarts more than 64 blocks under the world are discarded
Entity.checkBelowWorld runs in AbstractBoat.tick (through Entity.baseTick,
after the fire) and in AbstractMinecart.tick (after its hurt and damage wear
off), before either moves: under the level's floor minus 64, onBelowWorld
discards it. Horizon had no such check for vehicles, so a boat or minecart
that fell out of the world kept falling for ever (#8024 measured one at
y -243 where vanilla had removed it by tick 33).

The discard ejects the mob riders where they are and spills a chest boat's or
container minecart's slots (AbstractChestBoat.remove,
AbstractMinecartContainer.remove), with no ENTITY_DIE and no vehicle item.
The logic lives in game/boats/below_world.rs; minecarts.rs keeps its size.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 05:41:46 -06:00
Nikita Maximilian GrimmandClaude Opus 5.5 08d02c752f Format the shot calls that lost their random argument
A projectile added keeps an earlier UUID for its id (a client copy's from the
spawn packet) through or_insert again, so the client copy needs no reset.

Part of #12561

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 12:13:18 +01:00
Nikita Maximilian Grimm 8db4b7bc14 Merge remote-tracking branch 'origin/main' into projectile-uuid-draws 2026-11-09 11:37:10 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 b5fd8221fc Keep a loaded projectile's UUID through Projectile::own_uuid
The loads take the own random's UUID through one helper, so persist.rs and
creatures.rs keep their size. A client copy drops its own random's UUID: the
spawn packet's, kept before it is added, wins.

Part of #12561

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 11:37:08 +01:00
DUzzL a48be025f1 Skip item wear for unbreakable stacks 2026-11-09 09:59:02 +01:00
MSedgeMC f65d12322e A pad guide reads a stack's equippable slot and swappable flag
The controller guide offers Equip only when Equippable.swapWithEquipmentSlot
applies: the stack's slot is an armour slot and swappable is true. Horizon
read the item prototype for both.

Test: a helmet patched swappable:0b no longer offers Equip, and its patched
slot is used.
2026-11-09 08:43:41 +00:00
MSedgeMC 86d2c0d5cd Edit box test expectations count the maximum in UTF-16 units
The two native text field replacement tests pinned the old character count:
a maxLength of 4 kept four code points and one of 3 kept three. EditBox's
maxLength is Java String.length(), so the emoji takes two of them: 4 units
keep "a😀b" and 3 keep "a😀", which is what vanilla's setValue truncation
leaves.
2026-11-09 08:20:36 +00:00
MSedgeMC 9523592a93 An edit box's maximum length counts UTF-16 code units, as Java's does
EditBox.maxLength caps Java `String.length()`: UTF-16 code units, so a
character outside the BMP takes two of them. Horizon measured the limit in
Rust chars (code points) in both edit box ports, so a chat box with a maximum
of 256 accepted 256 emoji where vanilla accepts 128, and a pasted emoji could
take a place the next character should have been refused.

`ui::EditBox` (the screens' field) and `screen_draw::TextField` (the chat box
and the native screen API's field) now measure the maximum in UTF-16 units and
keep whole characters, which is the prefix vanilla's insertText keeps after
its half-surrogate guard drops the split one.
2026-11-09 08:03:43 +00:00
Nikita Maximilian GrimmandClaude Opus 5.5 412e1a72c9 A projectile shoots with its own random after its UUID's draws
Vanilla's Entity makes its UUID from the first two draws of its own random
(Mth.createInsecureUUID), and Projectile.shoot spreads the shot with that same
random, from which an arrow keeps drawing (AbstractArrow.setBaseDamageFromMob,
its later rolls). Horizon shot dispensed, crossbow and mob projectiles with a
fresh random that never drew the UUID, player throws with the level's random,
gave an arrow's later draws a random of their own, and gave projectiles a
clock-seeded UUID when first asked.

An arrow now makes its own random in Projectile::new and draws its UUID first;
another kind makes it at the shot (shoot_own), the same stream since nothing
draws between. Every shot goes through shoot_own, the arrow keeps the random,
and the skeleton's base damage comes from it. add_projectile keeps the UUID; a
player's trident takes its shot's. Loaded and summoned projectiles take their
own random's UUID unless the tag has one.

The seed sync proof reads a dispensed arrow's and snowball's UUID and Motion
and a summoned arrow's UUID: 25/30 on main, 30/30 with this.

Part of #12561

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 05:21:24 +01:00
MSedgeMC 555ab73579 Merge remote-tracking branch 'upstream/main' into HEAD
# Conflicts:
#	crates/horizon-client/src/mobs/goals.rs
2026-11-08 15:19:47 +00:00
Horizon agentandClaude Opus 5.5 f4c1e560e8 Drop the .analysis scratch files
They are a local analysis's inputs and outputs, not part of the fix.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 13:28:19 +01:00
MSedgeMC fcfec5fe13 A breeding animal stops when its partner panics
`BreedGoal.getFreePartner` drops a panicking partner and
`BreedGoal.canContinueToUse` requires `!partner.isPanicking()`. Horizon's
`Breed` neither snapshotted the panic state nor tested it, so a pair kept
breeding after one of them started panicking.
2026-11-08 12:26:50 +00:00
RINNECODERandClaude Opus 5.5 61127ad12d Block sprites: on Metal the arrays grow past 512 layers when the sprites need it
Metal keeps 512 layers per sprite array, so the 8 arrays terrain.wgsl binds
hold at most 4096 block textures and a set with more (10,271) was refused
at startup. The split now grows to fit the arrays (up to the GPU's limit
per array) only when the textures need it; up to 4096 it stays 512, as
before.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 17:26:09 -06:00
104 changed files with 3510 additions and 1228 deletions

No files matched your search

+164 -19
View File
@@ -4,6 +4,7 @@
use horizon_core::paletted::{PalettedContainer, Strategy};
use horizon_world::nbt::{Compound, Tag};
use horizon_world::registry::BlockStateId;
use std::sync::atomic::{AtomicI16, AtomicU64, Ordering};
use std::sync::{Arc, OnceLock};
pub mod block_entity;
@@ -457,6 +458,44 @@ impl Light {
}
}
}
/// One of a chunk's heightmaps (`Chunk::surface`): the heights, built whole on the first read,
/// and the columns a write may have moved since, each found again on its next read. Reads share
/// the chunk (helper threads read the world): a stale column is found the same by any of them.
#[derive(Default)]
struct Heights {
heights: OnceLock<Box<[AtomicI16; 256]>>,
stale: [AtomicU64; 4],
}
impl Heights {
/// `Heightmap.update`: a write two or more below column `i`'s height can't move it; any
/// other marks the column.
fn write(&mut self, i: usize, y: i32) {
let Some(heights) = self.heights.get_mut() else {
return;
};
let stale = self.stale[i >> 6].get_mut();
if *stale & 1 << (i & 63) == 0 && y >= i32::from(*heights[i].get_mut()) - 1 {
*stale |= 1 << (i & 63);
}
}
}
impl Clone for Heights {
fn clone(&self) -> Self {
let copy = Self::default();
// A reader can refresh a height and then clear its stale bit while we clone. Copy the
// bits first: a stale snapshot will rescan; a clean one acquires the refreshed height.
// Copying heights first could keep an old height with a newly cleared bit.
for (to, from) in copy.stale.iter().zip(&self.stale) {
to.store(from.load(Ordering::Acquire), Ordering::Relaxed);
}
if let Some(heights) = self.heights.get() {
let _ = copy.heights.set(Box::new(std::array::from_fn(|i| {
AtomicI16::new(heights[i].load(Ordering::Relaxed))
})));
}
copy
}
}
#[inline]
fn column(x: i32, z: i32) -> usize {
((z & 15) << 4 | (x & 15)) as usize
@@ -574,10 +613,10 @@ pub struct Chunk {
pub sections: Vec<Arc<Section>>,
/// `Heightmap.Types.WORLD_SURFACE` per column (`z << 4 | x`): one above
/// the highest non-air block, or the column's minimum Y. Built on the first read; a
/// `set` that can move a column's surface drops it.
surface: OnceLock<Box<[i16; 256]>>,
/// `set` that can move a column's surface marks that column, found again on its next read.
surface: Heights,
/// `Heightmap.Types.MOTION_BLOCKING` per column, kept like `surface`.
motion_blocking: OnceLock<Box<[i16; 256]>>,
motion_blocking: Heights,
pub lit: bool,
/// Light read from a save with `isLightOn`: no light job is needed.
pub light_loaded: bool,
@@ -618,8 +657,8 @@ impl Chunk {
z,
min_y: layout.min_y(),
sections: (0..layout.sections()).map(|_| empty.clone()).collect(),
surface: OnceLock::new(),
motion_blocking: OnceLock::new(),
surface: Heights::default(),
motion_blocking: Heights::default(),
lit: false,
light_loaded: false,
fresh: false,
@@ -698,12 +737,7 @@ impl Chunk {
// move it. Any other change rebuilds that map on the next read.
if previous != v {
for map in [&mut self.surface, &mut self.motion_blocking] {
if map
.get()
.is_some_and(|heights| y >= i32::from(heights[column(x, z)]) - 1)
{
*map = OnceLock::new();
}
map.write(column(x, z), y);
}
}
previous
@@ -714,24 +748,55 @@ impl Chunk {
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();
self.surface = Heights::default();
self.motion_blocking = Heights::default();
}
/// `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.
/// Sections must change only through `set` once this has been read.
pub fn world_surface(&self, x: i32, z: i32, air: impl Fn(u32) -> bool) -> i32 {
let surface = self.surface.get_or_init(|| self.prime(|v| !air(v)));
i32::from(surface[column(x, z)])
self.height(&self.surface, x, z, |v| !air(v))
}
/// `getHeight(Heightmap.Types.MOTION_BLOCKING, x, z)` (`x`, `z` within
/// the chunk): one above the highest state where `blocks` (a motion
/// blocking block or a fluid), or the column's minimum Y. Kept as
/// `world_surface` is.
pub fn motion_blocking(&self, x: i32, z: i32, blocks: impl Fn(u32) -> bool) -> i32 {
let heights = self.motion_blocking.get_or_init(|| self.prime(blocks));
i32::from(heights[column(x, z)])
self.height(&self.motion_blocking, x, z, blocks)
}
/// One column's height in `map`: the whole map built on its first read, a column a write
/// marked found again down that column alone.
fn height(&self, map: &Heights, x: i32, z: i32, counts: impl Fn(u32) -> bool) -> i32 {
let heights = map.heights.get_or_init(|| {
let built = self.prime(&counts);
Box::new(std::array::from_fn(|i| AtomicI16::new(built[i])))
});
let i = column(x, z);
let (word, bit) = (i >> 6, 1u64 << (i & 63));
if map.stale[word].load(Ordering::Acquire) & bit != 0 {
let height = self.column_height(i, &counts);
heights[i].store(height, Ordering::Relaxed);
map.stale[word].fetch_and(!bit, Ordering::Release);
return i32::from(height);
}
i32::from(heights[i].load(Ordering::Relaxed))
}
/// [`Self::prime`] for column `i` alone.
fn column_height(&self, i: usize, counts: impl Fn(u32) -> bool) -> i16 {
for (s, section) in self.sections.iter().enumerate().rev() {
if section.blocks.uniform().is_some_and(|v| !counts(v)) {
continue;
}
let base = self.min_y + s as i32 * 16;
if let Some(dy) = (0..16)
.rev()
.find(|&dy| counts(section.blocks.get(dy << 8 | i)))
{
return (base + dy as i32 + 1) as i16;
}
}
self.min_y as i16
}
/// `Heightmap.primeHeightmaps`: each section from the top, skipping
/// those of one state that doesn't count, until every column is found.
@@ -797,7 +862,7 @@ impl Chunk {
+ self.sections.capacity() * std::mem::size_of::<Arc<Section>>();
census.heightmap_bytes += [&self.surface, &self.motion_blocking]
.iter()
.filter(|map| map.get().is_some())
.filter(|map| map.heights.get().is_some())
.count()
* std::mem::size_of::<[i16; 256]>();
for section in &self.sections {
@@ -1020,9 +1085,89 @@ mod tests {
let before = chunk.world_surface(3, 3, is_air);
chunk.set(3, MIN_Y + HEIGHT, 3, stone);
chunk.set(3, before - 1, 3, chunk.get(3, before - 1, 3));
assert!(chunk.surface.get().is_some());
assert!(chunk.surface.heights.get().is_some());
assert_eq!(chunk.world_surface(3, 3, |_| unreachable!()), before);
}
/// A write two below a column's height can't move it, so the next read asks about no block;
/// a write at or above it (placing on top, clearing the top block) is found again on the next
/// read by looking down that column alone, and the other columns keep their heights.
#[test]
fn a_write_finds_its_own_column_again() {
// Each column holds its own state (16 + i), so a read that looks at another column's
// blocks shows up as a state that isn't air or this column's.
let state = |i: usize| 16 + i as u32;
let mut chunk = Chunk::empty(0, 0);
for i in 0..256 {
for y in MIN_Y..MIN_Y + 20 + (i as i32 % 7) {
chunk.set(i as i32 & 15, y, i as i32 >> 4, state(i));
}
}
let seen = std::cell::RefCell::new(Vec::new());
let surface = |c: &Chunk, i: usize| {
c.world_surface(i as i32 & 15, i as i32 >> 4, |v| {
seen.borrow_mut().push(v);
v == 0
})
};
let heights: Vec<i32> = (0..256).map(|i| surface(&chunk, i)).collect();
assert_eq!(heights[0], MIN_Y + 20);
assert_eq!(heights[6], MIN_Y + 26);
seen.borrow_mut().clear();
let (column, x, z) = (0x35, 5, 3);
let top = heights[column];
// Two below the height: the column keeps its map entry.
chunk.set(x, top - 2, z, 0);
assert_eq!(surface(&chunk, column), top);
assert!(seen.borrow().is_empty());
chunk.set(x, top - 2, z, state(column));
// At the height: placed on top, found again down this column only.
chunk.set(x, top, z, state(column));
assert_eq!(surface(&chunk, column), top + 1);
assert!(!seen.borrow().is_empty());
assert!(seen.borrow().iter().all(|&v| v == 0 || v == state(column)));
seen.borrow_mut().clear();
assert_eq!(surface(&chunk, column), top + 1);
for i in (0..256).filter(|&i| i != column) {
assert_eq!(surface(&chunk, i), heights[i]);
}
assert!(
seen.borrow().is_empty(),
"a read found again an untouched column"
);
// One below the height: the top block cleared, the height drops by one.
chunk.set(x, top, z, 0);
assert_eq!(surface(&chunk, column), top);
assert!(seen.borrow().iter().all(|&v| v == 0 || v == state(column)));
}
/// Chunk snapshots can be cloned while helper threads refresh a stale height. No snapshot
/// may retain the old height with its stale bit cleared, for either map.
#[test]
fn cloning_during_a_height_refresh_keeps_the_new_height() {
for _ in 0..4096 {
let mut chunk = Chunk::empty(0, 0);
chunk.set(0, MIN_Y + 4, 0, 1);
assert_eq!(chunk.world_surface(0, 0, |v| v == 0), MIN_Y + 5);
assert_eq!(chunk.motion_blocking(0, 0, |v| v != 0), MIN_Y + 5);
chunk.set(0, MIN_Y + 5, 0, 1);
let ready = std::sync::Barrier::new(2);
std::thread::scope(|scope| {
scope.spawn(|| {
ready.wait();
assert_eq!(chunk.world_surface(0, 0, |v| v == 0), MIN_Y + 6);
assert_eq!(chunk.motion_blocking(0, 0, |v| v != 0), MIN_Y + 6);
});
ready.wait();
for _ in 0..4 {
let snapshot = chunk.clone();
assert_eq!(snapshot.world_surface(0, 0, |v| v == 0), MIN_Y + 6);
assert_eq!(snapshot.motion_blocking(0, 0, |v| v != 0), MIN_Y + 6);
}
});
}
}
/// MOTION_BLOCKING ignores states that don't count (air, grass) and
/// follows writes as the surface map does, both maps kept at once.
#[test]
+6 -19
View File
@@ -439,33 +439,20 @@ fn deliver(home: &Path, report: Report) -> Option<PathBuf> {
}
}
/// The report for a hung server tick, written by the watchdog thread (`ServerWatchdog`): the
/// stack is the server thread's, read from the watchdog (`horizon_threads::stack_dump`), as
/// vanilla's report carries the server thread's stack; without it, the details say why.
pub fn report_watchdog(
home: &Path,
side: &'static str,
message: String,
server_stack: Result<String, horizon_threads::stack_dump::NoStack>,
) -> Option<PathBuf> {
let mut details = system_details(side);
let stack = match server_stack {
Ok(stack) => stack_lines(&stack, false),
Err(why) => {
details.push(("Server thread stack".into(), why.to_string()));
String::new()
}
};
/// The report for a hung server tick, written by the watchdog thread
/// (`ServerWatchdog`): the stuck thread's stack is not available to Rust, so
/// the report carries none.
pub fn report_watchdog(home: &Path, side: &'static str, message: String) -> Option<PathBuf> {
deliver(
home,
Report {
time: Local::now(),
description: "Watching Server".into(),
error: format!("{message} (Watching Server)"),
stack,
stack: String::new(),
thread: thread_name(side),
side,
details,
details: system_details(side),
log: crate::logging::recent_lines(),
},
)
+57 -165
View File
@@ -75,6 +75,7 @@ pub use entity_batch::{
WATER_MASK_OVERLAY, WORLD_TEXT_OVERLAY, WORLD_TEXT_POLYGON_OFFSET_OVERLAY, is_native_terrain,
mark_oit,
};
use external::{ShadowCull, shadow_box, shadow_casters, shadow_runs};
use section_fade::{
CHUNK_VISIBILITY_THRESHOLD, FadeInputs, chunk_visibility, fade_millis, section_instance,
section_visibility,
@@ -194,6 +195,7 @@ mod cull;
mod dx12_compiler;
mod end_sky;
pub mod entity_skin;
pub mod external;
mod flat;
mod frame_gen;
mod gpu_culling;
@@ -1369,8 +1371,10 @@ pub struct Renderer {
/// Per-frame scratch, kept to avoid allocating every frame.
visible: Vec<resident::Visible>,
visible_lods: Vec<lod::Visible>,
/// The sections a shader pack's shadow camera sees (`pack_post::ShadowView`).
/// The sections a shader pack's shadow camera sees (`external::ShadowView`).
shadow_visible: Vec<resident::Visible>,
/// The external shader pipeline world frames call (`external`); `None` draws vanilla's way.
external: Option<Box<dyn external::ExternalPipeline>>,
translucent_draws: Vec<(u64, usize, [u32; 5], (u32, Option<u32>))>,
origins: Vec<[f32; 4]>,
/// `DrawIndexedIndirectArgs` words: index count, instance count, first
@@ -1577,9 +1581,9 @@ fn requested_split() -> Option<u32> {
.and_then(|v| v.parse().ok())
.filter(|&n| (1..=SPRITE_SPLIT).contains(&n))
}
fn adapter_requested_split(info: &wgpu::AdapterInfo, requested: Option<u32>) -> Option<u32> {
// Metal can fail to sample large mipmapped array textures despite advertising the layer limit.
requested.or_else(|| (info.backend == wgpu::Backend::Metal).then_some(512))
fn adapter_requested_split(info: &wgpu::AdapterInfo, asked: Option<u32>, n: u32) -> Option<u32> {
// Metal can fail on big mipmapped arrays (#8471): 512 layers each, more only for n > 8 x 512.
asked.or((info.backend == wgpu::Backend::Metal).then(|| n.div_ceil(SPRITE_ARRAYS).max(512)))
}
/// The split the CPU mip chains are cut by before the adapter is known.
fn sprite_split() -> u32 {
@@ -2108,7 +2112,7 @@ fn check_layers(adapter: &wgpu::Adapter, layers: u32) -> Result<u32> {
Ok(layer_split(
adapter.limits().max_texture_array_layers,
info.backend == wgpu::Backend::Gl,
adapter_requested_split(&info, requested_split()),
adapter_requested_split(&info, requested_split(), layers),
layers,
)?)
}
@@ -2823,10 +2827,8 @@ impl Renderer {
// Computed once: the sprite arrays, the shaders' constants and the
// shader pack atlas all cut the sprites by it.
let split = check_layers(&adapter, layers)?;
if split < sprite_split() {
log::info!("Horizon: {split} block textures per array (the GPU's limit)");
}
let info = adapter.get_info();
surface_setup::log_sprite_split(&info, split, layers);
crate::graphics::suggest_reduced_far_detail(crate::graphics::lod_capped(&info));
let size = window.inner_size();
let max_side = device.limits().max_texture_dimension_2d;
@@ -5242,6 +5244,7 @@ impl Renderer {
solid_runs: page_runs::PageRuns::default(),
phase2_runs: page_runs::PageRuns::default(),
shadow_runs: page_runs::PageRuns::default(),
external: None,
block_entities: Default::default(),
block_entity_section_revisions: Default::default(),
block_entity_membership_revisions: Default::default(),
@@ -5625,6 +5628,9 @@ impl Renderer {
self.offscreen = Some(Self::offscreen_target(&self.device, &self.config));
}
self.depth = Self::depth(&self.device, &self.config);
if let Some(pipeline) = &mut self.external {
pipeline.resize([width, height]);
}
}
/// Whether sections are drawn as one index list per frame (`flat`).
pub fn flat_draws(&self) -> bool {
@@ -7261,12 +7267,19 @@ impl Renderer {
}
Ok(report)
}
/// Sets the external shader pipeline world frames call (`external`), or none.
pub fn set_external_pipeline(&mut self, pipeline: Option<Box<dyn external::ExternalPipeline>>) {
self.external = pipeline;
}
/// Makes the running shader pack, of either kind, forget what it kept from earlier frames:
/// the next frame draws as a freshly loaded pack's first. Nothing to do without a pack.
pub fn reset_shader_pack_history(&mut self) {
// A frame still on the submit thread uses the targets and readbacks replaced
// here.
self.land_frame();
if let Some(pipeline) = &mut self.external {
pipeline.reset_history();
}
if let Some((_, Some(post))) = &mut self.pack_post {
post.reset_history(&self.device);
}
@@ -8600,6 +8613,13 @@ impl Renderer {
// A shader pack's shadow pass draws the sections in its camera's box, all of them (the
// occlusion graph follows the eye, not the light), within shadowDistanceRenderMul; for
// an Iris-format pack, only those whose shadow can fall into the camera's view.
let state = external::FrameState {
frame,
output: &output,
};
if let Some(pipeline) = self.external.as_mut().filter(|_| !frame.title) {
pipeline.begin_frame(&state);
}
let shadow_view = self
.pack_post
.as_ref()
@@ -8611,6 +8631,12 @@ impl Renderer {
let native = self.native.as_ref().filter(|_| !frame.title)?;
native.shadow.as_ref()?;
native.frame.shadow_view(frame)
})
.or_else(|| {
self.external
.as_ref()
.filter(|_| !frame.title)?
.shadow_request(&state)
});
// `LevelRenderer.extractSectionDrawGroups`' fade-in time, for the instances.
let fade_ms = fade_millis(frame.chunk_fade);
@@ -11900,13 +11926,12 @@ impl Renderer {
{
self.readback.present(&self.device, &mut encoder, image);
}
// A 0.2 pack is shown at paper white: HDR output lifts nothing of its world.
let lifts_world = !frame.title
&& self.output.frame.format != LINEAR_FRAME
&& self
.native
.as_ref()
.is_none_or(|n| n.frame.api == horizon_shader_api::Api::V0_1);
let lifts_world = output::lifts_world(
frame.title,
self.output.frame.format == LINEAR_FRAME,
self.shader_pack_kind() == Some("iris"),
self.native.as_ref().map(|n| n.frame.api),
);
match (&self.hdr, &surface_frame) {
(Some(hdr), Some(f)) => hdr.present(
&self.queue,
@@ -12742,61 +12767,6 @@ impl Cull for Frusta {
self.0.contains(min, max) || self.1.is_some_and(|f| f.contains(min, max))
}
}
/// A shader pack's shadow camera's box, and with `casters` only what can shade the camera's view
/// (`shadow_casters`): a box must meet all of them.
#[derive(Clone, Copy, Debug)]
pub struct ShadowCull {
pub light: Frustum,
pub casters: Option<[Frustum; 2]>,
/// `shadowDistance × shadowDistanceRenderMul`: only what lies within this many blocks of the
/// camera along each axis, a cube around it. Our probe's Iris 1.11.7 maps keep the sections
/// whose box, grown by `SHADOW_CASTER_MARGIN` as for the casters, meets that cube (#8464):
/// out to its corners, and a section 1 block past its side but not one 1.5 blocks past.
pub reach: Option<f32>,
}
impl ShadowCull {
/// Whether the camera-relative box `min..max`, grown by the margin, meets (`whole`: lies
/// within) `reach`'s cube.
fn within_reach(&self, min: Vec3, max: Vec3, whole: bool) -> bool {
let (min, max) = (min - SHADOW_CASTER_MARGIN, max + SHADOW_CASTER_MARGIN);
self.reach.is_none_or(|d| {
if whole {
min.cmpge(Vec3::splat(-d)).all() && max.cmple(Vec3::splat(d)).all()
} else {
max.cmpge(Vec3::splat(-d)).all() && min.cmple(Vec3::splat(d)).all()
}
})
}
}
/// How far past a section's 16 blocks the shadow pass looks for what can shade the view: our
/// probes' Iris 1.11.7 maps keep the sections whose box, grown by this much on every side, meets
/// `shadow_casters`' shape. Over 76 scenes at fields of view 30 to 110, Iris keeps every section
/// that needs up to 1.1224 blocks and drops every one that needs 1.1252 or more (#8464): one
/// growth, whatever the field of view, of 9/8 (a fit, not Iris's code). Without it the edges of
/// the wedge lose a column of terrain.
const SHADOW_CASTER_MARGIN: f32 = 1.125;
impl Cull for ShadowCull {
fn intersects(&self, min: Vec3, max: Vec3) -> bool {
let (grown_min, grown_max) = (min - SHADOW_CASTER_MARGIN, max + SHADOW_CASTER_MARGIN);
self.within_reach(min, max, false)
&& self.light.intersects(min, max)
&& self
.casters
.is_none_or(|c| c.iter().all(|f| f.intersects(grown_min, grown_max)))
}
fn contains(&self, min: Vec3, max: Vec3) -> bool {
let (grown_min, grown_max) = (min - SHADOW_CASTER_MARGIN, max + SHADOW_CASTER_MARGIN);
self.within_reach(min, max, true)
&& self.light.contains(min, max)
&& self
.casters
.is_none_or(|c| c.iter().all(|f| f.contains(grown_min, grown_max)))
}
/// Our probe's Iris 1.11.7 maps fit testing the casters' whole sections (a guess that matched).
fn tight(&self) -> bool {
self.casters.is_none()
}
}
/// The block entities of `sections` whose box, a block wider on every side, comes within the
/// 64 blocks block entities are drawn to (`Renderer::visible_block_entities_in_frustum`).
fn block_entities_within_reach<'a>(
@@ -12819,17 +12789,6 @@ fn block_entities_within_reach<'a>(
})
.flatten()
}
/// The shadow camera's box that the shadow pass draws terrain in: the sides of `view.cull`, and
/// its near and far planes only with `depth_planes`.
fn shadow_box(view: &pack_post::ShadowView) -> Frustum {
let mut planes = frustum(view.cull);
if !view.depth_planes {
// `frustum`'s faces at depth 0 and 1 take in everything.
planes[4] = Vec4::W;
planes[5] = Vec4::W;
}
Frustum { planes }
}
/// Camera-relative clip planes (`Frustum`).
#[derive(Clone, Copy, Debug)]
pub struct Frustum {
@@ -12953,21 +12912,6 @@ fn projection_center(view_proj: Mat4) -> Option<Vec3> {
let b = DVec3::new(rows[0].w, rows[1].w, rows[2].w);
Some((a.inverse() * -b).as_vec3())
}
/// The runs of a section's solid quads the shadow pass draws: every face within the unculled
/// distance, or everywhere for `every_face`; else the quads that can face the light.
fn shadow_runs(
sides: &Sides,
view: &pack_post::ShadowView,
origin: [f32; 3],
draw: impl FnMut(u32, u32),
) {
let unculled = view.unculled.is_some_and(|d| {
let o = Vec3::from(origin) + 8.0;
o.x.abs().max(o.z.abs()) <= d + 8.0
});
let toward = (!unculled && !view.every_face).then_some(view.light * 1.0e5);
facing_runs(sides, toward, origin, draw);
}
/// Calls `draw(first, quads)` for each run of a mesh's solid quads that can
/// face `eye` (`projection_center`), the mesh at the camera-relative
@@ -12994,68 +12938,6 @@ fn facing_runs_from(
None => sides.every(part, draw),
}
}
/// The camera's view `view_proj` (wgpu depth, reversed or not) stretched without end toward
/// `light` (a direction): the boxes whose shadow, cast away from the light, can fall into the
/// view. Its planes are the view's faces turned toward the light, and through each edge between
/// such a face and one turned away, the plane along the light; unused ones take in everything.
fn shadow_casters(view_proj: Mat4, light: Vec3) -> [Frustum; 2] {
let faces = frustum(view_proj);
let toward = faces.map(|p| p.truncate().dot(light) >= 0.0);
let inverse = view_proj.inverse();
// Corner (x, y, z) of clip space's box; `frustum`'s faces at x -1 and 1 are 0 and 1, at
// y -1 and 1 are 2 and 3, at depth 0 and 1 are 4 and 5.
let corner = |c: [usize; 3]| {
inverse.project_point3(Vec3::new(
c[0] as f32 * 2.0 - 1.0,
c[1] as f32 * 2.0 - 1.0,
c[2] as f32,
))
};
let corners: Vec<[usize; 3]> = (0..8).map(|i| [i & 1, (i >> 1) & 1, i >> 2]).collect();
let centre = corners.iter().map(|&c| corner(c)).sum::<Vec3>() / 8.0;
let mut planes = [Vec4::W; 12];
let mut n = 0;
for (k, &face) in faces.iter().enumerate() {
if toward[k] {
planes[n] = face;
n += 1;
}
}
for a in &corners {
for axis in 0..3 {
if a[axis] == 1 {
continue;
}
let mut b = *a;
b[axis] = 1;
// The edge from a to b lies on the faces of the two other axes.
let [f, g] = [(axis + 1) % 3, (axis + 2) % 3].map(|o| o * 2 + a[o]);
if toward[f] == toward[g] {
continue;
}
let (p, q) = (corner(*a), corner(b));
let normal = (q - p).cross(light);
if normal.length_squared() < 1e-12 {
continue;
}
let normal = normal.normalize();
let side = if normal.dot(centre - p) < 0.0 {
-1.0
} else {
1.0
};
// At most six such edges with at most four faces toward the light: 12 planes.
if let Some(plane) = planes.get_mut(n) {
*plane = (normal * side).extend(-normal.dot(p) * side);
n += 1;
}
}
}
let [a, b] = [0, 6].map(|i| Frustum {
planes: planes[i..i + 6].try_into().expect("six planes"),
});
[a, b]
}
fn taa_frustum(view_proj: Mat4, rendered_size: [u32; 2]) -> Frustum {
// TAA shifts clip x/y by at most half a rendered pixel times clip w.
// Widen x/y before extracting the planes: this includes every such shifted
@@ -15065,11 +14947,21 @@ mod sprite_upload_tests {
limit_bucket: None,
};
info.backend = wgpu::Backend::Metal;
assert_eq!(adapter_requested_split(&info, None), Some(512));
assert_eq!(adapter_requested_split(&info, Some(1024)), Some(1024));
assert_eq!(adapter_requested_split(&info, None, 1200), Some(512));
assert_eq!(
adapter_requested_split(&info, None, 512 * SPRITE_ARRAYS),
Some(512)
);
assert_eq!(adapter_requested_split(&info, Some(1024), 1200), Some(1024));
// More sprites than the arrays hold at 512: the arrays grow, up to the GPU's layers.
let split = adapter_requested_split(&info, None, 10_271);
assert_eq!(split, Some(1284));
assert_eq!(layer_split(2048, false, split, 10_271), Ok(1284));
let split = adapter_requested_split(&info, None, 2048 * SPRITE_ARRAYS + 1);
assert!(layer_split(2048, false, split, 2048 * SPRITE_ARRAYS + 1).is_err());
info.backend = wgpu::Backend::Vulkan;
assert_eq!(adapter_requested_split(&info, None), None);
assert_eq!(adapter_requested_split(&info, None, 10_271), None);
}
#[test]
@@ -15354,7 +15246,7 @@ mod shadow_face_tests {
}
/// The layers (faces) of the quads the shadow pass draws of `solid`.
fn drawn(view: &pack_post::ShadowView, origin: [f32; 3]) -> Vec<u16> {
fn drawn(view: &external::ShadowView, origin: [f32; 3]) -> Vec<u16> {
let mut solid = block([7.0, 7.0, 7.0]);
let sides = crate::mesher::sort_tinted(&mut solid, &mut Vec::new());
let mut layers = Vec::new();
@@ -15367,8 +15259,8 @@ mod shadow_face_tests {
layers
}
fn view(every_face: bool) -> pack_post::ShadowView {
pack_post::ShadowView {
fn view(every_face: bool) -> external::ShadowView {
external::ShadowView {
cull: Mat4::IDENTITY,
light: Vec3::Y,
distance: None,
@@ -15582,7 +15474,7 @@ mod shadow_caster_tests {
..horizon_shaders::Shadow::default()
};
let camera = DVec3::new(13.5, y + 1.62, -13.5);
let view = pack_post::ShadowView {
let view = external::ShadowView {
cull: pack_uniforms::shadow_culling_at(Vec3::Y, camera, &shadow),
light: Vec3::Y,
distance: None,
@@ -0,0 +1,214 @@
//! The hook an external shader pipeline fills (#14035, layer plan M6). A shader pack's runtime
//! (a platform crate, later) implements [`ExternalPipeline`]; the renderer calls it at fixed
//! points of a world frame and draws vanilla's way wherever it doesn't answer. With none set
//! (`Renderer::set_external_pipeline`), each point is one `Option` test.
//!
//! Workspace-internal: the trait names the engine's frame, its mesh format and its pass order,
//! has one implementer in this workspace, carries no stability promise and is never exposed to
//! mods. A mesher or pass change updates its implementer in the same PR. HDR output
//! (`output.rs`, `frame_policy`) is not an implementer: it stays the one output stage after the
//! frame, and a pipeline draws into the frame it gives.
//!
//! Shadows are an engine frame point, on request: a pipeline that wants a shadow pass answers
//! [`ShadowView`], the engine culls the sections from it ([`ShadowCull`]) and hands back the
//! terrain seen from the light. Vanilla's path never asks, so it never computes shadow
//! visibility.
use glam::{Mat4, Vec3, Vec4};
use super::{Cull, EffectiveOutput, Frame, Frustum, Sides, facing_runs, frustum};
/// What a world frame gives an external pipeline at its points: the frame's own values, and
/// the display's from the renderer's one output snapshot (`EffectiveOutput`: whether HDR is on,
/// paper white, peak), never worked out again, so each split-screen window keeps its own.
pub struct FrameState<'a> {
pub frame: &'a Frame<'a>,
pub output: &'a EffectiveOutput,
}
/// An external shader pipeline, called by the renderer at fixed points of a world frame (not
/// title frames). Every method has a default that leaves vanilla's way in place.
pub trait ExternalPipeline: Send {
/// A world frame starts, before culling.
fn begin_frame(&mut self, _state: &FrameState<'_>) {}
/// The shadow pass this frame wants; `None` draws none.
fn shadow_request(&self, _state: &FrameState<'_>) -> Option<ShadowView> {
None
}
/// The window's drawable size changed.
fn resize(&mut self, _size: [u32; 2]) {}
/// Forget what was kept from earlier frames: the next is drawn as a fresh pipeline's first.
fn reset_history(&mut self) {}
}
/// What the shadow camera sees of the terrain, for the game's draw lists: its view and
/// projection in wgpu's clip space, the direction toward the light, and how far from the camera
/// terrain is drawn (`shadowDistanceRenderMul`).
pub struct ShadowView {
pub cull: glam::Mat4,
pub light: glam::Vec3,
pub distance: Option<f32>,
/// Draw everything, every face, within this many blocks (`shadow.culling=false|reversed`):
/// packs that voxelize the world in their shadow pass need all of it.
pub unculled: Option<f32>,
/// Every face of the drawn sections, not only those that can face the light. Iris 1.11.7's
/// shadow map holds the faces turned away from the light too (#9033): under a hill at low sun
/// a cave's ceiling, which faces away, is often all that shadows the cave.
pub every_face: bool,
/// Only the sections whose shadow can fall into the camera's view: those the camera's
/// frustum, stretched toward the light, takes in (`shadow_casters`). Our probes' Iris 1.11.7
/// maps fit keeping only those with `shadow.culling` unset (#8464): under a noon sun, only
/// the terrain above the ground the camera sees; with a low sun ahead, none behind the camera.
pub view_casters: bool,
/// Whether the projection's near and far planes in `cull` drop sections too. Our probes'
/// Iris 1.11.7 maps hold the terrain at every depth we put it along the light (from 535 blocks
/// beyond the shadow camera to about 120 before it), whatever `shadowNearPlane` and
/// `shadowFarPlane` the pack sets, or none (#10403): only its sides cull.
pub depth_planes: bool,
}
/// A shader pack's shadow camera's box, and with `casters` only what can shade the camera's view
/// (`shadow_casters`): a box must meet all of them.
#[derive(Clone, Copy, Debug)]
pub struct ShadowCull {
pub light: Frustum,
pub casters: Option<[Frustum; 2]>,
/// `shadowDistance × shadowDistanceRenderMul`: only what lies within this many blocks of the
/// camera along each axis, a cube around it. Our probe's Iris 1.11.7 maps keep the sections
/// whose box, grown by `SHADOW_CASTER_MARGIN` as for the casters, meets that cube (#8464):
/// out to its corners, and a section 1 block past its side but not one 1.5 blocks past.
pub reach: Option<f32>,
}
impl ShadowCull {
/// Whether the camera-relative box `min..max`, grown by the margin, meets (`whole`: lies
/// within) `reach`'s cube.
fn within_reach(&self, min: Vec3, max: Vec3, whole: bool) -> bool {
let (min, max) = (min - SHADOW_CASTER_MARGIN, max + SHADOW_CASTER_MARGIN);
self.reach.is_none_or(|d| {
if whole {
min.cmpge(Vec3::splat(-d)).all() && max.cmple(Vec3::splat(d)).all()
} else {
max.cmpge(Vec3::splat(-d)).all() && min.cmple(Vec3::splat(d)).all()
}
})
}
}
/// How far past a section's 16 blocks the shadow pass looks for what can shade the view: our
/// probes' Iris 1.11.7 maps keep the sections whose box, grown by this much on every side, meets
/// `shadow_casters`' shape. Over 76 scenes at fields of view 30 to 110, Iris keeps every section
/// that needs up to 1.1224 blocks and drops every one that needs 1.1252 or more (#8464): one
/// growth, whatever the field of view, of 9/8 (a fit, not Iris's code). Without it the edges of
/// the wedge lose a column of terrain.
const SHADOW_CASTER_MARGIN: f32 = 1.125;
impl Cull for ShadowCull {
fn intersects(&self, min: Vec3, max: Vec3) -> bool {
let (grown_min, grown_max) = (min - SHADOW_CASTER_MARGIN, max + SHADOW_CASTER_MARGIN);
self.within_reach(min, max, false)
&& self.light.intersects(min, max)
&& self
.casters
.is_none_or(|c| c.iter().all(|f| f.intersects(grown_min, grown_max)))
}
fn contains(&self, min: Vec3, max: Vec3) -> bool {
let (grown_min, grown_max) = (min - SHADOW_CASTER_MARGIN, max + SHADOW_CASTER_MARGIN);
self.within_reach(min, max, true)
&& self.light.contains(min, max)
&& self
.casters
.is_none_or(|c| c.iter().all(|f| f.contains(grown_min, grown_max)))
}
/// Our probe's Iris 1.11.7 maps fit testing the casters' whole sections (a guess that matched).
fn tight(&self) -> bool {
self.casters.is_none()
}
}
/// The shadow camera's box that the shadow pass draws terrain in: the sides of `view.cull`, and
/// its near and far planes only with `depth_planes`.
pub(super) fn shadow_box(view: &ShadowView) -> Frustum {
let mut planes = frustum(view.cull);
if !view.depth_planes {
// `frustum`'s faces at depth 0 and 1 take in everything.
planes[4] = Vec4::W;
planes[5] = Vec4::W;
}
Frustum { planes }
}
/// The runs of a section's solid quads the shadow pass draws: every face within the unculled
/// distance, or everywhere for `every_face`; else the quads that can face the light.
pub(super) fn shadow_runs(
sides: &Sides,
view: &ShadowView,
origin: [f32; 3],
draw: impl FnMut(u32, u32),
) {
let unculled = view.unculled.is_some_and(|d| {
let o = Vec3::from(origin) + 8.0;
o.x.abs().max(o.z.abs()) <= d + 8.0
});
let toward = (!unculled && !view.every_face).then_some(view.light * 1.0e5);
facing_runs(sides, toward, origin, draw);
}
/// The camera's view `view_proj` (wgpu depth, reversed or not) stretched without end toward
/// `light` (a direction): the boxes whose shadow, cast away from the light, can fall into the
/// view. Its planes are the view's faces turned toward the light, and through each edge between
/// such a face and one turned away, the plane along the light; unused ones take in everything.
pub(super) fn shadow_casters(view_proj: Mat4, light: Vec3) -> [Frustum; 2] {
let faces = frustum(view_proj);
let toward = faces.map(|p| p.truncate().dot(light) >= 0.0);
let inverse = view_proj.inverse();
// Corner (x, y, z) of clip space's box; `frustum`'s faces at x -1 and 1 are 0 and 1, at
// y -1 and 1 are 2 and 3, at depth 0 and 1 are 4 and 5.
let corner = |c: [usize; 3]| {
inverse.project_point3(Vec3::new(
c[0] as f32 * 2.0 - 1.0,
c[1] as f32 * 2.0 - 1.0,
c[2] as f32,
))
};
let corners: Vec<[usize; 3]> = (0..8).map(|i| [i & 1, (i >> 1) & 1, i >> 2]).collect();
let centre = corners.iter().map(|&c| corner(c)).sum::<Vec3>() / 8.0;
let mut planes = [Vec4::W; 12];
let mut n = 0;
for (k, &face) in faces.iter().enumerate() {
if toward[k] {
planes[n] = face;
n += 1;
}
}
for a in &corners {
for axis in 0..3 {
if a[axis] == 1 {
continue;
}
let mut b = *a;
b[axis] = 1;
// The edge from a to b lies on the faces of the two other axes.
let [f, g] = [(axis + 1) % 3, (axis + 2) % 3].map(|o| o * 2 + a[o]);
if toward[f] == toward[g] {
continue;
}
let (p, q) = (corner(*a), corner(b));
let normal = (q - p).cross(light);
if normal.length_squared() < 1e-12 {
continue;
}
let normal = normal.normalize();
let side = if normal.dot(centre - p) < 0.0 {
-1.0
} else {
1.0
};
// At most six such edges with at most four faces toward the light: 12 planes.
if let Some(plane) = planes.get_mut(n) {
*plane = (normal * side).extend(-normal.dot(p) * side);
n += 1;
}
}
}
let [a, b] = [0, 6].map(|i| Frustum {
planes: planes[i..i + 6].try_into().expect("six planes"),
});
[a, b]
}
@@ -295,6 +295,22 @@ pub(crate) fn resolve_output(
}
}
/// Whether HDR output lifts the world's light-source marks above paper white. Not on the title
/// screen or the float frame, and not under a pack that lights its world itself: a 0.2 pack is
/// shown at paper white, and a `shaders/` pack's `final` pass leaves its own alpha in the
/// frame, which isn't marks (HDR lighting is off under it too).
pub(crate) fn lifts_world(
title: bool,
float_frame: bool,
shaders_pack: bool,
native_api: Option<horizon_shader_api::Api>,
) -> bool {
!title
&& !float_frame
&& !shaders_pack
&& native_api.is_none_or(|api| api == horizon_shader_api::Api::V0_1)
}
#[cfg(test)]
pub(crate) mod tests {
use super::*;
@@ -337,6 +353,29 @@ pub(crate) mod tests {
resolve_output(settings, caps, reading, frame, true)
}
/// HDR output lifts light-source marks only on the 8/16-bit frame's world with no pack or
/// a 0.1 pack: a `shaders/` pack's alpha isn't marks, and a 0.2 pack is shown at paper white.
#[test]
fn only_horizons_own_gamma_world_is_lifted() {
use horizon_shader_api::Api::{V0_1, V0_2};
// (title, float frame, `shaders/` pack, Horizon pack) -> lifts
let cases = [
((false, false, false, None), true),
((false, false, false, Some(V0_1)), true),
((false, false, true, None), false),
((false, false, false, Some(V0_2)), false),
((false, true, false, None), false),
((true, false, false, None), false),
];
for ((title, float, shaders, api), lifts) in cases {
assert_eq!(
lifts_world(title, float, shaders, api),
lifts,
"title {title}, float {float}, shaders/ pack {shaders}, pack {api:?}"
);
}
}
/// Every mode against every offer, system report, pack and device: HDR output only where
/// the mode asks for it and the destination offers it, and the frame each combination
/// draws in, as `Renderer::new` picked it before the snapshot.
@@ -1201,31 +1201,7 @@ pub(super) type TerrainDraw<'a> = dyn FnMut(
Option<(u32, &wgpu::BindGroupLayout)>,
) + 'a;
/// What the shadow camera sees of the terrain, for the game's draw lists: its view and
/// projection in wgpu's clip space, the direction toward the light, and how far from the camera
/// terrain is drawn (`shadowDistanceRenderMul`).
pub(super) struct ShadowView {
pub cull: glam::Mat4,
pub light: glam::Vec3,
pub distance: Option<f32>,
/// Draw everything, every face, within this many blocks (`shadow.culling=false|reversed`):
/// packs that voxelize the world in their shadow pass need all of it.
pub unculled: Option<f32>,
/// Every face of the drawn sections, not only those that can face the light. Iris 1.11.7's
/// shadow map holds the faces turned away from the light too (#9033): under a hill at low sun
/// a cave's ceiling, which faces away, is often all that shadows the cave.
pub every_face: bool,
/// Only the sections whose shadow can fall into the camera's view: those the camera's
/// frustum, stretched toward the light, takes in (`shadow_casters`). Our probes' Iris 1.11.7
/// maps fit keeping only those with `shadow.culling` unset (#8464): under a noon sun, only
/// the terrain above the ground the camera sees; with a low sun ahead, none behind the camera.
pub view_casters: bool,
/// Whether the projection's near and far planes in `cull` drop sections too. Our probes'
/// Iris 1.11.7 maps hold the terrain at every depth we put it along the light (from 535 blocks
/// beyond the shadow camera to about 120 before it), whatever `shadowNearPlane` and
/// `shadowFarPlane` the pack sets, or none (#10403): only its sides cull.
pub depth_planes: bool,
}
pub(super) use super::external::ShadowView;
/// An arena page's key, native vertices and optional per-quad source records.
pub(super) type TerrainPage = (u32, wgpu::Buffer, Option<wgpu::Buffer>);
@@ -3831,7 +3807,7 @@ impl PackPost {
timestamp_writes: self
.timing
.as_mut()
.and_then(|t| t.compute_writes(&pass.name)),
.and_then(|t| t.compute_writes(&format!("{} (compute)", pass.name))),
});
cp.set_pipeline(&pipeline);
cp.set_bind_group(0, &group, &[]);
@@ -151,7 +151,9 @@ impl PassTiming {
Some(stamps(&self.queries, i))
}
/// Timestamp writes for the compute pass `name`, in a timed frame.
/// Timestamp writes for the compute pass `name`, in a timed frame. Shader packs' compute
/// passes come as `<program> (compute)`, apart from the fragment program of the same name
/// (`deferred2.csh` beside `deferred2.fsh`), whose time theirs would otherwise add to.
pub fn compute_writes(&mut self, name: &str) -> Option<wgpu::ComputePassTimestampWrites<'_>> {
let i = self.push(name)?;
Some(compute_stamps(&self.queries, i))
@@ -236,23 +238,7 @@ impl PassTiming {
drop(data);
self.read.unmap();
let times = pass_times(&stamps, self.period_ns);
for (name, ms) in self.names.iter().zip(times) {
let Some(ms) = ms.filter(|_| !name.is_empty()) else {
continue;
};
let i = match self.samples.iter().position(|s| s.0 == *name) {
Some(i) => i,
None => {
self.samples.push((name.clone(), VecDeque::new()));
self.samples.len() - 1
}
};
let window = &mut self.samples[i].1;
if window.len() == WINDOW {
window.pop_front();
}
window.push_back(ms);
}
record(&mut self.samples, self.names.iter().zip(times));
self.reports += 1;
if self.log && self.reports.is_multiple_of(REPORT) {
let medians = self.medians();
@@ -283,6 +269,40 @@ impl PassTiming {
}
}
/// Adds one timed frame's pass times to `samples`, each pass's window of its last `WINDOW`
/// frames. Passes of one name in a frame (a program drawn over several render passes) count as
/// one sample, their sum: as separate samples they shared one window, and its median was the
/// time of neither. Unnamed and unstamped passes are left out.
fn record<'a>(
samples: &mut Vec<(String, VecDeque<f32>)>,
frame: impl Iterator<Item = (&'a String, Option<f32>)>,
) {
let mut sums: Vec<(&str, f32)> = Vec::new();
for (name, ms) in frame {
let Some(ms) = ms.filter(|_| !name.is_empty()) else {
continue;
};
match sums.iter_mut().find(|s| s.0 == name) {
Some(s) => s.1 += ms,
None => sums.push((name, ms)),
}
}
for (name, ms) in sums {
let i = match samples.iter().position(|s| s.0 == name) {
Some(i) => i,
None => {
samples.push((name.to_owned(), VecDeque::new()));
samples.len() - 1
}
};
let window = &mut samples[i].1;
if window.len() == WINDOW {
window.pop_front();
}
window.push_back(ms);
}
}
/// Pass `i`'s beginning and end timestamps.
fn stamps(queries: &wgpu::QuerySet, i: u32) -> wgpu::RenderPassTimestampWrites<'_> {
wgpu::RenderPassTimestampWrites {
@@ -323,7 +343,21 @@ fn pass_times(stamps: &[u64], period_ns: f32) -> Vec<Option<f32>> {
#[cfg(test)]
mod tests {
use super::pass_times;
use super::{pass_times, record};
use std::collections::VecDeque;
#[test]
fn passes_of_one_name_count_as_their_sum() {
let names = ["terrain", "sky", "terrain", "", "water"].map(String::from);
let times = [Some(1.0), Some(2.0), Some(3.0), Some(9.0), None];
let mut samples: Vec<(String, VecDeque<f32>)> = Vec::new();
record(&mut samples, names.iter().zip(times));
let got: Vec<(&str, Vec<f32>)> = samples
.iter()
.map(|(n, w)| (n.as_str(), w.iter().copied().collect()))
.collect();
assert_eq!(got, [("terrain", vec![4.0]), ("sky", vec![2.0])]);
}
#[test]
fn overlapping_passes_cost_from_the_previous_end() {
@@ -221,6 +221,18 @@ pub(super) async fn open_adapter(
configured,
})
}
/// Logs a sprite split below the CPU's (the GPU's limit), and warns when Metal's arrays grew
/// past 512 layers (#12155), so a big pack's texture-corruption report ties to it (#8471).
pub(super) fn log_sprite_split(info: &wgpu::AdapterInfo, split: u32, layers: u32) {
if info.backend == wgpu::Backend::Metal && split > 512 {
log::warn!(
"Horizon: {split}-layer Metal sprite arrays for {layers} on {}",
info.name
);
} else if split < super::sprite_split() {
log::info!("Horizon: {split} block textures per array (the GPU's limit)");
}
}
#[cfg(test)]
mod tests {
use super::{AdapterChoice, pick_adapter};
+6 -87
View File
@@ -928,7 +928,7 @@ impl Opt {
Opt::CloudRange => s.cloud_range = step(2.0, 128.0) as i32,
Opt::EntityDistance => s.entity_distance = step(2.0, 20.0) / 4.0,
Opt::BiomeBlend => s.biome_blend = step(0.0, 7.0) as i32,
Opt::Preset => s.graphics_preset = PRESETS[step(0.0, 3.0) as usize].into(),
Opt::Preset => s.graphics_preset = PRESETS[preset_from_slider(v)].into(),
Opt::Mipmaps => s.mipmap_levels = step(0.0, 4.0) as i32,
Opt::MaxAnisotropy => s.file.set(
"maxAnisotropyBit",
@@ -1218,92 +1218,6 @@ impl Opt {
}
}
}
const PRESETS: [&str; 4] = ["fast", "fancy", "fabulous", "custom"];
fn preset_index(p: &str) -> usize {
PRESETS.iter().position(|x| *x == p).unwrap_or(1)
}
/// A slider moved to `v` (OptionInstance.set): the graphics preset applies
/// its options when its value changes (`Options.applyGraphicsPreset`).
pub fn set_slider(opt: Opt, s: &mut Settings, v: f32) {
let preset = preset_index(&s.graphics_preset);
opt.set_slider(s, v);
if opt == Opt::Preset && preset_index(&s.graphics_preset) != preset {
apply_preset(s);
}
}
/// A slider pressed or dragged to `v`: apply it live, and tell the game when
/// a category volume changed (`Options.createSoundSliderOptionInstance`'s
/// callback, which `OptionInstance.set` runs only for a new value).
fn drag_slider(opt: Opt, s: &mut Settings, v: f32, events: &mut Vec<Event>) {
let volume = |s: &Settings| match opt {
Opt::Volume(0) => Some(s.master_volume),
Opt::Volume(i) => Some(s.volumes[i]),
_ => None,
};
let before = volume(s);
set_slider(opt, s, v);
events.push(Event::SliderDrag);
if let (Opt::Volume(source), Some(after)) = (opt, volume(s))
&& before != Some(after)
{
events.push(Event::Volume(source, after));
}
}
/// GraphicsPreset.apply: the options Fast, Fancy and Fabulous set.
pub fn apply_preset(s: &mut Settings) {
let fabulous = s.graphics_preset == "fabulous";
match s.graphics_preset.as_str() {
"fast" => {
s.biome_blend = 1;
s.render_distance = 8;
s.prioritize_chunk_updates = 0;
s.simulation_distance = 6;
s.smooth_lighting = false;
s.clouds = "fast".into();
s.particles = 1;
s.mipmap_levels = 2;
s.entity_shadows = false;
s.entity_distance = 0.75;
s.menu_blur = 2;
s.cloud_range = 32;
s.cutout_leaves = false;
s.improved_transparency = false;
s.weather_radius = 5;
set_filtering(s, 1, 0);
}
"fancy" | "fabulous" => {
s.biome_blend = 2;
s.render_distance = if fabulous { 32 } else { 16 };
s.prioritize_chunk_updates = 1;
s.simulation_distance = 12;
s.smooth_lighting = true;
s.clouds = "true".into();
s.particles = 0;
s.mipmap_levels = 4;
s.entity_shadows = true;
s.entity_distance = if fabulous { 1.25 } else { 1.0 };
s.menu_blur = 5;
s.cloud_range = if fabulous { 128 } else { 64 };
s.cutout_leaves = true;
s.improved_transparency = fabulous;
s.weather_radius = 10;
// Fabulous takes anisotropic filtering (RGSS on a device with known issues, which
// wgpu does not report).
set_filtering(
s,
if fabulous { 2 } else { 1 },
if fabulous { 2 } else { 1 },
);
}
_ => {}
}
}
/// The preset's `maxAnisotropyBit` and `textureFiltering` (0 none, 1 RGSS, 2 anisotropic).
fn set_filtering(s: &mut Settings, anisotropy_bit: i32, filtering: i32) {
use crate::options_file::V;
s.file.set("maxAnisotropyBit", V::Int(anisotropy_bit));
s.file.set("textureFiltering", V::Int(filtering));
}
/// `Options.preferredGraphicsBackend`'s tooltip: the restart note once the choice differs from
/// the startup one, the description, and Vulkan's warning.
@@ -8284,6 +8198,11 @@ mod tests {
}
}
#[path = "options_ui_preset.rs"]
mod preset;
pub(crate) use preset::{
PRESETS, apply_preset, drag_slider, preset_from_slider, preset_index, set_slider,
};
#[cfg(test)]
#[path = "options_ui_graphics_tests.rs"]
mod graphics_tests;
@@ -0,0 +1,121 @@
//! The graphics preset: `Options.graphicsPreset`'s slider position and the
//! options `GraphicsPreset.apply` sets.
use super::*;
pub(crate) const PRESETS: [&str; 4] = ["fast", "fancy", "fabulous", "custom"];
/// `SliderableEnum.fromSliderValue`: the slider's 0..1 position maps by
/// `floor(v * values)` with 1.0 just below it, clamped to the last stop (4
/// graphics presets), not by rounding `v * 3`.
pub(crate) fn preset_from_slider(v: f32) -> usize {
let v = if v >= 1.0 { 0.99999 } else { v };
((v * 4.0).floor() as usize).min(3)
}
pub(crate) fn preset_index(p: &str) -> usize {
PRESETS.iter().position(|x| *x == p).unwrap_or(1)
}
/// A slider moved to `v` (OptionInstance.set): the graphics preset applies
/// its options when its value changes (`Options.applyGraphicsPreset`).
pub(crate) fn set_slider(opt: Opt, s: &mut Settings, v: f32) {
let preset = preset_index(&s.graphics_preset);
opt.set_slider(s, v);
if opt == Opt::Preset && preset_index(&s.graphics_preset) != preset {
apply_preset(s);
}
}
/// A slider pressed or dragged to `v`: apply it live, and tell the game when
/// a category volume changed (`Options.createSoundSliderOptionInstance`'s
/// callback, which `OptionInstance.set` runs only for a new value).
pub(crate) fn drag_slider(opt: Opt, s: &mut Settings, v: f32, events: &mut Vec<Event>) {
let volume = |s: &Settings| match opt {
Opt::Volume(0) => Some(s.master_volume),
Opt::Volume(i) => Some(s.volumes[i]),
_ => None,
};
let before = volume(s);
set_slider(opt, s, v);
events.push(Event::SliderDrag);
if let (Opt::Volume(source), Some(after)) = (opt, volume(s))
&& before != Some(after)
{
events.push(Event::Volume(source, after));
}
}
/// GraphicsPreset.apply: the options Fast, Fancy and Fabulous set.
pub(crate) fn apply_preset(s: &mut Settings) {
let fabulous = s.graphics_preset == "fabulous";
match s.graphics_preset.as_str() {
"fast" => {
s.biome_blend = 1;
s.render_distance = 8;
s.prioritize_chunk_updates = 0;
s.simulation_distance = 6;
s.smooth_lighting = false;
s.clouds = "fast".into();
s.particles = 1;
s.mipmap_levels = 2;
s.entity_shadows = false;
s.entity_distance = 0.75;
s.menu_blur = 2;
s.cloud_range = 32;
s.cutout_leaves = false;
s.improved_transparency = false;
s.weather_radius = 5;
set_filtering(s, 1, 0);
}
"fancy" | "fabulous" => {
s.biome_blend = 2;
s.render_distance = if fabulous { 32 } else { 16 };
s.prioritize_chunk_updates = 1;
s.simulation_distance = 12;
s.smooth_lighting = true;
s.clouds = "true".into();
s.particles = 0;
s.mipmap_levels = 4;
s.entity_shadows = true;
s.entity_distance = if fabulous { 1.25 } else { 1.0 };
s.menu_blur = 5;
s.cloud_range = if fabulous { 128 } else { 64 };
s.cutout_leaves = true;
s.improved_transparency = fabulous;
s.weather_radius = 10;
// Fabulous takes anisotropic filtering (RGSS on a device with known issues, which
// wgpu does not report).
set_filtering(
s,
if fabulous { 2 } else { 1 },
if fabulous { 2 } else { 1 },
);
}
_ => {}
}
}
/// The preset's `maxAnisotropyBit` and `textureFiltering` (0 none, 1 RGSS, 2 anisotropic).
fn set_filtering(s: &mut Settings, anisotropy_bit: i32, filtering: i32) {
use crate::options_file::V;
s.file.set("maxAnisotropyBit", V::Int(anisotropy_bit));
s.file.set("textureFiltering", V::Int(filtering));
}
#[cfg(test)]
mod tests {
use super::*;
/// `SliderableEnum.fromSliderValue`: the graphics preset slider's stops are
/// floor(v * 4), 1.0 landing on the last, and the value setter follows.
#[test]
fn the_graphics_preset_slider_uses_floor_stops() {
assert_eq!(preset_from_slider(0.0), 0);
assert_eq!(preset_from_slider(0.24), 0);
assert_eq!(preset_from_slider(0.25), 1);
assert_eq!(preset_from_slider(0.49), 1);
assert_eq!(preset_from_slider(0.5), 2);
assert_eq!(preset_from_slider(0.74), 2);
assert_eq!(preset_from_slider(0.75), 3);
assert_eq!(preset_from_slider(1.0), 3);
let mut s = Settings::default();
set_slider(Opt::Preset, &mut s, 0.2);
assert_eq!(s.graphics_preset, "fast");
set_slider(Opt::Preset, &mut s, 0.8);
assert_eq!(s.graphics_preset, "custom");
}
}
@@ -681,26 +681,36 @@ impl TextField {
}
/// `setValue`.
pub fn set_value(&mut self, m: &Measure, value: &str) {
self.value = value.chars().take(self.max_length).collect();
self.value = crate::ui::utf16_prefix(value, self.max_length);
self.move_cursor_to(m, chars(&self.value), false);
self.set_highlight(m, self.cursor);
}
/// `insertText`: replaces the selection within the maximum length. True
/// when the value changed (its responder runs).
/// when the value changed (its responder runs). The limit is in UTF-16
/// code units, as Java's `String.length()` counts them.
pub fn insert_text(&mut self, m: &Measure, input: &str) -> bool {
let (start, end) = (
self.cursor.min(self.highlight),
self.cursor.max(self.highlight),
);
let room = self.max_length as isize - chars(&self.value) as isize + (end - start) as isize;
if room <= 0 {
let selected_units: usize = self
.value
.chars()
.skip(start)
.take(end - start)
.map(char::len_utf16)
.sum();
let room = self
.max_length
.saturating_sub(crate::ui::utf16_len(&self.value) - selected_units);
if room == 0 {
return false;
}
let filtered: String = input
.chars()
.filter(|c| *c >= ' ' && *c != '\u{7f}' && *c != '§')
.collect();
let insert: String = filtered.chars().take(room as usize).collect();
let insert = crate::ui::utf16_prefix(&filtered, room);
let n = chars(&insert);
self.value = format!(
"{}{}{}",
@@ -912,7 +922,8 @@ impl TextField {
text_as_typed(out, t, draw_x, ty, color, self.text_shadow);
draw_x += w + 1;
}
let insert = self.cursor < len || len >= self.max_length;
// `value.length() >= maxLength` is Java's length: UTF-16 units.
let insert = self.cursor < len || crate::ui::utf16_len(&self.value) >= self.max_length;
let mut cursor_x = draw_x;
if !on_screen {
cursor_x = if rel_cursor > 0 { tx + self.width } else { tx };
@@ -1427,3 +1438,32 @@ mod scissor_tests {
);
}
}
#[cfg(test)]
mod max_length_tests {
use super::*;
fn atlas() -> HudAtlas {
HudAtlas::build(std::path::Path::new("/nonexistent/horizon-test-assets")).unwrap()
}
/// `EditBox.maxLength` counts Java `String.length()` (UTF-16 code units),
/// and the chat box is this field: ChatScreen's 256 takes 128 emoji, not
/// 256, and a half-fitted emoji is dropped rather than split.
#[test]
fn the_chat_box_maximum_counts_utf16_units() {
let atlas = atlas();
let m = Measure(&atlas.font);
let mut field = TextField::new(0, 0, 200, 20);
field.max_length = 3;
field.insert_text(&m, "ab😀");
assert_eq!(
field.value, "ab",
"three units: a, b and one of the emoji's"
);
field.set_value(&m, "");
field.max_length = 4;
field.insert_text(&m, "ab😀");
assert_eq!(field.value, "ab😀", "four units, both of the emoji's");
}
}
@@ -501,7 +501,12 @@ pub(crate) fn row(batch: &mut HudBatch, atlas: &HudAtlas, cols: &Columns, y: f32
let lit = [rect[0] + 2.0, y + 1.0, rect[2] - 4.0, style::ROW - 2.0];
style::fill(batch, atlas, lit, style::hover());
}
crate::widgets::widget_cursor(hover, live);
// AbstractSliderButton.handleCursor: a dragged slider resizes, buttons point.
if slider.is_some() {
crate::widgets::slider_cursor(hover, live, held);
} else {
crate::widgets::widget_cursor(hover, live);
}
// The control socket's widget tree, the narrator and a controller's focus.
let kind = match slider {
Some(value) => crate::widgets::Kind::Slider {
+3 -186
View File
@@ -12,8 +12,11 @@
//! or an empty answer keeps the cached lists, `Retry-After` sets the poll interval, and
//! `getFriendData` waits out a ten-second cooldown between reads. The presence statuses
//! are `PresenceHandler.getPresenceStatus`'s, supplied by the client each tick.
mod list_updates;
mod model;
use list_updates::{Handler, spawn_updater};
pub use list_updates::{State, set_foreground};
use model::presence_of;
pub use model::{
Friend, FriendData, Presence, PresenceStatus, ResultCode, parse_friends, parse_presence,
@@ -30,36 +33,6 @@ use std::{
time::{Duration, Instant},
};
/// `RemoteFriendListUpdateHandler.State`.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum State {
Loading,
UpgradeNeeded,
ConnectionIssue,
UserMayLackActiveProfile,
Unauthorized,
TemporaryUnavailable,
GenericError,
Success,
}
impl State {
/// `RemoteFriendListUpdateHandler.mapResultCodeToState`.
fn of(code: ResultCode) -> Self {
match code {
ResultCode::Success => Self::Success,
ResultCode::UpgradeNeeded => Self::UpgradeNeeded,
ResultCode::ConnectionIssue => Self::ConnectionIssue,
ResultCode::UnknownProfile => Self::UserMayLackActiveProfile,
ResultCode::Unauthorized => Self::Unauthorized,
ResultCode::TemporaryUnavailable
| ResultCode::Forbidden
| ResultCode::ServiceNotAvailable
| ResultCode::TooManyRequests => Self::TemporaryUnavailable,
ResultCode::Error | ResultCode::GenericError => Self::GenericError,
}
}
}
/// `Options.sharePresence` (`PresenceSharing`): how much of the player the service sees.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum SharePresence {
@@ -103,45 +76,6 @@ impl Service {
}
}
/// `RemoteFriendListUpdateHandler`.
struct Handler {
enabled: bool,
state: State,
latest: FriendData,
/// `lastUpdateNanos`: a read at that moment is due; `None` reads at once.
last_update: Option<Instant>,
/// A forced read (`forceUpdate`) is due, whatever the interval.
forced: bool,
/// `getUpdateIntervalNanos`'s foreground: the friends overlay is open.
foreground: bool,
}
impl Default for Handler {
fn default() -> Self {
Self {
enabled: false,
state: State::Loading,
latest: FriendData::default(),
last_update: None,
forced: false,
foreground: false,
}
}
}
impl Handler {
/// `getUpdateIntervalNanos`: the service's interval (a minute when it named none),
/// five times as long in the background.
fn interval(&self, service: &Service) -> Duration {
let foreground = service.friends_poll.unwrap_or(Duration::from_secs(60));
if self.foreground {
foreground
} else {
foreground * 5
}
}
}
/// `PresenceHandler`.
struct PresenceHandler {
latest: Vec<Presence>,
@@ -270,11 +204,6 @@ pub fn latest_presence() -> Vec<Presence> {
lock().presence.latest.clone()
}
/// `RemoteFriendListUpdateHandler`'s foreground interval: the friends overlay is open.
pub fn set_foreground(open: bool) {
lock().handler.foreground = open;
}
/// `Options.sharePresence`: what presence the handler posts.
pub fn set_share_presence(share: SharePresence) {
lock().share = share;
@@ -424,100 +353,6 @@ fn friend_action(body: Value) -> ResultCode {
code
}
/// `RemoteFriendListUpdateHandler.start`'s worker: one read per interval while the list
/// is enabled, the first one as soon as it starts.
fn spawn_updater() {
static STARTED: AtomicBool = AtomicBool::new(false);
if STARTED.swap(true, Ordering::Relaxed) {
return;
}
let _ = thread::Builder::new()
.name("Friends List".into())
.spawn(|| {
loop {
thread::sleep(Duration::from_secs(1));
let due = {
let inner = lock();
if !inner.handler.enabled {
continue;
}
inner.handler.forced
|| inner
.handler
.last_update
.is_none_or(|at| at.elapsed() >= inner.handler.interval(&inner.service))
};
if due {
update_friend_data();
}
}
});
}
/// `RemoteFriendListUpdateHandler.runUpdateFriendDataInternal`.
fn update_friend_data() {
let etag = {
let mut inner = lock();
if inner.service.pending {
// Another read is in flight; its answer is the cache's.
return;
}
if !inner.service.can_make_request() {
inner.handler.forced = false;
inner.handler.state = State::Success;
inner.handler.latest = inner.service.friends_cache.clone();
inner.handler.last_update = Some(Instant::now());
return;
}
inner.service.pending = true;
inner.service.cooldown = Some(Instant::now() + Duration::from_secs(10));
inner.service.friends_etag.clone()
};
log::debug!("Performing Friends List update");
let result = match session() {
Some(session) => fetch_friends(&session, etag.as_deref()),
None => Err(Failure {
code: ResultCode::Unauthorized,
}),
};
let mut inner = lock();
inner.service.pending = false;
inner.handler.forced = false;
inner.handler.last_update = Some(Instant::now());
match result {
Ok(answer) => {
if let Some(retry) = answer.retry_after {
inner.service.friends_poll = Some(retry);
}
if let Some(etag) = answer.etag {
inner.service.friends_etag = Some(etag);
}
if answer.status != 304 && !answer.body.is_empty() {
match parse_friends(&answer.body) {
Some(data) => inner.service.friends_cache = data,
None => {
// authlib's parse failure is an ERROR result: the handler keeps
// the cached lists and shows the generic error.
inner.handler.latest = inner.service.friends_cache.clone();
inner.handler.state = State::GenericError;
return;
}
}
}
inner.handler.latest = inner.service.friends_cache.clone();
inner.handler.state = State::Success;
}
Err(failure) => {
inner.handler.latest = inner.service.friends_cache.clone();
inner.handler.state = State::of(failure.code);
log::warn!(
"Friends List update failed with result code: {:?}",
failure.code
);
}
}
}
/// `MinecraftServicesFriendsService.presence`: post one status, cache the answer.
/// `None` means the answer said nothing new (304, an empty body or a failure); the
/// handler keeps its last presence then.
@@ -674,24 +509,6 @@ fn http_error(error: ureq::Error) -> Failure {
mod tests {
use super::*;
#[test]
fn result_codes_map_to_the_handlers_states() {
assert_eq!(State::of(ResultCode::Success), State::Success);
assert_eq!(
State::of(ResultCode::TooManyRequests),
State::TemporaryUnavailable
);
assert_eq!(
State::of(ResultCode::UnknownProfile),
State::UserMayLackActiveProfile
);
assert_eq!(State::of(ResultCode::UpgradeNeeded), State::UpgradeNeeded);
assert_eq!(
State::of(ResultCode::ConnectionIssue),
State::ConnectionIssue
);
}
#[test]
fn the_share_presence_option_maps_to_its_levels() {
assert_eq!(SharePresence::of(0), SharePresence::None);
@@ -0,0 +1,199 @@
use super::{
Failure, FriendData, ResultCode, Service, fetch_friends, lock, parse_friends, session,
};
use std::{
sync::atomic::{AtomicBool, Ordering},
thread,
time::{Duration, Instant},
};
/// `RemoteFriendListUpdateHandler.State`.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum State {
Loading,
UpgradeNeeded,
ConnectionIssue,
UserMayLackActiveProfile,
Unauthorized,
TemporaryUnavailable,
GenericError,
Success,
}
impl State {
/// `RemoteFriendListUpdateHandler.mapResultCodeToState`.
fn of(code: ResultCode) -> Self {
match code {
ResultCode::Success => Self::Success,
ResultCode::UpgradeNeeded => Self::UpgradeNeeded,
ResultCode::ConnectionIssue => Self::ConnectionIssue,
ResultCode::UnknownProfile => Self::UserMayLackActiveProfile,
ResultCode::Unauthorized => Self::Unauthorized,
ResultCode::TemporaryUnavailable
| ResultCode::Forbidden
| ResultCode::ServiceNotAvailable
| ResultCode::TooManyRequests => Self::TemporaryUnavailable,
ResultCode::Error | ResultCode::GenericError => Self::GenericError,
}
}
}
/// `RemoteFriendListUpdateHandler`.
pub(super) struct Handler {
pub(super) enabled: bool,
pub(super) state: State,
pub(super) latest: FriendData,
/// `lastUpdateNanos`: a read at that moment is due; `None` reads at once.
pub(super) last_update: Option<Instant>,
/// A forced read (`forceUpdate`) is due, whatever the interval.
pub(super) forced: bool,
/// `getUpdateIntervalNanos`'s foreground: the friends overlay is open.
foreground: bool,
}
impl Default for Handler {
fn default() -> Self {
Self {
enabled: false,
state: State::Loading,
latest: FriendData::default(),
last_update: None,
forced: false,
foreground: false,
}
}
}
impl Handler {
/// `getUpdateIntervalNanos`: the service's interval (a minute when it named none),
/// five times as long in the background.
fn interval(&self, service: &Service) -> Duration {
let foreground = service.friends_poll.unwrap_or(Duration::from_secs(60));
if self.foreground {
foreground
} else {
foreground * 5
}
}
}
/// `RemoteFriendListUpdateHandler`'s foreground interval: the friends overlay is open.
pub fn set_foreground(open: bool) {
lock().handler.foreground = open;
}
/// `RemoteFriendListUpdateHandler.start`'s worker: one read per interval while the list
/// is enabled, the first one as soon as it starts.
pub(super) fn spawn_updater() {
static STARTED: AtomicBool = AtomicBool::new(false);
if STARTED.swap(true, Ordering::Relaxed) {
return;
}
let _ = thread::Builder::new()
.name("Friends List".into())
.spawn(|| {
loop {
thread::sleep(Duration::from_secs(1));
let due = {
let inner = lock();
if !inner.handler.enabled {
continue;
}
inner.handler.forced
|| inner
.handler
.last_update
.is_none_or(|at| at.elapsed() >= inner.handler.interval(&inner.service))
};
if due {
update_friend_data();
}
}
});
}
/// `RemoteFriendListUpdateHandler.runUpdateFriendDataInternal`.
pub(super) fn update_friend_data() {
let etag = {
let mut inner = lock();
if inner.service.pending {
// Another read is in flight; its answer is the cache's.
return;
}
if !inner.service.can_make_request() {
inner.handler.forced = false;
inner.handler.state = State::Success;
inner.handler.latest = inner.service.friends_cache.clone();
inner.handler.last_update = Some(Instant::now());
return;
}
inner.service.pending = true;
inner.service.cooldown = Some(Instant::now() + Duration::from_secs(10));
inner.service.friends_etag.clone()
};
log::debug!("Performing Friends List update");
let result = match session() {
Some(session) => fetch_friends(&session, etag.as_deref()),
None => Err(Failure {
code: ResultCode::Unauthorized,
}),
};
let mut inner = lock();
inner.service.pending = false;
inner.handler.forced = false;
inner.handler.last_update = Some(Instant::now());
match result {
Ok(answer) => {
if let Some(retry) = answer.retry_after {
inner.service.friends_poll = Some(retry);
}
if let Some(etag) = answer.etag {
inner.service.friends_etag = Some(etag);
}
if answer.status != 304 && !answer.body.is_empty() {
match parse_friends(&answer.body) {
Some(data) => inner.service.friends_cache = data,
None => {
// authlib's parse failure is an ERROR result: the handler keeps
// the cached lists and shows the generic error.
inner.handler.latest = inner.service.friends_cache.clone();
inner.handler.state = State::GenericError;
return;
}
}
}
inner.handler.latest = inner.service.friends_cache.clone();
inner.handler.state = State::Success;
}
Err(failure) => {
inner.handler.latest = inner.service.friends_cache.clone();
inner.handler.state = State::of(failure.code);
log::warn!(
"Friends List update failed with result code: {:?}",
failure.code
);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn result_codes_map_to_the_handlers_states() {
assert_eq!(State::of(ResultCode::Success), State::Success);
assert_eq!(
State::of(ResultCode::TooManyRequests),
State::TemporaryUnavailable
);
assert_eq!(
State::of(ResultCode::UnknownProfile),
State::UserMayLackActiveProfile
);
assert_eq!(State::of(ResultCode::UpgradeNeeded), State::UpgradeNeeded);
assert_eq!(
State::of(ResultCode::ConnectionIssue),
State::ConnectionIssue
);
}
}
+65 -6
View File
@@ -1421,7 +1421,7 @@ pub fn append_field(
batch.text(atlas, value, x, y, color, shadow);
cursor_x += HudBatch::text_width(atlas, value) + 1.0;
}
let insert = char_len(value) >= max_chars;
let insert = utf16_len(value) >= max_chars;
if insert {
cursor_x -= 1.0;
}
@@ -1444,6 +1444,29 @@ fn byte_index(s: &str, chars: usize) -> usize {
pub fn char_len(s: &str) -> usize {
s.chars().count()
}
/// Java's `String.length()`: UTF-16 code units, the unit `EditBox.maxLength`
/// counts. A character outside the BMP — an emoji, say — is two of them, so
/// a box with room for one more character takes an `a` and rejects a `😀`.
pub fn utf16_len(s: &str) -> usize {
s.encode_utf16().count()
}
/// The longest prefix of `s` that fits `max` UTF-16 code units without
/// splitting a character. `EditBox.insertText` drops the half surrogate it
/// would otherwise keep, so this is what vanilla inserts; Rust strings cannot
/// hold the lone surrogate that `setValue`'s unguarded `substring` may leave,
/// so a value too long by half a character keeps the whole characters before
/// the cut.
pub fn utf16_prefix(s: &str, max: usize) -> String {
let mut units = 0;
for (i, c) in s.char_indices() {
let n = c.len_utf16();
if units + n > max {
return s[..i].to_owned();
}
units += n;
}
s.to_owned()
}
/// `EditBox.getWordPosition(dir, from)`: the character index `dir` words away
/// from `from`. Forward, past the rest of the word and its spaces; backward,
/// over any spaces to the start of the word.
@@ -1585,7 +1608,7 @@ impl EditBox {
self.focused && (self.focused_at.elapsed().as_millis() / 300).is_multiple_of(2)
}
pub fn set_value(&mut self, value: &str) {
self.value = value.chars().take(self.max_length).collect();
self.value = utf16_prefix(value, self.max_length);
self.move_cursor_to_end(false);
}
fn selected(&self) -> (usize, usize) {
@@ -1598,7 +1621,9 @@ impl EditBox {
let (a, b) = self.selected();
self.value.chars().skip(a).take(b - a).collect()
}
/// EditBox.insertText: replace the selection, respecting the maximum length.
/// EditBox.insertText: replace the selection, respecting the maximum
/// length. The limit is in UTF-16 code units, as Java's `String.length()`
/// counts them.
pub fn insert_text(&mut self, text: &str) {
// `EditBox.charTyped` only inserts through `canConsumeInput`, which a
// read-only box fails; the screens here hand typed text to this method
@@ -1608,10 +1633,17 @@ impl EditBox {
}
let (a, b) = self.selected();
let filtered = crate::clipboard::filter_text(text, self.multiline);
let selected_units: usize = self
.value
.chars()
.skip(a)
.take(b - a)
.map(char::len_utf16)
.sum();
let room = self
.max_length
.saturating_sub(char_len(&self.value) - (b - a));
let insert: String = filtered.chars().take(room).collect();
.saturating_sub(utf16_len(&self.value) - selected_units);
let insert = utf16_prefix(&filtered, room);
let (ba, bb) = (byte_index(&self.value, a), byte_index(&self.value, b));
self.value.replace_range(ba..bb, &insert);
let pos = a + char_len(&insert);
@@ -1889,8 +1921,10 @@ impl EditBox {
batch.text_styled(atlas, &t, draw_x, ty, color, true);
draw_x += atlas.font.width_text(&t) + 1.0;
}
// EditBox.extractWidgetRenderState: `value.length() >= maxLength`
// is Java's length, so the caret turns into a bar at the unit count.
let insert =
self.cursor < char_len(&self.value) || char_len(&self.value) >= self.max_length;
self.cursor < char_len(&self.value) || utf16_len(&self.value) >= self.max_length;
let mut cursor_x = draw_x;
if !on_screen {
cursor_x = if rel_cursor > 0 { tx + self.width } else { tx };
@@ -2636,6 +2670,31 @@ mod tests {
);
}
/// `EditBox.maxLength` counts Java `String.length()`: UTF-16 code units.
/// A pasted emoji takes two of them and is never split (vanilla's
/// `Character.isHighSurrogate` guard drops the half it would keep).
#[test]
fn an_edit_box_maximum_counts_utf16_units() {
assert_eq!(utf16_len("ab😀"), 4);
assert_eq!(utf16_prefix("ab😀", 3), "ab");
assert_eq!(utf16_prefix("ab😀", 4), "ab😀");
let mut edit = EditBox::new(0.0, 0.0, 200.0, 20.0);
edit.max_length = 3;
edit.insert_text("ab😀");
assert_eq!(edit.value, "ab", "three units: a, b and one of the emoji's");
edit.set_value("");
edit.max_length = 4;
edit.insert_text("ab😀");
assert_eq!(edit.value, "ab😀", "four units, both of the emoji's");
// The selection's own units give their room back: with "ab"
// selected there are two units left, enough for the emoji, not
// for the emoji and the x.
edit.set_cursor(0, false);
edit.set_cursor(2, true);
edit.insert_text("😀x");
assert_eq!(edit.value, "😀😀");
}
/// `EditBox.setInvertHighlightedTextColor`: a selection inverts its text
/// unless the box asks it not to (the anvil's rename box).
#[test]
@@ -16,6 +16,8 @@ pub enum Cursor {
NotAllowed,
/// `CursorTypes.RESIZE_NS`: a scroller held vertically (`MerchantScreen`).
ResizeNs,
/// `CursorTypes.RESIZE_EW`: a slider held horizontally.
ResizeEw,
}
thread_local! {
@@ -39,6 +41,19 @@ pub fn widget_cursor(hovered: bool, active: bool) {
});
}
}
/// `AbstractSliderButton.handleCursor`: a dragged slider resizes, a hovered
/// one points, and an inactive one is not allowed.
pub fn slider_cursor(hovered: bool, active: bool, dragging: bool) {
if hovered {
request_cursor(if !active {
Cursor::NotAllowed
} else if dragging {
Cursor::ResizeEw
} else {
Cursor::PointingHand
});
}
}
/// The cursor the frame asked for, and back to the arrow for the next one
/// (the window shows the arrow unless a widget asks).
@@ -414,6 +429,18 @@ pub fn record_as(label: &str, [x, y, w, h]: [f32; 4], enabled: bool, kind: Kind)
mod cursor_tests {
use super::*;
#[test]
fn a_dragged_slider_asks_for_the_horizontal_resize_cursor() {
let _ = take_cursor();
slider_cursor(false, true, true);
assert_eq!(take_cursor(), Cursor::Arrow);
slider_cursor(true, true, true);
assert_eq!(take_cursor(), Cursor::ResizeEw);
slider_cursor(true, true, false);
assert_eq!(take_cursor(), Cursor::PointingHand);
slider_cursor(true, false, true);
assert_eq!(take_cursor(), Cursor::NotAllowed);
}
#[test]
fn hovered_widgets_ask_for_the_hand_or_not_allowed() {
let _ = take_cursor();
+4 -6
View File
@@ -9855,12 +9855,10 @@ impl Game<'_> {
border,
game_time: self.world.game_time as f32,
shader_game_time: ((self.world.game_time % 24000) as f32 + alpha as f32) / 24000.0,
crumbling_sprite: self
.gui
.atlas
.sprites
.get("block/destroy_stage_0")
.map_or([0.0; 4], |r| [r.u0, r.v0, r.u1 - r.u0, r.v1 - r.v0]),
crumbling_sprite: frame_geometry::crumbling_sprite(
&self.gui.atlas.sprites,
self.settings.improved_transparency,
),
end_portal_sprites: ["environment/end_sky", "entity/end_portal/end_portal"].map(|n| {
self.gui
.atlas
+2 -1
View File
@@ -2,6 +2,7 @@
//! paddling controls, collisions, riding (`positionRider`, `clampRotation`,
//! dismounting), `BoatItem` placement, `VehicleEntity.hurtServer` breaking,
//! saving and `AbstractBoatRenderer` with animated paddles.
mod below_world;
mod placement;
use super::{Game, InteractTarget, entity::Entity, entity_culling::at, view_vector};
@@ -977,7 +978,7 @@ impl Game<'_> {
};
self.boats[i].wear_off_hurt();
// Entity.baseTick burns it before it moves; the fire may break it.
if !self.boat_fire_tick(i) {
if !self.boat_fire_tick(i) && !self.boat_below_world(i) {
let b = self.boats[i].clone();
self.boats[i] = self.tick_boat(b, false);
self.boat_server_passes(id, &driven, &ticking, &round);
@@ -0,0 +1,110 @@
//! `Entity.checkBelowWorld` for boats and minecarts: one more than 64 blocks
//! under its level's floor is discarded (`onBelowWorld`) before it moves.
//! `AbstractBoat.tick` reaches it through `Entity.baseTick`, after the fire;
//! `AbstractMinecart.tick` calls it itself, after its hurt and damage wear off.
//! A discard is a destroying removal: the riders get off where they are and a
//! chest boat or container minecart spills its slots
//! (`AbstractChestBoat.remove`, `AbstractMinecartContainer.remove`), but no
//! ENTITY_DIE and no item of the vehicle itself.
use super::super::{minecarts::cart_at, native_ticks::TickRound};
use super::Game;
/// `Entity.checkBelowWorld`: `getY() < level.getMinY() - 64`.
pub(super) fn below_world(y: f64, min_y: i32) -> bool {
y < f64::from(min_y - 64)
}
impl Game<'_> {
/// `Entity.checkBelowWorld` for the boat at `i`: true when it fell out of
/// the world and is gone.
pub(in crate::game) fn boat_below_world(&mut self, i: usize) -> bool {
if !below_world(self.boats[i].position.y, self.world.bottom_y()) {
return false;
}
self.eject_boat_mobs(i, true);
let id = self.boats[i].id;
self.release_vehicle_container(id);
let b = self.boats.remove(i);
self.drop_vehicle_contents(b.position.components(), &b.items);
self.boat_removed(&b);
true
}
/// The minecart `id` at or after `slot`, unless `AbstractMinecart.tick`'s
/// `checkBelowWorld` discards it: then its tick is over and the round's
/// post hook runs for it.
pub(in crate::game) fn cart_in_world(
&mut self,
id: u32,
slot: usize,
round: &TickRound,
kind: &str,
) -> Option<usize> {
let i = cart_at(&self.carts, id, slot)?;
if !below_world(self.carts[i].position.y, self.world.bottom_y()) {
return Some(i);
}
self.eject_cart_mobs(i);
self.release_vehicle_container(id);
let c = self.carts.remove(i);
self.drop_vehicle_contents(c.position.components(), &c.items);
self.native_tick_post_one(round, id, kind);
None
}
}
#[cfg(test)]
mod tests {
use super::below_world;
/// The line is 64 under the floor and the vehicle must be below it:
/// -128 in the overworld (floor -64), -64 in the Nether and the End.
#[test]
fn a_vehicle_falls_out_of_the_world_64_blocks_under_the_floor() {
assert!(!below_world(-128.0, -64));
assert!(below_world(-128.001, -64));
assert!(!below_world(-100.0, -64));
assert!(!below_world(-64.0, 0));
assert!(below_world(-64.5, 0));
}
/// A tick discards the boat and the chest minecart under -128 in the
/// overworld, before they move, and spills the cart's slots; the two on
/// the line or above it stay, whatever their fall this tick.
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn a_tick_discards_the_vehicles_under_the_world() {
use horizon_client::chunk::Chunk;
use horizon_core::position::ChunkPos;
let mut g = crate::game::native_machines::tests::server_game();
g.build_registries();
g.set_allow_commands(true);
g.world
.chunks
.insert((0, 0), std::sync::Arc::new(Chunk::empty(0, 0)));
g.block_ticks.add_empty_chunk(ChunkPos::new(0, 0)).unwrap();
assert!(g.run_command("forceload add 0 0"));
for summon in [
"summon oak_boat 0.5 -128 0.5",
"summon oak_boat 4.5 -128.5 4.5",
"summon minecart 6.5 -127 6.5",
"summon chest_minecart 2.5 -129 2.5 {Items:[{Slot:0b,id:\"minecraft:diamond\",count:3}]}",
] {
assert!(g.run_command(summon), "{summon}");
}
g.sync_tick_chunks();
g.tick_boats();
g.tick_carts();
let boats: Vec<f64> = g.boats.iter().map(|b| b.position.x).collect();
assert_eq!(boats, [0.5]);
let carts: Vec<f64> = g.carts.iter().map(|c| c.position.x).collect();
assert_eq!(carts, [6.5]);
let spilled: u32 = g
.dropped
.iter()
.filter(|i| &*i.stack.item == "minecraft:diamond")
.map(|i| i.stack.count)
.sum();
assert_eq!(spilled, 3);
}
}
+44 -8
View File
@@ -392,14 +392,10 @@ impl Game<'_> {
.get(self.world.block(c.x, c.y, c.z))
.name
.clone();
Some(match &*name {
"minecraft:ladder" => "ladder",
"minecraft:vine" => "vines",
"minecraft:weeping_vines" | "minecraft:weeping_vines_plant" => "weeping_vines",
"minecraft:twisting_vines" | "minecraft:twisting_vines_plant" => "twisting_vines",
"minecraft:scaffolding" => "scaffolding",
_ => "other_climbable",
})
let trapdoor = (self.res.items().block_tags)
.get("minecraft:trapdoors")
.is_some_and(|tag| tag.contains(&*name));
Some(fall_location_of(&name, trapdoor))
} else {
let f = self.res.facts().get(self.world.block(
p.position.x.floor() as i32,
@@ -737,6 +733,46 @@ fn damage_taken_award(amount: f32) -> Option<i32> {
(amount < 3.402_823_5e37).then(|| horizon_core::jmath::round_f32(amount * 10.0))
}
/// `FallLocation.blockToFallLocation`: what a climbable block stands for in a fall's death message.
/// `trapdoor` is whether the block is in `#minecraft:trapdoors`, which counts as a ladder.
fn fall_location_of(name: &str, trapdoor: bool) -> &'static str {
match name {
_ if trapdoor => "ladder",
"minecraft:ladder" => "ladder",
"minecraft:vine" => "vines",
"minecraft:weeping_vines" | "minecraft:weeping_vines_plant" => "weeping_vines",
"minecraft:twisting_vines" | "minecraft:twisting_vines_plant" => "twisting_vines",
"minecraft:scaffolding" => "scaffolding",
_ => "other_climbable",
}
}
#[cfg(test)]
mod fall_location_tests {
use super::fall_location_of;
/// The branches of `blockToFallLocation`: a ladder or a block of `#minecraft:trapdoors` is
/// `ladder`, then vine, the weeping and twisting vines (heads and plants), scaffolding, and any
/// other climbable (cave vines, here) is `other_climbable`.
#[test]
fn a_trapdoor_counts_as_a_ladder_and_the_other_climbables_keep_their_own_place() {
for (name, trapdoor, want) in [
("minecraft:ladder", false, "ladder"),
("minecraft:oak_trapdoor", true, "ladder"),
("minecraft:iron_trapdoor", true, "ladder"),
("minecraft:vine", false, "vines"),
("minecraft:weeping_vines", false, "weeping_vines"),
("minecraft:weeping_vines_plant", false, "weeping_vines"),
("minecraft:twisting_vines", false, "twisting_vines"),
("minecraft:twisting_vines_plant", false, "twisting_vines"),
("minecraft:scaffolding", false, "scaffolding"),
("minecraft:cave_vines", false, "other_climbable"),
] {
assert_eq!(fall_location_of(name, trapdoor), want, "{name}");
}
}
}
/// Hits one mob's tick deals another, handled after the mob loop.
impl Game<'_> {
/// A melee hit on another mob, then `postAttack`; whether it landed.
@@ -46,13 +46,13 @@ pub(super) struct CommandBlockScreen {
}
/// What `ServerboundSetCommandBlockPacket` carries.
struct CommandBlockEdit {
pos: Pos,
command: String,
track_output: bool,
mode: Mode,
conditional: bool,
auto: bool,
pub(super) struct CommandBlockEdit {
pub(super) pos: Pos,
pub(super) command: String,
pub(super) track_output: bool,
pub(super) mode: Mode,
pub(super) conditional: bool,
pub(super) auto: bool,
}
#[derive(Clone, Copy, PartialEq)]
@@ -948,8 +948,6 @@ impl Game<'_> {
self.as_guest_messaged(host, conn, |game| {
if allowed {
game.apply_command_block(&edit);
// BaseCommandBlock.onUpdated: the clients get the new command.
game.mark_block_entity_updated(edit.pos);
} else {
game.system_message(Text::translate("advMode.notAllowed", &[]));
}
@@ -984,7 +982,7 @@ impl Game<'_> {
);
}
/// What `handleSetCommandBlock` does once the player may edit the block.
fn apply_command_block(&mut self, s: &CommandBlockEdit) {
pub(super) fn apply_command_block(&mut self, s: &CommandBlockEdit) {
let pos = s.pos;
let registry = self.pack.assets.registry;
let Some(current) = self.block_state_at(pos) else {
@@ -1042,6 +1040,9 @@ impl Game<'_> {
};
self.system_message(Text::translate(key, &[Text::literal(command)]));
}
// `BaseCommandBlock.onUpdated`: the edited block entity is saved and
// its clients (and comparators) see the new command.
self.mark_block_entity_updated(pos);
}
}
+32 -16
View File
@@ -780,6 +780,13 @@ impl Game<'_> {
if self.pot_data(pos).is_some() {
return self.set_pot_data(pos, &tag).unwrap_or(false);
}
if let Some(changed) = self.load_comparator_output(pos, &tag) {
let changed = changed.unwrap_or(false);
if changed {
self.world.modified.insert((pos[0] >> 4, pos[2] >> 4));
}
return changed;
}
let key = (pos[0], pos[1], pos[2]);
let was_auto = self.command_block_auto(key);
let Some(e) = self.world.block_entities.get(&key) else {
@@ -887,6 +894,29 @@ impl Game<'_> {
true
}
/// `ComparatorBlockEntity.loadAdditional` (`getIntOr("OutputSignal", 0)`)
/// for a comparator at `p`: None for any other block; else whether the
/// loaded output differs from the one it had (None inside when the tag
/// does not decode). The block's own metadata comes from `comparator_data`.
fn load_comparator_output(&mut self, p: [i32; 3], tag: &Tag) -> Option<Option<bool>> {
let mut input = self.comparator_data(p)?;
let before = input.get("OutputSignal").cloned();
if let Some(value) = tag.get("OutputSignal") {
set_key(&mut input, "OutputSignal", value.clone());
} else if let Tag::Compound(map) = &mut input {
map.remove(&"OutputSignal".into());
}
let Some(normalized) = horizon_client::block_entity_nbt::canonical(&input) else {
return Some(None);
};
let output = normalized.get("OutputSignal").cloned();
if let Some(Tag::Int(value)) = output {
let pos = horizon_core::position::BlockPos::new(p[0], p[1], p[2]);
self.block_ticks.comparator_outputs.insert(pos, value);
}
Some(Some(output != before))
}
/// `ComparatorBlockEntity.saveAdditional`: expose the circuit's live output,
/// rather than keeping a second copy in the ordinary block entity map.
fn comparator_data(&self, p: [i32; 3]) -> Option<Tag> {
@@ -1050,22 +1080,8 @@ impl Game<'_> {
return Ok(());
}
let key = (p[0], p[1], p[2]);
if let Some(mut input) = self.comparator_data(*p) {
// ComparatorBlockEntity.loadAdditional reads getIntOr(0).
// Reuse the existing codec, with the block's own metadata.
if let Some(value) = tag.get("OutputSignal") {
set_key(&mut input, "OutputSignal", value.clone());
} else if let Tag::Compound(map) = &mut input {
map.remove(&"OutputSignal".into());
}
let normalized = horizon_client::block_entity_nbt::canonical(&input)
.ok_or_else(|| err("commands.data.block.invalid", &[]))?;
if let Some(Tag::Int(output)) = normalized.get("OutputSignal") {
self.block_ticks.comparator_outputs.insert(
horizon_core::position::BlockPos::new(p[0], p[1], p[2]),
*output,
);
}
if let Some(changed) = self.load_comparator_output(*p, &tag) {
changed.ok_or_else(|| err("commands.data.block.invalid", &[]))?;
self.mark_block_entity_updated(key);
return Ok(());
}
+125 -13
View File
@@ -1592,35 +1592,147 @@ fn merge_compound(into: &mut Tag, from: &Tag) {
}
}
/// `ListOperation.apply` (`mode`: replace_all, replace_section, insert,
/// append) with a 256-entry limit.
/// append) with a 256-entry limit (`SetLoreFunction`'s). An append, insert or section
/// replacement that cannot be done, an offset past the end or a result over the limit, gives
/// the old list back unchanged, as vanilla does (it logs an error). `replace_all` is the new
/// list as it is in vanilla, whose `ItemLore` then refuses more than 256 lines; here it is cut.
fn list_operation(m: &serde_json::Value, old: Vec<Tag>, new: Vec<Tag>) -> Vec<Tag> {
const LIMIT: usize = 256;
let offset = m["offset"].as_u64().unwrap_or(0) as usize;
let mut out = match m["mode"].as_str().unwrap_or("") {
"replace_all" => new,
match m["mode"].as_str().unwrap_or("") {
"replace_all" => {
let mut out = new;
out.truncate(LIMIT);
out
}
"insert" => {
let at = offset.min(old.len());
let mut out = old[..at].to_vec();
if offset > old.len() || old.len() + new.len() > LIMIT {
return old;
}
let mut out = old[..offset].to_vec();
out.extend(new);
out.extend_from_slice(&old[at..]);
out.extend_from_slice(&old[offset..]);
out
}
"replace_section" => {
let at = offset.min(old.len());
if offset > old.len() {
return old;
}
let size = m["size"].as_u64().map_or(new.len(), |s| s as usize);
let mut out = old[..at].to_vec();
let mut out = old[..offset].to_vec();
out.extend(new);
let end = (at + size).min(old.len());
out.extend_from_slice(&old[end..]);
let end = offset.saturating_add(size);
if end < old.len() {
out.extend_from_slice(&old[end..]);
}
if out.len() > LIMIT {
return old;
}
out
}
_ => {
if old.len() + new.len() > LIMIT {
return old;
}
let mut out = old;
out.extend(new);
out
}
};
out.truncate(256);
out
}
}
/// `ListOperation.Append/Insert/ReplaceSection.apply` with `/item modify`'s lore limit of 256
/// (`SetLoreFunction` passes 256): an operation that cannot be done logs an error in vanilla and
/// gives the old list back unchanged. The expected lists are worked out from the vanilla bytecode
/// (see the pull request), not from the code under test.
#[cfg(test)]
mod list_operation_tests {
use super::*;
use serde_json::json;
fn ints(range: std::ops::Range<i32>) -> Vec<Tag> {
range.map(Tag::Int).collect()
}
#[test]
fn an_append_that_would_pass_256_entries_changes_nothing() {
let old = ints(0..250);
let m = json!({"mode": "append"});
// 250 + 7 = 257 is over the limit: the old list, whole.
assert_eq!(list_operation(&m, old.clone(), ints(1000..1007)), old);
// 250 + 6 = 256 is exactly the limit: all of it.
let mut full = old.clone();
full.extend(ints(1000..1006));
assert_eq!(list_operation(&m, old, ints(1000..1006)), full);
}
#[test]
fn an_insert_past_the_end_or_over_the_limit_changes_nothing() {
let old = ints(0..3);
let at = |offset| json!({"mode": "insert", "offset": offset});
// Offset 4 is past the end of three entries.
assert_eq!(list_operation(&at(4), old.clone(), ints(9..10)), old);
// Offset 3 is the end: an insert there is an append.
assert_eq!(
list_operation(&at(3), old.clone(), ints(9..11)),
[0, 1, 2, 9, 10].map(Tag::Int)
);
// Offset 1 in the middle.
assert_eq!(
list_operation(&at(1), old, ints(9..11)),
[0, 9, 10, 1, 2].map(Tag::Int)
);
// 255 + 2 = 257 is over the limit, at any offset.
let big = ints(0..255);
assert_eq!(list_operation(&at(0), big.clone(), ints(9..11)), big);
}
#[test]
fn a_section_replaced_past_the_end_or_over_the_limit_changes_nothing() {
let old = ints(0..4);
let section = |offset, size: Option<u64>| {
let mut m = json!({"mode": "replace_section", "offset": offset});
if let Some(size) = size {
m["size"] = json!(size);
}
m
};
// Offset 5 is past the end of four entries.
assert_eq!(
list_operation(&section(5, None), old.clone(), ints(9..10)),
old
);
// Offset 4 is the end: nothing to replace, the new entries go after.
assert_eq!(
list_operation(&section(4, None), old.clone(), ints(9..10)),
[0, 1, 2, 3, 9].map(Tag::Int)
);
// Entries 1 and 2 (size 2) become one entry.
assert_eq!(
list_operation(&section(1, Some(2)), old.clone(), ints(9..10)),
[0, 9, 3].map(Tag::Int)
);
// No size: as many entries as the new ones.
assert_eq!(
list_operation(&section(0, None), ints(0..3), ints(7..9)),
[7, 8, 2].map(Tag::Int)
);
// 256 entries, one replaced by two: 257 is over the limit.
let full = ints(0..256);
assert_eq!(
list_operation(&section(0, Some(1)), full.clone(), ints(9..11)),
full
);
}
#[test]
fn replacing_everything_gives_the_new_list() {
let m = json!({"mode": "replace_all"});
assert_eq!(
list_operation(&m, ints(0..3), ints(7..9)),
[7, 8].map(Tag::Int)
);
}
}
#[cfg(test)]
+22 -47
View File
@@ -1129,17 +1129,7 @@ impl Game<'_> {
self.refresh_gear_knockback_resistance();
self.tick_leads();
// Vanilla's spawning and raids run before ChunkMap.tick.
let from = self.lan_synced_from();
horizon_client::spans::time("spawn_natural", || self.spawn_natural());
// ServerChunkCache.tickChunks: the custom spawners tick only with spawn_mobs.
if self.ui.rules.bool("spawn_mobs") {
self.tick_wandering_trader();
self.tick_patrol_spawner();
self.tick_cat_spawner();
self.tick_village_siege();
}
self.tick_raids();
self.lan_synced_since(from);
self.tick_spawners();
let ticked =
horizon_client::spans::time("tick_creature_entities", || self.tick_creature_entities());
self.dismount_underwater_mob_riders();
@@ -2280,6 +2270,11 @@ impl Game<'_> {
self.next_entity += 1;
p.id = self.next_entity;
}
// The UUID its own random made, unless it already has one (a client
// copy's from the spawn packet).
if let Some(uuid) = p.uuid.take() {
self.persist.uuids.entry(p.id).or_insert(uuid);
}
// Projectile.setOwner: the acting player's or the mob's UUID, which
// its save keeps even once the owner is gone.
if p.owner_uuid.is_none() {
@@ -2647,20 +2642,9 @@ impl Game<'_> {
x,
z,
source,
apply_blocking,
} => match target {
// A player's source names no melee attacker, as its damage's doesn't.
Who::Player => {
let _attacker = self.knockback_scope(match source.attacker {
Some(Who::Mob(id)) => id as i32,
_ => 0,
});
let source = HurtSource {
damage_type: source_damage_type(source),
causing: 0,
direct: 0,
};
self.knock_back_player(power, x, z, source);
}
Who::Player => self.mob_pushes_player(power, x, z, source, apply_blocking),
Who::Mob(id) => {
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) {
m.knockback(power, x, z, &source);
@@ -6566,7 +6550,7 @@ impl Game<'_> {
let mut equip_event: Option<bool> = None;
let mut consume = false;
let mut give: Option<&str> = None;
let mut sound: Option<&str> = None;
let mut sound: Option<(&str, f32, f32)> = None;
// Sounds played as vanilla plays them: (event, volume, pitch) at the feet.
let mut eat: Option<(&'static str, f32, f32)> = None;
let feet = [m.position.x, m.position.y, m.position.z];
@@ -6599,17 +6583,18 @@ impl Game<'_> {
Species::Cow | Species::Mooshroom if item == "minecraft:bucket" && !m.is_baby() => {
give = Some("minecraft:milk_bucket");
consume = true;
sound = Some("entity.cow.milk");
sound = Some(("entity.cow.milk", 1.0, 1.0));
}
// Goat.mobInteract: a bucket on an adult gives milk.
Species::Goat if item == "minecraft:bucket" && !m.is_baby() => {
give = Some("minecraft:milk_bucket");
consume = true;
sound = Some(if m.extra.screaming {
let milk = if m.extra.screaming {
"entity.goat.screaming.milk"
} else {
"entity.goat.milk"
});
};
sound = Some((milk, 1.0, 1.0));
}
// MushroomCow.mobInteract: a bowl gives back what a flower left as
// a suspicious stew, or plain stew when it holds no effects.
@@ -6624,7 +6609,7 @@ impl Game<'_> {
}
None => {
give = Some("minecraft:mushroom_stew");
sound = Some("entity.mooshroom.milk");
sound = Some(("entity.mooshroom.milk", 1.0, 1.0));
}
}
}
@@ -6636,10 +6621,7 @@ impl Game<'_> {
"minecraft:red_mushroom"
};
drops.push((mushroom.into(), 5));
shear_puff = Some((
[m.position.x, m.position.y, m.position.z],
f64::from(m.height()),
));
shear_puff = Some((feet, f64::from(m.height())));
self.audio.play_bound(
"minecraft:entity.mooshroom.shear",
7,
@@ -6661,6 +6643,8 @@ impl Game<'_> {
at: pos,
setup,
}])?;
let ctx = self.who_context(Some(horizon_client::mobs::Who::Player), None);
self.post_game_event("minecraft:shear", feet, ctx);
self.damage_held_tool();
}
// MushroomCow.mobInteract: a brown adult takes the flower's effects
@@ -6696,6 +6680,8 @@ impl Game<'_> {
);
drops.extend(stacks.into_iter().map(|s| (s.item.to_string(), s.count)));
wool = true;
let ctx = self.who_context(Some(horizon_client::mobs::Who::Player), None);
self.post_game_event("minecraft:shear", feet, ctx);
self.damage_held_tool();
}
// Entity.interact: shears take a tamed wolf's armor off for its owner.
@@ -7143,11 +7129,9 @@ impl Game<'_> {
}])?;
}
self.swing_arm();
if let Some(s) = sound {
let pitch =
(self.world.random.next_float() - self.world.random.next_float()) * 0.2 + 1.0;
if let Some((s, volume, pitch)) = sound {
self.audio
.play(&format!("minecraft:{s}"), Some(center), 1.0, pitch);
.play(&format!("minecraft:{s}"), Some(center), volume, pitch);
}
if let Some((s, volume, pitch)) = eat {
let event = format!("minecraft:{s}");
@@ -7389,16 +7373,7 @@ impl Game<'_> {
let velocity = self.known_player_movement();
let start = Vec3::new(eye[0], eye[1] - 0.1, eye[2]);
let mut proj = Projectile::new(kind.clone(), start, Some(Who::Player));
proj.shoot_from_rotation(
&mut self.world.random,
self.pitch,
self.yaw,
0.0,
1.5,
1.0,
velocity,
on_ground,
);
proj.shoot_from_rotation(self.pitch, self.yaw, 0.0, 1.5, 1.0, velocity, on_ground);
self.add_projectile(proj);
// SnowballItem.use / EggItem.use / EnderpearlItem.use: awardStat(ITEM_USED).
self.award_stat("used", item, 1);
+1 -1
View File
@@ -371,7 +371,7 @@ impl Game<'_> {
}
// Flame: the arrow is spawned burning (EnchantmentHelper.onProjectileSpawned).
self.ignite_spawned_arrow(&weapon, &mut arrow);
arrow.shoot(&mut self.world.random, dir.to_array(), power, 1.0);
arrow.shoot_own(dir.to_array(), power, 1.0);
self.add_projectile(arrow);
let (player, name, projectile) = (
horizon_client::lan::playerdata::ints_uuid(self.acting_player_uuid()),
@@ -44,8 +44,6 @@ mod transfer;
/// What the tick loop tells `ServerWatchdog`: when the tick in progress was
/// due (`getNextTickTime`), how long a tick is, and whether the server runs.
struct Watchdog {
/// The thread running the ticks, whose stack the report carries.
server: horizon_threads::stack_dump::Watched,
base: Instant,
next_nanos: AtomicU64,
tick_nanos: AtomicU64,
@@ -55,11 +53,9 @@ struct Watchdog {
impl Watchdog {
/// `DedicatedServer.initServer`: a thread that stops a server whose tick
/// runs `max_tick_time` ms past its due time; none when that is 0 or less.
/// Started from the thread that runs the ticks.
fn start(max_tick_time: i64) -> Option<Arc<Self>> {
let max = Duration::from_millis(u64::try_from(max_tick_time).ok().filter(|m| *m > 0)?);
let watchdog = Arc::new(Self {
server: horizon_threads::stack_dump::Watched::current(),
base: Instant::now(),
next_nanos: AtomicU64::new(0),
tick_nanos: AtomicU64::new(50_000_000),
@@ -88,8 +84,6 @@ impl Watchdog {
let now = u64::try_from(self.base.elapsed().as_nanos()).unwrap_or(u64::MAX);
let behind = now.saturating_sub(next);
if behind > max {
// ServerWatchdog: the server thread's stack first, while it is still stuck.
let stack = self.server.stack(Duration::from_secs(5));
let took = format!("{:.2}", behind as f32 / 1e9);
let should = format!(
"{:.2}",
@@ -112,7 +106,6 @@ impl Watchdog {
std::path::Path::new("."),
"server",
message,
stack,
) {
Some(path) => log_from(
"Server Watchdog",
+2 -3
View File
@@ -1193,13 +1193,12 @@ impl Game<'_> {
return;
}
// Projectile.shoot spreads the shot with the projectile's own random.
let mut own = random_source::entity();
match item.as_str() {
"minecraft:experience_bottle"
| "minecraft:splash_potion"
| "minecraft:lingering_potion" => {
let mut shot = Projectile::new(ProjectileKind::Snowball, at, None);
shot.shoot(&mut own, dir, power, uncertainty);
shot.shoot(&mut random_source::entity(), dir, power, uncertainty);
self.launch_thrown(one, at, shot.velocity, None);
}
_ => {
@@ -1246,7 +1245,7 @@ impl Game<'_> {
a.set_item(&one);
a.pickup = horizon_client::arrows::Pickup::Allowed;
}
shot.shoot(&mut own, dir, power, uncertainty);
shot.shoot_own(dir, power, uncertainty);
self.add_projectile(shot);
}
}
@@ -124,6 +124,15 @@ impl Game<'_> {
/// `ItemStack.hurtAndBreak` for the player's `stack`: the wear Unbreaking
/// leaves, and `ItemDurabilityTrigger` when some is left (`applyDamage`).
pub(super) fn wear(&mut self, stack: &ItemStack, amount: u32) -> u32 {
// ItemStack.isDamageableItem: an unbreakable stack, or one without
// damage/max_damage, takes no wear before Unbreaking or mods are asked.
let component = |id| stack.data().and_then(|data| data.other(id));
if component("minecraft:unbreakable").is_some()
|| component("!minecraft:max_damage").is_some()
|| component("!minecraft:damage").is_some()
{
return 0;
}
let mut loss = self.durability_loss(stack, amount);
// The mods hear a server player's wear once Unbreaking has had its say,
// and may change it or stop it.
@@ -0,0 +1,140 @@
//! Milking a farm animal and shearing a sheep or mooshroom by hand: the milk
//! pitch is fixed at 1.0 (`AbstractCow.mobInteract`, `Goat.mobInteract`,
//! `MushroomCow.mobInteract`) and shearing posts `GameEvent.SHEAR` with the
//! player (`Sheep.mobInteract`, `MushroomCow.mobInteract`).
use super::super::*;
use horizon_client::chunk::Chunk;
use horizon_client::mobs::{Mob, Who};
use horizon_client::motion::PlayerMotion;
use horizon_core::{geometry::Vec3, position::ChunkPos};
use std::sync::Arc;
fn game() -> Game<'static> {
let root = std::path::PathBuf::from(
std::env::var_os("HORIZON_TEST_RESOURCES")
.expect("set HORIZON_TEST_RESOURCES to prepared resources"),
);
let settings = Settings::default();
let loaded = load_resources(
root.clone(),
None,
&root,
&settings,
std::time::Instant::now(),
false,
true,
&Progress::new(),
&mut |_, _| unreachable!(),
)
.unwrap();
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
options.resources = root.clone();
options.execution_role = execution_role::ExecutionRole::IntegratedServer;
let res: &'static _ = Box::leak(Box::new(loaded.resources));
let mut game = Game::new(
res,
loaded.pack,
loaded.gui.unwrap(),
options,
settings,
Audio::silent(&root),
None,
)
.unwrap();
game.build_registries();
game.set_allow_commands(true);
game.world
.chunks
.insert((0, 0), Arc::new(Chunk::empty(0, 0)));
game.block_ticks
.add_empty_chunk(ChunkPos::new(0, 0))
.unwrap();
game.player = Some(PlayerMotion::new(game.motion, Vec3::new(0.5, 64.0, 0.5)).unwrap());
game.screen = Screen::Playing;
game.selected = 0;
game.set_mode_id(0);
game
}
fn spawn(game: &mut Game<'_>, kind: &str, id: u32, at: [f64; 3]) {
game.mobs.clear();
let kind = game.res.mob_types().get(kind).unwrap().clone();
game.mobs
.push(Mob::new(id, kind, Vec3::new(at[0], at[1], at[2]), 0.0));
}
fn target(id: u32) -> Option<InteractTarget> {
Some(InteractTarget {
id: id as i32,
location: [0.0, 0.0, 0.0],
})
}
fn take_shear_events() -> Vec<(&'static str, [f64; 3], Option<Who>)> {
super::TEST_POSTED_EVENTS.with(|events| {
std::mem::take(events.borrow_mut().as_mut().unwrap())
.into_iter()
.filter(|event| event.0 == "minecraft:shear")
.collect()
})
}
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn milk_sounds_carry_pitch_one_and_draw_no_level_random() {
let mut game = game();
for (kind, item, sound) in [
(
"minecraft:cow",
"minecraft:bucket",
"minecraft:entity.cow.milk",
),
(
"minecraft:goat",
"minecraft:bucket",
"minecraft:entity.goat.milk",
),
(
"minecraft:mooshroom",
"minecraft:bowl",
"minecraft:entity.mooshroom.milk",
),
] {
spawn(&mut game, kind, 7, [2.5, 64.0, 2.5]);
game.inventory[0] = Some(ItemStack::new(item, 1));
game.audio.capture = Some(Vec::new());
let before = game.world.random.state();
assert!(game.interact_mob(target(7)).unwrap(), "{kind}");
let sounds = game.audio.capture.take().unwrap();
let heard: Vec<_> = sounds.iter().filter(|s| s.event == sound).collect();
assert_eq!(heard.len(), 1, "{kind}: {sounds:?}");
assert_eq!(heard[0].pitch, 1.0, "{kind} milk pitch");
assert_eq!(
game.world.random.state(),
before,
"{kind} milk drew the level random"
);
}
}
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn shearing_by_hand_posts_the_shear_game_event() {
let mut game = game();
game.inventory[0] = Some(ItemStack::new("minecraft:shears", 1));
super::TEST_POSTED_EVENTS.with(|events| *events.borrow_mut() = Some(Vec::new()));
for (kind, id, at) in [
("minecraft:sheep", 11, [1.5, 64.0, 2.5]),
("minecraft:mooshroom", 12, [2.5, 64.0, 2.5]),
("minecraft:snow_golem", 13, [3.5, 64.0, 2.5]),
] {
spawn(&mut game, kind, id, at);
assert!(game.interact_mob(target(id)).unwrap(), "{kind}");
assert_eq!(
take_shear_events(),
vec![("minecraft:shear", at, Some(Who::Player))],
"{kind}"
);
}
super::TEST_POSTED_EVENTS.with(|events| *events.borrow_mut() = None);
}
@@ -8,39 +8,7 @@ impl Game<'_> {
/// other players' (`renderBlockDestroyAnimation`: within 32 blocks of the camera, the
/// furthest progress on each block).
pub(super) fn cracks(&self, camera: glam::DVec3) -> Vec<([i32; 3], [f32; 2])> {
// `destroy_stage_0` .. `destroy_stage_9`: the sprite name by stage, so the
// lookup does not format a string per crack.
const DESTROY_STAGES: [&str; 10] = [
"block/destroy_stage_0",
"block/destroy_stage_1",
"block/destroy_stage_2",
"block/destroy_stage_3",
"block/destroy_stage_4",
"block/destroy_stage_5",
"block/destroy_stage_6",
"block/destroy_stage_7",
"block/destroy_stage_8",
"block/destroy_stage_9",
];
// `ModelBakery.DESTROY_STAGES_OIT`: with improved transparency the cracks are
// ordinary translucent quads (`OIT_CRUMBLING`) and use their own sprites.
const DESTROY_STAGES_OIT: [&str; 10] = [
"block/destroy_oit_stage_0",
"block/destroy_oit_stage_1",
"block/destroy_oit_stage_2",
"block/destroy_oit_stage_3",
"block/destroy_oit_stage_4",
"block/destroy_oit_stage_5",
"block/destroy_oit_stage_6",
"block/destroy_oit_stage_7",
"block/destroy_oit_stage_8",
"block/destroy_oit_stage_9",
];
let names = if self.settings.improved_transparency {
&DESTROY_STAGES_OIT
} else {
&DESTROY_STAGES
};
let names = crack_stage_names(self.settings.improved_transparency);
let mut stages: Vec<([i32; 3], u32)> = Vec::new();
let mut add = |pos: [i32; 3], stage: u32| match stages.iter_mut().find(|c| c.0 == pos) {
Some(c) => c.1 = c.1.max(stage),
@@ -567,3 +535,86 @@ impl Game<'_> {
rain
}
}
// `destroy_stage_0` .. `destroy_stage_9`: the sprite name by stage, so the
// lookup does not format a string per crack.
const DESTROY_STAGES: [&str; 10] = [
"block/destroy_stage_0",
"block/destroy_stage_1",
"block/destroy_stage_2",
"block/destroy_stage_3",
"block/destroy_stage_4",
"block/destroy_stage_5",
"block/destroy_stage_6",
"block/destroy_stage_7",
"block/destroy_stage_8",
"block/destroy_stage_9",
];
// `ModelBakery.DESTROY_STAGES_OIT`: with improved transparency the cracks are
// ordinary translucent quads (`OIT_CRUMBLING`) and use their own sprites.
const DESTROY_STAGES_OIT: [&str; 10] = [
"block/destroy_oit_stage_0",
"block/destroy_oit_stage_1",
"block/destroy_oit_stage_2",
"block/destroy_oit_stage_3",
"block/destroy_oit_stage_4",
"block/destroy_oit_stage_5",
"block/destroy_oit_stage_6",
"block/destroy_oit_stage_7",
"block/destroy_oit_stage_8",
"block/destroy_oit_stage_9",
];
/// The crack sprites drawn: with improved transparency vanilla's own OIT set.
fn crack_stage_names(improved_transparency: bool) -> &'static [&'static str; 10] {
if improved_transparency {
&DESTROY_STAGES_OIT
} else {
&DESTROY_STAGES
}
}
/// The crack's sprite size (and stage 0's origin) the crumbling shaders read: of the set drawn,
/// as a resource pack may replace one set and not the other (a 32x pack's `destroy_stage_*`
/// beside vanilla's 16x `destroy_oit_stage_*`, #11164).
pub(super) fn crumbling_sprite(
sprites: &std::collections::HashMap<String, horizon_client::hud::Rect>,
improved_transparency: bool,
) -> [f32; 4] {
sprites
.get(crack_stage_names(improved_transparency)[0])
.map_or([0.0; 4], |r| [r.u0, r.v0, r.u1 - r.u0, r.v1 - r.v0])
}
#[cfg(test)]
mod crumbling_sprite_tests {
use super::*;
#[test]
fn the_crack_takes_the_size_of_the_set_it_draws() {
let rect = |u0: f32, size: f32| horizon_client::hud::Rect {
u0,
v0: 0.0,
u1: u0 + size,
v1: size,
w: 16,
h: 16,
};
// A pack's 32x destroy stages beside vanilla's 16x OIT stages, in a 1024-wide atlas.
let sprites = std::collections::HashMap::from([
("block/destroy_stage_0".to_owned(), rect(0.0, 32.0 / 1024.0)),
(
"block/destroy_oit_stage_0".to_owned(),
rect(0.5, 16.0 / 1024.0),
),
]);
assert_eq!(
crumbling_sprite(&sprites, false),
[0.0, 0.0, 32.0 / 1024.0, 32.0 / 1024.0]
);
assert_eq!(
crumbling_sprite(&sprites, true),
[0.5, 0.0, 16.0 / 1024.0, 16.0 / 1024.0]
);
}
}
+20 -2
View File
@@ -54,6 +54,12 @@ impl Game<'_> {
}
}
fn set_has_record(&mut self, pos: [i32; 3], has: bool) {
self.set_has_record_notify(pos, has, true);
}
/// The state change alone (`level.setBlock` with flag 2); `notify` adds
/// `notifyItemChangedInJukebox`'s `block_change`, which
/// `JukeboxBlock.setPlacedBy` does not post.
fn set_has_record_notify(&mut self, pos: [i32; 3], has: bool, notify: bool) {
let state = self.world.block(pos[0], pos[1], pos[2]);
let registry = self.pack.assets.registry;
let Some(mut s) = registry.state(state).cloned() else {
@@ -70,7 +76,7 @@ impl Game<'_> {
});
// `JukeboxBlockEntity.notifyItemChangedInJukebox`: the disc coming
// or going is a block change the sculk sensors hear.
if self.world.block(pos[0], pos[1], pos[2]) != state {
if notify && self.world.block(pos[0], pos[1], pos[2]) != state {
self.post_block_game_event("minecraft:block_change", pos, None, Some(new));
}
}
@@ -82,7 +88,8 @@ impl Game<'_> {
/// `updateCustomBlockEntityTag` has filled the block entity.
pub(super) fn jukebox_placed(&mut self, pos: [i32; 3], stack: &ItemStack) {
if block_entity_item::carries_record(stack) {
self.set_has_record(pos, true);
// #15320: no `block_change` here, only the `block_place` after it.
self.set_has_record_notify(pos, true, false);
}
}
/// `onSongChanged`: neighbours (the signal) and comparators.
@@ -831,5 +838,16 @@ mod block_change_tests {
events.borrow()
);
});
// #15320: `JukeboxBlock.setPlacedBy` sets the state alone; the
// placement's `block_place` is the only event.
game.world.set(2, 4, 5, jukebox).unwrap();
super::super::sculk::TEST_POSTED_EVENTS
.with(|events| *events.borrow_mut() = Some(Vec::new()));
game.set_has_record_notify([2, 4, 5], true, false);
let with_record = game.world.block(2, 4, 5);
assert_ne!(with_record, jukebox, "the state still turns has_record on");
super::super::sculk::TEST_POSTED_EVENTS.with(|events| {
assert_eq!(events.borrow().as_ref().unwrap(), &[], "nothing posted");
});
}
}
+2 -2
View File
@@ -191,8 +191,8 @@ impl Game<'_> {
let disable_blocking = if self.using.is_some() {
0.0
} else {
self.attack_item_info()
.map_or(0.0, |i| i.disable_blocking_seconds)
self.held()
.map_or(0.0, |stack| self.items().weapon_disable_blocking(stack))
};
let ignite = self.weapon_ignite_seconds();
Some(Strike {
+4
View File
@@ -1101,6 +1101,8 @@ mod tests {
let root = std::path::PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES").unwrap());
let home = std::path::PathBuf::from(std::env::var_os("HORIZON_HOME").unwrap()).join("h260");
let world = home.join("saves/h260");
// A new world each run: a border saved by the last one would already be 50.
let _ = std::fs::remove_dir_all(&home);
{
let mut game = open_game(&root, &home, &world);
// A server ticks before it saves.
@@ -1138,6 +1140,8 @@ mod tests {
let root = std::path::PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES").unwrap());
let home = std::path::PathBuf::from(std::env::var_os("HORIZON_HOME").unwrap())
.join("h260-controls");
// A new world each run, as the first open expects.
let _ = std::fs::remove_dir_all(&home);
let world = home.join("saves/h260");
let sizes = |game: &mut Game<'_>| {
assert!(game.run_command("execute in minecraft:overworld run worldborder get"));
+130 -10
View File
@@ -30,8 +30,7 @@ use horizon_world::registry::BlockStateId;
use std::{collections::HashMap, sync::Arc};
mod index;
mod nbt;
pub(super) use index::CartIndex;
use index::cart_at;
pub(super) use index::{CartIndex, cart_at};
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub(super) enum CartKind {
@@ -1678,9 +1677,10 @@ impl Game<'_> {
near.clear();
near.extend(0..self.carts.len() as u32);
}
for &j in &near {
let allowed = self.cart_push_allowed(&self.carts, i, &near);
for (k, &j) in near.iter().enumerate() {
let j = j as usize;
if j == i || !hitbox.intersects(self.carts[j].bounds()) {
if !allowed[k] || j == i || !hitbox.intersects(self.carts[j].bounds()) {
continue;
}
// AbstractMinecart.push(entity): `this` is cart j, `entity` cart i.
@@ -1740,9 +1740,8 @@ impl Game<'_> {
self.pickup_from_cart(i, index);
}
/// `OldMinecartBehavior.pushAndPickupEntities` for living entities in cart
/// `i`'s box (0.2 wider on each side): only a rideable cart moving at
/// `horizontalDistanceSqr() >= 0.01` picks up; an eligible mob boards an
/// empty cart (`startRiding`); others get `Entity.push`.
/// `i`'s box (0.2 wider on each side): an eligible mob boards a moving
/// rideable cart (`startRiding`); others get `Entity.push`.
fn pickup_from_cart(&mut self, i: usize, index: &mut CartIndex) {
let v = self.carts[i].velocity;
if self.carts[i].kind != CartKind::Rideable || v.x * v.x + v.z * v.z < 0.01 {
@@ -1769,10 +1768,10 @@ impl Game<'_> {
let candidates: Vec<u32> = if near.is_empty() {
Vec::new()
} else {
let teams = self.mob_teams();
let (teams, own) = (self.mob_teams(), self.team_with_owner(id, || None));
near.iter()
.map(|&j| &self.mobs[j as usize])
.filter(|m| m.pushable() && pushable_by(None, teams.of(Who::Mob(m.id)), true))
.filter(|m| m.pushable() && pushable_by(own, teams.of(Who::Mob(m.id)), true))
.map(|m| m.id)
.collect()
};
@@ -1895,7 +1894,7 @@ impl Game<'_> {
if !self.native_tick_pre_one(&mut round, id, kind) {
continue;
}
let Some(i) = cart_at(&self.carts, id, i) else {
let Some(i) = self.cart_in_world(id, i, &round, kind) else {
continue;
};
// The ticked copy takes the loot roll instead of copying it: only
@@ -3764,6 +3763,127 @@ mod cart_push_tests {
assert_eq!(cart(1).loot.as_ref(), Some(&loot));
assert_eq!(cart(2).loot, None);
}
/// `OldMinecartBehavior.pushAndPickupEntities` for carts: a moving rideable
/// cart only pushes what `EntitySelector.pushableBy(cart)` allows, by its
/// own team's collision rule and the target's; a still cart keeps vanilla's
/// unfiltered minecart push.
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn a_moving_carts_team_rules_which_carts_it_pushes() {
use crate::game::{Game, commands::Target};
use horizon_client::scoreboard::CollisionRule;
/// Puts a cart on a team of its own, as `/team join` would.
fn on_a_team(game: &mut Game<'_>, id: u32, rule: CollisionRule) {
let name = game.holder_name(Target::Cart(id)).expect("a cart's UUID");
let team = format!("cart-{id}");
game.score.board.add_team(&team).collision_rule = rule;
game.score.board.add_to_team(&name, &team);
}
/// Pushes with cart `pusher` moving at `velocity`, and returns the
/// target's velocity afterwards.
fn push_apart(game: &mut Game<'_>, pusher: usize, target: usize, velocity: Vec3) -> Vec3 {
game.carts[target].velocity = Vec3::ZERO;
game.carts[pusher].velocity = velocity;
let mut index = super::CartIndex::default();
game.push_from_cart(pusher, &mut index);
game.carts[target].velocity
}
let mut game = server_game();
// Cart 2 moves east, into cart 1's box; yaw 0 faces east, along the push.
game.carts.push(Minecart::new(
1,
CartKind::Rideable,
Vec3::new(8.5, 64.0, 8.5),
));
game.carts.push(Minecart::new(
2,
CartKind::Rideable,
Vec3::new(8.1, 64.0, 8.5),
));
let east = Vec3::new(0.2, 0.0, 0.0);
// No teams: the moving cart pushes the still one.
assert_ne!(
push_apart(&mut game, 1, 0, east),
Vec3::ZERO,
"the moving cart pushes the still one"
);
// The target's `never` rule holds the push back.
on_a_team(&mut game, 1, CollisionRule::Never);
assert_eq!(
push_apart(&mut game, 1, 0, east),
Vec3::ZERO,
"the target's collisionRule never keeps it off"
);
// The moving cart's own rule does too.
game.score.board.remove_team("cart-1");
on_a_team(&mut game, 2, CollisionRule::Never);
assert_eq!(
push_apart(&mut game, 1, 0, east),
Vec3::ZERO,
"the moving cart's collisionRule never keeps it off"
);
// Allies with `pushOwnTeam` do not push each other.
game.score.board.remove_team("cart-2");
on_a_team(&mut game, 1, CollisionRule::PushOwnTeam);
let name = game.holder_name(Target::Cart(2)).expect("a cart's UUID");
game.score.board.add_to_team(&name, "cart-1");
assert_eq!(
push_apart(&mut game, 1, 0, east),
Vec3::ZERO,
"pushOwnTeam keeps allies apart"
);
// A still cart is vanilla's else branch: no team filter at all.
assert_ne!(
push_apart(&mut game, 1, 0, Vec3::ZERO),
Vec3::ZERO,
"a still cart pushes the minecart in its box whatever the teams"
);
}
/// `EntitySelector.pushableBy(cart)` for mobs: a moving rideable cart on a
/// `never` team neither boards nor pushes the cow in its path.
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn a_moving_carts_team_keeps_it_from_boarding_a_cow() {
use crate::game::commands::Target;
use horizon_client::{mobs::Mob, scoreboard::CollisionRule};
let mut game = server_game();
let kind = game.res.mob_types().get("minecraft:cow").cloned().unwrap();
let mut cart = Minecart::new(2, CartKind::Rideable, Vec3::new(8.5, 64.0, 8.5));
cart.velocity = Vec3::new(0.2, 0.0, 0.0);
game.carts.push(cart);
let at = Vec3::new(8.5, 64.0, 8.5);
game.mobs.push(Mob::new(7, kind, at, 0.0));
let mut index = super::CartIndex::default();
game.push_from_cart(0, &mut index);
assert_eq!(
game.mobs[0].extra.mount,
Some(2),
"the cow boards the empty moving cart"
);
game.mobs[0].extra.mount = None;
game.mobs[0].velocity = Vec3::ZERO;
// The cart's own `never` rule: no boarding and no push.
let name = game.holder_name(Target::Cart(2)).expect("a cart's UUID");
game.score.board.add_team("cart-2").collision_rule = CollisionRule::Never;
game.score.board.add_to_team(&name, "cart-2");
game.carts[0].velocity = Vec3::new(0.2, 0.0, 0.0);
let mut index = super::CartIndex::default();
game.push_from_cart(0, &mut index);
assert_eq!(
game.mobs[0].extra.mount, None,
"the cart's collisionRule never keeps the cow off"
);
assert_eq!(
game.mobs[0].velocity,
Vec3::ZERO,
"and the cow is not pushed either"
);
}
}
#[cfg(test)]
@@ -97,7 +97,7 @@ impl CartIndex {
/// the usual case while carts tick in order) or found by a scan. A world
/// with hundreds of mineshaft chest carts asked this of every cart several
/// times a tick.
pub(super) fn cart_at(carts: &[Minecart], id: u32, hint: usize) -> Option<usize> {
pub(in crate::game) fn cart_at(carts: &[Minecart], id: u32, hint: usize) -> Option<usize> {
if carts.get(hint).is_some_and(|c| c.id == id) {
return Some(hint);
}
@@ -395,9 +395,10 @@ mod tests {
field.max_length = usize::MAX;
field.multiline = true;
field.apply_edit(&mut edit);
// maxLength is 4 UTF-16 code units: a, b and the emoji's two.
assert_eq!(
(edit.value.as_str(), edit.cursor, edit.highlight),
("a\u{1f600}bc", 4, 1)
("a\u{1f600}b", 3, 1)
);
assert_eq!((edit.max_length, edit.multiline), (4, false));
edit.focused = false;
@@ -523,9 +524,11 @@ mod tests {
field.cursor = 1;
field.highlight = usize::MAX;
field.apply_text(&mut edit, &measure);
// maxLength 3 is three UTF-16 code units, so the emoji fills the
// last two and the b past them is cut.
assert_eq!(
(edit.value.as_str(), edit.cursor, edit.highlight),
("a\u{1f600}b", 1, 3)
("a\u{1f600}", 1, 2)
);
}
}
@@ -800,6 +800,26 @@ impl<'a> Game<'a> {
levels.loaded_ticking(cx, cz) && super::ticking_chunk(&self.world, cx, cz)
})
}
/// The tick's spawning phases in `ServerLevel.tick`'s order: the raids
/// tick first (`this.raids.tick`), then the chunk source's
/// (`getChunkSource().tick`): natural spawning, then the custom spawners
/// of `ServerChunkCache.tickChunks`. A wave that lands this tick therefore
/// joins before that tick's spawns, with the lower entity ids.
pub(super) fn tick_spawners(&mut self) {
let from = self.lan_synced_from();
horizon_client::spans::time("spawn_raids", || self.tick_raids());
horizon_client::spans::time("spawn_natural", || self.spawn_natural());
// ServerChunkCache.tickChunks: the custom spawners tick only with spawn_mobs.
if self.ui.rules.bool("spawn_mobs") {
horizon_client::spans::time("spawn_custom", || {
self.tick_wandering_trader();
self.tick_patrol_spawner();
self.tick_cat_spawner();
self.tick_village_siege();
});
}
self.lan_synced_since(from);
}
/// `ServerChunkCache.tickChunks`: the spawn state over the loaded mobs,
/// the categories under their global caps (creatures every 400 ticks),
/// then `tickSpawningChunk` in the shuffled spawning chunks of every
@@ -1340,4 +1360,29 @@ mod tests {
.is_none()
);
}
/// `ServerLevel.tick` ticks the raids before the chunk source spawns
/// (`raids.tick`, then `getChunkSource().tick`): a wave that lands in a
/// tick joins before that tick's natural and custom spawns, with the
/// lower entity ids. The phases name themselves in the spans they run in.
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn raids_tick_before_natural_and_custom_spawning() {
let mut game = crate::game::native_machines::tests::server_game();
horizon_client::spans::record_here(true);
let _ = horizon_client::spans::take_raw();
game.tick_spawners();
let raw = horizon_client::spans::take_raw();
horizon_client::spans::record_here(false);
let phases: Vec<&str> = raw
.iter()
.map(|s| s.name)
.filter(|name| name.starts_with("spawn_"))
.collect();
assert_eq!(
phases,
["spawn_raids", "spawn_natural", "spawn_custom"],
"ServerLevel.tick ticks the raids before the chunk source's spawns"
);
}
}
+64 -5
View File
@@ -26,6 +26,31 @@ pub(in crate::game) struct Shown {
/// A drawn quad covering this much of the view is the screen's dimming, not something to avoid.
const GUIDE_BACKDROP_SHARE: f32 = 0.9;
/// `ItemStack.get(EQUIPPABLE)` for the guide: the stack's slot and
/// `swappable`, else the item's own.
fn guide_equippable(
stack: &ItemStack,
default_slot: Option<&str>,
default_swappable: bool,
) -> Option<(String, bool)> {
let data = stack.data();
if data.is_some_and(|d| d.other("!minecraft:equippable").is_some()) {
return None;
}
if let Some(tag) = data.and_then(|d| d.other("minecraft:equippable")) {
let slot = match tag.get("slot") {
Some(horizon_world::nbt::Tag::String(slot)) => slot.to_string_lossy(),
_ => return None,
};
let swappable = tag
.get("swappable")
.and_then(horizon_client::item_data::component_bool)
.unwrap_or(true);
return Some((slot, swappable));
}
Some((default_slot?.to_owned(), default_swappable))
}
impl Game<'_> {
/// The guide shows while the pad plays: not over a menu, a prompt, the debug screen or a sleeping
/// or missing player, not once the mouse or keyboard took over, and not when the setting is off.
@@ -630,11 +655,10 @@ impl Game<'_> {
}
}
// Equippable.swapWithEquipmentSlot: only what can be swapped onto the armour slots.
_ if info.swappable
&& matches!(
info.equip_slot.as_deref(),
Some("head" | "chest" | "legs" | "feet")
) =>
_ if guide_equippable(&stack, info.equip_slot.as_deref(), info.swappable)
.is_some_and(|(slot, swappable)| {
swappable && matches!(slot.as_str(), "head" | "chest" | "legs" | "feet")
}) =>
{
Verb::Equip
}
@@ -667,3 +691,38 @@ impl Game<'_> {
(self.creative || self.projectile_slot(&weapon).is_some()).then_some(())
}
}
#[cfg(test)]
mod guide_equippable_tests {
use super::guide_equippable;
use horizon_client::items::ItemStack;
#[test]
fn a_stack_swappable_flag_comes_from_its_equippable() {
let mut helmet = ItemStack::new("minecraft:golden_helmet", 1);
let default = Some("head");
assert_eq!(
guide_equippable(&helmet, default, true),
Some(("head".into(), true))
);
helmet.data_mut().set_other(
"minecraft:equippable",
Some(horizon_world::nbt::Tag::compound([
("slot", horizon_world::nbt::Tag::String("head".into())),
("swappable", horizon_world::nbt::Tag::Byte(0)),
])),
);
assert_eq!(
guide_equippable(&helmet, default, true),
Some(("head".into(), false))
);
helmet.data_mut().set_other(
"!minecraft:equippable",
Some(horizon_world::nbt::Tag::compound(Vec::<(
&str,
horizon_world::nbt::Tag,
)>::new())),
);
assert_eq!(guide_equippable(&helmet, default, true), None);
}
}
+5 -5
View File
@@ -707,7 +707,7 @@ impl Game<'_> {
p.id = self.next_entity;
self.restore_portal_cooldown(p.id, &tag);
self.remember_tags(p.id, &tag);
self.remember_uuid(p.id, uuid, &tag);
self.remember_uuid(p.id, Some(p.own_uuid(uuid)), &tag);
self.add_projectile(p);
}
}
@@ -725,8 +725,8 @@ impl Game<'_> {
self.next_entity += 1;
p.id = self.next_entity;
self.restore_portal_cooldown(p.id, &tag);
// Entity.load: the saved or summoned UUID, as the other projectiles keep theirs.
self.remember_uuid(p.id, uuid, &tag);
// Entity.load: the saved or summoned UUID, else its own random's.
self.remember_uuid(p.id, Some(p.own_uuid(uuid)), &tag);
self.add_projectile(p);
self.remember_last_projectile_tags(&tag);
}
@@ -752,7 +752,7 @@ impl Game<'_> {
self.next_entity += 1;
p.id = self.next_entity;
self.restore_portal_cooldown(p.id, &tag);
self.remember_uuid(p.id, uuid, &tag);
self.remember_uuid(p.id, Some(p.own_uuid(uuid)), &tag);
self.add_projectile(p);
self.remember_last_projectile_tags(&tag);
}
@@ -771,7 +771,7 @@ impl Game<'_> {
self.next_entity += 1;
p.id = self.next_entity;
self.restore_portal_cooldown(p.id, &tag);
self.remember_uuid(p.id, uuid, &tag);
self.remember_uuid(p.id, Some(p.own_uuid(uuid)), &tag);
self.add_projectile(p);
self.remember_last_projectile_tags(&tag);
}
@@ -340,6 +340,7 @@ impl Game<'_> {
Cursor::PointingHand => winit::window::CursorIcon::Pointer,
Cursor::NotAllowed => winit::window::CursorIcon::NotAllowed,
Cursor::ResizeNs => winit::window::CursorIcon::NsResize,
Cursor::ResizeEw => winit::window::CursorIcon::EwResize,
});
}
self.cursor_icon = effective;
+26 -1
View File
@@ -582,7 +582,7 @@ impl Game<'_> {
self.team_with_owner(m.id, || tame_owner(m))
}
/// A mob's own team, else its tamed owner's (`owner` is asked only then).
fn team_with_owner(
pub(super) fn team_with_owner(
&self,
id: u32,
owner: impl FnOnce() -> Option<[i32; 4]>,
@@ -643,6 +643,31 @@ impl Game<'_> {
.collect(),
}
}
/// The carts (indices into `carts`, as `near` holds them) that cart `i`
/// pushes this tick, as `OldMinecartBehavior.pushAndPickupEntities` asks
/// `EntitySelector.pushableBy(cart)`: a moving rideable cart keeps only
/// what its own team's and the target's collision rules allow; a still or
/// unrideable cart pushes every minecart in its box (no team filter).
pub(super) fn cart_push_allowed(
&self,
carts: &[super::minecarts::Minecart],
i: usize,
near: &[u32],
) -> Vec<bool> {
let v = carts[i].velocity;
let moving =
carts[i].kind == super::minecarts::CartKind::Rideable && v.x * v.x + v.z * v.z >= 0.01;
if !moving {
return vec![true; near.len()];
}
let own = self.team_with_owner(carts[i].id, || None);
near.iter()
.map(|&j| {
let target = &carts[j as usize];
sb::pushable_by(own, self.team_with_owner(target.id, || None), true)
})
.collect()
}
/// The mobs on the player's team (`isAlliedTo`).
pub(super) fn player_allies(&self) -> HashSet<u32> {
let Some(team) = self.player_team() else {
+5
View File
@@ -2951,3 +2951,8 @@ mod shriek_capture_tests {
}
}
}
// Milking and shearing farm animals; lives here for `TEST_POSTED_EVENTS`.
#[cfg(test)]
#[path = "farm_animal_interaction_tests.rs"]
mod farm_animal_interaction_tests;
@@ -110,6 +110,33 @@ fn a_torch_that_stays_connects_the_wire_in_a_line_and_powers_it() {
);
}
/// `handleSetCommandBlock` edits the block entity through
/// `BaseCommandBlock.setCommand`/`onUpdated`: the command block is marked
/// changed and its update tag reaches the clients. Horizon applied the edit
/// without marking the chunk.
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn editing_a_command_block_marks_its_chunk() {
let mut game = game();
run(&mut game, &["setblock 5 100 2 command_block[facing=up]"]);
game.world.modified.clear();
if let Some(log) = game.world.block_log.as_mut() {
log.clear();
}
game.apply_command_block(&super::command_blocks::CommandBlockEdit {
pos: (5, 100, 2),
command: "say hi".into(),
track_output: false,
mode: horizon_client::command_block::Mode::Redstone,
conditional: false,
auto: false,
});
assert!(
game.world.modified.contains(&(0, 0)),
"the edited command block's chunk is dirty"
);
}
/// `TestBlock.ownSignal` reads the block state's `mode`, not the block
/// entity's saved copy: a `start` state with a powered block entity answers
/// 15 even when `/data merge` left a different mode in the entity.
+52
View File
@@ -4,13 +4,19 @@
//! the block sound, attacker knockback (`blockedByItem`) and axes disabling
//! the shield (`Player.blockUsingItem`, `BlocksAttacks.disable`).
use super::Game;
use super::creatures::source_damage_type;
use horizon_client::{
health::{BlockOutcome, Blocking, HurtSource},
items::ItemStack,
mobs::{Damage, Who},
};
use horizon_core::random::RandomSource;
use horizon_world::blocking_rules::{BlockingRules, DamageReduction, ItemDamageFunction};
#[cfg(test)]
#[path = "shield_ram_tests.rs"]
mod ram_tests;
/// `Inventory.SLOT_OFFHAND`: the `using` slot of an off-hand item.
pub(super) const OFFHAND_SLOT: usize = 40;
@@ -113,6 +119,52 @@ impl Game<'_> {
angle,
})
}
/// `LivingEntity.knockback` of the player by a mob event. A ram
/// (`RamTarget.tick`) names its hit's damage: `applyItemBlocking` runs a
/// second time for it, and `blockingFactor` halves the power when it
/// blocked part of it. What follows that block is the hurt's too
/// (`hurtBlockingItem`, `blockedByItem`), but not its sound: `onBlocked`
/// belongs to `hurtServer`.
pub(super) fn mob_pushes_player(
&mut self,
mut power: f64,
x: f64,
z: f64,
source: Damage,
ram_damage: Option<f32>,
) {
let damage_type = source_damage_type(source);
let attacker = match source.attacker {
Some(Who::Mob(id)) => Some(id),
_ => None,
};
if let Some(damage) = ram_damage
&& let Some(blocking) = self.player_blocking(source.from, damage_type, false)
&& let blocked = blocking.blocked(damage)
&& blocked > 0.0
{
let outcome = BlockOutcome {
blocked,
fully_blocked: blocked >= damage,
..Default::default()
};
let hurt = HurtSource {
damage_type,
causing: attacker.map_or(0, |id| id as i32),
direct: 0,
};
self.after_block(outcome, (attacker, false), hurt);
power *= 0.5;
}
// A player's source names no melee attacker, as its damage's doesn't.
let _attacker = self.knockback_scope(attacker.map_or(0, |id| id as i32));
let source = HurtSource {
damage_type,
causing: 0,
direct: 0,
};
self.knock_back_player(power, x, z, source);
}
/// After a hurt with the shield raised: `hurtBlockingItem`, `onBlocked`
/// and, for melee from a mob, `blockUsingItem`. `source` is the hit's.
pub(super) fn after_block(
@@ -0,0 +1,84 @@
//! `RamTarget.tick`: the push a ram gives, and the shield that softens it
//! (`applyItemBlocking`'s `blockingFactor`).
use super::super::*;
use horizon_client::chunk::Chunk;
use horizon_client::mobs::{Damage, MobEvent, Who};
use std::sync::Arc;
fn game() -> Game<'static> {
let root = std::path::PathBuf::from(
std::env::var_os("HORIZON_TEST_RESOURCES")
.expect("set HORIZON_TEST_RESOURCES to prepared resources"),
);
let settings = Settings::default();
let loaded = load_resources(
root.clone(),
None,
&root,
&settings,
std::time::Instant::now(),
false,
true,
&Progress::new(),
&mut |_, _| unreachable!(),
)
.unwrap();
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
options.resources = root.clone();
options.execution_role = execution_role::ExecutionRole::IntegratedServer;
let res: &'static _ = Box::leak(Box::new(loaded.resources));
let mut game = Game::new(
res,
loaded.pack,
loaded.gui.unwrap(),
options,
settings,
Audio::silent(&root),
None,
)
.unwrap();
game.player = Some(PlayerMotion::new(game.motion, Vec3::new(0.5, 64.0, 0.5)).unwrap());
// Facing the goat, which stands to the south.
game.yaw = 0.0;
game.build_registries();
game.set_allow_commands(true);
game.world
.chunks
.insert((0, 0), Arc::new(Chunk::empty(0, 0)));
assert!(game.run_command("summon minecraft:goat 0.5 64 1.5 {NoAI:1b,PersistenceRequired:1b}"));
game
}
/// `RamTarget.tick` on a goat with `speedFactor` 3.0 and force 2.5: the hit's
/// 2 damage, then the push away from the goat.
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn a_shield_halves_the_push_of_a_ram() {
let mut game = game();
let goat = game.mobs[0].id;
let from = [0.5, 64.0, 1.5];
let ram = |game: &mut Game<'_>| {
game.handle_mob_events(vec![MobEvent::Knockback {
target: Who::Player,
power: 7.5,
x: 0.0,
z: 1.0,
source: Damage::mob(goat, from),
apply_blocking: Some(2.0),
}])
.unwrap();
game.player.as_ref().unwrap().velocity
};
// An open victim takes the whole push.
assert_eq!(ram(&mut game).z, -7.5, "an unblocked ram's push");
// A raised shield blocks the hit (`blockingFactor` 0.5), and the push with it.
game.player.as_mut().unwrap().velocity = Vec3::ZERO;
game.offhand = Some(ItemStack::new("minecraft:shield", 1));
// `BlocksAttacks.blockDelayTicks` of the shield: 0.25s.
game.using = Some((super::OFFHAND_SLOT, 5));
assert_eq!(
ram(&mut game).z,
-3.75,
"a ram a shield blocks pushes half as far"
);
}
@@ -161,8 +161,8 @@ impl Game<'_> {
let disable_blocking = if self.using.is_some() {
0.0
} else {
self.attack_item_info()
.map_or(0.0, |i| i.disable_blocking_seconds)
self.held()
.map_or(0.0, |stack| self.items().weapon_disable_blocking(stack))
};
Some(PlayerStab {
victim,
+32 -5
View File
@@ -317,11 +317,7 @@ impl Game<'_> {
"minecraft:block.ender_chest.close"
}
} else if name.contains("chest") {
if open {
"minecraft:block.chest.open"
} else {
"minecraft:block.chest.close"
}
horizon_client::mobs::transport_items::chest_hinge_sound(&name, open)
} else if &*name == "minecraft:barrel" {
if open {
"minecraft:block.barrel.open"
@@ -564,6 +560,37 @@ impl Game<'_> {
mod tests {
use super::*;
/// `ChestBlockEntity`'s opener counter plays the block's own hinge
/// sound: a copper chest's, by weather state, not the wooden chest's.
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn a_player_opens_and_closes_a_chest_with_its_own_hinge_sound() {
let mut game = super::super::native_machines::tests::server_game();
for (block, sound) in [
("minecraft:chest", "minecraft:block.chest"),
(
"minecraft:exposed_copper_chest",
"minecraft:block.copper_chest",
),
(
"minecraft:waxed_oxidized_copper_chest",
"minecraft:block.copper_chest_oxidized",
),
] {
let state = game.pack.assets.registry.default_state(block).unwrap();
game.world.set(8, 64, 8, state).unwrap();
game.audio.heard.clear();
game.container_edge_sound((8, 64, 8), true);
game.container_edge_sound((8, 64, 8), false);
let heard: Vec<_> = game.audio.heard.keys().cloned().collect();
assert_eq!(
heard,
[format!("{sound}.close"), format!("{sound}.open")],
"{block}"
);
}
}
#[test]
fn double_chest_halves_connect_around_the_facing() {
let half = |kind, facing| ChestHalf {
+4 -10
View File
@@ -577,17 +577,11 @@ impl Game<'_> {
let (v, on_ground) = self.player.as_ref().map_or((Vec3::ZERO, true), |p| {
(self.known_player_movement(), p.ground.on_ground)
});
projectile.shoot_from_rotation(
&mut self.world.random,
self.pitch,
self.yaw,
0.0,
2.5,
1.0,
v,
on_ground,
);
projectile.shoot_from_rotation(self.pitch, self.yaw, 0.0, 2.5, 1.0, v, on_ground);
let id = self.take_entity_id();
if let Some(uuid) = projectile.uuid {
self.persist.uuids.insert(id, uuid);
}
let owner_uuid = self.acting_player_uuid();
self.tridents.push(ThrownTrident {
join: Default::default(),
@@ -628,7 +628,6 @@ impl Game<'_> {
(self.known_player_movement(), pl.ground.on_ground)
});
arrow.shoot_from_rotation(
&mut self.world.random,
self.pitch,
self.yaw,
0.0,
+1 -10
View File
@@ -101,16 +101,7 @@ impl Game<'_> {
let velocity = self.known_player_movement();
let start = Vec3::new(pos.x, eye[1], pos.z);
let mut proj = Projectile::new(ProjectileKind::WindCharge, start, Some(Who::Player));
proj.shoot_from_rotation(
&mut self.world.random,
self.pitch,
self.yaw,
0.0,
1.5,
1.0,
velocity,
on_ground,
);
proj.shoot_from_rotation(self.pitch, self.yaw, 0.0, 1.5, 1.0, velocity, on_ground);
self.add_projectile(proj);
let pitch = 0.4 / (self.world.random.next_float() * 0.4 + 0.8);
self.audio
+12
View File
@@ -647,6 +647,18 @@ pub struct Blocking {
/// Angle between the head's view and the source position (pi without one).
pub angle: f64,
}
impl Blocking {
/// `BlocksAttacks.resolveBlockedDamage`: what the item stops of `damage`,
/// nothing for a source it bypasses or an arrow that pierces it.
pub fn blocked(&self, damage: f32) -> f32 {
if damage <= 0.0 || self.bypassed || self.pierce {
return 0.0;
}
self.rules
.resolve(&mut MatchedSets, damage, self.angle)
.unwrap_or(0.0)
}
}
/// How many hurts `PlayerMotion::hurt_log` keeps.
const HURT_LOG: usize = 64;
/// One hurt as it was taken: what came in, what a raised blocking item
+12 -3
View File
@@ -881,10 +881,19 @@ mod tests {
unprompted: Some(body(0x21, 2)),
};
let served = serve(Connection::local(b), probe, Duration::from_secs(30));
// The queued packet and the one the session sends of its own accord
// are framed by different paths (`Outbox::take` and `Session::tick`
// in `sweep`), so which of the two goes out first is the sweep's
// timing, not an order the pool promises: take both, then tell them
// apart. The kick still has to come after the queued one.
served.send(vec![body(3, 10)], None);
assert_eq!(client.recv().unwrap(), packet(&body(3, 10)));
// What the session sends of its own accord.
assert_eq!(client.recv().unwrap(), packet(&body(0x21, 2)));
let mut first = client.recv().unwrap();
let mut second = client.recv().unwrap();
if first == packet(&body(0x21, 2)) {
std::mem::swap(&mut first, &mut second);
}
assert_eq!(first, packet(&body(3, 10)));
assert_eq!(second, packet(&body(0x21, 2)));
client.send(&body(9, 1)).unwrap();
client.send(&body(10, 1)).unwrap();
assert_eq!(client.recv().unwrap(), packet(&body(0x7f, 3)));
+11 -38
View File
@@ -1435,31 +1435,6 @@ impl Extra {
}
}
/// `DeathProtection.TOTEM_EFFECTS`: clear, then regeneration II, absorption II
/// and fire resistance.
fn totem_death_effects() -> Vec<crate::items::ConsumeEffect> {
use crate::items::{ConsumeEffect, ConsumeEffectSpec};
let spec = |id: &str, duration, amplifier| ConsumeEffectSpec {
id: id.to_owned(),
duration,
amplifier,
ambient: false,
show_particles: true,
show_icon: true,
};
vec![
ConsumeEffect::ClearAllEffects,
ConsumeEffect::ApplyEffects {
effects: vec![
spec("minecraft:regeneration", 900, 1),
spec("minecraft:absorption", 100, 1),
spec("minecraft:fire_resistance", 800, 0),
],
probability: 1.0,
},
]
}
/// How far the boxes of a set of mobs reach from their positions: the widest reach sideways, below
/// and above among them, worked out from each mob's real box ([`Mob::bounds`]: a shulker's grows
/// toward its open lid, sideways or below its position). A box query over many mobs (a vehicle's,
@@ -2084,14 +2059,14 @@ pub enum MobEvent {
StabDismount {
target: Who,
},
/// `LivingEntity.knockback(power, x, z, source)`: pushed away from the
/// direction.
/// `LivingEntity.knockback(power, x, z, source)`; a ram's blocked again first (`RamTarget.tick`).
Knockback {
target: Who,
power: f64,
x: f64,
z: f64,
source: Damage,
apply_blocking: Option<f32>,
},
/// Set another mob's attack target (HurtByTargetGoal alerts).
SetTarget {
@@ -2599,6 +2574,9 @@ pub struct Seen {
pub delay: i32,
/// `getLastHurtByMob` and its timestamp.
pub hurt_by: Option<(Who, i32)>,
/// `PathfinderMob.isPanicking`: a running panic goal or the brain's
/// `IS_PANICKING` memory.
pub panicking: bool,
/// A raider in an active raid.
pub active_raid: bool,
/// A copper golem's `openedChestPos` (`ContainerUser.hasContainerOpen`).
@@ -2609,8 +2587,6 @@ pub struct Seen {
pub vehicle: Option<u32>,
/// `LivingEntity.getHitbox`: the box, cut below the seat when riding.
pub hitbox: Aabb,
/// A villager's reputation of the player (`getPlayerReputation`).
pub player_rep: i32,
/// `isPushable` and pushed by others (`Entity.push(Entity)` moves it).
pub pushable: bool,
/// A sleeping villager, wandering trader or fox vetoes its own `push(Entity)`.
@@ -2793,11 +2769,11 @@ impl Mob {
self.ticks_frozen >= crate::motion::PlayerMotion::TICKS_REQUIRED_TO_FREEZE
}
/// What other mobs see of this one this tick (`player`: the player's
/// UUID, which villagers' reputation is keyed by). Inline: the game's
/// snapshot of every mob writes each one in its slot, not through a copy.
/// What other mobs see of this one this tick (`_player` is unused: it keyed the villager
/// reputation that nothing read). Inline: the game's snapshot of every mob writes each one in
/// its slot, not through a copy.
#[inline]
pub fn seen(&self, player: Option<[i32; 4]>) -> Seen {
pub fn seen(&self, _player: Option<[i32; 4]>) -> Seen {
let m = self;
let villager = m.species() == Species::Villager;
Seen {
@@ -2838,14 +2814,11 @@ impl Mob {
section: (m.extra.section, m.extra.section_seq),
delay: m.extra.ints[5],
hurt_by: m.ai.last_hurt_by.map(|w| (w, m.ai.last_hurt_by_time)),
panicking: m.ai.panicking,
active_raid: raider::has_active_raid(m),
opened_chest: m.extra.opened_chest,
passenger: m.extra.mount.is_some(),
vehicle: m.extra.mount,
player_rep: match (villager, player) {
(true, Some(uuid)) => villager::ext(m).map_or(0, |e| e.gossips.reputation(uuid)),
_ => 0,
},
pushable: m.pushable(),
sleeping: villager::sleeping(m)
|| (m.species() == Species::Fox && fox::flag(m, fox::SLEEPING)),
@@ -4361,7 +4334,7 @@ impl Mob {
let (main, off) = match self.extra.hand_protection.clone() {
Some(h) => (h.main, h.off),
None => {
let totem = || Some(totem_death_effects());
let totem = || Some(living::totem_death_effects());
(
(self.extra.main_hand.as_deref() == Some(TOTEM))
.then(totem)
+41 -13
View File
@@ -2835,13 +2835,13 @@ pub fn do_hurt_target(m: &mut Mob, who: Who, cx: &mut Cx) {
let power = (m.attrs.attack_knockback as f32 + super::gear::knockback(m) as f32) / 2.0;
if power > 0.0 {
use horizon_core::geometry::trig::Mth;
let to_rad = std::f32::consts::PI / 180.0;
cx.events.push(MobEvent::Knockback {
target: who,
power: f64::from(power),
x: f64::from((m.yaw * to_rad).msin()),
z: f64::from(-(m.yaw * to_rad).mcos()),
x: f64::from(m.yaw.to_radians().msin()),
z: f64::from(-m.yaw.to_radians().mcos()),
source,
apply_blocking: None,
});
m.velocity = Vec3::new(m.velocity.x * 0.6, m.velocity.y, m.velocity.z * 0.6);
}
@@ -3048,6 +3048,7 @@ fn tick_effects(
// a totem adds its effects to it). An effect a tick appends ticks from
// the next tick on.
let first = m.extra.effects.len();
let old_max_absorption = super::armor::max_absorption(m);
let old_boost_amp = m
.extra
.effects
@@ -3140,17 +3141,13 @@ fn tick_effects(
}
i += 1;
}
let absorption_gone = m
.extra
.effects
.iter()
.any(|e| e.0 == "minecraft:absorption" && e.1 <= 0 && e.1 != -1);
m.extra.effects.retain(|e| e.1 > 0 || e.1 == -1);
// LivingEntity.onEffectsRemoved -> refreshDirtyAttributes ->
// onAttributeUpdated(MAX_ABSORPTION): the amount is clamped to the
// maximum left without the effect's modifier.
if absorption_gone {
m.absorption = m.absorption.min(super::armor::max_absorption(m));
// LivingEntity.onEffectsRemoved, or onEffectUpdated for a promoted hidden
// instance (a weaker Absorption) -> refreshDirtyAttributes ->
// onAttributeUpdated(MAX_ABSORPTION): the amount is clamped to the new max.
let max_absorption = super::armor::max_absorption(m);
if max_absorption != old_max_absorption {
m.absorption = m.absorption.min(max_absorption);
}
// LivingEntity.onEffectsRemoved / onEffectUpdated -> refreshDirtyAttributes.
let new_boost_amp = m
@@ -6173,6 +6170,37 @@ mod hurt_teleport_contact_tests {
}
}
#[cfg(test)]
mod hidden_absorption_promotion_tests {
use super::*;
use std::sync::Arc;
/// #11384: a promoted hidden instance goes through `onEffectUpdated(effect,
/// true)`, whose `refreshDirtyAttributes` clamps the absorption amount to the
/// weaker effect's `max_absorption` (Absorption II's 8 down to I's 4).
#[test]
fn a_promoted_weaker_absorption_clamps_the_amount() {
let mut kind = (*crate::mobs::sensor_scan_tests::kind()).clone();
kind.id = "minecraft:pig".into();
kind.species = Species::Pig;
let mut m = Mob::new(203, Arc::new(kind), Vec3::ZERO, 0.0);
m.add_effect("absorption", 600, 0);
m.add_effect("absorption", 2, 1);
assert_eq!(m.extra.effect_fallbacks.len(), 1, "Absorption I waits");
assert_eq!(
(m.absorption, super::super::armor::max_absorption(&m)),
(8.0, 8.0)
);
for _ in 0..3 {
tick_effects(&mut m, None, &mut |_| {});
}
assert_eq!(m.extra.effects.len(), 1);
assert_eq!(m.extra.effects[0].2, 0, "Absorption I took over");
assert_eq!(super::super::armor::max_absorption(&m), 4.0);
assert_eq!(m.absorption, 4.0);
}
}
#[cfg(test)]
mod baby_speed_tests {
use super::*;
+31 -24
View File
@@ -276,8 +276,37 @@ pub(super) fn no_ai_step(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>)
if m.species() == Species::GlowSquid {
glow_squid_dark_tick(m);
}
if m.species() == Species::Tadpole {
// Tadpole.aiStep ages it outside serverAiStep too.
grow_up(m, events);
super::ai::make_age_locked_particle(m, events);
}
}
/// `Tadpole.aiStep`: growing up into a frog after 24000 ticks.
fn grow_up(m: &mut Mob, events: &mut Vec<MobEvent>) {
if m.extra.age >= 0 && !m.extra.age_locked {
m.extra.age += 1;
if m.extra.age >= 24_000 && !m.removed {
m.removed = true;
// Tadpole.ageUp: playSound at its feet, volume 0.15 (not
// when silent: Entity.playSound).
if !m.silent {
let at = [m.position.x, m.position.y, m.position.z];
events.push(MobEvent::Sound("entity.tadpole.grow_up", at, 0.15, 1.0));
}
// `ConversionParams.single(this, false, false)`; the game
// finalizes the frog and keeps it persistent.
events.push(MobEvent::Convert {
from: Box::new(m.clone()),
kind: "minecraft:frog",
params: super::conversion::Params {
keep_equipment: false,
preserve_can_pick_up_loot: false,
},
});
}
}
}
/// Species aiStep additions.
pub fn ai_step(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
use Species as S;
@@ -288,29 +317,7 @@ pub fn ai_step(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
glow_squid_ai_step(m, events);
}
S::Tadpole => {
// Tadpole.aiStep: growing up into a frog after 24000 ticks.
if m.extra.age >= 0 && !m.extra.age_locked {
m.extra.age += 1;
if m.extra.age >= 24_000 && !m.removed {
m.removed = true;
// Tadpole.ageUp: playSound at its feet, volume 0.15 (not
// when silent: Entity.playSound).
if !m.silent {
let at = [m.position.x, m.position.y, m.position.z];
events.push(MobEvent::Sound("entity.tadpole.grow_up", at, 0.15, 1.0));
}
// `ConversionParams.single(this, false, false)`; the game
// finalizes the frog and keeps it persistent.
events.push(MobEvent::Convert {
from: Box::new(m.clone()),
kind: "minecraft:frog",
params: super::conversion::Params {
keep_equipment: false,
preserve_can_pick_up_loot: false,
},
});
}
}
grow_up(m, events);
if !m.fluids.in_water && m.on_ground && m.vertical_collision_below {
let (a, b) = (m.random.next_float(), m.random.next_float());
m.velocity = Vec3::new(
+40 -1
View File
@@ -1,4 +1,5 @@
//! Effective mob armor and toughness at the damage boundary.
use super::finalize::{self, Attr};
use super::{Mob, Species};
use horizon_world::attributes::{Definition, Instance, Observer, entity::EntityAttributes};
use std::convert::Infallible;
@@ -48,10 +49,12 @@ pub fn values(mob: &Mob) -> (f32, f32) {
0.0
};
let map = mob.extra.armor_attributes.as_deref();
let armor_base =
finalize::base_value_with(mob, Attr::Armor, || Some(f64::from(mob.kind.armor)));
let armor = effective(
map,
"minecraft:armor",
f64::from(mob.kind.armor),
armor_base,
f64::from(worn) + covered,
30.0,
);
@@ -245,4 +248,40 @@ mod tests {
assert_eq!(max_absorption_of(None, 0.0, 8.0), 8.0);
assert_eq!(max_absorption_of(None, 0.0, -4.0), 0.0);
}
fn magma_cube(size: i32) -> Mob {
let mut kind = crate::mobs::sensor_scan_tests::kind();
let ty = Arc::make_mut(&mut kind);
ty.id = "minecraft:magma_cube".into();
ty.species = Species::MagmaCube;
let mut mob = Mob::new(1, kind, Vec3::ZERO, 0.0);
crate::mobs::cube::set_size(&mut mob, size);
mob
}
/// MagmaCube.setSize writes `size * 3` into `Attributes.ARMOR`, and
/// `LivingEntity.getArmorValue` reads that base back (no armor map needed).
#[test]
fn magma_cube_armor_is_its_size_times_three_and_softens_hits() {
for size in [2, 4] {
assert_eq!(
values(&magma_cube(size)),
((size * 3) as f32, 0.0),
"size {size}"
);
}
let mut small = magma_cube(1);
let mut big = magma_cube(4);
let hit = Damage::of(DamageKind::Cactus);
let small_before = small.health;
let big_before = big.health;
assert!(small.damage(0.5, hit));
assert!(big.damage(0.5, hit));
assert!(
big_before - big.health < small_before - small.health,
"size 4 lost {} and size 1 lost {}",
big_before - big.health,
small_before - small.health
);
}
}
+62 -1
View File
@@ -224,7 +224,9 @@ impl Goal for DefendTrader {
let Some(t) = cx.w.target_info(by) else {
return false;
};
if !t.alive || t.is_player && t.creative {
if !t.alive
|| t.is_player && (t.creative || cx.w.player.as_ref().is_some_and(|p| p.spectator))
{
return false;
}
let follow = m.attrs.follow_range;
@@ -271,3 +273,62 @@ pub fn trader_llama_tick(m: &mut Mob, w: &MobWorld) {
m.removed = true;
}
}
#[cfg(test)]
mod defend_trader_tests {
use super::*;
use crate::mobs::world::{test_player, with_test_world};
use crate::mobs::{Mob, Species, sensor_scan_tests::kind};
use horizon_core::geometry::Vec3;
use horizon_core::random::LegacyRandom;
use std::sync::Arc;
/// `TraderLlamaDefendWanderingTraderGoal` runs the trader's attacker
/// through `TargetingConditions.DEFAULT`, which never accepts a
/// spectator.
#[test]
fn a_trader_llama_does_not_defend_the_trader_from_a_spectator() {
let mut outcomes = Vec::new();
if !with_test_world(&[], |w, _| {
for spectator in [false, true] {
let mut world = w.clone();
let mut p = test_player(Vec3::new(3.5, 64.0, 0.5));
p.spectator = spectator;
world.player = Some(p);
let mut owner_kind = (*kind()).clone();
owner_kind.species = Species::WanderingTrader;
let mut owner = Mob::new(2, Arc::new(owner_kind), Vec3::new(1.5, 64.0, 0.5), 0.0);
owner.ai.last_hurt_by = Some(Who::Player);
owner.ai.last_hurt_by_time = 5;
let seen = [owner.seen(None)];
let world = crate::mobs::MobWorld {
others: &seen,
..world
};
let mut llama_kind = (*kind()).clone();
llama_kind.species = Species::TraderLlama;
let mut llama = Mob::new(1, Arc::new(llama_kind), Vec3::new(0.5, 64.0, 0.5), 0.0);
llama.extra.leash = Some(super::super::leash::Holder::Mob(2));
llama.ai.goals.add(1, DefendTrader::default());
let mut lr = LegacyRandom::new(0);
let mut events = Vec::new();
let mut cx = Cx {
w: &world,
lr: &mut lr,
events: &mut events,
};
let mut goals = std::mem::take(&mut llama.ai.goals);
goals.tick(&mut llama, &mut cx);
outcomes.push(goals.is_running("DefendTrader"));
llama.ai.goals = goals;
}
}) {
return;
}
assert_eq!(
outcomes,
vec![true, false],
"a survival player is defended against; a spectator is not"
);
}
}
+73 -21
View File
@@ -189,26 +189,9 @@ impl GoalSelector {
// --- random positions (DefaultRandomPos / LandRandomPos / RandomPos) -------
/// RandomPos.moveUpToAboveSolid: out of the solid blocks, then up to
/// `above` more blocks of air.
fn move_up_to_above_solid(w: &MobWorld, mut p: [i32; 3], above: i32) -> [i32; 3] {
if !w.solid(p[0], p[1], p[2]) {
return p;
}
p[1] += 1;
while p[1] < w.world.top_y() && w.solid(p[0], p[1], p[2]) {
p[1] += 1;
}
let first = p[1];
while p[1] < w.world.top_y() && p[1] - first < above {
p[1] += 1;
if w.solid(p[0], p[1], p[2]) {
p[1] -= 1;
break;
}
}
p
}
#[path = "random_pos.rs"]
mod random_pos;
use random_pos::move_up_to_above_solid;
#[path = "home.rs"]
mod home;
pub use home::within_home;
@@ -2219,6 +2202,8 @@ impl Goal for Breed {
// AbstractHorse.canParent of the partner.
&& (!(equine || camel) || s.tame && !s.ridden && s.health >= s.stats[0] as f32)
&& (!sniffer || super::sniffer::can_mate_state(s.variant))
// BreedGoal.getFreePartner: `!potentialPartner.isPanicking()`.
&& !s.panicking
})
.find(|s| m.distance_sqr(s.position) <= 64.0)
.map(|s| s.id);
@@ -2227,7 +2212,8 @@ impl Goal for Breed {
fn can_continue(&mut self, _: &mut Mob, cx: &mut Cx) -> bool {
self.partner
.and_then(|p| cx.w.seen(p))
.is_some_and(|s| s.alive && s.in_love)
// BreedGoal.canContinueToUse: `!this.partner.isPanicking()`.
.is_some_and(|s| s.alive && s.in_love && !s.panicking)
&& self.love_time < 60
}
fn stop(&mut self, _: &mut Mob, _: &mut Cx) {
@@ -3429,3 +3415,69 @@ mod hurt_by_anger_tests {
);
}
}
#[cfg(test)]
mod breed_continue_tests {
use super::*;
use crate::mobs::world::with_test_world;
use horizon_core::geometry::Vec3;
use std::sync::Arc;
/// `BreedGoal.canContinueToUse` refuses a partner that is panicking. The
/// goal starts with a calm partner and must stop when the partner panics.
#[test]
fn a_breed_goal_stops_when_the_partner_panics() {
let mut kind = None;
if !with_test_world(&[], |_, types| {
kind = Some(Arc::new(types.types["minecraft:cow"].clone()));
}) {
return;
}
let Some(kind) = kind else { return };
let mut partner = Mob::new(2, kind.clone(), Vec3::new(2.5, 64.0, 0.5), 0.0);
partner.extra.in_love = 600;
let mut m = Mob::new(1, kind, Vec3::new(0.5, 64.0, 0.5), 0.0);
m.extra.in_love = 600;
m.ai.goals.add(1, Breed::new(1.0));
let mut started = None;
let calm = [partner.seen(None)];
if !with_test_world(&calm, |w, _| {
let mut lr = LegacyRandom::new(0);
let mut events = Vec::new();
let mut cx = Cx {
w,
lr: &mut lr,
events: &mut events,
};
let mut goals = std::mem::take(&mut m.ai.goals);
goals.tick(&mut m, &mut cx);
started = Some(goals.is_running("Breed"));
m.ai.goals = goals;
}) {
return;
}
partner.ai.panicking = true;
let panicking = [partner.seen(None)];
let mut stopped = None;
if !with_test_world(&panicking, |w, _| {
let mut lr = LegacyRandom::new(0);
let mut events = Vec::new();
let mut cx = Cx {
w,
lr: &mut lr,
events: &mut events,
};
let mut goals = std::mem::take(&mut m.ai.goals);
goals.tick(&mut m, &mut cx);
stopped = Some(!goals.is_running("Breed"));
m.ai.goals = goals;
}) {
return;
}
assert_eq!(
(started, stopped),
(Some(true), Some(true)),
"the breed goal starts for a calm partner and stops when it panics"
);
}
}
+129 -7
View File
@@ -273,17 +273,25 @@ impl Goal for Ram {
fn flags(&self) -> u8 {
MOVE | LOOK
}
fn can_use(&mut self, m: &mut Mob, _cx: &mut Cx) -> bool {
// The ram activity needs the cooldown to be over (it also waits for
// no tempting player and no breed target, which goats lack here).
ram_cooldown(m) <= 0
fn can_use(&mut self, m: &mut Mob, cx: &mut Cx) -> bool {
// `GoatAi.initRamActivity`: the ram activity wants the cooldown over,
// no tempting player and no breed target, and `updateActivity` picks
// RAM before IDLE: a goat being fed or bred only follows.
ram_cooldown(m) <= 0 && !tempted(m, cx.w) && !breeding(m, cx.w)
}
fn can_continue(&mut self, m: &mut Mob, cx: &mut Cx) -> bool {
let g = &m.extra.goat;
match g.phase {
Phase::Done => false,
Phase::Prepare => {
g.time <= PREPARE_TIME_OUT
// `GoatAi.updateActivity` runs every tick: a player who starts
// feeding the goat, or a breed target that appears, switches the
// activity away and `PrepareRamNearestTarget.stop` starts the
// cooldown. (A charge under way keeps `RAM_TARGET`; only its own
// `canStillUse` ends that one.)
!tempted(m, cx.w)
&& !breeding(m, cx.w)
&& g.time <= PREPARE_TIME_OUT
&& g.candidate
.is_some_and(|c| cx.w.target_info(c.target).is_some_and(|t| t.alive))
}
@@ -427,15 +435,20 @@ fn ram_tick(m: &mut Mob, cx: &mut Cx) {
};
let boost = 0.25 * (level("minecraft:speed") - level("minecraft:slowness")) as f32;
let factor = (m.ai.speed * 1.65).clamp(0.2, 3.0) + boost;
// `blockingFactor * speedFactor` stays a float, then the force (a
// double) scales it.
let force = if m.is_baby() { 1.0 } else { 2.5 };
let power = f64::from(factor) * force;
let d = m.extra.goat.ram_direction;
// The knockback's source is `mobAttack`, not the hurt's `noAggroMobAttack`.
// The knockback's source is `mobAttack`, not the hurt's `noAggroMobAttack`,
// and `applyItemBlocking` runs again for it before the push.
cx.events.push(MobEvent::Knockback {
target,
power: f64::from(factor * force),
power,
x: d.x,
z: d.z,
source: Damage::mob(m.id, from),
apply_blocking: Some(amount),
});
finish_ram(m, cx.events);
play(m, cx.events, impact, 1.0);
@@ -576,3 +589,112 @@ mod sound_tests {
assert_eq!(goat.random.next_long(), expected.next_long());
}
}
#[cfg(test)]
mod ram_tests {
use super::*;
use crate::mobs::world::{test_player, with_test_world};
fn fake_goat() -> std::sync::Arc<super::super::MobType> {
let mut kind = crate::mobs::sensor_scan_tests::kind();
let k = std::sync::Arc::make_mut(&mut kind);
k.species = super::super::Species::Goat;
k.food = std::sync::Arc::from(["minecraft:wheat".to_string()]);
kind
}
/// `GoatAi.initRamActivity` wants `TEMPTING_PLAYER` and `BREED_TARGET`
/// absent, and `GoatAi.updateActivity` picks RAM before IDLE: a goat being
/// fed or bred only follows.
#[test]
fn a_fed_or_bred_goat_starts_no_ram() {
let kind = fake_goat();
let mut partner = Mob::new(9, kind.clone(), Vec3::new(2.5, 64.0, 0.5), 0.0);
partner.extra.in_love = 600;
let others = [partner.seen(None)];
let ran = with_test_world(&others, |w, _| {
let mut lr = horizon_core::random::LegacyRandom::new(0);
let mut events = Vec::new();
let mut goat = Mob::new(7, kind.clone(), Vec3::new(0.5, 64.0, 0.5), 0.0);
let mut due = |goat: &mut Mob, w: &MobWorld| {
Ram.can_use(
goat,
&mut Cx {
w,
lr: &mut lr,
events: &mut events,
},
)
};
assert!(due(&mut goat, w), "a ram is due");
// `BREED_TARGET`: in love, with an adult goat in love nearby.
goat.extra.in_love = 600;
assert!(!due(&mut goat, w), "a goat being bred only follows");
goat.extra.in_love = 0;
// `TEMPTING_PLAYER`: a player within 10 blocks holding goat food.
let mut fed = w.clone();
let mut p = test_player(Vec3::new(0.5, 64.0, 3.5));
p.main_hand = Some("minecraft:wheat".into());
fed.player = Some(p);
assert!(!due(&mut goat, &fed), "a goat being fed only follows");
// Any other item is no temptation.
let mut stone = w.clone();
let mut p = test_player(Vec3::new(0.5, 64.0, 3.5));
p.main_hand = Some("minecraft:stone".into());
stone.player = Some(p);
assert!(due(&mut goat, &stone));
});
let _ = ran;
}
/// `RamTarget.tick` posts the hit (`noAggroMobAttack`) and then its push,
/// which names the damage the victim's `applyItemBlocking` runs again on.
#[test]
fn a_ram_posts_the_hits_damage_with_its_push() {
let kind = fake_goat();
let ran = with_test_world(&[], |w, _| {
let mut w = w.clone();
w.player = Some(test_player(Vec3::new(0.5, 64.0, 0.5)));
let mut goat = Mob::new(7, kind.clone(), Vec3::new(0.5, 64.0, 0.5), 0.0);
goat.attrs.attack_damage = 2.0;
goat.ai.speed = 3.0;
goat.extra.goat.ram_direction = Vec3::new(0.0, 0.0, 1.0);
let mut lr = horizon_core::random::LegacyRandom::new(0);
let mut events = Vec::new();
ram_tick(
&mut goat,
&mut Cx {
w: &w,
lr: &mut lr,
events: &mut events,
},
);
assert!(matches!(
events[0],
MobEvent::DamagePlayer {
amount: 2.0,
kind: "minecraft:mob_attack_no_aggro",
..
}
));
let push = events
.iter()
.find_map(|e| match e {
MobEvent::Knockback {
target: Who::Player,
power,
x,
z,
apply_blocking,
..
} => Some((*power, *x, *z, *apply_blocking)),
_ => None,
})
.unwrap_or_else(|| panic!("the ram posts no push: {events:?}"));
// clamp(3.0 * 1.65, 0.2, 3.0) * 2.5, and the hit's damage for the
// victim's second `applyItemBlocking`.
assert_eq!(push, (7.5, 0.0, 1.0, Some(2.0)));
});
let _ = ran;
}
}
+1 -6
View File
@@ -557,12 +557,7 @@ fn snowball_attack(m: &mut Mob, who: Who, _power: f32, cx: &mut Cx) {
let yo = (xd * xd + zd * zd).sqrt() * f64::from(0.2_f32);
let mut p = Projectile::new(ProjectileKind::Snowball, start, Some(Who::Mob(m.id)));
// SnowGolem.rangedAttackUncertainty: 12 on every difficulty.
p.shoot(
&mut crate::random_source::entity(),
[xd, yd + yo - start.y, zd],
1.6,
12.0,
);
p.shoot_own([xd, yd + yo - start.y, zd], 1.6, 12.0);
cx.events.push(MobEvent::Projectile(p));
let pitch = 0.4 / (m.random.next_float() * 0.4 + 0.8);
cx.events.push(MobEvent::Sound(
+1 -2
View File
@@ -668,8 +668,7 @@ fn shoot_crossbow(m: &mut Mob, who: Who, cx: &mut Cx) {
);
p.set_bonus(super::gear::arrow_bonus(m));
// rangedAttackUncertainty: 14 - 4 * difficulty.
p.shoot(
&mut crate::random_source::entity(),
p.shoot_own(
[dx, dy, dz],
1.6,
(14 - i32::from(cx.w.difficulty.id) * 4) as f32,
+25
View File
@@ -21,6 +21,31 @@ use horizon_world::{
registry::BlockStateId,
};
/// `DeathProtection.TOTEM_EFFECTS`: clear, then regeneration II, absorption II
/// and fire resistance.
pub(super) fn totem_death_effects() -> Vec<crate::items::ConsumeEffect> {
use crate::items::{ConsumeEffect, ConsumeEffectSpec};
let spec = |id: &str, duration, amplifier| ConsumeEffectSpec {
id: id.to_owned(),
duration,
amplifier,
ambient: false,
show_particles: true,
show_icon: true,
};
vec![
ConsumeEffect::ClearAllEffects,
ConsumeEffect::ApplyEffects {
effects: vec![
spec("minecraft:regeneration", 900, 1),
spec("minecraft:absorption", 100, 1),
spec("minecraft:fire_resistance", 800, 0),
],
probability: 1.0,
},
]
}
/// How many block particles a landing throws up
/// (`(int)(min(0.2F + power / 15, 2.5) * 150)`), none when the fall is within
/// `safe_fall_distance` (`power = max(floor(fall + 1.0E-6 - safe), 0)`).
+1 -6
View File
@@ -226,12 +226,7 @@ fn spit(m: &mut Mob, who: Who, _power: f32, cx: &mut Cx) {
let zd = t.position.z - m.position.z;
let yo = (xd * xd + zd * zd).sqrt() * f64::from(0.2_f32);
let mut p = Projectile::new(ProjectileKind::LlamaSpit, start, Some(Who::Mob(m.id)));
p.shoot(
&mut crate::random_source::entity(),
[xd, yd + yo, zd],
1.5,
10.0,
);
p.shoot_own([xd, yd + yo, zd], 1.5, 10.0);
cx.events.push(MobEvent::Projectile(p));
let pitch = 1.0 + (m.random.next_float() - m.random.next_float()) * 0.2;
cx.events.push(MobEvent::Sound(
@@ -1255,17 +1255,7 @@ fn shoot_tick(m: &mut Mob, cx: &mut Bx) {
);
// The new entity's own random spreads the shot (5 - difficulty id * 4):
// not the breeze's.
let mut own = LegacyRandom::new(
cx.now
.wrapping_mul(0x5DEE_CE66)
.wrapping_add(i64::from(m.id)),
);
p.shoot(
&mut own,
[xd, yd, zd],
0.7,
5.0 - f32::from(cx.w.difficulty.id) * 4.0,
);
p.shoot_own([xd, yd, zd], 0.7, 5.0 - f32::from(cx.w.difficulty.id) * 4.0);
cx.events.push(MobEvent::Projectile(p));
breeze_sound(m, cx, "entity.breeze.shoot", 1.5);
}
@@ -577,6 +577,7 @@ impl Goal for Charge {
x: f64::from(horizon_core::geometry::trig::sin(yaw)),
z: -f64::from(horizon_core::geometry::trig::cos(yaw)),
source: super::Damage::mob(m.id, from),
apply_blocking: None,
});
m.velocity = Vec3::new(m.velocity.x * 0.6, m.velocity.y, m.velocity.z * 0.6);
}
@@ -0,0 +1,23 @@
//! `RandomPos` helpers of the random-position goals.
use crate::mobs::MobWorld;
/// RandomPos.moveUpToAboveSolid: out of the solid blocks, then up to
/// `above` more blocks of air.
pub(super) fn move_up_to_above_solid(w: &MobWorld, mut p: [i32; 3], above: i32) -> [i32; 3] {
if !w.solid(p[0], p[1], p[2]) {
return p;
}
p[1] += 1;
while p[1] < w.world.top_y() && w.solid(p[0], p[1], p[2]) {
p[1] += 1;
}
let first = p[1];
while p[1] < w.world.top_y() && p[1] - first < above {
p[1] += 1;
if w.solid(p[0], p[1], p[2]) {
p[1] -= 1;
break;
}
}
p
}
@@ -300,6 +300,7 @@ fn cause_extra_knockback(
x: f64::from((m.yaw * to_rad).msin()),
z: f64::from(-(m.yaw * to_rad).mcos()),
source,
apply_blocking: None,
});
m.velocity = Vec3::new(m.velocity.x * 0.6, m.velocity.y, m.velocity.z * 0.6);
}
@@ -89,6 +89,24 @@ fn name_at(w: &MobWorld, p: [i32; 3]) -> String {
.name
.to_string()
}
/// `ChestBlock.getOpenChestSound` / `getCloseChestSound` of a chest block:
/// a copper chest's comes from `CopperChestBlock.getHingeSound`, by its
/// weather state (a waxed chest's is its state's).
pub fn chest_hinge_sound(block: &str, open: bool) -> &'static str {
let base = block
.trim_start_matches("minecraft:")
.trim_start_matches("waxed_");
match (base.ends_with("copper_chest"), base.split('_').next(), open) {
(false, _, true) => "minecraft:block.chest.open",
(false, _, false) => "minecraft:block.chest.close",
(true, Some("weathered"), true) => "minecraft:block.copper_chest_weathered.open",
(true, Some("weathered"), false) => "minecraft:block.copper_chest_weathered.close",
(true, Some("oxidized"), true) => "minecraft:block.copper_chest_oxidized.open",
(true, Some("oxidized"), false) => "minecraft:block.copper_chest_oxidized.close",
(true, _, true) => "minecraft:block.copper_chest.open",
(true, _, false) => "minecraft:block.copper_chest.close",
}
}
fn is_chest_block(name: &str) -> bool {
matches!(name, "minecraft:chest" | "minecraft:trapped_chest") || name.ends_with("copper_chest")
}
@@ -330,20 +348,7 @@ fn open_or_close(cx: &mut Bx, p: [i32; 3], open: bool, mob: u32) {
at[0] += f64::from(o[0] - p[0]) * 0.5;
at[2] += f64::from(o[2] - p[2]) * 0.5;
}
let name = name_at(w, p);
let base = name
.trim_start_matches("minecraft:")
.trim_start_matches("waxed_");
let sound = match (base.ends_with("copper_chest"), base.split('_').next(), open) {
(false, _, true) => "block.chest.open",
(false, _, false) => "block.chest.close",
(true, Some("weathered"), true) => "block.copper_chest_weathered.open",
(true, Some("weathered"), false) => "block.copper_chest_weathered.close",
(true, Some("oxidized"), true) => "block.copper_chest_oxidized.open",
(true, Some("oxidized"), false) => "block.copper_chest_oxidized.close",
(true, _, true) => "block.copper_chest.open",
(true, _, false) => "block.copper_chest.close",
};
let sound = chest_hinge_sound(&name_at(w, p), open).trim_start_matches("minecraft:");
let pitch = cx.lr.next_float() * 0.1 + 0.9;
cx.events.push(MobEvent::Sound(sound, at, 0.5, pitch));
}
+7 -7
View File
@@ -294,8 +294,7 @@ fn drowned_throw(m: &mut Mob, who: Who, _power: f32, cx: &mut Cx) {
if let Some(a) = p.arrow.as_mut() {
a.set_item(&trident);
}
p.shoot(
&mut crate::random_source::entity(),
p.shoot_own(
[xd, yd + dist * f64::from(0.2_f32), zd],
1.6,
ranged_attack_uncertainty(cx.w.difficulty.id),
@@ -895,8 +894,6 @@ pub fn perform_ranged_attack(m: &mut Mob, who: Who, cx: &mut Cx, power: f32) {
let dist = (xd * xd + zd * zd).sqrt();
// AbstractArrow.setBaseDamageFromMob: power * 2 + triangle(difficulty * 0.11, 0.57425).
let id = i32::from(cx.w.difficulty.id);
let mut arrow_random = crate::random_source::entity();
let base = f64::from(power) * 2.0 + arrow_random.triangle(f64::from(id) * 0.11, 0.574_25);
let effect = match m.species() {
Species::Stray => Some(("minecraft:slowness", 600)),
Species::Bogged => Some(("minecraft:poison", 100)),
@@ -905,17 +902,20 @@ pub fn perform_ranged_attack(m: &mut Mob, who: Who, cx: &mut Cx, power: f32) {
};
let mut p = Projectile::new(
ProjectileKind::Arrow {
base_damage: base,
base_damage: 0.0,
critical: false,
effect,
},
start,
Some(Who::Mob(m.id)),
);
// From the arrow's own random, after its UUID.
if let (ProjectileKind::Arrow { base_damage, .. }, Some(a)) = (&mut p.kind, p.arrow.as_mut()) {
*base_damage = f64::from(power) * 2.0 + a.random.triangle(f64::from(id) * 0.11, 0.574_25);
}
p.set_bonus(super::gear::arrow_bonus(m));
ignite_for_wither_skeleton(m, &mut p);
p.shoot(
&mut arrow_random,
p.shoot_own(
[xd, yd + dist * f64::from(0.2_f32), zd],
1.6,
(14 - id * 4) as f32,
+56 -1
View File
@@ -173,7 +173,7 @@ impl Goal for CopyOwnerTarget {
.find(|s| s.id == owner)
.and_then(|s| s.target)
});
seen.is_some_and(|t| t != Who::Mob(m.id))
seen.is_some_and(|t| t != Who::Mob(m.id) && copyable(cx.w, t))
}
fn can_continue(&mut self, m: &mut Mob, cx: &mut Cx) -> bool {
m.target_info(cx.w).is_some_and(|t| t.alive)
@@ -187,6 +187,14 @@ impl Goal for CopyOwnerTarget {
});
}
}
/// `TargetingConditions.test` for the owner's target: `canBeSeenByAnyone`
/// leaves out the dead and spectators.
fn copyable(w: &crate::mobs::MobWorld, t: Who) -> bool {
match t {
Who::Player => w.player.as_ref().is_some_and(|p| p.alive && !p.spectator),
Who::Mob(id) => w.seen(id).is_some_and(|s| s.alive),
}
}
/// `VexMoveControl`: accelerate straight at the wanted point.
pub fn move_control(m: &mut Mob) {
if m.ai.mov.operation != Operation::MoveTo {
@@ -433,4 +441,51 @@ mod owner_save_tests {
let saved = mob_to_nbt(&loose, [0; 4], Some(&stale), None, |_| None);
assert_eq!(saved.get("owner"), None, "no owner, no key");
}
/// `VexCopyOwnerTargetGoal.canUse` runs the owner's target through
/// `TargetingConditions.forNonCombat()`, whose `canBeSeenByAnyone` drops a
/// dead target. A dead target is not copied.
#[test]
fn a_vex_does_not_copy_a_dead_owners_target() {
use crate::mobs::world::with_test_world;
let mut outcomes = Vec::new();
if !with_test_world(&[], |w, _| {
for alive in [true, false] {
let mut owner_kind = (*crate::mobs::sensor_scan_tests::kind()).clone();
owner_kind.species = Species::Evoker;
let mut owner = Mob::new(2, Arc::new(owner_kind), Vec3::new(1.5, 64.0, 0.5), 0.0);
owner.ai.target = Some(Who::Mob(3));
let mut target_kind = (*crate::mobs::sensor_scan_tests::kind()).clone();
target_kind.species = Species::Cow;
let mut target = Mob::new(3, Arc::new(target_kind), Vec3::new(2.5, 64.0, 0.5), 0.0);
target.health = if alive { target.health } else { 0.0 };
let seen = [owner.seen(None), target.seen(None)];
let world = crate::mobs::MobWorld {
others: &seen,
..w.clone()
};
let mut vex = vex();
vex.position = Vec3::new(0.5, 64.0, 0.5);
vex.extra.counter2 = 2;
vex.ai.targets.add(2, CopyOwnerTarget);
let mut lr = horizon_core::random::LegacyRandom::new(0);
let mut events = Vec::new();
let mut cx = Cx {
w: &world,
lr: &mut lr,
events: &mut events,
};
let mut goals = std::mem::take(&mut vex.ai.targets);
goals.tick(&mut vex, &mut cx);
outcomes.push(goals.is_running("CopyOwnerTarget"));
vex.ai.targets = goals;
}
}) {
return;
}
assert_eq!(
outcomes,
vec![true, false],
"an alive target is copied; a dead one is not"
);
}
}
+1 -2
View File
@@ -231,8 +231,7 @@ fn witch_attack(m: &mut Mob, who: Who, _power: f32, cx: &mut Cx) {
Some(Who::Mob(m.id)),
);
// Splash potions throw with gravity 0.05.
p.shoot(
&mut crate::random_source::entity(),
p.shoot_own(
[xd, yd + dist * 0.2, zd],
if dist <= 2.0 { 0.45 } else { 0.75 },
8.0,
+48 -1
View File
@@ -2900,7 +2900,7 @@ impl LivingAirWorld for MotionWorld<'_, '_, '_> {
== "minecraft:scaffolding")
}
fn suppressing_ladder_slide(&mut self) -> Result<bool> {
Ok(self.player.shift_key_down)
Ok(self.player.shift_key_down && !(self.player.client_side && self.player.flying))
}
fn is_player_class(&self) -> bool {
true
@@ -6267,4 +6267,51 @@ mod tests {
assert_eq!(player.velocity.y, want, "client_side={client_side}");
}
}
/// `LocalPlayer.isSuppressingSlidingDownLadder` is `!abilities.flying && super...()`, where
/// `LivingEntity`'s is `isShiftKeyDown()`: a flying player on the client keeps sliding down a
/// ladder whether it sneaks or not. The server's player (`ServerPlayer` has no override) only
/// looks at the sneak.
#[test]
fn a_flying_client_does_not_stop_sliding_down_a_ladder_by_sneaking() {
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client")
.join(horizon_core::VANILLA_VERSION);
if !root.join("movement-properties.nbt").exists() {
return;
}
let assets = Assets::load(&root).unwrap();
let render = assets.reload(&root).unwrap();
let binding = crate::collisions::load(&root, assets.registry).unwrap();
let resources = MotionResources::load(&root, &assets).unwrap();
// (client_side, flying, sneaking, suppressing)
for (client_side, flying, sneaking, want) in [
(true, false, true, true),
(true, false, false, false),
(true, true, true, false),
(true, true, false, false),
(false, false, true, true),
(false, true, true, true),
(false, true, false, false),
] {
let mut scene = World::test_scene(&assets, &render, 2).unwrap();
let mut player = PlayerMotion::new(&resources, Vec3::new(0.5, 64.0, 0.5)).unwrap();
player.client_side = client_side;
player.flying = flying;
player.shift_key_down = sneaking;
let mut w = MotionWorld {
entities: &[],
player: &mut player,
scene: &mut scene,
assets: &assets,
collision_binding: &binding,
resources: &resources,
health: &resources.health,
};
assert_eq!(
w.suppressing_ladder_slide().unwrap(),
want,
"client_side={client_side} flying={flying} sneaking={sneaking}"
);
}
}
}
+43 -9
View File
@@ -275,6 +275,18 @@ pub struct Projectile {
/// charge until [`Projectile::set_item`] gives it another. None for the
/// kinds that are no `Fireball` (a wither skull, a wind charge).
pub item: Option<crate::items::ItemStack>,
/// The UUID its own random made (`Mth.createInsecureUUID`), for the server
/// to keep unless a save gives one: an arrow's at construction, another
/// kind's when it is shot (`shoot_own`), which is the same stream since
/// nothing else draws from it between.
pub uuid: Option<[i32; 4]>,
}
/// A new projectile's own random (`Entity.random`) and the UUID its first two
/// draws make (`Mth.createInsecureUUID`).
pub fn new_own_random() -> (horizon_core::random::LegacyRandom, [i32; 4]) {
let mut random = crate::random_source::entity();
let uuid = crate::save::insecure_uuid(&mut random);
(random, uuid)
}
/// `ThrownEnderpearl.isAllowedToTeleportOwner(owner, level)` for an owner in
/// the pearl's own dimension: vanilla reaches
@@ -463,6 +475,9 @@ impl Projectile {
} else {
0.1
};
// An arrow keeps its own random (`Entity.random`) for its later draws.
let own = kind_is_arrow.then(new_own_random);
let uuid = own.as_ref().map(|o| o.1);
Self {
join: Default::default(),
id: 0,
@@ -496,12 +511,8 @@ impl Projectile {
fall_distance: 0.0,
synced_fire: false,
item,
arrow: kind_is_arrow.then(|| {
// The arrow's own random (`Entity.random`).
Box::new(crate::arrows::ArrowState::new(
crate::random_source::entity(),
))
}),
arrow: own.map(|(random, _)| Box::new(crate::arrows::ArrowState::new(random))),
uuid,
}
}
/// Entity box (`EntityType.sized` cube of `kind.size()`), as `bounds`.
@@ -792,10 +803,33 @@ impl Projectile {
self.velocity = Vec3::new(v[0], v[1], v[2]);
self.update_rotation();
}
/// Projectile.shootFromRotation for player throws.
/// The UUID a loaded or summoned projectile keeps (`Entity.load`): the
/// tag's, else the one its own random made. The constructor makes that
/// random either way, so a kind that makes it at the shot makes it here.
pub fn own_uuid(&mut self, tag_uuid: Option<[i32; 4]>) -> [i32; 4] {
let own = self.uuid.take().unwrap_or_else(|| new_own_random().1);
tag_uuid.unwrap_or(own)
}
/// `Projectile.shoot` from the projectile's own random, after its UUID's
/// draws: an arrow's, or for another kind one made now.
pub fn shoot_own(&mut self, dir: [f64; 3], speed: f32, uncertainty: f32) {
let mut random = match self.arrow.as_mut() {
Some(a) => a.random.clone(),
None => {
let (random, uuid) = new_own_random();
self.uuid = Some(uuid);
random
}
};
self.shoot(&mut random, dir, speed, uncertainty);
if let Some(a) = self.arrow.as_mut() {
a.random = random;
}
}
/// Projectile.shootFromRotation for player throws, from the projectile's
/// own random (`shoot_own`).
pub fn shoot_from_rotation(
&mut self,
random: &mut impl RandomSource,
pitch: f32,
yaw: f32,
offset: f32,
@@ -814,7 +848,7 @@ impl Projectile {
f64::from(-o.sin()),
f64::from(y.cos() * p.cos()),
];
self.shoot(random, dir, speed, uncertainty);
self.shoot_own(dir, speed, uncertainty);
self.velocity = Vec3::new(
self.velocity.x + source_velocity.x,
self.velocity.y
+66 -35
View File
@@ -5073,6 +5073,7 @@ impl Screen {
slot,
kind,
creation,
reset: None,
..
} = &self.subscreen
{
@@ -6562,15 +6563,7 @@ impl Screen {
}
}
// `RealmsResetWorldScreen`'s tiles are drawn, not widgets: the click is a rectangle.
if let SubScreen::ResetWorld {
server,
slot,
kind,
creation,
reset,
..
} = &self.subscreen
{
if let SubScreen::ResetWorld { reset, .. } = &self.subscreen {
if reset.is_some() {
return Outcome::None;
}
@@ -6582,15 +6575,7 @@ impl Screen {
x >= tx && x < tx + 60.0 && y >= ty && y < ty + 69.0
});
if let Some(tile) = tile {
let (server, slot, kind) = (server.clone(), *slot, *kind);
let creation = creation.clone();
if tile == 1 {
self.open_upload_select(server, slot, kind, creation);
} else if tile >= 2
&& let Some(group) = TemplateGroup::ALL.get(tile - 2)
{
self.open_template_list(server, slot, kind, creation, Some(*group));
}
return self.click(Some(70 + tile as u8), x, y, (w, h));
}
return Outcome::None;
}
@@ -12681,24 +12666,70 @@ mod tests {
/// the Realm, slot and reset kind to upload into.
#[test]
fn the_generate_tile_asks_for_the_create_world_screen() {
let mut screen = listed(vec![server(7, None)]);
screen.subscreen = SubScreen::ResetWorld {
server: server(7, None),
slot: 2,
kind: ResetKind::Empty,
creation: None,
templates: None,
job: None,
reset: None,
error: None,
};
match screen.click(Some(70), 0.0, 0.0, (854.0, 480.0)) {
Outcome::CreateWorld(request) => {
assert_eq!((request.server.id, request.slot), (7, 2));
assert_eq!(request.kind, ResetKind::Empty);
assert!(request.creation.is_none());
for (size, (tx, ty)) in [
((854.0, 480.0), (305.0, 63.0)),
((400.0, 243.0), (78.0, 56.0)),
] {
for kind in [ResetKind::Reset, ResetKind::Empty, ResetKind::NewRealm] {
let creation = (kind == ResetKind::NewRealm)
.then(|| ("Realm name".to_owned(), "Description".to_owned()));
let mut screen = listed(vec![server(7, None)]);
screen.subscreen = SubScreen::ResetWorld {
server: server(7, None),
slot: 2,
kind,
creation: creation.clone(),
templates: None,
job: None,
reset: None,
error: None,
};
for (hit, (x, y)) in [
(Some(70), (0.0, 0.0)),
(None, (tx, ty)),
(None, (tx + 30.0, ty + 34.0)),
(None, (tx + 59.5, ty + 68.5)),
] {
match screen.click(hit, x, y, size) {
Outcome::CreateWorld(request) => {
assert_eq!((request.server.id, request.slot), (7, 2));
assert_eq!(request.kind, kind);
assert_eq!(request.creation, creation);
}
_ => panic!("the Generate New World tile asks for Create World"),
}
}
for (dx, dy) in [(-1.0, 0.0), (60.0, 0.0), (0.0, -1.0), (0.0, 69.0)] {
assert!(matches!(
screen.click(None, tx + dx, ty + dy, size),
Outcome::None
));
}
let abort = Arc::new(AtomicBool::new(false));
if let SubScreen::ResetWorld { reset, .. } = &mut screen.subscreen {
*reset = Some(RunningTask {
job: Arc::new(Mutex::new(None)),
title: Arc::new(Mutex::new(String::new())),
abort: Arc::clone(&abort),
});
}
for hit in [
None,
Some(70),
Some(71),
Some(72),
Some(73),
Some(74),
Some(75),
] {
assert!(matches!(screen.click(hit, tx, ty, size), Outcome::None));
assert!(matches!(
screen.subscreen,
SubScreen::ResetWorld { reset: Some(_), .. }
));
assert!(!abort.load(Ordering::Relaxed));
}
}
_ => panic!("the Generate New World tile asks for Create World"),
}
}
+8 -4
View File
@@ -635,7 +635,9 @@ impl Book {
(ns.to_lowercase(), p.to_lowercase())
};
let Some((namespace, path)) = query.split_once(':') else {
return in_lines(&query) || results.iter().any(|r| id(r).1.contains(&query));
// `FullTextSearchTree.searchPlainText`: the plain-text tree only,
// not the ids (#15321).
return in_lines(&query);
};
let (namespace, path) = (namespace.trim(), path.trim());
results.iter().any(|r| id(r).0.contains(namespace))
@@ -2307,10 +2309,12 @@ mod tests {
&*book.entries[custom_index].result.item,
"minecraft:blaze_rod"
);
// Searching without namespace prefix matches the item path in Vanilla parity.
// `FullTextSearchTree`: plain text searches the tooltip lines only
// (#15321); `namespace:path` also searches the result's id.
let lines = |_: &ItemStack| vec!["Custom Recipe".to_owned()];
assert!(book.collection_matches(&[custom_index], "blaze", &lines));
assert!(book.collection_matches(&[custom_index], "rod", &lines));
assert!(book.collection_matches(&[custom_index], "custom", &lines));
assert!(!book.collection_matches(&[custom_index], "blaze", &lines));
assert!(!book.collection_matches(&[custom_index], "rod", &lines));
assert!(book.collection_matches(&[custom_index], "minecraft:blaze", &lines));
assert!(!book.collection_matches(&[custom_index], "iron", &lines));
}
+29 -8
View File
@@ -296,7 +296,14 @@ impl Host {
for t in self.threads.drain(..) {
let _ = t.join();
}
if let Some(r) = self.shared.reach.lock().unwrap().as_mut() {
// A panic on a sharing thread must not crash the game at stop.
if let Some(r) = self
.shared
.reach
.lock()
.unwrap_or_else(|e| e.into_inner())
.as_mut()
{
r.release();
}
}
@@ -357,13 +364,27 @@ fn share(shared: Arc<Shared>, port: u16) {
// Renew the router mapping at half-lease until stopped.
while !shared.stopped() {
std::thread::sleep(Duration::from_millis(100));
let mut reach = shared.reach.lock().unwrap();
if let Some(m) = reach.as_mut().and_then(|r| r.mapping.as_mut())
&& m.due()
&& m.renew().is_err()
{
// Try again in a minute rather than hammering the router.
m.renewed = Instant::now() - m.lifetime / 2 + Duration::from_secs(60);
// Renewed without the lock: the router may be slow, and stop() waits for this thread
// anyway before it releases the mapping, so it is always back by then.
let due = shared
.reach
.lock()
.unwrap_or_else(|e| e.into_inner())
.as_mut()
.and_then(|r| r.mapping.take_if(|m| m.due()));
if let Some(mut m) = due {
if m.renew().is_err() {
// Try again in a minute rather than hammering the router.
m.renewed = Instant::now() - m.lifetime / 2 + Duration::from_secs(60);
}
if let Some(r) = shared
.reach
.lock()
.unwrap_or_else(|e| e.into_inner())
.as_mut()
{
r.mapping = Some(m);
}
}
}
if let Some(t) = relay {
+86 -4
View File
@@ -434,6 +434,12 @@ fn split_url(url: &str) -> Option<(String, u16, String)> {
))
}
/// How long a router has for its whole HTTP answer.
const HTTP_DEADLINE: Duration = Duration::from_secs(10);
/// Gateways tried from one discovery: anyone on the LAN can answer SSDP, each with its own.
const MAX_GATEWAYS: usize = 8;
/// A tiny HTTP/1.1 client (Connection: close; Content-Length, chunked or
/// to the end).
fn http(
@@ -441,6 +447,16 @@ fn http(
url: &str,
headers: &[(&str, &str)],
body: &str,
) -> io::Result<(u16, String)> {
http_within(method, url, headers, body, HTTP_DEADLINE)
}
fn http_within(
method: &str,
url: &str,
headers: &[(&str, &str)],
body: &str,
within: Duration,
) -> io::Result<(u16, String)> {
let (host, port, path) = split_url(url).ok_or_else(|| io::Error::other("bad URL"))?;
let addr = (host.as_str(), port)
@@ -459,8 +475,39 @@ fn http(
req.push_str("\r\n");
req.push_str(body);
s.write_all(req.as_bytes())?;
// One deadline for the whole answer: a per-read timeout alone let a device sending a byte every
// few seconds hold the request (and the lock or the stop waiting on it) for weeks.
let deadline = Instant::now() + within;
let mut raw = Vec::new();
s.take(1 << 20).read_to_end(&mut raw)?;
let mut chunk = [0u8; 8192];
loop {
let left = deadline
.checked_duration_since(Instant::now())
.filter(|d| !d.is_zero())
.ok_or_else(|| io::Error::new(io::ErrorKind::TimedOut, "router too slow"))?;
s.set_read_timeout(Some(left.min(Duration::from_secs(5))))?;
// A read that times out at the deadline is the router being too slow, whatever kind the
// platform reports it as (Linux: WouldBlock).
let n = match s.read(&mut chunk) {
Ok(n) => n,
Err(e)
if matches!(
e.kind(),
io::ErrorKind::WouldBlock | io::ErrorKind::TimedOut
) =>
{
return Err(io::Error::new(io::ErrorKind::TimedOut, "router too slow"));
}
Err(e) => return Err(e),
};
if n == 0 {
break;
}
raw.extend_from_slice(&chunk[..n]);
if raw.len() > 1 << 20 {
return Err(io::Error::other("router answer too big"));
}
}
let text = String::from_utf8_lossy(&raw).into_owned();
let (head, rest) = text
.split_once("\r\n\r\n")
@@ -487,11 +534,16 @@ fn dechunk(mut s: &str) -> String {
let Ok(n) = usize::from_str_radix(size.split(';').next().unwrap_or("").trim(), 16) else {
break;
};
if n == 0 || rest.len() < n {
// `n` is the device's byte count: a chunk ending inside a character (the text went through
// `from_utf8_lossy`) or past the end stops here rather than panicking.
let (Some(chunk), Some(next)) = (rest.get(..n), rest.get(n..)) else {
break;
};
if n == 0 {
break;
}
out.push_str(&rest[..n]);
s = rest[n..].trim_start_matches("\r\n");
out.push_str(chunk);
s = next.trim_start_matches("\r\n");
}
out
}
@@ -539,6 +591,7 @@ pub fn ssdp_discover(timeout: Duration) -> Vec<String> {
if let Ok((n, _)) = s.recv_from(&mut buf)
&& let Some(l) = ssdp_location(&String::from_utf8_lossy(&buf[..n]))
&& !found.contains(&l)
&& found.len() < MAX_GATEWAYS
{
found.push(l);
}
@@ -750,6 +803,35 @@ pub fn windows_gateway(text: &str) -> Option<Ipv4Addr> {
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_chunk_ending_inside_a_character_stops_the_body() {
// "é" is two bytes: a one-byte chunk ends inside it.
assert_eq!(dechunk("2\r\nab\r\n1\r\né\r\n0\r\n\r\n"), "ab");
assert_eq!(dechunk("2\r\nab\r\n9\r\nshort"), "ab");
assert_eq!(dechunk("3\r\nabc\r\n2\r\né\r\n0\r\n\r\n"), "abcé");
}
#[test]
fn a_router_trickling_its_answer_runs_out_of_time() {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let url = format!("http://{}/desc.xml", listener.local_addr().unwrap());
std::thread::spawn(move || {
let (mut s, _) = listener.accept().unwrap();
let _ = s.read(&mut [0; 1024]);
// Each byte well within the per-read timeout.
for b in b"HTTP/1.1 200 OK\r\n\r\n".iter().cycle().take(200) {
if s.write_all(&[*b]).is_err() {
return;
}
std::thread::sleep(Duration::from_millis(20));
}
});
let started = Instant::now();
let err = http_within("GET", &url, &[], "", Duration::from_millis(300)).unwrap_err();
assert_eq!(err.kind(), io::ErrorKind::TimedOut);
assert!(started.elapsed() < Duration::from_secs(2));
}
use std::{
collections::HashMap,
net::TcpListener,
+75
View File
@@ -1278,6 +1278,28 @@ impl Items {
}
self.get(&stack.item)?.tool_damage_per_block
}
/// `Weapon.disableBlockingForSeconds`: the stack's `weapon` component,
/// else the item's prototype; zero without one.
pub fn weapon_disable_blocking(&self, stack: &ItemStack) -> f32 {
if stack.count == 0 || &*stack.item == "minecraft:air" {
return 0.0;
}
if stack
.data()
.is_some_and(|d| d.other("!minecraft:weapon").is_some())
{
return 0.0;
}
if let Some(weapon) = stack.data().and_then(|d| d.other("minecraft:weapon")) {
return horizon_world::datafix::dynamic::as_float(
weapon.get("disable_blocking_for_seconds"),
0.0,
);
}
self.get(&stack.item)
.map_or(0.0, |i| i.disable_blocking_seconds)
}
/// `Weapon.itemDamagePerAttack`: the stack's `weapon` component, else the
/// item's prototype. A removed component leaves no weapon.
pub fn weapon_damage(&self, stack: &ItemStack) -> Option<u32> {
@@ -2956,6 +2978,59 @@ mod tests {
assert_eq!(items.weapon_damage(&sword), None);
}
#[test]
fn weapon_disable_blocking_comes_from_the_stack() {
let root = std::env::temp_dir().join(format!(
"horizon-weapon-disable-blocking-{}",
std::process::id()
));
fs::create_dir_all(&root).unwrap();
fs::write(
root.join("items.json"),
r#"{"minecraft:stick":{},"minecraft:diamond_axe":{"minecraft:weapon":{"disable_blocking_for_seconds":5.0}}}"#,
)
.unwrap();
let items = Items::catalog(&root, &PackOverlay::NONE).unwrap();
let mut axe = ItemStack::new("minecraft:diamond_axe", 1);
assert_eq!(items.weapon_disable_blocking(&axe), 5.0);
for item in ["minecraft:stick", "test:unknown", "minecraft:air"] {
assert_eq!(items.weapon_disable_blocking(&ItemStack::new(item, 1)), 0.0);
}
// A replacement component uses the codec's zero default, not the axe's.
let empty = crate::item_data::command_components("[weapon={}]").unwrap();
(axe.damage, axe.extra) = crate::item_data::decode_components(Some(&empty));
assert_eq!(items.weapon_disable_blocking(&axe), 0.0);
let custom =
crate::item_data::command_components("[weapon={disable_blocking_for_seconds:1.5f}]")
.unwrap();
(axe.damage, axe.extra) = crate::item_data::decode_components(Some(&custom));
assert_eq!(items.weapon_disable_blocking(&axe), 1.5);
let mut stick = ItemStack::new("minecraft:stick", 1);
(stick.damage, stick.extra) = crate::item_data::decode_components(Some(&custom));
assert_eq!(items.weapon_disable_blocking(&stick), 1.5);
stick.count = 0;
assert_eq!(items.weapon_disable_blocking(&stick), 0.0);
stick.count = 1;
stick.item = "minecraft:air".into();
assert_eq!(items.weapon_disable_blocking(&stick), 0.0);
axe.data_mut().set_other(
"!minecraft:weapon",
Some(horizon_world::nbt::Tag::compound(Vec::<(
&str,
horizon_world::nbt::Tag,
)>::new())),
);
assert_eq!(items.weapon_disable_blocking(&axe), 0.0);
let removal = crate::item_data::command_components("[!weapon]").unwrap();
(axe.damage, axe.extra) = crate::item_data::decode_components(Some(&removal));
let restored = crate::item_nbt::item_from_nbt(&crate::item_nbt::item_to_nbt(&axe)).unwrap();
assert_eq!(items.weapon_disable_blocking(&restored), 0.0);
fs::remove_dir_all(root).unwrap();
}
#[test]
fn loot_table_ids_list_the_roots_and_the_packs_tables() {
let root = std::env::temp_dir().join(format!("horizon-loot-ids-{}", std::process::id()));
-1
View File
@@ -7,7 +7,6 @@ pub mod boot_paint;
pub mod heap_census;
pub mod jobs;
pub mod process_memory;
pub mod stack_dump;
use std::sync::{
OnceLock,
+25 -1
View File
@@ -62,8 +62,32 @@ pub fn resident_bytes() -> Option<u64> {
Some(pages * u64::try_from(page).ok()?)
}
/// The memory this process holds in RAM, in bytes. iOS has no `proc_pidinfo` in libc: the Mach
/// task's basic info instead.
#[cfg(target_os = "ios")]
#[allow(deprecated)]
pub fn resident_bytes() -> Option<u64> {
unsafe extern "C" {
// libc declares it for macOS only.
static mach_task_self_: libc::mach_port_t;
}
let mut count = libc::MACH_TASK_BASIC_INFO_COUNT;
// SAFETY: all-zero is a valid value of this plain C struct; task_info writes at most `count`
// natural_t words into it.
unsafe {
let mut info: libc::mach_task_basic_info = std::mem::zeroed();
let ok = libc::task_info(
mach_task_self_,
libc::MACH_TASK_BASIC_INFO,
(&raw mut info).cast(),
&raw mut count,
);
(ok == libc::KERN_SUCCESS).then_some(info.resident_size)
}
}
/// The memory this process holds in RAM, in bytes.
#[cfg(target_vendor = "apple")]
#[cfg(all(target_vendor = "apple", not(target_os = "ios")))]
pub fn resident_bytes() -> Option<u64> {
let size = std::mem::size_of::<libc::proc_taskinfo>() as libc::c_int;
// SAFETY: all-zero is a valid value of this plain C struct, and the call writes at most `size`
-240
View File
@@ -1,240 +0,0 @@
//! Another thread's stack, read from outside it: the server watchdog's crash report says what
//! the hung tick was doing, as vanilla's `ServerWatchdog` dumps the server thread's stack (#15349).
//!
//! On Linux, Android and Apple systems a signal interrupts the watched thread and its handler
//! captures that thread's own backtrace. The handler allocates, which a signal handler should not
//! do: ask only right before the process ends, as the watchdog does. A thread that never runs the
//! handler (stuck in the kernel, or deadlocked inside the handler) leaves the asker without an
//! answer once its wait is over.
use std::time::Duration;
/// A thread whose stack another thread can read ([`Watched::stack`]).
#[derive(Clone, Copy, Debug)]
pub struct Watched {
#[cfg_attr(
not(any(target_vendor = "apple", target_os = "linux", target_os = "android")),
allow(dead_code)
)]
thread: usize,
}
/// Why [`Watched::stack`] has no stack.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum NoStack {
/// This system can't interrupt another thread to read its stack.
Unsupported,
/// The thread did not answer within the wait (blocked in the kernel, for one).
NoAnswer,
}
impl std::fmt::Display for NoStack {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
NoStack::Unsupported => "not available on this system",
NoStack::NoAnswer => "the thread did not answer",
})
}
}
impl Watched {
/// The calling thread. It must still run when [`Watched::stack`] is asked.
pub fn current() -> Self {
Self {
#[cfg(any(target_vendor = "apple", target_os = "linux", target_os = "android"))]
thread: unsafe { libc::pthread_self() } as usize,
#[cfg(not(any(target_vendor = "apple", target_os = "linux", target_os = "android")))]
thread: 0,
}
}
/// The thread's stack as `std::backtrace::Backtrace` prints it, starting at the frame the
/// thread was in (on Apple's arm64, at its caller), waiting at most `wait` for it.
pub fn stack(&self, wait: Duration) -> Result<String, NoStack> {
#[cfg(any(target_vendor = "apple", target_os = "linux", target_os = "android"))]
{
imp::stack(self.thread, wait).map(|b| after_handler(&b.to_string()))
}
#[cfg(not(any(target_vendor = "apple", target_os = "linux", target_os = "android")))]
{
let _ = wait;
Err(NoStack::Unsupported)
}
}
}
/// A backtrace's text without the frames the signal added: the capture's, the handler's and the
/// signal trampoline's, the one frame the system puts between the handler and the interrupted
/// code (named `__restore_rt`, `_sigtramp`, or after whatever symbol precedes it). Unchanged when
/// the handler's frame isn't found.
fn after_handler(text: &str) -> String {
fn frame_name(line: &str) -> Option<&str> {
let (n, name) = line.trim().split_once(": ")?;
n.chars().all(|c| c.is_ascii_digit()).then_some(name)
}
let lines: Vec<&str> = text.lines().collect();
let frames: Vec<usize> = (0..lines.len())
.filter(|&i| frame_name(lines[i]).is_some())
.collect();
let Some(handler) = frames.iter().position(|&i| {
frame_name(lines[i]).is_some_and(|n| n == "on_signal" || n.ends_with("::on_signal"))
}) else {
return text.to_owned();
};
let start = frames.get(handler + 2).copied().unwrap_or(lines.len());
lines[start..].iter().map(|l| format!("{l}\n")).collect()
}
#[cfg(any(target_vendor = "apple", target_os = "linux", target_os = "android"))]
mod imp {
use super::NoStack;
use std::{
backtrace::Backtrace,
sync::{
Mutex, OnceLock,
atomic::{AtomicPtr, Ordering},
},
time::{Duration, Instant},
};
/// Ignored when no handler is installed, and used by nothing else here (Go's runtime takes it
/// for the same reason): a stray one does nothing.
const SIGNAL: libc::c_int = libc::SIGURG;
/// The handler's answer, taken by the asker.
static ANSWER: AtomicPtr<Backtrace> = AtomicPtr::new(std::ptr::null_mut());
/// One question at a time.
static ASKING: Mutex<()> = Mutex::new(());
extern "C" fn on_signal(_: libc::c_int) {
let stack = Box::into_raw(Box::new(Backtrace::force_capture()));
// An answer nobody took (its asker gave up) is left as it is: freeing here could be
// the allocation that goes wrong.
ANSWER.store(stack, Ordering::Release);
}
fn installed() -> bool {
static INSTALLED: OnceLock<bool> = OnceLock::new();
*INSTALLED.get_or_init(|| unsafe {
let mut action: libc::sigaction = std::mem::zeroed();
action.sa_sigaction = on_signal as extern "C" fn(libc::c_int) as libc::sighandler_t;
action.sa_flags = libc::SA_RESTART;
libc::sigemptyset(&mut action.sa_mask);
libc::sigaction(SIGNAL, &action, std::ptr::null_mut()) == 0
})
}
fn take() -> Option<Backtrace> {
let answer = ANSWER.swap(std::ptr::null_mut(), Ordering::Acquire);
// SAFETY: only `on_signal` stores, a pointer from `Box::into_raw`, and the swap takes it once.
(!answer.is_null()).then(|| *unsafe { Box::from_raw(answer) })
}
pub(super) fn stack(thread: usize, wait: Duration) -> Result<Backtrace, NoStack> {
let _one = ASKING.lock().unwrap_or_else(|e| e.into_inner());
if !installed() {
return Err(NoStack::Unsupported);
}
// A late answer to an earlier question.
drop(take());
// SAFETY: `Watched::current`'s contract keeps the thread alive.
if unsafe { libc::pthread_kill(thread as libc::pthread_t, SIGNAL) } != 0 {
return Err(NoStack::NoAnswer);
}
let until = Instant::now() + wait;
loop {
if let Some(stack) = take() {
return Ok(stack);
}
if Instant::now() >= until {
return Err(NoStack::NoAnswer);
}
std::thread::sleep(Duration::from_millis(2));
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::{
Arc,
atomic::{AtomicBool, Ordering},
mpsc,
};
#[inline(never)]
fn spin(mut n: u64) -> u64 {
for _ in 0..1_000_000 {
n = std::hint::black_box(n.wrapping_mul(31).wrapping_add(7));
}
n
}
/// Its time goes to a call: Apple's arm64 unwinder loses the frame the signal interrupted
/// (it resumes from the return address), while every caller's frame stays.
#[inline(never)]
fn busy_in_a_named_frame(stop: &AtomicBool) {
let mut n = 0u64;
while !stop.load(Ordering::Relaxed) {
n = std::hint::black_box(spin)(n);
}
}
#[cfg(any(target_vendor = "apple", target_os = "linux", target_os = "android"))]
#[test]
fn reads_the_stack_of_a_busy_thread_from_another() {
let stop = Arc::new(AtomicBool::new(false));
let (tx, rx) = mpsc::channel();
let worker = {
let stop = stop.clone();
std::thread::spawn(move || {
tx.send(Watched::current()).unwrap();
busy_in_a_named_frame(&stop);
})
};
let watched = rx.recv().unwrap();
let stack = watched.stack(Duration::from_secs(10));
stop.store(true, Ordering::Relaxed);
worker.join().unwrap();
let stack = stack.unwrap();
let busy = stack.find("busy_in_a_named_frame");
assert!(
busy.is_some(),
"the busy thread's frame is in its stack:\n{stack}"
);
assert!(
!stack[..busy.unwrap()].contains("on_signal") && !stack.contains("Backtrace"),
"the stack starts where the thread was, not in the signal handler:\n{stack}"
);
}
#[cfg(not(any(target_vendor = "apple", target_os = "linux", target_os = "android")))]
#[test]
fn says_when_the_system_cannot_read_a_stack() {
assert_eq!(
Watched::current().stack(Duration::from_millis(10)),
Err(NoStack::Unsupported)
);
}
#[test]
fn drops_the_capture_handler_and_trampoline_frames() {
let text = " 0: std::backtrace::Backtrace::force_capture
at /rustc/library/std/src/backtrace.rs:312:9
1: horizon_threads::stack_dump::imp::on_signal
at ./src/stack_dump.rs:120:40
2: __os_lock_spin_lock
3: horizon_client::game::Game::tick_server
at ./src/game/levels.rs:10:5
4: main
";
assert_eq!(
after_handler(text),
" 3: horizon_client::game::Game::tick_server
at ./src/game/levels.rs:10:5
4: main
"
);
}
}
+4 -5
View File
@@ -240,11 +240,10 @@ From preview on, stability.md's deprecation windows apply.
## `shaders/`-format packs
They keep their own buffers and tone map to SDR in their last program, and their alpha is theirs. Today their
`final` program writes all four channels onto the frame (`gfx/pack_post.rs` `target`), and `keep_world` copies that
alpha into the kept world. With HDR and its HDR Lighting setting on, the HDR pass reads it as marks: below 0.75 it
lifts as a light-source tier, from 0.75 up nothing (the lighting gain is off under a pack). From this note on their
picture is shown at paper white with nothing lifted, as #3125 suggests. HDR values they could read (`hdr`, headroom,
They keep their own buffers and tone map to SDR in their last program, and their alpha is theirs. Their `final`
program writes all four channels onto the frame (`gfx/pack_post.rs` `target`), and `keep_world` copies that alpha
into the kept world, but the HDR pass doesn't read it as marks: their picture is shown at paper white with nothing
lifted, as #3125 suggests. HDR values they could read (`hdr`, headroom,
paper white) are a later, optional addition for pack authors.
## Upscaling, anti-aliasing, frame generation
+1 -1
View File
@@ -45,7 +45,7 @@ Every setting applies at once, except worker threads, the graphics API and trans
**Translucent batching** (Advanced, after Graphics API; on by default, `batchTranslucent` in `horizon-graphics.txt`, a new key so that the `translucentBatching:false` earlier builds saved for everyone doesn't keep it off; it applies at the next start or resource reload, and is no part of a preset) gives water, glass, ice and other translucent quads a vertex buffer of their own, with pages up to 2 GiB, and their sorted indices the same: the back-to-front list then stays in one page and goes out in a few draw calls instead of switching pages every few sections. The picture is the same and frames come faster, most over water and at high render distances (RTX 4090 at 1080p: 4 to 16% more FPS at render distance 32; over the ocean at 64, 354 draw calls a frame instead of 3,832 and 43% more FPS); it costs a little video memory, as solid and translucent quads no longer share free space (terrain meshes take 1 to 15% more at render distance 32, 5 to 10% more at 64). The large pages shrink once the view has settled (a second after terrain stops arriving): one much larger than what it holds, as after a teleport or a lower render distance, moves into a page of the right size, about 32 MiB a frame. It needs indirect multi-draw that isn't OpenGL's (which loops over the entries on the CPU), and devices whose vanilla frames draw flat (Metal, Intel Gen11 and Gen12 on Vulkan) don't offer it: there the switch is greyed out and reads Off, and macOS doesn't show it. A `shaders/` pack's terrain and shadow passes, and the `[shadow]` pass of one of Horizon's own packs, draw flat where the device can, reading translucent quads beside their section's solid ones: while such a pack runs they go back there. Such a pack picked at the start has the world laid out for it from the first; switching to or from one later meshes the world again.
**HDR** has its own page and three choices: **Off** (the default) keeps SDR; **On** uses HDR wherever the display path offers it; **Auto** uses HDR only while the system reports HDR active for the window's display and an HDR output is offered, otherwise SDR. On is unavailable where no HDR output is offered; Auto stays selectable before the system is queried, including on an SDR display. The controls below work only while HDR is active. The existing `hdr` key in `horizon-graphics.txt` saves On as `true`, Off as `false`, and Auto as `auto`; old boolean files keep their meaning, and older builds ignore `auto` and keep their Off default. It presents to an HDR10 swapchain (`Rgb10a2Unorm`, BT.2020 with the PQ curve) or, where only that is offered, an scRGB one (`Rgba16Float`, extended linear sRGB), on displays and compositors that offer either; `HORIZON_HDR_OUTPUT=pq` or `scrgb` allows only one, and offers it to an `--offscreen` run, which has no display to ask: there HDR is drawn and screenshotted (the `-hdr.png` below) but not presented, and automatic brightness takes 203 and 1000 nits, so HDR can be checked without an HDR display. The frame is drawn exactly as in SDR, in vanilla's gamma space (into a 16-bit target (`Rgba16Unorm`, which clips at white after every pass as the 8-bit one does) when HDR is on as the renderer is made, at the start or a resource reload, with **Smooth Gradients** on (the default, `hdrSmoothGradients`; off, the 8-bit frame) and where the device can draw into it, else an 8-bit one; HDR switched off after that keeps the 16-bit frame and copies it to the 8-bit swapchain, without frame generation, until the next start; with `HORIZON_HDR_FRAME=float` the frame follows HDR, Smooth Gradients and the picked pack unless it was refused, is missing or its owner mod is off (the renderer is made again on the same device before the next frame, and the world meshed again), and with no shader pack or a 0.2 one, where the device can keep the HUD on a layer of its own, a float one (`Rgba16Float`) takes the 16-bit one's place whatever Smooth Gradients is: the world's shaders write light past white into it themselves, and it holds no marks (below)), and a last pass (`hdr.wgsl`) decodes it to linear light at the **Paper White** and lifts light sources (blocks that emit light, lava, the sun and the moon) towards the **Peak Brightness**; everything else stays as in SDR. The mesher puts the light a block's face emits in its vertex colour's alpha (`EMISSION_BITS`), the frame's alpha carries the mark, and only pixels that still show the world before the hand and HUD are lifted. On Vulkan, `horizon-hdr` sends HDR metadata (MaxCLL and mastering luminance at the peak, MaxFALL as 0, unknown, since a frame's average isn't known ahead of it), which wgpu doesn't: without it a compositor may clip everything above SDR white. Paper White and Peak Brightness are **Auto** by default: they follow what the system reports for the display. Set by hand, on the slider and in calibration alike, Paper White runs from 10 to 480 nits by 10, and Peak Brightness from 200 to 2000 nits by 50, where most displays' peaks are, then by 500 up to PQ's 10 000, for displays that are brighter or tone-map the signal themselves; `hdrPeak` keeps any value from 200 to 10 000. On Wayland that's the compositor's preferred image description of the window (`wp_color_management_v1`, in `horizon-hdr`'s `wayland.rs`), watched while HDR is On or Auto (Off does not ask the system); the preferred target peak above reference white reports HDR headroom, so a move to another output or a recalibrated desktop applies at once. KWin maps the content's reference white (203 nits) onto the desktop's SDR white, so Auto puts Paper White at 203 and Peak Brightness at the display's peak over that ratio; Hyprland (0.57) doesn't: it reports the standard 203 as reference white, so Auto puts Paper White at 203, and it shows HDR windows below its own SDR white (`sdr_max_luminance`, which it doesn't report): in a test, a white browser page was brighter than both the game's white and a 1000-nit patch in mpv. Other compositors are unchecked. On Windows Auto takes what wgpu reads from the OS (`Surface::display_hdr_info`: DXGI's `MaxLuminance` and the SDR white level of Windows' SDR content brightness slider, both absolute nits), read again every 2 seconds since no event says they changed; the same reading's current output colour-space state drives Auto (an offered Direct3D 12 HDR10 swapchain alone does not). macOS reports potential EDR headroom, fixed for a display rather than following desktop brightness: Auto uses it above 1, or stays SDR if it is missing or invalid. macOS reports no absolute nits or separate HDR-mode flag. X11, OpenGL, other paths without a report and offscreen runs keep Auto in SDR. Windows' SDR white is the player's own slider; its peak is the display's own figure, or the active calibration profile's where the player ran Windows HDR Calibration, and the readLine truncated
**HDR** has its own page and three choices: **Off** (the default) keeps SDR; **On** uses HDR wherever the display path offers it; **Auto** uses HDR only while the system reports HDR active for the window's display and an HDR output is offered, otherwise SDR. On is unavailable where no HDR output is offered; Auto stays selectable before the system is queried, including on an SDR display. The controls below work only while HDR is active. The existing `hdr` key in `horizon-graphics.txt` saves On as `true`, Off as `false`, and Auto as `auto`; old boolean files keep their meaning, and older builds ignore `auto` and keep their Off default. It presents to an HDR10 swapchain (`Rgb10a2Unorm`, BT.2020 with the PQ curve) or, where only that is offered, an scRGB one (`Rgba16Float`, extended linear sRGB), on displays and compositors that offer either; `HORIZON_HDR_OUTPUT=pq` or `scrgb` allows only one, and offers it to an `--offscreen` run, which has no display to ask: there HDR is drawn and screenshotted (the `-hdr.png` below) but not presented, and automatic brightness takes 203 and 1000 nits, so HDR can be checked without an HDR display. The frame is drawn exactly as in SDR, in vanilla's gamma space (into a 16-bit target (`Rgba16Unorm`, which clips at white after every pass as the 8-bit one does) when HDR is on as the renderer is made, at the start or a resource reload, with **Smooth Gradients** on (the default, `hdrSmoothGradients`; off, the 8-bit frame) and where the device can draw into it, else an 8-bit one; HDR switched off after that keeps the 16-bit frame and copies it to the 8-bit swapchain, without frame generation, until the next start; with `HORIZON_HDR_FRAME=float` the frame follows HDR, Smooth Gradients and the picked pack unless it was refused, is missing or its owner mod is off (the renderer is made again on the same device before the next frame, and the world meshed again), and with no shader pack or a 0.2 one, where the device can keep the HUD on a layer of its own, a float one (`Rgba16Float`) takes the 16-bit one's place whatever Smooth Gradients is: the world's shaders write light past white into it themselves, and it holds no marks (below)), and a last pass (`hdr.wgsl`) decodes it to linear light at the **Paper White** and lifts light sources (blocks that emit light, lava, the sun and the moon) towards the **Peak Brightness**; everything else stays as in SDR. The mesher puts the light a block's face emits in its vertex colour's alpha (`EMISSION_BITS`), the frame's alpha carries the mark, and only pixels that still show the world before the hand and HUD are lifted. Under a `shaders/` pack nothing is: the alpha its last program leaves is its own, and its picture is shown at paper white. On Vulkan, `horizon-hdr` sends HDR metadata (MaxCLL and mastering luminance at the peak, MaxFALL as 0, unknown, since a frame's average isn't known ahead of it), which wgpu doesn't: without it a compositor may clip everything above SDR white. Paper White and Peak Brightness are **Auto** by default: they follow what the system reports for the display. Set by hand, on the slider and in calibration alike, Paper White runs from 10 to 480 nits by 10, and Peak Brightness from 200 to 2000 nits by 50, where most displays' peaks are, then by 500 up to PQ's 10 000, for displays that are brighter or tone-map the signal themselves; `hdrPeak` keeps any value from 200 to 10 000. On Wayland that's the compositor's preferred image description of the window (`wp_color_management_v1`, in `horizon-hdr`'s `wayland.rs`), watched while HDR is On or Auto (Off does not ask the system); the preferred target peak above reference white reports HDR headroom, so a move to another output or a recalibrated desktop applies at once. KWin maps the content's reference white (203 nits) onto the desktop's SDR white, so Auto puts Paper White at 203 and Peak Brightness at the display's peak over that ratio; Hyprland (0.57) doesn't: it reports the standard 203 as reference white, so Auto puts Paper White at 203, and it shows HDR windows below its own SDR white (`sdr_max_luminance`, which it doesn't report): in a test, a white browser page was brighter than both the game's white and a 1000-nit patch in mpv. Other compositors are unchecked. On Windows Auto takes what wgpu reads from the OS (`Surface::display_hdr_info`: DXGI's `MaxLuminance` and the SDR white level of Windows' SDR content brightness slider, both absolute nits), read again every 2 seconds since no event says they changed; the same reading's current output colour-space state drives Auto (an offered Direct3D 12 HDR10 swapchain alone does not). macOS reports potential EDR headroom, fixed for a display rather than following desktop brightness: Auto uses it above 1, or stays SDR if it is missing or invalid. macOS reports no absolute nits or separate HDR-mode flag. X11, OpenGL, other paths without a report and offscreen runs keep Auto in SDR. Windows' SDR white is the player's own slider; its peak Line truncated
With `HORIZON_HDR_FRAME=float`, HDR on and no shader pack or a 0.2 one, devices with independent blending draw the frame in layers ([hdr-linear-light.md](design/hdr-linear-light.md), The HUD and menus). The world's shaders write their own light into the frame instead of a mark (`hdr_written`, `hdr_light.wgsl`), a whole frame at a time, so glass, water, clouds and particles each lay their own light over the light behind them. Improved Transparency's layers write their own light too, before they are weighted and composited. No float frame holds marks: under a 0.2 pack Horizon's own draws keep their colour, alpha 1 where opaque and coverage where blended, HDR output lifts nothing, and the pack's `[tonemap]` takes the tone curve's place below (one or the other runs, never both); without the HUD layer (HDR off, or a device without independent blending) its float frame finishes on the swapchain's 8 bits before the camera overlays, which then draw with the HUD as in SDR, and HDR output stays off. The camera overlays (vignette, pumpkin, powder snow, portal, nausea), post effects and entity outlines draw on light. After the post effects and before the camera overlays, the tone curve eases that light into the peak (`Hdr::tonemap`) in place of HDR lighting's own easing and the light sources' cap at the peak: up to 60% of the peak light stays as it is, and above it ITU-R BT.2390's EETF in PQ rolls it off, on its brightest channel so it keeps its hue. The HUD and menus then draw on an 8-bit target of their own, the world clipped to white, as they found the frame before (each draw clips at white as there), and write how much of the world they hide into an R8 coverage target. The last pass lays the HUD over the world's light by that coverage, in gamma as the HUD blends: light behind see-through HUD shows as vanilla weights the HUD over it, and none shows through what covers it. The menu blur blurs that picture, light above white included, and the menu draws over the result. UI Brightness scales the HUD's own part, not the world under it. A screenshot maps the world's light into SDR, unchanged up to 0.6 of paper white in linear light and smoothly rolled towards white above it, with one scale for all RGB channels to keep their hue (where Horizon lifts nothing, with HDR Lighting off or under a 0.2 pack, the world is clipped at white as SDR draws it); the HUD's own part stays as drawn, whatever UI Brightness is, over the mapped world by its coverage. The world's icon uses the same mapping without the HUD; the `-hdr.png` beside a screenshot keeps the whole HDR picture. `HORIZON_HDR_DEBUG=coverage` tints the coverage magenta for inspection.
+161
View File
@@ -0,0 +1,161 @@
"""anvil_dump prints NBT as JSON; given no file it asks for one instead of crashing."""
import gzip
import json
import struct
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
TOOL = Path(__file__).resolve().parents[1] / "tools" / "anvil_dump.py"
def tag_string(text: str) -> bytes:
data = text.encode("utf-8")
return struct.pack(">H", len(data)) + data
def entry(tag_id: int, name: str, payload: bytes) -> bytes:
return struct.pack(">B", tag_id) + tag_string(name) + payload
def compound(entries: list[bytes]) -> bytes:
"""A TAG_Compound payload: its entries, then the TAG_End that closes it."""
return b"".join(entries) + b"\x00"
def named_root(entries: list[bytes]) -> bytes:
"""A named root compound, the shape a level.dat has."""
return entry(10, "", compound(entries))
LEVEL = named_root([
entry(8, "LevelName", tag_string("Horizon")),
entry(1, "Difficulty", struct.pack(">b", 2)),
entry(3, "Version", struct.pack(">i", 42)),
entry(4, "Seed", struct.pack(">q", 1234567890123)),
entry(9, "Spawn", struct.pack(">bi", 6, 2) + struct.pack(">dd", 1.5, -2.25)),
entry(7, "Bytes", struct.pack(">i", 10) + bytes(range(10))),
])
CHUNK = named_root([
entry(3, "xPos", struct.pack(">i", 0)),
entry(3, "zPos", struct.pack(">i", 0)),
entry(8, "Status", tag_string("minecraft:full")),
])
def region(chunks: dict[tuple[int, int], bytes]) -> bytes:
"""An Anvil region file with the given chunks, one sector each."""
header = bytearray(4096)
blocks = b""
for index, (position, chunk) in enumerate(sorted(chunks.items())):
cx, cz = position
sector = 2 + index
header[(cx + cz * 32) * 4:(cx + cz * 32) * 4 + 3] = sector.to_bytes(3, "big")
payload = gzip.compress(chunk)
blocks += struct.pack(">i", len(payload) + 1) + bytes([1]) + payload
blocks += b"\x00" * (4096 - len(blocks) % 4096)
return bytes(header) + b"\x00" * 4096 + blocks
def run(folder: Path, *arguments: str) -> subprocess.CompletedProcess:
return subprocess.run([sys.executable, str(TOOL), *arguments],
cwd=folder, text=True, capture_output=True)
class UsageTests(unittest.TestCase):
def test_help_prints_the_usage_and_exits_zero(self):
with tempfile.TemporaryDirectory() as folder:
result = run(Path(folder), "--help")
left = sorted(path.name for path in Path(folder).iterdir())
self.assertEqual(result.returncode, 0, result.stderr)
self.assertNotIn("Traceback", result.stderr)
self.assertIn("Usage", result.stdout)
for option in ("--full", "--typed", "--help"):
self.assertIn(option, result.stdout, "the usage does not document " + option)
self.assertEqual(left, [], "the tool wrote files while being asked for help")
def test_short_help_prints_the_usage_and_exits_zero(self):
with tempfile.TemporaryDirectory() as folder:
result = run(Path(folder), "-h")
self.assertEqual(result.returncode, 0, result.stderr)
self.assertNotIn("Traceback", result.stderr)
self.assertIn("Usage", result.stdout)
def test_without_a_file_it_asks_for_one(self):
with tempfile.TemporaryDirectory() as folder:
result = run(Path(folder))
left = sorted(path.name for path in Path(folder).iterdir())
self.assertNotEqual(result.returncode, 0, "it reported success without a file")
self.assertNotIn("Traceback", result.stderr)
self.assertIn("Usage", result.stdout + result.stderr)
self.assertEqual(left, [], "the tool wrote files without being told where")
class DumpTests(unittest.TestCase):
def setUp(self):
self.folder = tempfile.TemporaryDirectory()
self.addCleanup(self.folder.cleanup)
self.path = Path(self.folder.name)
(self.path / "level.dat").write_bytes(LEVEL)
def test_it_dumps_a_dat_file_as_json(self):
result = run(self.path, "level.dat")
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(json.loads(result.stdout), {
"LevelName": "Horizon",
"Difficulty": 2,
"Version": 42,
"Seed": 1234567890123,
"Spawn": [1.5, -2.25],
"Bytes": "B[10: [0, 1, 2, 3]...]",
})
def test_a_path_selects_one_value(self):
result = run(self.path, "level.dat", "LevelName")
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(json.loads(result.stdout), "Horizon")
def test_a_path_walks_into_a_list(self):
result = run(self.path, "level.dat", "Spawn", "1")
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(json.loads(result.stdout), -2.25)
def test_typed_numbers_carry_their_nbt_type(self):
result = run(self.path, "level.dat", "Difficulty", "--typed")
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(json.loads(result.stdout), "2b")
def test_full_prints_long_arrays_whole(self):
result = run(self.path, "level.dat", "Bytes", "--full")
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(json.loads(result.stdout), list(range(10)))
def test_a_gzipped_dat_reads_the_same(self):
(self.path / "zipped.dat").write_bytes(gzip.compress(LEVEL))
result = run(self.path, "zipped.dat", "LevelName")
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(json.loads(result.stdout), "Horizon")
def test_a_region_file_lists_its_chunks(self):
(self.path / "r.0.0.mca").write_bytes(region({(0, 0): CHUNK}))
result = run(self.path, "r.0.0.mca")
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(json.loads(result.stdout), [[0, 0]])
def test_a_region_file_dumps_a_chunk(self):
(self.path / "r.0.0.mca").write_bytes(region({(0, 0): CHUNK}))
result = run(self.path, "r.0.0.mca", "0", "0")
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(json.loads(result.stdout), {
"xPos": 0,
"zPos": 0,
"Status": "minecraft:full",
})
if __name__ == "__main__":
unittest.main()
+1 -1
View File
@@ -5,7 +5,7 @@ import subprocess
import tempfile
import unittest
from tests.test_world_input_capture import method
from test_world_input_capture import method
ROOT = Path(__file__).resolve().parents[1]
+26
View File
@@ -150,6 +150,32 @@ class CommandLineTests(unittest.TestCase):
self.assertIn("GAME es_es ", out)
self.assertIn("LAUNCHER es ", out)
def test_show_keys_is_not_dropped_when_a_mode_also_prints_lines(self):
with tempfile.TemporaryDirectory() as tmp:
folder = Path(tmp)
write(folder, "en_us.json", '{\n "a": "Apple",\n "b": "Banana"\n}\n')
write(folder, "xx.json", '{\n "a": "Manzana",\n "c": "Datil"\n}\n')
common = ("--game-dir", tmp, "--game-only")
code, summary, _ = run(*common, "--summary")
code, with_keys, _ = run(*common, "--summary", "--show-keys")
code, unreviewed, _ = run(*common, "--unreviewed")
code, unreviewed_keys, _ = run(*common, "--unreviewed", "--show-keys")
code, payload, _ = run(*common, "--json", "--show-keys")
self.assertEqual(code, 0)
# the summary keeps its line per file, and the keys behind the counts follow it
self.assertIn("GAME xx 2/2 keys, missing 1, extra 1", summary)
self.assertIn("GAME xx 2/2 keys, missing 1, extra 1", with_keys)
self.assertNotIn("xx: missing (1)", summary)
self.assertIn("xx: missing (1)", with_keys)
self.assertIn(" b", with_keys)
self.assertIn("xx: not in the reference (1)", with_keys)
self.assertIn(" c", with_keys)
self.assertNotEqual(summary, with_keys)
# the unreviewed listing says the same, and the json stays a json
self.assertNotEqual(unreviewed, unreviewed_keys)
self.assertIn("xx: missing (1)", unreviewed_keys)
self.assertEqual(by_code(json.loads(payload)["game"]["files"], "xx")["missing"], ["b"])
class ReviewTests(unittest.TestCase):
"""The record of what a person checked: anything else, or anything changed since, is unreviewed."""
+68
View File
@@ -0,0 +1,68 @@
"""tools/ci/legacy_releases.py: the pinned SHA-256s of the releases published before signing began."""
import unittest
from tools.ci import legacy_releases as legacy
def release(tag, assets, draft=False):
return {"tag_name": tag, "draft": draft, "assets": assets}
def asset(name, digest="sha256:" + "ab" * 32):
return {"name": name, "digest": digest}
class TagKeyTests(unittest.TestCase):
def test_the_numbers_order_by_value_not_by_spelling(self):
self.assertLess(legacy.tag_key("v0.2.9"), legacy.tag_key("v0.2.10"))
self.assertLess(legacy.tag_key("v0.9.0"), legacy.tag_key("v0.10.0"))
def test_a_game_version_comes_before_a_launcher_version(self):
# A game tag starts with "v"; a launcher tag is "[email protected]".
self.assertLess(legacy.tag_key("v1.0.0"), legacy.tag_key("[email protected]"))
def test_the_launcher_versions_keep_their_own_order(self):
self.assertLess(legacy.tag_key("[email protected]"), legacy.tag_key("[email protected]"))
class EntriesTests(unittest.TestCase):
def test_a_draft_is_left_out(self):
lines = legacy.entries([release("v1.0.0", [asset("horizon.tar.gz")], draft=True)])
self.assertEqual(lines, [])
def test_a_signed_release_is_left_out(self):
signed = release("v1.0.0", [asset("horizon.tar.gz"), asset("SHA256SUMS.sig")])
self.assertEqual(legacy.entries([signed]), [])
def test_an_unsigned_release_keeps_its_digest(self):
lines = legacy.entries([release("v1.0.0", [asset("horizon.tar.gz")])])
self.assertEqual(lines, [f"v1.0.0 horizon.tar.gz {'ab' * 32}"])
def test_the_digest_is_written_without_its_prefix(self):
lines = legacy.entries([release("v1.0.0", [asset("horizon.tar.gz", "sha256:" + "CD" * 32)])])
self.assertEqual(lines, [f"v1.0.0 horizon.tar.gz {'cd' * 32}"])
def test_an_asset_without_a_digest_is_refused(self):
with self.assertRaises(SystemExit):
legacy.entries([release("v1.0.0", [asset("horizon.tar.gz", "")])])
def test_the_releases_come_out_in_order(self):
releases = [release("v0.2.10", [asset("a")]), release("v0.2.9", [asset("b")])]
self.assertEqual([line.split()[0] for line in legacy.entries(releases)], ["v0.2.9", "v0.2.10"])
class RenderTests(unittest.TestCase):
def test_the_header_names_the_file_and_its_writer(self):
text = legacy.render([])
self.assertTrue(text.startswith("# Releases published before signing began"))
self.assertIn("tools/ci/legacy_releases.py", text)
def test_the_header_and_the_lines_are_both_written(self):
text = legacy.render([release("v1.0.0", [asset("horizon.tar.gz")])])
self.assertTrue(text.startswith(legacy.HEADER))
self.assertIn("v1.0.0 horizon.tar.gz", text)
self.assertTrue(text.endswith("\n"))
if __name__ == "__main__":
unittest.main()
+1 -1
View File
@@ -7,7 +7,7 @@ from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
from unittest import mock
from tests.test_class_index import Pool
from test_class_index import Pool
from tools import port_ledger
+97
View File
@@ -0,0 +1,97 @@
"""tools/ci/resolve_reviewer_ids.py: the numeric GitHub IDs the comment commands decide by."""
import tempfile
import unittest
from pathlib import Path
from unittest import mock
from tools.ci import resolve_reviewer_ids as resolve
class ReviewerListTests(unittest.TestCase):
"""The check reads the list and the owner's ID; it never writes them."""
def _in(self, list_text, nightly_text, accounts=None):
accounts = accounts or {}
with tempfile.TemporaryDirectory() as folder, \
mock.patch.multiple(resolve, LIST=Path(folder, "reviewers.txt"),
NIGHTLY_SET=Path(folder, "nightly_set.py")), \
mock.patch.object(resolve, "user", side_effect=lambda ref: accounts.get(ref)):
Path(folder, "reviewers.txt").write_text(list_text, encoding="utf-8")
Path(folder, "nightly_set.py").write_text(nightly_text, encoding="utf-8")
errors = []
with mock.patch.object(resolve, "print", create=True):
code = resolve.check()
return code
OWNER = 'OWNER = "ludovic111"\nOWNER_ID: int | None = 136255825\n'
def test_a_list_of_ids_and_logins_is_accepted(self):
code = self._in("136255825 ludovic111\n", self.OWNER,
{"user/136255825": {"id": 136255825, "login": "ludovic111", "type": "User"}})
self.assertEqual(code, 0)
def test_a_line_without_an_id_is_reported(self):
code = self._in("ludovic111\n", self.OWNER,
{"user/136255825": {"id": 136255825, "login": "ludovic111", "type": "User"}})
self.assertEqual(code, 1)
def test_an_id_that_belongs_to_another_login_is_reported(self):
code = self._in("136255825 ludovic111\n", self.OWNER,
{"user/136255825": {"id": 136255825, "login": "someone-else", "type": "User"}})
self.assertEqual(code, 1)
def test_an_id_github_does_not_know_is_reported(self):
code = self._in("999999999 ludovic111\n", self.OWNER,
{"user/136255825": {"id": 136255825, "login": "ludovic111", "type": "User"}})
self.assertEqual(code, 1)
def test_the_paused_line_is_not_a_problem(self):
code = self._in("paused\n", self.OWNER,
{"user/136255825": {"id": 136255825, "login": "ludovic111", "type": "User"}})
self.assertEqual(code, 0)
def test_an_owner_id_that_is_not_set_is_reported(self):
code = self._in("136255825 ludovic111\n", 'OWNER = "ludovic111"\n',
{"user/136255825": {"id": 136255825, "login": "ludovic111", "type": "User"}})
self.assertEqual(code, 1)
class OwnerLoginTests(unittest.TestCase):
def test_the_owner_is_read_from_the_nightly_set(self):
with tempfile.TemporaryDirectory() as folder, \
mock.patch.object(resolve, "NIGHTLY_SET", Path(folder, "nightly_set.py")):
Path(folder, "nightly_set.py").write_text('OWNER = "ludovic111"\n', encoding="utf-8")
self.assertEqual(resolve.owner_login(), "ludovic111")
def test_a_nightly_set_without_an_owner_is_refused(self):
with tempfile.TemporaryDirectory() as folder, \
mock.patch.object(resolve, "NIGHTLY_SET", Path(folder, "nightly_set.py")):
Path(folder, "nightly_set.py").write_text("# nothing here\n", encoding="utf-8")
with self.assertRaises(SystemExit):
resolve.owner_login()
class UserTests(unittest.TestCase):
"""The lookup asks GitHub for a login or for a numeric ID."""
def test_a_login_is_asked_for_by_name(self):
with mock.patch.object(resolve.subprocess, "run") as run:
run.return_value = mock.Mock(returncode=0, stdout='{"id": 1, "login": "ludovic111", "type": "User"}')
found = resolve.user("ludovic111")
self.assertEqual(found["login"], "ludovic111")
self.assertIn("users/ludovic111", run.call_args.args[0])
def test_an_id_is_asked_for_by_number(self):
with mock.patch.object(resolve.subprocess, "run") as run:
run.return_value = mock.Mock(returncode=0, stdout='{"id": 1, "login": "ludovic111", "type": "User"}')
resolve.user("user/1")
self.assertIn("user/1", run.call_args.args[0])
def test_an_account_github_does_not_know_is_none(self):
with mock.patch.object(resolve.subprocess, "run") as run:
run.return_value = mock.Mock(returncode=1, stdout="")
self.assertIsNone(resolve.user("nobody"))
if __name__ == "__main__":
unittest.main()
+15 -5
View File
@@ -2,13 +2,18 @@
"""Dump Minecraft NBT files (level.dat, data/*.dat, players/*.dat) and Anvil
region chunks (region/, entities/, poi/) as readable SNBT-like text.
Usage:
anvil_dump.py FILE.dat [PATH...]
anvil_dump.py r.0.0.mca [CX CZ] [PATH...]
PATH selects nested keys (for example `sections 3 block_states`). Long
arrays are summarised unless --full is given; --typed suffixes numbers
with their NBT type (b, s, l, f, d; plain ints are TAG_Int). Used to compare Horizon saves
with worlds written by the vanilla server.
PATH selects nested keys (for example `sections 3 block_states`).
Options:
--full print long arrays whole instead of summarising them
--typed suffix numbers with their NBT type (b, s, l, f, d; plain ints are TAG_Int)
-h, --help print this help and exit
Used to compare Horizon saves with worlds written by the vanilla server.
"""
import gzip
import io
@@ -17,6 +22,8 @@ import struct
import sys
import zlib
USAGE = __doc__
def read_tag(f, tag_id):
if tag_id == 1:
@@ -119,11 +126,14 @@ def select(v, path):
def main(argv):
if '--help' in argv or '-h' in argv:
print(USAGE)
return 0
full = '--full' in argv
typed = '--typed' in argv
argv = [a for a in argv if a not in ('--full', '--typed')]
if not argv:
print(__doc__)
print(USAGE)
return 1
path = argv[0]
rest = argv[1:]
+42
View File
@@ -1910,6 +1910,29 @@ def aquatic_from_bucket():
return ops
def noai_tadpole_age():
"""`Tadpole.aiStep` counts the age up and grows the tadpole into a frog at
24000; `NoAI` skips only `serverAiStep`, so a NoAI tadpole ages too. An
age-locked one doesn't (#14615)."""
box = 'x=8,y=218,z=8,dx=10,dy=6,dz=4'
# (tag, x, saved Age, AgeLocked)
tadpoles = [('h14615_young', 10, 0, False), ('h14615_due', 13, 23998, False),
('h14615_locked', 16, 0, True)]
ops = []
for tag, x, age, locked in tadpoles:
lock = ',AgeLocked:1b' if locked else ''
ops += setup(f'/summon minecraft:tadpole {x}.5 220 10.5 {{Tags:["{tag}"],NoAI:1b,'
f'NoGravity:1b,Silent:1b,PersistenceRequired:1b,Age:{age}{lock}}}')
for ticks in (1, 2):
ops.append({'op': 'tick', 'n': ticks})
for tag, *_ in tadpoles:
target = f'@e[type=minecraft:tadpole,tag={tag},limit=1]'
ops += [cmd(f'/execute if entity {target}'), cmd(f'/data get entity {target} Age')]
ops.append(cmd(f'/execute if entity @e[type=minecraft:frog,{box}]'))
ops += setup(f'/kill @e[type=minecraft:tadpole,{box}]', f'/kill @e[type=minecraft:frog,{box}]')
return ops
def dispensed_spawn_egg_persistence():
"""`SpawnEggItemBehavior.execute` spawns with `EntitySpawnReason.DISPENSER`
and never calls `Mob.setPersistenceRequired`: the mob saves
@@ -7654,6 +7677,23 @@ def dispensed_item_escape():
f'/fill {x0} {y0 - 1} {z0} {x0 + 15} {y0} {z0 + 15} air', f'/forceload remove {x0} {z0}')
def comparator_setblock_output():
"""#13870: `BlockInput.place` loads a comparator's tag (`ComparatorBlockEntity.loadAdditional`:
`getIntOr("OutputSignal", 0)`), as `/data merge block` does."""
x0, y0, z0 = 4096 - 320, 260, 3584
ops = [cmd(f'/forceload add {x0} {z0}', False), {'op': 'wait_chunks', 'chunks': [[x0 >> 4, z0 >> 4]]},
cmd('/tick freeze', False),
cmd(f'/fill {x0} {y0 - 1} {z0} {x0 + 15} {y0 - 1} {z0 + 15} stone', False)]
for n, data in enumerate(('{OutputSignal:20}', '{OutputSignal:-1}', '{}', '{OutputSignal:7}'), 1):
x = x0 + 2 * n
ops += [cmd(f'/setblock {x} {y0} {z0 + 8} comparator{data}', expect_result=1),
cmd(f'/data get block {x} {y0} {z0 + 8} OutputSignal')]
# A second /setblock over the same comparator loads the tag again.
ops += [cmd(f'/setblock {x0 + 8} {y0} {z0 + 8} comparator{{OutputSignal:3}}'),
cmd(f'/data get block {x0 + 8} {y0} {z0 + 8} OutputSignal')]
return ops + setup('/tick unfreeze', f'/fill {x0} {y0 - 1} {z0} {x0 + 15} {y0} {z0 + 15} air',
f'/forceload remove {x0} {z0}')
def groups():
try:
from .spectral_duration_corpus import cases as spectral_saved_duration
@@ -7825,6 +7865,7 @@ def groups():
('axolotl integer memory TTL', axolotl_integer_memory_ttl()),
('parrot saved Age', parrot_saved_age()),
('animal NoAI love', animal_noai_love()),
('NoAI tadpole age', noai_tadpole_age()),
('turtle summon home', turtle_summon_home()),
('command minecart loaded success', command_minecart_loaded_success()),
('ravager NoAI counters', ravager_noai_counters()),
@@ -7850,6 +7891,7 @@ def groups():
('primed tnt fall', primed_tnt_fall()),
('dispensed item escape', dispensed_item_escape()),
('command block last output', command_block_last_output()),
('comparator setblock output', comparator_setblock_output()),
('glide fall accumulation', glide_fall_accumulation()),
('living short timers', living_short_timers()),
('projectile fall distance', projectile_fall_distance()),
+9 -3
View File
@@ -5,10 +5,12 @@ CommandSelection.ALL), which the tree sent to players keeps. Brigadier keeps
children in registration order; the data generator's commands report, which
the sent tree is built from, sorts them.
python3 tools/generate_lan_command_order.py
python3 tools/generate_lan_command_order.py [--out PATH]
"""
import argparse
import json
import os
from pathlib import Path
import subprocess
import sys
@@ -22,6 +24,9 @@ OUT = lab.ROOT / 'crates/horizon-client/src/lan/command_order.rs'
def main():
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument('--out', type=Path, default=OUT)
args = parser.parse_args()
lock, jars, libraries = base.verify_artifacts()
build = lab.LOCAL / 'lan-command-order'
classes = build / 'classes'
@@ -56,8 +61,9 @@ def main():
]
lines += [f' ("{p}", "{n}"),' for p, n in rows]
lines += ['];', '']
OUT.write_text('\n'.join(lines))
print(f'wrote {len(rows)} nodes to {OUT}')
args.out.write_text('\n'.join(lines))
print(f'wrote {len(rows)} nodes to {args.out}')
return 0
if __name__ == '__main__':
+13 -8
View File
@@ -28,6 +28,7 @@ since, counts as unreviewed. Players see no difference; the record is for review
python3 tools/lang_coverage.py --game-only
python3 tools/lang_coverage.py --summary # one line per file, for a pull request
python3 tools/lang_coverage.py --show-keys # the keys behind every count
python3 tools/lang_coverage.py --summary --show-keys # the summary, then the keys behind it
python3 tools/lang_coverage.py --json # the same, for a script
python3 tools/lang_coverage.py --fail-under 95 # exit 1 under that cover (for CI, later)
python3 tools/lang_coverage.py --unreviewed --lang fr_fr # what a French reviewer reads
@@ -355,7 +356,8 @@ def main(argv: list[str] | None = None) -> int:
parser.add_argument("--lang", nargs="+", metavar="CODE",
help="only these file stems (es_es and fr_fr for the game, es and fr for the launcher)")
parser.add_argument("--summary", action="store_true", help="one line per file, for a pull request")
parser.add_argument("--show-keys", action="store_true", help="list the keys behind every count")
parser.add_argument("--show-keys", action="store_true",
help="list the keys behind every count (--json already carries them)")
parser.add_argument("--json", action="store_true", help="machine output")
parser.add_argument("--fail-under", type=float, metavar="PERCENT",
help="exit 1 when a game language covers less than this")
@@ -444,13 +446,16 @@ def main(argv: list[str] | None = None) -> int:
if reference:
print()
print_launcher(args.launcher_dir, launcher)
if args.show_keys:
if reference:
print_game_keys(game)
for *_, rows in crates:
print_game_keys(rows)
if launcher:
print_launcher_keys(launcher)
if args.show_keys and not args.json:
# the keys behind the counts, after the lines that carry the counts; --json holds
# them already, and printing more would break what a script reads.
if reference:
print_game_keys(game)
for *_, rows in crates:
print_game_keys(rows)
if launcher:
print_launcher_keys(launcher)
if args.fail_under is not None:
every = game + [row for *_, rows in crates for row in rows]
+3
View File
@@ -47,6 +47,9 @@ def mob_absorption_max(raw=False):
('data_soak_effect', None, [f'/effect give {T} minecraft:absorption 100 0',
f'/data merge entity {T} {{AbsorptionAmount:10.0f}}',
f'/damage {T} 1 minecraft:generic']),
# #11384: Absorption II for a second over Absorption I. When it runs out the hidden I is promoted
# (onEffectUpdated -> refreshDirtyAttributes), and the amount of 8 is clamped to I's maximum of 4.
('promote', None, [f'/effect give {T} minecraft:absorption 100 0', f'/effect give {T} minecraft:absorption 1 1']),
]
tags = []
for i, (name, base, setup) in enumerate(cases):
+1 -1
View File
@@ -127,7 +127,7 @@ AbstractSliderButton.getSprite port crates/horizon-client-render/src/ui.rs::slid
AbstractSliderButton.getHandleSprite port crates/horizon-client-render/src/ui.rs::slider_sprites_ext lit while hovered or while its value can change (Tab, Enter, a mouse drag)
AbstractSliderButton.updateWidgetNarration skip:other narration only
AbstractSliderButton.extractWidgetRenderState port crates/horizon-client-render/src/ui.rs::slider_ext the whole message, value included, scrolls inside the slider (margin 2)
AbstractSliderButton.handleCursor port crates/horizon-client-render/src/widgets.rs::widget_cursor gap: minor: no resize cursor while dragging
AbstractSliderButton.handleCursor port crates/horizon-client-render/src/widgets.rs::slider_cursor a hovered slider points, a dragged one resizes horizontally, an inactive one is not allowed
AbstractSliderButton.setFocused port crates/horizon-client/src/game/key_focus.rs::slider_edit_on_focus the arrows change the value only when the mouse or Tab focused the slider, not when an arrow did
AbstractSliderButton.keyPressed port crates/horizon-client/src/game/key_focus.rs::act left and right nudge a focused slider once Tab or Enter let them (Act::Nudge), Enter toggles it (Act::ToggleSlider)
AbstractSliderButton.setValue port crates/horizon-client-render/src/options_ui.rs::set_slider gap: minor: the 0..1 clamp is done by the caller (ui.rs slider_value)
1 ActiveTextCollector.accept inline crates/horizon-client-render/src/screen_draw.rs::text default overload: left-aligned text at x,y with default parameters
127 AbstractSliderButton.getHandleSprite port crates/horizon-client-render/src/ui.rs::slider_sprites_ext lit while hovered or while its value can change (Tab, Enter, a mouse drag)
128 AbstractSliderButton.updateWidgetNarration skip:other narration only
129 AbstractSliderButton.extractWidgetRenderState port crates/horizon-client-render/src/ui.rs::slider_ext the whole message, value included, scrolls inside the slider (margin 2)
130 AbstractSliderButton.handleCursor port crates/horizon-client-render/src/widgets.rs::widget_cursor crates/horizon-client-render/src/widgets.rs::slider_cursor gap: minor: no resize cursor while dragging a hovered slider points, a dragged one resizes horizontally, an inactive one is not allowed
131 AbstractSliderButton.setFocused port crates/horizon-client/src/game/key_focus.rs::slider_edit_on_focus the arrows change the value only when the mouse or Tab focused the slider, not when an arrow did
132 AbstractSliderButton.keyPressed port crates/horizon-client/src/game/key_focus.rs::act left and right nudge a focused slider once Tab or Enter let them (Act::Nudge), Enter toggles it (Act::ToggleSlider)
133 AbstractSliderButton.setValue port crates/horizon-client-render/src/options_ui.rs::set_slider gap: minor: the 0..1 clamp is done by the caller (ui.rs slider_value)
+2
View File
@@ -41,6 +41,8 @@ DeathScreen.tick inline crates/horizon-client/src/game/screens.rs::Game::draw_de
DeathScreen.setButtonsActive inline crates/horizon-client/src/game/screens.rs::Game::draw_death_screen buttons are active once the 1000 ms delay has passed
DirectJoinServerScreen.keyPressed port crates/horizon-client/src/multiplayer.rs::Screen::confirm Enter joins the typed address when it parses (direct_address, friend invite codes included); on the server list it joins the selected server or LAN game
DirectJoinServerScreen.init port crates/horizon-client/src/multiplayer.rs::Screen::buttons Direct Connection field with Join and Cancel (Screen::Direct arm)
DirectJoinServerScreen.updateSelectButtonStatus port crates/horizon-client/src/multiplayer.rs::Screen::buttons the Join Server button is active only while servers::parse_address accepts the typed address (Screen::Direct)
DirectJoinServerScreen.setInitialFocus skip:ui the address field is drawn focused (focused_at); it is the Direct screen's only text field
DirectJoinServerScreen.resize skip:ui layout only; Screen::Direct keeps the typed address
DirectJoinServerScreen.onSelect port crates/horizon-client/src/multiplayer.rs::Screen::click Join returns the typed address as Outcome::Connect
DirectJoinServerScreen.removed port crates/horizon-client/src/title.rs::Title::remember_direct_address saves the address to last_server; gap: minor: the options file write is not in this fn
Can't render this file because it contains an unexpected character in line 28 and column 160.
+2 -1
View File
@@ -65,7 +65,8 @@ InWorldGameRulesScreen.onGamemasterPermissionChanged port crates/horizon-client/
LanguageSelectScreen.addTitle inline crates/horizon-client-render/src/options_ui.rs::OptionsUi::layout language search box at the top of the Language page
LanguageSelectScreen.setInitialFocus skip:ui focus only: typed text goes to the language search whatever has focus (type_text)
LanguageSelectScreen.addContents port crates/horizon-client-render/src/options_ui.rs::OptionsUi::layout language rows of the Language page
LanguageSelectScreen.addFooter port crates/horizon-client-render/src/options_ui.rs::OptionsUi::layout gap: minor: unsure whether the translation warning line is drawn; Font... and Done buttons
LanguageSelectScreen.addFooter port crates/horizon-client-render/src/options_ui.rs::OptionsUi::layout the options.languageAccuracyWarning line above the Font... and Done buttons
LanguageSelectScreen.LanguageSelectionList.Entry.getNarration skip:other narration text for screen readers
LanguageSelectScreen.repositionElements skip:ui layout only: rows are laid out from the window size each frame
LanguageSelectScreen.onDone port crates/horizon-client-render/src/options_ui.rs::OptionsUi::back Done applies the chosen language (language_choice)
LanguageSelectScreen.LanguageSelectionList.filterEntries port crates/horizon-client-render/src/options_ui.rs::OptionsUi::layout filter by the search text against name and region (Page::Language)
1 ClientBundleTooltip.extractEmptyBundleTooltip port crates/horizon-client/src/game/stations.rs::Game::draw_bundle_tooltip empty branch: description, bar and label drawn from the layout
65 LanguageSelectScreen.addTitle inline crates/horizon-client-render/src/options_ui.rs::OptionsUi::layout language search box at the top of the Language page
66 LanguageSelectScreen.setInitialFocus skip:ui focus only: typed text goes to the language search whatever has focus (type_text)
67 LanguageSelectScreen.addContents port crates/horizon-client-render/src/options_ui.rs::OptionsUi::layout language rows of the Language page
68 LanguageSelectScreen.addFooter port crates/horizon-client-render/src/options_ui.rs::OptionsUi::layout gap: minor: unsure whether the translation warning line is drawn; Font... and Done buttons the options.languageAccuracyWarning line above the Font... and Done buttons
69 LanguageSelectScreen.LanguageSelectionList.Entry.getNarration skip:other narration text for screen readers
70 LanguageSelectScreen.repositionElements skip:ui layout only: rows are laid out from the window size each frame
71 LanguageSelectScreen.onDone port crates/horizon-client-render/src/options_ui.rs::OptionsUi::back Done applies the chosen language (language_choice)
72 LanguageSelectScreen.LanguageSelectionList.filterEntries port crates/horizon-client-render/src/options_ui.rs::OptionsUi::layout filter by the search text against name and region (Page::Language)
+1
View File
@@ -148,4 +148,5 @@ TropicalFish.applyImplicitComponents port crates/horizon-client/src/entity_nbt.r
TropicalFish.finalizeSpawn port crates/horizon-client/src/mobs/finalize.rs::Ctx::finalize one in ten is a lone random fish, else a shared common variant
TropicalFish.checkTropicalFishSpawnRules port crates/horizon-client/src/mobs/spawn_rules.rs::check_spawn_rules water below and above, and any height biomes or the surface rule
TropicalFish.Pattern.addToTooltip port crates/horizon-client/src/item_tooltip.rs::PlayerHeadName::tropical_fish_lines ticked in #3512; `TropicalFish.Pattern.addToTooltip` is cited at crates/horizon-client/src/item_tooltip.rs:362
TraderLlama.TraderLlamaDefendWanderingTraderGoal.canUse port crates/horizon-client/src/mobs/caravan.rs::DefendTrader::can_use spectator attackers are refused by TargetingConditions.DEFAULT
OwnerHurtByTargetGoal.canUse port crates/horizon-client/src/mobs/tame.rs::wants_to_attack dead attackers are refused by canBeSeenByAnyone
Can't render this file because it contains an unexpected character in line 131 and column 104.
+1
View File
@@ -156,4 +156,5 @@ Evoker.EvokerAttackSpellGoal.createSpellEntity port crates/horizon-client/src/mo
SulfurCube.doPush inline crates/horizon-client/src/mobs/sulfur_cube.rs::mob_contacts mob contact stage only; ordinary AI push impulse remains in ai::push_entities
Slime.canBeABaby port crates/horizon-client/src/mobs/species.rs::can_be_a_baby the false arm: an AbstractCubeMob that is ageable but never a baby
MagmaCube.canBeABaby port crates/horizon-client/src/mobs/species.rs::can_be_a_baby the false arm: same as the slime
Vex.VexCopyOwnerTargetGoal.canUse port crates/horizon-client/src/mobs/vex.rs::CopyOwnerTarget::can_use owner target must be alive and not a spectator
SulfurCube.SulfurCubeTemptGoal.canUse port crates/horizon-client/src/mobs/sulfur_cube.rs::SulfurTempt::can_use range scaled by visibility, floor 2
1 Strider.checkStriderSpawnRules port crates/horizon-client/src/mobs/spawn_rules.rs::check_spawn_rules strider arm: climbs lava upward, then needs air above
156 SulfurCube.doPush inline crates/horizon-client/src/mobs/sulfur_cube.rs::mob_contacts mob contact stage only; ordinary AI push impulse remains in ai::push_entities
157 Slime.canBeABaby port crates/horizon-client/src/mobs/species.rs::can_be_a_baby the false arm: an AbstractCubeMob that is ageable but never a baby
158 MagmaCube.canBeABaby port crates/horizon-client/src/mobs/species.rs::can_be_a_baby the false arm: same as the slime
159 Vex.VexCopyOwnerTargetGoal.canUse port crates/horizon-client/src/mobs/vex.rs::CopyOwnerTarget::can_use owner target must be alive and not a spectator
160 SulfurCube.SulfurCubeTemptGoal.canUse port crates/horizon-client/src/mobs/sulfur_cube.rs::SulfurTempt::can_use range scaled by visibility, floor 2
+3 -3
View File
@@ -38,7 +38,7 @@ Piglin.customServerAiStep port crates/horizon-client/src/mobs/piglin.rs::ai_step
Piglin.finishConversion port crates/horizon-client/src/mobs/piglin.rs::ai_step the conversion cancels the admire and drops the admired item; the rest of a piglin's carried inventory is still not scattered
Piglin.hurtServer inline crates/horizon-client/src/mobs/piglin.rs::celebrate gap: player: a hit by a player does not stop an admiring piglin or start its admiring-disabled wait; babies do not flee the attacker
Piglin.canUseNonMeleeWeapon port crates/horizon-client/src/game/piglins.rs::Game::piglin_wanted gap: minor: checks the preferred-weapons tag only, not the usable-crossbow tag or the kinetic weapon component
Piglin.holdInOffHand port crates/horizon-client/src/game/piglins.rs::Game::start_admiring gap: minor: does not drop an item already in the off hand and does not set persistence
Piglin.holdInOffHand port crates/horizon-client/src/mobs/piglin.rs::hold_in_offhand equips the item (drop chance 2.0 as setItemSlotAndDropWhenKilled) and makes the piglin persistent unless it is the barter item
Piglin.wantsToPickUp port crates/horizon-client/src/game/piglins.rs::Game::piglin_wants gap: minor: does not check the admiring-disabled wait after a hit by a player
Piglin.startRiding port crates/horizon-client/src/mobs/riders.rs::start_riding the boarding tests (not the vehicle it is on, not one it carries, the boarding cooldown, a free vehicle) plus the `Piglin` case: a baby piglin on a hoglin goes on top of the up-to-three piglins already there
Piglin.getTopPassenger port crates/horizon-client/src/mobs/riders.rs::top_passenger the top of the passenger chain, capped at four passengers on a hoglin
@@ -56,7 +56,7 @@ PiglinAi.createIdleMovementBehaviors inline crates/horizon-client/src/mobs/pigli
PiglinAi.updateActivity inline crates/horizon-client/src/mobs/piglin.rs::register_piglin gap: minor: the activity choice is the goal priority order; the aggressive and dancing flags are set elsewhere
PiglinAi.isBabyRidingBaby port crates/horizon-client/src/mobs/piglin.rs::is_baby_riding_baby a baby piglin on a baby piglin or a baby hoglin
PiglinAi.pickUpItem port crates/horizon-client/src/game/piglins.rs::Game::piglin_pickups loved item admired, food eaten, else worn or stored
PiglinAi.holdInOffhand port crates/horizon-client/src/game/piglins.rs::Game::start_admiring gap: minor: does not drop an item already in the off hand and does not set persistence
PiglinAi.holdInOffhand port crates/horizon-client/src/game/piglins.rs::Game::start_admiring drops what the off hand held, whole, then holds the item through hold_in_offhand (Piglin.holdInOffHand), which sets persistence
PiglinAi.removeOneItemFromItemEntity port crates/horizon-client/src/game/piglins.rs::Game::piglin_pickups takes one item from the stack (nuggets whole)
PiglinAi.cancelAdmiring port crates/horizon-client/src/mobs/piglin.rs::stop_admiring the off hand is emptied and admiring is disabled for a while
PiglinAi.throwItems port crates/horizon-client/src/game/piglins.rs::Game::finish_admiring gap: minor: only the barter path throws; the target choice is nearest player within 16, else a spot nearby
@@ -137,7 +137,7 @@ Drowned.checkDrownedSpawnRules port crates/horizon-client/src/mobs/spawn_rules.r
Drowned.isDeepEnoughToSpawn inline crates/horizon-client/src/mobs/spawn_rules.rs::check_spawn_rules below sea level minus 5
Drowned.populateDefaultEquipmentSlots port crates/horizon-client/src/mobs/finalize.rs::Ctx::populate 10 percent chance of a trident (10 in 16) or fishing rod
Drowned.canReplaceCurrentItem port crates/horizon-client/src/game/mob_looting.rs::Game::can_replace the mob pickup compare (armour, toughness, attack damage) with the drowned's nautilus shell guard; called before a mob swaps what it holds
Drowned.rangedAttackUncertainty port crates/horizon-client/src/mobs/undead.rs::drowned_throw gap: minor: fixed uncertainty 6 (normal difficulty), not 14 minus 4 times the difficulty
Drowned.rangedAttackUncertainty port crates/horizon-client/src/mobs/undead.rs::drowned_throw the field defaults to -1.0f, so the throw's uncertainty is 14 minus 4 times the difficulty id (ranged_attack_uncertainty)
Drowned.rideTick port crates/horizon-client/src/mobs/riders.rs::takes_vehicle_body the body yaw of a PathfinderMob it steers (getControlledVehicle)
Drowned.wantsToPickUp port crates/horizon-client/src/mobs/equip.rs::wants_to_pick_up spears are refused, then the zombie checks
Drowned.DrownedGoToBeachGoal.isValidTarget port crates/horizon-client/src/mobs/undead.rs::beach_target two empty blocks above and a standable block below
1 Evoker.EvokerWololoSpellGoal.canContinueToUse port crates/horizon-client/src/mobs/illager.rs::UseSpell::can_continue wololo branch: sheep target still set and warm-up left
38 Piglin.finishConversion port crates/horizon-client/src/mobs/piglin.rs::ai_step the conversion cancels the admire and drops the admired item; the rest of a piglin's carried inventory is still not scattered
39 Piglin.hurtServer inline crates/horizon-client/src/mobs/piglin.rs::celebrate gap: player: a hit by a player does not stop an admiring piglin or start its admiring-disabled wait; babies do not flee the attacker
40 Piglin.canUseNonMeleeWeapon port crates/horizon-client/src/game/piglins.rs::Game::piglin_wanted gap: minor: checks the preferred-weapons tag only, not the usable-crossbow tag or the kinetic weapon component
41 Piglin.holdInOffHand port crates/horizon-client/src/game/piglins.rs::Game::start_admiring crates/horizon-client/src/mobs/piglin.rs::hold_in_offhand gap: minor: does not drop an item already in the off hand and does not set persistence equips the item (drop chance 2.0 as setItemSlotAndDropWhenKilled) and makes the piglin persistent unless it is the barter item
42 Piglin.wantsToPickUp port crates/horizon-client/src/game/piglins.rs::Game::piglin_wants gap: minor: does not check the admiring-disabled wait after a hit by a player
43 Piglin.startRiding port crates/horizon-client/src/mobs/riders.rs::start_riding the boarding tests (not the vehicle it is on, not one it carries, the boarding cooldown, a free vehicle) plus the `Piglin` case: a baby piglin on a hoglin goes on top of the up-to-three piglins already there
44 Piglin.getTopPassenger port crates/horizon-client/src/mobs/riders.rs::top_passenger the top of the passenger chain, capped at four passengers on a hoglin
56 PiglinAi.updateActivity inline crates/horizon-client/src/mobs/piglin.rs::register_piglin gap: minor: the activity choice is the goal priority order; the aggressive and dancing flags are set elsewhere
57 PiglinAi.isBabyRidingBaby port crates/horizon-client/src/mobs/piglin.rs::is_baby_riding_baby a baby piglin on a baby piglin or a baby hoglin
58 PiglinAi.pickUpItem port crates/horizon-client/src/game/piglins.rs::Game::piglin_pickups loved item admired, food eaten, else worn or stored
59 PiglinAi.holdInOffhand port crates/horizon-client/src/game/piglins.rs::Game::start_admiring gap: minor: does not drop an item already in the off hand and does not set persistence drops what the off hand held, whole, then holds the item through hold_in_offhand (Piglin.holdInOffHand), which sets persistence
60 PiglinAi.removeOneItemFromItemEntity port crates/horizon-client/src/game/piglins.rs::Game::piglin_pickups takes one item from the stack (nuggets whole)
61 PiglinAi.cancelAdmiring port crates/horizon-client/src/mobs/piglin.rs::stop_admiring the off hand is emptied and admiring is disabled for a while
62 PiglinAi.throwItems port crates/horizon-client/src/game/piglins.rs::Game::finish_admiring gap: minor: only the barter path throws; the target choice is nearest player within 16, else a spot nearby
137 Drowned.isDeepEnoughToSpawn inline crates/horizon-client/src/mobs/spawn_rules.rs::check_spawn_rules below sea level minus 5
138 Drowned.populateDefaultEquipmentSlots port crates/horizon-client/src/mobs/finalize.rs::Ctx::populate 10 percent chance of a trident (10 in 16) or fishing rod
139 Drowned.canReplaceCurrentItem port crates/horizon-client/src/game/mob_looting.rs::Game::can_replace the mob pickup compare (armour, toughness, attack damage) with the drowned's nautilus shell guard; called before a mob swaps what it holds
140 Drowned.rangedAttackUncertainty port crates/horizon-client/src/mobs/undead.rs::drowned_throw gap: minor: fixed uncertainty 6 (normal difficulty), not 14 minus 4 times the difficulty the field defaults to -1.0f, so the throw's uncertainty is 14 minus 4 times the difficulty id (ranged_attack_uncertainty)
141 Drowned.rideTick port crates/horizon-client/src/mobs/riders.rs::takes_vehicle_body the body yaw of a PathfinderMob it steers (getControlledVehicle)
142 Drowned.wantsToPickUp port crates/horizon-client/src/mobs/equip.rs::wants_to_pick_up spears are refused, then the zombie checks
143 Drowned.DrownedGoToBeachGoal.isValidTarget port crates/horizon-client/src/mobs/undead.rs::beach_target two empty blocks above and a standable block below
Loaded 100 of 104 files, more files were not shown because too many files have changed in this diff. Show more