Compare commits

...
Author SHA1 Message Date
Kacper 089a7c1242 Merge pull request #10259 from Ragbie/fix/port-pack-old-layout
Port pack: a mod made for 1.20 is not carried or counted as come over
2026-11-06 09:20:28 +01:00
Kacper 6cfb512e1e Merge pull request #10311 from rison1337/perf/cull-offscreen-blob-shadows
Skip block scans for off-screen entity shadows
2026-11-06 09:20:12 +01:00
Kacper 6f6042da38 Merge pull request #10474 from martipops/fix/pad-hid-layout
Controllers: read Bluetooth Xbox pads from their layout, on any firmware
2026-11-06 09:20:06 +01:00
Kacper 345a66c710 Merge pull request #10014 from Hiuoy/feat/client-logging-hooks
Native hooks: ClientPlayerNetworkEvent.LoggingIn / .LoggingOut (client_player_network_logging_in, client_disconnecting's label)
2026-11-06 09:19:55 +01:00
Kacper 2cc12be695 Merge pull request #10295 from foudretbr/fix/launcher-native-cache-busy
launcher: retry native cache fixture exec on ETXTBSY and copy via temp rename
2026-11-06 09:19:49 +01:00
Kacper 8126b95fb6 Merge pull request #10264 from ilguerro/perf/9996-aquifer
Aquifers keep their 12 nearby cells unpacked and reuse 8 of them a cell down
2026-11-06 09:19:43 +01:00
Kacper a36ce4ef76 Merge pull request #10495 from voidsipp/fix/boat-second-passenger-authority
Use the first passenger for remote boat control
2026-11-06 09:19:36 +01:00
Kacper d53d7bf02a Merge pull request #10087 from yahya-nusseibeh/fix/port-pack-capped-reads
port-pack: cap every jar and pack entry it inflates (launcher OOM from a mod search)
2026-11-06 09:19:27 +01:00
Kacper e42f12db85 Merge pull request #10232 from KBroneZ/claude/fix-10162-pack-templates
Read structure templates from data packs
2026-11-06 09:19:18 +01:00
Kacper 988f7d1ade Merge pull request #10362 from CocofireHD/fix/10273-reoccuring-freezes-on-diamondfire-server-mcd
Multiplayer: a guest server's resent columns keep the packet's light
2026-11-06 09:19:12 +01:00
Kacper 8e3cade985 Merge pull request #10307 from Skr1zzzz/fix-9190
Friends: Cancel and Escape go back to the screen that opened it
2026-11-06 09:19:07 +01:00
Kacper 8ca4e1d985 Merge pull request #9975 from Hiuoy/feat/client-tick-hooks
Native hooks: ClientTickEvent.Pre / .Post (client_tick_pre, client_tick_post)
2026-11-06 09:19:01 +01:00
Kacper b5c12d0f76 Merge pull request #10185 from degepie/fix/pack-lang-lenient-merge
Resource packs: read a lang file leniently when merging the layers
2026-11-06 09:18:54 +01:00
Kacper 2251cfb716 Merge pull request #10209 from ilguerro/perf/9997-climate-search
Climate searches pass distances along and stop a box's sum once it is too far
2026-11-06 09:18:49 +01:00
Kacper e5b2d2ea71 Merge pull request #10320 from TheMrZZ/fix/squid-damage-result
Preserve inherited squid damage acceptance result
2026-11-06 09:18:42 +01:00
Kacper c1a7b0d35e Merge pull request #10484 from voidsipp/fix/10445-leashed-happy-ghast
Match Happy Ghast leash movement and distances
2026-11-06 09:18:30 +01:00
Kacper b502fe41ac Merge pull request #9973 from MSedgeMC/fix/trample-hook
Landing: a trample is cancellable, so a claims mod can stop it
2026-11-06 09:18:24 +01:00
Kacper a8c5f833e4 Merge pull request #9924 from Hiuoy/feat/9921-hook-neoforge-labels
Native hook catalog: NeoForge labels for 39 hooks
2026-11-06 09:18:18 +01:00
Kacper 8ca4e05005 Merge pull request #10333 from PzmuV1517/shaders/compat-2-constant-array-crash
Shader packs: a global constant reads an element of a constant array (Ebin no longer crashes)
2026-11-06 09:18:08 +01:00
Kacper adcc59b920 Merge pull request #10332 from PzmuV1517/shaders/compat-1-real-packs-summary
Shader packs: real_packs keeps going past a panicking pack and sums up each pack
2026-11-06 09:18:02 +01:00
Kacper f42833e146 Merge pull request #10489 from TheMrZZ/fix/noai-dolphin-air-reset
Restore NoAI Dolphin air after its superclass tick
2026-11-06 09:17:55 +01:00
Kacper 1bd640668a Merge pull request #10304 from MSedgeMC/fix/uuid-bridge
Entity saves: a UUID bridge for references to a mob
2026-11-06 09:17:49 +01:00
Kacper cc5f7ff239 Merge pull request #10235 from MSedgeMC/fix/armor-stand-light-probe
Armour stands: probe the marker's own box and every other stand's eye, at 15 while burning
2026-11-06 09:17:38 +01:00
Kacper f7a5a3809f Merge pull request #10200 from Hiuoy/feat/break-speed
Native hooks: PlayerEvent.BreakSpeed, how fast a player digs a block
2026-11-06 09:17:32 +01:00
Kacper 1faeccc439 Merge pull request #10293 from IanWorley/fix/tests-on-apple-silicon
Tests: the drop-motion and install.sh signature tests pass on Apple silicon
2026-11-06 09:17:20 +01:00
Kacper 57512281ba Merge pull request #10266 from TheMrZZ/fix/ominous-warning-sound-source
Use neutral source for ominous item warning
2026-11-06 09:17:10 +01:00
Kacper ebafaf775a Merge pull request #10478 from TheMrZZ/fix/spellcaster-spell-ticks
Bind Evoker and Illusioner NBT to their live spell counter
2026-11-06 09:17:04 +01:00
Kacper 2f890f7df9 Merge pull request #10524 from TheMrZZ/fix/mob-local-sounds
Keep local mob sounds out of shared server capture
2026-11-06 09:16:57 +01:00
Kacper 62a7855892 Merge pull request #10499 from arslanarm/shaderpacks/lush-caves-biome-category
Shader packs: biome_category matches Iris's readings, fitted to vanilla's biome tags
2026-11-06 09:16:46 +01:00
Kacper cc3714e6ad Merge pull request #10358 from CocofireHD/fix/8713-no-zoom-menu-in-extras-on-mac
Extras: a Zoom row that reaches the mod that replaced the extra
2026-11-06 09:16:40 +01:00
Kacper d08bf63777 Merge pull request #10127 from CocofireHD/fix/9273-stuck-button-highlight
UI: a button's highlight goes when its screen leaves
2026-11-06 09:16:34 +01:00
Kacper 92dbf8406e Merge pull request #10134 from Hiuoy/feat/9921-more-labels
Native hook catalog: NeoForge labels for five more hooks
2026-11-06 09:16:28 +01:00
Kacper 77ac54a216 Merge pull request #10000 from TheMrZZ/fix/raid-live-village-pois
Validate loaded bed claims for raid villages
2026-11-06 09:16:21 +01:00
Kacper 8815f97746 Merge pull request #10141 from ilguerro/perf/9996-perlin
Perlin volumes skip the Ys a lerp does not read, and batch Ys across cells
2026-11-06 09:16:15 +01:00
Kacper 7e74759055 Merge pull request #10309 from Skr1zzzz/fix-9570
Shader packs: material maps share the stitched colour atlas's layout
2026-11-06 09:16:08 +01:00
Kacper 6c0d2781e6 Merge pull request #10297 from CocofireHD/fix/10037-directional-audio-bug-3d-positional-sound-an
Audio: play multi-channel buffers direct like OpenAL
2026-11-06 09:15:57 +01:00
Kacper 45a678e309 Merge pull request #10221 from MSedgeMC/fix/item-light-probe
Dropped items: light the block at the item's eye, and 15 for one on fire
2026-11-06 09:15:51 +01:00
Kacper efaa2a0e3f Merge pull request #10238 from MSedgeMC/fix/entity-light-probe-frame
Mobs and projectiles: light the block at the eye of the frame, not of the tick
2026-11-06 09:15:46 +01:00
Kacper ed5ac7df1f Merge pull request #10483 from voidsipp/fix/10468-finite-native-options
Keep native shader float options finite
2026-11-06 09:15:39 +01:00
Kacper 3e92f5e6fd Merge pull request #10076 from yahya-nusseibeh/fix/launcher-rustls
Launcher: rustls 0.23.46 for four RustSec advisories
2026-11-06 09:15:33 +01:00
Kacper a0e3c383a2 Merge pull request #10012 from some-du6e/optifine/color-properties
OptiFine custom colours: read optifine/color.properties
2026-11-06 09:15:26 +01:00
Kacper 1c9379c29e Merge pull request #10247 from TheMrZZ/fix/noai-glow-squid-countdown
Keep glow squid dark countdown running with NoAI
2026-11-06 09:15:20 +01:00
Kacper 46e88c47b1 Merge pull request #10075 from Ragbie/fix/nautilus-sight-armadillo-panic
A nautilus only goes for a pufferfish it sees, and frost panics armadillos
2026-11-06 09:15:15 +01:00
Kacper 8671d460bf Merge pull request #10480 from voidsipp/fix/snbt-unicode-key-order
Keep UTF-16 key order in compound SNBT
2026-11-06 09:15:09 +01:00
Kacper 9f282e42ea Merge pull request #9992 from UMCEKO/fix/tnt-owner-saved
Primed TNT saves and loads its owner
2026-11-06 09:15:03 +01:00
Kacper 7b0e4fee81 Merge pull request #10310 from xxaxio/fix/launcher-account-skin-overflow
launcher: fix account skin arm-type segmented control overflow and caption layout
2026-11-06 09:14:57 +01:00
Kacper c76e42d88d Merge pull request #10066 from Hiuoy/feat/render-level-stage
Native hook catalog: RenderLevelStageEvent labels for the world drawing hooks
2026-11-06 09:14:51 +01:00
Kacper fe8b0c622f Merge pull request #10328 from rison1337/perf/cache-name-tag-layout
Cache unchanged entity name-tag glyph layouts
2026-11-06 09:14:45 +01:00
Kacper c8137fbec9 Merge pull request #10302 from foudretbr/feat/tooltip-render-surface
Native mod API: item tooltip render surface and container item drawing
2026-11-06 09:14:33 +01:00
Kacper a4d6ba2781 Merge pull request #10062 from MSedgeMC/feat/scissor-intersect
A nested GUI scissor clips to the intersection and restores the outer one
2026-11-06 09:14:27 +01:00
Kacper 84cc6b5d64 Merge pull request #10089 from yahya-nusseibeh/fix/telemetry-store-off-workers
Telemetry server: hub, intake and export off the async workers too
2026-11-06 09:14:21 +01:00
Kacper 5258de5549 Merge pull request #10038 from BnCatt/older-servers/1.21.11-either-holders
Older servers: read 1.21.11's EitherHolder item components
2026-11-06 09:14:15 +01:00
Kacper c939630a79 Merge pull request #10135 from TaquitoSuabe/perf/gl-occlusion-culling
gfx: enable early depth occlusion culling on OpenGL
2026-11-06 09:14:09 +01:00
Kacper 932d351e11 Merge pull request #10306 from TheMrZZ/fix/lodestone-tracker-default
Omit the saved lodestone tracker true default
2026-11-06 09:11:39 +01:00
Kacper 8504efabb8 Merge pull request #10001 from Ragbie/fix/enderman-teleport-danger
Endermen teleport only where vanilla lets them
2026-11-06 09:11:31 +01:00
Kacper 63db515796 Merge pull request #10502 from arslanarm/shaderpacks/dimension-folder-gbuffers
Shader packs: terrain without a program in the pack is drawn by nothing
2026-11-06 09:11:25 +01:00
Kacper d629a4f6f6 Merge pull request #10497 from arslanarm/shaderpacks/terrain-block-id-mid
Shader packs: terrain's mc_Entity.y reads 0 off fluids, as in Iris
2026-11-06 09:11:18 +01:00
Kacper 77f2a09212 Merge pull request #10341 from Skr1zzzz/prepare-spans
Spans: the frame's prepare steps
2026-11-06 09:11:12 +01:00
Kacper aa1a7c0c42 Merge pull request #10156 from Hiuoy/feat/10132-living-damage
Native hooks: LivingDamageEvent.Pre and .Post, the damage a living entity's health takes
2026-11-06 09:11:06 +01:00
Kacper 36e6c2dc3f Merge pull request #10231 from TheMrZZ/fix/selector-empty-tags
Correct empty tag selector predicates
2026-11-06 09:10:58 +01:00
Kacper aca06a277b Merge pull request #10282 from TheMrZZ/fix/item-frame-missing-facing
Default loaded item frames to DOWN when Facing is absent
2026-11-06 09:10:52 +01:00
Kacper fedc1c7313 Merge pull request #10175 from vnsmoreira/fix-translucent-color-feedback
Fix shader-pack color-buffer reads in translucent draws
2026-11-06 09:10:41 +01:00
Kacper df6139520e Merge pull request #10034 from Ragbie/fix/mob-spear-reach
A mob's melee reach follows the spear it holds
2026-11-06 09:10:35 +01:00
Kacper 1a57f3660d Merge pull request #10070 from yahya-nusseibeh/fix/launcher-no-remove-var
Launcher: open HORIZON_LAUNCHER_PROJECT once without remove_var
2026-11-06 09:10:29 +01:00
Kacper 1e030fd397 Merge pull request #10348 from TheMrZZ/fix/guest-respawn-world-point
Keep guest respawn world information separate from personal landing
2026-11-06 09:10:23 +01:00
Kacper 4c1c81539f Merge pull request #10360 from TheMrZZ/fix/ageable-integer-load
Load animal age and love with NumericTag integer conversion
2026-11-06 09:10:17 +01:00
Kacper bf6bb5d470 Merge pull request #10343 from rison1337/perf/cull-offscreen-entity-flames
Skip offscreen burning-entity flame geometry
2026-11-06 09:10:11 +01:00
Kacper f4cc8528ae Merge pull request #10508 from Skr1zzzz/head-skin-cache
Player heads: parse a profile once, not on every draw
2026-11-06 09:10:04 +01:00
Kacper c12ad4d2cf Merge pull request #10207 from MSedgeMC/fix/pack-update-keeps-mod-choice
launcher: a pack update keeps the player's mod choices
2026-11-06 09:09:52 +01:00
Kacper b8dccdaa55 Merge pull request #10376 from TheMrZZ/fix/hive-default-saved-patch
Omit default hive block state from saved item patches
2026-11-06 09:09:45 +01:00
Kacper 50ac3abcda Merge pull request #10202 from TheMrZZ/fix/furnace-output-components
Furnaces: require output component identity before cooking
2026-11-06 09:09:38 +01:00
Kacper f4eb3234a8 Merge pull request #10496 from voidsipp/fix/10465-server-address-ports
Match vanilla server-address port syntax
2026-11-06 09:09:30 +01:00
Kacper a02fdabe6f Merge pull request #10061 from MSedgeMC/fix/region-file-compression
Region files are written with the compression server.properties names
2026-11-06 09:00:51 +01:00
Kacper d16b78e715 Merge pull request #10022 from DUzzL/fix/9964-burning-powder-snow
Burning players and mobs destroy powder snow they stand in
2026-11-06 09:00:43 +01:00
Kacper e5cc020034 Merge pull request #10272 from MSedgeMC/fix/item-interact-start
Starting an item use posts item_interact_start
2026-11-06 09:00:36 +01:00
Kacper 799fcccba4 Merge pull request #10279 from MSedgeMC/fix/jukebox-play-vibration
A playing jukebox posts jukebox_play every twenty ticks
2026-11-06 09:00:28 +01:00
Kacper 54feca80d1 Merge pull request #10246 from MSedgeMC/fix/disable-mob-generation
The End's no-creatures rule comes from the data, not the dimension
2026-11-06 09:00:22 +01:00
Kacper c1f8fee513 Merge pull request #10226 from MSedgeMC/fix/creative-search-focus
The creative search box loses the focus with its tab
2026-11-06 09:00:15 +01:00
Kacper 8ca476e489 Merge pull request #10172 from MSedgeMC/feat/creative-chat-key
The chat key opens the creative inventory's search tab
2026-11-06 09:00:07 +01:00
Kacper efabb913d0 Merge pull request #10098 from MSedgeMC/fix/f1-tab-list
F1 takes the player list away
2026-11-06 08:59:58 +01:00
Kacper bed239a2cd Merge pull request #10154 from Vaspyyy/fix/stonecutter-take-sound-once
Play a station's take sound once per shift-click
2026-11-06 08:59:51 +01:00
Kacper 63ddd76a15 Merge pull request #10100 from yahya-nusseibeh/fix/nbt-heap-quota
Network NBT: no more tags than vanilla's quota admits
2026-11-06 08:59:44 +01:00
Kacper 208f1c010f Merge pull request #10101 from DUzzL/fix/10051-frame-item-count
Keep one item in item frames filled by /item or NBT
2026-11-06 08:59:35 +01:00
Kacper 935e3927ab Merge pull request #10004 from DUzzL/fix/9960-allay-aim-eyes
Allay: aim deliveries at the player's eyes
2026-11-06 08:59:28 +01:00
Kacper 412594e68d Merge pull request #10016 from lightcodegit/fix/loot-guest-and-double-chest
Loot: guests roll a container's table on open; double chests roll both halves
2026-11-06 08:59:21 +01:00
Kacper 63d25cbd63 Merge pull request #10155 from lightcodegit/fix/push-entities-up
Snow, dripstone clay and spreading sculk lift what stands on them
2026-11-06 08:58:57 +01:00
Kacper 43aba8dbe5 Merge pull request #10126 from lightcodegit/fix/spectator-cant-open
A refused spectator opening gets the red action bar
2026-11-06 08:58:50 +01:00
Kacper 9ee9bb4d0e Merge pull request #10105 from DUzzL/fix/10041-adventure-can-place-on
Adventure mode: honor can_place_on for buckets and flint and steel
2026-11-06 08:58:42 +01:00
Kacper 4d5f0a965f Merge pull request #10010 from DUzzL/fix/9976-scaffolding-projectiles
Fix projectiles hitting the underside of scaffolding
2026-11-06 08:58:35 +01:00
Kacper 585e28d1fb Merge pull request #10186 from degepie/fix/enter-block-trigger
Fire the enter_block trigger from the server's block pass
2026-11-06 08:58:28 +01:00
Kacper 966a63278e Merge pull request #10094 from DUzzL/fix/10045-enderman-anger-harming
Splash of harming makes endermen teleport instead of hurting them
2026-11-06 08:58:21 +01:00
Kacper cf8a5a51b4 Merge pull request #10097 from DUzzL/fix/10052-hotbar-q-same-tick
Send the carried slot before a drop
2026-11-06 08:58:13 +01:00
Kacper 22462182e3 Merge pull request #10494 from voidsipp/fix/10466-final-live-config-values
Apply only final config values to the running server
2026-11-06 08:56:41 +01:00
Kacper c8c4a29367 Merge pull request #10363 from CocofireHD/fix/8668-container-to-hopper-minecart-transfer-speed
Pin the hopper minecart's one-item-a-tick transfer (#8668)
2026-11-06 08:56:34 +01:00
Kacper f24cc569db Merge pull request #10515 from Alysia42/tnt-first-tick
Tick TNT that joined during a tick in that tick's late pass
2026-11-06 08:56:09 +01:00
Kacper c3140e1bae Merge pull request #10192 from KBroneZ/claude/fix-10160-state-provider
upgrade-datapack: a simple state provider becomes a state object
2026-11-06 08:56:03 +01:00
Florian ERNSTandCodex 061648308e Keep local mob sounds out of shared server capture
Co-Authored-By: Codex <[email protected]>
2026-11-06 08:41:33 +01:00
DUzzL b6e947c9a4 Merge remote-tracking branch 'upstream/main' into fix/10051-frame-item-count
# Conflicts:
#	crates/horizon-client/src/game/commands/items.rs
2026-11-06 08:29:22 +01:00
Florian ERNSTandCodex 8b33877963 Bind spellcaster NBT to the live spell counter
Load and save Evoker and Illusioner SpellTicks using the counter already consumed by casting goals and server AI. Cover the real codec and actual Game tick path.

Co-Authored-By: Codex <[email protected]>
2026-11-06 08:05:53 +01:00
Alysia Zappavigna 3b56859e04 Tick TNT that joined during a tick in that tick's late pass (fixes #10063) 2026-11-06 18:05:23 +11:00
voidsip a4586852b1 Merge remote-tracking branch 'refs/remotes/origin/leash-refresh-main' into fix/10445-leashed-happy-ghast
# Conflicts:
#	mods/horizon/src/test/java/dev/horizonlab/parity/VanillaCommandExecOracle.java
2026-11-06 00:31:03 -06:00
voidsip f2a63474b9 Match vanilla server-address port syntax 2026-11-06 00:22:03 -06:00
voidsip 24224f4728 Respect the controlling passenger for remote boats 2026-11-06 00:21:23 -06:00
voidsip 4e580b2732 fix(server): apply only final values in config batches 2026-11-06 00:21:04 -06:00
Arslan ArmanulyandClaude Opus 5.5 0a14bac24c Shader packs: biome_category matches Iris's readings, fitted to vanilla's biome tags
`biome_category` read 0 (CAT_NONE) in lush caves and the other cave biomes,
where Iris 1.11.7 reads 5 (CAT_PLAINS), and differed in 12 more vanilla
biomes. A probe pack of ours read Iris's value in each of vanilla 26.3's 67
biomes (`fillbiome` around the camera): they fit vanilla's `is_*` biome tags
(BiomeTags; `is_savanna` unused), then desert, swamps, mushroom fields and
snowy plains by name, NONE for the void, and PLAINS for the rest. Mountain
biomes read 17, which Iris names CAT_MOUNTAIN; CAT_UNDERGROUND is 18.

Biomes vanilla doesn't have keep the old guess from words of their name.

Fixes #10453

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 11:18:32 +05:00
Arslan ArmanulyandClaude Opus 5.5 4eb7a27e58 Shader packs: terrain without a program in the pack is drawn by nothing
A pack (or the dimension folder it uses) with no gbuffers program for terrain
had the game draw terrain itself, into colortex0 and depth. Iris 1.11.7 draws
none: with probe packs of ours having no gbuffers programs, at the root or in
world-1 below a root that has them, colortex0 held only the sky (or fog
colour), entities and particles, depthtex0 had no terrain, and colortex1 to 7
none either; water and lava were missing too.

When no program of gbuffers_terrain's chain (nor _solid or _cutout) is in the
folder, the pack takes solid and cutout terrain from the game and draws
nothing; the same for translucent terrain and gbuffers_water's chain. A
program that is there but fails to load still leaves terrain to the game.

Fixes #10455

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 11:18:32 +05:00
foudretbr 8be5c59fce Native mod API: item tooltip render surface and container item drawing
Fixes #10199
2026-11-06 07:17:10 +01:00
Florian ERNSTandCodex 3aaff8da93 Restore NoAI Dolphin air after its superclass tick
Use the existing species maximum after shared Mob tick work, preserving the normal underwater countdown. Cover the real full-tick path and actual Game commands against fresh Java.

Co-Authored-By: Codex <[email protected]>
2026-11-06 07:16:46 +01:00
Skr1zzzz a8107ee025 Player heads: parse a profile once, not on every draw
head_skin decoded the textures property (base64, JSON, URL checks) and hashed
the skin location (SHA-1) for every head on every frame, before asking the
atlas whether the skin was resident; an armor stand's worn head also turned
its profile NBT into JSON each frame. With 60 stands wearing textured heads
and 36 heads in the inventory that was ~9 us a head a frame (#4664: a server
full of menu heads, CPU-bound with the inventory open).

The pure part (profile -> textures -> skin location, failures included) is
now kept in a bounded cache beside the resolver; worn heads reuse the JSON of
an unchanged profile. The download state, atlas residency, drawn tracking and
eviction re-uploads still run per draw.
2026-11-06 09:02:52 +03:00
voidsip 625f6ca1a5 Match Happy Ghast leash movement and distances 2026-11-05 23:52:43 -06:00
voidsip d12a260a53 Reject overflowing native shader float options 2026-11-05 23:52:06 -06:00
voidsip 0d70c87082 Keep UTF-16 key order in compound SNBT 2026-11-05 23:47:01 -06:00
martipopsandClaude Opus 5.5 b32520088e Controllers: read Bluetooth Xbox pads from their layout, on any firmware
SDL rows match a firmware version exactly, and gilrs' copy has no row for
045e:0b13 at 0x0520. The pad then falls back to gilrs' default mapping: its
right stick is on ABS_Z/ABS_RZ, which nothing read, its triggers on
ABS_GAS/ABS_BRAKE, which that mapping doesn't name, and X and Y stay crossed
because it lists C and Z.

Recognise that layout from what the pad reports, so a new firmware works
without a row.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 00:28:36 -05:00
Arslan ArmanulyandClaude Opus 5.5 460ecfce99 Shader packs: terrain's mc_Entity.y reads 0 off fluids, as in Iris
Terrain's `mc_Entity.y` was -1 on every block but water and lava, and -1 on
those too when the pack has no block.properties. Iris 1.11.7, with probe packs
of ours, reads 1 on water and lava and 0 on every other block, whether
block.properties maps it, leaves it out, or the pack has no such file.

The sprite layers' table now starts each layer at (-1, 0), with y 1 on the
fluid sprites, before block.properties is read.

Fixes #10452

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 10:27:02 +05:00
yahya-nusseibehandClaude Opus 5.5 58d5f3b519 Merge main into fix/nbt-heap-quota
Brings the branch up to date and re-runs CI: verify failed once in
horizon-relay's slow_first_lines_and_too_many_connections_are_closed (a
timing test, 5.6 s against its 3 s on a loaded runner), which this
change doesn't touch.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stK8iXVDXqoi7midcqd
2026-11-06 05:43:15 +02:00
Skr1zzzz d3678c9112 Atlas readback tests skip without an adapter, as the module's others do
CI runners have no GPU: the two material-atlas tests panicked in
request_adapter instead of returning like layers_land_in_their_cells.
2026-11-06 05:53:06 +03:00
CocofireHDandopencode 7cc9057fa6 Extras: keep the Zoom hint out of the Mods page's way in
The not-installed Zoom row pushed Page::Mods from Extras, which
every_setting_and_page_is_reached_from_one_place refuses: Options > Mods
already has the one way in. It is now a note that says where to install
Zoom; once the mod is built in the row is still its switch and Settings
button.

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-06 02:47:58 +01:00
Florian ERNSTandCodex 8dd47b5ef2 Omit default hive block state from saved item patches
Co-Authored-By: Codex <[email protected]>
2026-11-06 02:37:28 +01:00
Florian ERNSTandCodex 97d414cc4d Load animal age and love with NumericTag integer conversion
Co-Authored-By: Codex <[email protected]>
2026-11-06 02:04:52 +01:00
rison1337andCodex GPT-6 3f11bff706 Keep blob-shadow culling enabled with native mods
Remove the blanket trait-mod presence bypass while preserving shader-pack guards. Verify the projected shadow in a game with an active native camera hook.

Co-Authored-By: Codex GPT-6 <[email protected]>
2026-11-06 05:44:30 +05:00
CocofireHDandopencode 27b110085e Docs: note the Zoom row on the Extras page
Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-06 01:40:05 +01:00
CocofireHDandopencode 5bb149a282 Extras: a Zoom row that reaches the mod that replaced the extra
Zoom moved from the built-in extras to Horizon's official native mod
(#2030), so the Extras page lost its Zoom setting. Players looking for it
there, including the macOS report in #8713, found nothing.

The Extras page now keeps a Zoom row: with the mod built in it is the mod's
own on/off switch plus a Settings button that opens its page; without the
mod it opens the Mods page where it is installed. The cause is not
macOS-specific: every platform lost the row when Zoom moved.

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-06 01:40:04 +01:00
rison1337andCodex GPT-6 4b3116c54f Keep flame culling enabled with native mods
Remove the blanket trait-mod presence bypass. Retain shader-pack and TAA guards, and document the native camera-hook image checks.

Co-Authored-By: Codex GPT-6 <[email protected]>
2026-11-06 05:37:40 +05:00
Florian ERNSTandCodex 1d13a300fa Keep guest respawn world information separate from personal landing
Co-Authored-By: Codex <[email protected]>
2026-11-06 01:23:39 +01:00
CocofireHDandopencode 54642a5e80 Pin the hopper minecart's one-item-a-tick transfer (#8668)
MinecartHopper.tick has no transfer cooldown in 26.3: it calls tryConsumeItems every tick, and consumedItemThisFrame caps a tick at one item even when makeStepAlongTrack tries too. The reported pre-#2250 whole-stack pickup is gone; add the regression test that catches it coming back, and cite the per-frame cap next to hopper_cart_suck.

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-06 01:20:48 +01:00
rison1337andCodex GPT-6 5905a4977f Skip offscreen burning-entity flame geometry
Cull conservative bounds of flame billboards before light lookup and vertex expansion. Keep shader/mod/TAA paths and unsupported dimensions, with emitted-geometry and edge-image coverage plus paired FPS samples.

Co-Authored-By: Codex GPT-6 <[email protected]>
2026-11-06 05:16:18 +05:00
Skr1zzzz 98d37c0887 Spans: the frame's prepare steps
HORIZON_SPIKES named almost nothing inside bench's prepare phase: with 150
players on a server and the camera facing the sky, prepare took ~0.43 ms a
frame and its spans accounted for ~0.005 ms. Each step between the fixed
ticks and the renderer now has a lap (pr_hud, pr_entities, pr_lights,
pr_frame, ...), dynamic lights two finer ones. They showed the cost: name
tags (eg_name_tags) built every frame for every player, drawn or not (#4664).
2026-11-06 03:13:25 +03:00
CocofireHD be24d83d97 Merge remote-tracking branch 'origin/main' into fix/10273-reoccuring-freezes-on-diamondfire-server-mcd
Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-06 01:12:19 +01:00
CocofireHDandopencode 561f797c04 Multiplayer: a guest server's resent columns keep the packet's light
A resident column the server sends again was always relit here, for a
Horizon server's bulk edits whose packet carries the light from before
its relight. A proxy's next server resends its whole world at the same
coordinates, so every column queued light jobs for itself and its eight
neighbours, which a small CPU could not work through.

Relight a resent column only when the server announced Horizon's quiet
commands, the same feature flag Horizon servers send in configuration;
any other server's packet light is final, as vanilla applies it. A
reconfiguration resets the announcement until the new server sends its
own.

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-06 01:05:31 +01:00
PzmuV1517 cb65d4f80d Shader packs: a global constant reads an element of a constant array
`const vec4 s = vec4(scale[0].x, ...)` over a constant array read the index and the
array from the function's arena while folding in the module's: naga panicked (index out
of bounds) and Ebin Resurrected failed to load. Both now come from the global arena.
2026-11-06 01:52:10 +02:00
PzmuV1517 04116c33c6 Shader packs: real_packs reads past a pack that panics and sums up each pack
A panic in one pack no longer stops the run: it is caught, and each pack ends with a
SUMMARY line of the programs skipped in each dimension. A pack without shaders.properties
keys is read as one (every key at its default), not a failure.
2026-11-06 01:52:10 +02:00
rison1337andCodex GPT-6 9734a8a255 Cache unchanged entity name-tag glyph layouts
Retain bounded local text quads while updating camera transforms, lighting and cosmetic marks every frame. Invalidate on text/font/atlas/bidi changes and bypass animated or inline content.

Co-Authored-By: Codex GPT-6 <[email protected]>
2026-11-06 04:49:27 +05:00
Florian ERNSTandCodex cd7d6a3dcc Preserve inherited squid damage acceptance result
Co-Authored-By: Codex <[email protected]>
2026-11-06 00:48:21 +01:00
Skr1zzzz b73ef3faa3 Shader packs: material maps share the stitched colour atlas's layout
With a stitched colour atlas (sprites at their own sizes), the normal and
specular atlases were still packed as a grid by PackAtlas::material, so a
block's material texels sat under another sprite's colour (#9570). The
companion atlases now reuse the colour atlas's size, rectangles and padding;
each map is nearest-sampled from its single level into its sprite's
rectangle (an fs_material entry point), and animated sprites copy into the
same rectangles. The grid path is unchanged.
2026-11-06 02:12:54 +03:00
rison1337andCodex GPT-6 868e494b0c Cull off-screen blob shadows before scanning blocks
Use conservative projected-shadow bounds, retaining shadows whose casters are outside the view. Keep shader packs and native mods on the original path. Add resource-backed bounds coverage and measured FPS results.

Co-Authored-By: Codex GPT-6 <[email protected]>
2026-11-06 04:11:09 +05:00
AxxioandAntigravity 576c5942f1 launcher: fix account skin arm-type segmented control overflow and caption layout
In the glass theme account settings (skin_panel), long translated option
labels such as French 'Bras classiques' protruded outside the segmented
control pill due to an inherited ml(40px) margin and cross-axis stretching.
Additionally, the multi-sentence caption wrapped into a 10-line block inside
the narrow column, clipping off the bottom on 1080p displays.

- Remove the ml(40px) margin in glass theme mode.
- Set items_start() on the control column so segments size to natural width.
- Adjust segments option padding to 12px with flex_none and justify_center.
- Reorganize skin_panel so the caption spans full width across the bottom.
- Adjust Microsoft card width to 360px and reduce vertical padding in glass_who_plays.

Co-Authored-By: Antigravity <[email protected]>
2026-11-06 00:10:36 +01:00
Skr1zzzz 575fadbeeb Friends: Cancel and Escape go back to the screen that opened it
Join a Friend opens from the title screen's Friends button and from the
server list. Both exits always went to the server list, so the title's
entry landed the player on a list they never opened (#9190, item 10).
The page now remembers whether the title opened it.
2026-11-06 02:10:26 +03:00
Florian ERNSTandCodex 6df80522aa Omit the saved lodestone tracker true default
Co-Authored-By: Codex <[email protected]>
2026-11-06 00:08:11 +01:00
MSedgeMC f16012e26c Entity saves: a UUID bridge for references to a mob
A save can now name an entity it only knows as a `Who`: `mob_to_nbt` takes a
`uuid_of` resolver, `who_uuid` writes a `Who` as a UUID and `who_by_uuid` turns a
stored UUID back into the `Who` of a loaded player or mob. `EntityMemory::uuid_of`
supplies the id -> UUID map the game layer keeps, and the game layer's `mob_by_uuid`
now resolves through the same seam.

The neutral mobs' `angry_at` is the first writer: anger at another mob now saves
that mob's UUID instead of nothing. The warden's suspect list and the shulker
bullet's target are the two follow-ups this bridge unblocks; neither is written
here.
2026-11-05 23:06:45 +00:00
foudretbr 5a92cfb6b1 launcher: retry native cache fixture exec on ETXTBSY and copy via temp rename
Under Linux, executing a freshly copied test fixture can fail with ETXTBSY (os error 26) if another test thread forked while the file was being written. Copy the fixture via a temporary file before renaming it into place, and retry exec/spawn if ETXTBSY occurs.

Fixes #10271
2026-11-06 00:04:15 +01:00
CocofireHDandopencode 22807a3f3a Audio: play multi-channel buffers direct like OpenAL
Stereo music and records were downmixed to mono and panned by the camera, so turning away hard-panned them and distance attenuation could silence them. OpenAL Soft plays a multi-channel buffer without spatialization or distance attenuation (AL_SOFT_source_spatialize auto), as vanilla's HRTF-off context does.

Keep the decoded channel count through the cache and mixer; mono buffers keep the existing panning and linear attenuation. Add a mixer test that stereo frames stay direct and unattenuated at any camera angle or distance.

Fixes #10037

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-05 23:47:14 +01:00
Ian WorleyandClaude Opus 5.5 b27965b4ca test: pass the drop-motion and install.sh signature tests on Apple silicon
Vanilla's Math.sin is HotSpot's intrinsic, which rounds one of the drop
test's throws a bit lower on AArch64 than on x86-64; a vanilla 26.3 run
on an M-series Mac gives the AArch64 value. The install.sh fixture only
holds the Linux x86-64 archive and ran the cases with /usr/bin's
OpenSSL, LibreSSL on macOS: a fake uname picks that archive, and the
cases get the OpenSSL 3 the script checked for.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 16:41:36 -06:00
Florian ERNSTandCodex 9c5982d8e0 Default loaded item frames down when Facing is absent
Co-Authored-By: Codex <[email protected]>
2026-11-05 23:07:30 +01:00
MSedgeMC 1665d177c6 test: a playing jukebox posts jukebox_play every twenty ticks
A windowless game with a resident chunk, a jukebox at (0, 64, 0) and a disc put
in through use_jukebox: 41 ticks of the jukebox tick post the event at ticks 0,
20 and 40 and nothing else, and a jukebox that is never played posts none.
2026-11-05 21:59:53 +00:00
MSedgeMC 36dac313c8 fix: a playing jukebox tells the listeners around it
JukeboxSongPlayer.tick posts GameEvent.JUKEBOX_PLAY at the block every
PLAY_EVENT_INTERVAL_TICKS (20) ticks of a song, right before the music
particles:

  37: invokevirtual #97   // Method shouldEmitJukeboxPlayingEvent:()Z
  44: getstatic     #101  // Field GameEvent.JUKEBOX_PLAY
  56: invokeinterface     // LevelAccessor.gameEvent:(Holder,BlockPos,Context)
  66: invokestatic  #104  // Method spawnMusicParticles:(LevelAccessor,BlockPos;)V

Horizon drew the note particle in the same twenty-tick branch and posted
nothing, so a sculk sensor beside a playing jukebox never clicked and an allay
never heard the song, although the stop half (JUKEBOX_STOP_PLAY with level
event 1011) was already posted when the jukebox went away.
2026-11-05 21:59:53 +00:00
MSedgeMC 9fb4fde63b test: a use start is an item_interact_start vibration
A windowless game from the prepared resources, a bow in hand: the two events the
use posts are read back from the game's own event recorder, at the player's feet
(10, 64, -4). A spear, whose use effects set interact_vibrations false, posts
neither.
2026-11-05 21:49:11 +00:00
MSedgeMC d2cd07f1ac fix: starting an item use posts item_interact_start
LivingEntity.startUsingItem ends with
this.useItem.causeUseVibration(this, GameEvent.ITEM_INTERACT_START), and
ItemStack.causeUseVibration posts only when the stack's use effects ask for
interaction vibrations - the same gate and the same position stopUsingItem
already used for ITEM_INTERACT_FINISH. Horizon posted only the finish event, so
a sculk sensor beside a player drawing a bow never clicked and an allay never
heard the use start.

use_vibration takes the event now, and start_using_item calls it where vanilla
posts it: after the use is set up, before the kinetic weapon's map (which this
change leaves alone).
2026-11-05 21:49:11 +00:00
Florian ERNSTandCodex dec4b523f3 Use neutral source for ominous item warning
Co-Authored-By: Codex <[email protected]>
2026-11-05 22:42:57 +01:00
MSedgeMC 3236b57773 Declare no extra comment paths for the trample hook fragment
stamp_native_hooks.py refuses a fragment that lists a guarded file
(native_hooks.rs is the generated source it stamps itself), so the fragment
declared an invalid comment path and the repo-wide stamp check failed.
2026-11-05 21:35:17 +00:00
Evrim CicekandClaude Opus 5.5 c572bb7f88 Port pack: folder entries don't vote in old_layout; the report's reason in one line
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 00:25:58 +03:00
MSedgeMC 9329e154cb Merge remote-tracking branch 'upstream/main' into HEAD 2026-11-05 21:21:52 +00:00
MSedgeMC 72a1b13eae Merge remote-tracking branch 'upstream/main' into fix/trample-hook 2026-11-05 21:21:41 +00:00
Evrim CicekandClaude Opus 5.5 2ea59c309b Port pack: a mod made for 1.20 is not carried or counted as come over
check already refused a jar whose data folders are named as before 1.21; carry and report did
not, so a 1.20 pack's mods were carried and counted as partial while 26.3 read none of their
recipes, loot tables, tags or structures (Prominence II: 149 of 308 "come over", 28,948 recipes
and 8,784 loot tables in recipes/ and loot_tables/).

old_layout moves to carry and reads every folder 1.21 renamed (structures/, advancements/,
tags/blocks/, ...), by the larger share: a 1.20 jar may hold a misnamed singular tag folder and
a 1.21 jar a stale plural copy. carry writes old_layout to carry-report.json and carries
nothing; the report's row says why, is not one Port can carry over, and the launcher does not
count it as portable.

Fixes #10249

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 00:21:33 +03:00
TaquitoSuabe cf92894885 gfx: enable early depth occlusion culling on OpenGL (Backend::Gl)
Cull::supported previously gated early-depth occlusion culling to Vulkan and Metal. Enable wgpu::Backend::Gl when the device supports SHADER_EARLY_DEPTH_TEST alongside indirect first instance and storage buffer writes.
2026-11-05 14:14:46 -07:00
Florian ERNSTandCodex 0e05b77d95 Keep glow squid dark countdown running with NoAI
Co-Authored-By: Codex <[email protected]>
2026-11-05 22:00:30 +01:00
MSedgeMC 4fea4cfd63 test: the shipped presets' disable_mob_generation, through the client seam
The three vanilla dimensions, built from the shipped data, must answer what
their noise settings say: the overworld and the nether add creature packs at
generation, the End does not. This is the value `spawn_generation_creatures`
now returns on, read through `VanillaTerrain`, so it covers the whole path from
`end.json` to the gate instead of just the JSON read.

With the setting read as a hardcoded false (the pre-change behaviour, where only
`Dim::End` decided) it fails as
`assertion left == right failed: disable_mob_generation of minecraft:the_end —
left: false, right: true`.
2026-11-05 20:58:11 +00:00
MSedgeMC 61e96ef269 fix: the noise settings' disable_mob_generation skips the creature packs
`NoiseBasedChunkGenerator.spawnOriginalMobs` reads
`NoiseGeneratorSettings.disableMobGeneration` and places nothing when it is
set. The shipped presets are explicit about it: `end` sets it true, the other
six set it false. Horizon never read the field and instead skipped the pass for
`Dim::End` alone, so a datapack dimension that used the End's noise settings -
or any preset asking for no creature packs - still got them, and the reason the
End was empty lived in a dimension check rather than in the data.

The setting now reaches the client seam: `Generator`'s settings carry it, the
pipeline trait exposes it next to `sea_level`, `VanillaTerrain` answers it, and
`spawn_generation_creatures` returns before doing any work when it is set. The
existing `Dim::End` check stays as well, so a preset that fails to load cannot
start filling the End; for the shipped data the two agree, since `end.json` is
the only preset that sets the flag.
2026-11-05 20:58:06 +00:00
Riccardo GuerrieroandClaude Opus 5.5 9ee63c0510 Aquifers keep their 12 nearby cells unpacked and reuse 8 of them a cell down
Part of #9996.

NoiseBasedAquifer::compute_substance is 4.7% of the overworld's proto
phase by itself. A sixth of that went to rebuilding the 12 grid cells
around a block whenever its aquifer cell changed: every location was
unpacked again from its packed long, and the neighbourhood was copied
as a 288-byte Option. The fill walks each column from the top down, so
a rebuild usually follows the cell just above.

Each drawn location is now also kept as the coordinates BlockPos.getX,
getY and getZ read back from it, and the 12 cells are arrays of grid
index, coordinates and horizontal distance to the column. One cell
down, the 8 cells at the old anchor's y and the y below move up two
slots and only the 4 new ones are loaded, in vanilla's loop order. A
cached fluid status is read inline; computing a missing one is out of
line. Default air, water and lava are classified by their IDs before
any block name is read.

Every block, pending fluid update, error and cached cell is the same as
before: the same operations in the same order, the same positional
random draws for the same cells. A test runs the previous search
against the new one over whole columns, scrambled blocks and positions
outside the grid, and the snapshots match byte for byte.

A first draft by DeepSeek V4.1 Flash, via pi, was reviewed and
reworked.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01WZzq8LUjYucp4kaN6UfSg7
2026-11-05 21:52:13 +01:00
MSedgeMC 9794dd7ca9 port_map: Entity.getLightProbePosition, the eye over the frame
The ledger had no row for the probe the renderers read; it now names the mob and
projectile port and where the armour stand's override lives.
2026-11-05 20:40:55 +00:00
MSedgeMC 5d76ff0225 test: the mob and projectile probes over the frame
A mob walking 0.9 -> 1.1 (eye 1.62) reads the cell at 0 at the start of the frame
and 1 from the boundary on, where the old probe read the tick's cell 1 on every
frame; a falling one reads 71.62 then 70.62. An arrow falling 64 -> 65 reads
64.13, 64.63 and 65.0, so the cell at 65 only at the end of the frame, where the
old probe read 65.0 throughout.
2026-11-05 20:40:52 +00:00
MSedgeMC caa5c31dd0 fix: light a mob and a projectile from the block its eye is in that frame
EntityRenderer.getPackedLightCoords takes BlockPos.containing of
Entity.getLightProbePosition(partialTick), and Entity leaves that method as
getEyePosition: getEyeHeight() over getPosition(partialTick), which is
Mth.lerp(partialTick, xo, x) on each axis.

The mob pass probed floor(position) with position.y + eye_height(), and the
projectile pass the same on its own position: the tick's own position, so a mob
walking from x = 0.9 to 1.1 was drawn lit by the cell at 1 for every frame of the
tick, and a falling one by the cell it was about to reach, instead of the cell it
was drawn in.

Mob::light_probe(partial) and Projectile::light_probe_y(partial) now give the
probe of the frame, through mobs::living::eye_probe_cell (Mth.lerp plus the eye
height, floored per axis).

Old/new for a mob walking 0.9 -> 1.1 (eye 1.62): cell x = 1 on every frame before,
x = 0 at alpha 0 and 1 from the boundary on. For an arrow falling 64 -> 65: 65.0
before, 64.13 at alpha 0 and 64.63 at alpha 0.5, the cell at 65 only at alpha 1.
2026-11-05 20:40:49 +00:00
MSedgeMC 4465421885 port_map: ArmorStand.getLightProbePosition is ported, not skipped
The row said the probe was drawing-only and skipped; it is what selects the block
an armour stand is drawn with, so it now points at the code that walks it.
2026-11-05 20:35:23 +00:00
MSedgeMC 1d38cdeba7 test: the armour stand's probe cells and packed light
Four cases over the jar's values: a full stand's eye at 65.7775 (cell 65, where
the old base + 0.5 read 64), a small stand at 64.2 (65.1875, cell 65, where the
old read 64), a marker's box scanned for a 15 at (0, 65, 0) and falling back to
the first cell walked (0, 64, 0), y walked before z, the cell above the box left
out, and the marker branch ignoring the partial tick; and a burning stand's block
light at 15 with the sky light left as the probe cell's.
2026-11-05 20:35:23 +00:00
MSedgeMC 249341e7cc fix: light an armour stand from its own light probe
EntityRenderer.getPackedLightCoords reads the block Entity.getLightProbePosition
is in, and ArmorStand overrides that method:

- a marker walks BlockPos.betweenClosed of getDimensionsMarker(false) - the box it
  would have without the marker flag, 0.5 by 1.975, or 0.25 by 0.9875 when small -
  from position() with no partial tick, and returns the first cell whose
  max(block, sky) brightness is 15, else the brightest, ties going to the first
  cell BlockPos walks (x fastest, then y, then z);
- every other stand is Entity.getLightProbePosition, the eye: eyeHeight 1.7775 for
  a full stand and 0.9875 for a small one (BABY_DIMENSIONS), at the frame's
  interpolated position.

Horizon read floor(base + 0.5) for every stand, so a stand standing on a lit floor
in a dark room was drawn with the floor's light: a full stand on y = 64 read the
cell at 64 where its eye at 65.7775 is in the cell at 65, and a hologram marker on
a dark ceiling was lit from its feet's block instead of the brightest cell of the
box it would have without its flag.

EntityRenderer.getBlockLightLevel also puts a stand that isOnFire at 15 block
light, which was not read at all.
2026-11-05 20:35:23 +00:00
Florian ERNSTandCodex 85c277b5e2 Correct empty tag selector predicates
Co-Authored-By: Codex <[email protected]>
2026-11-05 21:31:34 +01:00
MSedgeMC 309ddb48ed port_map: CreativeModeInventoryScreen.selectTab is the creative tab switch 2026-11-05 20:20:54 +00:00
MSedgeMC 87a0ccc90c test: the creative search box loses the focus with its tab
`Creative::select`: the box starts unfocused, the search tab focuses it, and
another tab takes the focus away.
2026-11-05 20:20:54 +00:00
MSedgeMC 1e36a5da1f fix: the creative search box loses the focus with its tab
`CreativeModeInventoryScreen.selectTab` shows and focuses the search box only
on the search tab; any other tab hides it, lets it lose the focus and clears
its query. Horizon focused the box when the search tab was selected but never
took the focus away, so the box stayed focused behind every other tab.

`Creative::select` now sets the focus from the tab it shows, keeping the blink
reset the search tab always had.
2026-11-05 20:20:54 +00:00
MSedgeMC 6ad03dd306 port_map: the dropped item's light probe and block light
The ItemEntityRenderer row names where its light comes from now, and
EntityRenderer.getBlockLightLevel's note covers dropped items beside mobs and
projectiles.
2026-11-05 20:14:11 +00:00
MSedgeMC 837ec11a1a test: the dropped item's probe, frame position and packed light
The three cases quote the old values: the failing probe cell of a falling item
(64 against the eye's 65), the tick's own position against the frame's halfway
one (64.7125 rather than the destination 65.2125), and a burning item's block
light (15 over a dark cell's 0, sky untouched).
2026-11-05 20:14:11 +00:00
MSedgeMC a26ffbb007 fix: light a dropped item from the block its eye is in
EntityType.ITEM declares eyeHeight(0.2125), and EntityRenderer.getPackedLightCoords
reads the block of Entity.getLightProbePosition, which Entity leaves as
getEyePosition: the item's position plus that eye height, at the position the frame
draws (getPosition(partialTick)), not this tick's own.

The dropped-item pass probed the cell of base + 0.1 at the tick's position, so a
falling item at y = 64.85 was lit by the cell at 64 (the floor's light) while its
eye at 65.0625 sits in the cell at 65, and the light jumped a frame early when an
item crossed a cell boundary.

EntityRenderer.getBlockLightLevel is isOnFire ? 15 : the level's block light, so a
burning item is now drawn at full block light with the level's sky light; the
item's fire ticks already clear for a fire-immune stack, which ItemEntity.fireImmune
makes never on fire.

A picked-up item's particle is not an entity and keeps the cell it is drawn in.
2026-11-05 20:14:11 +00:00
MSedgeMC eb6d139e34 test: a pack update keeps the player's mod choices
The fixture registry takes a pack version so the test can install
cozy 1.0.0, turn coords_hud off as the Mods page does, and install
cozy 1.1.0: the file is updated and the mod is still off. Turned back
on, 1.2.0 keeps it on. With the old unconditional set_enabled(m.on)
the update re-enables it and the test fails at 'the update kept it
off'.
2026-11-05 19:39:47 +00:00
MSedgeMC fc6f53a337 fix: a pack update keeps the player's mod choices
install_version_in() applied java::set_enabled(home, id, m.on) for
every mod of the pack on every install, and installing a registry pack
again is how it updates. horizon-mods.txt is the same list the Mods
page toggles, so a mod the player turned on or off in a pack profile
was reset to the pack author's 'on' by the next pack update.

The install now reads the pack's previous record (pack.json's 'id
version' entries) and carries each listed mod's current on/off choice
through the install, which turns a mod on as an explicit install. A
mod the new pack version adds takes the pack's own 'on', and a fresh
install has no previous record so every mod does, as before. The Java
mod path already keeps '.disabled' through updates the same way.
2026-11-05 19:39:47 +00:00
Florian ERNSTandCodex 16d11c6bb2 Require matching furnace output components before cooking
Co-Authored-By: Codex <[email protected]>
2026-11-05 20:28:16 +01:00
HiuoyandClaude Opus 5.5 b248489d15 Native hooks: PlayerEvent.BreakSpeed, how fast a player digs (#10198)
break_speed fires at the end of Player.getDestroySpeed, on the Game that
works out a dig: the window's own prediction, and the host's (single
player's server thread, a LAN or dedicated host) for each player. A mod is
told the block and vanilla's speed, and may change it;
BlockBehaviour.getDestroyProgress then divides it by the hardness.

tick_mining now takes its progress from destroy_progress, like the other
digs, so the speed is worked out in one place. The remote client asks it
for blocks of hardness 0 too, so a mod's speed of 0 stops those as well.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 23:28:08 +04:00
Riccardo GuerrieroandClaude Opus 5.5 6165c33ebe Climate searches pass distances along and stop a box's sum once it is too far
Part of #9997.

ClimateIndex::search, vanilla's Climate.RTree search, is about 5% of
overworld proto generation (callgrind, generate_proto, seed 12345) and 2%
of the nether's. About 21 boxes are measured per search: the root's two
children and the 19 below.

Three changes, none of which changes which leaf a search returns:

- The best leaf's distance goes down into each subtree and back up with
  its result. Vanilla measures the best leaf again at the start of every
  subtree and after it returns; the values are the same.
- The offset axis is always 0 in the target, so each box's squared
  offset distance is stored once instead of measured every time.
- When every bound in the tree and every target value is within 2^29,
  no term or sum of a distance can overflow, so a partial sum only grows.
  A box's sum then stops once it reaches the best distance so far: the
  search would only compare it with that distance and skip the box. The
  axes are added depth first, as surface biomes are points in depth.
  Otherwise the full wrapping sum is used as before.

The search still walks the same children in the same order from the
same previous leaf, and replaces the best only on a strictly smaller
distance, so ties go the same way. New tests compare it with a copy of
the old search over long target sequences that keep the previous
result, on the vanilla overworld and nether lists and on generated
lists full of ties, inverted spans, extremes and values at the bound.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01WZzq8LUjYucp4kaN6UfSg7
2026-11-05 20:15:24 +01:00
MSedgeMC 404cf600c1 port_map: CreativeModeInventoryScreen.keyPressed is the chat key's search tab 2026-11-05 19:02:11 +00:00
MSedgeMC b66525f609 test: the creative chat key opens a focused, empty search tab
`Creative::search_tab` and `select` as `Game::key_pressed` uses them: the
search tab is found by its type, selecting it focuses the search box, and the
query the previous tab left is emptied.
2026-11-05 19:02:11 +00:00
MSedgeMC 1b8b51978d feat: the chat key opens the creative inventory's search tab
`CreativeModeInventoryScreen.keyPressed` takes the chat key on a tab that is
not the search as "show the search tab": it selects
`CreativeModeTabs.searchTab()` and sets `ignoreTextInput` so the letter the key
carries never reaches the search box. Horizon had neither: T on a creative tab
did nothing, and the letter went nowhere.

`Creative::search_tab` finds the search tab by its type, so the key selects it
through the same path a tab click uses (`select_creative_tab`, which clears the
quick-craft clicks as `selectTab` does). `Game::key_pressed` takes the chat key
there, and the `ignoreTextInput` flag it sets makes `type_chars_into_screen`
(`charTyped`) drop that one letter and no other.
2026-11-05 19:02:11 +00:00
degepieandClaude Sonnet 5.5 dcffd6cae9 Fire the enter_block trigger from the server's block pass
23f8bd4fe removed the only consumer of MotionEvent::EnterBlock, and the event was pushed only in the client pass. The server pass pushes it now and the guest server tick triggers it for the acting player.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UE3PMKbnTcxfBSeT5uQWCK
2026-11-05 23:52:32 +05:00
KBroneZandClaude Opus 5.5 694d953496 feat: read structure templates from data packs
A data pack's `data/<ns>/structure/*.nbt` were ignored. A jigsaw pool naming one
placed nothing, so `/place jigsaw` failed with "Failed to generate jigsaw" and
`/place template` said the template does not exist; that is how a skyblock pack
places its island. Packs also could not replace vanilla's templates.

The enabled packs' templates now join the worldgen overrides, highest pack first,
over the prepared ones, as `StructureTemplateManager` reads resources. `/place
template` and structure blocks look in the world's `generated` folder, the packs,
then the game's own.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 20:44:36 +02:00
KBroneZandClaude Opus 5.5 3f782b89ee fix: upgrade a simple state provider to a state object, not a bare id
26.3 reads a direct block state provider as a state object. A bare string names
a provider in the `worldgen/block_state_provider` registry, so an upgraded
feature with a default-state `simple_state_provider` failed with
`unknown state provider minecraft:air` and was skipped at world start. Vanilla's
own features write `{"id": "minecraft:clay"}`.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 20:44:35 +02:00
VaspyyyandClaude Opus 5.5 0e33837c78 Play a station's take sound once per shift-click
Shift-clicking a stonecutter result queued ui.stonecutter.take_result for
every item taken, and the server side played every copy at once: cutting
60 granite played the sound 60 times stacked. Vanilla's lastSoundTime
lets it through once per tick. The menu now queues each take sound once
per click (stonecutter, loom, cartography table), and lan_station_sounds
plays one sound per tick.

Fixes #10148

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 19:37:45 +01:00
makscoder 53aad00102 Snow, dripstone clay and spreading sculk lift what stands on them
`Block.pushEntitiesUp` runs when these blocks gain the collision they add on
top. The farmland/path to-dirt path already emits the `push_entities_up` block
event; the three growth paths did not, so a body standing on them sank into the
new block.
2026-11-05 21:35:45 +03:00
Vinícius MoreiraandCodex 3c794c2825 Fix shader-pack color-buffer reads in translucent draws
Preserve the preceding scene color when a translucent shader draw reads
and writes the same buffer, rather than sampling a white fallback.
Reuse the existing snapshot mechanism to avoid attachment feedback.

Validated with Sildur’s Vibrant before-and-after captures and an
original asset-free GPU regression.

Co-Authored-By: Codex <[email protected]>
2026-11-05 15:33:25 -03:00
HiuoyandClaude Opus 5.5 29662d963c Native hooks: LivingDamageEvent.Pre and .Post (#10132)
living_damage_pre and living_damage_post fire at the end of
LivingEntity.actuallyHurt, for players, mobs and the ender dragon. Pre is
told what is about to come off the health, once armour, enchantments,
effects and absorption have taken their part, and a mod may change it.
Post is told what came off, 0 when nothing did. The translated actuallyHurt
in horizon-world gets two hook points whose defaults do nothing.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 22:32:13 +04:00
degepieandClaude Sonnet 5.5 c69d03f96e Resource packs: read a lang file leniently when merging the layers
#9560 made Language::load lenient, but the pack merge parsed each layer with strict JSON and dropped the ones that failed, so a lang file with # comments never got that far.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UE3PMKbnTcxfBSeT5uQWCK
2026-11-05 23:25:08 +05:00
Florian ERNSTandCodex de4029c1df Merge main and retain raid harness observations
Co-Authored-By: Codex <[email protected]>
2026-11-05 19:16:54 +01:00
HiuoyandClaude Opus 5.5 62a47df275 Native hook catalog: NeoForge labels for five more hooks
frame, gui_render_post, tooltip_component, experience_orb_pickup and
item_dropped get the NeoForge event ATM10's mods listen to for the same
point: RenderFrameEvent.Pre, RenderGuiEvent.Post,
RenderTooltipEvent.GatherComponents, PlayerXpEvent.PickupXp and
ItemTossEvent. The bracketed notes say where a hook differs (#9921).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 22:07:18 +04:00
CocofireHDandopencode 232b0e85aa UI: a button's highlight goes when its screen leaves
Vanilla's Gui.setScreen calls Screen.clearFocus on the screen it leaves, so
a screen that comes back starts with no widget focused. Horizon kept the
focus a mouse click gave a button (the title's focused_button, pause_focus,
death_focus, the options' focus), so going back to the main menu redrew that
button highlighted (AbstractWidget.isHoveredOrFocused) with the pointer
somewhere else.

The front screen is now tracked with key_screen() (which the keyboard's focus
already uses for its own screen changes); when it changes, every menu drops
its click focus. The startup menu does the same for its title behind the
options, before the loading hands that title to the game.

Fixes #9273

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-05 19:06:06 +01:00
makscoder 05bc471106 A refused spectator opening gets the red action bar
ServerPlayer.openMenu, when createMenu returns null for a spectator, sends
container.spectatorCantOpen to the action bar (red). Horizon's refusal in
spectator_open_menu and the chest-boat guest handler was silent.
2026-11-05 20:59:03 +03:00
yahya-nusseibehandClaude Opus 5.5 45af2b23b2 Network NBT: no more tags than vanilla's quota admits
Vanilla reads network NBT with NbtAccounter's 2 MiB quota, which
charges each tag an estimate of its heap size: at least 8 bytes (EndTag,
the smallest; a byte tag in a list costs 13). Horizon counted only the
bytes read, plus up to 1,000,000 nodes, so a crafted server could send a
1 MB list of byte tags that vanilla refuses and have Horizon build ~1 M
32-byte Tags from it, about 16 times the memory.

- buf::Reader::nbt caps a network NBT value at NBT_QUOTA / 8 = 262,144
  tags (NBT_MAX_TAGS). Every value vanilla accepts has at most that
  many, so nothing vanilla accepts is refused, and one value's tags now
  take at most ~8 MiB here.
- custom::validate checks component values with the same reader, so it
  follows.
- Test: a list of 262,143 byte tags (with its root, 262,144) reads; one
  more is refused although it is a few hundred KB.

A full port of NbtAccounter's per-type charges would be closer still;
this bound needs only its smallest charge, so it can't refuse what
vanilla takes.

Found in yahya-nusseibeh's review of Horizon (L2).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stK8iXVDXqoi7midcqd
2026-11-05 19:44:32 +02:00
DUzzL de7d92e134 Keep one item in item frames filled by /item or NBT 2026-11-05 18:43:51 +01:00
MSedgeMC 8c6933bda4 port_map: Hud.extractTabList is tab_list::visible
The row was absent; map-check: 150 rows, no problems.
2026-11-05 17:40:40 +00:00
MSedgeMC 4ea8baade5 test: F1 hides the player list even while Tab is held
tab_list::visible with the HUD hidden answers false for a held key with two
listed players and for a local server with a list objective, and the
scoreboard's LAN test asserts the same through the game's call.
2026-11-05 17:40:32 +00:00
MSedgeMC 15047ffac4 fix: F1 takes the player list away
Hud.extractRenderState calls extractTabList inside `if (!isHidden)`, so with
the HUD hidden (F1) the player list does not draw at all, whatever the
player-list key or the list objective says.

Horizon's hud_layers only skipped the tab list under F1 when the chat screen
was open: with no screen, holding Tab drew the list over the hidden HUD. The
gate is now in tab_list::visible, next to the key and the local-server rules
it already applied, so every caller gets it.
2026-11-05 17:40:23 +00:00
DUzzL b20ab60d01 Make a splash of harming teleport an enderman instead of hurting it 2026-11-05 18:35:16 +01:00
yahya-nusseibehandClaude Opus 5.5 c0e2f62b35 Telemetry server: hub, intake and export off the async workers too
The server runs two async workers and keeps SQLite on tokio's blocking
pool (lib.rs, `blocking`), but /hub/vote, /hub/votes and /hub/report and
the token-protected /v1/export/* still called app.store() on the
workers. While a blocking-pool task holds the store mutex (a large batch,
the daily upkeep), two votes or reports from different addresses park
both workers on it and every handler stops answering, /healthz
included; per-address limits don't help, it takes two requests.
/hub/votes also held votes_cache's lock while waiting for the store.

- hub::vote and hub::votes run their store calls through blocking();
  votes reads the cache, releases its lock, counts on the blocking pool
  and stores the result after.
- intake::receive checks the day's count and queues the report in one
  blocking() call, under one store lock as before; report marks the
  issue filed through blocking().
- export::benchmarks and export::hardware read through blocking().
- A failed blocking task answers 503 "try again later" through each
  module's own refusal (the hub's keeps its CORS headers).

Behaviour is unchanged otherwise: the crate's 58 tests pass as they are.

Found in yahya-nusseibeh's review of Horizon (M1).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stK8iXVDXqoi7midcqd
2026-11-05 19:32:12 +02:00
Riccardo GuerrieroandClaude Opus 5.5 817c37749f Perlin volumes skip the Ys a lerp does not read, and batch Ys across cells
Part of #9996.

The terrain noise (old_blended_noise) is a lerp between two 16-octave
smeared Perlin stacks. Lerp reads its first branch only where alpha is
not 1 and its second only where alpha is not 0, and in the overworld
about 37% of the coarse points have alpha 1 and 50% alpha 0. The lerp
now passes each branch a bit per Y of each column it reads, and a noise
volume skips the others (DensitySampler::sample_needed,
Noise::add_to_needed; samplers without it still sample everything).

When a column's Y cells change at every Y, as in the stacks'
high-frequency octaves, four Ys in four cells now go through the same
vectorized lane code as four Ys in one cell, each lane with its own
cell's corner terms. Gradient lookups are a loop rather than
array::map, which LLVM had left out of line.

Every value is computed by the same operations in the same order:
snapshots match byte for byte and the tests compare against the
unbatched, unmasked loops.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Fekd3xkgubE8VfXrALh2td
2026-11-05 18:29:50 +01:00
yahya-nusseibehandClaude Opus 5.5 4f49676a37 port-pack: cap every jar and pack entry it inflates
horizon-port-pack read jar entries with read_to_string/read_to_end and
no limit. zip stops an entry only at the size it declares, which can be
4 GiB (more with zip64), and the launcher runs check::check in its own
process on every Java mod a mod-browser search lists, without a click.
So one published mod with a highly compressible mods.toml could abort
the launcher of everyone whose search showed it. A 522 KB jar whose
mods.toml inflates to 512 MiB made `horizon-port-pack check` reach
594 MiB and abort; with this change it peaks at 9 MiB and refuses the
jar.

- crate::entry: read()/read_text() with a per-entry cap, and a Budget
  for a running total over one pass of an archive, so many entries
  under their cap can't add up either. Limits: 16 MiB per text entry
  (JSON, TOML, the manifest), 64 MiB per file kept as it is, 1 GiB per
  archive pass. Only bytes actually read count; an entry declaring more
  than what is left is refused unread.
- Every zip read in the crate goes through it: jar::read's metadata
  (a too-large file refuses the jar), carry's namespace scan and file
  copy, worldgen's piece counts, structure-set and biome tags,
  processors, upgrade::read_pack and import's JSON index. check.rs kept
  its 16 MiB per-class cap and now also refuses a jar whose classes
  together unpack to more than 1 GiB (a thousand entries just under the
  per-class cap was the same attack).
- Tests: the cap and the running total (declared or not, refused
  entries counting), UTF-8, and check() refusing a small jar whose
  mods.toml unpacks past 16 MiB.

No parity change: real mod files are far under these limits.

Found in yahya-nusseibeh's review of Horizon (H1).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stK8iXVDXqoi7midcqd
2026-11-05 19:28:47 +02:00
yahya-nusseibehandClaude Opus 5.5 24987d6660 Launcher: rustls 0.23.46 for four RustSec advisories
launcher/Cargo.lock pinned rustls 0.23.45 (through ureq 2.12), which
cargo audit flags with RUSTSEC-2026-0346 (client session Debug impls
expose TLS secrets), -0347 (buffered data sent to the outer server after
an ECH rejection), -0348 (Brotli certificate decompression) and -0349
(post-quantum downgrade from an unauthenticated ServerHello), all fixed
in 0.23.46. The launcher carries Microsoft sign-in tokens over this
stack.

- `cargo update -p rustls --precise 0.23.46`: rustls alone moves.
- cargo audit on the new lockfile: no vulnerabilities (8 informational
  warnings: unmaintained or yanked crates, unchanged).

Found in yahya-nusseibeh's review of Horizon (L4).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stK8iXVDXqoi7midcqd
2026-11-05 19:21:11 +02:00
Evrim CicekandClaude Opus 5.5 391ce2975d A nautilus's target is within 16 blocks, and pandas and polar bears panic on the same causes
From review, against vanilla:
- Sensor.isEntityTargetable is range(16) from feet to feet, a sphere; the
  nearby scan alone was a cube.
- Panda's PandaPanicGoal and an adult PolarBear's panic use
  #panic_environmental_causes as well; their own copies of it lacked freeze
  and sulfur_cube_hot. They now share armadillo::environmental.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 20:19:00 +03:00
yahya-nusseibehandClaude Opus 5.5 ce92f7e45a Launcher: open HORIZON_LAUNCHER_PROJECT once without remove_var
The mod page opened the project named by HORIZON_LAUNCHER_PROJECT (a
screenshot aid) and then called std::env::remove_var so it wouldn't open
again. By then the download, check and keyring threads are running, and
remove_var races getenv on any of them (glibc's DNS resolution
included): unsound, which is why edition 2024 makes it `unsafe`. The
launcher is on 2021, so it compiled silently.

- A static flag remembers that the page was opened this run; the
  environment is no longer written. Same behaviour: the page opens once,
  after the first search that finds it.

Found in yahya-nusseibeh's review of Horizon (L5).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stK8iXVDXqoi7midcqd
2026-11-05 19:11:41 +02:00
DUzzL a7f6ecbdfb Send the carried slot before a drop (ensureHasSentCarriedItem) 2026-11-05 18:11:01 +01:00
DUzzL 31c1bd1f8b Honor can_place_on for buckets and flint/fire charge in adventure mode 2026-11-05 18:08:40 +01:00
MSedgeMC 7e5063a4d5 port_map: ScreenRectangle.intersection is the scissor stack's cut 2026-11-05 17:03:55 +00:00
MSedgeMC 185908fd38 test: nested GUI scissors clip to the intersection and restore the outer clip
`screen_draw::paint`: an inner scissor draws to the overlap of both, a scissor
with nothing around it is cut to the screen, and one that misses the outer
clip draws nothing while the outer clip takes over again when the inner closes.
2026-11-05 17:03:55 +00:00
MSedgeMC dd0bffd667 feat: a nested GUI scissor is intersected with the one around it
`GuiGraphicsExtractor.ScissorStack` pushes each clip intersected with the one
around it (the screen while there is none, `ScreenRectangle.empty()` when they
miss) and pops it back when it closes. `screen_draw::paint` kept a single clip
that each `Draw::Scissor` replaced: a nested scissor stopped the outer clip
applying at all, and the outer one never came back after the inner closed.

`paint` now keeps the stack: `Draw::Scissor` pushes the intersection with the
enclosing clip (or the screen) and `Draw::NoScissor` clips what was drawn since
and pops back to the enclosing clip. `intersect` is `ScreenRectangle.intersection`.
2026-11-05 17:03:54 +00:00
MSedgeMC c8f1559eb0 port_map: the region file version's configure row is ported
`RegionFileVersion.configure` was the row that named this gap: read from
server.properties and never applied. `Compression::configure` now applies it
from the dedicated server's startup and `compress_records` asks
`Compression::selected()`, so the row says `port` with what each half does.
2026-11-05 17:02:04 +00:00
Evrim CicekandClaude Opus 5.5 1f031f6bd3 A nautilus only goes for a pufferfish it sees, and frost and a hot sulfur cube panic armadillos
NautilusAi.findNearestValidAttackTarget picks the closest #nautilus_hostiles
in water from NEAREST_VISIBLE_LIVING_ENTITIES, the sensor's list of what the
nautilus can see; Horizon took any pufferfish within 16 blocks, through
walls. Its cooldown and 1 in 2 are drawn from the level's random
(level.getRandom()), not the nautilus's own.

Armadillo.actuallyHurt panics (rolls out) on #panic_environmental_causes,
which also holds freeze and sulfur_cube_hot; Horizon listed the other six.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 20:02:04 +03:00
MSedgeMC 2e7afbfe64 test: the region compression follows region-file-compression
Two cases, one per half of the port:

- `compression::tests::the_selected_region_version_follows_the_property` walks
  the four names `RegionFileVersion` registers (`gzip`, `deflate`, `none`,
  `lz4`) against `Compression::selected()`, checks the default before any is
  set is `deflate`, and checks that `zstd` and the wrong case `LZ4` are refused
  and leave the selection alone, as vanilla logs the value and keeps the one it
  had.
- `save::tests::a_chunk_is_written_with_the_selected_region_version` runs the
  function the save path calls, `compress_records`, once per name, and asserts
  the record's codec is the selected one and that its bytes decode back to the
  same chunk.

With `compress_records` back on `Compression::Zlib` the second test fails with
`left: Zlib, right: None` ("written with none") and would then fail on `lz4`
with `left: Zlib, right: Lz4`.

Also here: the helper-parity test next door now dates its expected records with
`Compression::selected()` rather than `Zlib`, so it keeps checking that the
helpers agree with one thread whatever version the process selected instead of
pinning the default.
2026-11-05 17:02:04 +00:00
MSedgeMC 5a3dc52e1f fix: region files are written with region-file-compression
Vanilla's `Main.main` hands `server.properties`' `region-file-compression` to
`RegionFileVersion.configure` right after the settings are saved, and every
`RegionFile` written afterwards uses the version it selected (`getSelected`):
`gzip`, `deflate` (the default), `none` or `lz4`. Horizon parsed the key into
`ServerProperties.region_file_compression` and nothing read it, so every chunk
region was written as `Compression::Zlib` whatever a server asked for.

`Compression::configure(name)` and `Compression::selected()` are the two halves
of `RegionFileVersion` over the same kind of process-wide selection, starting at
`VERSION_DEFLATE`, with `REGION_FILE_NAMES` in the registration order vanilla's
error message lists. A name is matched exactly, as `VERSIONS_BY_NAME` does; an
unknown one changes nothing and says so. The dedicated server's startup calls it
where vanilla does, before the EULA check, and logs vanilla's line for a value
the table does not know. `save.rs::compress_records` encodes each record with
the selection, so the chunks the save path writes carry it; the read side needs
no change, as `Compression` already decoded all four and the header's version
byte names each chunk's codec.
2026-11-05 17:02:04 +00:00
HiuoyandClaude Opus 5.5 bb4d24e909 Native hook catalog: RenderLevelStageEvent labels for the world drawing hooks
81 of ATM10's mods listen to RenderLevelStageEvent to draw in the
world at a stage of the frame. Main has three hooks for that, none
labelled:

- world_submit (fragment world_canvas): quads and text right after
  the particles, before the hand;
- world_primitives (fragment): lines, boxes, beams and text at the end
  of the world pass, before the hand;
- world_drawn (API 58): the drawn world's colour, depth and matrices,
  once the whole level is drawn.

Each gets "neoforge": "RenderLevelStageEvent (...)", with where it
draws in words, so horizon-port-pack events maps the 81 mods to them.
world_drawn also gets side client and cancellable false; the fragments
had theirs. NeoForge's user documentation doesn't cover this event,
so the labels use only its name.

Fixes #10036

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 20:50:51 +04:00
HiuoyandClaude Opus 5.5 513312eb8b Native hook catalog: world_lifecycle also answers ServerStartingEvent
NeoForge documents ServerStartingEvent as fired after
ServerAboutToStartEvent and before ServerStartedEvent. Horizon has
server_about_to_start at the start of loadLevel, and world_lifecycle's
WorldOpenReady once the open has finished, before the tick loop.
Nothing of Horizon's runs between the two NeoForge events, so
WorldOpenReady answers both (30 ATM10 mods listen to Starting).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 20:43:53 +04:00
BnCattandClaude Opus 5.5 6c44743c21 Older servers: read 1.21.11's EitherHolder components
1.21.11 sends instrument, provides_trim_material, damage_type,
chicken/variant and zombie_nautilus/variant as EitherHolders (a flag,
then the entry's holder or its ResourceKey), as it does jukebox_playable;
26.1 made them plain holders. Horizon read the flag as the holder, so a
goat horn's slot came one byte long and the entity data around it one
byte short ("could not translate 1.21.11 packet 20/101").

The flag is read for all six now: a holder reads on as the pinned one,
a key (no pinned numbering for it) drops the component.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 19:43:38 +03:00
Evrim CicekandClaude Opus 5.5 7cead764e8 A held item's attack_range patch, and slimes' reach, follow it too
From review, against vanilla:
- getActiveItem().get(ATTACK_RANGE) includes the stack's patch: an added
  minecraft:attack_range (the codec's defaults: max_reach 3, min_reach 0,
  mob_factor 1) sets the reach, and !minecraft:attack_range takes a spear's
  away.
- AbstractCubeMob.dealDamage calls isWithinMeleeAttackRange, for the player
  it touches and for iron golems; it repeated the default formula.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 19:43:32 +03:00
DUzzL ef05a384d5 Burning players and mobs destroy powder snow they stand in 2026-11-05 17:29:34 +01:00
Evrim CicekandClaude Opus 5.5 d4a2f07032 A mob's melee reach follows the spear it holds
Mob.isWithinMeleeAttackRange takes the active item's attack_range when it
has one: the box is inflated by effectiveMaxRange (max_reach * mob_factor,
2.25 for every spear) instead of the default sqrt(2.04) - 0.6, and a target
within effectiveMinRange (min_reach * mob_factor, 1.0) is out of range.
Horizon always used the default, so a mob given a spear without a
SpearUseGoal (a vindicator, say) hit what stood right beside it and missed
what the spear reached.

New mob AI scenarios: a vindicator with a spear against a player out at the
spear's reach and right beside it, and zombies with a spear (their
SpearUseGoal; the probe now names it as vanilla does).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 19:29:23 +03:00
HiuoyandClaude Opus 5.5 0801940eeb Native hook catalog: label the screen input and render hooks, and item pickup
Seven more hooks whose doc comments already name their NeoForge event:
screen_render_post (ScreenEvent.Render.Post), the five screen input
hooks (ScreenEvent.MouseButtonPressed.Pre, .MouseButtonReleased.Pre,
.MouseScrolled.Pre, .KeyPressed.Pre, .CharacterTyped.Pre), all client,
and item_picked_up (ItemEntityPickupEvent.Pre, server), whose cancel
stands for setCanPickup(TriState.FALSE), with no forced pickup.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 20:21:57 +04:00
Florian ERNSTandCodex 5cede4ff4e Record refreshed raid loss and harness compatibility proof
Co-Authored-By: Codex <[email protected]>
2026-11-05 17:17:06 +01:00
makscoder 574b20a662 Loot: guests roll a container's table on open; double chests roll both halves
The host's open unpacks the loot table it still holds; the guest's
(lan_open_container) read the slots without it, so a LAN or splitscreen
player met an empty container that kept its table, and
player_generates_container_loot never fired for them. unpack_opened_loot
now runs for the acting guest too.

ChestBlock.getMenuProvider unpacks both halves of a double chest; only
the clicked half was unpacked. After its own roll is consumed, the
partner's is unpacked too (the consumed own roll ends the recursion).
2026-11-05 19:15:23 +03:00
some-du6eandClaude Opus 5.5 2a767017b9 OptiFine custom colours: read optifine/color.properties
Every documented key: particle, fog and sky colours, potions, spawn eggs,
map, sheep, collar and dye colours (with their old names), text and its
colour codes, the loading screen, clouds, xporb.time, palette.format and
palette.block.* assignments.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 10:15:15 -06:00
HiuoyandClaude Opus 5.5 a8ea55f39a Native hooks: ClientPlayerNetworkEvent.LoggingIn, and LoggingOut's label
A client mod hears the window's player log in to a server, single
player's own included: native_client_player_network_logging_in(), at
the end of the Login arm (ClientPacketListener.handleLogin), after the
new client level. Respawns and dimension changes don't log in again.
Horizon makes the player with the server's first position, so
native_player() is None there; native_send_packet reaches the server.

client_disconnecting, which main fires before a connection is
discarded (Minecraft.disconnect), gets the catalog label
ClientPlayerNetworkEvent.LoggingOut, so horizon-port-pack events finds
it. The docs describe the pair.

Checked in game with horizon-ctl and a local probe mod: one login on
opening a world and one on reopening it, none on a trip to the Nether
or a respawn, and a logout on Save and Quit and on quitting the game.

Fixes #10008

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 20:15:14 +04:00
Florian ERNSTandCodex 9fcd6133ed Merge main and preserve map, frame and raid harness records
Keep all observation kinds when resolving the three shared driver lists.

Co-Authored-By: Codex <[email protected]>
2026-11-05 17:06:23 +01:00
DUzzL 812702d809 Allay: aim deliveries at the player's eyes like vanilla 2026-11-05 17:04:45 +01:00
Florian ERNSTandCodex bfc26f944a Validate loaded bed claims for raid villages
Keep stale home memories while checking the current HOME POI before
counting a loaded bed claim as a village center. Preserve unavailable
claims and existing job/meeting paths.

Co-Authored-By: Codex <[email protected]>
2026-11-05 16:57:12 +01:00
Evrim Cicek 7658a24435 Merge remote-tracking branch 'upstream/main' into fix/enderman-teleport-danger
# Conflicts:
#	crates/horizon-client/src/game/creatures.rs
2026-11-05 18:54:56 +03:00
Evrim CicekandClaude Opus 5.5 394db9011a An enderman's teleport: waterlogged ground, the sculk event, and hurts without AI
From review of the previous commit, against vanilla:
- Enderman.canRandomlyTeleportTo refuses ground that holds water (a
  waterlogged slab, leaves or roots).
- containsAnyLiquid and findBlocksIn scan the same closed range of cells.
- Both teleport sounds play at the feet (Entity.playSound).
- The game's sculk layer now hears GameEvent.TELEPORT from a mob.
- Enderman.hurtServer teleports inside every hurt, not only the rain's: the
  fire, lava and block hurts of baseTick and a fall's, and a NoAI
  enderman's, which never teleported. A lingering cloud's indirect_magic
  draws the chance too; only a thrown potion skips it.

New mob AI scenarios: enderman_rain_waterlogged (red on the previous commit,
it landed on the slabs at tick 2) and enderman_rain_noai.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 18:49:28 +03:00
DUzzL b1896906d1 Clip projectiles with their own collision context (scaffolding open from below) 2026-11-05 16:36:59 +01:00
Evrim CicekandClaude Opus 5.5 debd86ea59 Endermen teleport where vanilla lets them, and water hurts them when it should
Enderman.teleport is LivingEntity.randomTeleport(x, y, z, true,
#enderman_does_not_teleport_to). Horizon took the first full-collision
block as the ground and landed on top of it, checked water only at the
eyes and lava only at the feet, and never looked at the tag, so endermen
teleported onto magma, into fire, sweet berry bushes and wither roses.

Now, as vanilla: the ground is the first #entities_can_teleport_to block
below the spot, and the enderman lands at the spot's height less the blocks
it went down. The ground and every block in the new box must not be in
#enderman_does_not_teleport_to, and the box must hold no liquid. The
motion is kept (Entity.teleportTo -> snapTo), both teleport sounds play at
the new spot, and GameEvent.TELEPORT goes out from the old one.

The new rain scenarios also showed when the water's hurt comes. It is the
end of LivingEntity.aiStep, after pushEntities, with or without AI, not
baseTick. Enderman.hurtServer draws its 1 in 10 teleport chance after
super.hurtServer (its hurt sound's pitch first), also when the hurt
cooldown refused the damage, and teleports at once. Horizon instead forced
a teleport in rain without the draw, one tick late.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 18:25:33 +03:00
HiuoyandClaude Opus 5.5 f9a93d080a Native hooks: ClientTickEvent.Pre / .Post (client_tick_pre, client_tick_post)
A client mod hears each client tick of the window, at the top and the
end of vanilla's Minecraft.tick: native_client_tick_pre() before any of
the tick's work, native_client_tick_post() once the world's tick, the
chat queue, music and sounds are done. They fire 20 times a second on
the title screen, while paused and in a world alike, as vanilla's tick
runs, and never on a server (single player's ticks on its own thread).
The fragment labels them ClientTickEvent.Pre / .Post, client, not
cancellable.

Checked in game with horizon-ctl and a local probe mod: about 20 calls
a second of each, always paired, on the title screen, in a creative
world and under the pause menu.

Fixes #9971

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 19:10:36 +04:00
MSedgeMC caeaa41bdc Test: a landing change a mod cancels keeps the block as it was
The dispatch test asserts the hook's fields for a guest's trample, that a
cancel stops later mods, and that a change with no mods goes ahead.
2026-11-05 15:03:30 +00:00
MSedgeMC 15f071ff3b Landing: a cancelled trample keeps the block, its crop and its events
The landing's raw write is applied before the game finishes it: the game
now asks the hook there, and a cancel puts the previous block back and
skips the neighbour updates and the level events. The crop above trampled
farmland stays, because breaking it is the neighbour update and not a
write of its own.

`FallEvent::Write` carries the block it replaced, so the game has
something to put back. A guest's landing is asked with that guest's UUID.
2026-11-05 15:03:30 +00:00
MSedgeMC 4985864055 Native hooks: a landing's block change a mod can cancel
A player's landing tramples farmland into dirt and breaks turtle eggs with
no hook a server mod can cancel, so a land-claims mod cannot stop a guest
from trampling inside a claim (#9036, #9873 item 3).

`native_entity_change_block_v2` is asked with the player whose landing it
was, the position and both block names, and can cancel the change. It is
additive: `native_entity_change_block` keeps its signature and firing point.
2026-11-05 15:03:30 +00:00
umcekoandClaude Opus 5.5 9d9617fe29 Save and load primed TNT's owner
PrimedTnt.addAdditionalSaveData stores the igniter as an `owner` UUID
(EntityReference.store) and getOwner resolves it against the level when
asked. tnt_to_nbt now writes the live owner's UUID (a guest, the host
player or a mob), and a loaded TNT looks its saved owner up when it
explodes, so the blast is credited to the player or mob that lit it after
a reload, a portal trip or a chunk unload. An owner that is not online
keeps its UUID through the raw compound.

Fixes #9951

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 17:45:51 +03:00
HiuoyandClaude Opus 5.5 d9d4d19a1d Native hook catalog: label the hooks ATM10's mods listen to most
A scan of ATM10's 491 mods for their NeoForge event listeners (#9921)
showed nine unlabelled hooks behind events many of them use:
RegisterCommandsEvent (95 mods), ItemTooltipEvent (74), the item and
fluid block capabilities (129, 88), RenderHighlightEvent.Block (41),
ServerStartedEvent / ServerStoppedEvent (48, 41), LivingDropsEvent (36),
LivingKnockBackEvent and ExplosionEvent.Detonate. Each label comes from
the hook's own doc comment and firing point; `cancellable` matches its
mod_api contract, and block_highlight keeps the side and cancellable it
already had.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 18:32:46 +04:00
HiuoyandClaude Opus 5.5 2f9c963068 Native hook catalog: NeoForge labels for the core hooks (#9921)
The hooks that came before docs/native-hooks-neoforge.md had no
`neoforge`, `side` or `cancellable` keys, though NeoForge mods listen to
their events most: server and level ticks, block and item clicks,
attacks, joins, chunk loads, logins. Each value comes from the hook's
own doc comment or the vanilla method the proposal's table names, and
`cancellable` matches the hook's mod_api contract. Metadata only: no
name, signature or call site changes.

The proposal's status, its next-hooks table (most of it has landed)
and its capabilities and rollout notes now match main.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 17:59:50 +04:00
333 changed files with 16061 additions and 1200 deletions

No files matched your search

+1 -1
View File
@@ -394,7 +394,7 @@ pub fn apply_deprecated(map: &mut HashMap<String, String>, json: &[u8]) {
/// characters in strings, `=` or `=>` and `;`), which vanilla reads lang files with (expected
/// from `Language.loadFromJson`, Gson). A file that is not JSON even so adds nothing; a value
/// that is not a string, number or boolean (`GsonHelper.convertToString`) ends the file there.
fn read_lang_file(bytes: &[u8], map: &mut HashMap<String, String>) {
pub fn read_lang_file(bytes: &[u8], map: &mut HashMap<String, String>) {
use horizon_world::datafix::json::{Json, parse_lenient};
if let Ok(entries) = serde_json::from_slice::<HashMap<String, String>>(bytes) {
map.extend(entries);
+1 -1
View File
@@ -81,7 +81,7 @@ fn elements(tag: &Tag) -> Option<Vec<Tag>> {
}
/// `DoubleTag.intValue` / `FloatTag.intValue` floor before the i32 conversion;
/// integral tags use their intValue narrowing. Byte arrays then keep the low byte.
fn int_value(t: &Tag) -> Option<i32> {
pub fn int_value(t: &Tag) -> Option<i32> {
Some(match t {
Tag::Byte(v) => i32::from(*v),
Tag::Short(v) => i32::from(*v),
+127 -40
View File
@@ -2,7 +2,9 @@
//! small mixer standing in for OpenAL: one channel per source with its gain,
//! pitch, linear distance attenuation (`AL_LINEAR_DISTANCE`, reference 0,
//! rolloff 1), looping, relative flag and source state, Ogg/Vorbis decoding
//! (streams on a worker thread) and OpenAL Soft's stereo panning.
//! (streams on a worker thread) and OpenAL Soft's mixing: mono sources panned
//! and attenuated, multi-channel buffers (stereo music and records) played
//! direct as OpenAL does.
use crate::Result;
use crate::sound_engine::{
Attenuation, Backend, Bound, ChannelId, InstanceId, PlayResult, SoundEngine, SoundInstance,
@@ -29,7 +31,7 @@ enum State {
}
/// An OpenAL source.
struct Channel {
samples: Option<(Arc<Vec<f32>>, u32)>,
samples: Option<(Arc<Vec<f32>>, u32, u16)>,
position: f64,
state: State,
gain: f32,
@@ -135,8 +137,8 @@ impl Drop for MixerBackend {
static SHARED_CHANNELS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
/// Two players' copies of one sound: started within this of each other.
const SAME_SOUND: std::time::Duration = std::time::Duration::from_millis(150);
/// Decoded samples and their rate (`None`: the file did not load).
type Samples = Option<(Arc<Vec<f32>>, u32)>;
/// Decoded interleaved samples, their rate and channel count (`None`: the file did not load).
type Samples = Option<(Arc<Vec<f32>>, u32, u16)>;
/// Decoded static sounds (`SoundBufferLibrary`'s buffers), dropped least
/// recently used first past `CACHE_BYTES`: kept forever, every sound heard in
/// a session stayed (up to 1.4 GiB of samples, villages playing hundreds).
@@ -177,7 +179,7 @@ impl SoundCache {
fn size(samples: &Samples) -> usize {
samples
.as_ref()
.map_or(0, |(s, _)| s.len() * std::mem::size_of::<f32>())
.map_or(0, |(s, _, _)| s.len() * std::mem::size_of::<f32>())
}
}
/// A static sound to decode for a channel waiting on it.
@@ -260,7 +262,7 @@ pub struct Audio {
#[cfg(feature = "audio")]
device_ticks: u32,
}
fn decode(path: &Path) -> Option<(Vec<f32>, u32)> {
fn decode(path: &Path) -> Option<(Vec<f32>, u32, u16)> {
use symphonia::core::{
audio::SampleBuffer, codecs::DecoderOptions, formats::FormatOptions, io::MediaSourceStream,
meta::MetadataOptions, probe::Hint,
@@ -284,7 +286,8 @@ fn decode(path: &Path) -> Option<(Vec<f32>, u32)> {
let mut decoder = symphonia::default::get_codecs()
.make(&track.codec_params, &DecoderOptions::default())
.ok()?;
let mut mono = Vec::new();
let mut samples = Vec::new();
let mut channels = 0u16;
while let Ok(packet) = format.next_packet() {
if packet.track_id() != id {
continue;
@@ -293,14 +296,34 @@ fn decode(path: &Path) -> Option<(Vec<f32>, u32)> {
continue;
};
let spec = *buffer.spec();
let channels = spec.channels.count().max(1);
let mut samples = SampleBuffer::<f32>::new(buffer.capacity() as u64, spec);
samples.copy_interleaved_ref(buffer);
for frame in samples.samples().chunks(channels) {
mono.push(frame.iter().sum::<f32>() / channels as f32);
let frame_channels = spec.channels.count().max(1);
let mut decoded = SampleBuffer::<f32>::new(buffer.capacity() as u64, spec);
decoded.copy_interleaved_ref(buffer);
if channels == 0 {
channels = frame_channels.min(2) as u16;
}
if frame_channels <= 2 {
samples.extend_from_slice(decoded.samples());
} else {
// OpenAL plays multi-channel buffers direct; a stereo output hears the odd/even split.
for frame in decoded.samples().chunks(frame_channels) {
let (mut left, mut right) = (0.0, 0.0);
let (mut lefts, mut rights) = (0.0f32, 0.0f32);
for (i, sample) in frame.iter().enumerate() {
if i % 2 == 0 {
left += sample;
lefts += 1.0;
} else {
right += sample;
rights += 1.0;
}
}
samples.push(left / lefts.max(1.0));
samples.push(right / rights.max(1.0));
}
}
}
Some((mono, rate))
(channels != 0).then_some((samples, rate, channels))
}
/// A sound file without its variant number (`mob/creeper/say2.ogg` is `mob/creeper/say`): each
/// player's game picks its own variant of the same event.
@@ -427,7 +450,7 @@ impl Backend for MixerBackend {
// A streamed sound loops by restarting its stream.
let mixer = self.mixer.clone();
std::thread::spawn(move || {
let samples = decode(&file).map(|(s, r)| (Arc::new(s), r));
let samples = decode(&file).map(|(s, r, c)| (Arc::new(s), r, c));
attach(&mixer, channel, samples, looping);
});
return;
@@ -448,7 +471,7 @@ impl Backend for MixerBackend {
// Queued twice before the first decode finished.
let cached = cache.lock().ok().and_then(|mut c| c.get(&job.key));
let samples = cached.unwrap_or_else(|| {
let samples = decode(&job.file).map(|(s, r)| (Arc::new(s), r));
let samples = decode(&job.file).map(|(s, r, c)| (Arc::new(s), r, c));
if let Ok(mut c) = cache.lock() {
c.insert(job.key, samples.clone());
}
@@ -584,9 +607,13 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
let mut voices = std::mem::take(&mut mixer.voices);
voices.clear();
for (id, c) in &mixer.channels {
let Some((_, source_rate)) = &c.samples else {
let Some((_, source_rate, source_channels)) = &c.samples else {
continue;
};
// OpenAL plays a multi-channel buffer direct: no panning and no distance attenuation
// (`CalcVoiceParams`' non-spatialized path, vanilla's HRTF-off stereo context). Vanilla's
// music and records are stereo. A mono buffer is the positional one.
let direct = *source_channels > 1;
if c.state != State::Playing {
continue;
}
@@ -611,24 +638,33 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
let d = if c.relative { at } else { away(listener) };
let distance = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt() as f32;
let gain = if camera.is_some() {
c.gain.clamp(0.0, 1.0) * distance_gain(c.linear, distance)
c.gain.clamp(0.0, 1.0)
* if direct {
1.0
} else {
distance_gain(c.linear, distance)
}
} else {
0.0
};
// Relative sources are already in OpenAL's listener space (-Z ahead).
let [mut left, mut right] = if c.relative {
stereo_gains(d[0] as f32, d[1] as f32, -d[2] as f32)
let [left, right] = if direct {
[gain, gain]
} else {
stereo_gains(dot(d, right), dot(d, up), dot(d, forward))
// Relative sources are already in OpenAL's listener space (-Z ahead).
let [mut left, mut right] = if c.relative {
stereo_gains(d[0] as f32, d[1] as f32, -d[2] as f32)
} else {
stereo_gains(dot(d, right), dot(d, up), dot(d, forward))
};
if shared {
// Several players on the speakers: half the stereo width, as left and right are
// nobody's in particular.
let middle = (left + right) / 2.0;
left = middle + (left - middle) / 2.0;
right = middle + (right - middle) / 2.0;
}
[left * gain, right * gain]
};
if shared {
// Several players on the speakers: half the stereo width, as left and right are
// nobody's in particular.
let middle = (left + right) / 2.0;
left = middle + (left - middle) / 2.0;
right = middle + (right - middle) / 2.0;
}
let [left, right] = [left, right].map(|pan| gain * pan);
let step = f64::from(*source_rate) / f64::from(rate) * f64::from(c.pitch.max(0.0));
voices.push((*id, [left, right, gain], step));
}
@@ -636,13 +672,15 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
let Some(c) = mixer.channels.get_mut(&id) else {
continue;
};
let Some((samples, _)) = c.samples.clone() else {
let Some((samples, _, source_channels)) = c.samples.clone() else {
continue;
};
let source_channels = source_channels as usize;
let frames = samples.len() / source_channels;
for frame in data.chunks_mut(channels) {
let mut i = c.position as usize;
if i + 1 >= samples.len() {
if c.looping && samples.len() > 1 {
if i + 1 >= frames {
if c.looping && frames > 1 {
c.position = 0.0;
i = 0;
} else {
@@ -651,13 +689,24 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
}
}
let t = (c.position - i as f64) as f32;
let s = samples[i] * (1.0 - t) + samples[i + 1] * t;
if channels >= 2 {
frame[0] += s * left;
frame[1] += s * right;
if source_channels > 1 {
let l = samples[i * 2] * (1.0 - t) + samples[(i + 1) * 2] * t;
let r = samples[i * 2 + 1] * (1.0 - t) + samples[(i + 1) * 2 + 1] * t;
if channels >= 2 {
frame[0] += l * left;
frame[1] += r * right;
} else {
frame[0] += (l + r) * 0.5 * gain;
}
} else {
// A mono output has no panning: the source's own gain.
frame[0] += s * gain;
let s = samples[i] * (1.0 - t) + samples[i + 1] * t;
if channels >= 2 {
frame[0] += s * left;
frame[1] += s * right;
} else {
// A mono output has no panning: the source's own gain.
frame[0] += s * gain;
}
}
c.position += step;
}
@@ -1687,7 +1736,7 @@ mod tests {
fn channel(samples: Vec<f32>, linear: Option<f32>, at: [f32; 3], looping: bool) -> Channel {
Channel {
samples: Some((Arc::new(samples), 48_000)),
samples: Some((Arc::new(samples), 48_000, 1)),
position: 0.0,
state: State::Playing,
gain: 1.0,
@@ -1702,11 +1751,23 @@ mod tests {
}
}
/// A channel whose buffer is interleaved stereo.
fn stereo_channel(
samples: Vec<f32>,
linear: Option<f32>,
at: [f32; 3],
looping: bool,
) -> Channel {
let mut c = channel(Vec::new(), linear, at, looping);
c.samples = Some((Arc::new(samples), 48_000, 2));
c
}
#[test]
fn decoded_sounds_stay_within_the_budget_least_recently_used_out() {
let mut cache = SoundCache::default();
let quarter = CACHE_BYTES / 4 / std::mem::size_of::<f32>();
let sound = || Some((Arc::new(vec![0.0f32; quarter]), 44100));
let sound = || Some((Arc::new(vec![0.0f32; quarter]), 44100, 1));
for key in ["a", "b", "c", "d"] {
cache.insert(key.into(), sound());
}
@@ -1833,6 +1894,32 @@ mod tests {
}
}
#[test]
fn stereo_sounds_play_direct_without_panning_or_distance() {
// OpenAL Soft plays a multi-channel buffer direct (its non-spatialized path), so vanilla's
// stereo music and records keep their own left/right mix at full volume whichever way the
// camera faces and however far it is. Only mono buffers are panned and attenuated.
let wave = [0.1, -0.2, 0.3, -0.4, 0.5, -0.6]; // three stereo frames
let expected = [wave[0], wave[1], wave[2], wave[3]];
for (yaw, at, linear, listener) in [
(0.0, [100.0, 0.0, 0.0], Some(16.0), [0.0; 3]),
(180.0, [0.0, 0.0, -100.0], Some(16.0), [0.0; 3]),
(90.0, [2.0, 1.0, -3.0], Some(16.0), [5.0, 60.0, 5.0]),
(0.0, [0.0, 0.0, 0.0], None, [0.0; 3]),
] {
let mut m = Mixer {
listener,
listener_yaw: yaw,
..Mixer::default()
};
m.channels
.insert(1, stereo_channel(wave.to_vec(), linear, at, false));
let mut data = [0.0; 4];
mix(&mut m, &mut data, 2, 48_000);
assert_eq!(data, expected, "yaw {yaw}, source {at:?}");
}
}
#[test]
fn panning_follows_the_camera_rotation() {
let gains = |yaw: f32, pitch: f32, at: [f32; 3]| {
@@ -896,6 +896,13 @@ pub fn worldgen_overrides(
let mut warnings = Vec::new();
for pack in packs {
for file in pack.list_kinds(&overrides::PACK_KINDS) {
if let Some(id) = overrides::template_id(&file) {
match pack.read(&file) {
Some(nbt) => out.set_template(&id, nbt),
None => warnings.push(format!("{}: {file}: unreadable", pack.id)),
}
continue;
}
let read = || String::from_utf8(pack.read(&file)?).ok();
match out.add_file(&pack.id, &file, read) {
PackFile::Read | PackFile::Other => {}
@@ -956,6 +963,7 @@ mod tests {
for pack in ["a", "b", "off"] {
write(pack, "pack.mcmeta", "{}");
write(pack, "data/minecraft/worldgen/noise/cave.json", pack);
write(pack, "data/p/structure/room/a.nbt", pack);
write(
pack,
"data/minecraft/tags/worldgen/biome/is_hill.json",
@@ -992,6 +1000,8 @@ mod tests {
o.entries("noises").collect::<Vec<_>>(),
[("minecraft:cave", "a")]
);
assert_eq!(o.templates().len(), 1);
assert_eq!(&*o.templates()["p:room/a"], b"a");
assert_eq!(
o.entries("biomes").collect::<Vec<_>>(),
[("minecraft:plains", "{}")]
+2 -8
View File
@@ -6634,17 +6634,11 @@ impl Renderer {
if let Some(atlas) = &mut atlas {
atlas.separate_ao = self.separate_ao;
}
self.pack_material_atlases = atlas.as_ref().and_then(|_| {
self.pack_material_atlases = atlas.as_ref().and_then(|colour| {
let maps = self.pbr_maps.as_ref()?;
let make = |kind: usize, encoder: &mut wgpu::CommandEncoder| {
let arrays: Vec<_> = maps.iter().map(|pair| pair[kind].clone()).collect();
pack_atlas::PackAtlas::material(
&self.device,
encoder,
&arrays,
self.sprite_split,
layers,
)
colour.material_for(&self.device, encoder, &arrays, layers)
};
Some([make(0, &mut encoder)?, make(1, &mut encoder)?])
});
+1 -1
View File
@@ -220,7 +220,7 @@ impl Cull {
/// write storage.
pub fn supported(adapter: &wgpu::Adapter, device: &wgpu::Device) -> Option<DepthTest> {
let depth_test = match adapter.get_info().backend {
wgpu::Backend::Vulkan
wgpu::Backend::Vulkan | wgpu::Backend::Gl
if device
.features()
.contains(wgpu::Features::SHADER_EARLY_DEPTH_TEST) =>
@@ -30,6 +30,7 @@ struct Stitched {
/// Each layer's sprite at the first level: its top left corner and its side.
rects: Vec<[u32; 3]>,
padding: u32,
material: bool,
pipeline: wgpu::RenderPipeline,
/// One per sprite array.
groups: Vec<wgpu::BindGroup>,
@@ -66,10 +67,19 @@ fn vs(@builtin(vertex_index) v: u32, @location(0) rect: vec4<i32>, @location(1)
out.source = source.xy;
return out;
}
fn sprite_texel(in: Out) -> vec2<i32> {
return clamp(vec2<i32>(floor(in.position.xy)) - in.rect.xy, vec2(0), vec2(in.rect.z - 1));
}
@fragment
fn fs(in: Out) -> @location(0) vec4<f32> {
let texel = clamp(vec2<i32>(floor(in.position.xy)) - in.rect.xy, vec2(0), vec2(in.rect.z - 1));
return textureLoad(sprites, texel, i32(in.source.x), i32(in.source.y));
return textureLoad(sprites, sprite_texel(in), i32(in.source.x), i32(in.source.y));
}
@fragment
fn fs_material(in: Out) -> @location(0) vec4<f32> {
let texel = sprite_texel(in);
let edge = i32(textureDimensions(sprites, 0).x);
let sampled = min((texel * 2 + 1) * edge / (2 * in.rect.z), vec2(edge - 1));
return textureLoad(sprites, sampled, i32(in.source.x), i32(in.source.y));
}
"#;
@@ -210,14 +220,72 @@ impl PackAtlas {
stitch_mip_level(&sides, mip_option).min(array_levels.saturating_sub(deepest + 1));
let max = device.limits().max_texture_dimension_2d;
let layout = stitch::stitch(&sides, mip, anisotropy_bit, max)?;
let rects = layout
.holders
.iter()
.zip(&sides)
.map(|(&[x, y], &side)| [x + layout.padding, y + layout.padding, side])
.collect();
Some(Self::with_rects(
device,
encoder,
arrays,
split,
sprite,
[layout.width.max(1), layout.height.max(1)],
rects,
layout.padding,
mip + 1,
false,
))
}
/// Companion maps use the colour atlas's coordinates, not a second packing.
pub fn material_for(
&self,
device: &wgpu::Device,
encoder: &mut wgpu::CommandEncoder,
arrays: &[wgpu::Texture],
layers: u32,
) -> Option<Self> {
let Some(stitched) = &self.stitched else {
return Self::material(device, encoder, arrays, self.split, layers);
};
Some(Self::with_rects(
device,
encoder,
arrays,
self.split,
arrays[0].width(),
self.size(),
stitched.rects.clone(),
stitched.padding,
1,
true,
))
}
#[allow(clippy::too_many_arguments)]
fn with_rects(
device: &wgpu::Device,
encoder: &mut wgpu::CommandEncoder,
arrays: &[wgpu::Texture],
split: u32,
sprite: u32,
size: [u32; 2],
rects: Vec<[u32; 3]>,
padding: u32,
levels: u32,
material: bool,
) -> Self {
let texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some("Shader pack block atlas"),
size: wgpu::Extent3d {
width: layout.width.max(1),
height: layout.height.max(1),
width: size[0],
height: size[1],
depth_or_array_layers: 1,
},
mip_level_count: mip + 1,
mip_level_count: levels,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba8Unorm,
@@ -268,7 +336,7 @@ impl PackAtlas {
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &module,
entry_point: Some("fs"),
entry_point: Some(if material { "fs_material" } else { "fs" }),
compilation_options: Default::default(),
targets: &[Some(wgpu::TextureFormat::Rgba8Unorm.into())],
}),
@@ -293,12 +361,6 @@ impl PackAtlas {
})
})
.collect();
let rects = layout
.holders
.iter()
.zip(&sides)
.map(|(&[x, y], &side)| [x + layout.padding, y + layout.padding, side])
.collect::<Vec<_>>();
let layers = rects.len() as u32;
let atlas = Self {
texture,
@@ -309,13 +371,14 @@ impl PackAtlas {
separate_ao: false,
stitched: Some(Stitched {
rects,
padding: layout.padding,
padding,
material,
pipeline,
groups,
}),
};
atlas.copy(device, encoder, arrays, 0..layers);
Some(atlas)
atlas
}
/// Whether the sprites keep their own sizes ([`PackAtlas::stitched`]).
@@ -425,7 +488,11 @@ impl PackAtlas {
rect: [x, y, side, stitched.padding].map(|v| (v >> level) as i32),
source: [
layer % self.split,
(self.sprite / side).ilog2() + level,
if stitched.material {
0
} else {
(self.sprite / side).ilog2() + level
},
size[0],
size[1],
],
@@ -563,7 +630,7 @@ impl PackAtlas {
"layout(set = 1, binding = 0) readonly buffer HzBlockSlots { uint hz_block_slots[]; };\n\
layout(set = 1, binding = 1) readonly buffer HzSlotOrigins { vec4 hz_slot_origins[]; };\n",
);
// Of each sprite layer, `mc_Entity` (id, then 1 for fluids or -1) and
// Of each sprite layer, `mc_Entity` (id, then 1 for fluids or 0) and
// `at_midBlock.w` (its block's light), the game's table; and the arena page's
// vertices (`TerrainVertex`, six words each).
declarations.push_str(
@@ -641,7 +708,7 @@ impl PackAtlas {
let entity = if flat {
"vec4(float(hz_block_ids[3u * hz_layer]), float(hz_block_ids[3u * hz_layer + 1u]), 0.0, 0.0)"
} else {
"vec4(-1.0, -1.0, 0.0, 0.0)"
"vec4(-1.0, 0.0, 0.0, 0.0)"
};
// The block a face belongs to: half a block behind its centre (the quad's diagonal);
// w, the light it gives (Iris's `BLOCK_EMISSION_ATTRIBUTE`, 0 to 15).
@@ -894,6 +961,144 @@ mod tests {
assert_eq!([at(9, 5), at(8, 4), at(10, 6)], [[100, 0, 0, 255]; 3]);
}
fn material_fixture(animated: bool) {
let instance = crate::gfx::test_gpu::instance();
// No adapter (a CI runner without a GPU): skip, as the module's other readback tests do.
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
return;
};
let (device, queue) =
pollster::block_on(adapter.request_device(&Default::default())).unwrap();
let colour_array = upscaled_array(&device, &queue, &[2, 4, 1]);
let mut encoder = device.create_command_encoder(&Default::default());
let colour = PackAtlas::stitched(
&device,
&mut encoder,
&[colour_array],
3,
4,
&[2, 4, 1],
0,
0,
)
.unwrap();
for kind in 0..2 {
// Different source resolution, with encoded channel boundaries and defaults.
let markers = if kind == 0 {
[
[128, 126, 255, 19],
crate::sprites::FLAT_NORMAL,
[130, 125, 254, 21],
]
} else {
[
[17, 240, 65, 255],
[18, 0, 66, 0],
crate::sprites::NO_SPECULAR,
]
};
let pixels: Vec<_> = markers.iter().flat_map(|m| m.repeat(4)).collect();
let array = wgpu::util::DeviceExt::create_texture_with_data(
&device,
&queue,
&wgpu::TextureDescriptor {
label: None,
size: wgpu::Extent3d {
width: 2,
height: 2,
depth_or_array_layers: 3,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba8Unorm,
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
view_formats: &[],
},
wgpu::util::TextureDataOrder::LayerMajor,
&pixels,
);
let arrays = [array];
let material = colour
.material_for(&device, &mut encoder, &arrays, 3)
.unwrap();
assert_eq!(material.size(), [16, 8]);
assert_eq!(material.rects_table(), colour.rects_table());
assert_eq!(material.texture.mip_level_count(), 1);
queue.submit([encoder.finish()]);
encoder = device.create_command_encoder(&Default::default());
let changed = [243, 1, 229, 0];
if animated {
// Alter every source layer, but copy only the animated layer.
queue.write_texture(
arrays[0].as_image_copy(),
&changed.repeat(12),
wgpu::TexelCopyBufferLayout {
bytes_per_row: Some(8),
rows_per_image: Some(2),
offset: 0,
},
arrays[0].size(),
);
material.copy(&device, &mut encoder, &arrays, [0]);
}
let read = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: 256 * 8,
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
mapped_at_creation: false,
});
encoder.copy_texture_to_buffer(
material.texture.as_image_copy(),
wgpu::TexelCopyBufferInfo {
buffer: &read,
layout: wgpu::TexelCopyBufferLayout {
bytes_per_row: Some(256),
..Default::default()
},
},
material.texture.size(),
);
queue.submit([encoder.finish()]);
encoder = device.create_command_encoder(&Default::default());
read.slice(..).map_async(wgpu::MapMode::Read, |_| {});
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
let data = read.slice(..).get_mapped_range().unwrap();
// Independent, hand-worked colour rectangles, including all padding pixels.
for (layer, [x, y, side]) in [[9usize, 1, 2], [1, 1, 4], [9, 5, 1]]
.into_iter()
.enumerate()
{
let expected = if animated && layer == 0 {
changed
} else {
markers[layer]
};
for py in y - 1..=y + side {
for px in x - 1..=x + side {
let at = py * 256 + px * 4;
assert_eq!(
&data[at..at + 4],
&expected,
"kind={kind}, layer={layer}, ({px},{py})"
);
}
}
}
}
queue.submit([encoder.finish()]);
}
#[test]
fn stitched_material_maps_share_colour_rectangles() {
material_fixture(false);
}
#[test]
fn animated_stitched_material_updates_only_its_rectangle() {
material_fixture(true);
}
#[test]
fn terrain_inputs_respects_separate_ao() {
let instance = crate::gfx::test_gpu::instance();
@@ -40,6 +40,9 @@ mod higher_shadowcolor_tests;
#[cfg(test)]
mod single_side_tests;
#[cfg(test)]
mod undrawn_terrain_tests;
#[cfg(test)]
mod shadow_faces_tests;
@@ -55,6 +58,9 @@ mod custom_type_tests;
#[cfg(test)]
mod sky_depth_tests;
#[cfg(test)]
mod translucent_color_tests;
/// The texture behind a pack sampler name.
#[derive(Clone, Copy, PartialEq)]
enum Source {
@@ -477,7 +483,7 @@ pub(super) struct World<'a> {
pub lightmap: wgpu::TextureView,
/// Terrain's geometry, when the pack draws it.
pub terrain: Option<Geometry<'a>>,
/// Of each block sprite layer, `mc_Entity` (id, then 1 for fluids or -1) and `at_midBlock.w`
/// Of each block sprite layer, `mc_Entity` (id, then 1 for fluids or 0) and `at_midBlock.w`
/// (its block's light): three values a layer.
pub block_ids: &'a [i32],
/// The atlas of the sun and moon's sprites.
@@ -555,6 +561,10 @@ pub struct PackPost {
terrain_cutout: Option<Pass>,
/// What draws translucent terrain (`gbuffers_water` or its fallback), after `deferred`.
water: Option<Pass>,
/// The pack has no program for solid and cutout terrain, or for translucent terrain:
/// nothing draws it (`horizon_shaders::Post::terrain_undrawn`).
terrain_undrawn: bool,
water_undrawn: bool,
/// What draws the sky (`gbuffers_skybasic` or its fallback), before terrain, and its vertices.
sky: Option<Sky>,
/// What draws entities and block entities, after terrain, and their vertices.
@@ -1273,8 +1283,9 @@ fn bindings(
let mut reads = 0u16;
let mut shadow_reads = 0u16;
let watershadow = interface.samplers.iter().any(|s| s.name == "watershadow");
// A gbuffers pass draws into its buffers and the depth buffer: reading them as well would
// be a feedback loop (undefined in GL, refused by wgpu), so those read a blank texture.
// Opaque gbuffers draws cannot sample their own attachments. Translucent draws can
// read colortex4 and above from the preceding deferred stage; those reads use a copy
// taken before the draw, so the sampled texture is never also an attachment.
let in_place = match geometry {
Some(_) => buffers.iter().fold(0u16, |m, &b| m | 1 << b),
None => 0,
@@ -1384,7 +1395,9 @@ fn bindings(
Source::Depth => geometry.is_some(),
// Translucent terrain reads the copy; opaque terrain would read itself.
Source::Depth1 | Source::Depth2 => group == Group::Gbuffers,
Source::Color(n) => in_place & 1 << n != 0,
Source::Color(n) => {
(group != Group::Translucent || n < 4) && in_place & 1 << n != 0
}
_ => false,
};
let mut sample_type = match src {
@@ -1865,7 +1878,13 @@ fn build_pass(
ids: geometry.is_some_and(|g| g.ids.is_some()),
groups: HashMap::new(),
portal_groups: HashMap::new(),
copies: image_buffers(&images) & reads,
copies: (image_buffers(&images)
| if p.group == Group::Translucent {
written
} else {
0
})
& reads,
reads: reads | image_buffers(&images),
buffers: !interface.buffers.is_empty(),
images,
@@ -2288,6 +2307,7 @@ impl PackPost {
cull: g.cull,
});
let split = post.terrain.is_some() && post.terrain_cutout.is_some();
let (terrain_undrawn, water_undrawn) = (post.terrain_undrawn, post.water_undrawn);
let mut terrain = post
.terrain
.zip(terrain_geometry.as_ref())
@@ -2834,6 +2854,8 @@ impl PackPost {
terrain,
terrain_cutout,
water,
terrain_undrawn,
water_undrawn,
sky,
entities,
clouds,
@@ -4061,9 +4083,9 @@ impl PackPost {
})
}
/// Whether the pack draws terrain itself.
/// Whether the pack draws terrain itself, or has nothing draw it.
pub fn has_terrain(&self) -> bool {
self.terrain.is_some() || self.terrain_cutout.is_some()
self.terrain.is_some() || self.terrain_cutout.is_some() || self.terrain_undrawn
}
/// Whether the pack's shadow pass draws terrain (`shadow_view`).
@@ -4091,7 +4113,7 @@ impl PackPost {
/// Whether the pack draws translucent terrain itself.
pub fn has_water(&self) -> bool {
self.water.is_some()
self.water.is_some() || self.water_undrawn
}
/// The names of the programs that run, in order.
@@ -0,0 +1,152 @@
//! A translucent draw reads the preceding scene even when it writes the same color buffer.
//! Original programs and independently calculated colors; no shader-pack assets.
use super::depth_test::{Gpu, SIZE, VERTEX, fixture_frame};
use super::*;
use crate::hud::HudBatch;
use wgpu::{TextureFormat as T, util::DeviceExt};
#[test]
fn translucent_draws_sample_the_color_written_by_deferred() {
let Some(gpu) = Gpu::new() else { return };
let positions: [[f32; 3]; 6] = [
[-1.0, -1.0, 0.0],
[1.0, -1.0, 0.0],
[1.0, 1.0, 0.0],
[-1.0, -1.0, 0.0],
[1.0, 1.0, 0.0],
[-1.0, 1.0, 0.0],
];
let vertices = gpu
.device
.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("Original translucent quad"),
contents: bytemuck::cast_slice(&positions),
usage: wgpu::BufferUsages::VERTEX,
});
const ATTRS: [wgpu::VertexAttribute; 1] = wgpu::vertex_attr_array![0=>Float32x3];
let layouts = [Some(wgpu::VertexBufferLayout {
array_stride: 12,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &ATTRS,
})];
// Odd and even numbers of deferred passes leave the current scene on different sides.
// Exercise two buffer numbers and the legacy sampler aliases too.
for (buffer, sampler) in [(4, "colortex4"), (7, "gaux4")] {
for stages in [1, 2] {
for blended in [false, true] {
let mut files = vec![
("deferred.vsh".into(), VERTEX.into()),
(
"deferred.fsh".into(),
format!(
"#version 120\n/* RENDERTARGETS: {buffer} */\nvoid main(){{gl_FragData[0]=vec4(0.25,0.5,0.75,1.0);}}"
),
),
(
"gbuffers_water.vsh".into(),
"#version 120\nvoid main(){gl_Position=gl_Vertex;}".into(),
),
(
"gbuffers_water.fsh".into(),
format!(
"#version 120\n/* RENDERTARGETS: {buffer} */\nuniform sampler2D {sampler};\nvoid main(){{gl_FragData[0]=vec4(texture2D({sampler},vec2(0.5)).rgb+vec3(0.125,0.0,0.0),0.5);}}"
),
),
("final.vsh".into(), VERTEX.into()),
(
"final.fsh".into(),
format!(
"#version 120\nuniform sampler2D colortex{buffer};varying vec2 uv;\nvoid main(){{gl_FragColor=vec4(texture2D(colortex{buffer},uv).rgb,1.0);}}"
),
),
(
"shaders.properties".into(),
format!(
"blend.gbuffers_water={}\n",
if blended {
"SRC_ALPHA ONE_MINUS_SRC_ALPHA ONE ONE_MINUS_SRC_ALPHA"
} else {
"off"
}
),
),
];
if stages == 2 {
files.extend([
("deferred1.vsh".into(), VERTEX.into()),
("deferred1.fsh".into(), format!(
"#version 120\n/* RENDERTARGETS: {buffer} */\nvoid main(){{gl_FragData[0]=vec4(0.5,0.75,0.25,1.0);}}"
)),
]);
}
let color = gpu.texture(T::Rgba8Unorm);
let depth = gpu.texture(T::Depth32Float);
gpu.clear(&color, &depth, 0.0);
let mut post = gpu.load(
files,
World {
atlas: None,
material_maps: None,
lightmap: color.clone(),
terrain: Some(Geometry {
buffers: &layouts,
table: None,
depth_write: true,
atlas: None,
material_maps: false,
ids: None,
cull: false,
}),
block_ids: &[],
sprite_rects: &[],
celestial: None,
gui_size: SIZE,
clouds: None,
weather: false,
hand: false,
},
horizon_shaders::GameInputs {
terrain: Some(horizon_shaders::GbuffersInputs {
declarations: "layout(location=0)in vec3 fixture_position;\n".into(),
body: "hz_Vertex=vec4(fixture_position,1.0);\n".into(),
..Default::default()
}),
..Default::default()
},
);
assert!(post.has_water(), "{:?}", post.notes);
let mut expected = if stages == 1 {
[0.25f32, 0.5, 0.75]
} else {
[0.5, 0.75, 0.25]
};
expected[0] += if blended { 0.0625 } else { 0.125 };
let expected = expected.map(|c| (c * 255.0).round() as u8);
let hud = HudBatch::new(SIZE);
for frame in 0..2 {
let pixels = gpu.render(
&mut post,
&color,
&depth,
&fixture_frame(&hud),
&mut |rp, list, _, _| {
if list == TerrainList::Translucent {
rp.set_vertex_buffer(0, vertices.slice(..));
rp.draw(0..6, 0..1);
}
},
);
for p in pixels {
assert!(
p[..3]
.iter()
.zip(expected)
.all(|(&a, b)| a.abs_diff(b) <= 2),
"buffer {buffer}, {stages} deferred stages, blended {blended}, frame {frame}: {p:?}, expected {expected:?}"
);
}
}
}
}
}
}
@@ -0,0 +1,49 @@
//! A pack with no program for terrain draws none (#10455): Iris 1.11.7 drew no terrain, solid
//! or translucent, into any buffer or depth with probe packs of ours having no gbuffers
//! programs. The game hands the pack its terrain, so it doesn't draw it either. The programs
//! here are original and cut down.
use super::destination_tests::Fixture;
use std::io::{Cursor, Write};
const VERTEX: &str = "#version 120\nvoid main() { gl_Position = ftransform(); }\n";
const WHITE: &str =
"#version 120\n/* DRAWBUFFERS:0 */\nvoid main() { gl_FragData[0] = vec4(1.0); }\n";
/// A pack with `programs` (each drawing white into colortex0) loaded for a game that can hand it
/// terrain: whether it takes solid and translucent terrain from the game.
fn takes_terrain(programs: &[&str]) -> Option<(bool, bool)> {
let gpu = Fixture::new()?;
let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
for name in programs {
for (ext, source) in [("vsh", VERTEX), ("fsh", WHITE)] {
zip.start_file(
format!("shaders/{name}.{ext}"),
zip::write::SimpleFileOptions::default(),
)
.unwrap();
zip.write_all(source.as_bytes()).unwrap();
}
}
let pack = horizon_shaders::Pack::from_zip_bytes(
"undrawn-terrain",
zip.finish().unwrap().into_inner(),
)
.unwrap();
let mut macros = horizon_shaders::Macros::standard();
let (properties, _) = horizon_shaders::read_properties(&pack, &mut macros);
let inputs = horizon_shaders::GameInputs {
terrain: Some(Default::default()),
..Default::default()
};
let post = horizon_shaders::load_post(&pack, "", &properties, &macros, &inputs);
let post = gpu.try_build(post).ok()?;
Some((post.has_terrain(), post.has_water()))
}
#[test]
fn a_pack_without_terrain_programs_takes_terrain_from_the_game() {
let Some(taken) = takes_terrain(&["composite"]) else {
return;
};
assert_eq!(taken, (true, true));
}
@@ -2709,6 +2709,41 @@ impl OptionsUi {
}
rows
}
/// The row that reaches the Zoom mod that replaced the extra: its switch and settings page
/// when it is built in, else a note saying where it is installed (Options > Mods). Zoom
/// became an official native mod, so Extras has no setting of its own for it (#8713).
fn zoom_mod_row(s: &Settings) -> Row {
let Some(i) = s
.mods
.installed
.iter()
.position(|m| m.id == "zoom" && m.native.is_some())
else {
// A note, not a button: Options > Mods already has the one way into the Mods page
// (`every_setting_and_page_is_reached_from_one_place`).
return Row::Note(lang::get("horizon.extras.zoom_mod_install"));
};
let label = || Text::option_name_value(tr("horizon.extras.zoom"), on_off(s.mods.is_on(i)));
let toggle = |width| button(0.0, 0.0, width, label(), Act::ToggleMod(i));
let settings = s.mods.installed[i]
.native
.as_ref()
.is_some_and(|page| !page.keys.is_empty() || !page.buttons.is_empty());
if settings {
Row::Buttons(
toggle(150.0),
Some(button(
0.0,
0.0,
150.0,
tr("horizon.mods.settings"),
Act::ModSettings(i),
)),
)
} else {
Row::Buttons(toggle(310.0), None)
}
}
/// A native mod's page: its name, its keys as Key Binds rows (the
/// player's choice, else the default), then its buttons.
fn native_rows(&self, s: &Settings, i: usize) -> Vec<Row> {
@@ -2781,6 +2816,11 @@ impl OptionsUi {
// (Horizon Settings has it).
let preset = button(0.0, 0.0, 150.0, tr("horizon.pvp.preset"), Act::PvpPreset);
let mut rows = Vec::new();
if self.page() == Page::Extras {
// Zoom, the FPS counter, Keystrokes and the Minimap are official mods now;
// Extras keeps a way to the one players look for here (#8713).
rows.push(Self::zoom_mod_row(s));
}
if self.page() == Page::Pvp {
// The editor moves what the game shows: only from a world.
let edit = button(0.0, 0.0, 150.0, tr("horizon.pvp.editor"), Act::HudEditor);
@@ -6648,6 +6688,60 @@ mod tests {
);
}
#[test]
fn extras_reaches_the_zoom_mod_that_replaced_the_extra() {
let mut s = Settings::default();
let mut ui = OptionsUi::default();
ui.show(Page::Extras);
let english = || lang::Language::load(&[], "en_us");
// Without the mod, Extras says where it is installed (a note, not a second way into
// Options > Mods).
let (widgets, _, _, _) = lang::scoped(english(), || ui.layout(&s, 480.0, 4000.0)).0;
assert!(
widgets.iter().any(|w| matches!(
&w.kind,
WKind::Text { text, .. } if text.plain() == "Zoom: install from Mods"
)),
"Extras has no hint for the Zoom mod"
);
// Built in, Zoom's settings live on its page: Extras opens that page.
s.mods.installed.push(crate::mod_list::Installed {
id: "zoom".into(),
title: "Zoom 2.0.0".into(),
description: String::new(),
problem: None,
dev: false,
native: Some(crate::mod_list::NativePage {
keys: vec![["zoom".into(), "Zoom".into(), "key.keyboard.z".into()]],
buttons: Vec::new(),
}),
});
let (widgets, _, _, _) = lang::scoped(english(), || ui.layout(&s, 480.0, 4000.0)).0;
assert!(
widgets.iter().any(|w| matches!(
&w.kind,
WKind::Button {
act: Act::ModSettings(0),
label,
..
} if label.plain() == "Settings"
)),
"Extras doesn't open the Zoom mod's settings"
);
// The mod's switch is on the same row, so Extras keeps the Zoom setting it had.
assert!(
widgets.iter().any(|w| matches!(
&w.kind,
WKind::Button {
act: Act::ToggleMod(0),
label,
..
} if label.plain().starts_with("Zoom")
)),
"Extras doesn't switch the Zoom mod"
);
}
#[test]
fn exclusive_fullscreen_and_its_tooltips_follow_the_option() {
let mut s = Settings::default();
+30 -3
View File
@@ -899,9 +899,24 @@ fn merge(kind: Merge, layers: &[Vec<u8>]) -> Option<Vec<u8>> {
.iter()
// SoundManager skips a sounds.json Gson rejects, whole.
.filter(|b| kind != Merge::Sounds || crate::sound_events::is_valid_file(b))
.filter_map(|b| match serde_json::from_slice(b) {
Ok(Value::Object(m)) => Some(m),
_ => None,
.filter_map(|b| {
match serde_json::from_slice(b) {
Ok(Value::Object(m)) => Some(m),
// `Language.loadFromJson` reads with Gson's lenient reader, so
// a lang file with `#` comments still counts; a file that
// strict JSON accepts keeps its bytes as they are.
_ if kind == Merge::Lang => {
let mut strings = std::collections::HashMap::new();
horizon_client_core::lang::read_lang_file(b, &mut strings);
Some(
strings
.into_iter()
.map(|(k, v)| (k, Value::String(v)))
.collect(),
)
}
_ => None,
}
})
.collect();
let list = |key: &str, layers: &mut dyn Iterator<Item = &Map<String, Value>>| {
@@ -1424,6 +1439,18 @@ mod tests {
parse(lang.unwrap()),
serde_json::json!({"a":"vanilla a","b":"pack b"})
);
// A layer with `#` comments is read leniently, not dropped.
let lenient = merge(
Merge::Lang,
&[
json(r#"{"a":"vanilla a"}"#),
json("# a pack comment\n{\"b\":\"pack b\", # trailing\n\"a\":\"pack a\"}"),
],
);
assert_eq!(
parse(lenient.unwrap()),
serde_json::json!({"a":"pack a","b":"pack b"})
);
// SoundManager: sounds add up; `replace` starts the event over.
let sounds = merge(
Merge::Sounds,
+148 -3
View File
@@ -975,11 +975,32 @@ impl TextField {
}
}
/// `ScreenRectangle.intersection`: the part of both rectangles, or
/// `ScreenRectangle.empty()` (`0, 0, 0, 0`, which clips everything) when they
/// do not overlap.
fn intersect(a: [i32; 4], b: [i32; 4]) -> [i32; 4] {
let rect = [
a[0].max(b[0]),
a[1].max(b[1]),
a[2].min(b[2]),
a[3].min(b[3]),
];
if rect[0] < rect[2] && rect[1] < rect[3] {
rect
} else {
[0, 0, 0, 0]
}
}
/// Draws the calls with the HUD batch: sprites nine-sliced, textures from
/// the atlas (`textures/gui/x.png` is `gui/x`; tiled textures from their
/// corner), scissor clipping, and the tooltip at the mouse.
pub fn paint(batch: &mut HudBatch, atlas: &HudAtlas, draws: &[Draw]) {
let mut clip: Option<(usize, [i32; 4])> = None;
// `GuiGraphicsExtractor.ScissorStack`: a scissor clips what is drawn since
// it opened, cut to the screen while none is open and to the one around it
// otherwise; closing it puts the enclosing one back.
let screen = [0, 0, batch.width as i32, batch.height as i32];
let mut clips: Vec<(usize, [i32; 4])> = Vec::new();
let mut tooltip = None;
for d in draws {
match d {
@@ -1083,10 +1104,11 @@ pub fn paint(batch: &mut HudBatch, atlas: &HudAtlas, draws: &[Draw]) {
);
}
Draw::Scissor { x0, y0, x1, y1 } => {
clip = Some((batch.vertices.len(), [*x0, *y0, *x1, *y1]))
let outer = clips.last().map_or(screen, |&(_, rect)| rect);
clips.push((batch.vertices.len(), intersect(outer, [*x0, *y0, *x1, *y1])));
}
Draw::NoScissor => {
if let Some((start, [x0, y0, x1, y1])) = clip.take() {
if let Some((start, [x0, y0, x1, y1])) = clips.pop() {
batch.clip_since(start, x0 as f32, y0 as f32, x1 as f32, y1 as f32);
}
}
@@ -1282,3 +1304,126 @@ mod button_label_tests {
batch.vertices.len()
}
}
#[cfg(test)]
mod scissor_tests {
use super::*;
fn atlas() -> HudAtlas {
HudAtlas::build(std::path::Path::new("/nonexistent/horizon-test-assets")).unwrap()
}
/// The bounds of every quad's six vertices, in draw order.
fn quads(batch: &HudBatch) -> Vec<[f32; 4]> {
batch
.vertices
.as_chunks::<6>()
.0
.iter()
.map(|q| {
let (mut x0, mut y0) = (f32::MAX, f32::MAX);
let (mut x1, mut y1) = (f32::MIN, f32::MIN);
for v in q {
x0 = x0.min(v.pos[0]);
y0 = y0.min(v.pos[1]);
x1 = x1.max(v.pos[0]);
y1 = y1.max(v.pos[1]);
}
[x0, y0, x1, y1]
})
.collect()
}
fn fill(x0: i32, y0: i32, x1: i32, y1: i32) -> Draw {
Draw::Fill {
x0,
y0,
x1,
y1,
color: -1,
}
}
fn scissor(x0: i32, y0: i32, x1: i32, y1: i32) -> Draw {
Draw::Scissor { x0, y0, x1, y1 }
}
/// `GuiGraphicsExtractor.ScissorStack.push`: a scissor inside another is
/// intersected with it (`ScreenRectangle.intersection`), and the outer one
/// still clips what is drawn after the inner one closes.
#[test]
fn a_nested_scissor_is_intersected_with_the_one_around_it() {
let atlas = atlas();
let mut batch = HudBatch::new([320, 240]);
paint(
&mut batch,
&atlas,
&[
scissor(10, 10, 100, 100),
fill(0, 0, 200, 200),
scissor(50, 50, 300, 300),
fill(0, 0, 400, 400),
Draw::NoScissor,
fill(0, 0, 200, 200),
Draw::NoScissor,
],
);
assert_eq!(
quads(&batch),
[
[10.0, 10.0, 100.0, 100.0],
[50.0, 50.0, 100.0, 100.0],
[10.0, 10.0, 100.0, 100.0],
]
);
}
/// A scissor with no scissor around it is cut to the screen, as
/// `ScissorStack.push` intersects with the screen while the stack is empty.
#[test]
fn a_scissor_wider_than_the_screen_is_cut_to_it() {
let atlas = atlas();
let mut batch = HudBatch::new([320, 240]);
paint(
&mut batch,
&atlas,
&[
scissor(-50, -50, 500, 500),
fill(-100, -100, 600, 600),
Draw::NoScissor,
],
);
assert_eq!(quads(&batch), [[0.0, 0.0, 320.0, 240.0]]);
}
/// A scissor that misses the one around it intersects to
/// `ScreenRectangle.empty()`: nothing inside it is drawn, and the outer
/// scissor takes over again when it closes.
#[test]
fn a_scissor_outside_the_one_around_it_draws_nothing() {
let atlas = atlas();
let mut batch = HudBatch::new([320, 240]);
paint(
&mut batch,
&atlas,
&[
scissor(10, 10, 50, 50),
fill(0, 0, 200, 200),
scissor(60, 60, 90, 90),
fill(0, 0, 200, 200),
Draw::NoScissor,
fill(0, 0, 200, 200),
Draw::NoScissor,
fill(0, 0, 200, 200),
],
);
assert_eq!(
quads(&batch),
[
[10.0, 10.0, 50.0, 50.0],
[10.0, 10.0, 50.0, 50.0],
[0.0, 0.0, 200.0, 200.0],
]
);
}
}
+171 -15
View File
@@ -237,6 +237,7 @@ pub struct Resolver {
done: std::sync::mpsc::Receiver<(NameOrId, Option<ResolvedProfile>)>,
send: std::sync::mpsc::Sender<(NameOrId, Option<ResolvedProfile>)>,
last_sweep: std::time::Instant,
heads: HeadProfiles,
}
struct Entry {
touched: std::time::Instant,
@@ -304,6 +305,7 @@ impl Resolver {
done,
send,
last_sweep: std::time::Instant::now(),
heads: HeadProfiles::default(),
}
}
/// `CacheBuilder.expireAfterAccess` with `maximumSize`.
@@ -465,6 +467,64 @@ pub fn dynamic_nbt(profile: &horizon_world::nbt::Tag) -> Option<NameOrId> {
}
}
const MAX_HEAD_PROFILES: usize = 4096;
#[derive(Debug, PartialEq)]
struct HeadProfile {
info: ProfileInfo,
skin: Option<(String, String)>,
}
fn prepare_head(profile: &serde_json::Value) -> HeadProfile {
let info = parse_profile(profile);
let skin = info.textures.as_ref().and_then(|(value, signature)| {
let url = unpack(value, *signature, &TEXTURE_URIS).skin?;
let location = skin_location(&texture_hash(&url)?);
Some((url, location))
});
HeadProfile { info, skin }
}
#[derive(Default)]
struct HeadProfiles {
entries: std::collections::HashMap<serde_json::Value, std::sync::Arc<HeadProfile>>,
nbt: std::collections::HashMap<horizon_world::nbt::Tag, std::sync::Arc<serde_json::Value>>,
resolved: [std::collections::HashMap<String, std::sync::Arc<HeadProfile>>; 2],
}
impl HeadProfiles {
fn get(&mut self, profile: &serde_json::Value) -> std::sync::Arc<HeadProfile> {
if let Some(parsed) = self.entries.get(profile) {
return parsed.clone();
}
if self.entries.len() >= MAX_HEAD_PROFILES {
self.entries.clear();
}
let parsed = std::sync::Arc::new(prepare_head(profile));
self.entries.insert(profile.clone(), parsed.clone());
parsed
}
}
impl Resolver {
/// Cache by NBT value rather than address: equipment can be replaced or
/// mutated in place, and neither must leave a stale profile behind.
pub fn head_profile_nbt(
&mut self,
profile: &horizon_world::nbt::Tag,
) -> std::sync::Arc<serde_json::Value> {
if let Some(json) = self.heads.nbt.get(profile) {
return json.clone();
}
if self.heads.nbt.len() >= MAX_HEAD_PROFILES {
self.heads.nbt.clear();
}
let json = std::sync::Arc::new(crate::scoreboard::tag_to_json(profile));
self.heads.nbt.insert(profile.clone(), json.clone());
json
}
}
/// `PlayerSkinRenderCache.getOrDefault` for a head: the resolved skin once
/// its texture is in the atlas (`atlas`), else `partialProfile`'s default.
pub fn head_skin(
@@ -478,32 +538,54 @@ pub fn head_skin(
{
return sprite;
}
let p = parse_profile(profile);
let property = p.textures.or_else(|| {
let key = p.dynamic.as_ref()?;
let p = resolver.heads.get(profile);
// Dynamic resolution is deliberately not cached: pending answers and the
// resolver's expiring service results must still advance on every draw.
let resolved = p.info.dynamic.as_ref().and_then(|key| {
if p.info.textures.is_some() {
return None;
}
match resolver.state(key) {
Resolve::Done(Some(profile)) => profile.textures(),
Resolve::Done(Some(profile)) => {
let (_, value, signature) = profile
.properties
.iter()
.find(|(n, _, _)| n == "textures")?;
let cache = &mut resolver.heads.resolved[usize::from(signature.is_some())];
if let Some(parsed) = cache.get(value) {
return Some(parsed.clone());
}
if cache.len() >= MAX_HEAD_PROFILES {
cache.clear();
}
let property = if signature.is_some() {
serde_json::json!({"name": "textures", "value": value, "signature": ""})
} else {
serde_json::json!({"name": "textures", "value": value})
};
let parsed = std::sync::Arc::new(prepare_head(
&serde_json::json!({"properties": [property]}),
));
cache.insert(value.clone(), parsed.clone());
Some(parsed)
}
_ => None,
}
});
if let Some((value, signature)) = property
&& let Some(url) = unpack(&value, signature, &TEXTURE_URIS).skin
&& let Some(hash) = texture_hash(&url)
{
let location = skin_location(&hash);
match downloads.state(Kind::Skin, &url) {
Download::Ready if atlas(&location) => {
downloads.drawn(&location);
return location;
if let Some((url, location)) = resolved.as_deref().unwrap_or(&p).skin.as_ref() {
match downloads.state(Kind::Skin, url) {
Download::Ready if atlas(location) => {
downloads.drawn(location);
return location.clone();
}
// Its slot was evicted: offer the image again.
Download::Ready => {
downloads.again(&location);
downloads.again(location);
}
_ => {}
}
}
crate::tab_list::default_skin(p.id).to_owned()
crate::tab_list::default_skin(p.info.id).to_owned()
}
/// `PlayerSkin.Patch.apply` for a head: the sprite of a profile's `texture`
@@ -1137,6 +1219,80 @@ pub fn sha1(data: &[u8]) -> [u8; 20] {
#[cfg(test)]
mod tests {
#[test]
fn head_profile_cache_matches_uncached_and_is_bounded() {
use base64::Engine;
let mut cache = super::HeadProfiles::default();
for (url, slim) in [
("https://textures.minecraft.net/texture/abc", false),
("https://textures.minecraft.net/texture/abc", true),
("https://evil.example/texture/abc", false),
] {
let payload = serde_json::json!({"textures": {
"SKIN": {"url": url, "metadata": {"model": if slim { "slim" } else { "classic" }}},
"CAPE": {"url": "http://textures.minecraft.net/texture/cape"}
}});
let value = base64::engine::general_purpose::STANDARD.encode(payload.to_string());
for signed in [false, true] {
let mut property = serde_json::json!({"name": "textures", "value": value});
if signed {
property["signature"] = serde_json::Value::Null;
}
let profile = serde_json::json!({"properties": [property]});
let parsed = cache.get(&profile);
assert_eq!(*parsed, super::prepare_head(&profile));
let textures = super::unpack(
&value,
if signed {
super::Signature::Signed
} else {
super::Signature::Unsigned
},
&super::TEXTURE_URIS,
);
assert_eq!(textures.slim, slim && !url.contains("evil"));
assert_eq!(textures.cape.is_some(), !url.contains("evil"));
assert_eq!(parsed.skin.is_some(), !url.contains("evil"));
assert!(std::sync::Arc::ptr_eq(&parsed, &cache.get(&profile)));
}
}
for profile in [
serde_json::json!({"properties": [{"name": "textures", "value": "!bad"}]}),
serde_json::json!({"name": "Notch"}),
serde_json::json!({}),
] {
let parsed = cache.get(&profile);
assert_eq!(*parsed, super::prepare_head(&profile));
assert!(parsed.skin.is_none());
assert!(std::sync::Arc::ptr_eq(&parsed, &cache.get(&profile)));
}
cache.entries.clear();
for i in 0..super::MAX_HEAD_PROFILES {
cache.get(&serde_json::json!({"id": i}));
}
assert_eq!(cache.entries.len(), super::MAX_HEAD_PROFILES);
cache.get(&serde_json::json!({}));
assert_eq!(cache.entries.len(), 1);
}
#[test]
fn head_profile_nbt_cache_tracks_value_changes() {
let mut resolver = super::Resolver::new();
let mut tag = horizon_world::nbt::Tag::String("Notch".into());
let first = resolver.head_profile_nbt(&tag);
assert_eq!(*first, crate::scoreboard::tag_to_json(&tag));
assert!(std::sync::Arc::ptr_eq(
&first,
&resolver.head_profile_nbt(&tag)
));
tag = horizon_world::nbt::Tag::String("Alex".into());
assert_eq!(*resolver.head_profile_nbt(&tag), serde_json::json!("Alex"));
for i in 0..super::MAX_HEAD_PROFILES {
resolver.head_profile_nbt(&horizon_world::nbt::Tag::Int(i as i32));
}
assert!(resolver.heads.nbt.len() < super::MAX_HEAD_PROFILES);
}
/// A profile's `texture` names a pack texture, never a file outside the
/// packs: bad characters and `..` segments draw nothing.
#[test]
+98 -36
View File
@@ -544,6 +544,12 @@ pub struct Streaming {
/// Chunks come from a server (`insert_remote`/`forget`): nothing is
/// generated and columns are only unloaded when the server says so.
pub remote: bool,
/// This server sends bulk block edits as whole columns whose light is
/// from before its relight (a Horizon server): a resent resident column
/// is lit here. Any other server's packet carries the light vanilla
/// applies, so it is not lit again (a proxy's next server resends its
/// whole world at the same coordinates).
pub relight_resent: bool,
/// More areas to keep loaded (centre chunk, radius), e.g. around the
/// players of a server. Each is a square, like vanilla's ticket levels:
/// chunks finish out to `radius + 1` (Chebyshev) and generation reaches
@@ -1108,6 +1114,7 @@ impl Streaming {
generated_total: 0,
meshed_total: 0,
remote: false,
relight_resent: false,
extra: Vec::new(),
cover: None,
mesh: true,
@@ -1154,14 +1161,15 @@ impl Streaming {
}
/// A resident column a server sent again whole (it changed it in bulk):
/// its new blocks taken, the sections that did not change kept as they
/// were, and its light and its neighbours' worked out again here, off
/// the game thread, as light is for the blocks a server changes one at a
/// time; the sections that changed remesh (with theirs) once lit. Until
/// then the sections that changed show the light the packet carried (a
/// Horizon server sends the light from before its relight, then the
/// new light; another server's is already right, which matters where
/// the neighbours this light needs aren't all here).
pub fn replace_remote(&mut self, world: &mut World, mut chunk: Chunk) {
/// were, and (for a Horizon server's bulk edit) its light and its
/// neighbours' worked out again here, off the game thread, as light is for
/// the blocks a server changes one at a time; the sections that changed
/// remesh (with theirs) once lit. Until then the sections that changed
/// show the light the packet carried (a Horizon server sends the light
/// from before its relight, then the new light; another server's is already
/// right, which matters where the neighbours this light needs aren't all
/// here).
pub fn replace_remote(&mut self, world: &mut World, chunk: Chunk) {
let key = (chunk.x, chunk.z);
let Some(old) = world.chunks.get(&key).cloned() else {
return self.insert_remote(world, chunk);
@@ -1169,34 +1177,7 @@ impl Streaming {
if !self.lit.contains(&key) {
return self.insert_remote(world, chunk);
}
// The cells whose blocks changed, by section: they remesh with the neighbours within a block of them (as
// vanilla's `setBlocksDirty`), not every section round each section changed (which, for the few blocks a
// section often changes in a bulk edit, is mostly sections that did not).
let mut cells = crate::light::LightChanges::default();
cells.keyed(world.layout());
let kept = keep_unchanged_sections(&old, &mut chunk, &mut cells);
chunk.light_loaded = old.light_loaded;
chunk.lit = true;
world.chunks.insert(key, Arc::new(chunk));
world.revision = world.revision.wrapping_add(1);
// A section with new biomes and the same blocks remeshes now (its tint), with its neighbours, whose tint
// blends across the border: above and below too, as a block's tint can sample the biome quart of the
// section next to it (`BiomeManager.getBiome`'s zoom reaches a quart beyond the block).
Self::light_updated(world, key.0, key.1, &kept.biomes);
// A section with the same blocks and new light takes the packet's light (vanilla's
// `handleLevelChunkWithLight` always applies it) and remeshes as a light update would.
Self::light_updated(world, key.0, key.1, &kept.light);
if !kept.blocks {
return;
}
for dz in -1..=1 {
for dx in -1..=1 {
world.dirty_light.insert((key.0 + dx, key.1 + dz));
}
}
cells.dirty_sections(|section| {
world.dirty_sections.insert(section);
});
take_resent_column(world, key, &old, chunk, self.relight_resent);
}
/// A server's light update for a resident column: the sections whose
/// light changed and their neighbours remesh
@@ -3046,6 +3027,51 @@ fn keep_unchanged_sections(
}
out
}
/// Takes the server's new `chunk` over the resident column `old`: the sections
/// whose blocks did not change keep `old`'s (with `chunk`'s biomes and light),
/// and the sections whose blocks changed remesh. `relight` says the packet's
/// light is from before the server's relight (a Horizon server's bulk edit), so
/// the column and its neighbours are lit here; another server's packet already
/// carries the light vanilla applies, so no light job is queued for it.
fn take_resent_column(
world: &mut World,
key: (i32, i32),
old: &Chunk,
mut chunk: Chunk,
relight: bool,
) {
// The cells whose blocks changed, by section: they remesh with the neighbours within a block of them (as
// vanilla's `setBlocksDirty`), not every section round each section changed (which, for the few blocks a
// section often changes in a bulk edit, is mostly sections that did not).
let mut cells = crate::light::LightChanges::default();
cells.keyed(world.layout());
let kept = keep_unchanged_sections(old, &mut chunk, &mut cells);
chunk.light_loaded = old.light_loaded;
chunk.lit = true;
world.chunks.insert(key, Arc::new(chunk));
world.revision = world.revision.wrapping_add(1);
// A section with new biomes and the same blocks remeshes now (its tint), with its neighbours, whose tint
// blends across the border: above and below too, as a block's tint can sample the biome quart of the
// section next to it (`BiomeManager.getBiome`'s zoom reaches a quart beyond the block).
Streaming::light_updated(world, key.0, key.1, &kept.biomes);
// A section with the same blocks and new light takes the packet's light (vanilla's
// `handleLevelChunkWithLight` always applies it) and remeshes as a light update would.
Streaming::light_updated(world, key.0, key.1, &kept.light);
if !kept.blocks {
return;
}
if relight {
for dz in -1..=1 {
for dx in -1..=1 {
world.dirty_light.insert((key.0 + dx, key.1 + dz));
}
}
}
cells.dirty_sections(|section| {
world.dirty_sections.insert(section);
});
}
/// Whether two sections' packed light (4,096 cells) holds the same values.
fn same_light(a: &crate::chunk::Light, b: &crate::chunk::Light) -> bool {
a.same_as(b)
@@ -4215,6 +4241,42 @@ mod tests {
);
}
}
fn resident_column(world: &mut World, light: u8) -> Chunk {
let mut chunk = Chunk::empty(2, -3);
chunk.set(1, 10, 1, 1);
Arc::make_mut(&mut chunk.sections[0]).light = crate::chunk::Light::Uniform(light);
chunk.lit = true;
world.chunks.insert((2, -3), Arc::new(chunk.clone()));
chunk
}
/// A server's resent column uses the light in its packet, as vanilla's
/// `handleLevelChunkWithLight` does: only a Horizon server's bulk edit,
/// whose packet light is from before its relight, is relit here. A proxy's
/// next server resends its whole world at the same coordinates, and
/// relighting every column and its neighbours is work that world never
/// asked for (#10273).
#[test]
fn a_resent_column_is_relit_only_for_a_horizon_servers_bulk_edit() {
for relight in [false, true] {
let mut world = World::new(horizon_world::registry::BlockStateId(0), 0);
let old = resident_column(&mut world, 0x50);
let mut new = old.clone();
new.set(1, 10, 1, 2);
Arc::make_mut(&mut new.sections[0]).light = crate::chunk::Light::Uniform(0xF0);
take_resent_column(&mut world, (2, -3), &old, new, relight);
let chunk = &world.chunks[&(2, -3)];
assert_eq!(chunk.block(1, 10, 1).0, 2);
// The packet's light is what the column shows.
assert_eq!(chunk.sections[0].light.get(0), 0xF0);
// The changed section remeshes either way.
assert!(world.dirty_sections.contains(&(2, 0, -3)));
// Only a Horizon bulk edit queues light work, for the column and
// its eight neighbours.
assert_eq!(world.dirty_light.len(), if relight { 9 } else { 0 });
}
}
}
#[cfg(test)]
+18 -7
View File
@@ -124,10 +124,18 @@ pub fn face(
}
}
/// `Hud.extractTabList`: whether the list shows. A single-player world
/// shows it only with another listed player or a `list` objective.
pub fn visible(key_down: bool, local_server: bool, listed: usize, objective: bool) -> bool {
key_down && !(local_server && listed <= 1 && !objective)
/// `Hud.extractTabList`: whether the list shows. `Hud.extractRenderState`
/// only calls it inside `if (!isHidden)`, so F1 takes the list away whatever
/// else holds it up. A single-player world shows it only with another listed
/// player or a `list` objective.
pub fn visible(
hud_shown: bool,
key_down: bool,
local_server: bool,
listed: usize,
objective: bool,
) -> bool {
hud_shown && key_down && !(local_server && listed <= 1 && !objective)
}
/// `Options.getBackgroundColor(553648127)`: the row background.
@@ -702,8 +710,11 @@ mod tests {
assert_eq!(ping_sprite(1000), "icon/ping_1");
// UUID.hashCode of 0 is 0: the first default skin.
assert_eq!(default_skin(0), "entity/player/slim/alex");
assert!(!visible(true, true, 1, false));
assert!(visible(true, true, 1, true));
assert!(visible(true, false, 1, false));
assert!(!visible(true, true, true, 1, false));
assert!(visible(true, true, true, 1, true));
assert!(visible(true, true, false, 1, false));
// F1 (`Hud.isHidden`) takes the list away however it was held up.
assert!(!visible(false, true, false, 2, false));
assert!(!visible(false, true, true, 1, true));
}
}
@@ -222,6 +222,8 @@
"horizon.extras.no_ai": "Keine KI",
"horizon.extras.agent_control": "Agentensteuerung",
"horizon.extras.discord_presence": "Discord Rich Presence",
"horizon.extras.zoom": "Zoom",
"horizon.extras.zoom_mod_install": "Zoom: über Mods installieren",
"horizon.extras.fullbright.tooltip": "Zeigt Blöcke und Objekte bei vollem Licht, selbst an dunklen Orten.",
"horizon.extras.dynamic_lights.tooltip": "Fackeln und andere Lichtquellen, die gehalten, fallen gelassen werden oder brennen, beleuchten die Blöcke um sich herum.",
"horizon.extras.own_name_tag.tooltip": "Zeigt deinen Namen in der dritten Person über deinem Kopf an, so wie andere Spieler ihn sehen. Versteckt, wenn der Server die Namen deines Teams verbirgt oder du unsichtbar bist.",
@@ -322,6 +322,8 @@
"horizon.extras.no_ai": "No AI",
"horizon.extras.agent_control": "Agent Control",
"horizon.extras.discord_presence": "Discord Rich Presence",
"horizon.extras.zoom": "Zoom",
"horizon.extras.zoom_mod_install": "Zoom: install from Mods",
"horizon.extras.splitscreen": "Splitscreen",
"horizon.extras.chat_animation": "Smooth chat",
"horizon.extras.chat_indicator": "Message indicator",
@@ -242,6 +242,8 @@
"horizon.extras.no_ai": "Bez AI",
"horizon.extras.agent_control": "Sterowanie agentem",
"horizon.extras.discord_presence": "Discord Rich Presence",
"horizon.extras.zoom": "Zoom",
"horizon.extras.zoom_mod_install": "Zoom: zainstaluj w Modach",
"horizon.extras.fullbright.tooltip": "Pokazuje bloki i byty w pełnym oświetleniu, nawet w ciemnych miejscach.",
"horizon.extras.dynamic_lights.tooltip": "Pochodnie i inne źródła światła trzymane w dłoni, upuszczone lub płonące oświetlają otaczające bloki.",
"horizon.extras.own_name_tag.tooltip": "Pokazuje twój nick nad głową w widoku z trzeciej osoby, tak jak widzą go inni gracze. Ukryty, gdy serwer ukrywa nazwy twojej drużyny lub jesteś niewidzialny.",
@@ -220,6 +220,7 @@ fn goal_name(m: &Mob, name: &str) -> String {
"RandomSwimming" => "RandomSwimmingGoal",
"LookAtPlayer" => "LookAtPlayerGoal",
"RandomLookAround" => "RandomLookAroundGoal",
"SpearUse" => "SpearUseGoal",
"HurtByTarget" => "HurtByTargetGoal",
"NearestTarget" => "NearestAttackableTargetGoal",
"Panic" => "PanicGoal",
@@ -1449,7 +1450,13 @@ pub fn main() -> horizon_client::Result<()> {
else {
continue;
};
let tag = horizon_client::entity_nbt::mob_to_nbt(&mobs[i], [0; 4], None, None);
let tag = horizon_client::entity_nbt::mob_to_nbt(
&mobs[i],
[0; 4],
None,
None,
|_| None,
);
let flower = horizon_client::mobs::bee::get(&mobs[i]).and_then(|b| b.flower);
let mut honey = Vec::new();
let stored = {
@@ -355,7 +355,7 @@ pub fn main() -> horizon_client::Result<()> {
}
let mob =
entity_nbt::mob_from_nbt(&tag, &types, 1, None).ok_or("unreadable villager")?;
let back = entity_nbt::mob_to_nbt(&mob, [0; 4], None, None);
let back = entity_nbt::mob_to_nbt(&mob, [0; 4], None, None, |_| None);
writeln!(out, "{name} snbt {text}")?;
let mut kept = horizon_world::nbt::Compound::new();
for key in [
+6 -2
View File
@@ -370,6 +370,8 @@ impl ArrowScene<'_, '_> {
binding: self.collision,
subject: None,
};
// CollisionContext.of(arrow): scaffolding is open from below.
let context = horizon_world::collision_context::CollisionContext::position(from.y);
let hit = horizon_core::geometry::traverse_blocks(from, to, 1024, |p: BlockPos| {
if Some(p) == ignored {
return None;
@@ -380,7 +382,7 @@ impl ArrowScene<'_, '_> {
}
let shape = self
.collision
.collision_shape(&mut scene, state, [p.x, p.y, p.z])
.collision_shape_with_context(&mut scene, state, p, &context)
.ok()?;
shape.clip(from, to, p)
});
@@ -447,13 +449,15 @@ pub fn clip_block_hit(
binding: collision,
subject: None,
};
// CollisionContext.of(projectile): scaffolding is open from below.
let context = horizon_world::collision_context::CollisionContext::position(from.y);
let hit = horizon_core::geometry::traverse_blocks(from, to, 1024, |p: BlockPos| {
let state = world.block(p.x, p.y, p.z);
if facts.is_air(state) {
return None;
}
let shape = collision
.collision_shape(&mut scene, state, [p.x, p.y, p.z])
.collision_shape_with_context(&mut scene, state, p, &context)
.ok()?;
shape.clip(from, to, p)
})
+46 -2
View File
@@ -467,6 +467,11 @@ impl Creative {
pub fn tab(&self) -> &Tab {
&self.tabs[self.selected.min(self.tabs.len() - 1)]
}
/// The search tab's index (`CreativeModeTabs.searchTab`), the tab the chat
/// key opens while another one is shown.
pub fn search_tab(&self) -> Option<usize> {
self.tabs.iter().position(|t| t.search)
}
/// selectTab: a different tab empties the search box; the scrollbar goes to the top.
/// A tab on another page shows that page.
pub fn select(&mut self, tab: usize) {
@@ -480,10 +485,13 @@ impl Creative {
self.page = t.page;
}
self.scroll = 0.0;
if self.tab().search {
self.search.focused = true;
// The search box is shown, focused and impossible to unfocus only on
// its own tab; another tab hides it and takes the focus away.
let search = self.tab().search;
if search {
self.search.reset_blink();
}
self.search.focused = search;
}
/// How many pages of tabs there are (1 without mods' tabs).
pub fn pages(&self) -> u32 {
@@ -1438,6 +1446,22 @@ mod tests {
}
}
/// `CreativeModeInventoryScreen.keyPressed`: from a tab that is not the
/// search, the chat key selects `CreativeModeTabs.searchTab`, which focuses
/// the search box and empties the query the other tab left.
#[test]
fn the_chat_key_opens_the_search_tab() {
let mut c = creative(0);
c.search.set_value("anvil");
let tab = c.search_tab().expect("the fixture has a search tab");
assert!(c.tabs[tab].search);
assert!(!c.tab().search);
c.select(tab);
assert!(c.tab().search);
assert!(c.search.focused);
assert_eq!(c.search.value, "", "another tab empties the query");
}
#[test]
fn search_matches_vanilla_trees() {
let c = creative(0);
@@ -1536,6 +1560,26 @@ mod tests {
assert!(!c.scrolling);
}
/// `CreativeModeInventoryScreen.selectTab`: the search box is focused on
/// its own tab and loses the focus when another tab is shown (and, hidden,
/// is no longer the field a key types into).
#[test]
fn the_search_box_loses_the_focus_with_its_tab() {
let mut c = creative(0);
let search = c
.tabs
.iter()
.position(|t| t.search)
.expect("the fixture has a search tab");
c.select(search);
assert!(c.search.focused);
c.select(0);
assert!(!c.search.focused, "another tab takes the focus away");
assert!(!c.tab().search);
c.select(search);
assert!(c.search.focused, "and the search tab takes it back");
}
#[test]
fn creative_tab_titles_have_vanilla_keys() {
assert_eq!(
+313 -1
View File
@@ -120,7 +120,13 @@ mod drop_motion_tests {
[
-4627867408251048793,
4597245257657090048,
4597448471913462163,
// Vanilla's `Math.sin` is HotSpot's intrinsic, which rounds this
// one a bit lower on AArch64 than on x86-64 (`jmath`).
if cfg!(target_arch = "aarch64") {
4597448471913462162
} else {
4597448471913462163
},
],
),
(
@@ -409,6 +415,29 @@ impl ItemEntity {
}
impl ItemEntity {
pub const WIDTH: f64 = 0.25;
/// `EntityType.ITEM` declares `eyeHeight(0.2125)`: an item is lit from the
/// block its eye is in, most of a block above the one its base rests in.
pub const EYE_HEIGHT: f64 = 0.2125;
/// `Entity.getLightProbePosition(partialTick)` for a dropped item: the eye
/// position (`getEyePosition`) over the position drawn this frame
/// (`getPosition(partialTick)`), not this tick's own.
pub fn light_probe(&self, partial: f64) -> [f64; 3] {
[
self.previous.x + (self.position.x - self.previous.x) * partial,
self.previous.y + (self.position.y - self.previous.y) * partial + Self::EYE_HEIGHT,
self.previous.z + (self.position.z - self.previous.z) * partial,
]
}
/// `EntityRenderer.getPackedLightCoords` for a dropped item: the level's
/// sky light over the block light of the cell it is lit from, which
/// `EntityRenderer.getBlockLightLevel` puts at 15 while the item
/// `isOnFire`. `light` is the level's packed light (sky high, block low);
/// `environment_tick` clears the fire ticks of a fire-immune stack, as
/// `ItemEntity.fireImmune` makes it never on fire.
pub fn render_light(&self, light: u8) -> u32 {
let block = if self.fire_ticks > 0 { 15 } else { light & 15 };
(u32::from(light >> 4) * 16) | ((u32::from(block) * 16) << 8)
}
pub fn bounds(&self) -> Aabb {
let h = Self::WIDTH / 2.0;
Aabb::new(
@@ -1896,6 +1925,84 @@ impl ArmorStand {
pub fn render_position(&self, alpha: f64) -> Vec3 {
self.previous.lerp(self.position, alpha)
}
/// `ArmorStand.getDimensionsMarker(false)`: the width, height and eye
/// height of the box a stand has without the marker flag, which is the box
/// a marker's light probe walks (`MARKER_DIMENSIONS` itself is empty, and
/// `isBaby()` is `isSmall()`).
pub fn probe_dimensions(&self) -> (f64, f64, f64) {
if self.small {
(0.25, 0.9875, 0.9875)
} else {
(0.5, 1.975, 1.7775)
}
}
/// `ArmorStand.getLightProbePosition` as the cell the renderer reads
/// (`BlockPos.containing` of the position it returns): a marker walks
/// `BlockPos.betweenClosed` of its non-marker box at `position()` - it
/// takes no partial tick - and returns the first cell whose brightness
/// `max(block, sky)` is 15, else the brightest, ties going to the first
/// cell walked; every other stand is lit from its eye
/// (`Entity.getLightProbePosition` is `getEyePosition(partialTick)`).
/// `light` is the level's packed light.
pub fn light_probe(
&self,
partial: f64,
mut light: impl FnMut(i32, i32, i32) -> u8,
) -> [i32; 3] {
let (width, height, eye) = self.probe_dimensions();
if !self.marker {
let at = self.render_position(partial);
return [
at.x.floor() as i32,
(at.y + eye).floor() as i32,
at.z.floor() as i32,
];
}
let half = width / 2.0;
let (lo_x, hi_x) = (
(self.position.x - half).floor() as i32,
(self.position.x + half).floor() as i32,
);
let (lo_z, hi_z) = (
(self.position.z - half).floor() as i32,
(self.position.z + half).floor() as i32,
);
let (lo_y, hi_y) = (
self.position.y.floor() as i32,
(self.position.y + height).floor() as i32,
);
// `BlockPos.betweenClosed` walks x fastest, then y, then z; the stand's
// own cell is what it falls back to when the box is empty.
let mut best = [
self.position.x.floor() as i32,
self.position.y.floor() as i32,
self.position.z.floor() as i32,
];
let mut brightest = i32::MIN;
for cell_z in lo_z..=hi_z {
for cell_y in lo_y..=hi_y {
for cell_x in lo_x..=hi_x {
let level = light(cell_x, cell_y, cell_z);
let brightness = i32::from((level & 15).max(level >> 4));
if brightness == 15 {
return [cell_x, cell_y, cell_z];
}
if brightness > brightest {
brightest = brightness;
best = [cell_x, cell_y, cell_z];
}
}
}
}
best
}
/// `EntityRenderer.getPackedLightCoords` for the cell `light_probe` chose:
/// the level's sky light over the cell's block light, at 15 block light
/// while the stand `isOnFire`. `light` is the level's packed light.
pub fn render_light(&self, light: u8) -> u32 {
let block = if self.fire_ticks > 0 { 15 } else { light & 15 };
(u32::from(light >> 4) * 16) | ((u32::from(block) * 16) << 8)
}
/// The body's yaw `alpha` of the way through the tick
/// (`LivingEntityRenderer.solveBodyRot`: `Mth.rotLerp(partialTick,
/// yBodyRotO, yBodyRot)`).
@@ -2573,6 +2680,113 @@ mod armor_stand_render_tests {
}
}
#[cfg(test)]
mod armor_stand_light_tests {
use super::*;
fn stand(x: f64, y: f64, z: f64) -> ArmorStand {
ArmorStand::new(1, Vec3::new(x, y, z), 0.0)
}
/// `ArmorStand.getLightProbePosition` for a stand that is not a marker is
/// `Entity.getEyePosition`: `EntityType.ARMOR_STAND` declares
/// `eyeHeight(1.7775)`, and a small stand (`isBaby()`) is the same type
/// scaled by 0.5 with `withEyeHeight(0.9875)`. Horizon read
/// `floor(base + 0.5)` for every stand, so a stand standing on a lit floor
/// took the floor's light instead of the light at its head.
#[test]
fn a_stand_is_lit_from_the_block_its_eye_is_in() {
let dark = |_: i32, _: i32, _: i32| 0u8;
// Full stand on y = 64: eye 65.7775, cell 65 (the old 64.5 was 64).
assert_eq!(stand(0.5, 64.0, 0.5).light_probe(1.0, dark), [0, 65, 0]);
assert_eq!((64.0_f64 + 0.5).floor() as i32, 64);
// Small stand: eye 0.9875 above its base, so y = 64.2 is 65.1875.
let mut small = stand(0.5, 64.2, 0.5);
small.small = true;
assert_eq!(small.light_probe(1.0, dark), [0, 65, 0]);
assert_eq!((64.2_f64 + 0.5).floor() as i32, 64);
// A small stand at y = 64.0 stays in its own cell: eye 64.9875.
let mut low = stand(0.5, 64.0, 0.5);
low.small = true;
assert_eq!(low.light_probe(1.0, dark), [0, 64, 0]);
}
/// `getEyePosition(partialTick)` interpolates: a stand falling from 70 to
/// 69 is probed 0.5 down at the middle of the frame, 69.5 + 1.7775 in the
/// cell at 71, while its feet's cell is 69.
#[test]
fn a_stand_probe_follows_the_frame() {
let dark = |_: i32, _: i32, _: i32| 0u8;
let mut falling = stand(0.5, 70.0, 0.5);
falling.interpolate(Some((Vec3::new(0.5, 69.0, 0.5), 0.0)));
assert_eq!(falling.light_probe(0.0, dark), [0, 71, 0]);
assert_eq!(falling.light_probe(0.5, dark), [0, 71, 0]);
assert_eq!(falling.light_probe(1.0, dark), [0, 70, 0]);
assert_eq!(falling.position.y.floor() as i32, 69);
}
/// A marker walks `BlockPos.betweenClosed` of its own box - the size it has
/// without the marker flag, 0.5 by 1.975 - from `position()`, taking the
/// first cell at brightness 15 and else the brightest. Horizon used the
/// stand's own cell, so a marker (a hologram on a dark ceiling) was lit by
/// its feet's block.
#[test]
fn a_marker_scans_the_cells_of_its_own_box() {
let at = |want: [i32; 3], level: u8| {
move |x: i32, y: i32, z: i32| {
if [x, y, z] == want { level } else { 0 }
}
};
let mut marker = stand(0.5, 64.0, 0.5);
marker.marker = true;
// The box is x/z 0.25..0.75 - the cell at 0 - and y 64..65.975.
assert_eq!(marker.light_probe(1.0, at([0, 65, 0], 15)), [0, 65, 0]);
assert_eq!(marker.light_probe(1.0, at([0, 65, 0], 9)), [0, 65, 0]);
// Nothing lit: the first cell walked, x fastest then y then z.
assert_eq!(marker.light_probe(1.0, |_, _, _| 0u8), [0, 64, 0]);
// The cell above the box is not scanned, so its 15 does not win.
assert_eq!(marker.light_probe(1.0, at([0, 66, 0], 15)), [0, 64, 0]);
// 15 wins over a brighter-looking neighbour, and y is walked before z:
// (0, 65, 0) comes before (0, 64, 1) though both are at 15.
let mut wide = stand(0.5, 64.0, 0.9);
wide.marker = true;
let two = |x: i32, y: i32, z: i32| {
if (x, y, z) == (0, 65, 0) || (x, y, z) == (0, 64, 1) {
15
} else {
0
}
};
assert_eq!(wide.light_probe(1.0, two), [0, 65, 0]);
// `getLightProbePosition`'s marker branch reads `position()`, not the
// partial-tick position: falling does not move the scan.
let mut moving = stand(0.5, 64.0, 0.5);
moving.marker = true;
moving.interpolate(Some((Vec3::new(0.5, 70.0, 0.5), 0.0)));
assert_eq!(moving.light_probe(0.0, |_, _, _| 0u8), [0, 70, 0]);
assert_eq!(moving.light_probe(0.5, |_, _, _| 0u8), [0, 70, 0]);
}
/// `EntityRenderer.getBlockLightLevel`: a stand `isOnFire` is drawn at 15
/// block light whatever the level says, and the sky light is the probe
/// cell's own.
#[test]
fn a_burning_stand_is_drawn_at_full_block_light() {
let light = |sky: u8, block: u8| (sky << 4) | block;
let cold = stand(0.5, 64.0, 0.5);
assert_eq!(cold.render_light(light(15, 0)), (15 * 16) as u32);
assert_eq!(cold.render_light(light(0, 9)), ((9 * 16) << 8) as u32);
let burning = ArmorStand {
fire_ticks: 100,
..stand(0.5, 64.0, 0.5)
};
assert_eq!(
burning.render_light(light(15, 0)),
((15 * 16) | ((15 * 16) << 8)) as u32
);
assert_eq!(burning.render_light(light(0, 0)), ((15 * 16) << 8) as u32);
}
}
#[cfg(test)]
mod merge_tests {
use super::*;
@@ -4247,6 +4461,104 @@ mod render_light_tests {
}
}
}
#[cfg(test)]
mod dropped_item_light_tests {
use super::*;
fn item(previous_y: f64, y: f64) -> ItemEntity {
ItemEntity {
rotation: [0.0, 0.0],
join: Default::default(),
no_gravity: false,
id: 1,
thrower: None,
target: None,
stack: ItemStack {
item: std::sync::Arc::from("minecraft:stone"),
count: 1,
damage: 0,
extra: None,
},
health: 5,
fire_ticks: 0,
position: Vec3::new(0.5, y, 0.5),
previous: Vec3::new(0.5, previous_y, 0.5),
velocity: Vec3::ZERO,
on_ground: false,
vertical_collision: false,
horizontal_collision: false,
age: 0,
pickup_delay: 0,
bob_offset: 0.0,
ticks: 0,
needs_sync: false,
stuck: Vec3::ZERO,
was_touching_water: false,
}
}
/// `Entity.getLightProbePosition` is the eye position, and `EntityType.ITEM`
/// declares `eyeHeight(0.2125)`. The item used to be lit from the cell of
/// `base + 0.1`, so a falling item at y = 64.85 read the cell at 64 (block
/// light of the floor) while its eye at 65.0625 is in the cell at 65.
#[test]
fn a_dropped_item_is_lit_from_the_block_its_eye_is_in() {
let falling = item(64.85, 64.85);
let probe = falling.light_probe(1.0);
assert!((probe[1] - 65.062_5).abs() < 1e-9);
assert_eq!(probe[1].floor(), 65.0);
// The old probe, `(y + 0.1).floor()`, and the base cell itself.
assert_eq!((64.85_f64 + 0.1).floor(), 64.0);
assert_eq!(64.85_f64.floor(), 64.0);
// Resting on a block top the two agree: the eye is 0.2125 up.
let resting = item(64.0, 64.0);
assert_eq!(resting.light_probe(1.0)[1].floor(), 64.0);
assert!((resting.light_probe(1.0)[1] - 64.212_5).abs() < 1e-9);
}
/// `Entity.getLightProbePosition(partialTick)` follows `getPosition(partialTick)`:
/// an item falling a block in one tick is probed halfway between the two
/// positions in the middle of the frame. The old code read the tick's own
/// position, so the light jumped a frame early.
#[test]
fn an_item_probe_follows_the_frame() {
let falling = item(64.0, 65.0);
let mid = falling.light_probe(0.5);
assert!((mid[1] - 64.712_5).abs() < 1e-9);
assert_eq!(mid[1].floor(), 64.0);
assert_eq!(falling.light_probe(1.0)[1].floor(), 65.0);
// Across x and z too, not just down.
let sliding = ItemEntity {
position: Vec3::new(1.5, 64.0, 0.5),
previous: Vec3::new(0.5, 64.0, 0.5),
..item(64.0, 64.0)
};
assert!((sliding.light_probe(0.5)[0] - 1.0).abs() < 1e-9);
}
/// `EntityRenderer.getBlockLightLevel` is `isOnFire() ? 15 : the level's
/// block light`, and the sky light is never overridden. A dropped item was
/// drawn at the level's block light while it burned.
#[test]
fn a_burning_item_is_drawn_at_full_block_light() {
let light = |sky: u8, block: u8| (sky << 4) | block;
let cold = item(64.0, 64.0);
assert_eq!(cold.render_light(light(15, 0)), (15 * 16) as u32);
assert_eq!(cold.render_light(light(0, 9)), ((9 * 16) << 8) as u32);
let burning = ItemEntity {
fire_ticks: 160,
..item(64.0, 64.0)
};
// Daylight, dark block: the block light goes to 15, the sky stays.
assert_eq!(
burning.render_light(light(15, 0)),
((15 * 16) | ((15 * 16) << 8)) as u32
);
// A dark cave: 15 block light, still no invented sky light.
assert_eq!(burning.render_light(light(0, 0)), ((15 * 16) << 8) as u32);
}
}
crate::impl_joined!(ItemEntity, ExperienceOrb, ArmorStand);
#[cfg(test)]
mod orb_lava_tests {
+460 -56
View File
@@ -8,6 +8,7 @@ use crate::{
arrows::Pickup,
entities::{ArmorStand, Cushion, ExperienceOrb, ItemEntity, ItemFrame, Painting},
mobs::{Mob, MobType, Who},
nbt_path::int_value,
projectiles::{Projectile, ProjectileKind},
save::{
doubles, float, floats, item_from_nbt, item_to_nbt, number, put, read_doubles, text,
@@ -170,6 +171,39 @@ fn player_uuid() -> [i32; 4] {
u as u32 as i32,
]
}
/// The UUID a save writes for a reference to `who`. A mob is only an id here:
/// its UUID lives in the game layer (`EntityMemory.uuids`), which is what
/// `uuid_of` answers from. When it answers `None` (the mob is no longer in the
/// level) nothing is written, rather than a made-up UUID that would name nothing
/// when the save is read back. The reference comes back with [`who_by_uuid`].
fn who_uuid(who: Who, uuid_of: &mut impl FnMut(u32) -> Option<[i32; 4]>) -> Option<[i32; 4]> {
match who {
Who::Player => Some(player_uuid()),
Who::Mob(id) => uuid_of(id),
}
}
/// The entity a stored UUID names, while it is in the level: `player` (the
/// player's own UUID, when one is in the level), or the mob `uuids` gives that
/// UUID to. `uuids` is the game layer's id -> UUID map, so this is the read half
/// of the pair [`who_uuid`] and `mob_to_nbt`'s `uuid_of` form. A UUID nothing
/// loaded has, or one naming a kind `Who` cannot carry (an item, a projectile, a
/// guest), is `None` - never some other entity as a stand-in.
pub fn who_by_uuid(
uuids: &HashMap<u32, [i32; 4]>,
mobs: &[Mob],
player: Option<[i32; 4]>,
uuid: [i32; 4],
) -> Option<Who> {
if player == Some(uuid) {
return Some(Who::Player);
}
// Only a loaded mob: the map also holds projectiles, vehicles and items,
// whose ids mean nothing to `Who`.
uuids
.iter()
.find(|(id, u)| **u == uuid && mobs.iter().any(|m| m.id == **id))
.map(|(id, _)| Who::Mob(*id))
}
/// `ResolvableProfile.CODEC`: the name, id and properties of a profile, then the
/// skin's texture ids and model (`PlayerSkin.Patch`: "texture", "cape", "elytra", "model").
fn profile_nbt(p: &horizon_protocol::entity_data::Profile) -> Tag {
@@ -343,11 +377,17 @@ fn is_air(registry: &BlockRegistry, state: BlockStateId) -> bool {
/// state (an enderman's `carriedBlockState`): without one, a caller that has no
/// resources (unit tests, structure templates) keeps whatever the loaded
/// compound had for it.
///
/// `uuid_of` is how a field that names another entity by UUID (a mob's
/// `angry_at`) is written: it answers the UUID of a mob by its id, from the game
/// layer's map, and `None` for one the level no longer has. A caller with no
/// such map passes `|_| None` and writes no reference.
pub fn mob_to_nbt(
m: &Mob,
uuid: [i32; 4],
raw: Option<&Tag>,
registry: Option<&BlockRegistry>,
mut uuid_of: impl FnMut(u32) -> Option<[i32; 4]>,
) -> Tag {
use crate::mobs::Species as S;
let base = Base {
@@ -429,8 +469,8 @@ pub fn mob_to_nbt(
-1
}),
);
if m.extra.angry_at == Some(crate::mobs::Who::Player) {
put(&mut tag, "angry_at", Tag::IntArray(player_uuid().to_vec()));
if let Some(uuid) = m.extra.angry_at.and_then(|who| who_uuid(who, &mut uuid_of)) {
put(&mut tag, "angry_at", Tag::IntArray(uuid.to_vec()));
}
}
// `Mannequin.addAdditionalSaveData`: the profile whose skin it wears, whether it
@@ -1223,6 +1263,11 @@ pub fn mob_to_nbt(
map.insert("drop_chances".into(), Tag::compound(set));
}
}
// SpellcasterIllager.addAdditionalSaveData: save the live casting timer,
// including zero and negative values, rather than stale loaded NBT.
if matches!(m.species(), S::Evoker | S::Illusioner) {
map.insert("SpellTicks".into(), Tag::Int(m.extra.counter));
}
crate::mobs::raider::write_nbt(m, map);
crate::mobs::riders::write_nbt(m, map);
// LivingEntity equipment: what the mob holds now.
@@ -1700,12 +1745,12 @@ pub fn mob_from_nbt(
m.extra.drop_chances = Some(Box::new(chances));
}
if saves_age(m.species()) {
m.extra.age = number(tag.get("Age")).unwrap_or(0) as i32;
m.extra.forced_age = number(tag.get("ForcedAge")).unwrap_or(0) as i32;
m.extra.age = tag.get("Age").and_then(int_value).unwrap_or(0);
m.extra.forced_age = tag.get("ForcedAge").and_then(int_value).unwrap_or(0);
m.extra.age_locked = flag(tag, "AgeLocked");
}
if is_animal(&m.kind.id) {
m.extra.in_love = number(tag.get("InLove")).unwrap_or(0) as i32;
m.extra.in_love = tag.get("InLove").and_then(int_value).unwrap_or(0);
// Animal.readAdditionalSaveData: the player who fed it into love.
m.extra.love_cause = crate::save::uuid_from_tag(tag.get("LoveCause"));
}
@@ -2194,6 +2239,14 @@ pub fn mob_from_nbt(
// one spent on the next tick.
m.extra.counter2 = if time == -1 { 0 } else { time.max(0) + 1 };
}
// SpellcasterIllager.readAdditionalSaveData: this is the same timer used
// by casting goals and the existing customServerAiStep countdown.
if matches!(
m.species(),
crate::mobs::Species::Evoker | crate::mobs::Species::Illusioner
) {
m.extra.counter = number(tag.get("SpellTicks")).unwrap_or(0) as i32;
}
crate::mobs::raider::read_nbt(&mut m, tag);
crate::mobs::riders::read_nbt(&mut m, tag);
// A mob saved mid-death finishes dying; one saved dead is dropped.
@@ -2259,7 +2312,9 @@ pub fn tnt_power(tag: &Tag) -> f32 {
}
/// `PrimedTnt.addAdditionalSaveData`: its `fuse` and, when it is not the
/// default, its `explosion_power`, and the encoded live block state.
/// default, its `explosion_power`, the encoded live block state, and its
/// `owner` (`EntityReference.store`). With no live owner, a loaded `owner`
/// stays as the raw compound has it.
#[allow(clippy::too_many_arguments)]
pub fn tnt_to_nbt(
position: Vec3,
@@ -2270,6 +2325,7 @@ pub fn tnt_to_nbt(
block_state: Tag,
rotation: [f32; 2],
uuid: [i32; 4],
owner: Option<[i32; 4]>,
raw: Option<&Tag>,
) -> Tag {
let mut tag = Base {
@@ -2287,8 +2343,15 @@ pub fn tnt_to_nbt(
put(&mut tag, "explosion_power", Tag::Float(power.to_bits()));
}
put(&mut tag, "block_state", block_state);
if let Some(owner) = owner {
put(&mut tag, "owner", Tag::IntArray(owner.to_vec()));
}
tag
}
/// `PrimedTnt.readAdditionalSaveData`: `EntityReference.read(input, "owner")`.
pub fn tnt_owner(tag: &Tag) -> Option<[i32; 4]> {
uuid_from_tag(tag.get("owner"))
}
/// `EvokerFangs.addAdditionalSaveData`: always save the live warmup counter.
/// Known Owner UUIDs use `EntityReference.store` encoding; unresolved
/// loaded Owner data remains in the raw compound.
@@ -3228,14 +3291,18 @@ pub fn item_frame_to_nbt(f: &ItemFrame, uuid: [i32; 4], raw: Option<&Tag>) -> Ta
}
tag
}
/// `ItemFrame.readAdditionalSaveData`: an absent Facing defaults to Direction.DOWN.
pub fn item_frame_from_nbt(tag: &Tag, id: u32) -> Option<ItemFrame> {
let pos = attached_block_pos(tag)?;
Some(ItemFrame {
id,
pos,
facing: number(tag.get("Facing")).unwrap_or(2).clamp(0, 5) as u8,
facing: number(tag.get("Facing")).unwrap_or(0).clamp(0, 5) as u8,
glow: kind(tag).as_deref() == Some("minecraft:glow_item_frame"),
item: tag.get("Item").and_then(item_from_nbt),
item: tag
.get("Item")
.and_then(item_from_nbt)
.map(|s| crate::items::ItemStack { count: 1, ..s }),
rotation: (number(tag.get("ItemRotation")).unwrap_or(0) & 7) as u8,
drop_chance: float(tag.get("ItemDropChance")).map_or(1.0, |v| v as f32),
fixed: flag(tag, "Fixed"),
@@ -3661,6 +3728,7 @@ mod tnt_power_tests {
[0.0, 0.0],
[1, 2, 3, 4],
None,
None,
);
assert_eq!(tnt_power(&saved), 10.0);
let plain = tnt_to_nbt(
@@ -3673,9 +3741,108 @@ mod tnt_power_tests {
[0.0, 0.0],
[1, 2, 3, 4],
None,
None,
);
assert!(plain.get("explosion_power").is_none());
}
/// `EntityReference.store(owner, output, "owner")`: the igniter's UUID as
/// an int array, read back by `EntityReference.read`; an owner the save
/// could not name keeps the loaded one from the raw compound.
#[test]
fn the_owner_round_trips_as_a_uuid() {
let save = |owner, raw: Option<&Tag>| {
let t = string("minecraft:tnt");
tnt_to_nbt(
Vec3::ZERO,
Vec3::ZERO,
false,
80,
4.0,
t,
[0.0; 2],
[1; 4],
owner,
raw,
)
};
let saved = save(Some([5, 6, 7, 8]), None);
assert_eq!(saved.get("owner"), Some(&Tag::IntArray(vec![5, 6, 7, 8])));
assert_eq!(tnt_owner(&saved), Some([5, 6, 7, 8]));
assert_eq!(tnt_owner(&save(None, None)), None);
assert_eq!(tnt_owner(&save(None, Some(&saved))), Some([5, 6, 7, 8]));
assert_eq!(tnt_owner(&save(Some([9; 4]), Some(&saved))), Some([9; 4]));
}
}
#[cfg(test)]
mod spell_ticks_nbt_tests {
use super::*;
use crate::mobs::Species;
fn caster(species: Species, name: &str) -> Mob {
let mut kind = crate::mobs::sensor_scan_tests::kind();
let ty = Arc::make_mut(&mut kind);
ty.species = species;
ty.id = format!("minecraft:{name}");
Mob::new(1, kind, Vec3::new(0.5, 64.0, 0.5), 0.0)
}
/// `SpellcasterIllager.readAdditionalSaveData`: bind loaded values to the
/// counter already used by casting goals and customServerAiStep.
#[test]
fn spell_ticks_load_into_the_live_counter() {
for (species, name) in [
(Species::Evoker, "evoker"),
(Species::Illusioner, "illusioner"),
] {
let m = caster(species, name);
let types = HashMap::from([(m.kind.id.clone(), m.kind.clone())]);
for value in [None, Some(30), Some(0), Some(-2)] {
let mut raw = Tag::compound([
("id", string(&m.kind.id)),
("Pos", doubles(&[0.5, 64.0, 0.5])),
]);
if let Some(value) = value {
put(&mut raw, "SpellTicks", Tag::Int(value));
}
let loaded = mob_from_nbt(&raw, &types, 2, None).unwrap();
assert_eq!(
loaded.extra.counter,
value.unwrap_or(0),
"{name}: {value:?}"
);
let saved = mob_to_nbt(&loaded, [1, 2, 3, 4], None, None);
assert_eq!(saved.get("SpellTicks"), Some(&Tag::Int(value.unwrap_or(0))));
}
}
}
/// `SpellcasterIllager.addAdditionalSaveData`: live state wins even when
/// no raw input exists or the original raw counter has become stale.
#[test]
fn spell_ticks_save_the_mutated_model_without_a_raw_fallback() {
for (species, name) in [
(Species::Evoker, "evoker"),
(Species::Illusioner, "illusioner"),
] {
let mut m = caster(species, name);
let raw = Tag::compound([
("SpellTicks", Tag::Int(30)),
("audit:unrelated", Tag::Int(7)),
]);
for value in [29, 27, 20, 0, -2] {
m.extra.counter = value;
for raw in [None, Some(&raw)] {
let saved = mob_to_nbt(&m, [1, 2, 3, 4], raw, None);
assert_eq!(saved.get("SpellTicks"), Some(&Tag::Int(value)), "{name}");
if raw.is_some() {
assert_eq!(saved.get("audit:unrelated"), Some(&Tag::Int(7)));
}
}
}
}
}
}
#[cfg(test)]
@@ -3734,11 +3901,11 @@ mod tests {
{
map.insert("Age".into(), Tag::Int(age));
}
let saved = mob_to_nbt(&mob, [0; 4], Some(&raw), None);
let saved = mob_to_nbt(&mob, [0; 4], Some(&raw), None, |_| None);
assert_eq!(saved.get("Age"), expected.as_ref(), "{species:?}, {age:?}");
assert_eq!(saved.get("h228_control"), Some(&Tag::Int(42)));
}
let saved = mob_to_nbt(&mob, [0; 4], None, None);
let saved = mob_to_nbt(&mob, [0; 4], None, None, |_| None);
assert_eq!(
saved.get("Age"),
expected.as_ref(),
@@ -3769,7 +3936,7 @@ mod tests {
("audit:unrelated", Tag::Int(7)),
]);
for base in [None, Some(&raw)] {
let saved = mob_to_nbt(&mob, [0; 4], base, None);
let saved = mob_to_nbt(&mob, [0; 4], base, None, |_| None);
for (key, expected) in [
("Age", Tag::Int(-123)),
("ForcedAge", Tag::Int(19)),
@@ -3829,7 +3996,7 @@ mod tests {
Some("minecraft:diamond_horse_armor")
);
assert_eq!(m.extra.head.as_deref(), Some("minecraft:iron_helmet"));
let saved = mob_to_nbt(&m, [0; 4], Some(&raw), None);
let saved = mob_to_nbt(&m, [0; 4], Some(&raw), None, |_| None);
let eq = saved.get("equipment").unwrap();
let id = |key: &str| {
eq.get(key)
@@ -3923,7 +4090,7 @@ mod tests {
1
);
mob.max_swell = 7;
let saved = mob_to_nbt(&mob, [0; 4], Some(&raw), None);
let saved = mob_to_nbt(&mob, [0; 4], Some(&raw), None, |_| None);
assert_eq!(saved.get("Fuse"), Some(&Tag::Short(7)));
}
}
@@ -4350,6 +4517,81 @@ mod tests {
let cleared = item_entity_to_nbt(&cleared, [1, 2, 3, 4], Some(&tag));
assert!(cleared.get("Thrower").is_none());
}
#[test]
fn animal_integer_loads_save_modeled_values_without_raw_source() {
let mut kind = crate::mobs::sensor_scan_tests::kind();
let def = Arc::make_mut(&mut kind);
def.id = "minecraft:pig".into();
def.species = crate::mobs::Species::Pig;
let types = [("minecraft:pig".into(), kind)].into();
for (input, expected) in [
(None, 0),
(Some(Tag::Int(0)), 0),
(Some(Tag::Byte(-7)), -7),
(Some(Tag::Short(13)), 13),
(Some(Tag::Int(i32::MIN)), i32::MIN),
(Some(Tag::Int(i32::MAX)), i32::MAX),
(Some(Tag::Long(2147483648)), i32::MIN),
(Some(Tag::Long(-2147483649)), i32::MAX),
(Some(Tag::Long(4294967296)), 0),
(Some(Tag::Long(i64::MAX)), -1),
(Some(Tag::Long(i64::MIN)), 0),
(Some(Tag::Float(2.5f32.to_bits())), 2),
(Some(Tag::Float((-2.5f32).to_bits())), -3),
(Some(Tag::Double(2.5f64.to_bits())), 2),
(Some(Tag::Double((-2.5f64).to_bits())), -3),
(Some(Tag::Float(2147483520f32.to_bits())), 2147483520),
(Some(Tag::Float(2147483648f32.to_bits())), i32::MAX),
(Some(Tag::Float((-2147483648f32).to_bits())), i32::MIN),
(Some(Tag::Float((-2147483904f32).to_bits())), i32::MIN),
(Some(Tag::Double(2147483647.75f64.to_bits())), i32::MAX),
(Some(Tag::Double(2147483648f64.to_bits())), i32::MAX),
(Some(Tag::Double((-2147483647.5f64).to_bits())), i32::MIN),
(Some(Tag::Double((-2147483648.5f64).to_bits())), i32::MIN),
(Some(Tag::Double(1e20f64.to_bits())), i32::MAX),
(Some(Tag::Double((-1e20f64).to_bits())), i32::MIN),
(Some(Tag::Float(1e20f32.to_bits())), i32::MAX),
(Some(Tag::Float((-1e20f32).to_bits())), i32::MIN),
] {
let mut raw = Tag::compound([
("id", string("minecraft:pig")),
("Pos", doubles(&[4.5, 200.0, 4.5])),
("NoAI", Tag::Byte(1)),
("NoGravity", Tag::Byte(1)),
("AgeLocked", Tag::Byte(1)),
("Unrelated", string("keep")),
]);
if let Some(value) = input.as_ref() {
for key in ["Age", "ForcedAge", "InLove"] {
put(&mut raw, key, value.clone());
}
}
let mob = mob_from_nbt(&raw, &types, 1, None).unwrap();
assert_eq!(
(mob.extra.age, mob.extra.forced_age, mob.extra.in_love),
(expected, expected, expected),
"{input:?}"
);
assert!(mob.no_ai && mob.no_gravity && mob.extra.age_locked);
for source in [None, Some(&raw)] {
let saved = mob_to_nbt(&mob, [0, 0, 0, 55], source, None);
for key in ["Age", "ForcedAge", "InLove"] {
assert_eq!(
saved.get(key),
Some(&Tag::Int(expected)),
"{key}: {input:?}"
);
}
assert_eq!(saved.get("NoAI"), Some(&Tag::Byte(1)));
assert_eq!(saved.get("AgeLocked"), Some(&Tag::Byte(1)));
assert_eq!(
saved.get("Unrelated"),
source.and_then(|tag| tag.get("Unrelated"))
);
}
}
}
#[test]
fn animal_nbt_preserves_signed_integer_in_love_and_defaults_after_removal() {
let mut kind = crate::mobs::sensor_scan_tests::kind();
@@ -4381,14 +4623,14 @@ mod tests {
// Query snapshots and saves both write the live field as an int,
// regardless of whether there is an old compound to preserve.
for old in [None, Some(&raw)] {
let mut saved = mob_to_nbt(&mob, [0, 0, 0, 1], old, None);
let mut saved = mob_to_nbt(&mob, [0, 0, 0, 1], old, None, |_| None);
assert_eq!(saved.get("InLove"), Some(&Tag::Int(expected)));
if let Tag::Compound(map) = &mut saved {
map.remove(&"InLove".into());
}
let cleared = mob_from_nbt(&saved, &types, 2, None).unwrap();
assert_eq!(cleared.extra.in_love, 0);
let saved = mob_to_nbt(&cleared, [0, 0, 0, 1], Some(&saved), None);
let saved = mob_to_nbt(&cleared, [0, 0, 0, 1], Some(&saved), None, |_| None);
assert_eq!(saved.get("InLove"), Some(&Tag::Int(0)));
}
}
@@ -4424,6 +4666,7 @@ mod tests {
[90.0, 10.0],
[1; 4],
None,
None,
);
assert_eq!(rotation(&tnt), [90.0, 10.0]);
let mut stand = ArmorStand::new(1, Vec3::ZERO, 0.0);
@@ -4969,7 +5212,7 @@ mod axolotl_nbt_tests {
ty.species = crate::mobs::Species::Axolotl;
let mut mob = Mob::new(1, kind, Vec3::ZERO, 0.0);
restore_axolotl_memories(&mut mob, &raw);
let saved = mob_to_nbt(&mob, [0; 4], Some(&raw), None);
let saved = mob_to_nbt(&mob, [0; 4], Some(&raw), None, |_| None);
let entry = saved
.get("Brain")
.and_then(|b| b.get("memories"))
@@ -5019,7 +5262,7 @@ mod axolotl_nbt_tests {
let m = mob_from_nbt(&input, &types, 1, None).unwrap();
let expected = kind == "tadpole" || value.unwrap_or(false);
for raw in [None, Some(&input)] {
let saved = mob_to_nbt(&m, [1; 4], raw, None);
let saved = mob_to_nbt(&m, [1; 4], raw, None, |_| None);
assert_eq!(
saved.get("FromBucket"),
Some(&Tag::Byte(i8::from(expected))),
@@ -5031,7 +5274,7 @@ mod axolotl_nbt_tests {
}
let pig = Mob::new(1, types["minecraft:pig"].clone(), Vec3::ZERO, 0.0);
assert!(
mob_to_nbt(&pig, [1; 4], None, None)
mob_to_nbt(&pig, [1; 4], None, None, |_| None)
.get("FromBucket")
.is_none()
);
@@ -5073,7 +5316,7 @@ mod axolotl_nbt_tests {
Tag::compound([("test:unrelated", Tag::compound([("value", Tag::Int(9))]))]),
)]),
)]);
let tag = mob_to_nbt(&m, [1, 2, 3, 4], Some(&raw), None);
let tag = mob_to_nbt(&m, [1, 2, 3, 4], Some(&raw), None, |_| None);
let memories = tag.get("Brain").unwrap().get("memories").unwrap();
assert!(memories.get("test:unrelated").is_some());
assert_eq!(
@@ -5191,7 +5434,7 @@ mod axolotl_nbt_tests {
crate::mobs::animal_brain::set_hunting_cooldown(&mut m, 0);
assert!(bucket_data(&m).get("HuntingCooldown").is_none());
let saved = mob_to_nbt(&m, [1, 2, 3, 4], Some(&tag), None);
let saved = mob_to_nbt(&m, [1, 2, 3, 4], Some(&tag), None, |_| None);
assert!(
saved
.get("Brain")
@@ -5229,7 +5472,7 @@ mod sheep_nbt_tests {
// Summoned white, then dyed red and sheared: the save has it now.
let raw = Tag::compound([("Color", Tag::Byte(0)), ("Sheared", Tag::Byte(0))]);
(m.extra.color, m.extra.sheared) = (14, true);
let tag = mob_to_nbt(&m, [1, 2, 3, 4], Some(&raw), None);
let tag = mob_to_nbt(&m, [1, 2, 3, 4], Some(&raw), None, |_| None);
assert_eq!(
(number(tag.get("Color")), number(tag.get("Sheared"))),
(Some(14), Some(1))
@@ -5268,13 +5511,13 @@ mod sheep_nbt_tests {
let protected = mob_from_nbt(&raw, &types, 1, None).unwrap();
assert!(protected.extra.cannot_be_hunted);
assert!(!protected.seen(None).huntable);
let saved = mob_to_nbt(&protected, [0; 4], Some(&raw), None);
let saved = mob_to_nbt(&protected, [0; 4], Some(&raw), None, |_| None);
assert_eq!(saved.get("CannotBeHunted"), Some(&Tag::Byte(1)));
for raw in [hoglin(None), hoglin(Some(0))] {
let prey = mob_from_nbt(&raw, &types, 2, None).unwrap();
assert!(!prey.extra.cannot_be_hunted);
assert!(prey.seen(None).huntable);
let saved = mob_to_nbt(&prey, [0; 4], Some(&raw), None);
let saved = mob_to_nbt(&prey, [0; 4], Some(&raw), None, |_| None);
assert_eq!(saved.get("CannotBeHunted"), Some(&Tag::Byte(0)));
}
}
@@ -5307,7 +5550,7 @@ mod sheep_nbt_tests {
Some("mymod:entities/boss")
);
assert_eq!(m.extra.death_loot_seed, 42);
let tag = mob_to_nbt(&m, [1, 2, 3, 4], None, None);
let tag = mob_to_nbt(&m, [1, 2, 3, 4], None, None, |_| None);
assert_eq!(
text(tag.get("DeathLootTable")).as_deref(),
Some("mymod:entities/boss")
@@ -5319,7 +5562,7 @@ mod sheep_nbt_tests {
("Pos", doubles(&[0.0, 64.0, 0.0])),
]);
let plain = mob_from_nbt(&plain, &types, 3, None).unwrap();
let tag = mob_to_nbt(&plain, [1, 2, 3, 4], None, None);
let tag = mob_to_nbt(&plain, [1, 2, 3, 4], None, None, |_| None);
assert!(tag.get("DeathLootTable").is_none() && tag.get("DeathLootTableSeed").is_none());
}
@@ -5345,7 +5588,7 @@ mod sheep_nbt_tests {
0.0,
);
m.extra.more_carrot_ticks = 33;
let tag = mob_to_nbt(&m, [1, 2, 3, 4], None, None);
let tag = mob_to_nbt(&m, [1, 2, 3, 4], None, None, |_| None);
assert_eq!(number(tag.get("MoreCarrotTicks")), Some(33));
let back = mob_from_nbt(&tag, &types, 2, None).unwrap();
assert_eq!(back.extra.more_carrot_ticks, 33);
@@ -5372,10 +5615,10 @@ mod sheep_nbt_tests {
Vec3::new(0.0, 4.0, 0.0),
0.0,
);
let tag = mob_to_nbt(&m, [1, 2, 3, 4], None, None);
let tag = mob_to_nbt(&m, [1, 2, 3, 4], None, None, |_| None);
assert_eq!(number(tag.get("Trusting")), Some(0));
m.extra.trusting = true;
let tag = mob_to_nbt(&m, [1, 2, 3, 4], None, None);
let tag = mob_to_nbt(&m, [1, 2, 3, 4], None, None, |_| None);
assert_eq!(number(tag.get("Trusting")), Some(1));
let back = mob_from_nbt(&tag, &types, 2, None).unwrap();
assert!(back.extra.trusting);
@@ -5404,7 +5647,7 @@ mod sheep_nbt_tests {
Vec3::new(0.0, 4.0, 0.0),
0.0,
);
let save = |m: &Mob| mob_to_nbt(m, [1, 2, 3, 4], None, None);
let save = |m: &Mob| mob_to_nbt(m, [1, 2, 3, 4], None, None, |_| None);
// The default description is not written; it comes back.
let tag = save(&m);
assert!(tag.get("description").is_none() && tag.get("hide_description").is_none());
@@ -5455,7 +5698,7 @@ mod sheep_nbt_tests {
)]);
let mut m = Mob::new(1, types["minecraft:pig"].clone(), Vec3::ZERO, 0.0);
m.add_effect(crate::brewing::effect("testfx:glow").unwrap().id, 200, 2);
let tag = mob_to_nbt(&m, [1, 2, 3, 4], None, None);
let tag = mob_to_nbt(&m, [1, 2, 3, 4], None, None, |_| None);
let Some(Tag::List(saved)) = tag.get("active_effects") else {
panic!("no active_effects");
};
@@ -5486,7 +5729,7 @@ mod sheep_nbt_tests {
200,
2,
);
let tag = mob_to_nbt(&m, [1, 2, 3, 4], None, None);
let tag = mob_to_nbt(&m, [1, 2, 3, 4], None, None, |_| None);
let Some(Tag::List(saved)) = tag.get("active_effects") else {
panic!("no active_effects");
};
@@ -5517,7 +5760,7 @@ mod sheep_nbt_tests {
Vec3::new(0.0, 4.0, 0.0),
0.0,
);
let tag = mob_to_nbt(&m, [1, 2, 3, 4], None, None);
let tag = mob_to_nbt(&m, [1, 2, 3, 4], None, None, |_| None);
assert_eq!(
text(tag.get("variant")).as_deref(),
Some("minecraft:temperate")
@@ -5528,7 +5771,7 @@ mod sheep_nbt_tests {
);
// Spawned in a warm biome with the mini sounds.
(m.extra.variant, m.extra.sound_variant) = (1, Some(2));
let tag = mob_to_nbt(&m, [1, 2, 3, 4], None, None);
let tag = mob_to_nbt(&m, [1, 2, 3, 4], None, None, |_| None);
assert_eq!(text(tag.get("variant")).as_deref(), Some("minecraft:warm"));
assert_eq!(
text(tag.get("sound_variant")).as_deref(),
@@ -5543,7 +5786,7 @@ mod sheep_nbt_tests {
0.0,
);
(hen.extra.variant, hen.extra.egg_time) = (2, 7795);
let tag = mob_to_nbt(&hen, [1, 2, 3, 4], None, None);
let tag = mob_to_nbt(&hen, [1, 2, 3, 4], None, None, |_| None);
assert_eq!(text(tag.get("variant")).as_deref(), Some("minecraft:cold"));
let back = mob_from_nbt(&tag, &types, 4, None).unwrap();
assert_eq!((back.extra.variant, back.extra.egg_time), (2, 7795));
@@ -5592,7 +5835,7 @@ mod sheep_nbt_tests {
Some(Tag::compound([("Age", Tag::Int(-100))])),
Some(Tag::compound([("Age", Tag::Int(100))])),
] {
let saved = mob_to_nbt(&mob, [0; 4], raw.as_ref(), None);
let saved = mob_to_nbt(&mob, [0; 4], raw.as_ref(), None, |_| None);
assert_eq!(
saved.get("Age"),
expected.as_ref(),
@@ -5623,7 +5866,7 @@ mod sheep_nbt_tests {
]);
let m = mob_from_nbt(&saved, &types, 1, None).unwrap();
assert_eq!(m.extra.age, -21359);
let tag = mob_to_nbt(&m, [1, 2, 3, 4], None, None);
let tag = mob_to_nbt(&m, [1, 2, 3, 4], None, None, |_| None);
assert_eq!(number(tag.get("Age")), Some(-21359));
assert_eq!(number(tag.get("AgeLocked")), Some(0));
assert_eq!(number(tag.get("VillagerDataFinalized")), Some(1));
@@ -5656,7 +5899,7 @@ mod sheep_nbt_tests {
assert_eq!(m.extra.counter, 2400);
m.extra.flag = true;
m.extra.counter = 123;
let tag = mob_to_nbt(&m, [1, 2, 3, 4], None, None);
let tag = mob_to_nbt(&m, [1, 2, 3, 4], None, None, |_| None);
assert_eq!(number(tag.get("Moistness")), Some(123));
assert_eq!(number(tag.get("GotFish")), Some(1));
let back = mob_from_nbt(&tag, &types, 2, None).unwrap();
@@ -5697,7 +5940,7 @@ mod sheep_nbt_tests {
);
m.extra.ints[5] = 48000;
m.extra.point = Some([12.0, 65.0, -30.0]);
let tag = mob_to_nbt(&m, [1, 2, 3, 4], None, None);
let tag = mob_to_nbt(&m, [1, 2, 3, 4], None, None, |_| None);
assert_eq!(number(tag.get("DespawnDelay")), Some(48000));
let Some(Tag::IntArray(target)) = tag.get("wander_target") else {
panic!("wander_target missing");
@@ -5735,7 +5978,7 @@ mod sheep_nbt_tests {
.unwrap();
let new = |kind: &str| Mob::new(1, types[kind].clone(), Vec3::new(0.0, 4.0, 0.0), 0.0);
let round_trip = |m: &Mob| {
let tag = mob_to_nbt(m, [1, 2, 3, 4], None, Some(&registry));
let tag = mob_to_nbt(m, [1, 2, 3, 4], None, Some(&registry), |_| None);
(mob_from_nbt(&tag, &types, 2, Some(&registry)).unwrap(), tag)
};
// A save with nothing but where the mob stands.
@@ -6065,7 +6308,7 @@ mod item_component_tests {
);
// The save keeps the packed `Variant` int and gains no `variant` name,
// which only `VariantUtils.writeVariant` species store.
let tag = mob_to_nbt(&horse, [1, 2, 3, 4], None, None);
let tag = mob_to_nbt(&horse, [1, 2, 3, 4], None, None, |_| None);
assert_eq!(tag.get("variant"), None);
assert!(
matches!(tag.get("Variant"), Some(Tag::Int(v)) if *v == 6 | 2 << 8),
@@ -6123,7 +6366,8 @@ mod item_component_tests {
else {
return m;
};
let tag = crate::data_cmd::merged(&mob_to_nbt(&m, [1, 2, 3, 4], None, None), data);
let tag =
crate::data_cmd::merged(&mob_to_nbt(&m, [1, 2, 3, 4], None, None, |_| None), data);
mob_from_nbt(&tag, &types, 2, None).unwrap()
};
// `entity_data={NoAI:1b}`: no id, so the egg's own type holds the data.
@@ -6381,6 +6625,54 @@ mod fireball_nbt_tests {
assert_eq!(item_frame_from_nbt(&frame, 1).unwrap().pos, [-1, 80, 6]);
}
#[test]
fn frame_missing_facing_loads_down_and_valid_directions_round_trip() {
// ItemFrame.readAdditionalSaveData: LEGACY_ID_CODEC, orElse(DOWN).
for kind in ["minecraft:item_frame", "minecraft:glow_item_frame"] {
// Explicit controls run before the missing-field assertion.
for facing in [Some(0), Some(1), Some(2), Some(3), Some(4), Some(5), None] {
let mut tag = Tag::compound([
("id", string(kind)),
("block_pos", Tag::IntArray(vec![4, 102, 6])),
("Fixed", Tag::Byte(1)),
("ItemRotation", Tag::Byte(5)),
("ItemDropChance", Tag::Float(0.25_f32.to_bits())),
(
"Item",
item_to_nbt(&crate::items::ItemStack::new("minecraft:diamond", 1)),
),
]);
if let Some(direction) = facing {
put(&mut tag, "Facing", Tag::Byte(direction));
}
let frame = item_frame_from_nbt(&tag, 17).unwrap();
assert_eq!(frame.kind(), kind);
assert_eq!(frame.id, 17);
assert!(frame.fixed);
assert_eq!(
frame.item.as_ref().unwrap().item.as_ref(),
"minecraft:diamond"
);
for raw in [None, Some(&tag)] {
let saved = item_frame_to_nbt(&frame, [1, 2, 3, 4], raw);
assert_eq!(
saved.get("Facing"),
Some(&Tag::Byte(facing.unwrap_or(0))),
"{kind}"
);
assert_eq!(saved.get("Item"), tag.get("Item"));
assert_eq!(saved.get("ItemRotation"), Some(&Tag::Byte(5)));
assert_eq!(saved.get("ItemDropChance"), tag.get("ItemDropChance"));
assert_eq!(saved.get("Fixed"), Some(&Tag::Byte(1)));
assert_eq!(saved.get("block_pos"), tag.get("block_pos"));
if facing.is_none() {
assert_eq!(saved.get("Pos"), Some(&doubles(&[4.5, 102.96875, 6.5])));
}
}
}
}
}
#[test]
fn a_named_frame_drops_with_its_custom_name() {
// `ItemFrame.getFrameItemStackWithData`: the frame item, then
@@ -6559,7 +6851,7 @@ mod neutral_anger_tests {
m.extra.anger = 400;
m.extra.anger_end = 3000;
m.extra.angry_at = Some(crate::mobs::Who::Player);
let tag = mob_to_nbt(&m, [1, 2, 3, 4], None, None);
let tag = mob_to_nbt(&m, [1, 2, 3, 4], None, None, |_| None);
assert_eq!(number(tag.get("anger_end_time")), Some(3000));
assert!(tag.get("angry_at").is_some());
let back = mob_from_nbt(&tag, &types, 2, None).unwrap();
@@ -6567,7 +6859,7 @@ mod neutral_anger_tests {
assert_eq!(back.extra.angry_at, Some(crate::mobs::Who::Player));
// A mob that is no NeutralMob writes no anger.
let pig = Mob::new(3, types["minecraft:pig"].clone(), Vec3::ZERO, 0.0);
let tag = mob_to_nbt(&pig, [1, 2, 3, 5], None, None);
let tag = mob_to_nbt(&pig, [1, 2, 3, 5], None, None, |_| None);
assert!(tag.get("anger_end_time").is_none());
// The anger that is left when the mob comes back.
assert_eq!(crate::mobs::anger_left(3000, 1000), 2000);
@@ -6575,6 +6867,118 @@ mod neutral_anger_tests {
}
}
#[cfg(test)]
mod uuid_bridge_tests {
use super::*;
use crate::mobs::{Species, sensor_scan_tests::kind};
/// A wolf, whose anger is `NeutralMob`'s, so its save carries `angry_at`.
/// No resources are needed: only the species decides that.
fn wolf(id: u32) -> Mob {
let mut kind = (*kind()).clone();
kind.id = "minecraft:wolf".into();
kind.species = Species::Wolf;
let mut m = Mob::new(id, Arc::new(kind), Vec3::ZERO, 0.0);
m.extra.anger = 400;
m.extra.anger_end = 3000;
m
}
/// A mob's anger at another mob is saved as that mob's UUID, which the game
/// layer answers for (`Who` itself carries none).
#[test]
fn a_mob_anger_target_is_saved_as_its_uuid() {
let mut m = wolf(1);
m.extra.angry_at = Some(Who::Mob(7));
let target = [7, 0, 0, 3];
let tag = mob_to_nbt(&m, [1, 2, 3, 4], None, None, |id| {
(id == 7).then_some(target)
});
assert_eq!(tag.get("angry_at"), Some(&Tag::IntArray(target.to_vec())));
assert_eq!(number(tag.get("anger_end_time")), Some(3000));
}
/// A target the map does not know (a mob that left the level) is not named:
/// nothing is written rather than a made-up UUID no entity answers for.
#[test]
fn an_unknown_anger_target_writes_no_angry_at() {
let mut m = wolf(1);
m.extra.angry_at = Some(Who::Mob(7));
let tag = mob_to_nbt(&m, [1, 2, 3, 4], None, None, |_| None);
assert_eq!(tag.get("angry_at"), None);
assert_eq!(number(tag.get("anger_end_time")), Some(3000));
}
/// The player is not an id: its UUID needs no map from the caller.
#[test]
fn a_player_anger_target_needs_no_map() {
let mut m = wolf(1);
m.extra.angry_at = Some(Who::Player);
let tag = mob_to_nbt(&m, [1, 2, 3, 4], None, None, |_| None);
assert_eq!(
tag.get("angry_at"),
Some(&Tag::IntArray(player_uuid().to_vec()))
);
}
/// The round trip both deferred fixes need: a mob's UUID written through the
/// save seam reads back as that mob, not as another one.
#[test]
fn a_saved_mob_uuid_resolves_back_to_it() {
let mut wolf = wolf(1);
let pig = {
let mut kind = (*kind()).clone();
kind.id = "minecraft:pig".into();
kind.species = Species::Pig;
Mob::new(9, Arc::new(kind), Vec3::ZERO, 0.0)
};
let (wolf_uuid, pig_uuid) = ([1, 2, 3, 4], [9, 0, 0, 1]);
let uuids: HashMap<u32, [i32; 4]> = HashMap::from([(1, wolf_uuid), (9, pig_uuid)]);
wolf.extra.angry_at = Some(Who::Mob(pig.id));
let tag = mob_to_nbt(&wolf, wolf_uuid, None, None, |id| uuids.get(&id).copied());
let Some(Tag::IntArray(stored)) = tag.get("angry_at") else {
panic!("anger at a mob is saved as that mob's UUID");
};
assert_eq!(stored, &pig_uuid.to_vec());
let mobs = [wolf, pig];
assert_eq!(
who_by_uuid(&uuids, &mobs, None, pig_uuid),
Some(Who::Mob(9))
);
// The other loaded mob is not mistaken for it.
assert_eq!(
who_by_uuid(&uuids, &mobs, None, wolf_uuid),
Some(Who::Mob(1))
);
// The player is the other half of `Who`, kept apart from the id map.
let player = [5, 5, 5, 5];
assert_eq!(
who_by_uuid(&uuids, &mobs, Some(player), player),
Some(Who::Player)
);
}
/// A UUID nothing loaded has is no entity at all: not another mob, and not
/// the projectile whose id shares the map.
#[test]
fn a_uuid_no_loaded_mob_has_resolves_to_nothing() {
let wolf = wolf(1);
let uuids: HashMap<u32, [i32; 4]> =
HashMap::from([(1, [1, 2, 3, 4]), (1000, [5, 0, 0, 1])]);
let mobs = [wolf];
// Never loaded (it left, or the save is another world's).
assert_eq!(who_by_uuid(&uuids, &mobs, None, [9, 9, 9, 9]), None);
// Loaded, but not a mob: `Who::Mob` would mean an arrow's id.
assert_eq!(who_by_uuid(&uuids, &mobs, None, [5, 0, 0, 1]), None);
// Nothing loaded at all resolves to nothing either.
assert_eq!(who_by_uuid(&uuids, &[], None, [1, 2, 3, 4]), None);
assert_eq!(
who_by_uuid(&uuids, &mobs, None, [1, 2, 3, 4]),
Some(Who::Mob(1))
);
}
}
#[cfg(test)]
mod held_item_name_tests {
use super::*;
@@ -6598,7 +7002,7 @@ mod held_item_name_tests {
helmet.damage = 40;
helmet.data_mut().custom_name = Some(horizon_world::nbt::Tag::String("Hat".into()));
crate::mobs::finalize::set_item_stack(&mut mob, crate::mobs::finalize::HEAD, helmet);
let raw = mob_to_nbt(&mob, [1, 2, 3, 4], None, None);
let raw = mob_to_nbt(&mob, [1, 2, 3, 4], None, None, |_| None);
let loaded = mob_from_nbt(&raw, &types, 2, None).unwrap();
let worn = crate::mobs::finalize::item_stack(&loaded, crate::mobs::finalize::HEAD).unwrap();
assert_eq!(worn.damage, 40);
@@ -6634,7 +7038,7 @@ mod held_item_name_tests {
let mut sword = crate::items::ItemStack::new("minecraft:diamond_sword", 1);
sword.data_mut().custom_name = Some(horizon_world::nbt::Tag::String("Old Sword".into()));
mob.extra.set_main_hand_stack(Some(sword));
let raw = mob_to_nbt(&mob, [1, 2, 3, 4], None, None);
let raw = mob_to_nbt(&mob, [1, 2, 3, 4], None, None, |_| None);
let mut loaded = mob_from_nbt(&raw, &types, 2, None).unwrap();
assert_eq!(
loaded
@@ -6648,7 +7052,7 @@ mod held_item_name_tests {
loaded
.extra
.set_main_hand(Some("minecraft:diamond_sword".into()));
let saved = mob_to_nbt(&loaded, [1, 2, 3, 4], Some(&raw), None);
let saved = mob_to_nbt(&loaded, [1, 2, 3, 4], Some(&raw), None, |_| None);
let restored = mob_from_nbt(&saved, &types, 3, None).unwrap();
assert_eq!(
restored.extra.main_hand.as_deref(),
@@ -6805,7 +7209,7 @@ mod axolotl_memory_ttl_tests {
ty.species = crate::mobs::Species::Axolotl;
let mut mob = Mob::new(1, kind, Vec3::ZERO, 0.0);
restore_axolotl_memories(&mut mob, &raw);
let saved = mob_to_nbt(&mob, [0; 4], Some(&raw), None);
let saved = mob_to_nbt(&mob, [0; 4], Some(&raw), None, |_| None);
let entry = memory(&saved, key).expect("loaded value must be present");
assert_eq!(entry.get("value"), Some(&Tag::Int(100)));
let expected = ttl.filter(|v| *v != i64::MAX).map(Tag::Long);
@@ -6818,7 +7222,7 @@ mod axolotl_memory_ttl_tests {
} else {
brain.mem.temptation_cooldown_ticks.set(99);
}
let saved = mob_to_nbt(&mob, [0; 4], Some(&raw), None);
let saved = mob_to_nbt(&mob, [0; 4], Some(&raw), None, |_| None);
let entry = memory(&saved, key).unwrap();
assert_eq!(entry.get("value"), Some(&Tag::Int(99)));
assert!(entry.get("ttl").is_none(), "stale raw TTL must not return");
@@ -6854,7 +7258,7 @@ mod generic_home_nbt_tests {
for raw in [None, Some(&raw)] {
for radius in [0, 5, -2, -1] {
m.ai.home = Some(([4, 5, 6], radius));
let saved = mob_to_nbt(&m, [0; 4], raw, None);
let saved = mob_to_nbt(&m, [0; 4], raw, None, |_| None);
if radius == -1 {
assert!(saved.get("home_radius").is_none());
assert!(saved.get("home_pos").is_none());
@@ -6864,7 +7268,7 @@ mod generic_home_nbt_tests {
}
}
m.ai.home = None;
let saved = mob_to_nbt(&m, [0; 4], raw, None);
let saved = mob_to_nbt(&m, [0; 4], raw, None, |_| None);
assert!(saved.get("home_radius").is_none());
assert!(saved.get("home_pos").is_none());
if raw.is_some() {
@@ -6957,7 +7361,7 @@ mod generic_home_nbt_tests {
for raw in [None, Some(&raw)] {
for home in [None, Some(([7, 8, 9], 5))] {
m.ai.home = home;
let saved = mob_to_nbt(&m, [0; 4], raw, None);
let saved = mob_to_nbt(&m, [0; 4], raw, None, |_| None);
assert_eq!(saved.get("home_pos"), Some(&Tag::IntArray(vec![4, 5, 6])));
assert_eq!(
saved.get("home_radius"),
@@ -7024,7 +7428,7 @@ mod piglin_live_anger_memory_tests {
for remaining in [600, 520] {
m.extra.anger = remaining;
for base in [None, Some(&raw)] {
let saved = mob_to_nbt(&m, [42, 43, 44, 45], base, None);
let saved = mob_to_nbt(&m, [42, 43, 44, 45], base, None, |_| None);
assert_eq!(
memory(&saved),
Some(&Tag::compound([
@@ -7046,7 +7450,7 @@ mod piglin_live_anger_memory_tests {
crate::mobs::piglin::set_guarded_anger_target(&mut m, Some([1, 2, 3, 4]));
m.extra.anger = 0;
m.ai.target = None;
let saved = mob_to_nbt(&m, [42, 43, 44, 45], Some(&raw()), None);
let saved = mob_to_nbt(&m, [42, 43, 44, 45], Some(&raw()), None, |_| None);
assert!(memory(&saved).is_none());
assert_unrelated(&saved);
}
@@ -7054,15 +7458,15 @@ mod piglin_live_anger_memory_tests {
#[test]
fn calm_loaded_and_missing_identity_states_are_distinct() {
let mut m = mob();
assert!(memory(&mob_to_nbt(&m, [42, 43, 44, 45], None, None)).is_none());
assert!(memory(&mob_to_nbt(&m, [42, 43, 44, 45], None, None, |_| None)).is_none());
let raw = raw();
// Loaded Brain semantics stay outside this live event projection.
let untouched = mob_to_nbt(&m, [42, 43, 44, 45], Some(&raw), None);
let untouched = mob_to_nbt(&m, [42, 43, 44, 45], Some(&raw), None, |_| None);
assert_eq!(memory(&untouched), memory(&raw));
crate::mobs::piglin::set_guarded_anger_target(&mut m, None);
assert_eq!(m.extra.anger, 600);
assert_eq!(m.ai.target, Some(Who::Player));
let saved = mob_to_nbt(&m, [42, 43, 44, 45], Some(&raw), None);
let saved = mob_to_nbt(&m, [42, 43, 44, 45], Some(&raw), None, |_| None);
assert!(memory(&saved).is_none());
assert_unrelated(&saved);
}
+109
View File
@@ -2,6 +2,47 @@
use crate::{hud::Rect, mesher::Vertex};
use std::collections::HashMap;
/// Test the flame's own envelope, which is taller and wider than the entity's box.
/// This only rejects camera-invisible geometry; callers keep other-view shader/mod paths.
pub fn in_view(
view: &crate::gfx::Frustum,
camera: glam::DVec3,
position: [f64; 3],
width: f32,
height: f32,
) -> bool {
let origin = (glam::DVec3::from(position) - camera).as_vec3();
bounds(origin, width, height).is_none_or(|(lo, hi)| view.intersects(lo, hi))
}
fn bounds(origin: glam::Vec3, width: f32, height: f32) -> Option<(glam::Vec3, glam::Vec3)> {
let scale = width * 1.4;
let remaining = height / scale;
// Unusual/invalid dimensions keep the original path. The ordinary aspect ratios also
// bound accumulated float error in the 0.45-step loop well below the spare 0.1 scale.
if !origin.is_finite()
|| !scale.is_finite()
|| !remaining.is_finite()
|| scale <= 0.0
|| !(0.0..=64.0).contains(&remaining)
{
return None;
}
// half starts at 0.5 and shrinks. z starts at 0.3 - trunc(remaining)*0.02 and
// recedes by 0.03 per layer. Two extra steps cover float rounding at layer boundaries.
let layers = (remaining / 0.45).ceil() + 2.0;
let z = 0.3 - remaining.trunc() * 0.02;
let depth = z.abs().max((z - layers * 0.03).abs());
// |rotated x/z| <= |x| + |z|, for every billboard yaw. The last layer starts
// below height and reaches 1.4*scale above its start; keep extra numerical slack.
let radius = (0.5 + depth) * scale;
let padding = glam::Vec3::splat(0.05 + origin.abs().max_element() * f32::EPSILON * 4.0);
Some((
origin - glam::Vec3::new(radius, 0.0, radius) - padding,
origin + glam::Vec3::new(radius, height + 1.5 * scale, radius) + padding,
))
}
#[allow(clippy::too_many_arguments)]
pub fn flames(
position: [f64; 3],
@@ -62,6 +103,74 @@ pub fn flames(
#[cfg(test)]
mod tests {
#[test]
fn culling_envelope_contains_emitted_flames_at_every_billboard_yaw() {
let rect = super::Rect {
u0: 0.0,
v0: 0.0,
u1: 1.0,
v1: 1.0,
w: 16,
h: 16,
};
let sprites =
super::HashMap::from([("block/fire_0".into(), rect), ("block/fire_1".into(), rect)]);
for (width, height) in [
(0.25, 0.25),
(0.5, 1.975),
(0.25, 0.9875),
(0.6, 1.8),
(0.9, 1.4),
(2.0, 1.9),
(5.0, 448.0),
] {
for scale in [0.0625, 1.0, 16.0] {
let (width, height) = (width * scale, height * scale);
for camera in [
glam::DVec3::ZERO,
glam::DVec3::new(30_000_000.0, -64.0, -30_000_000.0),
] {
let position = camera + glam::DVec3::new(7.25, -2.5, -12.0);
let (lo, hi) =
super::bounds((position - camera).as_vec3(), width, height).unwrap();
for yaw in (0..360).step_by(15) {
let mut out = Vec::new();
super::flames(
position.to_array(),
width,
height,
camera,
yaw as f32,
&sprites,
0,
&mut out,
);
assert!(!out.is_empty());
for v in out {
let p = glam::Vec3::from_array(v.pos);
assert!(
p.cmpge(lo).all() && p.cmple(hi).all(),
"{width}x{height}, yaw {yaw}: {p:?} outside {lo:?}..{hi:?}"
);
}
}
}
}
}
}
#[test]
fn unsupported_dimensions_keep_the_unculled_path() {
for (width, height) in [
(0.0, 1.0),
(f32::NAN, 1.0),
(1.0, f32::INFINITY),
(0.001, 100.0),
] {
assert!(super::bounds(glam::Vec3::ZERO, width, height).is_none());
}
}
#[test]
fn flames_cover_entity_height_and_alternate_animated_sprites() {
let rect = |u0| super::Rect {
+62 -1
View File
@@ -85,7 +85,8 @@ impl FurnaceCooking for Furnace {
let can_burn = match &self.items[2] {
None => true,
Some(out) => {
out.item == recipe.result.item && out.count + recipe.result.count <= max
out.same_item_same_components(&recipe.result)
&& out.count + recipe.result.count <= max
}
};
if can_burn {
@@ -249,6 +250,66 @@ mod tests {
(items, recipes, providers)
}
#[test]
fn cooking_requires_output_components_and_capacity() {
let (items, recipes, providers) = data("output-components");
let mut different = ItemStack::new("test:ingot", 1);
different.data_mut().custom_name = Some(horizon_world::nbt::Tag::String("Cold".into()));
for (output, eligible) in [
(Some(different), false),
(None, true),
(Some(ItemStack::new("test:ingot", 1)), true),
(Some(ItemStack::new("test:ingot", 64)), false),
(Some(ItemStack::new("test:ingot", 63)), true),
] {
let before = output.clone();
let mut f = Furnace {
kind: FurnaceKind::Furnace,
items: [
Some(ItemStack::new("test:ore", 3)),
Some(ItemStack::new("test:coal", 1)),
output,
],
lit_remaining: 0,
lit_total: 0,
cooking_timer: 0,
cooking_total: 200,
speed: 1.0,
recipes_used: HashMap::new(),
};
let recipe = recipes
.cook(f.kind.recipe_type(), f.items[0].as_ref().unwrap())
.unwrap();
assert_eq!(
(&*recipe.result.item, recipe.result.count, recipe.time),
("test:ingot", 1, 200)
);
let mut drops = Vec::new();
assert_eq!(
f.tick(&recipes, &items, &providers, &mut drops),
eligible.then_some(true),
"output {before:?}"
);
assert!(drops.is_empty());
if eligible {
assert!(f.items[1].is_none());
for _ in 1..200 {
f.tick(&recipes, &items, &providers, &mut drops);
}
assert_eq!(f.items[0].as_ref().unwrap().count, 2);
assert_eq!(
f.items[2].as_ref().unwrap().count,
before.as_ref().map_or(0, |s| s.count) + 1
);
} else {
assert_eq!(f.items[0].as_ref().unwrap().count, 3);
assert_eq!(f.items[1].as_ref().unwrap().count, 1);
assert_eq!(f.items[2], before);
assert_eq!((f.lit_remaining, f.cooking_timer), (0, 0));
}
}
}
/// The furnace sleeps while `idle`, so its tick must change nothing then: run the
/// real tick on every idle case and compare, and check a furnace that can smelt
/// isn't idle and does change.
+204 -66
View File
@@ -71,6 +71,7 @@ mod boarding;
mod boat_authority_fixture;
mod boat_leads;
mod boat_passengers;
mod boat_rider_fixture;
mod boats;
mod books;
mod bossbars;
@@ -2955,6 +2956,7 @@ struct Game<'a> {
dragon_model: Option<horizon_client::mobs::Part>,
/// Map textures waiting for the next HUD batch to upload them.
map_patches: Vec<horizon_client::font::Patch>,
name_tag_layouts: names::NameTagLayouts,
text_displays: std::collections::HashMap<i32, text_displays::RemoteTextDisplay>,
interactions: std::collections::HashMap<i32, interactions::RemoteInteraction>,
/// The fence knots a server tracks to this client, by entity id, at their
@@ -3041,6 +3043,9 @@ struct Game<'a> {
/// Where item entities are, while the block entities tick (hoppers).
item_columns: Option<machines::ItemColumns>,
creative_screen: Creative,
/// `CreativeModeInventoryScreen.ignoreTextInput`: the chat key opened the
/// search tab, so the letter it carries is not typed into the box.
creative_ignore_text: bool,
control: Option<horizon_client::control::Server>,
/// The Agent Control setting the socket was last opened or closed for (`agent_settings`).
agent_control_on: bool,
@@ -3172,6 +3177,10 @@ struct Game<'a> {
pause_menu: bool,
pause_focus: Option<u8>,
death_focus: Option<u8>,
/// The screen ([`Game::key_screen`]) the menu focus belongs to. Another
/// screen in front drops it, as `Gui.setScreen` drops the focus of the
/// screen it leaves (`Screen.clearFocus`).
focus_screen: u64,
/// MultiPlayerGameMode.previousLocalPlayerMode (F3+N goes back to it).
previous_mode: Option<horizon_client::command::args::GameMode>,
gamemode_switcher: Option<horizon_client::game_mode_switcher::GameModeSwitcher>,
@@ -7529,12 +7538,13 @@ impl Game<'_> {
/// game event from the player, at its feet.
pub(super) fn stop_using_item(&mut self, stack: &ItemStack) {
self.using = None;
self.use_vibration(stack);
self.use_vibration(stack, "minecraft:item_interact_finish");
}
/// `ItemStack.causeUseVibration` for `stack` used by the player: an
/// `ITEM_INTERACT_FINISH` game event from the player, at its feet, when the
/// stack's use effects ask for interaction vibrations (the default).
pub(super) fn use_vibration(&mut self, stack: &ItemStack) {
/// `ItemStack.causeUseVibration` for `stack` used by the player: the
/// `event` game event (`ITEM_INTERACT_START` when the use begins,
/// `ITEM_INTERACT_FINISH` when it ends) from the player, at its feet, when
/// the stack's use effects ask for interaction vibrations (the default).
pub(super) fn use_vibration(&mut self, stack: &ItemStack, event: &'static str) {
if !self
.items()
.get(&stack.item)
@@ -7546,7 +7556,7 @@ impl Game<'_> {
return;
};
let ctx = self.player_context(None);
self.post_game_event("minecraft:item_interact_finish", [p.x, p.y, p.z], ctx);
self.post_game_event(event, [p.x, p.y, p.z], ctx);
}
/// `ServerPlayerGameMode.tick` while a player digs: level event 2020 every
/// fourth tick since the START (the hit sound), else 2019 (the crack only),
@@ -7600,10 +7610,8 @@ impl Game<'_> {
ticks: 0,
});
}
let block_break_speed = self.block_break_speed();
let state = self.world.block(hit.pos[0], hit.pos[1], hit.pos[2]);
let facts = self.res.facts().get(state);
if facts.hardness < 0.0 {
if self.res.facts().get(state).hardness < 0.0 {
// The server still counts its destroy ticks on an unbreakable block.
let m = self.mining.as_mut().ok_or("mining")?;
m.ticks += 1;
@@ -7611,36 +7619,7 @@ impl Game<'_> {
self.destroy_progress_event(pos, hit.face, ticks);
return Ok(());
}
// Player.getDestroySpeed / BlockBehaviour.getDestroyProgress.
let held = self.held();
let mut speed = self.items().mining_speed(held, &facts.name);
let hardness = facts.hardness;
let harvest = !facts.requires_tool || self.items().correct_for_drops(held, &facts.name);
speed = self.modify_destroy_speed(speed);
speed *= block_break_speed;
let player = self.player.as_ref().ok_or("no player")?;
if !player.ground.on_ground {
speed /= 5.0;
}
let eye = self.eye_position().unwrap_or_default();
if self
.res
.facts()
.get(self.world.block(
eye[0].floor() as i32,
eye[1].floor() as i32,
eye[2].floor() as i32,
))
.fluid
== Fluid::Water
{
speed *= self.submerged_mining_speed();
}
let progress = if hardness == 0.0 {
1.0
} else {
speed / hardness / if harvest { 30.0 } else { 100.0 }
};
let progress = self.destroy_progress(hit.pos);
self.swing_arm();
let m = self.mining.as_mut().ok_or("mining")?;
m.progress += progress;
@@ -7817,18 +7796,29 @@ impl Game<'_> {
/// farmland breaks. The local player's and every server player's landings.
/// The level events they send go out too: a turtle egg's break particles
/// and sound (`decreaseEggs`' 2001, `Level.destroyBlock`'s for the last).
fn landing_block_updates(&mut self, event: &horizon_client::landing::FallEvent) {
///
/// A mod hears the change first (`native_entity_change_block_v2`, Bukkit's
/// `EntityChangeBlockEvent`): cancelling puts the block back and skips the
/// updates and the events, so nothing the change would have caused happens -
/// the crop on trampled farmland stays, because breaking it is the
/// neighbour update below and not a write of its own.
fn landing_block_updates(
&mut self,
player: Option<u128>,
event: &horizon_client::landing::FallEvent,
) {
use horizon_client::landing::FallEvent;
let (position, flags) = match *event {
// The block the landing changed, what it replaced, and what it became.
let (position, from, to, flags) = match *event {
FallEvent::Write {
position, flags, ..
} => (position, flags as i32),
position,
from,
state,
flags,
} => (position, from, state, flags as i32),
FallEvent::Destroy {
position, state, ..
} => {
self.level_event_at(2001, [position.x, position.y, position.z], state.0 as i32);
(position, level_flags::UPDATE_ALL)
}
} => (position, state, self.air_state(), level_flags::UPDATE_ALL),
FallEvent::LevelEvent {
event,
position,
@@ -7839,8 +7829,49 @@ impl Game<'_> {
}
_ => return,
};
if self.landing_change_cancelled(player, position, from, to) {
self.world
.set(position.x, position.y, position.z, from)
.ok();
return;
}
if matches!(event, FallEvent::Destroy { .. }) {
self.level_event_at(2001, [position.x, position.y, position.z], from.0 as i32);
}
self.with_level(|b, level, ticks| b.notify_written(level, ticks, position, flags));
}
/// `Blocks.AIR.defaultBlockState`: what a landing's destroy leaves.
fn air_state(&self) -> BlockStateId {
self.pack
.assets
.registry
.default_state("minecraft:air")
.unwrap_or(BlockStateId(0))
}
/// Whether a mod cancelled a landing's block change
/// (`native_entity_change_block_v2`): asked with the two blocks' names and
/// the player whose landing it was (`None` when no player did it).
fn landing_change_cancelled(
&mut self,
player: Option<u128>,
position: BlockPos,
from: BlockStateId,
to: BlockStateId,
) -> bool {
let name = |s: BlockStateId| self.res.facts().get(s).name.to_string();
let (from, to) = (name(from), name(to));
let mut cancel = false;
self.native_entity_change_block_v2(
player,
None,
"minecraft:player",
[position.x, position.y, position.z],
&from,
&to,
&mut cancel,
);
cancel
}
/// The ticks the frame shows (animated textures, the HUD, a shader pack's clock): the
/// client's, or under `HORIZON_STILL` (`FixedStep::still`) the world's game time, which
/// moves only while the world runs.
@@ -8080,8 +8111,6 @@ impl Game<'_> {
if let Some(log) = self.world.block_log.as_mut() {
log.extend(held);
}
// Everything that has joined has had its spawn packet.
self.ordered.announced = horizon_client::join_seq::JoinSeq::upcoming();
self.tick_friends();
horizon_client::spans::time("tick_hand", || self.tick_hand());
horizon_client::spans::time("tick_sleep", || self.tick_sleep());
@@ -8100,6 +8129,10 @@ impl Game<'_> {
self.run_ordered_ticks(horizon_client::join_seq::JoinSeq::MAX, true)?;
self.apply_late_wind_pushes();
}
// Everything that has joined has had its spawn packet and, if it joined
// before the entity passes (TNT a piston or repeater primed), its first
// tick in the late pass above (#10063).
self.ordered.announced = horizon_client::join_seq::JoinSeq::upcoming();
if running {
horizon_client::spans::time("tick_moving_pistons", || self.tick_moving_pistons())?;
horizon_client::spans::time("tick_block_entities", || self.tick_block_entities())?;
@@ -8650,7 +8683,7 @@ impl Game<'_> {
self.capture(true);
}
}
fn draw_item(&self, batch: &mut HudBatch, stack: &ItemStack, x: f32, y: f32) {
pub(super) fn draw_item(&self, batch: &mut HudBatch, stack: &ItemStack, x: f32, y: f32) {
// `GuiGraphics.renderItem`: the resolved item model (tints, foil and
// special renderers included), then the decorations.
self.draw_item_model(batch, stack, x, y);
@@ -8740,7 +8773,7 @@ impl Game<'_> {
}
/// `GuiGraphicsExtractor.itemDecorations`: the count (or `count_text`)
/// and damage bar.
fn draw_item_decorations(
pub(super) fn draw_item_decorations(
&self,
batch: &mut HudBatch,
stack: &ItemStack,
@@ -10826,6 +10859,10 @@ impl Game<'_> {
pub(super) fn has_control_down(&self) -> bool {
self.modifiers.control_key()
}
/// The same live word for Alt (`InputWithModifiers.hasAltDown`).
pub(super) fn has_alt_down(&self) -> bool {
self.modifiers.alt_key()
}
/// Real events word the modifiers by their own `ModifiersChanged`; a
/// control socket's `key` is synthesized, so it words the bit directly
/// and every reader can ask the one live state.
@@ -11275,12 +11312,27 @@ impl Game<'_> {
} else {
self.yaw
};
// Packs may move vertices or draw from another camera; TAA jitters the
// projection beyond this unjittered frustum. Keep their original geometry path.
let fire_view = self.view_frustum.as_ref().filter(|_| {
self.pack_ids.is_none()
&& self.native_shaders.picked.is_empty()
&& !self.settings.horizon.taa
});
let fire_visible = |position, width, height| {
fire_view.is_none_or(|view| {
horizon_client::fire_render::in_view(view, camera, position, width, height)
})
};
laps.lap("eg_prep");
for m in &self.mobs {
// Blaze.isOnFire: a charged blaze burns.
let blazing = m.species() == horizon_client::mobs::Species::Blaze && m.extra.flag;
if (m.is_on_fire() || blazing) && !m.removed {
let at = m.previous.lerp(m.position, alpha);
if !fire_visible([at.x, at.y, at.z], m.width(), m.height()) {
continue;
}
horizon_client::fire_render::flames(
[at.x, at.y, at.z],
m.width(),
@@ -11305,20 +11357,26 @@ impl Game<'_> {
let feet = self
.previous_position
.lerp(p.position, self.clock.camera_fraction());
horizon_client::fire_render::flames(
if fire_visible(
[feet.x, feet.y, feet.z],
p.dimensions.width,
p.dimensions.height,
camera,
fire_yaw,
&self.gui.atlas.sprites,
self.world.light(
feet.x.floor() as i32,
feet.y.floor() as i32,
feet.z.floor() as i32,
),
&mut out,
);
) {
horizon_client::fire_render::flames(
[feet.x, feet.y, feet.z],
p.dimensions.width,
p.dimensions.height,
camera,
fire_yaw,
&self.gui.atlas.sprites,
self.world.light(
feet.x.floor() as i32,
feet.y.floor() as i32,
feet.z.floor() as i32,
),
&mut out,
);
}
}
laps.lap("eg_fire_mobs");
for item in &self.dropped {
@@ -11329,6 +11387,9 @@ impl Game<'_> {
.resists_fire(&item.stack, "minecraft:in_fire")
{
let at = item.previous.lerp(item.position, alpha);
if !fire_visible([at.x, at.y, at.z], 0.25, 0.25) {
continue;
}
horizon_client::fire_render::flames(
[at.x, at.y, at.z],
0.25,
@@ -11350,6 +11411,9 @@ impl Game<'_> {
if stand.fire_ticks > 0 && !stand.marker {
let scale = if stand.small { 0.5 } else { 1.0 };
let at = stand.render_position(alpha);
if !fire_visible([at.x, at.y, at.z], 0.5 * scale, 1.975 * scale) {
continue;
}
horizon_client::fire_render::flames(
[at.x, at.y, at.z],
0.5 * scale,
@@ -11453,7 +11517,17 @@ impl Game<'_> {
);
// Text, block and item displays (DisplayRenderer's shadow radius).
casters.extend(self.text_displays.values().filter_map(|d| d.shadow(a)));
// Packs may move vertices or draw from another view.
let shadow_view = self
.view_frustum
.as_ref()
.filter(|_| self.pack_ids.is_none() && self.native_shaders.picked.is_empty());
for (pos, radius, strength) in casters {
if let Some(view) = shadow_view
&& !horizon_client::shadows::in_view(view, camera, pos, radius, strength)
{
continue;
}
horizon_client::shadows::shadow(
&self.world,
self.res.facts(),
@@ -13864,7 +13938,7 @@ impl Game<'_> {
return Ok(UseResult::Pass);
};
// `BucketItem.use`: `level.mayInteract` and `mayUseItemAt`.
if !self.may_build() {
if !self.may_build() && !self.can_place_on_in_adventure(hit.pos) {
return Ok(UseResult::Fail);
}
let mut canceled = false;
@@ -13915,7 +13989,7 @@ impl Game<'_> {
};
let clicked = self.world.block(hit.pos[0], hit.pos[1], hit.pos[2]);
if let Some((content, sound, mob)) = bucket {
if !self.may_build() {
if !self.may_build() && !self.can_place_on_in_adventure(hit.pos) {
return Ok(UseResult::Fail);
}
let target = self.bucket_target(content, &hit);
@@ -14031,7 +14105,7 @@ impl Game<'_> {
}
}
if item == "flint_and_steel" || item == "fire_charge" {
if !self.may_build() {
if !self.may_build() && !self.can_place_on_in_adventure(hit.pos) {
return Ok(UseResult::Fail);
}
let name = self.res.facts().get(clicked).name.clone();
@@ -16166,6 +16240,8 @@ impl Game<'_> {
});
}
fn render(&mut self) -> Result<bool> {
let prepare = spans::guard("prepare");
let mut pr = spans::laps();
// The window's GPU holds the atlas: its copy in memory is kept
// compressed (a no-op once it is).
if self.renderer.is_some() {
@@ -16183,6 +16259,9 @@ impl Game<'_> {
self.apply_ui_settings();
}
self.observe_screen();
// `Gui.setScreen` clears the focus of the screen it leaves: a menu
// that comes back starts with no widget highlighted.
self.drop_left_screen_focus();
// Minecraft.doWorldLoad: the LevelLoadingScreen while single player's
// server starts, with the server's chunk map and progress.
if let Some(starting) = &self.integrated_starting {
@@ -16204,6 +16283,8 @@ impl Game<'_> {
upgrade,
self.cursor,
);
pr.lap("pr_loading");
drop(prepare);
return renderer.render(&loading_frame(&hud));
}
return Ok(false);
@@ -16243,6 +16324,8 @@ impl Game<'_> {
Some(&status),
progress,
);
pr.lap("pr_loading");
drop(prepare);
return renderer.render(&loading_frame(&hud));
}
return Ok(false);
@@ -16259,6 +16342,8 @@ impl Game<'_> {
loader.progress.line(),
loader.progress.fraction(),
);
pr.lap("pr_loading");
drop(prepare);
return renderer.render(&loading_frame(&hud));
}
return Ok(false);
@@ -16275,6 +16360,8 @@ impl Game<'_> {
reload.progress.fraction(),
self.settings.file.bool("darkMojangStudiosBackground"),
);
pr.lap("pr_loading");
drop(prepare);
return renderer.render(&loading_frame(&hud));
}
return Ok(false);
@@ -16286,6 +16373,7 @@ impl Game<'_> {
else {
return Ok(false);
};
pr.lap("pr_setup");
let alpha = self.clock.fraction();
// Camera.getCameraEntityPartialTicks: the view, the hand and the
// player's own model stay smooth while the level is frozen.
@@ -16325,8 +16413,10 @@ impl Game<'_> {
self.pitch,
),
};
pr.lap("pr_camera");
self.audio
.set_listener([camera.x, camera.y, camera.z], view_yaw, view_pitch);
pr.lap("pr_listener");
let block = [
camera.x.floor() as i32,
camera.y.floor() as i32,
@@ -16348,6 +16438,7 @@ impl Game<'_> {
renderer.start_occlusion(camera, (self.streaming.radius * 16) as f32)
});
}
pr.lap("pr_occlusion");
let biome = self
.world
.chunks
@@ -16395,12 +16486,14 @@ impl Game<'_> {
std::array::from_fn(|i| sky.sky_color[i] + (flash[i] - sky.sky_color[i]) * 0.22);
sky.sky_light_factor = 1.0;
}
pr.lap("pr_environment");
// FishingHookRenderer's line, then DebugRenderer: chunk borders and
// hitboxes (F3+G, F3+B).
let mut lines = self.fishing_line(alpha, size);
let g = self.debug_gizmos(alpha as f32, camera.to_array());
lines.extend(g.lines);
let on_top = g.on_top;
pr.lap("pr_debug");
let colored_lights = if self.title.is_none() {
self.native_frame_visuals(camera, alpha, &mut lines)
} else {
@@ -16418,12 +16511,14 @@ impl Game<'_> {
} else {
Vec::new()
};
pr.lap("pr_native_visuals");
let view = horizon_client::camera::view_matrix(view_yaw, view_pitch);
let world_primitives = if self.title.is_none() {
self.world_primitive_geometry(camera, alpha, view)
} else {
Default::default()
};
pr.lap("pr_primitives");
let at = camera.to_array();
// GameRenderer.render3dHud: the debug crosshair, first person with the HUD shown.
let mut crosshair = horizon_client::gizmos::Gizmos::default();
@@ -16503,6 +16598,7 @@ impl Game<'_> {
} else {
Vec::new()
};
pr.lap("pr_outline");
let title = self.shows_panorama();
let (yaw, pitch) = if title {
// PanoramaRenderer: the title cube spins at 0.1 degrees per tick, pitched 10 degrees.
@@ -16561,11 +16657,13 @@ impl Game<'_> {
dimension: self.dim.id().to_string(),
}
});
pr.lap("pr_projection");
// GUI hooks project against this frame's camera, including its bob/spin and FOV.
let mut hud = horizon_client::spans::time("hud", || self.hud(size));
self.flush_ime(&mut hud);
self.native_gui_render_post(&mut hud, self.ui.hide_gui, self.focused);
self.resolve_inline_profiles(&hud.inline_profiles);
pr.lap("pr_hud");
self.locator_camera =
(!title).then_some((camera.to_array(), view_yaw, view_pitch, fov_degrees));
let requested_post_effects = self.requested_post_effects();
@@ -16582,6 +16680,7 @@ impl Game<'_> {
self.see_through_vertices.clear();
self.glowing_vertices.get_mut().clear();
self.shadow_only_vertices.get_mut().clear();
pr.lap("pr_entity_setup");
if !title {
let (built, static_sections, visible_static_sections, static_stats) =
spans::time("entity_geometry", || {
@@ -16598,6 +16697,7 @@ impl Game<'_> {
// be drawn over the title (#8192). Its models stay stored.
self.skin_frame.get_mut().clear_draws();
}
pr.lap("pr_entities");
let bolts = if title {
Vec::new()
} else {
@@ -16612,6 +16712,7 @@ impl Game<'_> {
self.dragon_fireball_geometry(camera, alpha, &mut dragon);
entities.extend(dragon);
}
pr.lap("pr_dragon_bolts");
let (celestial, stars) = if title {
(Vec::new(), None)
} else if self.dim == Dim::Overworld
@@ -16625,6 +16726,7 @@ impl Game<'_> {
} else {
(Vec::new(), None)
};
pr.lap("pr_celestial");
let (mut hand, hand_items) = if title || self.scoping() {
Default::default()
} else {
@@ -16637,6 +16739,7 @@ impl Game<'_> {
let aspect = size[0] as f32 / size[1].max(1) as f32;
self.item_activation_geometry(camera_alpha as f32, view, aspect, &mut hand);
}
pr.lap("pr_hand");
let screen = if title {
Vec::new()
} else {
@@ -16651,6 +16754,7 @@ impl Game<'_> {
self.end_geometry(camera, &mut entities);
}
self.outline_fill(&outline, camera, &mut entities);
pr.lap("pr_screen_end");
let weather = if !title && self.can_have_weather() && self.settings.horizon.weather {
horizon_client::spans::time("weather", || self.weather_geometry(camera, alpha))
} else {
@@ -16660,16 +16764,19 @@ impl Game<'_> {
.then(|| self.border_draw(camera.to_array(), alpha as f32))
.flatten();
let see_through = std::mem::take(&mut self.see_through_vertices);
pr.lap("pr_weather_border");
if title {
self.dynamic_lights.clear();
} else {
self.gather_dynamic_lights(camera, alpha);
}
pr.lap("pr_lights");
let pack_inputs = if title {
Default::default()
} else {
self.shader_pack_inputs(camera, alpha as f32, &sky)
};
pr.lap("pr_shader_inputs");
// Map textures and font glyphs changed while building this frame's
// geometry (game/maps.rs, world text) upload with this batch; draining
// map_patches any earlier leaves them a frame behind.
@@ -16836,6 +16943,9 @@ impl Game<'_> {
},
chunk_fade: self.settings.chunk_fade,
};
pr.lap("pr_frame");
// Renderer encoding and GPU waiting belong to render, not prepare.
drop(prepare);
let result = match self.renderer.as_mut() {
Some(renderer) => {
renderer.set_pass_timing(self.debug || self.gpu_passes);
@@ -16843,6 +16953,7 @@ impl Game<'_> {
}
None => Err("renderer".into()),
};
let _cleanup = spans::guard("pr_cleanup");
// A frame the surface refused (occluded, lost, timed out) is `Ok(false)`.
self.frame_drawn = matches!(result, Ok(true));
// Restore storage even when rendering fails (for example, a lost surface).
@@ -16992,6 +17103,9 @@ impl Game<'_> {
/// `Screen.keyPressed` hears the raw one, and only a raw key settles what
/// the container's hotbar keys are now (`checkHotbarKeyPressed`).
fn key_pressed(&mut self, key: KeyCode, raw: KeyCode) -> Result<()> {
// `CreativeModeInventoryScreen.keyPressed` clears `ignoreTextInput` on
// every key, before it decides anything.
self.creative_ignore_text = false;
if self.sleeping.is_some()
&& self.screen == Screen::Playing
&& !matches!(
@@ -17095,6 +17209,17 @@ impl Game<'_> {
&& self.search_tab_hotbar_key(key) => {}
KeyCode::KeyT if self.screen == Screen::Inventory => self.recipe_focus_search(),
KeyCode::KeyT if self.screen == Screen::Playing => self.open_chat_method(""),
// CreativeModeInventoryScreen.keyPressed: the chat key opens the
// search tab from another tab, and `ignoreTextInput` drops the
// letter it would have typed.
KeyCode::KeyT
if self.screen == Screen::Creative && !self.creative_screen.tab().search =>
{
if let Some(tab) = self.creative_screen.search_tab() {
select_creative_tab(&mut self.creative_screen, &mut self.inv_clicks, tab);
self.creative_ignore_text = true;
}
}
// key.advancements.
KeyCode::KeyL if self.screen == Screen::Playing => {
self.open_advancements(false);
@@ -19263,6 +19388,8 @@ impl Game<'_> {
let steps = self.clock.advance(elapsed);
for _ in 0..steps {
let started = Instant::now();
// Minecraft.tick: ClientTickEvent.Pre before any of it.
self.native_client_tick_pre();
if self.title.is_none() && !self.pause {
if self.tick_script.armed() {
self.tick_script_step(event_loop);
@@ -19285,6 +19412,8 @@ impl Game<'_> {
self.tick_chat_queue();
self.tick_music();
self.tick_sound_engine();
// ClientTickEvent.Post, the tick's work done.
self.native_client_tick_post();
self.clock.record(started.elapsed());
if let Some(report) = self.clock.take_sprint_report() {
self.sprint_report(&report);
@@ -19305,7 +19434,7 @@ impl Game<'_> {
}
result => result?,
};
self.apply_cursor_icon();
horizon_client::spans::time("pr_cursor", || self.apply_cursor_icon());
// The frame's spans, taken every frame they are recorded so they never
// pile up: the slow-frame line's, and the session's (`perf.frame`).
let frame_spans = if horizon_client::spans::recording_here() {
@@ -19969,6 +20098,7 @@ impl<'a> Game<'a> {
breath_clouds: Vec::new(),
dragon_model: prepared.dragon_model,
map_patches: Vec::new(),
name_tag_layouts: Default::default(),
text_displays: Default::default(),
interactions: Default::default(),
remote_knots: Default::default(),
@@ -20022,6 +20152,7 @@ impl<'a> Game<'a> {
mod_chunk_tickets: HashMap::new(),
item_columns: None,
creative_screen: prepared.creative_screen,
creative_ignore_text: false,
control: None,
agent_control_on: false,
agent_last: None,
@@ -20093,6 +20224,7 @@ impl<'a> Game<'a> {
pause_menu: true,
pause_focus: None,
death_focus: None,
focus_screen: 0,
previous_mode: None,
gamemode_switcher: None,
clipboard: Default::default(),
@@ -21807,6 +21939,10 @@ fn start_game<'a>(
},
);
}
if let Some(directory) = std::env::var_os("HORIZON_BOAT_RIDER_FIXTURE") {
game.run_boat_rider_fixture(std::path::Path::new(&directory))?;
return Ok(None);
}
if let Some(directory) = std::env::var_os("HORIZON_BOAT_AUTHORITY_FIXTURE") {
game.run_boat_authority_fixture(std::path::Path::new(&directory))?;
return Ok(None);
@@ -22497,6 +22633,8 @@ pub fn run_with() -> Result<()> {
return Ok(());
}
let _ = horizon_client::health::HURT_SERVER.set(native_hooks::hurt_server);
let _ = horizon_client::health::DAMAGE_PRE.set(native_hooks::damage_pre);
let _ = horizon_client::health::DAMAGE_POST.set(native_hooks::damage_post);
let _ = horizon_client::block_contact::HOOK.set(native_hooks::block_contact);
let _ = horizon_client::health::KNOCKBACK.set(native_hooks::knockback);
// Before any worker starts: on Linux they go behind the server thread
@@ -692,7 +692,9 @@ impl Game<'_> {
let uuid = self.persist.uuids.get(&m.id).copied().unwrap_or_default();
let raw = self.persist.raw.get(&m.id);
let registry = Some(self.pack.assets.registry);
let mut tag = horizon_client::entity_nbt::mob_to_nbt(m, uuid, raw, registry);
let mut tag = horizon_client::entity_nbt::mob_to_nbt(m, uuid, raw, registry, |other| {
self.persist.uuid_of(other)
});
let riders = passengers.iter().filter_map(|r| r.nbt.clone()).collect();
super::persist::add_passengers(&mut tag, riders);
tag
+19 -7
View File
@@ -202,16 +202,27 @@ impl Game<'_> {
let host = self
.player_view()
.filter(|p| !p.spectator && self.level.player_uuid.unwrap_or([0, 0, 0, 1]) == uuid)
.map(|p| p.position);
.map(|p| (p.position, p.eye));
let found = host.or_else(|| {
self.lan_present()
.into_iter()
.find(|p| p.uuid == uuid && !p.spectator)
.map(|p| {
horizon_core::geometry::Vec3::new(p.position[0], p.position[1], p.position[2])
(
horizon_core::geometry::Vec3::new(
p.position[0],
p.position[1],
p.position[2],
),
p.eye,
)
})
});
found.filter(|p| p.distance_squared(m.position) < 64.0 * 64.0)
// The 64-block search uses the feet; the deposit point is the eyes
// (`new EntityTracker(player, true)`).
found
.filter(|(feet, _)| feet.distance_squared(m.position) < 64.0 * 64.0)
.map(|(_, eye)| horizon_core::geometry::Vec3::new(eye[0], eye[1], eye[2]))
}
/// `GoAndGiveItemsToTarget` / `AllayAi.throwItem`: within 3 blocks of
/// the deposit, one item a tick, each restarting the three-second pause
@@ -249,13 +260,14 @@ impl Game<'_> {
};
let from = [
m.position.x,
m.position.y + m.eye_height() - 0.3,
m.position.y + m.eye_height() - 0.2,
m.position.z,
];
// BehaviorUtils.throwItem: the vector runs from the allay's feet.
let d = [
target.x - from[0],
target.y + 1.0 - from[1],
target.z - from[2],
target.x - m.position.x,
target.y + 1.0 - m.position.y,
target.z - m.position.z,
];
let len = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt().max(1e-6);
let v = [d[0] / len * 0.2, d[1] / len * 0.3, d[2] / len * 0.2];
@@ -1110,12 +1110,13 @@ impl Game<'_> {
)
* glam::Mat4::from_scale(glam::Vec3::new(-1.0, -1.0, 1.0))
* glam::Mat4::from_translation(glam::Vec3::new(0.0, -1.501, 0.0));
let light = self.world.light(
at.x.floor() as i32,
(at.y + 0.5).floor() as i32,
at.z.floor() as i32,
);
let packed = (u32::from(light >> 4) * 16) | ((u32::from(light & 15) * 16) << 8);
// `EntityRenderer.getPackedLightCoords`: the block
// `ArmorStand.getLightProbePosition` gives - the brightest cell of a
// marker's own box, else the stand's eye - at 15 block light while
// it burns.
let probe = a.light_probe(alpha, |x, y, z| self.world.light(x, y, z));
let light = self.world.light(probe[0], probe[1], probe[2]);
let packed = a.render_light(light);
let skinned = if skinning {
let mut models = self.skin_models.borrow_mut();
let mut frame = self.skin_frame.borrow_mut();
@@ -1629,8 +1630,8 @@ impl Game<'_> {
.extra
.as_deref()
.and_then(|d| d.other.iter().find(|(k, _)| k == "minecraft:profile"))
.map(|(_, t)| horizon_client::scoreboard::tag_to_json(t))
.unwrap_or(serde_json::Value::Null);
.map(|(_, t)| self.skin_profiles.borrow_mut().head_profile_nbt(t))
.unwrap_or_else(|| std::sync::Arc::new(serde_json::Value::Null));
self.pack.item_renderer.submit_special(
&special,
&profile,
@@ -0,0 +1,90 @@
//! Headless passenger-order regression through the real remote client paths.
use super::{Game, Keys, boats::Boat};
use horizon_client::{Result, held::Source, motion::PlayerMotion};
use horizon_core::geometry::Vec3;
use horizon_protocol::{connection::Pipe, packets::play::Serverbound};
use serde::Deserialize;
use serde_json::json;
use std::path::Path;
use winit::keyboard::KeyCode;
#[derive(Deserialize)]
struct Case {
name: String,
first: Vec<bool>,
velocity: [f64; 3],
spin: f32,
keys: [bool; 4],
event: i8,
}
impl Game<'_> {
pub(super) fn run_boat_rider_fixture(&mut self, directory: &Path) -> Result<()> {
let cases: Vec<Case> =
serde_json::from_str(&std::fs::read_to_string(directory.join("inputs.json"))?)?;
let (client, peer) = Pipe::pair();
self.connect_local(client, "BoatSeatFixture")?;
self.remote.as_mut().unwrap().entity_id = 1;
self.player = Some(PlayerMotion::new(self.motion, Vec3::new(0.0, 100.0, 0.0))?);
let mut output = Vec::new();
for case in cases {
self.boats.clear();
let mut b = Boat::new(3, "oak".into(), Vec3::new(0.0, 100.0, 0.0), 0.0);
let [x, y, z] = case.velocity;
b.velocity = Vec3::new(x, y, z);
b.delta_rotation = case.spin;
self.keys.release_source(Source::Agent);
for (key, held) in [KeyCode::KeyA, KeyCode::KeyD, KeyCode::KeyW, KeyCode::KeyS]
.into_iter()
.zip(case.keys)
{
if held {
self.keys.press(key, Source::Agent);
}
}
self.boats.push(b);
let mut steps = Vec::new();
for first in case.first {
self.remote_set_passengers(3, if first { &[1, 2] } else { &[2, 1] });
let mut accepts_server_moves = false;
self.tick_remote_objects(
|_, p| {
accepts_server_moves = true;
p
},
|_, yaw| yaw,
|_| false,
);
self.remote_ride(
Keys {
left: case.keys[0],
right: case.keys[1],
forward: case.keys[2],
backward: case.keys[3],
..Keys::default()
},
7,
)?;
let packets = self.remote_vehicle_packets();
if case.event != 0 {
self.remote_boat_event(3, case.event);
}
let boat = &self.boats[0];
steps.push(json!({
"controlled": self.remote.as_ref().unwrap().first_passenger,
"accepts_server_moves": accepts_server_moves,
"hands_busy": self.hands_busy(),
"moves": packets.iter().filter(|p| matches!(p, Serverbound::MoveVehicle(_))).count(),
"paddles": packets.iter().filter(|p| matches!(p, Serverbound::PaddleBoat(_))).count(),
"spin": format!("{:08x}", boat.delta_rotation.to_bits()),
"velocity": boat.velocity.components().map(|v| format!("{:016x}", v.to_bits())),
}));
}
output.push(json!({"name": case.name, "steps": steps}));
}
peer.shutdown();
self.remote = None;
std::fs::write(directory.join("rust.json"), serde_json::to_vec(&output)?)?;
Ok(())
}
}
+1 -1
View File
@@ -934,7 +934,7 @@ impl Game<'_> {
/// client moves only the boat its player drives.
fn boat_can_simulate_movement(&self, id: u32) -> bool {
if self.remote.is_some() {
self.riding == Some(id)
self.controls_boat(id)
} else {
!self.guest_vehicles().contains(&id)
}
@@ -294,6 +294,13 @@ impl Game<'_> {
}
}
}
"raid_state" => {
let state = self.harness_owner(|g| g.raid_fixture_state(&op))?;
let row = json!({"cmd":format!("#raid_state {} {}",op["label"].as_str().unwrap_or(""),op["id"]),
"messages":[],"results":[],"state":state});
out.push_str(&row.to_string());
out.push('\n');
}
"mob_anger" => {
// Metadata-state equality through the production writer, not packet delivery.
let uuid = u128::from_str_radix(
@@ -2799,6 +2799,18 @@ impl Game<'_> {
horizon_client::save::consume_container_loot(&mut extras, key);
chunk.saved = Some(std::sync::Arc::new(extras));
self.world.modified.insert((key.0 >> 4, key.2 >> 4));
// `ChestBlock.getMenuProvider` unpacks both halves of a double chest:
// the other half's table, if it still holds one, rolls too. The own roll
// is consumed above, so the partner's call does not come back here.
if let Some((other, _)) = self.chest_partner(key) {
let other_name = self
.res
.facts()
.get(self.world.block(other.0, other.1, other.2))
.name
.to_string();
self.unpack_opened_loot(other, &other_name);
}
}
/// RandomizableContainer.tryLoadLootTable for a tag a command gives a
/// container (`setblock`, `data`): an empty container with a `LootTable`
@@ -201,19 +201,8 @@ impl Game<'_> {
crate::game::persist::with_raw(tag, self.persist.raw.get(&id))
}
Target::Tnt(id) => {
let uuid = self.uuid_for(id);
let t = self.tnts.iter().find(|t| t.id == id)?;
entity_nbt::tnt_to_nbt(
t.position,
t.velocity,
t.on_ground,
t.fuse,
t.power,
t.block_state_nbt(self.pack.assets.registry),
t.rotation,
uuid,
self.persist.raw.get(&id),
)
let i = self.tnts.iter().position(|t| t.id == id)?;
self.tnt_nbt(i)
}
Target::Falling(id) => {
let f = self.falling.iter().find(|f| f.id == id)?.clone();
@@ -635,9 +635,9 @@ impl Game<'_> {
}
true
}
Slot::Frame(id) => match self.item_frames.iter_mut().find(|f| f.id == *id) {
Some(f) => {
f.item = stack;
Slot::Frame(id) => match self.item_frames.iter().position(|f| f.id == *id) {
Some(i) => {
self.set_frame_item(i, stack);
true
}
None => false,
@@ -30,26 +30,30 @@ impl Game<'_> {
})
.as_ref()
}
/// A template's tree by id, as `StructureTemplateManager` reads it from
/// disk.
/// A template's tree by id, as `StructureTemplateManager` reads it: the
/// world's saved structures, the enabled data packs from the highest, and
/// the game's own.
pub(in crate::game) fn read_template_tag(&self, id: &str) -> Option<Tag> {
let (ns, path) = id.split_once(':').unwrap_or(("minecraft", id));
let generated = self
.world_root
.join("generated")
.join(ns)
.join("structures")
.join(format!("{path}.nbt"));
let in_pack = format!("data/{ns}/structure/{path}.nbt");
let prepared = self
.options
.resources
.join("worldgen/structure")
.join(ns)
.join(format!("{path}.nbt"));
let candidates = [
self.world_root
.join("generated")
.join(ns)
.join("structures")
.join(format!("{path}.nbt")),
self.options
.resources
.join("worldgen/structure")
.join(ns)
.join(format!("{path}.nbt")),
std::fs::read(&generated).ok(),
self.fns.pack_file(&in_pack),
std::fs::read(&prepared).ok(),
];
for file in candidates {
let Ok(bytes) = std::fs::read(&file) else {
continue;
};
for bytes in candidates.into_iter().flatten() {
let Ok(raw) = Compression::Gzip.decode(&bytes, 1 << 28) else {
continue;
};
@@ -294,6 +294,11 @@ impl Game<'_> {
// The anvil's rename box, while it has an input to name.
Screen::Inventory if self.station_typing() => self.station_text(Some(text), false),
Screen::Creative if self.creative_screen.tab().search => {
// CreativeModeInventoryScreen.charTyped: the chat key that
// opened the tab does not type its letter.
if std::mem::take(&mut self.creative_ignore_text) {
return Ok(());
}
// As typed: replaces a selected query, keeps the length limit.
self.creative_search_edit(Some(text))
}
+102 -13
View File
@@ -144,15 +144,18 @@ impl ProjectileWorld for ProjWorld<'_, '_> {
binding: self.w.collision,
subject: None,
};
let context = horizon_world::collision_context::CollisionContext::position(from.y);
let r = traverse_blocks(from, to, 256, |p: BlockPos| {
let state = self.w.world.block(p.x, p.y, p.z);
if self.w.facts.is_air(state) {
return None;
}
// ClipContext hands CollisionContext.of(projectile): scaffolding
// is open for a projectile below it.
let shape = self
.w
.collision
.collision_shape(&mut scene, state, [p.x, p.y, p.z])
.collision_shape_with_context(&mut scene, state, p, &context)
.ok()?;
shape.clip(from, to, p)
});
@@ -2119,11 +2122,15 @@ impl Game<'_> {
None,
);
}
MobEvent::Sound(name, at, volume, pitch)
| MobEvent::LocalSound(name, at, volume, pitch) => {
MobEvent::Sound(name, at, volume, pitch) => {
self.audio
.play(&format!("minecraft:{name}"), Some(at), volume, pitch)
}
MobEvent::LocalSound(name, at, volume, pitch) => {
// Level.playLocalSound: play locally without broadcasting.
self.audio
.play_unshared(&format!("minecraft:{name}"), Some(at), volume, pitch)
}
MobEvent::LevelEventSound {
sound,
at,
@@ -2161,7 +2168,7 @@ impl Game<'_> {
// A mob's own `gameEvent(..)` calls: Armadillo.rollUp/rollOut's
// ENTITY_ACTION, Creeper.tick's PRIME_FUSE, Mob.ate's EAT, the
// shulker's lid (Shulker.setRawPeekAmount), an evoker's vexes
// (ENTITY_PLACE).
// (ENTITY_PLACE), Enderman.teleport's TELEPORT where it was.
MobEvent::GameEvent {
event, at, source, ..
} if matches!(
@@ -2172,6 +2179,7 @@ impl Game<'_> {
| "minecraft:container_open"
| "minecraft:container_close"
| "minecraft:entity_place"
| "minecraft:teleport"
) =>
{
let ctx = self.who_context(Some(Who::Mob(source)), None);
@@ -7137,11 +7145,11 @@ impl Game<'_> {
self.mark_motion(entity_start, (1 << 32) | u64::from(mob.id));
self.mark_entity(entity_start, &mob.kind.id);
self.rendered_mobs.set(self.rendered_mobs.get() + 1);
let light = self.world.light(
mob.position.x.floor() as i32,
(mob.position.y + mob.eye_height()).floor() as i32,
mob.position.z.floor() as i32,
);
// `EntityRenderer.getPackedLightCoords`: the block the mob's light
// probe (its eye) is in, at the position this frame draws rather
// than this tick's.
let probe = mob.light_probe(alpha);
let light = self.world.light(probe[0], probe[1], probe[2]);
let mut packed = (u32::from(light >> 4) * 16) | ((u32::from(light & 15) * 16) << 8);
// EntityRenderer.getBlockLightLevel: a burning mob has 15 block
// light, and so do the Allay, Blaze, Magma Cube, Vex and Wither
@@ -7398,11 +7406,12 @@ impl Game<'_> {
let p = glam::DVec3::from_array(at) - camera;
let c = p.as_vec3();
// EntityRenderer.getPackedLightCoords: the block its light probe
// (the eye) is in, not the one under its feet.
// (the eye) is in, at the position this frame draws rather than
// this tick's, and not the one under its feet.
let light = self.world.light(
a.position.x.floor() as i32,
a.light_probe_y().floor() as i32,
a.position.z.floor() as i32,
at[0].floor() as i32,
a.light_probe_y(alpha).floor() as i32,
at[2].floor() as i32,
);
let mut packed = (u32::from(light >> 4) * 16) | ((u32::from(light & 15) * 16) << 8);
// EntityRenderer.getBlockLightLevel: a shulker bullet and anything
@@ -7936,6 +7945,86 @@ fn mob_effect_instance(
Some(instance)
}
#[cfg(test)]
mod local_sound_event_tests {
use crate::game::{Audio, Game, Options, Progress, Settings, load_resources};
use horizon_client::mobs::MobEvent;
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn local_mob_events_play_without_entering_the_shared_capture() {
let root = std::path::PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES").unwrap());
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.game_dir =
Some(std::env::temp_dir().join(format!("horizon-h069-unit-{}", std::process::id())));
options.execution_role = crate::game::execution_role::ExecutionRole::IntegratedServer;
let mut game = Game::new(
&loaded.resources,
loaded.pack,
loaded.gui.unwrap(),
options,
settings,
Audio::silent(&root),
None,
)
.unwrap();
game.audio.capture = Some(Vec::new());
game.handle_mob_events(vec![
MobEvent::Sound("entity.cow.hurt", [1.0, 100.0, 2.0], 0.5, 1.0),
MobEvent::LocalSound("entity.breeze.whirl", [3.0, 100.0, 4.0], 0.8, 0.7),
MobEvent::Sound("entity.zombie.hurt", [5.0, 100.0, 6.0], 1.0, 0.9),
])
.unwrap();
// A positive LocalSound must still take the playback path. An absent
// producer cannot satisfy this actual Game adapter regression.
assert_eq!(
game.audio.heard.get("minecraft:entity.breeze.whirl"),
Some(&1)
);
assert_eq!(game.audio.heard.get("minecraft:entity.cow.hurt"), Some(&1));
assert_eq!(
game.audio.heard.get("minecraft:entity.zombie.hurt"),
Some(&1)
);
let shared = game.audio.capture.as_ref().unwrap();
assert_eq!(shared.len(), 2, "LocalSound must not become a shared sound");
assert_eq!(shared[0].event, "minecraft:entity.cow.hurt");
assert_eq!(shared[1].event, "minecraft:entity.zombie.hurt");
assert_eq!(
(
shared[0].position,
shared[0].volume,
shared[0].pitch,
shared[0].source
),
([1.0, 100.0, 2.0], 0.5, 1.0, 6)
);
assert_eq!(
(
shared[1].position,
shared[1].volume,
shared[1].pitch,
shared[1].source
),
([5.0, 100.0, 6.0], 1.0, 0.9, 5)
);
}
}
#[cfg(test)]
mod tests {
use super::super::perspective::CameraType;
@@ -408,6 +408,19 @@ pub(super) fn preflight(options: &mut Options) -> Result<bool> {
horizon_client::command::vanilla::set_horizon_commands(true);
}
props.store(&settings)?;
// `RegionFileVersion.configure`: the compression every region file this
// server writes uses, from `region-file-compression`. An unknown name is
// logged with the ones there are, and the default (deflate) stays.
if !horizon_world::compression::Compression::configure(&props.region_file_compression) {
log(
"ERROR",
&format!(
"Invalid `region-file-compression` value `{}` in server.properties. Please use one of: {}",
props.region_file_compression,
horizon_world::compression::REGION_FILE_NAMES.join(",")
),
);
}
let eula_file = PathBuf::from("eula.txt");
let agreed = match eula::agreed(&eula_file) {
Ok(a) => a,
+75 -2
View File
@@ -657,13 +657,13 @@ impl Dragon {
if damage <= self.last_hurt {
return Hurt::Landed { full: false };
}
self.health -= damage - self.last_hurt;
self.hurt_health(damage - self.last_hurt, source);
self.last_hurt = damage;
false
} else {
self.last_hurt = damage;
self.invulnerable = 20;
self.health -= damage;
self.hurt_health(damage, source);
self.hurt_time = 10;
true
};
@@ -705,6 +705,16 @@ impl Dragon {
}
Hurt::Landed { full }
}
/// `LivingEntity.actuallyHurt` for the dragon, which has no armour or
/// absorption: a native mod's `LivingDamageEvent.Pre` may change what
/// comes off the health, and `.Post` hears what did.
fn hurt_health(&mut self, damage: f32, source: HurtSource<'_>) {
use horizon_client::health::{Hurting, damage_post, damage_pre};
let mut damage = damage;
damage_pre(Hurting::Dragon(self.id as i32, source), &mut damage);
self.health -= damage;
damage_post(Hurting::Dragon(self.id as i32, source), damage);
}
/// `EnderDragon.knockback`: `LivingEntity.knockback` only when the phase
/// is not sitting. `(xd, zd)` points at the hit (`dealDefaultKnockback`:
/// the source position minus this position). Knockback resistance is the
@@ -5137,6 +5147,69 @@ mod tests {
assert!(matches!(d.phase, Phase::Dying { .. }));
}
/// What the `LivingDamageEvent` hooks of these tests were handed: the
/// dragon, Post (true) or Pre, and the amount.
static TAKEN: std::sync::Mutex<Vec<(i32, bool, f32)>> = std::sync::Mutex::new(Vec::new());
/// Sets the native `LivingDamageEvent.Pre` and `.Post` hooks for dragons
/// 9201 and 9202 (global, as `set_hook`'s): they record what they are
/// handed, and Pre halves 9202's amount.
fn set_damage_hooks() {
use horizon_client::health::{DAMAGE_POST, DAMAGE_PRE, Hurting};
fn pre(hurting: Hurting<'_>, amount: &mut f32) {
let Hurting::Dragon(entity @ 9201..=9202, _) = hurting else {
return;
};
TAKEN.lock().unwrap().push((entity, false, *amount));
if entity == 9202 {
*amount /= 2.0;
}
}
fn post(hurting: Hurting<'_>, amount: f32) {
if let Hurting::Dragon(entity @ 9201..=9202, _) = hurting {
TAKEN.lock().unwrap().push((entity, true, amount));
}
}
let installed = (
*DAMAGE_PRE.get_or_init(|| pre),
*DAMAGE_POST.get_or_init(|| post),
);
assert!(std::ptr::fn_addr_eq(
installed.0,
pre as fn(Hurting<'_>, &mut f32)
));
assert!(std::ptr::fn_addr_eq(
installed.1,
post as fn(Hurting<'_>, f32)
));
}
fn taken(entity: i32) -> Vec<(bool, f32)> {
let taken = TAKEN.lock().unwrap();
taken
.iter()
.filter(|t| t.0 == entity)
.map(|t| (t.1, t.2))
.collect()
}
#[test]
fn the_native_damage_hooks_see_and_change_what_a_dragons_health_takes() {
set_damage_hooks();
// The hit after the part's cut (20 / 4 + 1), then, within the
// cooldown, what a stronger one exceeds it by.
let mut d = dragon_as(9201);
assert_eq!(d.hurt(2, 20.0, HIT, false), Hurt::Landed { full: true });
assert_eq!(d.hurt(0, 10.0, HIT, false), Hurt::Landed { full: false });
assert_eq!(
taken(9201),
[(false, 6.0), (true, 6.0), (false, 4.0), (true, 4.0)]
);
assert_eq!(d.health, MAX_HEALTH - 10.0);
let mut d = dragon_as(9202);
assert_eq!(d.hurt(0, 9.0, HIT, false), Hurt::Landed { full: true });
assert_eq!(taken(9202), [(false, 9.0), (true, 4.5)]);
assert_eq!(d.health, MAX_HEALTH - 4.5);
}
#[test]
fn a_hit_names_the_player_behind_it() {
let source = |kind, by, direct| {
@@ -94,6 +94,8 @@ impl Game<'_> {
/// Fills `dynamic_lights` for the frame from [`Self::dynamic_light_sources`], the nearest
/// [`dynamic_light::MAX`] of them.
pub(super) fn gather_dynamic_lights(&mut self, camera: DVec3, alpha: f64) {
let _span = horizon_client::spans::guard("pr_light_gather");
let mut laps = horizon_client::spans::laps();
let mut out = std::mem::take(&mut self.dynamic_lights);
out.clear();
let sources: Vec<Source> = self
@@ -101,7 +103,9 @@ impl Game<'_> {
.into_iter()
.map(|(s, _)| s)
.collect();
laps.lap("pr_light_sources");
dynamic_light::select(&sources, camera, &mut out);
self.dynamic_lights = out;
laps.lap("pr_light_select");
}
}
+15
View File
@@ -379,6 +379,21 @@ impl Game<'_> {
}
true
}
/// `ItemFrame.setItem(stack, true)`: keeps one item, plays the add sound
/// for a non-empty stack and updates the comparators.
pub(super) fn set_frame_item(&mut self, i: usize, stack: Option<ItemStack>) {
let f = &mut self.item_frames[i];
f.item = stack.map(|s| ItemStack { count: 1, ..s });
let (pos, filled) = (f.pos, f.item.is_some());
if filled {
let (name, at) = (
sound(&self.item_frames[i], "add_item"),
center(&self.item_frames[i]),
);
self.audio.play(&name, Some(at), 1.0, 1.0);
}
self.frame_output_changed(pos);
}
/// `ItemFrame.setItem` / `setRotation`: `updateNeighbourForOutputSignal`, so
/// the comparators reading the frame take its new signal.
fn frame_output_changed(&mut self, pos: [i32; 3]) {
@@ -24,6 +24,16 @@ pub(super) struct Scheduled {
pub time: i64,
pub seq: u64,
}
impl FnState {
/// The file `path` of the highest enabled pack that has it.
pub(super) fn pack_file(&self, path: &str) -> Option<Vec<u8>> {
self.enabled
.iter()
.rev()
.filter_map(|id| self.packs.iter().find(|p| &p.id == id))
.find_map(|pack| pack.read(path))
}
}
#[derive(Default)]
pub(super) struct FnState {
pub store: DataStore,
+6 -2
View File
@@ -383,7 +383,9 @@ impl Game<'_> {
}
Ok(())
}
/// `BlockBehaviour.getDestroyProgress` with `Player.getDestroySpeed`.
/// `BlockBehaviour.getDestroyProgress` with `Player.getDestroySpeed`, whose
/// end asks the mods' `native_break_speed`. Every dig's progress comes from
/// here: single player's, a host's for each guest, a remote client's own.
pub(super) fn destroy_progress(&mut self, pos: [i32; 3]) -> f32 {
let block_break_speed = self.block_break_speed();
let state = self.world.block(pos[0], pos[1], pos[2]);
@@ -414,6 +416,8 @@ impl Game<'_> {
if self.player.as_ref().is_some_and(|p| !p.ground.on_ground) {
speed /= 5.0;
}
// NeoForge's PlayerEvent.BreakSpeed, where Player.getDestroySpeed ends.
self.native_break_speed(pos, state, speed, &mut speed);
speed / hardness / if harvest { 30.0 } else { 100.0 }
}
/// A key for the main-hand stack (`destroyingItem`): its item and components.
@@ -439,7 +443,7 @@ impl Game<'_> {
/// `LocalPlayer.isHandsBusy`: paddling a boat.
pub(super) fn hands_busy(&self) -> bool {
self.riding
.is_some_and(|id| self.boats.iter().any(|b| b.id == id))
.is_some_and(|id| self.controls_boat(id) && self.boats.iter().any(|b| b.id == id))
&& [
winit::keyboard::KeyCode::KeyW,
winit::keyboard::KeyCode::KeyS,
+20 -7
View File
@@ -620,11 +620,24 @@ impl Game<'_> {
let pose = glam::Mat4::from_translation(pos.as_vec3())
* glam::Mat4::from_translation(glam::Vec3::new(0.0, bob + min_offset_y, 0.0))
* glam::Mat4::from_rotation_y(spin);
let packed = self.world.light(
q.x.floor() as i32,
(q.y + 0.1).floor() as i32,
q.z.floor() as i32,
);
// `EntityRenderer.getPackedLightCoords`: an item entity is lit from
// the block its light probe (its eye, 0.2125 up) is in, at 15 block
// light while it burns. A pickup particle is not an entity and keeps
// the cell it is drawn in.
let packed = if entity {
let probe = item.light_probe(alpha);
item.render_light(self.world.light(
probe[0].floor() as i32,
probe[1].floor() as i32,
probe[2].floor() as i32,
))
} else {
vertex_light(self.world.light(
q.x.floor() as i32,
(q.y + 0.1).floor() as i32,
q.z.floor() as i32,
))
};
let seed = i64::from(self.pack.item_renderer.item_id(&item.stack.item))
+ i64::from(item.stack.damage);
let copies = item_render::cluster_poses(
@@ -664,11 +677,11 @@ impl Game<'_> {
let outline = self.thing_outline(item.id, "minecraft:item");
// Posed on the GPU when every layer can be, its silhouette too when it glows.
let skinned = skinning
.then(|| self.submit_item_skinned(&state, &copies, vertex_light(packed), lights))
.then(|| self.submit_item_skinned(&state, &copies, packed, lights))
.flatten();
if skinned.is_none() {
for &copy in &copies {
self.submit_item_world(&state, copy, vertex_light(packed), out);
self.submit_item_world(&state, copy, packed, out);
}
}
if let Some(rgb) = outline {
+89 -2
View File
@@ -32,6 +32,9 @@ impl Game<'_> {
self.using = Some((self.held_slot(), 0));
self.use_duration = duration;
self.use_ticks_set = ticks_set;
// `LivingEntity.startUsingItem`: `causeUseVibration(ITEM_INTERACT_START)`
// when the use effects of the stack ask for interaction vibrations.
self.use_vibration(&stack, "minecraft:item_interact_start");
// A new charge hears the crossbow's loading sounds again
// (`game/crossbow.rs`).
self.crossbow_sounds = [false; 2];
@@ -100,7 +103,7 @@ impl Game<'_> {
// a potion is drunk (no last tick of its own: a mod's tick ran it out).
if self.finish_drinking(slot, stack) {
self.use_item_finished(stack);
self.use_vibration(stack);
self.use_vibration(stack, "minecraft:item_interact_finish");
return;
}
self.using = None;
@@ -113,7 +116,7 @@ impl Game<'_> {
}
self.use_item_finished(stack);
// `LivingEntity.completeUsingItem` ends with `stopUsingItem`.
self.use_vibration(stack);
self.use_vibration(stack, "minecraft:item_interact_finish");
}
/// `LivingEntityUseItemEvent.Finish` after `finishUsingItem` of `stack`
/// (no ticks left): the hand holds what a mod made the result.
@@ -449,3 +452,87 @@ mod tests {
}
}
}
#[cfg(test)]
mod interact_vibration_tests {
use super::*;
/// Runs `f` on a windowless game built from the prepared resources with
/// `held` in the main hand and returns the game events it posted;
/// `None` without the resources.
fn with_game(held: &str, f: impl FnOnce(&mut Game<'_>)) -> Option<Vec<String>> {
use crate::game::{Audio, Options, Progress, Settings, load_resources};
let root = std::path::PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES")?);
let settings = Settings::default();
let loaded = load_resources(
root.clone(),
None,
&root,
&settings,
std::time::Instant::now(),
false,
true,
&Progress::new(),
&mut |_, _| unreachable!(),
)
.ok()?;
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
options.resources = root.clone();
options.execution_role = crate::game::execution_role::ExecutionRole::IntegratedServer;
let mut game = Game::new(
&loaded.resources,
loaded.pack,
loaded.gui.unwrap(),
options,
settings,
Audio::silent(&root),
None,
)
.unwrap();
game.player = Some(
horizon_client::motion::PlayerMotion::new(
game.motion,
horizon_core::geometry::Vec3::new(10.5, 64.0, -3.25),
)
.unwrap(),
);
*game.held_mut() = Some(ItemStack::new(held, 1));
crate::game::dispense_fixture::start_recording();
f(&mut game);
Some(crate::game::dispense_fixture::stop_recording())
}
/// `LivingEntity.startUsingItem` ends with
/// `this.useItem.causeUseVibration(this, GameEvent.ITEM_INTERACT_START)`,
/// and `ItemStack.causeUseVibration` posts only when the stack's use effects
/// ask for interaction vibrations (the default). `stopUsingItem` posts the
/// finish the same way, which Horizon already did, so a use is a pair of
/// vibrations and an item that turns them off is neither.
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn a_use_start_is_an_item_interact_start_vibration() {
let bow = || ItemStack::new("minecraft:bow", 1);
let Some(lines) = with_game("minecraft:bow", |game| {
assert!(game.start_using_item(), "the bow's use starts");
game.stop_using_item(&bow());
}) else {
return;
};
assert_eq!(
lines,
vec![
"G 10,64,-4 minecraft:item_interact_start".to_owned(),
"G 10,64,-4 minecraft:item_interact_finish".to_owned(),
]
);
// A spear's use effects set `interact_vibrations` false: no vibration.
let spear = || ItemStack::new("minecraft:iron_spear", 1);
let Some(lines) = with_game("minecraft:iron_spear", |game| {
assert!(game.start_using_item(), "the spear's use starts");
game.stop_using_item(&spear());
}) else {
return;
};
assert!(lines.is_empty(), "{lines:?}");
}
}
+102
View File
@@ -325,6 +325,11 @@ impl Game<'_> {
self.start_song(pos, &song);
}
if ticks % 20 == 0 {
// `JukeboxSongPlayer.tick`: `gameEvent(JUKEBOX_PLAY, pos,
// Context.of(state))` every twenty ticks while a song plays, for
// the sculk listeners and the allays around it.
let state = self.world.block(pos[0], pos[1], pos[2]);
self.post_block_game_event("minecraft:jukebox_play", pos, None, Some(state));
let color = f64::from(self.world.random.next_int_bound(4).unwrap_or(0)) / 24.0;
let at = [
f64::from(pos[0]) + 0.5,
@@ -431,3 +436,100 @@ mod tests {
assert!(!keeps_song(true, false, false));
}
}
#[cfg(test)]
mod jukebox_play_tests {
use super::*;
/// A windowless game from the prepared resources with a jukebox at
/// (0, 64, 0), a disc in hand and its song started, ticked `ticks` times:
/// the game events it posted, in order. `None` without the resources.
fn playing(ticks: i32) -> Option<Vec<String>> {
use crate::game::{Audio, Game, Options, Progress, Settings, load_resources};
let root = std::path::PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES")?);
let settings = Settings::default();
let loaded = load_resources(
root.clone(),
None,
&root,
&settings,
std::time::Instant::now(),
false,
true,
&Progress::new(),
&mut |_, _| unreachable!(),
)
.ok()?;
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
options.resources = root.clone();
options.execution_role = crate::game::execution_role::ExecutionRole::IntegratedServer;
let mut game = Game::new(
&loaded.resources,
loaded.pack,
loaded.gui.unwrap(),
options,
settings,
Audio::silent(&root),
None,
)
.unwrap();
// `World::set` writes into a resident chunk only.
game.world.chunks.insert(
(0, 0),
std::sync::Arc::new(horizon_client::chunk::Chunk::empty(0, 0)),
);
let jukebox = game
.pack
.assets
.registry
.default_state("minecraft:jukebox")?;
game.world.set(0, 64, 0, jukebox).unwrap();
let disc = ItemStack::new("minecraft:music_disc_cat", 1);
crate::game::dispense_fixture::start_recording();
assert!(
game.use_jukebox([0, 64, 0], Some(&disc)),
"the disc goes in"
);
for _ in 0..ticks {
game.tick_jukebox((0, 64, 0));
}
Some(crate::game::dispense_fixture::stop_recording())
}
/// `JukeboxSongPlayer.tick` posts `JUKEBOX_PLAY` at the jukebox every
/// `PLAY_EVENT_INTERVAL_TICKS` (20) ticks of a song, next to the note
/// particle it already drew. Horizon drew the particle and kept the
/// listeners deaf: a sculk sensor beside a playing jukebox never clicked.
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn a_playing_jukebox_posts_jukebox_play_every_twenty_ticks() {
let Some(lines) = playing(41) else { return };
let plays: Vec<&String> = lines
.iter()
.filter(|l| l.ends_with("minecraft:jukebox_play"))
.collect();
assert_eq!(
plays,
vec![
&"G 0,64,0 minecraft:jukebox_play".to_owned(),
&"G 0,64,0 minecraft:jukebox_play".to_owned(),
&"G 0,64,0 minecraft:jukebox_play".to_owned(),
],
"{lines:?}"
);
// The song is still playing after 41 ticks.
assert!(
lines.iter().all(|l| l.ends_with("minecraft:jukebox_play")),
"{lines:?}"
);
}
/// A jukebox whose song is stopped posts no play event: only the twenty
/// ticks of a playing song do.
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn a_stopped_jukebox_posts_no_play_event() {
let Some(lines) = playing(0) else { return };
assert!(lines.is_empty(), "{lines:?}");
}
}
+186 -8
View File
@@ -1278,6 +1278,17 @@ impl Game<'_> {
if pos.1 == super::minecarts::CART_KEY_Y {
return self.lan_open_vehicle_container(host, conn, pos);
}
// `RandomizableContainer.unpackLootTable`: a guest opening a block
// container rolls the table it still holds, as the host's own open does
// (`unpack_opened_loot` no-ops when there is no roll), and the stat
// fires for the acting guest.
let opened = self
.res
.facts()
.get(self.world.block(pos.0, pos.1, pos.2))
.name
.to_string();
self.unpack_opened_loot(pos, &opened);
let name = &self
.res
.facts()
@@ -1499,6 +1510,17 @@ impl Game<'_> {
// loot table is still to roll, and the table stays.
let spectator = host.hub.guest(conn).is_some_and(|g| g.spectator());
if spectator && self.boat_loot_pending(self.boats[i].id) {
// ServerPlayer.openMenu: the refused spectator's action bar, red.
let message =
horizon_client::text::Text::translate("container.spectatorCantOpen", &[])
.with_color(horizon_client::text::RED);
host.hub.send_to(
conn,
&C::SystemChat(play::SystemChat {
content: horizon_client::lan::data::component_nbt(&message.component()),
overlay: true,
}),
);
return false;
}
self.unpack_boat_loot(i);
@@ -3688,12 +3710,10 @@ impl Game<'_> {
let world = self
.level
.world_spawn
.map(|[x, y, z]| [f64::from(x) + 0.5, f64::from(y), f64::from(z) + 0.5]);
let spawn = host.hub.guests[i]
.spawn
.or(world)
.map(|[x, y, z]| [f64::from(x) + 0.5, f64::from(y), f64::from(z) + 0.5])
.or(self.spawn.map(|s| [s.x, s.y, s.z]))
.unwrap_or([0.5, 80.0, 0.5]);
let spawn = host.hub.guests[i].spawn.unwrap_or(world);
if !host.hub.guests[i].dead {
return;
}
@@ -3788,7 +3808,9 @@ impl Game<'_> {
world: &mut self.world,
entities: &[],
next_entity: &mut self.next_entity,
spawn,
// PlayerList.respawn / sendLevelInfo send level respawn data,
// independently of the personal destination passed to Hub::respawn.
spawn: world,
difficulty: i32::from(self.ui.difficulty),
difficulty_locked: self.level.difficulty_locked,
clock_rate,
@@ -4726,8 +4748,10 @@ impl Game<'_> {
let mut events = std::mem::take(&mut w.player.events);
// LayeredCauldronBlock.handleEntityOnFireInside along those moves.
for event in &events {
if let MotionEvent::CauldronExtinguish { pos } = event {
game.cauldron_extinguish(*pos);
match event {
MotionEvent::CauldronExtinguish { pos } => game.cauldron_extinguish(*pos),
MotionEvent::PowderSnowMelt { pos } => game.powder_snow_melt(*pos),
_ => {}
}
}
// Entity.baseTick: rain puts out a burning player.
@@ -4881,7 +4905,10 @@ impl Game<'_> {
host.hub.entity_event(id, event as i8, at);
}
}
MotionEvent::Fall(event) => self.landing_block_updates(&event),
MotionEvent::Fall(event) => {
let player = host.hub.guest(conn).map(|g| g.uuid);
self.landing_block_updates(player, &event)
}
// PowderSnowBlock.entityInside: every player around sees the
// snowflake (`ServerLevel.sendParticles`).
MotionEvent::Snowflake { position, velocity } => {
@@ -4900,6 +4927,11 @@ impl Game<'_> {
}
self.drown_particles(at, [0.0; 3]);
}
// ServerPlayer.onInsideBlock: the enter_block trigger, for the
// player who moved.
MotionEvent::EnterBlock { state } => {
self.as_guest(host, conn, |game| game.trigger_enter_block(state));
}
// HoneyBlock.maybeDoSlideEffects: the sound reaches the
// others (Player.playSound excludes the player, whose client
// plays it), the entity event 53 everyone around.
@@ -6010,3 +6042,149 @@ mod spin_target_tests {
);
}
}
#[cfg(test)]
mod respawn_world_point_tests {
use crate::game::{Audio, Game, Options, Progress, Settings, load_resources};
use horizon_client::lan::{Joined, NetConfig, Out, PlayerSender, guest::Guest, hub::HubConfig};
use horizon_core::geometry::Vec3;
use horizon_protocol::packets::{configuration::ClientInformation, play::Clientbound};
use std::sync::{Arc, atomic::AtomicI32, mpsc};
/// PlayerList.respawn sends world respawn data, independently of the personal landing.
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn guest_respawn_keeps_world_information_distinct_from_personal_landing() {
let root = std::path::PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES").unwrap());
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 = crate::game::execution_role::ExecutionRole::IntegratedServer;
let mut game = Game::new(
&loaded.resources,
loaded.pack,
loaded.gui.unwrap(),
options,
settings,
Audio::silent(&root),
None,
)
.unwrap();
let scratch = std::env::temp_dir().join(format!(
"horizon-respawn-point-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::create_dir_all(&scratch).unwrap();
game.world_root = scratch.clone();
let world = [0, 100, 0];
let personal = [5.5, 101.0, 5.5];
game.level.world_spawn = Some(world);
game.spawn = Some(Vec3::new(0.5, 100.0, 0.5));
game.lan_host(
super::super::lan::LanSettings {
port: 0,
guest_commands: true,
force_game_mode: false,
},
NetConfig {
announce: false,
..NetConfig::loopback(0, "respawn-test".into(), 2)
},
HubConfig {
default_game_mode: 0,
guest_commands: true,
force_game_mode: false,
hardcore: false,
view_distance: 2,
simulation_distance: 2,
dimension: "minecraft:overworld".into(),
allow_flight: false,
seed: 0,
max_players: 2,
integrated: false,
levels: Default::default(),
},
)
.unwrap();
let (tx, rx) = mpsc::channel();
let mut guest = Guest::new(
Joined {
conn: 2,
uuid: 330,
name: "RespawnGuest".into(),
properties: Vec::new(),
info: ClientInformation::new(2),
brand: "test".into(),
sender: PlayerSender::new(tx, Arc::default()),
latency: Arc::new(AtomicI32::new(0)),
address: "127.0.0.1:25565".parse().unwrap(),
expected: false,
},
2,
[0.5, 100.0, 0.5],
2,
);
guest.dimension = "minecraft:overworld".into();
guest.placed = true;
guest.loaded = true;
guest.spawn = Some(personal);
guest.health = 0.0;
guest.dead = true;
let mut host = game.lan.take().unwrap();
host.hub.guests.push(guest);
game.guest_respawn(&mut host, 2);
let guest = &host.hub.guests[0];
assert_eq!(guest.uuid, 330);
assert_eq!(guest.position, personal);
assert_eq!(guest.health, 20.0);
assert!(!guest.dead);
let mut packets = Vec::new();
for out in rx.try_iter() {
match out {
Out::Packet(body) => packets.push(body),
Out::Packets(bodies) => packets.extend(bodies),
Out::Disconnect(_) => panic!("unexpected disconnect"),
}
}
let mut sequence = Vec::new();
let mut points = Vec::new();
for body in packets {
let mut r = horizon_protocol::buf::Reader::new(&body);
let id = r.varint().unwrap();
match Clientbound::decode(id, r.rest()).unwrap() {
Clientbound::Respawn(_) => sequence.push(84),
Clientbound::SetDefaultSpawnPosition(p) => {
sequence.push(99);
points.push((
p.pos.dimension,
p.pos.pos,
p.yaw.to_bits(),
p.pitch.to_bits(),
));
}
_ => {}
}
}
assert_eq!(sequence, [84, 99, 99]);
assert_eq!(points, vec![("minecraft:overworld".into(), world, 0, 0); 2]);
drop(host);
drop(game);
std::fs::remove_dir_all(scratch).unwrap();
}
}
@@ -492,9 +492,9 @@ impl Game<'_> {
}
gs.last_sound = self.ticks;
let at = gs.pos.map(|v| f64::from(v) + 0.5);
for sound in sounds {
self.audio.play(sound, Some(at), 1.0, 1.0);
}
// The menu's `lastSoundTime` lets one sound through per tick, however
// many results a shift-click took.
self.audio.play(sounds[0], Some(at), 1.0, 1.0);
}
/// A click on the guest's station window; false for other windows.
@@ -1275,6 +1275,8 @@ impl Game<'_> {
/// `MinecartHopper.tick`'s `tryConsumeItems` (`suckInItems`) for an enabled
/// hopper minecart: one item from the container above, else the item
/// entities over it or touching it, each tick without a cooldown.
/// `consumedItemThisFrame` in 26.3 caps a tick at one item: the tick tries
/// once, and `makeStepAlongTrack` may try again, but only one succeeds.
pub(super) fn hopper_cart_suck(&mut self, cart: &mut super::minecarts::Minecart) {
// The cart's slots are edited as a container under the placeholder key
// its menu uses; when that menu is open the placeholder is the live copy.
@@ -1702,3 +1704,80 @@ mod mod_container_tests {
assert_eq!(view.slots_for_face(None), [0, 1, 2]);
}
}
#[cfg(test)]
mod hopper_cart_transfer_tests {
use super::super::{
minecarts::{CartKind, Minecart},
native_machines::tests::server_game,
};
use super::Game;
use horizon_client::{containers::BlockEntity, items::ItemStack};
use horizon_core::geometry::Vec3;
/// Issue #8668: a hopper minecart takes one item a tick from the container
/// above. `MinecartHopper.tick` has no transfer cooldown in 26.3: it calls
/// `tryConsumeItems` every tick, and `consumedItemThisFrame` caps a tick at
/// one item even though `makeStepAlongTrack` may try too. A whole stack a
/// tick (the old behaviour) or a block hopper's eight-tick cooldown both
/// fail this.
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn hopper_cart_takes_one_item_a_tick() {
let mut game = server_game();
let rail = game
.pack
.assets
.registry
.default_state("minecraft:rail")
.unwrap();
let chest = game
.pack
.assets
.registry
.default_state("minecraft:chest")
.unwrap();
// A rail holds the cart still; the chest sits in the block the cart
// pulls from (`getLevelY` is y + 0.5, so the block above is y + 1.5).
game.world.set(8, 64, 8, rail).unwrap();
game.world.set(8, 65, 8, chest).unwrap();
game.world.block_entities.insert(
(8, 65, 8),
BlockEntity::Chest(vec![Some(ItemStack::new("minecraft:stone", 64)); 27]),
);
game.next_entity += 1;
let id = game.next_entity;
let mut cart = Minecart::new(id, CartKind::Hopper, Vec3::new(8.5, 64.0625, 8.5));
cart.enabled = true;
game.carts.push(cart);
let count = |game: &Game<'_>| {
let in_chest: u32 = match game.world.block_entities.get(&(8, 65, 8)) {
Some(BlockEntity::Chest(s)) => s.iter().flatten().map(|s| s.count).sum(),
_ => 0,
};
let in_cart: u32 = game
.carts
.iter()
.find(|c| c.id == id)
.unwrap()
.items
.iter()
.flatten()
.map(|s| s.count)
.sum();
(in_chest, in_cart)
};
let (before_chest, before_cart) = count(&game);
for _ in 0..20 {
game.tick_carts();
}
let (after_chest, after_cart) = count(&game);
assert_eq!(before_chest, 27 * 64);
assert_eq!(before_cart, 0);
assert_eq!(before_chest - after_chest, after_cart - before_cart);
assert_eq!(
after_cart, 20,
"one item a tick, not a stack nor eight ticks"
);
}
}
+4 -2
View File
@@ -674,7 +674,8 @@ mod tests {
Mob::new(2, ghoul.clone(), at, 0.0),
);
assert_eq!(ghoul.health, 40.0);
let saved = |m: &Mob| horizon_client::entity_nbt::mob_to_nbt(m, [0, 0, 0, 2], None, None);
let saved =
|m: &Mob| horizon_client::entity_nbt::mob_to_nbt(m, [0, 0, 0, 2], None, None, |_| None);
let keys = |tag: &horizon_world::nbt::Tag| -> Vec<String> {
let horizon_world::nbt::Tag::Compound(map) = tag else {
panic!("a mob saves a compound");
@@ -725,7 +726,8 @@ mod tests {
let trader = Mob::new(1, types["minecraft:wandering_trader"].clone(), at, 0.0);
let merchant = Mob::new(2, merchant.clone(), at, 0.0);
assert!(merchant.ai.goals.has("Float") && trader.ai.goals.has("Float"));
let saved = |m: &Mob| horizon_client::entity_nbt::mob_to_nbt(m, [0, 0, 0, 2], None, None);
let saved =
|m: &Mob| horizon_client::entity_nbt::mob_to_nbt(m, [0, 0, 0, 2], None, None, |_| None);
let keys = |tag: &Tag| -> Vec<String> {
let Tag::Compound(map) = tag else {
panic!("a mob saves a compound");
+256 -9
View File
@@ -8,13 +8,112 @@
//! and the player's own in third person with the `ownNameTag` extra.
use super::{Game, view_vector};
use horizon_client::{
block_entity_render::vertex_light, hud::HudBatch, mesher::Vertex, mobs::Mob, mod_api,
save::uuid_text, scoreboard as sb, text::Text,
block_entity_render::vertex_light,
hud::{HudAtlas, HudBatch, HudVertex},
mesher::Vertex,
mobs::Mob,
mod_api,
save::uuid_text,
scoreboard as sb,
text::Text,
};
/// The world size of one text pixel in a name tag: `Matrix4f.scale(0.025F, -0.025F, 0.025F)`.
pub(super) const NAME_TAG_UNIT: f32 = 0.025;
/// Local glyph quads only: camera transforms, lighting and cosmetic marks stay per frame.
/// Slots follow submission order; comparing the whole styled text also handles entity reorder,
/// team/score changes and native name hooks without keeping entity IDs alive.
#[derive(Default)]
pub(super) struct NameTagLayouts {
epoch: (u64, u64, bool),
next: usize,
lines: Vec<Option<NameTagLine>>,
}
struct NameTagLine {
text: Text,
alpha: u32,
background: bool,
y: f32,
behind: usize,
vertices: Vec<HudVertex>,
}
impl NameTagLayouts {
fn begin_frame(&mut self, atlas: &HudAtlas) {
let epoch = (
atlas.revision,
atlas.font.epoch(),
horizon_client::lang::bidirectional(),
);
if self.epoch != epoch {
self.lines.clear();
self.epoch = epoch;
}
self.next = 0;
}
fn end_frame(&mut self) {
self.lines.truncate(self.next);
}
fn name_tag(
&mut self,
batch: &mut HudBatch,
atlas: &HudAtlas,
text: &Text,
y: f32,
alpha: u32,
background: bool,
) -> usize {
// At most 512 * 512 HUD vertices (~10 MiB), plus short text keys. Longer tags and
// excess lines still render normally. Animated/random glyphs and live inline skins
// must go through the font each time, including legacy formatting supplied by hooks.
const MAX_LINES: usize = 512;
const MAX_VERTICES: usize = 512;
let slot = self.next;
self.next += 1;
let stable =
text.resolved().spans.iter().all(|span| {
span.style.obfuscated != Some(true) && span.style.inline_object.is_none()
}) && text.spans.iter().map(|s| s.text.len()).sum::<usize>() <= 1024;
if slot < MAX_LINES
&& stable
&& let Some(Some(line)) = self.lines.get(slot)
&& line.text == *text
&& line.alpha == alpha
&& line.background == background
&& line.y == y
{
batch.vertices.extend_from_slice(&line.vertices);
return line.behind;
}
let behind = batch.name_tag(
atlas,
text,
y,
alpha << 24 | 0xFF_FFFF,
if background { 0x3F00_0000 } else { 0 },
);
if slot < MAX_LINES {
if self.lines.len() <= slot {
self.lines.resize_with(slot + 1, || None);
}
self.lines[slot] =
(stable && batch.vertices.len() <= MAX_VERTICES).then(|| NameTagLine {
text: text.clone(),
alpha,
background,
y,
behind,
vertices: batch.vertices.clone(),
});
}
behind
}
}
impl Game<'_> {
/// A named name tag names the mob under the crosshair.
pub(super) fn name_mob(&mut self, index: usize) -> bool {
@@ -469,6 +568,8 @@ impl Game<'_> {
// max((0.25 + 0.75) / 2, 0.5), as ARGB.color (the floor of 255 times it).
const FADED: u32 = 0x7F;
let mut patches = Vec::new();
let mut layouts = std::mem::take(&mut self.name_tag_layouts);
layouts.begin_frame(&self.gui.atlas);
for tag in self.name_tags(camera, alpha) {
let o = tag.at - camera;
// `gbuffers_entities` draws name tag text (2.3); a sneaking player's faded tag, on its
@@ -486,6 +587,7 @@ impl Game<'_> {
NAME_TAG_UNIT,
out,
tag.marks,
Some(&mut layouts),
));
continue;
}
@@ -503,6 +605,7 @@ impl Game<'_> {
NAME_TAG_UNIT,
out,
tag.marks,
Some(&mut layouts),
));
// RenderPipelines.TEXT_SEE_THROUGH: no depth test, no lightmap.
patches.extend(self.styled_name_tag_quads(
@@ -515,8 +618,11 @@ impl Game<'_> {
NAME_TAG_UNIT,
see_through,
tag.marks,
Some(&mut layouts),
));
}
layouts.end_frame();
self.name_tag_layouts = layouts;
self.map_patches.extend(patches);
}
/// The lines of one name tag, `o` from the camera: the text at
@@ -545,6 +651,7 @@ impl Game<'_> {
unit,
out,
Marks::default(),
None,
)
}
@@ -563,6 +670,7 @@ impl Game<'_> {
unit: f32,
out: &mut Vec<Vertex>,
marks: Marks,
mut layouts: Option<&mut NameTagLayouts>,
) -> Vec<horizon_client::font::Patch> {
// Options.getBackgroundOpacity(0.25) black, as ARGB.color.
const BACKGROUND: u32 = 0x3F00_0000;
@@ -595,13 +703,23 @@ impl Game<'_> {
batch.vertices.clear();
// Font.prepareText, as any text: bold, italic, fonts and each
// glyph's own box (accented.png's are 12 pixels tall).
let behind = batch.name_tag(
&self.gui.atlas,
text,
y0,
text_alpha << 24 | 0xFF_FFFF,
if background { BACKGROUND } else { 0 },
);
let behind = match layouts.as_deref_mut() {
Some(layouts) => layouts.name_tag(
&mut batch,
&self.gui.atlas,
text,
y0,
text_alpha,
background,
),
None => batch.name_tag(
&self.gui.atlas,
text,
y0,
text_alpha << 24 | 0xFF_FFFF,
if background { BACKGROUND } else { 0 },
),
};
if n == last && marks.horizon {
let x = -self.gui.atlas.font.width_text(text) / 2.0 - 10.0;
// 8 text pixels square, a pixel left of the background.
@@ -777,6 +895,135 @@ mod tests {
use horizon_core::geometry::Vec3;
use std::sync::Arc;
fn tag_font() -> HudAtlas {
static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
let root = std::env::temp_dir().join(format!(
"horizon-tag-cache-{}-{}",
std::process::id(),
NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
));
let textures = root.join("assets/minecraft/textures/font");
std::fs::create_dir_all(root.join("assets/minecraft/font")).unwrap();
std::fs::create_dir_all(&textures).unwrap();
std::fs::write(root.join("assets/minecraft/font/default.json"),
r#"{"providers":[{"type":"bitmap","file":"minecraft:font/cache.png","ascent":7,"chars":["ab"]}]}"#).unwrap();
let file = std::fs::File::create(textures.join("cache.png")).unwrap();
let mut encoder = png::Encoder::new(file, 16, 8);
encoder.set_color(png::ColorType::Rgba);
encoder.set_depth(png::BitDepth::Eight);
let mut writer = encoder.write_header().unwrap();
let pixels: Vec<u8> = (0..128)
.flat_map(|i| [255, 255, 255, if i % 8 < 4 { 255 } else { 0 }])
.collect();
writer.write_image_data(&pixels).unwrap();
drop(writer);
let mut atlas = HudAtlas::boot();
atlas.font = horizon_client::font::Font::load(
&root,
&Default::default(),
&[],
(1024, 1024),
(512, 512),
atlas.sprites["horizon/white"],
);
std::fs::remove_dir_all(root).unwrap();
atlas
}
fn tag_bytes(
cache: &mut NameTagLayouts,
atlas: &HudAtlas,
text: &Text,
alpha: u32,
background: bool,
) -> Vec<u8> {
cache.begin_frame(atlas);
let mut batch = HudBatch::new([1, 1]);
let behind = cache.name_tag(&mut batch, atlas, text, 0.0, alpha, background);
cache.end_frame();
let mut fresh = HudBatch::new([1, 1]);
assert_eq!(
behind,
fresh.name_tag(
atlas,
text,
0.0,
alpha << 24 | 0xFF_FFFF,
if background { 0x3F00_0000 } else { 0 }
)
);
let bytes: &[u8] = bytemuck::cast_slice(&batch.vertices);
assert_eq!(bytes, bytemuck::cast_slice::<_, u8>(&fresh.vertices));
bytes.to_vec()
}
#[test]
fn cached_tag_quads_follow_text_style_pass_and_font_changes() {
let mut atlas = tag_font();
let mut cache = NameTagLayouts::default();
let a = Text::literal("a");
let first = tag_bytes(&mut cache, &atlas, &a, 255, false);
assert!(!first.is_empty());
assert_eq!(tag_bytes(&mut cache, &atlas, &a, 255, false), first);
for text in [
Text::literal("b"),
a.clone().bold(),
Text::colored("a", 0x12AB34),
] {
assert_ne!(tag_bytes(&mut cache, &atlas, &text, 255, false), first);
tag_bytes(&mut cache, &atlas, &text, 255, false);
}
assert_ne!(tag_bytes(&mut cache, &atlas, &a, 127, true), first);
tag_bytes(&mut cache, &atlas, &a, 127, true);
tag_bytes(&mut cache, &atlas, &a, 255, false);
// Reset and occupy the first glyph slot with b: a's old UV is now wrong.
atlas.font.reset_cache();
atlas.font.glyph('b');
assert_ne!(tag_bytes(&mut cache, &atlas, &a, 255, false), first);
// Font options may change without replacing the atlas.
atlas.font.uniform = true;
tag_bytes(&mut cache, &atlas, &a, 255, false);
}
#[test]
fn obfuscated_tag_glyphs_keep_animating_after_cache_warmup() {
let atlas = tag_font();
let mut cache = NameTagLayouts::default();
let text = Text::legacy("§kaaaaaaaaaaaaaaaa");
let mut images = std::collections::HashSet::new();
for seed in 0..8 {
cache.begin_frame(&atlas);
atlas.font.reseed(seed);
let mut batch = HudBatch::new([1, 1]);
cache.name_tag(&mut batch, &atlas, &text, 0.0, 255, false);
cache.end_frame();
let bytes = bytemuck::cast_slice::<_, u8>(&batch.vertices).to_vec();
atlas.font.reseed(seed);
let mut fresh = HudBatch::new([1, 1]);
fresh.name_tag(&atlas, &text, 0.0, u32::MAX, 0);
assert_eq!(bytes, bytemuck::cast_slice::<_, u8>(&fresh.vertices));
images.insert(bytes);
}
assert!(images.len() > 1, "the fixture must actually animate");
}
#[test]
fn inline_tag_objects_follow_live_sprite_updates() {
let mut atlas = HudAtlas::boot();
let mut sprite = atlas.sprites["horizon/white"];
atlas.sprites.insert("item/probe".into(), sprite);
let text = horizon_client::component::to_text(
&serde_json::json!({"object":"atlas", "sprite":"minecraft:item/probe"}),
&mut |_| Text::empty(),
);
let mut cache = NameTagLayouts::default();
let first = tag_bytes(&mut cache, &atlas, &text, 255, false);
assert!(!first.is_empty());
sprite.u0 += 0.1;
atlas.sprites.insert("item/probe".into(), sprite);
assert_ne!(tag_bytes(&mut cache, &atlas, &text, 255, false), first);
}
/// The player model `perspective::player_type` builds from the prepared
/// resources, without them: its box only needs the id and the pointer.
fn player_mob() -> Mob {
@@ -4,9 +4,11 @@ use horizon_client::{
collisions::SceneCollisions,
hud::{HudBatch, rgba},
hud_layout::GuiDraw,
items::ItemStack,
mod_api::Text,
};
use horizon_core::geometry::Vec3;
use winit::keyboard::KeyCode;
/// This window's state; unavailable on a server or before a window is attached.
#[derive(Clone, Debug, Default)]
@@ -291,4 +293,40 @@ impl Game<'_> {
self.titles.set_overlay(text, animate);
}
}
/// Draw an item stack with its default decorations at `at` in GUI units.
pub(super) fn native_gui_draw_item(
&self,
batch: &mut HudBatch,
stack: &ItemStack,
at: [f32; 2],
) {
self.draw_item(batch, stack, at[0], at[1]);
}
/// Draw an item stack with custom count/decoration text (or None) at `at` in GUI units.
pub(super) fn native_gui_draw_item_with(
&self,
batch: &mut HudBatch,
stack: &ItemStack,
at: [f32; 2],
text: Option<&str>,
) {
self.draw_item_model(batch, stack, at[0], at[1]);
self.draw_item_decorations(batch, stack, at[0], at[1], text.map(str::to_owned));
}
/// Whether Shift is pressed (live modifier state).
pub(super) fn native_has_shift_down(&self) -> bool {
self.has_shift_down()
}
/// Whether Control is pressed (live modifier state).
pub(super) fn native_has_control_down(&self) -> bool {
self.has_control_down()
}
/// Whether Alt is pressed (live modifier state).
pub(super) fn native_has_alt_down(&self) -> bool {
self.has_alt_down()
}
/// Whether a specific key is currently held down.
pub(super) fn native_is_key_down(&self, key: KeyCode) -> bool {
self.physical_keys.contains(&key)
}
}
+181 -1
View File
@@ -2194,9 +2194,25 @@ impl Game<'_> {
mod_api::dispatch_client_start(self, mod_api::mods());
}
/// Before an actual connection is discarded, not on a dimension change.
/// NeoForge's `ClientPlayerNetworkEvent.LoggingOut`, told here after
/// `native_client_level_unload` and only when a connection ends.
pub(super) fn native_client_disconnecting(&self) {
mod_api::dispatch_client_disconnecting(self, mod_api::mods());
}
/// The window's player logs in to a server (NeoForge's
/// `ClientPlayerNetworkEvent.LoggingIn`, in
/// `ClientPacketListener.handleLogin`), single player's own server or a
/// remote one: once per login packet, after the new client level
/// (`native_client_level_load`). Respawns and dimension changes don't log
/// in again; a server that sends its player back through configuration
/// does, without a `native_client_disconnecting` between. Horizon makes
/// the player with the server's first position, so `native_player` is
/// None here. A mod may send the server its own packets
/// (`native_send_packet`). Client only.
/// API @API:client_player_network_logging_in@.
pub(super) fn native_client_player_network_logging_in(&mut self) {
mod_api::dispatch_client_player_network_logging_in(self, mod_api::mods());
}
/// Once before this client's final save/exit. A mod can flush and join its worker here.
pub(super) fn native_client_stopping(&self) {
mod_api::dispatch_client_stopping(self, mod_api::mods());
@@ -2501,6 +2517,21 @@ impl Game<'_> {
pub(super) fn native_level_tick_post(&mut self, dimension: &str) {
mod_api::dispatch_level_tick_post(self, mod_api::mods(), dimension);
}
/// A client tick of the window starts (NeoForge's `ClientTickEvent.Pre`,
/// at the top of `Minecraft.tick`), before any of its work. The window
/// ticks 20 times a second on the title screen, while paused and in a
/// world alike, and runs the ticks a slow frame owes back to back. Never
/// on a server, single player's own included: it ticks on its own thread.
/// Client only. API @API:client_tick@.
pub(super) fn native_client_tick_pre(&mut self) {
mod_api::dispatch_client_tick_pre(self, mod_api::mods());
}
/// The same client tick ends (`ClientTickEvent.Post`): the world's tick,
/// the chat queue, music and sounds are done. A tick that errors ends
/// before it. Client only. API @API:client_tick@.
pub(super) fn native_client_tick_post(&mut self) {
mod_api::dispatch_client_tick_post(self, mod_api::mods());
}
/// The HUD (not drawn under F1), after the minimap: place a box with
/// `corners.place` (boxes stack away from their edge) and draw it with
/// `native_fill` and `native_text`. Skip a corner `native_corner_shown`
@@ -2587,6 +2618,30 @@ impl Game<'_> {
) {
mod_api::dispatch_left_click_block(self, mod_api::mods(), pos, face, state, canceled);
}
/// NeoForge's `PlayerEvent.BreakSpeed`: how fast the player digs the block
/// `state` at `pos`, where `Player.getDestroySpeed` ends. `speed` starts as
/// vanilla's, `original` (the tool, mining efficiency, Haste and Mining
/// Fatigue, the block break speed attribute, the slowdowns under water and
/// off the ground), and a mod may change it; every mod is called in turn,
/// and `original` stays vanilla's. `BlockBehaviour.getDestroyProgress` then
/// divides the speed by the block's hardness and by 30, or 100 without the
/// right tool, for each tick of the dig: a speed of 0 makes no progress, so
/// the block isn't broken, not even one that breaks at once. Not asked for
/// an unbreakable block (hardness -1) nor in creative mode. Fires on the
/// `Game` that works out the dig: single player's, a LAN or dedicated
/// host's for each guest (the helpers read that player), and a remote
/// client's own prediction, so a mod changes the speed alike on both sides
/// to keep the crack in step with the server. It is asked every tick of a
/// dig, on each side, so it should only compute. API @API:break_speed@.
pub(super) fn native_break_speed(
&self,
pos: [i32; 3],
state: BlockStateId,
original: f32,
speed: &mut f32,
) {
mod_api::dispatch_break_speed(self, mod_api::mods(), pos, state, original, speed);
}
/// A living player's server tick ends (`ServerPlayer.tick`): once a tick
/// for every player in the world, after their digging, item use,
/// cooldowns, statistics and advancements. The helpers read that player.
@@ -6281,6 +6336,36 @@ impl Game<'_> {
mod_api::dispatch_entity_break_door(self, mod_api::mods(), entity, kind, pos);
}
/// A mob or a player is about to change the block at `pos`: a landing's
/// trampling (farmland to dirt, a turtle egg breaking), before its
/// neighbour updates and events. `player` is the player whose landing it
/// was, `entity` and `kind` the mob for a mob's landing (`None` and the
/// player's type here). `from` and `to` are the block names. Set `cancel`
/// and the block stays as it was and nothing the change would have caused
/// happens, as Bukkit's `EntityChangeBlockEvent` (@API:entity_change_block_v2@).
pub(super) fn native_entity_change_block_v2(
&mut self,
player: Option<u128>,
entity: Option<u32>,
kind: &str,
pos: [i32; 3],
from: &str,
to: &str,
cancel: &mut bool,
) {
mod_api::dispatch_entity_change_block_v2(
self,
mod_api::mods(),
player,
entity,
kind,
pos,
from,
to,
cancel,
);
}
/// A mob changed a block at `pos`, after the new state was applied.
/// 214.
pub(super) fn native_entity_change_block(
@@ -6590,6 +6675,18 @@ impl Game<'_> {
) {
mod_api::dispatch_tooltip_component_draw(self, mod_api::mods(), stack, batch, origin, size);
}
/// Intercept item tooltip rendering before geometry calculation and drawing.
/// Setting `cancel` to true suppresses vanilla tooltip rendering.
pub(super) fn native_tooltip_render_pre(
&mut self,
stack: &ItemStack,
batch: &mut HudBatch,
x: f32,
y: f32,
cancel: &mut bool,
) {
mod_api::dispatch_tooltip_render_pre(self, mod_api::mods(), stack, batch, x, y, cancel);
}
/// The small bar `GuiGraphicsExtractor.itemDecorations` draws under an
/// item, wherever the game shows one: `bar` arrives holding vanilla's value
@@ -7867,6 +7964,63 @@ impl Game<'_> {
canceled,
);
}
/// `LivingEntity.actuallyHurt` is about to take `amount` from entity
/// `entity`'s health (NeoForge's `LivingDamageEvent.Pre`), once armour
/// (already worn down by the hit), enchantment protection, effects
/// (Resistance) and absorption have taken their part. The other arguments
/// are `native_living_incoming_damage`'s. Change `amount` to change what
/// the health loses: a negative or NaN amount takes nothing, an infinite
/// one `Float.MAX_VALUE`. The hit can't be canceled here (that is
/// `native_living_incoming_damage`): its invulnerable time, hurt sound and
/// knockback follow whatever the amount. Not told when no hit reaches
/// `actuallyHurt` (refused, canceled, or within the cooldown and no
/// stronger than the last hit) nor when a wolf's armour takes it.
/// Players, mobs and the ender dragon. It runs on the thread that ticks
/// the world (the server thread): state shared with render thread hooks
/// needs synchronising. No `self`: the game is in the middle of the hit.
/// API @API:living_damage@.
pub(super) fn native_living_damage_pre(
entity: i32,
kind: &str,
damage_type: &str,
causing: i32,
direct: i32,
amount: &mut f32,
) {
mod_api::dispatch_living_damage_pre(
mod_api::mods(),
entity,
kind,
damage_type,
causing,
direct,
amount,
);
}
/// The end of `LivingEntity.actuallyHurt` (NeoForge's
/// `LivingDamageEvent.Post`): `amount` came off entity `entity`'s health,
/// 0 when nothing did (absorption took it all, or a mod made it 0), after
/// the combat tracker. The arguments are
/// `native_living_damage_pre`'s. It only hears, on the server thread, with
/// no `self`. API @API:living_damage@.
pub(super) fn native_living_damage_post(
entity: i32,
kind: &str,
damage_type: &str,
causing: i32,
direct: i32,
amount: f32,
) {
mod_api::dispatch_living_damage_post(
mod_api::mods(),
entity,
kind,
damage_type,
causing,
direct,
amount,
);
}
/// A player's weapon hit on a mob, before `Mob::damage_with` borrows the
/// victim: melee, sweep and spear hits, including LAN guests. Unlike v1,
/// this has a host and snapshots of the weapon, victim position/view and
@@ -8246,7 +8400,33 @@ pub(super) fn hurt_server(hurting: Hurting<'_>, amount: &mut f32) -> bool {
);
canceled
}
/// `native_living_incoming_damage`'s entity, entity type and source for a hit.
/// `native_living_damage_pre` for the damage code (`health::DAMAGE_PRE`). Its
/// one caller.
pub(super) fn damage_pre(hurting: Hurting<'_>, amount: &mut f32) {
let (entity, kind, source) = hurt_server_args(hurting);
Game::native_living_damage_pre(
entity,
kind,
source.damage_type,
source.causing,
source.direct,
amount,
);
}
/// `native_living_damage_post` for the damage code (`health::DAMAGE_POST`).
/// Its one caller.
pub(super) fn damage_post(hurting: Hurting<'_>, amount: f32) {
let (entity, kind, source) = hurt_server_args(hurting);
Game::native_living_damage_post(
entity,
kind,
source.damage_type,
source.causing,
source.direct,
amount,
);
}
/// The damage hooks' entity, entity type and source for a hit.
fn hurt_server_args(hurting: Hurting<'_>) -> (i32, &str, HurtSource<'_>) {
match hurting {
Hurting::Player(entity, source) => (entity, "minecraft:player", source),
@@ -262,7 +262,7 @@ impl Game<'_> {
}
#[cfg(test)]
mod tests {
pub(crate) mod tests {
use super::super::{
Audio, Options, Progress, Settings,
boats::Boat,
@@ -278,7 +278,7 @@ mod tests {
/// A server's game on the prepared resources: nobody is playing, and the
/// chunk at the origin is loaded.
fn server_game() -> Game<'static> {
pub(crate) fn server_game() -> Game<'static> {
let root = std::path::PathBuf::from(
std::env::var_os("HORIZON_TEST_RESOURCES")
.expect("set HORIZON_TEST_RESOURCES to prepared resources"),
@@ -844,8 +844,10 @@ impl<'a> Game<'a> {
/// `NoiseBasedChunkGenerator.spawnOriginalMobs`: creature packs for a
/// freshly generated chunk, from the biome at its corner column's top,
/// with the chunk's decoration-seeded random, finalized with the
/// region's random as generation adds them (mobs are not generated in
/// the End).
/// region's random as generation adds them. The settings' own
/// `disable_mob_generation` skips the pass, which is how the End's preset
/// keeps its chunks empty (the dimension is also checked, as before, so a
/// preset that fails to load cannot start filling the End).
pub(super) fn spawn_generation_creatures(&mut self, cx: i32, cz: i32) {
if !self.world.chunks.contains_key(&(cx, cz)) || self.dim == Dim::End {
return;
@@ -857,6 +859,11 @@ impl<'a> Game<'a> {
if terrain.is_flat() {
return;
}
// `NoiseGeneratorSettings.disableMobGeneration`: the shipped End preset
// asks for no packs at generation, and so would any dimension using it.
if terrain.disable_mob_generation() {
return;
}
let mut region = terrain.region_random(cx, cz);
let any = region.as_any();
let added = if let Some(r) = any.downcast_mut::<LegacyRandom>() {
@@ -967,19 +967,7 @@ impl Game<'_> {
if !self.may_enter(plan.dim) {
return;
}
let uuid = self.uuid_for(id);
let t = &self.tnts[i];
let tag = horizon_client::entity_nbt::tnt_to_nbt(
t.position,
t.velocity,
t.on_ground,
t.fuse,
t.power,
t.block_state_nbt(self.pack.assets.registry),
t.rotation,
uuid,
self.persist_raw(id),
);
let tag = self.tnt_nbt(i);
self.tnts.remove(i);
self.forget_entity(id);
self.portals.arrivals.push(Arrival { plan, tag });
@@ -114,7 +114,8 @@ impl Game<'_> {
let at = block(s.position);
let center = at.map(|v| f64::from(v) + 0.5);
if s.tick_count == s.spawn_item_after - ABOUT_TO_SPAWN {
self.audio.play(
self.audio.play_on(
6, // SoundSource.NEUTRAL, as OminousItemSpawner.tickServer selects.
"minecraft:block.trial_spawner.about_to_spawn_item",
Some(center),
1.0,
@@ -237,3 +238,6 @@ impl Game<'_> {
}
}
}
#[cfg(test)]
mod warning_tests;
@@ -0,0 +1,112 @@
use horizon_core::geometry::Vec3;
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn warning_dispatch_uses_neutral_at_tick_four() {
use crate::game::{Audio, Game, Options, Progress, Settings, load_resources};
use horizon_client::motion::PlayerMotion;
let root = std::path::PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES").unwrap());
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 = crate::game::execution_role::ExecutionRole::IntegratedServer;
let mut game = Game::new(
&loaded.resources,
loaded.pack,
loaded.gui.unwrap(),
options,
settings,
Audio::silent(&root),
None,
)
.unwrap();
let scratch = std::env::temp_dir().join(format!(
"horizon-ominous-sound-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::create_dir_all(&scratch).unwrap();
game.world_root = scratch.clone();
game.player = Some(PlayerMotion::new(game.motion, Vec3::new(4.5, 100.0, 4.5)).unwrap());
const WARNING: &str = "minecraft:block.trial_spawner.about_to_spawn_item";
let center = [6.5, 102.5, 4.5];
game.audio.capture = Some(Vec::new());
game.audio
.play("minecraft:block.stone.break", Some(center), 1.0, 1.0);
game.audio.play_on(
6,
"minecraft:block.note_block.pling",
Some(center),
1.0,
1.0,
);
assert_eq!(
game.audio
.capture
.as_ref()
.unwrap()
.iter()
.map(|s| s.source)
.collect::<Vec<_>>(),
[4, 6]
);
game.audio.capture.as_mut().unwrap().clear();
let (tag, _) = horizon_world::nbt::parse_snbt(r#"{id:"minecraft:ominous_item_spawner",UUID:[I;128,0,0,1],Pos:[6.25d,102.75d,4.875d],item:{id:"minecraft:diamond",count:1},spawn_item_after_ticks:40L}"#).unwrap();
game.load_ominous_item_spawner(tag);
assert_eq!(game.ominous_spawners.len(), 1);
let s = &game.ominous_spawners[0];
assert_eq!(s.uuid, [128, 0, 0, 1]);
assert_eq!(s.position, [6.25, 102.75, 4.875]);
assert_eq!(&*s.item.item, "minecraft:diamond");
assert_eq!(s.item.count, 1);
assert_eq!(s.spawn_item_after, 40);
assert_eq!(s.tick_count, 0);
for tick in 1..=5 {
game.tick_ominous_item_spawners();
assert_eq!(game.ominous_spawners[0].tick_count, tick);
let warning: Vec<_> = game
.audio
.capture
.as_ref()
.unwrap()
.iter()
.filter(|s| s.event == WARNING)
.collect();
assert_eq!(warning.len(), if tick >= 4 { 1 } else { 0 });
if tick == 4 {
let sound = warning[0];
assert_eq!(sound.position, center);
assert_eq!(sound.volume.to_bits(), 1.0f32.to_bits());
assert_eq!(sound.pitch.to_bits(), 1.0f32.to_bits());
assert_eq!(
sound.source, 6,
"OminousItemSpawner.tickServer explicitly selects NEUTRAL"
);
}
}
game.audio
.play("minecraft:block.stone.break", Some(center), 1.0, 1.0);
assert_eq!(
game.audio.capture.as_ref().unwrap().last().unwrap().source,
4,
"the explicit warning source must not change later event-family inference"
);
drop(game);
std::fs::remove_dir_all(scratch).unwrap();
}
@@ -11,6 +11,7 @@ use serde_json::{Value, json};
use std::time::{Duration, Instant};
mod face_buttons;
mod hid_layout;
mod triggers;
/// The controllers gilrs sees and the DualSense's output.
@@ -261,7 +262,11 @@ fn state_of(pad: &gilrs::Gamepad) -> PadState {
sdl_mapped,
);
state.left = left;
state.right = right;
state.right = if hid_layout::right_stick_on_z(pad) {
hid_layout::right_stick([axis(A::LeftZ), axis(A::RightZ)])
} else {
right
};
state.triggers = triggers::triggers(pad);
state
}
@@ -1,4 +1,5 @@
//! Linux's codes for X and Y don't say where those buttons sit.
use super::hid_layout;
use gilrs::{Button, Gamepad, MappingSource};
use horizon_client::gamepad::GlyphStyle;
@@ -18,14 +19,20 @@ pub(super) fn crossed(pad: &Gamepad) -> bool {
Button::Z,
]
.map(|button| pad.button_code(button).is_some()),
hid_layout::numbered_buttons(pad),
GlyphStyle::of_device(pad.vendor_id(), pad.product_id()),
)
}
fn crossed_layout(source: MappingSource, face_buttons: [bool; 6], style: GlyphStyle) -> bool {
fn crossed_layout(
source: MappingSource,
face_buttons: [bool; 6],
numbered_buttons: bool,
style: GlyphStyle,
) -> bool {
source == MappingSource::Driver
// The Xbox-style layout has no C or Z.
&& face_buttons == [true, true, true, true, false, false]
// The Xbox-style layout has no C or Z, unless HID's numbered buttons name them unused.
&& (face_buttons == [true, true, true, true, false, false] || numbered_buttons)
// These drivers send the same codes for what is really on the north and the west.
&& !matches!(style, GlyphStyle::PlayStation | GlyphStyle::Nintendo)
}
@@ -49,7 +56,12 @@ mod tests {
fn an_unmapped_xbox_style_pad_is_crossed() {
// The GameSir Nova Lite 2 of #7323: `3537:1098`, no SDL row, X sends BTN_NORTH.
let style = GlyphStyle::of_device(Some(0x3537), Some(0x1098));
assert!(crossed_layout(MappingSource::Driver, XBOX_BUTTONS, style));
assert!(crossed_layout(
MappingSource::Driver,
XBOX_BUTTONS,
false,
style
));
}
#[test]
@@ -57,6 +69,7 @@ mod tests {
assert!(!crossed_layout(
MappingSource::SdlMappings,
XBOX_BUTTONS,
false,
GlyphStyle::Xbox
));
}
@@ -65,7 +78,12 @@ mod tests {
fn a_sony_or_nintendo_pad_means_what_its_codes_say() {
for vendor in [0x054c, 0x057e] {
let style = GlyphStyle::of_device(Some(vendor), None);
assert!(!crossed_layout(MappingSource::Driver, XBOX_BUTTONS, style));
assert!(!crossed_layout(
MappingSource::Driver,
XBOX_BUTTONS,
false,
style
));
}
}
@@ -78,11 +96,49 @@ mod tests {
assert!(!crossed_layout(
MappingSource::Driver,
different,
false,
GlyphStyle::Xbox
));
}
}
#[test]
fn an_xbox_pad_over_bluetooth_is_crossed_though_it_lists_c_and_z() {
// `045e:0b13` on hid_microsoft reports BTN_SOUTH to BTN_THUMBR, with no SDL row for
// firmware 0x0520: X sends BTN_NORTH.
let style = GlyphStyle::of_device(Some(0x045e), Some(0x0b13));
assert!(crossed_layout(
MappingSource::Driver,
[true; 6],
true,
style
));
}
#[test]
fn numbered_buttons_on_a_sony_or_nintendo_pad_are_left_alone() {
for vendor in [0x054c, 0x057e] {
let style = GlyphStyle::of_device(Some(vendor), None);
assert!(!crossed_layout(
MappingSource::Driver,
[true; 6],
true,
style
));
}
}
#[test]
fn six_real_buttons_are_not_crossed() {
// A pad with C and Z and its stick elsewhere: they may be real buttons, so X is X.
assert!(!crossed_layout(
MappingSource::Driver,
[true; 6],
false,
GlyphStyle::Xbox
));
}
#[test]
fn only_x_and_y_change_places() {
assert_eq!(named(Button::West, true), Button::North);
@@ -0,0 +1,87 @@
//! A pad the kernel's generic HID driver describes, which an SDL row would otherwise explain.
//! SDL rows match one firmware version exactly, so a new firmware of a known pad lands here too.
use gilrs::{Axis, Button, Gamepad, MappingSource};
/// Whether this pad has its right stick on `ABS_Z`/`ABS_RZ`, as an Xbox pad over Bluetooth does.
pub(super) fn right_stick_on_z(pad: &Gamepad) -> bool {
cfg!(target_os = "linux")
&& stick_on_z(
pad.mapping_source(),
[
Axis::RightStickX,
Axis::RightStickY,
Axis::LeftZ,
Axis::RightZ,
]
.map(|axis| pad.axis_code(axis).is_some()),
)
}
/// Whether HID's numbered buttons reach us as A, B, C, X, Y, Z: this layout's X and Y are the
/// fourth and fifth buttons, and C and Z are the third and sixth, which nothing presses.
pub(super) fn numbered_buttons(pad: &Gamepad) -> bool {
right_stick_on_z(pad)
&& [Button::C, Button::Z]
.iter()
.all(|&button| pad.button_code(button).is_some())
}
/// `[RightStickX, RightStickY, LeftZ, RightZ]` present. With `RightStickX` there, Z is a trigger
/// or belongs to a model `gamepad::stick_axes` knows.
fn stick_on_z(source: MappingSource, axes: [bool; 4]) -> bool {
source == MappingSource::Driver && axes == [false, false, true, true]
}
/// The right stick from those axes. gilrs reverses Y only on the axes it calls Y, so RZ, which
/// grows downward like every Linux Y axis, is turned over here to match them.
pub(super) fn right_stick([z, rz]: [f32; 2]) -> [f32; 2] {
[z, -rz]
}
#[cfg(test)]
mod tests {
use super::*;
/// `045e:0b13` firmware 0x0520 over Bluetooth: X, Y, Z, RZ, GAS, BRAKE and hats.
const XBOX_BLUETOOTH: [bool; 4] = [false, false, true, true];
#[test]
fn a_pad_without_rx_or_ry_has_its_right_stick_on_z() {
assert!(stick_on_z(MappingSource::Driver, XBOX_BLUETOOTH));
}
#[test]
fn an_sdl_row_already_says_where_the_stick_is() {
assert!(!stick_on_z(MappingSource::SdlMappings, XBOX_BLUETOOTH));
}
#[test]
fn a_pad_with_rx_and_ry_keeps_them() {
// xpad: Z and RZ are its triggers.
assert!(!stick_on_z(MappingSource::Driver, [true, true, true, true]));
// The 8BitDo Ultimate 2 has RX too, and stick_axes reads it.
assert!(!stick_on_z(
MappingSource::Driver,
[true, false, true, true]
));
}
#[test]
fn a_pad_missing_either_axis_has_no_stick_there() {
assert!(!stick_on_z(
MappingSource::Driver,
[false, false, true, false]
));
assert!(!stick_on_z(
MappingSource::Driver,
[false, false, false, true]
));
}
#[test]
fn pushing_the_stick_up_reads_up() {
// Up is the low end of ABS_RZ, and up is positive on every other stick axis.
assert_eq!(right_stick([0.0, -1.0]), [0.0, 1.0]);
assert_eq!(right_stick([-0.5, 0.25]), [-0.5, -0.25]);
}
}
@@ -1,7 +1,13 @@
//! Read triggers from mapped buttons, or the conventional unmapped Linux axes.
use super::hid_layout;
use gilrs::{Axis, Button, Gamepad, MappingSource};
use horizon_client::gamepad::BITDO_ULTIMATE2_VID_PID;
/// Linux's event type for absolute axes, and its codes for an Xbox Bluetooth pad's triggers.
const EV_ABS: u16 = 0x03;
const ABS_GAS: u16 = 0x09;
const ABS_BRAKE: u16 = 0x0a;
pub(super) fn triggers(pad: &Gamepad) -> [f32; 2] {
let axes = cfg!(target_os = "linux")
&& axis_triggers(
@@ -27,11 +33,15 @@ pub(super) fn triggers(pad: &Gamepad) -> [f32; 2] {
.map(|button| pad.button_code(button).is_some()),
(pad.vendor_id(), pad.product_id()),
);
let on_gas_and_brake = hid_layout::right_stick_on_z(pad);
[
(Button::LeftTrigger2, Axis::LeftZ),
(Button::RightTrigger2, Axis::RightZ),
(Button::LeftTrigger2, Axis::LeftZ, ABS_BRAKE),
(Button::RightTrigger2, Axis::RightZ, ABS_GAS),
]
.map(|(button, axis)| {
.map(|(button, axis, absolute)| {
if on_gas_and_brake && let Some(value) = unnamed_axis(pad, absolute) {
return trigger_value(false, None, false, Some(value));
}
trigger_value(
pad.button_code(button).is_some(),
pad.button_data(button).map(|data| data.value()),
@@ -42,6 +52,16 @@ pub(super) fn triggers(pad: &Gamepad) -> [f32; 2] {
})
}
/// gilrs' default mapping has no name for these, and those pads also list trigger buttons that
/// never fire, so an axis that has moved wins.
fn unnamed_axis(pad: &Gamepad, absolute: u16) -> Option<f32> {
let code = (u32::from(EV_ABS) << 16) | u32::from(absolute);
pad.state()
.axes()
.find(|(axis, _)| axis.into_u32() == code)
.map(|(_, data)| data.value())
}
fn axis_triggers(
source: MappingSource,
six_axes: bool,
+35 -8
View File
@@ -1142,22 +1142,33 @@ impl Game<'_> {
/// Vanilla's arrow navigation moves a screen's focus off the widget a
/// click focused: the screens that draw that click focus themselves drop
/// it, so only the pad's focus shows.
fn clear_screen_click_focus(&mut self) {
/// it, so only the pad's focus shows. The screen it clears need not be
/// the one in front (a screen that just opened leaves the old focus
/// behind it), so every menu's click focus goes.
pub(super) fn clear_screen_click_focus(&mut self) {
if let Some(title) = self.title.as_mut() {
title.focused_button = None;
}
match self.screen {
Screen::Paused => self.pause_focus = None,
Screen::Dead => self.death_focus = None,
Screen::Options => self.ui.options.clear_click_focus(),
_ => {}
}
self.pause_focus = None;
self.death_focus = None;
self.ui.options.clear_click_focus();
if let Some(options) = self.world_options.as_mut() {
options.clear_click_focus();
}
}
/// `Gui.setScreen` clears the focus of the screen it leaves
/// (`Screen.clearFocus`): the screen that opens starts with none, so the
/// button a click focused is not still highlighted
/// (`AbstractWidget.isHoveredOrFocused`) when the player comes back to it.
/// The frame after another screen is front, `key_screen` names a new one.
pub(super) fn drop_left_screen_focus(&mut self) {
let screen = self.key_screen();
if another_screen_is_front(&mut self.focus_screen, screen) {
self.clear_screen_click_focus();
}
}
/// The controller's focus ring in focus mode, for the screen's narration:
/// the focused widget.
pub(super) fn pad_focus_rect(&self) -> Option<[f32; 4]> {
@@ -2376,6 +2387,13 @@ fn field_at(targets: &[Target], at: (f32, f32)) -> bool {
})
}
/// Whether `front` is another screen than the one `shown` names yet, and
/// records it: the menu focus belongs to the screen that showed it
/// (`Gui.setScreen` clears the focus of the screen it leaves).
pub(super) fn another_screen_is_front(shown: &mut u64, front: u64) -> bool {
std::mem::replace(shown, front) != front
}
#[cfg(test)]
mod tests {
use super::*;
@@ -2434,6 +2452,15 @@ mod tests {
assert_eq!(named_target("reset", &labels, &[first, second]), None);
}
#[test]
fn a_new_screen_is_a_new_place_for_the_focus() {
let mut shown = 7;
assert!(!another_screen_is_front(&mut shown, 7), "the same screen");
assert!(another_screen_is_front(&mut shown, 8), "another screen");
assert!(!another_screen_is_front(&mut shown, 8), "still that one");
assert_eq!(shown, 8);
}
#[test]
fn a_scrollbar_tells_how_far_rows_moved_when_rectangles_cannot() {
// A whole row a notch: the same rectangles, so only the amount shows it.
+29 -22
View File
@@ -52,6 +52,15 @@ pub struct EntityMemory {
/// Only filled while a mod is loaded.
pub(super) live: Vec<(u32, String, bool)>,
}
impl EntityMemory {
/// The UUID entity `id` was loaded or saved with: what a save writes for a
/// field naming that entity (`entity_nbt::mob_to_nbt`'s `uuid_of`). `None`
/// for one that has none yet, so no reference is written rather than a
/// made-up UUID nothing would answer for.
pub(super) fn uuid_of(&self, id: u32) -> Option<[i32; 4]> {
self.uuids.get(&id).copied()
}
}
/// The key that marks a compound queued with the chunk loads as a fresh add
/// (`PersistentEntitySectionManager.addNewEntity`: `/summon`, a portal
@@ -1154,6 +1163,7 @@ impl Game<'_> {
uuid,
self.persist.raw.get(&id),
Some(self.pack.assets.registry),
|other| self.persist.uuid_of(other),
);
if horizon_client::mobs::camel::is_camel(self.mobs[i].species())
&& let Tag::Compound(c) = &mut tag
@@ -1275,13 +1285,24 @@ impl Game<'_> {
join_saved_team(&mut self.score.board, &name, tag);
}
}
/// The entity a UUID read back from a save names, while it is in the level:
/// the player, or a mob loaded or saved with that UUID. `None` for nothing
/// loaded (an entity that is not here, or one `Who` cannot carry) - never
/// some other entity as a stand-in.
pub(super) fn who_by_uuid(&self, uuid: [i32; 4]) -> Option<Who> {
let player = if self.player.is_some() {
self.level.player_uuid
} else {
None
};
entity_nbt::who_by_uuid(&self.persist.uuids, &self.mobs, player, uuid)
}
/// The mob that was loaded or saved with a UUID.
pub(super) fn mob_by_uuid(&self, uuid: [i32; 4]) -> Option<u32> {
self.persist
.uuids
.iter()
.find(|(id, u)| **u == uuid && self.mobs.iter().any(|m| m.id == **id))
.map(|(id, _)| *id)
match self.who_by_uuid(uuid) {
Some(Who::Mob(id)) => Some(id),
_ => None,
}
}
/// A mob's UUID, made up for new ones.
pub(super) fn mob_uuid(&mut self, id: u32) -> [i32; 4] {
@@ -1799,23 +1820,9 @@ impl Game<'_> {
i: usize,
keys: &HashSet<ChunkKey>,
) -> Option<(ChunkKey, Tag)> {
let (id, at) = (self.tnts[i].id, self.tnts[i].position);
keys.contains(&chunk_of(at)).then(|| {
let uuid = self.uuid_for(id);
let t = &self.tnts[i];
let tag = entity_nbt::tnt_to_nbt(
t.position,
t.velocity,
t.on_ground,
t.fuse,
t.power,
t.block_state_nbt(self.pack.assets.registry),
t.rotation,
uuid,
self.persist.raw.get(&id),
);
(chunk_of(at), tag)
})
let at = self.tnts[i].position;
keys.contains(&chunk_of(at))
.then(|| (chunk_of(at), self.tnt_nbt(i)))
}
pub(super) fn save_boat(
&mut self,
+8 -1
View File
@@ -1987,7 +1987,14 @@ impl Game<'_> {
}
};
let m = &mut self.mobs[i];
if harm {
if harm && m.species() == horizon_client::mobs::Species::Enderman {
// `Enderman.hurtServer` for a thrown potion: only water hurts
// (`hurtWithCleanWater`), then `repeatedlyTryToTeleport`, which
// the AI step runs and which does nothing while it rides.
if m.alive() && !m.extra.passenger {
m.extra.counter2 = 64;
}
} else if harm {
// HealOrHarmMobEffect.applyInstantaneousEffect: indirectMagic(potion, owner).
let harm = (intensity * f64::from(6 << amp) + 0.5) as i32 as f32;
let source = Damage::indirect_magic(owner.who(), [at.x, at.y, at.z]);
@@ -34,6 +34,18 @@ impl Game<'_> {
}
self.extinguish_sound([at.x, at.y, at.z]);
}
/// `PowderSnowBlock.entityInside` (runBefore EXTINGUISH) for a burning
/// player: `Level.destroyBlock(pos, false)`. A client of a remote server
/// leaves it to the server.
pub(super) fn powder_snow_melt(&mut self, pos: BlockPos) {
let at = [pos.x, pos.y, pos.z];
if self.remote.is_some() || !self.is_powder_snow(at) {
return;
}
self.with_level(|b, level, ticks| b.destroy_block(level, ticks, pos, false));
let _ = self.process_level_events();
self.world.modified.insert((at[0] >> 4, at[2] >> 4));
}
/// Whether the block at `pos` is powder snow.
pub(super) fn is_powder_snow(&self, pos: [i32; 3]) -> bool {
&*self
+100 -4
View File
@@ -1221,14 +1221,19 @@ impl Game<'_> {
/// bed or bell a villager has taken): `PoiManager.isVillageCenter`.
pub(super) fn village_centers(&self) -> HashSet<(i32, i32, i32)> {
let mut out = HashSet::new();
let world = self.mob_world_base(&[]);
for m in &self.mobs {
if m.species() != Species::Villager || !m.alive() {
continue;
}
for p in [m.extra.home, m.extra.bed, m.extra.meeting]
.into_iter()
.flatten()
{
// A remembered HOME is no longer an occupied village POI once its
// loaded bed head disappears. An unavailable chunk remains unknown.
let bed = m.extra.bed.filter(|&[x, y, z]| {
!self.world.loaded(x, z)
|| horizon_client::mobs::villager_brain::poi_at(&world, x, y, z)
== Some(horizon_client::mobs::villager_brain::Poi::Home)
});
for p in [m.extra.home, bed, m.extra.meeting].into_iter().flatten() {
out.insert(section(p));
}
}
@@ -1520,3 +1525,94 @@ mod tests {
assert_eq!(section([-1, -17, 15]), (-1, -2, 0));
}
}
impl Game<'_> {
/// Read-only command-harness observation of the existing raid state and inputs.
pub(in crate::game) fn raid_fixture_state(
&self,
op: &serde_json::Value,
) -> horizon_client::Result<serde_json::Value> {
use serde_json::json;
let id = op["id"].as_i64().ok_or("raid observation: missing id")? as i32;
let mut state = json!({"id":id,"present":self.raiding.raids.get(id).is_some()});
if let Some(r) = self.raiding.raids.get(id) {
state["status"] = json!(r.status.name());
state["started"] = json!(r.started);
state["active"] = json!(r.active);
state["groups_spawned"] = json!(r.groups_spawned);
state["center"] = json!(r.center);
// Observe the production answer; never substitute a corrected POI test here.
state["village"] = json!(Self::is_village(&self.village_centers(), r.center));
if op["members"].as_bool() == Some(true) {
state["member_present"] = json!(self.raid_members(id).next().is_some());
}
}
let observe = |m: &Mob| -> horizon_client::Result<serde_json::Value> {
let uuid = self
.persist
.uuids
.get(&m.id)
.copied()
.ok_or("raid observation: actor UUID is not registered")?;
let tag = horizon_client::entity_nbt::mob_to_nbt(
m,
uuid,
self.persist.raw.get(&m.id),
Some(self.pack.assets.registry),
);
Ok(json!({"uuid":uuid,"type":m.kind.id,"health":m.health,
"pos":[m.position.x,m.position.y,m.position.z],"noai":m.no_ai,
"home":m.extra.bed.map(|p| p.to_vec()).unwrap_or_default(),
"raid_id":raider::state(m).and_then(|s|s.raid_id).unwrap_or(-1),
"nbt":horizon_client::snbt::to_snbt(&tag)}))
};
let actors = self
.raid_members(id)
.map(observe)
.collect::<horizon_client::Result<Vec<_>>>()?;
let mut villagers = Vec::new();
for uuid in op["villagers"]
.as_array()
.ok_or("raid observation: missing villagers")?
{
let uuid = u128::from_str_radix(
&uuid
.as_str()
.ok_or("raid observation: invalid UUID")?
.replace('-', ""),
16,
)?;
let Some(super::commands::Target::Mob(id)) = self.target_by_uuid(uuid) else {
return Err("raid observation: villager UUID missing/wrong type".into());
};
let m = self
.mobs
.iter()
.find(|m| m.id == id && m.species() == Species::Villager)
.ok_or("raid observation: actor is not a villager")?;
villagers.push(observe(m)?);
}
let pos: [i32; 3] =
std::array::from_fn(|i| op["poi"][i].as_i64().unwrap_or(i64::MIN) as i32);
let loaded = self.world.loaded(pos[0], pos[2]);
let live_poi = loaded.then(|| {
let w = self.mob_world_base(&[]);
format!(
"{:?}",
horizon_client::mobs::villager_brain::poi_at(&w, pos[0], pos[1], pos[2])
)
});
eprintln!(
"raid input raw: {}",
json!({"label":op["label"],"id":id,"loaded":loaded,
"live_poi":live_poi,"claimed_pois":self.village_pois(),
"raid_nbt":horizon_client::snbt::to_snbt(&self.raiding.raids.to_nbt()),
"actors":actors,"villagers":villagers})
);
Ok(state)
}
}
#[cfg(test)]
#[path = "raids/tests/bed_loss.rs"]
mod bed_loss_tests;
@@ -0,0 +1,109 @@
//! Real Game raid creation/wave dispatch with a loaded claimed-bed precondition.
use super::super::{Audio, Game, Options, Progress, Settings, load_resources};
use horizon_client::{chunk::Chunk, mobs::Mob, raid::Status};
use horizon_core::{geometry::Vec3, position::ChunkPos};
use std::sync::Arc;
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn admitted_raid_loses_removed_loaded_bed_without_erasing_memory() {
let root = std::path::PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES").unwrap());
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 = super::super::execution_role::ExecutionRole::IntegratedServer;
let mut game = Game::new(
&loaded.resources,
loaded.pack,
loaded.gui.unwrap(),
options,
settings,
Audio::silent(&root),
None,
)
.unwrap();
game.world_root =
std::path::PathBuf::from(std::env::var_os("HORIZON_HOME").unwrap()).join("raid-unit-world");
for x in -5..5 {
for z in -5..5 {
game.world
.chunks
.insert((x, z), Arc::new(Chunk::empty(x, z)));
game.block_ticks
.add_empty_chunk(ChunkPos::new(x, z))
.unwrap();
}
}
let registry = game.pack.assets.registry;
let air = registry.default_state("minecraft:air").unwrap();
let stone = registry.default_state("minecraft:stone").unwrap();
for x in -63..=63 {
for z in -63..=63 {
game.world.set(x, 199, z, stone).unwrap();
}
}
let foot = registry.default_state("minecraft:white_bed").unwrap();
let mut head_state = registry.state(foot).unwrap().clone();
head_state.properties.insert("part".into(), "head".into());
let head = registry.id(&head_state).unwrap();
game.world.set(2, 200, 2, foot).unwrap();
game.world.set(2, 200, 1, head).unwrap();
let kind = game
.res
.mob_types()
.get("minecraft:villager")
.unwrap()
.clone();
let mut villager = Mob::new(162, kind, Vec3::new(3.5, 200.0, 2.5), 0.0);
villager.no_ai = true;
// The full Game/Java fixture proves real AI acquisition; this unit starts
// from that claimed-bed state and exercises production raid/wave dispatch.
villager.extra.bed = Some([2, 200, 1]);
game.mobs.push(villager);
assert!(game.raid_command_create([8, 200, 8], 1));
let id = game.raiding.raids.ids()[0];
for _ in 0..400 {
game.world.game_time += 1;
game.tick_raids();
if game.raiding.raids.get(id).unwrap().groups_spawned == 1 {
break;
}
}
let raid = game.raiding.raids.get(id).unwrap();
assert!(raid.started);
assert_eq!(raid.groups_spawned, 1, "real first wave must be admitted");
assert!(
game.raid_members(id).next().is_some(),
"real wave members required"
);
for _ in 0..20 {
game.tick_raids();
}
assert_eq!(game.raiding.raids.get(id).unwrap().status, Status::Ongoing);
// Unavailable claims remain unknown rather than becoming known air.
let chunk = game.world.chunks.remove(&(0, 0)).unwrap();
assert!(Game::is_village(&game.village_centers(), [2, 200, 1]));
game.world.chunks.insert((0, 0), chunk);
game.level_set([2, 200, 1], air).unwrap();
game.level_set([2, 200, 2], air).unwrap();
assert_eq!(game.world.block(2, 200, 1), air);
assert_eq!(game.world.block(2, 200, 2), air);
game.tick_raids();
assert_eq!(game.raiding.raids.get(id).unwrap().status, Status::Loss);
assert_eq!(
game.mobs.iter().find(|m| m.id == 162).unwrap().extra.bed,
Some([2, 200, 1])
);
}
+29 -10
View File
@@ -2013,9 +2013,12 @@ impl Game<'_> {
self.send_chat_acknowledgement();
self.close_dialogs();
// The server configuring us now (a proxy's next one)
// offers its own far terrain, or none.
// offers its own far terrain, or none, and announces its
// own features again (a Horizon server's quiet commands).
if let Some(remote) = &mut self.remote {
remote.far_terrain = None;
remote.quiet_commands = false;
remote.mod_net = false;
}
self.clear_lod();
if let Some(remote) = &self.remote {
@@ -2253,6 +2256,9 @@ impl Game<'_> {
let Some(r) = self.remote.as_mut() else {
return;
};
// A Horizon server's bulk edit sends whole columns with the light
// from before its relight; any other server's packet light is final.
let relight_resent = r.quiet_commands;
let dimension = r.registries.dimension(spawn.dimension_type);
// The server's dimension type gives the level's shape, and its
// chunks the shape's column.
@@ -2358,6 +2364,7 @@ impl Game<'_> {
self.native_client_level_unload(&old);
}
}
self.streaming.relight_resent = relight_resent;
let has_sky = self
.remote
.as_ref()
@@ -3303,6 +3310,8 @@ impl Game<'_> {
false,
horizon_client::level_loading::Reason::Other,
);
// handleLogin: ClientPlayerNetworkEvent.LoggingIn, the new level made.
self.native_client_player_network_logging_in();
// No ClientInformation here: the configuration phase sent it,
// and ClientPacketListener.broadcastClientInformation sends it
// again only when an option changes it.
@@ -6411,6 +6420,9 @@ impl Game<'_> {
// NetherPortalBlock.entityInside on the client's own level:
// the portal overlay and its trigger sound (the server moves
// the player).
horizon_client::motion::MotionEvent::PowderSnowMelt { pos } => {
self.powder_snow_melt(pos);
}
horizon_client::motion::MotionEvent::InsidePortal { .. } => {
self.client_inside_nether_portal();
}
@@ -7489,15 +7501,12 @@ impl Game<'_> {
}
return;
}
let facts = self.res.facts().get(state).clone();
// The server's own getDestroyProgress: under water, off the ground,
// with haste, fatigue and efficiency, so the client stops digging
// when the server is done too (#1254).
let progress = if facts.hardness == 0.0 {
1.0
} else {
self.destroy_progress(hit.pos)
};
// with haste, fatigue, efficiency and the mods' native_break_speed, so
// the client stops digging when the server is done too (#1254). A
// block of hardness 0 gives infinity, breaking at once, unless a mod
// made the speed 0.
let progress = self.destroy_progress(hit.pos);
// sameDestroyTarget: the same block and still the item the break began with.
let held = self.main_hand_key();
let same = self.remote.as_ref().is_some_and(|r| {
@@ -8415,8 +8424,16 @@ impl Game<'_> {
stack: &ItemStack,
) -> (bool, Option<DryChanges>, Vec<DrySound>) {
// `BucketItem.use`: `level.mayInteract` and `mayUseItemAt`.
// `Player.mayUseItemAt` tests the clicked block against `can_place_on`.
if !self.may_build() {
return (false, None, Vec::new());
let aim = if &*stack.item == "minecraft:bucket" {
self.bucket_hit()
} else {
self.pick()
};
if !aim.is_some_and(|hit| self.can_place_on_in_adventure(hit.pos)) {
return (false, None, Vec::new());
}
}
if &*stack.item == "minecraft:bucket" {
let Some(hit) = self.bucket_hit() else {
@@ -9396,6 +9413,8 @@ impl Game<'_> {
/// MultiPlayerGameMode.dropItem (key.drop): predict the removal, then send
/// the action; swing only if a nonempty stack was removed.
pub(super) fn remote_drop(&mut self, all: bool) {
// ensureHasSentCarriedItem: the server drops from the slot just chosen.
self.remote_send_carried();
let dropped =
horizon_client::items::take_held_drop(&mut self.inventory[self.selected], all, false);
self.send(S::PlayerAction(play::PlayerAction {
@@ -332,7 +332,7 @@ impl Game<'_> {
pub(super) fn turn_remote_object(&mut self, id: i32, yaw: Option<f32>, on_ground: bool) {
let id = id as u32;
// The boat the player drives turns here (isLocalInstanceAuthoritative).
let driven = self.riding;
let driven = self.riding.filter(|&id| self.controls_boat(id));
if let Some(b) = self.boats.iter_mut().find(|b| b.id == id) {
if Some(id) == driven {
return;
@@ -390,7 +390,7 @@ impl Game<'_> {
}
}
// The boat the player drives moves here, not where the server says.
let driven = self.riding;
let driven = self.riding.filter(|&id| self.controls_boat(id));
for b in &mut self.boats {
if Some(b.id) == driven {
continue;
@@ -254,6 +254,12 @@ impl Game<'_> {
Some(position)
}
/// AbstractBoat.getControllingPassenger: only the first player steers a boat.
/// A non-remote world retains its existing local movement ownership.
pub(super) fn controls_boat(&self, id: u32) -> bool {
self.riding == Some(id) && self.remote.as_ref().is_none_or(|r| r.first_passenger)
}
/// The local player's tick in its vehicle, in place of its own movement:
/// false when it rides nothing.
pub(super) fn remote_ride(&mut self, keys: Keys, sprint_window: i32) -> Result<bool> {
@@ -287,8 +293,11 @@ impl Game<'_> {
// It steers with the input `LocalPlayer.rideTick` latched on the
// previous tick, so the paddle packet describes the motion the
// server is sent; this tick's keys are latched after it ticked.
let mut b = self.tick_boat(self.boats[i].clone(), true);
b.input = [keys.left, keys.right, keys.forward, keys.backward];
let mut b = self.boats[i].clone();
if self.controls_boat(id) {
b = self.tick_boat(b, true);
b.input = [keys.left, keys.right, keys.forward, keys.backward];
}
// positionRider: the seat turns with the boat; clampRotation.
self.yaw += b.delta_rotation;
let delta = (self.yaw - b.yaw + 180.0).rem_euclid(360.0) - 180.0;
@@ -349,6 +358,7 @@ impl Game<'_> {
}
let Some(b) = self
.riding
.filter(|&id| self.controls_boat(id))
.and_then(|id| self.boats.iter().find(|b| b.id == id))
else {
return Vec::new();
@@ -383,6 +393,7 @@ impl Game<'_> {
}
let Some(b) = self
.riding
.filter(|&id| self.controls_boat(id))
.and_then(|id| self.boats.iter_mut().find(|b| b.id == id))
else {
return;
@@ -414,7 +425,7 @@ impl Game<'_> {
/// the synched bubble time, followed by the oars on boats this client does not drive.
pub(super) fn tick_remote_boat_bubbles(&mut self) {
for i in 0..self.boats.len() {
let driven = self.riding == Some(self.boats[i].id);
let driven = self.controls_boat(self.boats[i].id);
if !driven {
// `AbstractBoat.tick`: the non-authoritative copy carries no
// cached motion into the next time this player drives it.
+16 -6
View File
@@ -326,7 +326,8 @@ impl Game<'_> {
let tags = self.tags_of(t);
for (tag, negated) in &s.tags {
let has = if tag.is_empty() {
!tags.is_empty()
// EntitySelectorOptions' empty-name predicate (lambda$bootStrap$43).
tags.is_empty()
} else {
tags.contains(tag)
};
@@ -835,6 +836,8 @@ impl Game<'_> {
};
let has_objective = self.score.board.display_objective(sb::SLOT_LIST).is_some();
let shown = tab_list::visible(
// `Hud.extractRenderState` draws the list inside `if (!isHidden)`.
!self.ui.hide_gui,
key,
self.has_singleplayer_server(),
players.len(),
@@ -993,7 +996,7 @@ mod tab_list_tests {
let mut guests = vec![guest(2, "Alex")];
let players = local_tab_players(me.clone(), &guests);
assert_eq!(players.as_slice(), std::slice::from_ref(&me));
assert!(!tab_list::visible(true, true, players.len(), false));
assert!(!tab_list::visible(true, true, true, players.len(), false));
guests[0].placed = true;
let players = local_tab_players(me.clone(), &guests);
@@ -1001,14 +1004,18 @@ mod tab_list_tests {
assert_eq!(players[0], me);
assert_eq!(players[1].uuid, 2);
assert_eq!(players[1].name, "Alex");
assert!(tab_list::visible(true, true, players.len(), false));
assert!(!tab_list::visible(false, true, players.len(), false));
assert!(tab_list::visible(true, true, true, players.len(), false));
assert!(!tab_list::visible(true, false, true, players.len(), false));
guests.clear();
let players = local_tab_players(me, &guests);
assert_eq!(players.len(), 1);
assert!(!tab_list::visible(true, true, players.len(), false));
assert!(tab_list::visible(true, true, players.len(), true));
assert!(!tab_list::visible(true, true, true, players.len(), false));
assert!(tab_list::visible(true, true, true, players.len(), true));
// F1 (`Hud.isHidden`) takes the list away: `extractRenderState` draws
// it inside `if (!isHidden)`, the key and the objective aside.
assert!(!tab_list::visible(false, true, true, players.len(), true));
assert!(!tab_list::visible(false, true, false, 2, false));
}
#[test]
@@ -1122,3 +1129,6 @@ mod team_tests {
);
}
}
#[cfg(test)]
mod empty_tag_tests;
@@ -0,0 +1,104 @@
use super::*;
use horizon_core::geometry::Vec3;
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn empty_tag_selectors_follow_actual_tag_edits() {
use crate::game::{Audio, Game, Options, Progress, Settings, load_resources};
use horizon_client::motion::PlayerMotion;
let root = std::path::PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES").unwrap());
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 = crate::game::execution_role::ExecutionRole::IntegratedServer;
let mut game = Game::new(
&loaded.resources,
loaded.pack,
loaded.gui.unwrap(),
options,
settings,
Audio::silent(&root),
None,
)
.unwrap();
let scratch = std::env::temp_dir().join(format!(
"horizon-empty-tags-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::create_dir_all(&scratch).unwrap();
game.world_root = scratch.clone();
game.player = Some(PlayerMotion::new(game.motion, Vec3::new(10.5, 64.0, 0.5)).unwrap());
game.build_registries();
game.set_allow_commands(true);
for (n, x, tags) in [(1, 4.5, ""), (2, 8.5, ",Tags:[\"audit\"]")] {
assert!(game.run_command(&format!(
"summon pig {x} 64 4.5 {{UUID:[I;267,0,0,{n}],NoAI:1b,NoGravity:1b{tags}}}"
)));
}
let a = game.target_with_uuid([267, 0, 0, 1]).unwrap();
let b = game.target_with_uuid([267, 0, 0, 2]).unwrap();
assert_ne!(a, b);
let select = |g: &mut Game<'_>, tag: &str| {
let text = format!("@e[type=minecraft:pig,tag={tag}]");
let selector = horizon_client::command::selector::parse(
&mut horizon_client::command::Reader::new(&text),
&g.ui.registries,
true,
)
.unwrap();
g.select(&selector).into_iter().collect::<HashSet<_>>()
};
for (phase, tagged_a) in [false, true, false].into_iter().enumerate() {
if phase != 0 {
for (n, tagged) in [(1, tagged_a), (2, !tagged_a)] {
assert!(game.run_command(&format!(
"tag 0000010b-0000-0000-0000-{n:012} {} audit",
if tagged { "add" } else { "remove" }
)));
}
}
assert_eq!(game.target_with_uuid([267, 0, 0, 1]), Some(a));
assert_eq!(game.target_with_uuid([267, 0, 0, 2]), Some(b));
assert_eq!(
game.tags_of(a),
if tagged_a {
vec!["audit".to_owned()]
} else {
vec![]
}
);
assert_eq!(
game.tags_of(b),
if tagged_a {
vec![]
} else {
vec!["audit".to_owned()]
}
);
let (empty, tagged) = if tagged_a { (b, a) } else { (a, b) };
// Named-tag admissions pass before the old empty-tag assertion fails.
assert_eq!(select(&mut game, "audit"), HashSet::from([tagged]));
assert_eq!(select(&mut game, "!audit"), HashSet::from([empty]));
assert_eq!(select(&mut game, ""), HashSet::from([empty]));
assert_eq!(select(&mut game, "!"), HashSet::from([tagged]));
}
drop(game);
std::fs::remove_dir_all(scratch).unwrap();
}
+30 -7
View File
@@ -796,7 +796,7 @@ mod block_id_tests {
/// their sprite layer, not their block. A layer takes the id of the block named like its sprite
/// (`block/oak_leaves` for `oak_leaves`, and still/flow sprites for their fluid), else the id
/// most blocks drawing it share (ties: the lowest); -1 when no block drawing it has one.
/// y is 1 for the fluid sprites, -1 otherwise.
/// y is 1 for the fluid sprites, 0 otherwise (`fluid_pairs`).
pub(super) fn block_id_table(pack: &super::PackResources, ids: &BlockIds) -> Vec<i32> {
let layers = pack
.render
@@ -804,13 +804,15 @@ pub(super) fn block_id_table(pack: &super::PackResources, ids: &BlockIds) -> Vec
.values()
.max()
.map_or(0, |l| l + 1) as usize;
let mut table = vec![-1; layers * 2];
let sprite = |name: &str| pack.render.sprite_layers.get(name).copied();
let fluids = ["water_still", "water_flow", "lava_still", "lava_flow"]
.map(|f| sprite(&format!("minecraft:block/{f}")));
let mut table = fluid_pairs(layers, fluids.into_iter().flatten());
if ids.is_empty() {
return table;
}
let registry = pack.assets.registry;
let catalog = &pack.render.model_catalog;
let sprite = |name: &str| pack.render.sprite_layers.get(name).copied();
// (layer, id) -> (a block named like the sprite, the blocks).
let mut votes = BlockIdVotes::default();
for i in 0..registry.len() {
@@ -849,10 +851,17 @@ pub(super) fn block_id_table(pack: &super::PackResources, ids: &BlockIds) -> Vec
}
}
votes.write(&mut table);
for f in ["water_still", "water_flow", "lava_still", "lava_flow"] {
if let Some(l) = sprite(&format!("minecraft:block/{f}"))
&& let Some(slot) = table.get_mut(l as usize * 2 + 1)
{
table
}
/// `layers` (id, fluid) pairs before the ids are known: id -1, and `mc_Entity.y` 1 for the
/// `fluids` layers, 0 for the others. Iris 1.11.7, with probe packs of ours (#10452), gave
/// terrain y 1 on water and lava, and 0 on every other block, whether `block.properties`
/// maps it or not.
fn fluid_pairs(layers: usize, fluids: impl IntoIterator<Item = u32>) -> Vec<i32> {
let mut table: Vec<i32> = std::iter::repeat_n([-1, 0], layers).flatten().collect();
for l in fluids {
if let Some(slot) = table.get_mut(l as usize * 2 + 1) {
*slot = 1;
}
}
@@ -950,6 +959,20 @@ fn terrain_table(ids: &[i32], emission: &[i32]) -> Vec<i32> {
.collect()
}
#[cfg(test)]
mod fluid_pair_tests {
use super::fluid_pairs;
/// Iris 1.11.7's `mc_Entity.y` in terrain (probe packs of ours, #10452): 1 on water and
/// lava, 0 on every other block; the ids start at -1 (no id) until `block.properties` gives
/// them.
#[test]
fn terrain_reads_y_1_on_fluids_and_0_elsewhere() {
assert_eq!(fluid_pairs(4, [1, 3]), [-1, 0, -1, 1, -1, 0, -1, 1]);
assert_eq!(fluid_pairs(2, []), [-1, 0, -1, 0]);
}
}
#[cfg(test)]
mod emission_tests {
use super::{layer_lights, terrain_table};
@@ -45,6 +45,10 @@ pub(super) struct StartupMenu {
key_synthetic: bool,
/// The screen the keyboard's focus is on (`key_screen`).
key_screen: u64,
/// The screen (`Self::key_screen`) the title's click focus belongs to:
/// another one in front drops it, as `Gui.setScreen` drops the focus of
/// the screen it leaves (`Screen.clearFocus`).
focus_screen: u64,
pub pending: Option<PendingWorldEntry>,
pub reload_packs: bool,
/// Browse mods' worker (`mod_browser`).
@@ -1195,6 +1199,16 @@ impl Loading {
horizon_client::ui_theme::draw_boot(&self.boot, batch, &logs);
return;
};
// `Gui.setScreen` clears the focus of the screen it leaves
// (`Screen.clearFocus`): the title behind the options, and the one
// that comes back, start with no button highlighted.
let screen = self.menu.key_screen();
if super::pad_menu::another_screen_is_front(&mut self.menu.focus_screen, screen) {
self.menu.options.clear_click_focus();
if let Some(title) = self.menu.title.as_mut() {
title.focused_button = None;
}
}
if self.menu.pending.is_some() {
horizon_client::level_loading::draw_loading_overlay(
batch,
@@ -2732,6 +2732,11 @@ impl Game<'_> {
y: f32,
after_name: Vec<(horizon_client::text::Text, u32)>,
) {
let mut cancel = false;
self.native_tooltip_render_pre(stack, batch, x, y, &mut cancel);
if cancel {
return;
}
let mut lines = self.stack_tooltip_lines(stack, false, creative);
if lines.is_empty() {
return;
@@ -51,6 +51,7 @@ impl Game<'_> {
}
// BaseContainerBlockEntity.canOpen: a spectator opens no container that still
// has a loot table to unpack (it clears while it rolls, on first open).
// ServerPlayer.openMenu sends the refused spectator a red action bar.
if self
.world
.chunks
@@ -58,6 +59,10 @@ impl Game<'_> {
.and_then(|c| c.saved.as_ref())
.is_some_and(|s| s.rolled_loot.iter().any(|r| r.pos == key))
{
self.send_overlay_message(
Text::translate("container.spectatorCantOpen", &[])
.with_color(horizon_client::text::RED),
);
return;
}
let Some(entity) = BlockEntity::for_block(&name) else {
+180 -1
View File
@@ -3,6 +3,7 @@
//! (flint and steel / fire charge).
use super::{Game, entity::Entity};
use horizon_client::entity_rules::EntityRules;
use horizon_client::lan::playerdata::uuid_ints;
use horizon_client::{
Result, collisions::SceneCollisions, items::ItemStack, mesher::Vertex, mobs::Who, text::Text,
};
@@ -282,7 +283,13 @@ impl Game<'_> {
t.position.z,
];
let bounds = t.bounds();
let (owner, guest, power) = (t.owner, t.guest, t.power);
let (id, owner, guest, power) = (t.id, t.owner, t.guest, t.power);
let (owner, guest) = match owner {
Some(_) => (owner, guest),
None => self
.loaded_tnt_owner(id)
.map_or((None, None), |(w, g)| (Some(w), g)),
};
let in_water = self.bounds_in_water(bounds);
self.tnts.remove(i);
// PrimedTnt.tick: with `tnt_explodes` off it goes away without exploding.
@@ -313,6 +320,57 @@ impl Game<'_> {
}
Ok(())
}
/// The saved compound of primed TNT `i`: chunk saves, `/data get`, portals.
pub(super) fn tnt_nbt(&mut self, i: usize) -> horizon_world::nbt::Tag {
let id = self.tnts[i].id;
let uuid = self.uuid_for(id);
let owner = self.tnt_owner_uuid(self.tnts[i].owner, self.tnts[i].guest);
let t = &self.tnts[i];
horizon_client::entity_nbt::tnt_to_nbt(
t.position,
t.velocity,
t.on_ground,
t.fuse,
t.power,
t.block_state_nbt(self.pack.assets.registry),
t.rotation,
uuid,
owner,
self.persist.raw.get(&id),
)
}
/// `EntityReference.of(owner)`: the UUID of the player or mob that lit it.
fn tnt_owner_uuid(&mut self, owner: Option<Who>, guest: Option<u32>) -> Option<[i32; 4]> {
match owner? {
Who::Player => match guest {
Some(conn) => self
.lan
.as_ref()?
.hub
.guest(conn)
.map(|g| uuid_ints(g.uuid)),
None => self.level.player_uuid,
},
Who::Mob(id) if self.mobs.iter().any(|m| m.id == id) => Some(self.uuid_for(id)),
Who::Mob(id) => self.persist.uuids.get(&id).copied(),
}
}
/// `PrimedTnt.getOwner` for TNT loaded with an `owner`: looked up when
/// asked, so a player who joined or a mob loaded since is found.
fn loaded_tnt_owner(&self, id: u32) -> Option<(Who, Option<u32>)> {
let uuid = horizon_client::entity_nbt::tnt_owner(self.persist.raw.get(&id)?)?;
let guest = self.lan.as_ref().and_then(|host| {
let mut guests = host.hub.guests.iter();
guests.find(|g| uuid_ints(g.uuid) == uuid).map(|g| g.conn)
});
if guest.is_some() {
return Some((Who::Player, guest));
}
if self.level.player_uuid == Some(uuid) {
return Some((Who::Player, None));
}
self.mob_by_uuid(uuid).map(|m| (Who::Mob(m), None))
}
/// `TntBlock.useItemOn`: flint and steel or a fire charge primes TNT.
pub(super) fn ignite_tnt(&mut self, stack: &ItemStack) -> Result<bool> {
let item = &*stack.item;
@@ -594,6 +652,7 @@ mod tests {
tnt.block_state_nbt(&registry),
[0.0, 0.0],
[221, 0, 0, 1],
None,
Some(&raw),
);
assert_eq!(saved.get("block_state"), Some(&expected));
@@ -1021,5 +1080,125 @@ mod tests {
);
}
}
/// A server's game on the prepared resources with the chunk at the origin
/// loaded, as `native_machines`' tests make one; None without them.
fn server_game() -> Option<Game<'static>> {
use crate::game::*;
let root = std::env::var_os("HORIZON_TEST_RESOURCES")
.map(std::path::PathBuf::from)
.unwrap_or_else(|| {
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client")
.join(horizon_core::VANILLA_VERSION)
});
if !root.join("movement-properties.nbt").exists() {
return None;
}
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 = crate::game::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.options.dedicated = Some(Vec::new());
game.world.chunks.insert(
(0, 0),
std::sync::Arc::new(horizon_client::chunk::Chunk::empty(0, 0)),
);
game.streaming.forced.insert((0, 0));
Some(game)
}
/// #10063: TNT lit between level ticks (flint and steel, a command) has its
/// first tick in the next level tick, so its fuse reads 79 after it, and it
/// explodes on the 80th (`PrimedTnt.tick`). Horizon held every TNT that
/// joined during a tick back one more: the late pass compared its join
/// against an `announced` already raised past it.
#[test]
fn lit_tnt_ticks_on_the_next_level_tick_and_explodes_on_the_80th() {
let Some(mut game) = server_game() else {
return;
};
game.add_primed_tnt(BlockPos::new(8, 64, 8), 0.0, 80, None, None);
// Hang it in the empty chunk, so it neither falls out nor lands.
game.tnts[0].no_gravity = true;
game.tnts[0].velocity = Vec3::ZERO;
game.tick_world(true).unwrap();
assert_eq!(game.tnts.len(), 1);
assert_eq!(
game.tnts[0].fuse, 79,
"its first tick is the next level tick"
);
let mut exploded_on = None;
for tick in 2..=90 {
game.tick_world(true).unwrap();
if game.tnts.is_empty() {
exploded_on = Some(tick);
break;
}
}
assert_eq!(exploded_on, Some(80), "explodes on the 80th tick");
}
/// TNT a blast primes joins while the entities tick (vanilla's
/// `EntityTickList` loop) and waits for the next tick. The fix for #10063
/// must not tick it in the tick it joined.
#[test]
fn tnt_primed_by_a_blast_waits_for_the_next_tick() {
let Some(mut game) = server_game() else {
return;
};
let block = game
.pack
.assets
.registry
.default_state("minecraft:tnt")
.unwrap();
game.world.set(9, 64, 8, block).unwrap();
game.add_primed_tnt(BlockPos::new(8, 64, 8), 0.0, 1, None, None);
game.tnts[0].no_gravity = true;
game.tnts[0].velocity = Vec3::ZERO;
let first = game.tnts[0].id;
for _ in 0..3 {
game.tick_world(true).unwrap();
if game.tnts.iter().all(|t| t.id != first) {
break;
}
}
assert!(
game.tnts.iter().all(|t| t.id != first),
"the first TNT went off"
);
assert_eq!(game.tnts.len(), 1, "its blast primed the TNT block");
let primed = &game.tnts[0];
assert_eq!(
primed.position, primed.previous,
"not ticked in the tick the blast primed it"
);
game.tick_world(true).unwrap();
let primed = &game.tnts[0];
assert_ne!(primed.position, primed.previous, "ticked on the next tick");
}
}
horizon_client::impl_joined!(PrimedTnt);
+178 -5
View File
@@ -56,6 +56,37 @@ pub static HURT_SERVER: OnceLock<fn(Hurting<'_>, &mut f32) -> bool> = OnceLock::
pub fn hurt_server_canceled(hurting: Hurting<'_>, amount: &mut f32) -> bool {
HURT_SERVER.get().is_some_and(|hook| hook(hurting, amount))
}
/// The game's `LivingDamageEvent.Pre` hook (`native_living_damage_pre`), set
/// once by the game: `LivingEntity.actuallyHurt` once armour, enchantments,
/// effects and absorption took their part, before the health. It may change
/// the amount.
pub static DAMAGE_PRE: OnceLock<fn(Hurting<'_>, &mut f32)> = OnceLock::new();
/// The game's `LivingDamageEvent.Post` hook (`native_living_damage_post`), set
/// once by the game: the end of `actuallyHurt`, with what came off the health.
pub static DAMAGE_POST: OnceLock<fn(Hurting<'_>, f32)> = OnceLock::new();
/// [`DAMAGE_PRE`] on what is about to come off the health (nothing while it is
/// unset). An amount it made negative or NaN takes nothing, an infinite one
/// `Float.MAX_VALUE`.
pub fn damage_pre(hurting: Hurting<'_>, amount: &mut f32) {
let Some(hook) = DAMAGE_PRE.get() else {
return;
};
let before = amount.to_bits();
hook(hurting, amount);
if amount.to_bits() != before {
*amount = if amount.is_nan() || *amount < 0.0 {
0.0
} else {
amount.min(f32::MAX)
};
}
}
/// [`DAMAGE_POST`] with what came off the health (nothing while it is unset).
pub fn damage_post(hurting: Hurting<'_>, amount: f32) {
if let Some(hook) = DAMAGE_POST.get() {
hook(hurting, amount);
}
}
/// A knockback's strength and direction, as `LivingEntity.knockback` has them
/// before knockback resistance is applied. The entity is pushed away from
/// the direction (`x`, `z`), which vanilla points at the source of the hit.
@@ -679,6 +710,14 @@ impl horizon_world::blocking_rules::TypeMatchWorld for MatchedSets {
}
}
impl Environment<'_> {
/// The hit's `DamageSource`, for the native damage hooks.
fn hurt_source(&self) -> HurtSource<'_> {
HurtSource {
damage_type: &self.source.definition().name,
causing: self.causing,
direct: 0,
}
}
fn event(&mut self, event: HealthEvent) {
self.events.push(MotionEvent::Health(event));
}
@@ -779,6 +818,14 @@ impl ServerDamageEnvironment<Attributes> for Environment<'_> {
fn damage_event(&mut self) -> io::Result<()> {
self.phase("entity-damage")
}
fn damage_pre(&mut self, damage: &mut f32) -> io::Result<()> {
damage_pre(Hurting::Player(self.entity, self.hurt_source()), damage);
Ok(())
}
fn damage_post(&mut self, damage: f32) -> io::Result<()> {
damage_post(Hurting::Player(self.entity, self.hurt_source()), damage);
Ok(())
}
}
impl CombatEnvironment<Attributes> for Environment<'_> {
fn dead_flag(&self) -> bool {
@@ -854,11 +901,7 @@ impl HurtWorld for Environment<'_> {
Ok(self.status.fire_resistance)
}
fn hurt_start(&mut self, _: &mut State, damage: &mut f32) -> io::Result<bool> {
let source = HurtSource {
damage_type: &self.source.definition().name,
causing: self.causing,
direct: 0,
};
let source = self.hurt_source();
let canceled = hurt_server_canceled(Hurting::Player(self.entity, source), damage);
self.canceled |= canceled;
Ok(canceled)
@@ -1297,6 +1340,136 @@ mod tests {
assert_eq!(seen(9013), [("minecraft:arrow 44".to_owned(), 5.0)]);
}
/// What the `LivingDamageEvent` hooks of these tests were handed: the
/// entity, Post (true) or Pre, and the amount.
static TAKEN: std::sync::Mutex<Vec<(i32, bool, f32)>> = std::sync::Mutex::new(Vec::new());
/// Sets the native `LivingDamageEvent.Pre` and `.Post` hooks of these
/// tests (global: they stay set for the process), for entities 9201 to
/// 9205 and 9211 to 9212 only: they record what each is handed, and Pre
/// halves x2's amount and makes 9203's -5, 9204's NaN and 9205's infinite.
fn set_damage_hooks() -> fn(i32) -> Vec<(bool, f32)> {
use super::Hurting;
fn entity(hurting: &Hurting<'_>) -> Option<i32> {
let entity = match hurting {
Hurting::Player(e, _) | Hurting::Dragon(e, _) | Hurting::ArmorStand(e, _) => *e,
Hurting::Mob(mob, _) => mob.id as i32,
};
matches!(entity, 9201..=9205 | 9211..=9212).then_some(entity)
}
fn pre(hurting: Hurting<'_>, amount: &mut f32) {
let Some(entity) = entity(&hurting) else {
return;
};
TAKEN.lock().unwrap().push((entity, false, *amount));
match entity {
9202 | 9212 => *amount /= 2.0,
9203 => *amount = -5.0,
9204 => *amount = f32::NAN,
9205 => *amount = f32::INFINITY,
_ => {}
}
}
fn post(hurting: Hurting<'_>, amount: f32) {
if let Some(entity) = entity(&hurting) {
TAKEN.lock().unwrap().push((entity, true, amount));
}
}
let installed = (
*super::DAMAGE_PRE.get_or_init(|| pre),
*super::DAMAGE_POST.get_or_init(|| post),
);
assert!(std::ptr::fn_addr_eq(
installed.0,
pre as fn(Hurting<'_>, &mut f32)
));
assert!(std::ptr::fn_addr_eq(
installed.1,
post as fn(Hurting<'_>, f32)
));
|entity| {
let taken = TAKEN.lock().unwrap();
taken
.iter()
.filter(|t| t.0 == entity)
.map(|t| (t.1, t.2))
.collect()
}
}
#[test]
fn the_native_damage_hooks_see_and_change_what_a_mobs_health_takes() {
use crate::mobs::{Damage, DamageKind, Mob};
let taken = set_damage_hooks();
let mob = |id| {
Mob::new(
id,
crate::mobs::sensor_scan_tests::kind(),
Default::default(),
0.0,
)
};
let hit = Damage {
kind: DamageKind::PlayerAttack,
attacker: None,
from: Some([3.0, 0.0, 0.0]),
attacker_species: None,
};
let mut plain = mob(9201);
assert!(plain.damage(2.0, hit));
// Within the cooldown only what exceeds the last hit reaches the health.
assert!(plain.damage(5.0, hit));
assert_eq!(plain.health, 5.0);
assert_eq!(
taken(9201),
[(false, 2.0), (true, 2.0), (false, 3.0), (true, 3.0)]
);
let mut halved = mob(9202);
assert!(halved.damage(2.0, hit));
assert_eq!(halved.health, 9.0);
assert_eq!(taken(9202), [(false, 2.0), (true, 1.0)]);
// A negative or NaN amount takes nothing and an infinite one
// Float.MAX_VALUE; the hit lands all the same.
let (mut negative, mut nan, mut infinite) = (mob(9203), mob(9204), mob(9205));
assert!(negative.damage(2.0, hit) && nan.damage(2.0, hit) && infinite.damage(2.0, hit));
assert_eq!(
(negative.health, nan.health, negative.hurt_time),
(10.0, 10.0, 10)
);
assert!(infinite.health <= 0.0);
assert_eq!(taken(9204), [(false, 2.0), (true, 0.0)]);
assert_eq!(taken(9205), [(false, 2.0), (true, f32::MAX)]);
}
/// Needs the prepared data in `.local/horizon-client/26.3`; skipped
/// without it.
#[test]
fn the_native_damage_hooks_see_and_change_what_a_players_health_takes() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client")
.join(horizon_core::VANILLA_VERSION);
if !root.join("movement-properties.nbt").exists() {
return;
}
let taken = set_damage_hooks();
let assets = crate::assets::Assets::load(&root).unwrap();
let resources = crate::motion::MotionResources::load(&root, &assets).unwrap();
let (health, rules) = (&resources.health, super::HealthRules::default());
let id = health.damage.id("minecraft:arrow").unwrap();
let player = |entity| {
let mut p = crate::motion::PlayerMotion::new(&resources, Default::default()).unwrap();
p.entity_id = entity;
p
};
let mut plain = player(9211);
assert!(plain.hurt(health, rules, id, 5.0).unwrap());
assert_eq!(plain.health.health(), 15.0);
assert_eq!(taken(9211), [(false, 5.0), (true, 5.0)]);
let mut halved = player(9212);
assert!(halved.hurt(health, rules, id, 5.0).unwrap());
assert_eq!(halved.health.health(), 17.5);
assert_eq!(taken(9212), [(false, 5.0), (true, 2.5)]);
}
/// What the knockback hook of these tests saw: the entity, the hit that
/// causes it (a mob's damage kind) and the push it was handed.
static PUSHES: std::sync::Mutex<Vec<(i32, String, super::Knock)>> =
+6
View File
@@ -164,6 +164,9 @@ pub enum FallEvent {
Broadcast(u8),
Write {
position: BlockPos,
/// The block it replaced, so a mod that cancels the change
/// (`native_entity_change_block_v2`) can have it put back.
from: BlockStateId,
state: BlockStateId,
flags: u32,
},
@@ -241,6 +244,7 @@ impl FallEvent {
position,
state,
flags,
..
} => format!("write {} {} {}", p(position), state.0, flags),
Self::Destroy {
position, drops, ..
@@ -587,9 +591,11 @@ impl BlockLandingWorld for MotionWorld<'_, '_, '_> {
Ok(true)
}
fn set_block(&mut self, position: BlockPos, state: BlockStateId, flags: u32) -> Result<bool> {
let from = self.scene.block(position.x, position.y, position.z);
self.scene.set(position.x, position.y, position.z, state)?;
self.fall_event(FallEvent::Write {
position,
from,
state,
flags,
});
+39 -4
View File
@@ -1768,6 +1768,14 @@ impl Menu {
self.containers[RESULT][0] = result;
}
/// A station's take sound, gated by its menu's `lastSoundTime`: once per
/// tick, so a shift-click that takes many results plays it once.
fn take_sound(&mut self, sound: &'static str) {
if !self.sounds.contains(&sound) {
self.sounds.push(sound);
}
}
/// `ResultSlot.onTake` and the other `Slot.onTake` overrides.
fn on_take(&mut self, env: &Env, s: usize, taken: &ItemStack) {
if self.slots[s].role == Role::Potion {
@@ -1796,7 +1804,7 @@ impl Menu {
if self.remove(env, 0, 1).is_some() {
self.stone_setup_result(env, self.data[0]);
}
self.sounds.push("minecraft:ui.stonecutter.take_result");
self.take_sound("minecraft:ui.stonecutter.take_result");
return;
}
if self.slots[s].role == Role::GrindResult {
@@ -1851,8 +1859,7 @@ impl Menu {
// onCraftedBy is the caller's (map_crafted).
self.remove(env, 0, 1);
self.remove(env, 1, 1);
self.sounds
.push("minecraft:ui.cartography_table.take_result");
self.take_sound("minecraft:ui.cartography_table.take_result");
self.slot_changed(env, s);
return;
}
@@ -1863,7 +1870,7 @@ impl Menu {
if self.containers[MAIN][0].is_none() || self.containers[MAIN][1].is_none() {
self.data[0] = -1;
}
self.sounds.push("minecraft:ui.loom.take_result");
self.take_sound("minecraft:ui.loom.take_result");
return;
}
if matches!(self.kind, Kind::CrafterBlock) && self.slots[s].container == MAIN {
@@ -3467,6 +3474,34 @@ pub(crate) mod tests {
});
}
/// `StonecutterMenu`'s `lastSoundTime`: a shift-click crafting a whole
/// stack plays the take sound once, not once per item.
#[test]
fn a_stonecutter_shift_click_plays_the_take_sound_once() {
with_menu(Kind::Stonecutter, |env, menu| {
menu.creative = false;
menu.set_slot(env, 0, stack("granite", 60));
let polished = menu
.stone
.recipes
.iter()
.position(|&r| {
&*env.station_recipes.stonecutting[r].result.item
== "minecraft:polished_granite"
})
.expect("granite cuts into polished granite");
assert!(menu.button(env, polished as i32));
menu.click(env, 1, 0, Input::QuickMove).unwrap();
assert_eq!(menu.slot(0), None);
let made: u32 = (2..menu.slot_count())
.filter_map(|i| menu.slot(i))
.map(|s| s.count)
.sum();
assert_eq!(made, 60);
assert_eq!(menu.sounds, ["minecraft:ui.stonecutter.take_result"]);
});
}
#[test]
fn quick_move_landing_routes_plain_containers() {
for (kind, size) in [
+118 -5
View File
@@ -3326,6 +3326,25 @@ impl Mob {
source: Damage,
armor_effectiveness: Option<&mut dyn FnMut(f32) -> f32>,
player_uuid: Option<[i32; 4]>,
) -> bool {
// Enderman.hurtServer: past `isInvulnerableTo`, the teleport draw
// follows `super.hurtServer` whatever it answered (a hurt cooldown, a
// dead enderman), its hurt sound's pitch included.
let enderman = self.species() == Species::Enderman
&& !self.removed
&& !(self.invulnerable_flag && !source.kind.bypasses_invulnerability());
let hurt = self.hurt_server(amount, source, armor_effectiveness, player_uuid);
if enderman {
enderman::after_hurt(self, &source);
}
hurt
}
fn hurt_server(
&mut self,
amount: f32,
source: Damage,
armor_effectiveness: Option<&mut dyn FnMut(f32) -> f32>,
player_uuid: Option<[i32; 4]>,
) -> bool {
axolotl::before_damage(self, amount, &source);
raider::note_hurt(self);
@@ -3457,6 +3476,8 @@ impl Mob {
// Wolf.actuallyHurt: wolf armor takes the damage instead of the wolf.
let absorbed = self.wolf_armor_absorbs(&source);
let mut knock = true;
// What came off the health, for `LivingDamageEvent.Post`.
let mut taken = 0.0;
let dealt = if self.invulnerable as f32 > 10.0 {
if amount <= self.last_hurt {
return false;
@@ -3465,8 +3486,8 @@ impl Mob {
if absorbed {
self.hurt_wolf_armor(dealt);
} else {
let dealt = self.soak_hit(dealt, &source);
self.health -= dealt;
let through = self.soak_hit(dealt, &source);
taken = self.hurt_health(through, &source);
}
self.last_hurt = amount;
knock = false;
@@ -3478,7 +3499,7 @@ impl Mob {
self.hurt_wolf_armor(amount);
} else {
let through = self.soak_hit(amount, &source);
self.health -= through;
taken = self.hurt_health(through, &source);
}
self.hurt_time = 10;
amount
@@ -3489,6 +3510,11 @@ impl Mob {
if !absorbed && dealt != 0.0 {
self.record_damage(source, dealt);
}
// The end of `actuallyHurt`: a native mod's `LivingDamageEvent.Post`.
// `Wolf.actuallyHurt` never gets there when the armour takes the hit.
if !absorbed {
crate::health::damage_post(crate::health::Hurting::Mob(self, &source), taken);
}
// LivingEntity.hurtArmor: `Horse` is the only mob that overrides it, and
// it hands `doHurtEquipment` the body slot alone. A wolf's armour has
// its own rule (`Wolf.actuallyHurt`, `hurt_wolf_armor`) and a player's
@@ -3587,12 +3613,24 @@ impl Mob {
if self.species() == Species::GlowSquid && self.ai.last_hurt_by.is_some() {
aquatic::hurt_server(self);
}
true
// Squid.hurtServer (also GlowSquid's result): parent health damage has
// happened, but without a last living attacker the subclass returns false.
!matches!(self.species(), Species::Squid | Species::GlowSquid)
|| self.ai.last_hurt_by.is_some()
}
/// `damage` by a source whose direct entity is `direct` (an entity type id), which `source`
/// cannot carry: `DamageSources.indirectMagic(potion or cloud, owner)`.
pub fn damage_by(&mut self, amount: f32, source: Damage, direct: &'static str) -> bool {
// Enderman.hurtServer: only a thrown potion skips the teleport draw;
// a lingering cloud's `indirect_magic` goes through it.
let cloud = self.species() == Species::Enderman
&& direct == "minecraft:area_effect_cloud"
&& !self.removed
&& !(self.invulnerable_flag && !source.kind.bypasses_invulnerability());
let hurt = self.damage(amount, source);
if cloud {
enderman::teleport_chance(self, &source);
}
if hurt {
self.last_direct = Some(direct);
}
@@ -3841,6 +3879,15 @@ impl Mob {
|| present_ambient != self.extra.ambient_effects.contains(&effect)
|| was_visible != visible)
}
/// `LivingEntity.actuallyHurt`'s last step: what got past absorption goes to
/// a native mod's `LivingDamageEvent.Pre`, which may change it, then comes
/// off the health. What came off.
fn hurt_health(&mut self, through: f32, source: &Damage) -> f32 {
let mut through = through;
crate::health::damage_pre(crate::health::Hurting::Mob(self, source), &mut through);
self.health -= through;
through
}
/// [`Mob::soak_absorption`] for a hit from `source`: `LivingEntity.actuallyHurt`
/// credits a `ServerPlayer` attacker with the absorbed damage in tenths.
fn soak_hit(&mut self, damage: f32, source: &Damage) -> f32 {
@@ -5542,6 +5589,70 @@ mod effect_refusal_tests {
);
}
fn check_squid_damage_result(id: &str) {
let root = std::path::PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES").unwrap());
let types = MobTypes::load_for_server(&root).unwrap();
let mut attacker = mob(&types, "minecraft:cow");
attacker.id = 2;
attacker.position = Vec3::new(4.0, 64.0, 4.0);
assert!(attacker.alive());
let by_cow = Damage {
kind: DamageKind::Generic,
..Damage::mob(attacker.id, [4.0, 64.0, 4.0]).with_species(Species::Cow)
};
// An unrelated parent's generic acceptance remains true.
let mut cow = mob(&types, "minecraft:cow");
assert!(cow.damage(1.0, Damage::of(DamageKind::Generic)));
assert_eq!(cow.health, 9.0);
// A parent's existing rejection never reaches the subclass aftermath.
let mut protected = mob(&types, id);
protected.invulnerable_flag = true;
assert!(!protected.damage(1.0, by_cow));
assert_eq!(protected.health, 10.0);
if protected.species() == Species::GlowSquid {
assert_eq!(aquatic::dark_ticks(&protected), 0);
}
for no_ai in [false, true] {
for attributed in [true, false] {
let mut victim = mob(&types, id);
victim.no_ai = no_ai;
assert_eq!(victim.health, 10.0);
assert!(victim.ai.last_hurt_by.is_none());
let source = if attributed {
by_cow
} else {
Damage::of(DamageKind::Generic)
};
let accepted = victim.damage(1.0, source);
// Check the parent's state first: false must not roll back damage.
assert_eq!(victim.health, 9.0);
assert_eq!(
victim.ai.last_hurt_by,
attributed.then_some(Who::Mob(attacker.id))
);
if victim.species() == Species::GlowSquid {
assert_eq!(
aquatic::dark_ticks(&victim),
if attributed { 100 } else { 0 }
);
}
assert_eq!(accepted, attributed, "{id} NoAI={no_ai}");
}
}
}
#[test]
#[ignore = "needs prepared entity types in HORIZON_TEST_RESOURCES"]
fn glow_squid_damage_result_keeps_parent_health_side_effect() {
check_squid_damage_result("minecraft:glow_squid");
}
#[test]
#[ignore = "needs prepared entity types in HORIZON_TEST_RESOURCES"]
fn squid_damage_result_keeps_parent_health_side_effect() {
check_squid_damage_result("minecraft:squid");
}
/// Squid.hurtServer reports a hit only when a living entity dealt it, so
/// environmental damage leaves a glow squid glowing; a mob's blow dims it.
#[test]
@@ -5555,11 +5666,13 @@ mod effect_refusal_tests {
let types = MobTypes::load_for_server(&root).unwrap();
let mut burned = mob(&types, "minecraft:glow_squid");
burned.health = 10.0;
assert!(burned.damage(1.0, Damage::of(DamageKind::Campfire)));
assert!(!burned.damage(1.0, Damage::of(DamageKind::Campfire)));
assert_eq!(burned.health, 9.0);
assert_eq!(aquatic::dark_ticks(&burned), 0);
let mut struck = mob(&types, "minecraft:glow_squid");
struck.health = 10.0;
assert!(struck.damage(1.0, Damage::mob(2, [0.0, 64.0, 0.0])));
assert_eq!(struck.health, 9.0);
assert_eq!(aquatic::dark_ticks(&struck), 100);
}
}
+119 -6
View File
@@ -711,6 +711,10 @@ pub fn tick(
if horse {
super::equine::ai_step(m, w, events);
}
// LivingEntity.aiStep ends in the water's hurt with or without AI;
// a NoAI enderman's hurts still teleport it (Enderman.hurtServer).
hurt_by_water(m, w, events);
enderman_hurt_teleports(m, w, events);
// Ravager.aiStep runs outside serverAiStep: NoAI still advances
// attack/stun/roar, including the stun-to-roar transition.
if m.species() == Species::Ravager {
@@ -720,6 +724,9 @@ pub fn tick(
// Pufferfish.aiStep's mob contacts run even when serverAiStep is skipped.
aquatic::sting_touching_mobs(m, w, events);
}
if m.species() == Species::GlowSquid {
aquatic::glow_squid_dark_tick(m);
}
} else {
ai_step(m, w, level_random, events);
}
@@ -766,6 +773,10 @@ pub fn tick(
super::panda::tick(m, w, events);
}
aquatic::school_tick(m, w, level_random);
// Dolphin.tick: after super.tick, NoAI restores the species maximum.
if m.no_ai && m.species() == Species::Dolphin {
m.air = m.max_air_supply();
}
true
}
/// `LivingEntity.aiStep` -> `Entity.applyEffectsFromBlocks` for the blocks
@@ -840,6 +851,17 @@ fn effects_from_blocks(
},
));
visited.push(p);
// PowderSnowBlock.entityInside: a burning mob destroys the
// block (with `mob_griefing`), then EXTINGUISH puts it out.
if w.block_name(x, y, z) == "minecraft:powder_snow" {
if m.is_on_fire() && w.mob_griefing {
events.push(MobEvent::BreakBlock {
pos: p,
drop: false,
});
}
m.extinguish_fire();
}
match w.block_name(x, y, z) {
"minecraft:cactus" => {
m.damage(1.0, Damage::of(DamageKind::Cactus));
@@ -1230,10 +1252,6 @@ fn base_tick(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
}
}
}
if m.kind.flags.water_sensitive && (m.fluids.in_water || in_rain(m, w)) {
m.damage(1.0, Damage::of(DamageKind::Drown));
}
freeze_tick(m, w);
}
if m.species() == Species::Illusioner {
@@ -1255,6 +1273,8 @@ fn base_tick(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
// lava ignites to the full 160 / 300 ticks and its hit lands after the
// invulnerable time of that tick has already dropped.
block_contact_damage(m, w, fire);
// Enderman.hurtServer teleports inside the hurt (fire, lava, a block).
enderman_hurt_teleports(m, w, events);
if !m.alive() {
m.death_time += 1;
}
@@ -1619,10 +1639,13 @@ fn ai_step(m: &mut Mob, w: &MobWorld, level_random: &mut LegacyRandom, events: &
} else if !(m.species() == Species::Mannequin && m.extra.immovable) {
// Mannequin.isEffectiveAi: an immovable one never travels.
m.travel(w, input, events);
// A fall's hurt (checkFallDamage) teleports at once.
enderman_hurt_teleports(m, w, events);
}
// LivingEntity.aiStep: applyEffectsFromBlocks follows travel.
m.apply_effects_from_blocks(w, events);
push_entities(m, w, events);
hurt_by_water(m, w, events);
// Spider.tick: climbing while pressed against a wall.
if m.kind.flags.climber {
m.climbing = m.horizontal_collision;
@@ -1632,6 +1655,24 @@ fn ai_step(m: &mut Mob, w: &MobWorld, level_random: &mut LegacyRandom, events: &
burn_undead(m, w, events);
species_ai_step(m, w, level_random, events);
}
/// The end of `LivingEntity.aiStep`: a mob that water hurts (an enderman, a
/// blaze, a snow golem) takes 1 drowning damage in water or rain
/// (`isSensitiveToWater`, `isInWaterOrRain`). An enderman's teleport is part of
/// that hurt (`Enderman.hurtServer`), so it happens now, not next tick.
fn hurt_by_water(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
if m.kind.flags.water_sensitive && (m.fluids.in_water || in_rain(m, w)) {
m.damage(1.0, Damage::of(DamageKind::Drown));
enderman_hurt_teleports(m, w, events);
}
}
/// The teleport `Enderman.hurtServer` makes inside a hurt, after the hurt's
/// sound; with or without AI.
fn enderman_hurt_teleports(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
if m.species() == Species::Enderman && m.extra.counter2 > 0 {
events.append(&mut m.outbox);
super::enderman::run_pending_teleports(m, w, events);
}
}
impl Mob {
/// LivingEntity.isPushable (and the push it takes from `Entity.push(Entity)`):
/// bats and shulkers are never moved by others, nor is a mob on a climbable
@@ -1996,7 +2037,9 @@ fn species_ai_step(
super::cube::ai_step(m, w, events);
super::sulfur_cube::ai_step(m, w, events);
}
Species::Nautilus | Species::ZombieNautilus => super::nautilus::ai_step(m, w, events),
Species::Nautilus | Species::ZombieNautilus => {
super::nautilus::ai_step(m, w, level_random, events)
}
Species::Chicken => farm::chicken_ai_step(m, w, events),
Species::Parrot => super::parrot::calculate_flapping(m),
Species::Armadillo => super::armadillo::tick(m),
@@ -2551,7 +2594,6 @@ pub fn accepts_damage(m: &mut Mob, source: &Damage, _amount: f32) -> bool {
pub fn after_hurt(m: &mut Mob, source: &Damage) {
m.ai.no_action_time = 0;
match m.species() {
Species::Enderman => super::enderman::after_hurt(m, source),
Species::Armadillo => super::armadillo::after_hurt(m, source),
Species::Shulker => super::shulker::after_hurt(m),
Species::Warden => super::warden::hurt(m, source),
@@ -5055,3 +5097,74 @@ mod flight_effect_fall_tests {
assert!(ran, "prepared resources must run the integration fixture");
}
}
#[cfg(test)]
mod dolphin_noai_air_tests {
use super::*;
use std::sync::Arc;
fn check(water: bool, no_ai: bool, initial: i32, expected: [i32; 2]) {
let build = |world: &mut crate::world::World,
registry: &horizon_world::registry::BlockRegistry| {
world
.chunks
.insert((0, 0), Arc::new(crate::chunk::Chunk::empty(0, 0)));
if water {
let water = registry.default_state("minecraft:water").unwrap();
for x in 0..=4 {
for z in 0..=4 {
for y in 0..=8 {
world.set(x, y, z, water).unwrap();
}
}
}
}
};
let ran = crate::mobs::world::with_built_test_world(&[], build, |w, types| {
let kind = Arc::new(types.types["minecraft:dolphin"].clone());
let mut mob = Mob::new(1, kind, Vec3::new(2.5, 3.0, 2.5), 0.0);
mob.no_ai = no_ai;
mob.no_gravity = true;
mob.persistent = true;
mob.attrs.speed = 0.0;
mob.air = initial;
let mut random = LegacyRandom::new(0);
let mut events = Vec::new();
assert_eq!(mob.health, 10.0);
assert_eq!(mob.max_air_supply(), 4800);
for (index, expected) in expected.into_iter().enumerate() {
assert!(tick(&mut mob, w, &mut random, &mut events));
assert_eq!(mob.age, (index + 1) as u32);
assert_eq!(mob.fluids.eye_in_water, water);
assert_eq!(mob.health, 10.0);
assert_eq!(mob.no_ai, no_ai);
assert_eq!(
mob.air,
expected,
"water={water}, NoAI={no_ai}, initial={initial}, tick={}",
index + 1
);
}
});
if !ran {
eprintln!("Prepared mob data unavailable; Dolphin full-tick coverage did not run");
}
}
/// `Dolphin.tick` restores max air after super.tick for a NoAI dolphin,
/// even when that superclass tick refilled or consumed loaded air.
#[test]
fn noai_dolphins_restore_air_after_the_full_mob_tick() {
for water in [false, true] {
for initial in [4800, 100] {
check(water, true, initial, [4800, 4800]);
}
}
}
/// The NoAI exception must not suppress normal underwater consumption.
#[test]
fn normal_dolphins_keep_their_underwater_countdown() {
check(true, false, 4800, [4799, 4798]);
}
}
+13 -4
View File
@@ -463,16 +463,22 @@ pub fn hurt_server(m: &mut Mob) {
set_dark_ticks(m, 100);
}
/// `GlowSquid.aiStep`: its positive dark timer runs after the parent step,
/// including when NoAI skips server AI. This upkeep consumes no random draws.
pub(super) fn glow_squid_dark_tick(m: &mut Mob) {
let ticks = dark_ticks(m);
if ticks > 0 {
set_dark_ticks(m, ticks - 1);
}
}
/// `GlowSquid.aiStep` after `super.aiStep()`: the dark timer counts down, and
/// a `glow` particle appears at `getRandomX(0.6)`, `getRandomY()`,
/// `getRandomZ(0.6)` (drawn in that order). A window that simulates the world
/// and a client of a server both draw it, so the server's own `ai_step` pushes
/// the event as well (as the nautilus bubbles do).
fn glow_squid_ai_step(m: &mut Mob, events: &mut Vec<MobEvent>) {
let ticks = dark_ticks(m);
if ticks > 0 {
set_dark_ticks(m, ticks - 1);
}
glow_squid_dark_tick(m);
let (w, h) = (f64::from(m.width()), f64::from(m.height()));
let r = &mut m.random;
let x = m.position.x + w * ((2.0 * r.next_double() - 1.0) * 0.6);
@@ -1605,3 +1611,6 @@ mod pufferfish_sting_tests {
});
}
}
#[cfg(test)]
mod countdown_tests;
@@ -0,0 +1,69 @@
use super::*;
use horizon_core::random::LegacyRandom;
use std::sync::Arc;
#[test]
#[ignore = "requires prepared vanilla resources in .local/horizon-client"]
fn mob_tick_keeps_glow_squid_countdowns_with_no_ai() {
let ran = crate::mobs::world::with_built_test_world(
&[],
|world, registry| {
world
.chunks
.insert((0, 0), Arc::new(crate::chunk::Chunk::empty(0, 0)));
let water = registry.default_state("minecraft:water").unwrap();
for x in 0..16 {
for y in 60..69 {
for z in 0..16 {
world.set(x, y, z, water).unwrap();
}
}
}
},
|world, types| {
// Normal and negative controls run before the old NoAI positive failure.
for no_ai in [false, true] {
for initial in [-2, 40] {
let kind = Arc::new(types.types["minecraft:glow_squid"].clone());
let mut squid = Mob::new(1, kind, Vec3::new(8.5, 64.0, 8.5), 0.0);
squid.no_ai = no_ai;
squid.no_gravity = true;
squid.persistent = true;
set_dark_ticks(&mut squid, initial);
let mut random = LegacyRandom::new(0);
let mut events = Vec::new();
for elapsed in 1..=10 {
assert!(squid.tick(world, &mut random, &mut events));
assert_eq!(squid.health, 10.0);
let expected = if initial > 0 {
initial - elapsed
} else {
initial
};
assert_eq!(
dark_ticks(&squid),
expected,
"NoAI={no_ai}, initial={initial}, elapsed={elapsed}"
);
}
let glows = events
.iter()
.filter(|event| {
matches!(event,
MobEvent::ParticleAt { kind, .. } if *kind == ParticleType::Glow.name())
})
.count();
assert_eq!(
glows,
if no_ai { 0 } else { 10 },
"the timer-only NoAI path must preserve existing particle dispatch"
);
}
}
},
);
assert!(
ran,
"prepared vanilla resources are required; do not silently skip this regression"
);
}
@@ -272,6 +272,8 @@ pub fn environmental(kind: DamageKind) -> bool {
| DamageKind::Lava
| DamageKind::LightningBolt
| DamageKind::HotFloor
| DamageKind::Freeze
| DamageKind::SulfurCubeHot
)
}
/// The hurt sound: a ball's is muffled, and it has no ambient sound
@@ -806,3 +808,44 @@ mod tests {
assert!(!environmental(DamageKind::Campfire));
}
}
#[cfg(test)]
mod panic_environmental_tests {
use super::*;
/// `#panic_environmental_causes` from the vanilla data: every type in it
/// panics the armadillo (`Armadillo.actuallyHurt` rolls out).
#[test]
fn the_environmental_causes_are_vanillas_tag() {
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client")
.join(horizon_core::VANILLA_VERSION)
.join("data/minecraft/tags/damage_type/panic_environmental_causes.json");
let Ok(text) = std::fs::read_to_string(path) else {
return;
};
let json: serde_json::Value = serde_json::from_str(&text).unwrap();
let mut ids: Vec<&str> = json["values"]
.as_array()
.unwrap()
.iter()
.map(|v| v.as_str().unwrap())
.collect();
ids.sort_unstable();
let kinds = [
("minecraft:cactus", DamageKind::Cactus),
("minecraft:freeze", DamageKind::Freeze),
("minecraft:hot_floor", DamageKind::HotFloor),
("minecraft:in_fire", DamageKind::InFire),
("minecraft:lava", DamageKind::Lava),
("minecraft:lightning_bolt", DamageKind::LightningBolt),
("minecraft:on_fire", DamageKind::OnFire),
("minecraft:sulfur_cube_hot", DamageKind::SulfurCubeHot),
];
assert_eq!(ids, kinds.map(|(id, _)| id).to_vec());
for (id, kind) in kinds {
assert!(environmental(kind), "{id}");
}
assert!(!environmental(DamageKind::Drown));
}
}
+5 -7
View File
@@ -246,12 +246,8 @@ pub fn ai_step(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
// Player.touch: entities within the player's box grown by (1, 0.5, 1).
&& m.bounds().intersects(p.bounds.inflate(Vec3::new(1.0, 0.5, 1.0)))
{
let reach = 2.04f32.sqrt() as f64 - 0.6;
if m.bounds()
.inflate(Vec3::new(reach, 0.0, reach))
.intersects(p.bounds)
&& m.sees(w, Who::Player)
{
// isWithinMeleeAttackRange, with what the cube holds.
if m.melee_reaches(p.bounds) && m.sees(w, Who::Player) {
events.push(MobEvent::CubeHurtPlayer {
cube: m.id,
amount: attack_damage(m),
@@ -274,7 +270,8 @@ fn attack_damage(m: &Mob) -> f32 {
/// `AbstractCubeMob.push(entity)`: an iron golem whose `pushEntities` takes
/// this cube (its box overlaps the cube's, `EntitySelector.pushableBy`) gets
/// `dealDamage`: alive, `doTeamsAllowDamage`, `isWithinMeleeAttackRange`
/// (always true for overlapping boxes) and `hasLineOfSight`, then a
/// (true for overlapping boxes unless a held spear's minimum range refuses
/// them) and `hasLineOfSight`, then a
/// `mobAttack` hit for `getAttackDamage`.
fn hurt_touching_golems(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
// canDealDamage also needs Mob.isEffectiveAi: a NoAI cube never hurts.
@@ -292,6 +289,7 @@ fn hurt_touching_golems(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
&& o.species == Species::IronGolem
&& o.alive
&& o.bounds.intersects(bounds)
&& m.melee_reaches(o.bounds)
&& crate::scoreboard::pushable_by(theirs, own, pushable)
&& crate::scoreboard::teams_allow_damage(own, theirs)
})
+71 -33
View File
@@ -71,8 +71,12 @@ pub fn stared_at(m: &Mob, w: &MobWorld) -> bool {
Vec3::new(m.position.x, m.eye_y(), m.position.z),
)
}
/// Enderman.teleport(x, y, z): never while riding something; drop to standing
/// ground, require free space without liquid.
/// `Enderman.teleport(x, y, z)`: never while riding something, then
/// `LivingEntity.randomTeleport(x, y, z, true, #enderman_does_not_teleport_to)`:
/// the first `#entities_can_teleport_to` block below the spot is the ground,
/// and the enderman lands at the spot's own height less the blocks it went
/// down (`checkPositionAndTeleport`), unless the ground or a block in its new
/// box is one it does not teleport to, the box collides or holds a liquid.
pub fn teleport_to(
m: &mut Mob,
w: &MobWorld,
@@ -84,8 +88,8 @@ pub fn teleport_to(
if m.extra.passenger {
return false;
}
// LivingEntity.randomTeleport: WorldBorder.clampVec3ToBound keeps the
// target inside the border (Mth.clamp, 1.0E-5F inside the far edges).
// WorldBorder.clampVec3ToBound keeps the target inside the border
// (Mth.clamp, 1.0E-5F inside the far edges).
let (x, z) = match w.border {
Some(border) => {
let [min_x, min_z, max_x, max_z] = border.bounds(0.0);
@@ -101,40 +105,71 @@ pub fn teleport_to(
if !w.world.loaded(bx, bz) {
return false;
}
let refused = |name: &str| w.has_tag("minecraft:enderman_does_not_teleport_to", name);
let mut by = y.floor() as i32;
while by > w.world.bottom_y() && !w.facts.get(w.world.block(bx, by, bz)).full_collision {
let mut ny = y;
let ground = loop {
if by <= w.world.bottom_y() {
return false;
}
by -= 1;
}
let ground = w.facts.get(w.world.block(bx, by, bz));
if !ground.full_collision || ground.fluid == Fluid::Water {
let name = w.block_name(bx, by, bz);
if w.has_tag("minecraft:entities_can_teleport_to", name) {
break name;
}
ny -= 1.0;
};
if refused(ground) {
return false;
}
let ny = f64::from(by + 1);
let half = f64::from(m.width()) / 2.0;
let b = Aabb::new(
Vec3::new(x - half, ny, z - half),
Vec3::new(x + half, ny + f64::from(m.height()), z + half),
);
if !w.no_collision(b) || w.fluids(b, ny + m.eye_height()).in_water || w.fluids(b, ny).in_lava {
if !w.no_collision(b) {
return false;
}
let old = m.center();
// LevelReader.containsAnyLiquid and findBlocksIn: the same closed range of
// cells, from BlockPos.containing(min) to BlockPos.containing(max).
let (lo, hi) = (b.min_position(), b.max_position());
for cx in lo.x.floor() as i32..=hi.x.floor() as i32 {
for cy in lo.y.floor() as i32..=hi.y.floor() as i32 {
for cz in lo.z.floor() as i32..=hi.z.floor() as i32 {
if w.fluid(cx, cy, cz) != Fluid::None || refused(w.block_name(cx, cy, cz)) {
return false;
}
}
}
}
// Enderman.canRandomlyTeleportTo: not onto ground that holds water
// (a waterlogged slab, leaves or roots).
if w.fluid(bx, by, bz) == Fluid::Water {
return false;
}
let old = m.position;
let old_block = [
m.position.x.floor() as i32,
m.position.y.floor() as i32,
m.position.z.floor() as i32,
];
// Entity.teleportTo -> snapTo: the old position moves too, the motion stays.
m.position = Vec3::new(x, ny, z);
m.previous = m.position;
m.velocity = Vec3::ZERO;
m.ai.nav.stop();
// `LivingEntity.randomTeleport(..., true)` broadcasts entity event 46
// before the trail and the teleport sounds.
// `randomTeleport(..., true)` broadcasts entity event 46.
events.push(MobEvent::EntityEvent {
mob: m.id,
event: 46,
at: [m.position.x, m.position.y, m.position.z],
});
// Enderman.teleport: GameEvent.TELEPORT where it was, silent or not.
events.push(MobEvent::GameEvent {
event: "minecraft:teleport",
at: [old.x, old.y, old.z],
source: m.id,
state: horizon_world::registry::BlockStateId(0),
});
if !m.silent {
// Level event 2018: the portal trail, from the old block to the new
// one, packed as `BlockUtil.clampedPackDifferenceInPosition` does.
@@ -145,14 +180,12 @@ pub fn teleport_to(
pos: old_block,
data: packed(0) << 16 | packed(1) << 8 | packed(2),
});
// Both sounds are at the new feet: `xo` was moved by snapTo, and
// Entity.playSound plays at the position.
let at = [m.position.x, m.position.y, m.position.z];
events.push(MobEvent::Sound("entity.enderman.teleport", at, 1.0, 1.0));
events.push(MobEvent::Sound("entity.enderman.teleport", at, 1.0, 1.0));
}
events.push(MobEvent::Sound("entity.enderman.teleport", old, 1.0, 1.0));
events.push(MobEvent::Sound(
"entity.enderman.teleport",
m.center(),
1.0,
1.0,
));
true
}
pub fn teleport_random(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) -> bool {
@@ -226,20 +259,29 @@ pub fn on_damage(m: &mut Mob, source: &Damage) -> bool {
true
}
pub fn after_hurt(m: &mut Mob, source: &Damage) {
// A thrown potion's hit (`indirect_magic`, which only a splash potion
// deals to a mob) leaves `Enderman.hurtServer` before the random chance:
// A thrown potion's hit (`indirect_magic` with the potion as the direct
// entity, `Mob::damage_by`) and a projectile it dodges leave
// `Enderman.hurtServer` before the random chance:
// `repeatedlyTryToTeleport` replaces it.
if source.kind != super::DamageKind::IndirectMagic
&& source.attacker.is_none()
&& m.random.next_int_bound(10).unwrap_or(0) != 0
{
if !dodges(m, source) && source.kind != super::DamageKind::IndirectMagic {
teleport_chance(m, source);
}
}
/// `Enderman.hurtServer`'s `!(source.getEntity() instanceof LivingEntity) &&
/// random.nextInt(10) != 0`: a teleport after the hurt.
pub fn teleport_chance(m: &mut Mob, source: &Damage) {
if source.attacker.is_none() && m.random.next_int_bound(10).unwrap_or(0) != 0 {
m.extra.counter2 = 1;
}
}
/// Pending teleports (repeatedlyTryToTeleport: up to 64 tries).
/// Enderman.aiStep: never jumps; the teleports a hurt left run.
pub fn ai_step(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
// Enderman.aiStep: never jumps.
m.ai.jumping = false;
run_pending_teleports(m, w, events);
}
/// The teleports `hurtServer` asked for (`teleport()`, or
/// `repeatedlyTryToTeleport`: up to 64 tries).
pub fn run_pending_teleports(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
if m.extra.counter2 > 0 {
let tries = m.extra.counter2;
m.extra.counter2 = 0;
@@ -256,10 +298,6 @@ pub fn ai_step(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
}
}
}
if (m.fluids.in_water || super::ai::in_rain(m, w)) && m.alive() {
// Hurt by water: teleport away.
m.extra.counter2 = m.extra.counter2.max(1);
}
}
/// EndermanModel.setupAnim: half-speed clamped limbs, held-block arms, creepy head.
/// Humanoid riding pose runs first (`isPassenger`); only xRot is halved/clamped after.
+116 -4
View File
@@ -655,11 +655,63 @@ impl Mob {
self.ai.seen_cache.push((who, v));
v
}
/// Mob.isWithinMeleeAttackRange: the box inflated by sqrt(2.04) - 0.6.
/// Mob.isWithinMeleeAttackRange: the box inflated by sqrt(2.04) - 0.6, or
/// for an active item with `attack_range` (the spears) by its
/// `effectiveMaxRange` for a mob (`max_reach * mob_factor`), and not
/// within its `effectiveMinRange` (`min_reach * mob_factor`).
pub fn within_melee_range(&self, t: &TargetInfo) -> bool {
let reach = 2.04f32.sqrt() as f64 - 0.6;
let b = self.bounds().inflate(Vec3::new(reach, 0.0, reach));
b.intersects(t.bounds)
self.melee_reaches(t.bounds)
}
/// [`Self::within_melee_range`] for a target's box (`getHitbox`).
pub fn melee_reaches(&self, target: Aabb) -> bool {
let (max, min) = match self.active_attack_range() {
Some((max, min)) => (f64::from(max), f64::from(min)),
None => (2.04f32.sqrt() as f64 - 0.6, 0.0),
};
let reaches = |range: f64| {
self.bounds()
.inflate(Vec3::new(range, 0.0, range))
.intersects(target)
};
reaches(max) && (min <= 0.0 || !reaches(min))
}
/// The active item's `attack_range` for a mob: `effectiveMaxRange` and
/// `effectiveMinRange` (`max_reach` and `min_reach` times `mob_factor`).
/// A patch on the held stack wins (`!minecraft:attack_range` removes it;
/// the codec's defaults are 0, 3 and a factor of 1); else the spears'.
fn active_attack_range(&self) -> Option<(f32, f32)> {
let item = self.active_item()?;
let patch = (self.extra.main_hand.as_deref() == Some(item))
.then(|| self.extra.main_hand_stack().and_then(|s| s.data()))
.flatten();
if patch.is_some_and(|d| d.other("!minecraft:attack_range").is_some()) {
return None;
}
if let Some(range) = patch.and_then(|d| d.other("minecraft:attack_range")) {
let float = |key: &str, default: f32| match range.get(key) {
Some(horizon_world::nbt::Tag::Float(bits)) => f32::from_bits(*bits),
Some(horizon_world::nbt::Tag::Double(bits)) => f64::from_bits(*bits) as f32,
Some(horizon_world::nbt::Tag::Int(v)) => *v as f32,
_ => default,
};
let factor = float("mob_factor", 1.0);
return Some((
float("max_reach", 3.0) * factor,
float("min_reach", 0.0) * factor,
));
}
crate::spear::Kinetic::of(item).map(|_| {
let r = &crate::spear::REACH;
(r.max * r.mob_factor, r.min * r.mob_factor)
})
}
/// `LivingEntity.getActiveItem`: the item being used, else the main hand's.
pub fn active_item(&self) -> Option<&str> {
if super::spear_use::is_using_item(self) && self.extra.using_off_hand {
self.extra.off_hand.as_deref()
} else {
self.extra.main_hand.as_deref()
}
}
/// Whether the species is a `TamableAnimal` (not the horses and llamas,
/// which are tamed another way).
@@ -3319,3 +3371,63 @@ mod breed_xp_tests {
);
}
}
#[cfg(test)]
mod melee_range_tests {
use super::*;
fn mob_holding(item: &str, components: Option<&str>) -> Mob {
let kind = (*crate::mobs::sensor_scan_tests::kind()).clone();
let mut m = Mob::new(1, Arc::new(kind), Vec3::new(0.5, 64.0, 0.5), 0.0);
let mut stack = crate::items::ItemStack::new(item, 1);
if let Some(text) = components {
let tag = horizon_world::nbt::parse_snbt(text).unwrap().0;
(stack.damage, stack.extra) = crate::item_data::decode_components(Some(&tag));
}
m.extra.set_main_hand_stack(Some(stack));
m
}
/// A one-block target whose near face is `gap` blocks east of the mob's box.
fn target(m: &Mob, gap: f64) -> Aabb {
let x = m.bounds().max_position().x + gap;
Aabb::new(Vec3::new(x, 64.0, 0.0), Vec3::new(x + 1.0, 65.0, 1.0))
}
/// `Mob.isWithinMeleeAttackRange` with `getActiveItem().get(ATTACK_RANGE)`:
/// no component reaches sqrt(2.04) - 0.6 (about 0.83); a spear 4.5 and 2.0
/// times its mob factor 0.5; a patched component with the codec's
/// defaults (`mob_factor` 1, `min_reach` 0); a removed one the default.
#[test]
fn the_active_items_attack_range_sets_the_reach() {
let sword = mob_holding("minecraft:iron_sword", None);
assert!(sword.melee_reaches(target(&sword, 0.8)));
assert!(!sword.melee_reaches(target(&sword, 0.9)));
let spear = mob_holding("minecraft:iron_spear", None);
assert!(spear.melee_reaches(target(&spear, 2.2)));
assert!(!spear.melee_reaches(target(&spear, 2.3)));
assert!(
!spear.melee_reaches(target(&spear, 0.5)),
"inside the minimum range"
);
assert!(spear.melee_reaches(target(&spear, 1.1)));
let long = mob_holding(
"minecraft:iron_sword",
Some(r#"{"minecraft:attack_range":{max_reach:6.0f}}"#),
);
assert!(long.melee_reaches(target(&long, 5.9)));
assert!(
long.melee_reaches(target(&long, 0.0)),
"no minimum by default"
);
assert!(!long.melee_reaches(target(&long, 6.1)));
let blunt = mob_holding(
"minecraft:iron_spear",
Some(r#"{"!minecraft:attack_range":{}}"#),
);
assert!(blunt.melee_reaches(target(&blunt, 0.5)));
assert!(!blunt.melee_reaches(target(&blunt, 2.0)));
}
}
+131 -9
View File
@@ -186,6 +186,17 @@ pub fn elastic_pull(
holder: &HolderState,
motion: Vec3,
quad: bool,
) -> Option<([f64; 3], f64)> {
elastic_pull_with_distance(position, yaw, size, holder, motion, quad, ELASTIC)
}
fn elastic_pull_with_distance(
position: Vec3,
yaw: f32,
size: (f64, f64),
holder: &HolderState,
motion: Vec3,
quad: bool,
elastic_distance: f64,
) -> Option<([f64; 3], f64)> {
// SHARED_QUAD_ATTACHMENT_POINTS, ENTITY_ATTACHMENT_POINT, LEASHER_ATTACHMENT_POINT.
const SHARED_QUAD: [[f64; 3]; 4] = [
@@ -243,7 +254,7 @@ pub fn elastic_pull(
pivot[2] - object[2],
];
let distance = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt();
if distance < ELASTIC {
if distance < elastic_distance {
continue;
}
// Vec3.normalize, then scale(distance - slack).
@@ -253,7 +264,7 @@ pub fn elastic_pull(
} else {
[d[0] / length, d[1] / length, d[2] / length]
};
let pull = distance - ELASTIC;
let pull = distance - elastic_distance;
let mut f = [unit[0] * pull, unit[1] * pull, unit[2] * pull];
let t = lever[2] * f[0] - lever[0] * f[2];
if motion.x * f[0] + motion.y * f[1] + motion.z * f[2] >= 0.0 {
@@ -360,10 +371,10 @@ pub fn tick(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
};
// PathfinderMob.whenLeashedTo: home is the holder's block, within the
// elastic distance less one.
let elastic = if m.species() == super::Species::HappyGhast {
10.0
let (elastic, snap) = if m.species() == Species::HappyGhast {
(10.0, 16.0)
} else {
ELASTIC
(ELASTIC, SNAP)
};
m.ai.home = Some((
[
@@ -390,7 +401,7 @@ pub fn tick(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
+ (state.center.y - center.y).powi(2)
+ (state.center.z - center.z).powi(2))
.sqrt();
if distance > SNAP {
if distance > snap {
events.push(MobEvent::LeashBreak {
mob: m.id,
drop: true,
@@ -398,7 +409,7 @@ pub fn tick(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
});
return;
}
let stretched = distance > ELASTIC - state.width - f64::from(m.width());
let stretched = distance > elastic - state.width - f64::from(m.width());
// Leashable.checkElasticInteractions: both ends must support the shared
// quad attachment points. The live holder was resolved and checked above;
// these physics points are independent of the rendered rope offsets.
@@ -406,13 +417,14 @@ pub fn tick(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
&& matches!(holder, Holder::Mob(id) if w.seen(id).is_some_and(|s|
matches!(s.species, Species::Ghast | Species::HappyGhast)));
let pulled = stretched
&& elastic_pull(
&& elastic_pull_with_distance(
m.position,
m.yaw,
(f64::from(m.width()), f64::from(m.height())),
&state,
m.velocity,
quad,
elastic,
)
.is_some_and(|(force, torque)| {
m.extra.leash_spin += 10.0 * torque;
@@ -428,6 +440,11 @@ pub fn tick(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
);
check_fall_distance(m);
super::equine::on_elastic_leash_pull(m, w, events);
// HappyGhast.onElasticLeashPull: let the rope move it without
// competing with the current flight target.
if m.species() == Species::HappyGhast {
m.ai.mov.operation = super::control::Operation::Wait;
}
true
});
if !pulled {
@@ -472,7 +489,8 @@ pub fn remove(m: &mut Mob) -> Option<Holder> {
/// `PathfinderMob.closeRangeLeashBehaviour`: walk to within two blocks of
/// the holder unless panicking.
fn close_range(m: &mut Mob, w: &MobWorld, holder: &HolderState) {
if m.ai.panicking {
// HappyGhast.shouldStayCloseToLeashHolder is false at either age.
if m.ai.panicking || m.species() == Species::HappyGhast {
return;
}
let d = Vec3::new(
@@ -668,6 +686,110 @@ pub fn quad_holder_offsets(m: &Mob) -> Option<[[f64; 3]; 4]> {
mod tests {
use super::*;
#[test]
#[ignore = "requires HORIZON_TEST_RESOURCES and the vanilla Happy Ghast leash reference"]
fn happy_ghast_leash_dispatch_matches_vanilla() {
use super::super::{
control::Operation,
path::{Path, PathNode, PathType},
};
use std::sync::Arc;
let root = std::path::PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES").unwrap());
let reference = std::env::var_os("HORIZON_HAPPY_GHAST_LEASH_REFERENCE").unwrap();
let rows: Vec<serde_json::Value> =
serde_json::from_slice(&std::fs::read(reference).unwrap()).unwrap();
assert_eq!(rows.len(), 36, "missing leash cases");
let ran = crate::mobs::world::with_resource_test_world(
&root,
&[],
true,
true,
|world, registry| {
world
.chunks
.insert((0, 0), Arc::new(crate::chunk::Chunk::empty(0, 0)));
world
.set(
0,
64,
0,
registry.default_state("minecraft:oak_fence").unwrap(),
)
.unwrap();
},
|world, types| {
assert!(
holder_state(world, Holder::Knot([0, 64, 0])).is_some(),
"missing fence holder"
);
let mut failures = Vec::new();
for row in rows {
let kind = row["kind"].as_str().unwrap();
let position = &row["position"];
let id = if kind == "cow" {
"minecraft:cow"
} else {
"minecraft:happy_ghast"
};
let mut mob = Mob::new(
1,
Arc::new(types.types[id].clone()),
Vec3::new(
position[0].as_f64().unwrap(),
position[1].as_f64().unwrap(),
position[2].as_f64().unwrap(),
),
0.0,
);
if kind != "cow" {
mob.extra.age = if kind == "baby" { -24000 } else { 0 };
super::super::happy_ghast::ensure_setup(&mut mob);
}
mob.on_ground = false;
mob.ai.nav.path = Some(Path {
nodes: vec![PathNode {
x: 8,
y: 64,
z: 8,
kind: PathType::Open,
}],
next: 0,
target: [8, 64, 8],
dist_to_target: 0.0,
reached: true,
});
mob.ai.nav.target_pos = Some([8, 64, 8]);
mob.ai.mov.set_wanted(20.0, 70.0, 20.0, 1.0);
mob.extra.leash = Some(Holder::Knot([0, 64, 0]));
let mut events = Vec::new();
tick(&mut mob, world, &mut events);
let actual = serde_json::json!({
"kind": kind, "distance": row["distance"], "phase": row["phase"],
"position": position,
"snapped": events.iter().any(|event| matches!(event, MobEvent::LeashBreak { .. })),
"wanted": mob.ai.mov.operation == Operation::MoveTo,
"path": mob.ai.nav.path.as_ref().map(|path| path.target),
"velocity": [mob.velocity.x, mob.velocity.y, mob.velocity.z],
});
let states_match = ["kind", "distance", "phase", "snapped", "wanted", "path"]
.into_iter()
.all(|key| row[key] == actual[key]);
let velocity_matches = (0..3).all(|i| {
(row["velocity"][i].as_f64().unwrap()
- actual["velocity"][i].as_f64().unwrap())
.abs()
<= 1e-10
});
if !states_match || !velocity_matches {
failures.push((row, actual));
}
}
assert!(failures.is_empty(), "leash dispatch differs: {failures:?}");
},
);
assert!(ran, "prepared resources must execute the fixture");
}
#[test]
fn mob_tick_selects_quad_only_when_both_live_ends_support_it() {
let ran = crate::mobs::world::with_test_world(&[], |world, types| {
+46
View File
@@ -558,6 +558,15 @@ impl Mob {
pub fn eye_height(&self) -> f64 {
self.dims().2
}
/// `EntityRenderer.getPackedLightCoords` reads the block
/// `Entity.getLightProbePosition(partialTick)` is in, and a living entity
/// leaves that as `getEyePosition`: the eye height over
/// `getPosition(partialTick)`, the position the frame draws, not this
/// tick's. A mob walking across a cell boundary is drawn lit by the cell it
/// is drawn in.
pub fn light_probe(&self, partial: f64) -> [i32; 3] {
eye_probe_cell(self.previous, self.position, self.eye_height(), partial)
}
pub fn width(&self) -> f32 {
self.dims().0 as f32
}
@@ -1786,6 +1795,19 @@ impl horizon_world::water_splash::WaterSplashWorld for Splash<'_> {
}
}
/// `Entity.getEyePosition(partialTick)` as the cell the renderer lights an
/// entity from (`Entity.getLightProbePosition` is that position and
/// `EntityRenderer.getPackedLightCoords` takes `BlockPos.containing` of it):
/// `Mth.lerp(partialTick, xo, x)` on each axis, plus `getEyeHeight()` on y.
pub fn eye_probe_cell(previous: Vec3, position: Vec3, eye_height: f64, partial: f64) -> [i32; 3] {
let at = previous.lerp(position, partial);
[
at.x.floor() as i32,
(at.y + eye_height).floor() as i32,
at.z.floor() as i32,
]
}
/// `Entity.doWaterSplashEffect` for a mob this window is drawing.
/// `Level.addParticle` runs on the client. `ClientLevel.playSeededSound`
/// ignores `Entity.playSound` (`except` is null), and the SPLASH game event
@@ -1901,6 +1923,30 @@ impl RestitutionWorld for MobRestitution<'_, '_> {
#[cfg(test)]
mod tests {
use super::*;
/// `Entity.getLightProbePosition(partialTick)` for a living entity is
/// `getEyePosition`: `getEyeHeight()` over `Mth.lerp(partialTick, xo, x)`.
/// Horizon probed this tick's own position, so a mob walking into the next
/// cell was drawn lit by that cell for the whole frame before it got there,
/// and a falling one for a frame after it left.
#[test]
fn a_mob_probe_follows_the_frame() {
let eye = 1.62;
// Walking from x = 0.9 to 1.1: the eye is in the cell at 0 until the
// frame reaches the boundary, and in the cell at 1 from there on.
let (previous, position) = (Vec3::new(0.9, 64.0, 0.5), Vec3::new(1.1, 64.0, 0.5));
assert_eq!(eye_probe_cell(previous, position, eye, 0.0), [0, 65, 0]);
assert_eq!(eye_probe_cell(previous, position, eye, 0.5), [1, 65, 0]);
assert_eq!(eye_probe_cell(previous, position, eye, 1.0), [1, 65, 0]);
// The old probe was the tick's own position on every frame.
assert_eq!(position.x.floor() as i32, 1);
// Falling a block: the eye is at 71.62 at the start of the frame and
// 70.62 at its end.
let (previous, position) = (Vec3::new(0.5, 70.0, 0.5), Vec3::new(0.5, 69.0, 0.5));
assert_eq!(eye_probe_cell(previous, position, eye, 0.0), [0, 71, 0]);
assert_eq!(eye_probe_cell(previous, position, eye, 0.5), [0, 71, 0]);
assert_eq!(eye_probe_cell(previous, position, eye, 1.0), [0, 70, 0]);
}
/// A pig dropped hard onto a block bounces by that block's restitution
/// (`Entity.getBlockBounciness`): landing on a slime block sends it back
/// up, landing on stone stops it dead. The same 8-block fall then costs the
+114 -12
View File
@@ -276,7 +276,12 @@ fn check_restriction(m: &mut Mob) {
/// `AbstractNautilus.tick` and the brains' memories: the rider's breath, the
/// dash clock, bubbles, and choosing whom to charge at
/// (`NautilusAi.findNearestValidAttackTarget`).
pub fn ai_step(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
pub fn ai_step(
m: &mut Mob,
w: &MobWorld,
level_random: &mut horizon_core::random::LegacyRandom,
events: &mut Vec<MobEvent>,
) {
// customServerAiStep: the home is kept before the rest of the step.
check_restriction(m);
if m.extra.scratch[1] != 0 {
@@ -321,7 +326,7 @@ pub fn ai_step(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
m.extra.anger -= 1;
}
if m.ai.target.is_none() && m.alive() && !m.no_ai {
m.ai.target = find_target(m, w);
m.ai.target = find_target(m, w, level_random);
}
}
/// `spawnBubbles`: more as it swims faster, from behind its mouth.
@@ -375,7 +380,11 @@ pub fn aggravated(m: &Mob) -> bool {
}
/// `NautilusAi.findNearestValidAttackTarget`: whoever it is angry at, else
/// now and then (a coin flip every 2400 to 3600 ticks) the nearest pufferfish.
fn find_target(m: &mut Mob, w: &MobWorld) -> Option<Who> {
fn find_target(
m: &mut Mob,
w: &MobWorld,
level_random: &mut horizon_core::random::LegacyRandom,
) -> Option<Who> {
if m.extra.in_love > 0 || !m.fluids.in_water || m.is_baby() || m.extra.tame {
return None;
}
@@ -391,15 +400,26 @@ fn find_target(m: &mut Mob, w: &MobWorld) -> Option<Who> {
if m.extra.counter > 0 {
return None;
}
m.extra.counter = 2400 + m.random.next_int_bound(1201).unwrap_or(0);
if m.random.next_float() < 0.5 {
// The cooldown and the coin come from the level's random (`level.getRandom()`).
m.extra.counter = 2400 + level_random.next_int_bound(1201).unwrap_or(0);
if level_random.next_float() < 0.5 {
return None;
}
w.nearby(m.position, 16.0, |s| {
s.species == Species::Pufferfish && in_water_at(w, s.position)
})
.next()
.map(|s| Who::Mob(s.id))
// NEAREST_VISIBLE_LIVING_ENTITIES.findClosest(isHostileTarget): the
// nearest #nautilus_hostiles in water that the nautilus can see
// (`Sensor.isEntityTargetable`: line of sight).
let mut near: Vec<(f64, u32)> = w
.nearby(m.position, 16.0, |s| {
s.species == Species::Pufferfish && in_water_at(w, s.position)
})
.map(|s| (s.position.distance_squared(m.position), s.id))
// TargetingConditions.range(16): feet to feet.
.filter(|&(d, _)| d <= 16.0 * 16.0)
.collect();
near.sort_by(|a, b| a.0.total_cmp(&b.0));
near.into_iter()
.map(|(_, id)| Who::Mob(id))
.find(|&who| m.sees(w, who))
}
/// `hurtServer`: `NautilusAi.setAngerTarget` for 400 ticks.
pub fn after_hurt(m: &mut Mob, source: &super::Damage) {
@@ -681,11 +701,13 @@ mod tests {
assert_eq!(m.extra.owner, owner);
assert_eq!(m.extra.sitting, expected, "{id} {owner:?} {sitting:?}");
// No raw compound: this must come from the modeled flag.
let saved = crate::entity_nbt::mob_to_nbt(&m, [5, 6, 7, 8], None, None);
let saved =
crate::entity_nbt::mob_to_nbt(&m, [5, 6, 7, 8], None, None, |_| None);
assert_eq!(saved.get("Sitting"), Some(&Tag::Byte(i8::from(expected))));
// A stale raw flag must not override a later model change.
m.extra.sitting = !expected;
let saved = crate::entity_nbt::mob_to_nbt(&m, [5, 6, 7, 8], Some(&raw), None);
let saved =
crate::entity_nbt::mob_to_nbt(&m, [5, 6, 7, 8], Some(&raw), None, |_| None);
assert_eq!(saved.get("Sitting"), Some(&Tag::Byte(i8::from(!expected))));
}
}
@@ -790,3 +812,83 @@ mod tests {
assert_eq!(m.rider.expect("the rider stays on").jump, 0.0);
}
}
#[cfg(test)]
mod hostile_target_tests {
use super::*;
use std::sync::Arc;
/// A pool from x -8 to 8, with a stone wall at x = 3 when `wall`.
fn pool(
wall: bool,
) -> impl FnOnce(&mut crate::world::World, &horizon_world::registry::BlockRegistry) {
move |world, registry| {
let water = registry.default_state("minecraft:water").unwrap();
let stone = registry.default_state("minecraft:stone").unwrap();
for cx in -1..2 {
for cz in -1..1 {
world
.chunks
.insert((cx, cz), Arc::new(crate::chunk::Chunk::empty(cx, cz)));
}
}
for x in -8..=18 {
for y in 60..=66 {
for z in -4..=4 {
let block = if wall && x == 3 { stone } else { water };
world.set(x, y, z, block).unwrap();
}
}
}
}
}
/// `NautilusAi.findNearestValidAttackTarget`: past the cooldown and the
/// level random's 1 in 2, the target is the closest #nautilus_hostiles in
/// water among NEAREST_VISIBLE_LIVING_ENTITIES, so not a pufferfish behind
/// stone. Each seed is a fresh level random.
fn targets(wall: bool, fish_at: Vec3) -> Option<Vec<Option<Who>>> {
let mut out = None;
let mut fish = None;
crate::mobs::world::with_built_test_world(
&[],
|_, _| {},
|_, types| {
let kind = Arc::new(types.types["minecraft:pufferfish"].clone());
fish = Some(Mob::new(2, kind, fish_at, 0.0).seen(None));
},
);
let fish = fish?;
crate::mobs::world::with_built_test_world(&[fish], pool(wall), |w, types| {
let kind = Arc::new(types.types["minecraft:nautilus"].clone());
let picks = (0..16)
.map(|seed| {
let mut m = Mob::new(1, kind.clone(), Vec3::new(0.5, 62.0, 0.5), 270.0);
m.fluids.in_water = true;
let mut level = horizon_core::random::LegacyRandom::new(seed);
find_target(&mut m, w, &mut level)
})
.collect();
out = Some(picks);
});
out
}
#[test]
fn a_nautilus_only_targets_a_pufferfish_it_sees() {
let near = Vec3::new(6.5, 62.0, 0.5);
// 16 out on x and 3 on z: inside the sensor's box, past range(16).
let far = Vec3::new(16.5, 62.0, 3.5);
let (Some(open), Some(walled), Some(beyond)) = (
targets(false, near),
targets(true, near),
targets(false, far),
) else {
return;
};
assert!(open.contains(&Some(Who::Mob(2))), "{open:?}");
assert!(open.iter().all(|t| t.is_none() || *t == Some(Who::Mob(2))));
assert!(walled.iter().all(Option::is_none), "{walled:?}");
assert!(beyond.iter().all(Option::is_none), "{beyond:?}");
}
}
+27 -11
View File
@@ -6,7 +6,7 @@
//! (`MAIN_GENE_ID`, `HIDDEN_GENE_ID`); the rest is [`State`], whose `flags`,
//! `unhappy`, `sneeze` and `eat` are the synched data a server sends.
use super::{
DamageKind, Mob, MobEvent, MobWorld, Species, Who,
Mob, MobEvent, MobWorld, Species, Who,
body_anim::Panda as Pose,
finalize::{self, Attr},
goals::{
@@ -708,19 +708,11 @@ impl Goal for PandaPanic {
self.0.stop(m, cx)
}
}
/// `DamageTypeTags.PANIC_ENVIRONMENTAL_CAUSES`, or burning.
/// `DamageTypeTags.PANIC_ENVIRONMENTAL_CAUSES` (`armadillo::environmental`), or burning.
fn environmental(m: &Mob) -> bool {
m.fire_ticks > 0 && !m.kind.flags.fire_immune
|| m.last_damage.is_some_and(|(d, t)| {
matches!(
d.kind,
DamageKind::OnFire
| DamageKind::InFire
| DamageKind::Lava
| DamageKind::Cactus
| DamageKind::LightningBolt
| DamageKind::HotFloor
) && i64::from(m.age) - t <= 40
super::armadillo::environmental(d.kind) && i64::from(m.age) - t <= 40
})
}
@@ -1095,3 +1087,27 @@ pub fn register(m: &mut Mob) {
),
);
}
#[cfg(test)]
mod freeze_panic_tests {
use super::*;
/// `#panic_environmental_causes` holds freeze and sulfur_cube_hot too.
#[test]
fn freezing_and_a_hot_sulfur_cube_are_environmental() {
let kind = (*crate::mobs::sensor_scan_tests::kind()).clone();
for cause in [
super::super::DamageKind::Freeze,
super::super::DamageKind::SulfurCubeHot,
] {
let mut m = Mob::new(
1,
std::sync::Arc::new(kind.clone()),
horizon_core::geometry::Vec3::ZERO,
0.0,
);
m.last_damage = Some((super::super::Damage::of(cause), i64::from(m.age)));
assert!(environmental(&m), "{cause:?}");
}
}
}
+27 -11
View File
@@ -9,7 +9,7 @@
use horizon_core::{geometry::Vec3, random::RandomSource};
use super::{
DamageKind, Mob, MobWorld, Species, Who,
Mob, MobWorld, Species, Who,
goals::{
Cx, Float, FollowParent, Goal, HurtByTarget, LookAtPlayer, MOVE, MeleeAttack,
NearestTarget, Panic, RandomLookAround, Stroll, goal_clone,
@@ -88,20 +88,12 @@ impl Goal for PolarBearPanic {
self.0.stop(m, cx)
}
}
/// `DamageTypeTags.PANIC_ENVIRONMENTAL_CAUSES` (fire, lava, cactus), or
/// `DamageTypeTags.PANIC_ENVIRONMENTAL_CAUSES` (`armadillo::environmental`), or
/// burning.
fn environmental_panic(m: &Mob) -> bool {
m.fire_ticks > 0 && !m.kind.flags.fire_immune
|| m.last_damage.is_some_and(|(d, t)| {
matches!(
d.kind,
DamageKind::OnFire
| DamageKind::InFire
| DamageKind::Lava
| DamageKind::Cactus
| DamageKind::LightningBolt
| DamageKind::HotFloor
) && i64::from(m.age) - t <= 40
super::armadillo::environmental(d.kind) && i64::from(m.age) - t <= 40
})
}
@@ -276,3 +268,27 @@ mod tests {
assert_eq!(m.dims().1, f64::from(base_h * (1.0 + 1.0 / STAND_TICKS)));
}
}
#[cfg(test)]
mod freeze_panic_tests {
use super::*;
/// `#panic_environmental_causes` holds freeze and sulfur_cube_hot too.
#[test]
fn freezing_and_a_hot_sulfur_cube_are_environmental() {
let kind = (*crate::mobs::sensor_scan_tests::kind()).clone();
for cause in [
super::super::DamageKind::Freeze,
super::super::DamageKind::SulfurCubeHot,
] {
let mut m = Mob::new(
1,
std::sync::Arc::new(kind.clone()),
horizon_core::geometry::Vec3::ZERO,
0.0,
);
m.last_damage = Some((super::super::Damage::of(cause), i64::from(m.age)));
assert!(environmental_panic(&m), "{cause:?}");
}
}
}
+1 -1
View File
@@ -566,7 +566,7 @@ mod tests {
// Animal.addAdditionalSaveData / readAdditionalSaveData: "InLove" (#1396).
let mut m = rabbit();
m.extra.in_love = 570;
let tag = crate::entity_nbt::mob_to_nbt(&m, [0, 0, 0, 1], None, None);
let tag = crate::entity_nbt::mob_to_nbt(&m, [0, 0, 0, 1], None, None, |_| None);
assert_eq!(tag.get("InLove"), Some(&horizon_world::nbt::Tag::Int(570)));
let types = HashMap::from([(m.kind.id.clone(), m.kind.clone())]);
let back = crate::entity_nbt::mob_from_nbt(&tag, &types, 2, None).unwrap();
+1 -1
View File
@@ -757,7 +757,7 @@ mod tests {
.is_some_and(|d| d.ominous_banner)
);
assert!(is_captain(&m));
let saved = crate::entity_nbt::mob_to_nbt(&m, [0; 4], None, None);
let saved = crate::entity_nbt::mob_to_nbt(&m, [0; 4], None, None, |_| None);
let chances = saved.get("drop_chances").unwrap();
assert_eq!(
chances.get("head"),
+1 -1
View File
@@ -1003,7 +1003,7 @@ mod tests {
assert_eq!(map.get(&"sleeping_pos".into()), expected.as_ref());
assert_eq!(map.get(&"h232_control".into()), Some(&Tag::Int(42)));
}
let saved = crate::entity_nbt::mob_to_nbt(&m, [0; 4], None, None);
let saved = crate::entity_nbt::mob_to_nbt(&m, [0; 4], None, None, |_| None);
assert_eq!(saved.get("sleeping_pos"), expected.as_ref());
}
}
Loaded 100 of 333 files, more files were not shown because too many files have changed in this diff. Show more