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
Kacper 10b0942e4f Merge pull request #10191 from KBroneZ/claude/fix-10159-empty-noise
upgrade-datapack: a noise with no octaves becomes a silent 26.3 noise
2026-11-06 08:55:56 +01:00
Kacper 81aeeacf55 Merge pull request #10522 from kacpherek/perf/smp-server
Server: boats and minecarts stop walking every mob, and a Perfetto trace of every thread
2026-11-06 08:54:22 +01:00
Kacper a0261a708a Merge pull request #10190 from KBroneZ/claude/fix-10158-default-states
Read default_block and default_fluid in 26.3's block state form
2026-11-06 08:52:19 +01:00
Kacper 894f7a0684 Merge pull request #10167 from DUzzL/fix/10113-f3-keys-main-menu
Handle F3 debug keys on the title screen
2026-11-06 08:52:13 +01:00
Kacper d5b1c59f67 Merge pull request #10507 from DUzzL/fix/10447-lead-creative-spectator
Keep the lead in creative and drop mob leads when the holder spectates
2026-11-06 08:52:07 +01:00
Kacper b0ea840d13 Merge pull request #10128 from DUzzL/fix/10048-ladder-mob-push
Mobs on a ladder or vine are not pushable; no crit while climbing
2026-11-06 08:52:00 +01:00
Kacper 479d2082a8 Merge pull request #10187 from DUzzL/fix/10073-horse-jump-anim
Fix horse rear ending before a donkey or mule lands
2026-11-06 08:51:52 +01:00
Kacper d4b24f27d6 Merge pull request #10206 from DUzzL/fix/10157-shulker-tooltip
Fix numbered translation args in container item tooltips
2026-11-06 08:51:46 +01:00
Kacper 3ad30a2021 Merge pull request #10488 from AlfaRojo/fix/crossbow-flame-ignite
Crossbows: Flame ignites the arrows they shoot
2026-11-06 08:51:38 +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
Kacpherek f80bf5d7f3 Vehicles: test a mob's position before working out its box
A boat's or minecart's box query (boat_contacts, boat_obstacles,
cart_entity_collisions) asked every mob of the level for its box, and
Mob::bounds reads the mob's whole size (dims: sleeping, baby, species, scale)
across several cache lines. While boats and carts tick, the widest half width
and tallest height of the mobs (BoxReach) are worked out once, and a mob whose
position is farther than that from the query is skipped without its box. The
reach is worked out again when a mob joins or leaves, and nothing a vehicle
does resizes a mob, so the same mobs are found.
2026-11-06 08:36:08 +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
Ludovic Marie 0d21d5520e Merge pull request #10477 from Detimes/feat-10404-older-clients-1a
🤖 Agent: a 26.2 client is now refused in configuration before any untranslated registry data, tags or play packets (bebf670); 26.3 path byte-for-byte unchanged with the option off
2026-11-06 08:27:12 +01:00
Kacpherek 02bdb03444 Vehicles: seat riders and find boat contacts without a pass over every mob per vehicle
position_riders finds every vehicle's riders in one pass over the mobs instead
of one per boat, minecart and primed TNT. A boat tests a mob's box before
anything else, and works out the scoreboard teams only when something touches
it, instead of once per boat a tick.
2026-11-06 08:10:31 +01:00
Kacpherek c7183d6232 Trace: every thread's spans as a timeline Perfetto opens
HORIZON_TRACE=FILE, horizon-ctl trace FILE [SECONDS] and horizon-trace on a
dedicated server's console write each span of every thread as a Chrome trace
event, so what ran beside what, and what a thread waited for, can be read off
in ui.perfetto.dev. A thread that records spans but is never taken from (a far
terrain or lighting worker while spans are on) now drops them past 16k instead
of growing for as long as spans stay on.
2026-11-06 08:10:30 +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
Ludovic Marie 98af9399cd Merge pull request #10509 from horizon-rust/perf/launcher-prepared-cache
🤖 Agent: is_prepared now answers from a 5 s per-version cache (21.4 µs to 0.21 µs per call); need_prepared and the CLI still run the full per-file check, so an empty JSON is caught before launch.
2026-11-06 07:53:06 +01:00
Horizon agentandClaude Opus 5.5 bebf670623 Turn a 26.2 client away before untranslated registry data
Only packet ids are translated for an older client so far (#10404): the
registry data, tags (registry ids) and play packets (chunks, light, login,
player_action numbering...) would reach a real 26.2 client in 26.3 ids and
layouts. With accept_older_clients on, such a client now logs in and is
told so in configuration, right after the known packs and before any of
them. NetConfig::older_clients_play (tests only, not in server.toml) keeps
the play path reachable for the id renumbering test until the later parts
translate the contents.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 07:51:40 +01:00
Horizon agent 5daf0853e3 Merge remote-tracking branch 'origin/main' into review-10477 2026-11-06 07:44:24 +01:00
Horizon agentandClaude Sonnet 5.5 bb8a9d9524 Launcher: pages drawn ask "is it prepared?" from memory, not the disk
#10389 made is_prepared parse research-lock.json and the manifest, list the
prepared folder and stat every table on each call. Pages call it while drawing
(instance rows, the Play menu, can_start), so every frame did file I/O.

is_prepared now keeps its answer per version folder for 5 s and drops it when
the launcher installs, removes, reuses resources or a job exits.
check_prepared runs the full check and refreshes the answer; starting the game
or a server (need_prepared), the CLI and the post-prepare auto-play use it, so
an empty JSON is still caught before a launch.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-06 07:44:15 +01: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
Ludovic Marie 3a81751180 Merge pull request #10458 from TheMrZZ/fix/h028-stand-fall-save
🤖 Agent: Entity.checkFallDamage subtracts (float)ya into the double, which the f64::from(.. as f32) step reproduces, and Entity.load reads fall_distance back. Main merged in again after #10422 landed (GitHub read the criss-cross bases as a conflict); the diff to main is this PR's own 6 files.
2026-11-06 07:28:07 +01:00
Horizon-agentandClaude Opus 5.5 ec72efe92d Merge main into #10458 after #10422 landed
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 07:25:35 +01:00
Ludovic Marie 87c51ab9c9 Merge pull request #10471 from arslanarm/shaderpacks/shadow-pass-block-entities
🤖 Agent: `camera_list` is now collected into a Vec on every frame, even with no pack, where the old iterator was lazy; it's one small allocation per frame. The shadow-only set and the spawner's `shadow_pass_drew` scan (linear in shadow_visible per spawner) run only under a pack whose shadow draws entities.
2026-11-06 07:23:45 +01: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
Kacper 108c130237 Merge pull request #10340 from CocofireHD/fix/10327-docs-readme-md-index-the-benchmark-and-perfo
docs: index benchmark and performance result pages in docs/README.md
2026-11-06 07:18:18 +01:00
Kacper 73fdce3c01 Merge pull request #10375 from voidsipp/fix/10326-docs-index
Index the missing design, server and release docs
2026-11-06 07:18:10 +01:00
Kacper bf22a35ab3 Merge pull request #10350 from xxaxio/docs/10325-index-player-launcher-docs
docs/README.md: index missing player, launcher and platform pages
2026-11-06 07:18:04 +01:00
Kacper e41b6599ec Merge pull request #10351 from xxaxio/docs/10324-remove-stale-not-ported-notes
docs: remove stale "not ported" notes
2026-11-06 07:17:58 +01:00
Kacper 0c2aec59f6 Merge pull request #10234 from KBroneZ/claude/fix-10164-forceload
Load chunks in /forceload add, as vanilla does
2026-11-06 07:17:48 +01:00
Kacper 50c2ade6c6 Merge pull request #10233 from KBroneZ/claude/fix-10163-template-loot
Keep a placed template's container loot table
2026-11-06 07:17:42 +01:00
Kacper 3281781334 Merge pull request #10370 from xxaxio/fix/10322-map-can-place-on-banner
game/maps: honour can_place_on when using a map on a banner in adventure mode
2026-11-06 07:17:35 +01:00
Kacper 5871fa922f Merge pull request #10278 from Joansitoh/fix/10044-underwater-sky
Stop the underwater sky scan on kelp and seagrass
2026-11-06 07:17:25 +01:00
Kacper 16bb8b3e65 Merge pull request #10277 from Joansitoh/fix/10044-bucket-liquid
Replace a bubble column when a bucket empties into it
2026-11-06 07:17:18 +01:00
Kacper 1e35996644 Merge pull request #10274 from DUzzL/fix/10250-zip-end-record-disk
Normalize zip END records with equal non-zero disk numbers
2026-11-06 07:17:12 +01: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
Ludovic Marie 499b049803 Merge pull request #10422 from TheMrZZ/fix/h046-loot-pickup-save
🤖 Agent: Mob.addAdditionalSaveData always writes canPickUpLoot(), so moving it out of the put-if-absent defaults is the fix (Allay and sulfur cube overrides now win over a loaded byte). Main merged in after #10393 landed; clippy and the entity_nbt tests pass on the merge.
2026-11-06 07:16:40 +01:00
Ludovic Marie 29825eb774 Merge pull request #10411 from DUzzL/fix/10318-piglin-gear-compare
🤖 Agent: piglin_replace follows Piglin.canReplaceCurrentItem in order (PREVENT_ARMOR_CHANGE first, then wanted/unwanted, then Mob.canReplaceCurrentItem), with getPreferredWeaponType null for babies.
2026-11-06 07:12:21 +01:00
Horizon-agentandClaude Opus 5.5 ded0d1a283 Merge main into #10458
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 07:11:56 +01:00
Horizon-agentandClaude Opus 5.5 1c8729793f Merge main into #10422
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 07:11:51 +01:00
Ludovic Marie 69c1400e58 Merge pull request #10389 from CocofireHD/fix/10303-my-game-crash-after-launch
🤖 Agent: `is_prepared` now reads the manifest and stats every top-level JSON on each call, and the launcher calls it from render paths (glass_home, glass_palette, can_start): a few hundred µs per frame at most, but a cache keyed on the folder would remove it. I dropped the unused `is_json_file`; launcher clippy and backend tests pass.
2026-11-06 07:11:07 +01:00
Ludovic Marie 948c6d6a88 Merge pull request #10438 from megabytesme/fix/windows-cross-compile-icon
🤖 Agent: checked on Linux with mingw: the build script now compiles `main_res.o` for x86_64-pc-windows-gnu and passes it to the link, which the host-gated `#[cfg(windows)]` skipped. I kept the warn-only fallback outside CI for windres paths with a space (#10398).
2026-11-06 07:11:03 +01:00
Ludovic Marie b8c058a249 Merge pull request #10386 from voidsipp/fix/10355-boat-attack-knockback
🤖 Agent: causeExtraKnockback's non-living branch is Entity.push (no knockback resistance, no halving), as strike_boat does; the guest path still checks the attack range before skipping look_at_entity. We merged #10434 into this branch: both extend the boat fixture and harness.
2026-11-06 07:10:59 +01:00
Ludovic Marie 5a0c5d522f Merge pull request #10437 from voidsipp/fix/10401-snbt-float-text
🤖 Agent: for one-digit mantissas {:.1e} picks the closest two-digit decimal, which is what Java's Float/Double.toString does (Float.MIN_VALUE prints 1.4E-45). We switched the harness to the shared release world_probe: debug at CARGO_BUILD_JOBS=1 would have compiled horizon-world again in CI.
2026-11-06 07:10:55 +01:00
Ludovic Marie ff27525aaf Merge pull request #10448 from masa-masa-0314/fix/realms-add-realm-strings
🤖 Agent: RealmsPdpScreen shows the trial description (and a Try for free button) only when trialAvailable; Horizon does not ask for it, so we dropped the description and kept the title with Buy, vanilla's no-trial layout. Titles are drawn plain where vanilla underlines/bolds them: small follow-up.
2026-11-06 07:10:52 +01:00
Ludovic Marie 54112649da Merge pull request #10413 from DUzzL/fix/10319-golem-reset-anger
🤖 Agent: start() matches NeutralMob.stopBeingAngry plus IronGolem.startPersistentAngerTimer (nextInt(381) + 400 = rangeOfSeconds(20, 39)) at target priority 4. We moved the creeper test's doc comment back onto its own test: the new test had been inserted between them.
2026-11-06 07:10:48 +01:00
Ludovic Marie ef207a7c92 Merge pull request #10391 from masa-masa-0314/feat/realms-tier-distance
🤖 Agent: the tier fetch and both PUTs hit the same paths as RealmsClient.getRealmTierConfiguration/updateRenderDistance/updateSimulationDistance, and a reply missing either range is an error as in vanilla; nothing beyond what the vanilla settings tab offers.
2026-11-06 07:10:43 +01:00
Ludovic Marie c86e080406 Merge pull request #10388 from voidsipp/fix/conversion-command-tags
🤖 Agent: only Tags move to the new id, as ConversionType.convertCommon copies them; the old raw compound (UUID, species data) is left behind, which the save/reload test checks.
2026-11-06 07:10:39 +01:00
Ludovic Marie 26243ba3b7 Merge pull request #10392 from voidsipp/fix/7436-fishing-ground-motion
🤖 Agent: hook_block_hit now runs on the client before moving, as FishingHook.checkCollision does on both sides, and gravity is f64::from(0.03f32) (getDefaultGravity is a float).
2026-11-06 07:10:35 +01:00
Ludovic Marie a4f021ec67 Merge pull request #10414 from voidsipp/fix/4832-remote-pig-steering
🤖 Agent: matches Pig/Strider.getControllingPassenger (saddled, first passenger a player holding the right stick in either hand); the server side reads the same rule through moved_by_client, so it no longer simulates the mount twice.
2026-11-06 07:10:31 +01:00
Ludovic Marie a7bf97e67d Merge pull request #10417 from voidsipp/fix/next-1-glint-override
🤖 Agent: component_bool matches NbtOps.getBooleanValue (doubleValue != 0, so 0.5f and NaN are true, Short 256 too), not NumericTag.asBoolean's byteValue; that is the Hypixel glint_override report.
2026-11-06 07:10:28 +01:00
Ludovic Marie 11e2482ae5 Merge pull request #10426 from voidsipp/fix/levitation-fall-distance
🤖 Agent: LivingEntity.aiStep resets fall distance for slow_falling or levitation before travel, NoAI included; dropping the later travelInAir-style reset is right, it erased the current tick's descent.
2026-11-06 07:10:24 +01:00
Ludovic Marie 363906684c Merge pull request #10430 from voidsipp/fix/10397-packet-utf8-replacement
🤖 Agent: packet_utf8 differs from from_utf8_lossy only on ED A0..BF (encoded surrogates), which Java consumes as one replacement; the length limits still use the encoded bytes first and UTF-16 units after, as Utf8String.read does.
2026-11-06 07:10:20 +01:00
Ludovic Marie a567b64f20 Merge pull request #10433 from voidsipp/fix/10399-unreadable-moderation-lists
🤖 Agent: reading the bytes once and parsing them removes the exists()-then-load race too; only NotFound starts an empty list, so a directory or a locked ops.json now stops the stopped-server command instead of being overwritten.
2026-11-06 07:10:16 +01:00
Ludovic Marie faa7e2746f Merge pull request #10407 from DUzzL/fix/10323-evoker-fangs-teams
🤖 Agent: fangs_allied follows Entity.isAlliedTo both ways (AbstractIllager tag only when both are teamless, Evoker's vex root-owner rule); guests on the evoker's team are still bitten, as before, which is a follow-up rather than a regression.
2026-11-06 07:10:12 +01:00
Horizon-agent b4197ddd94 Merge remote-tracking branch 'origin/main' into w-10471
# Conflicts:
#	crates/horizon-client-render/src/gfx.rs
2026-11-06 07:08:12 +01:00
Ludovic Marie ad8663977c Merge pull request #10459 from TheMrZZ/fix/armor-stand-command-equip-events
🤖 Agent: follows LivingEntity.onEquipItem (same item+components or firstTick: nothing; EQUIPPABLE on the new stack picks equip over unequip) and data merge keeps first_tick. We removed the docs/parity run-log file.
2026-11-06 07:03:36 +01:00
Ludovic Marie 0b3a4bda9c Merge pull request #10424 from TheMrZZ/fix/hostile-sound-categories
🤖 Agent: all nine families are Monster subclasses (entity.hostile.* is Monster's shared hurt/death/fall set), so HOSTILE is right; the list scan is 34 short strings per sound, negligible. We removed the docs/parity run-log file.
2026-11-06 07:03:32 +01:00
Ludovic Marie 5fb8f59ea0 Merge pull request #10432 from TheMrZZ/fix/numeric-damage-component
🤖 Agent: DataComponents.DAMAGE goes through Codec.INT (Number.intValue: truncation, Long keeps its low 32 bits), and dynamic::as_int already does that. We removed the docs/parity run-log file.
2026-11-06 07:03:29 +01:00
Ludovic Marie 62d2b18c16 Merge pull request #10400 from TheMrZZ/fix/inline-fishing-tool-context
🤖 Agent: LootCommand.dropFishingLoot's no-tool endpoint passes ItemStack.EMPTY, and the code only reads tool_stack when a tool, mainhand or offhand was given, so an omitted tool stays empty. We removed the docs/parity run-log file.
2026-11-06 07:03:25 +01:00
Ludovic Marie 17541e8bff Merge pull request #10387 from TheMrZZ/fix/baby-pig-passenger-attachment
🤖 Agent: Pig BABY_DIMENSIONS has PASSENGER (0, 0.5, 0), so the one match arm is right. We removed docs/parity/baby-pig-passenger-attachment.md: run logs and hashes belong in the PR description, not in docs/.
2026-11-06 07:03:22 +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
Kevin RamirezandClaude Fable 5.1 927b546c9a Crossbows: Flame ignites the arrows they shoot (#10482)
`Game::shoot_crossbow` set the arrow's weapon and Piercing count but never
ran the weapon's `projectile_spawned` ignite, which only the bow path
applied. In vanilla, `AbstractArrow`'s constructor runs
`EnchantmentHelper.onProjectileSpawned` for every arrow built with a
weapon, bow or crossbow, so a Flame crossbow shoots burning arrows that
prime TNT and set entities on fire.

The bow's `bow_projectile_ignite` becomes `ignite_spawned_arrow(weapon,
arrow)`, shared by both weapons; the crossbow ignites each arrow of its
volley after Piercing, as the constructor does. The port map rows follow
the rename, and a resource-backed regression checks no Flame, Flame and
Flame with Multishot.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-11-06 00:02:20 -06:00
Ludovic Marie fff2a81e41 Merge pull request #10395 from DUzzL/fix/10321-spawnpoint-bubble-column
🤖 Agent: confirmed in Blocks.<clinit>: water, lava and bubble_column are the only three blocks with .liquid(), so the name list is complete for Block.isPossibleToRespawnInThis.
2026-11-06 07:02:01 +01:00
Ludovic Marie 1f883e7a79 Merge pull request #10402 from Skr1zzzz/server-tick-spans
🤖 Agent: `tick_blocks` starts a fresh `laps` after the scheduled ticks, so the stretch between `tb_entity_inside` and `tb_broadcast` shows only through `random_ticks`, `tb_scheduled` and `tb_fluids`. The counters and laps cost one relaxed load each when spans are off.
2026-11-06 07:01:57 +01:00
Ludovic Marie c956e11ff8 Merge pull request #10470 from DUzzL/fix/10441-ghastling-sounds
🤖 Agent: HappyGhast.getHurtSound/getDeathSound pick GHASTLING_* by isBaby, and happy_ghast::sounds already does exactly that, so the new voice_sounds arm is the whole fix.
2026-11-06 07:01:53 +01:00
Ludovic Marie f6a9444deb Merge pull request #10416 from Skr1zzzz/spawn-parity
🤖 Agent: checked against 26.3: `ChunkAccess.getHeight` returns `getFirstAvailable - 1` and `getRandomPosWithin` adds the 1 back, so topEmptyY is Horizon's `world_surface` itself. The old +1 made the bound 130 for a column solid to y 63. run-parity's natural_spawn harness checks the draws end to end.
2026-11-06 07:01:50 +01:00
Ludovic Marie 0610b830ca Merge pull request #10434 from voidsipp/fix/4545-boat-paddle-transition
🤖 Agent: rowing_time now gates on paddle_controller like AbstractBoat.getPaddleState (flag AND a controlling passenger) and clamps a as Mth.clampedLerp does; #10386 carries this branch merged, queued after it.
2026-11-06 07:01:46 +01:00
Ludovic Marie 8a21328406 Merge pull request #10439 from Skr1zzzz/random-ticks
🤖 Agent: the `[false; 256]` table relies on a local palette never holding more than 256 entries (global palettes keep the per-block path). The test covers 1, 2, 256 and 300 states, a stale palette entry, and the LCG draws against an independent oracle.
2026-11-06 07:01:39 +01:00
Ludovic Marie d165254d41 Merge pull request #10393 from TheMrZZ/fix/h011-orb-saved-uuid
🤖 Agent: matches Entity.saveWithoutId: the orb's command NBT now reads its registered uuid_for(id) instead of a fresh random UUID per read; queued first of the 10393/10422/10458 stack (the other two carry this branch merged).
2026-11-06 07:01:34 +01:00
Ludovic Marie 0125ca5281 Merge pull request #10435 from Hiuoy/feat/model-layers
🤖 Agent: `native_bake_layer` re-reads entity-models.json on every call for a vanilla layer no mob has (the doc says so), so a mod that bakes per frame would hitch; caching that document is the natural next step. The humanoid-against-zombie#main bake test is the right oracle, and the fragment stamps cleanly (tests.test_stamp_native_hooks).
2026-11-06 07:01:29 +01:00
Ludovic Marie 743d6229fe Merge pull request #10419 from senjinthedragon/resize-free-first
🤖 Agent: the early return for an unchanged size stays ahead of the new `device.poll(wait)`, so the GPU sync happens once per real size change. A drag-resize pays it per new size, a fair price for not holding both sets of buffers.
2026-11-06 07:01:25 +01:00
Ludovic Marie 6df8d31833 Merge pull request #10456 from arslanarm/shaderpacks/smooth-across-teleports
🤖 Agent: `world_seen` never resets inside a `Custom`, so the restart on entering the Nether relies on the pack being rebuilt for each dimension. If `PackPost` is ever kept across dimensions, it will need resetting there.
2026-11-06 07:01:21 +01:00
Ludovic Marie cc4911fc2b Merge pull request #10457 from arslanarm/shaderpacks/colortex-mips-blend
🤖 Agent: only `Source::Color` gets the linear-mip sampler; shadowcolor, custom textures and the lightmap keep `Linear` (nearest level), which matches what was measured. Buffers without mipmaps read the same as before.
2026-11-06 07:01:17 +01:00
Ludovic Marie 8d1a46ef24 Merge pull request #10467 from arslanarm/shaderpacks/shadow-depth-filter-precision
🤖 Agent: the 256ths weights are a fit to one NVIDIA GPU but now apply on every GPU, so an AMD or Intel reading of Iris may move them later. The cost is two floors and a multiply per filtered shadow read.
2026-11-06 07:01:13 +01:00
Ludovic Marie a6a228a193 Merge pull request #10469 from arslanarm/shaderpacks/shadow-pass-spectator
🤖 Agent: the spectator rule keys on `pack_ids.is_some()`, so a native pack's shadow keeps the first-person model, as the test pins. The other game modes stay as before, since only spectator was measured.
2026-11-06 07:01:09 +01:00
Ludovic Marie 57140bb20f Merge pull request #10473 from arslanarm/shaderpacks/terrain-vertex-grid
🤖 Agent: the +1/32 bias moves only places within one float step under a 32768th line, so #9839's water tops (29094.34) and 13/15 offsets (28398.93) still floor as before, and the new test case pins the 1, 0.5 and 9.5 edges.
2026-11-06 07:01:05 +01:00
Ludovic Marie 0c7ddd45c5 Merge pull request #10436 from arslanarm/shaderpacks/shadow-pass-depth-culling
🤖 Agent: with the near and far planes dropped, the shadow pass now draws every section inside the box's sides out to its reach, so a pack with tight planes draws more shadow terrain than before. Worth watching in the cave perf numbers. #10471 conflicts with this in gfx.rs; I'll merge main into it once this lands.
2026-11-06 07:01:00 +01:00
Horizon-agentandClaude Opus 5.5 c9b0011952 Merge #10434 (boat paddles) into #10386
Both extend the boat authority fixture and harness: keep the paddle and
attack cases, both Case fields and both docstring paragraphs. The harness
keeps main's fast-profile build with #10434's --resources option.
Queued after #10434, so either landing first leaves the other mergeable.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 06:57:38 +01:00
Horizon-agentandClaude Opus 5.5 a25a11fbf7 Merge #10422 and #10393 into #10458
All three add a command_exec fixture group at the same lines and a test
module at the end of entity_nbt.rs; keep every group, flag, dispatch block
and module. Queued in order so whichever lands first leaves the others
mergeable.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 06:56:29 +01:00
Horizon-agentandClaude Opus 5.5 9f9daee867 Merge #10393 (orb saved UUID) into #10422
Both add a command_exec fixture group at the same lines; keep both groups,
their isolation sets, flags and dispatch blocks. Queued in order so either
landing first leaves the other mergeable.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 06:55:28 +01:00
DUzzL 468a73f128 Keep the lead in creative and break mob leads when the holder spectates 2026-11-06 06:54:41 +01:00
Horizon-agentandClaude Opus 5.5 c542322582 Float text harness: build the shared release world_probe
A debug world_probe at one job compiles horizon-world from scratch beside
the release probe every other horizon-world harness uses.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 06:54:15 +01:00
Horizon-agentandClaude Opus 5.5 264d5f38c7 Realms product page: the trial header without its description
Horizon does not ask trialAvailabilityTask. Vanilla shows the trial
description (and offers the trial in the footer) only when one is
available; otherwise the header is the title alone and the footer offers
Buy, which is what this screen shows.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 06:53:56 +01:00
Horizon-agentandClaude Opus 5.5 dd23018283 Keep the creeper test's doc comment on its own test
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 06:53:11 +01:00
Horizon-agentandClaude Opus 5.5 3b575a0016 Drop the run-log document from the PR
The PR description already holds the vanilla reference and the measured
results; docs/ keeps player and contributor documentation only.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 06:52:48 +01:00
Horizon-agentandClaude Opus 5.5 94d295d20c Drop the run-log document from the PR
The PR description already holds the vanilla reference and the measured
results; docs/ keeps player and contributor documentation only.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 06:52:45 +01:00
voidsip 625f6ca1a5 Match Happy Ghast leash movement and distances 2026-11-05 23:52:43 -06:00
Horizon-agentandClaude Opus 5.5 ec080faa9f Drop the run-log document from the PR
The PR description already holds the vanilla reference and the measured
results; docs/ keeps player and contributor documentation only.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 06:52:42 +01:00
Horizon-agentandClaude Opus 5.5 1d1b060e1a Drop the run-log document from the PR
The PR description already holds the vanilla reference and the measured
results; docs/ keeps player and contributor documentation only.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 06:52:37 +01:00
Horizon-agentandClaude Opus 5.5 5e8d9a5123 Drop the run-log document from the PR
The PR description already holds the vanilla reference and the measured
results; docs/ keeps player and contributor documentation only.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 06:52:26 +01: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
Horizon-agentandClaude Opus 5.5 3649dfb9eb json_file: drop is_json_file, which nothing calls
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 06:45:51 +01:00
Horizon-agentandClaude Opus 5.5 94d8386027 build.rs: keep the no-icon fallback for local builds
The GNU windres the launcher's native mod builds use can't take a path with a
space, so a local build there must go on without the icon (#10398 asked to keep
it); CI still fails without it. One Windows block for the stack and the icon.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 06:45:50 +01: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
Arslan ArmanulyandClaude Opus 5.5 c9bc6104b3 Shader packs: a terrain vertex a float step under a 32768th sits on it
A shader pack's terrain gl_Vertex floors each vertex's place in its
section to a 32768th. Plants whose random offset is 9/15 of a block came
out a 32768th under a half block, where our probe packs read Iris
1.11.7's exactly on it: we guess, untested, that our float sums put them
a hair under the line. A place up to 1/32 of a 32768th under a grid line
is now on it.

Part of #8464

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 10:26:00 +05:00
Arslan ArmanulyandClaude Opus 5.5 02c05de455 Shader packs: shadowtex filters in whole 256ths, as our Iris readings fit
A pack's texture() on shadowtex0/1 through a plain sampler blended the
four texels with the exact fraction. Iris 1.11.7's filtered reads on our
NVIDIA GPU fit weights in whole 256ths: the fraction rounded half up on
each axis, the far corner's weight their product over 256 rounded half
up, the others the rest of each axis. #8464's lit probe then differs
from Iris's at no pixel of ow_ground_noon.

Part of #8464

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 10:25:52 +05:00
Arslan ArmanulyandClaude Opus 5.5 6a737285e8 Shader packs: the shadow pass takes the block entities of the sections it draws
A shader pack's shadow pass drew the block entities the camera's view
had built: a chest beside the view, in a section whose terrain the
shadow pass draws, was left out, and a spawner's mob out of the view
and out of those sections went in. Our probe packs' Iris 1.11.7 shadow
maps hold a chest wherever the shadow pass draws its section's terrain
and nothing of the one spawner in our scenes, which lies outside them.
The block entities of the sections the shadow pass drew last frame are
now added hidden from the camera, and a spawner's mob that is out of
the view and out of those sections is left out.

Part of #8464

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 10:25:51 +05:00
Arslan ArmanulyandClaude Opus 5.5 1354db6f08 Shader packs: a spectator's own model stays out of the shadow pass, as in Iris
In first person, a shader pack's shadow pass drew the spectator's own
model (its head) at the camera. Our probe packs' readings of Iris
1.11.7's shadow map in spectator mode hold the terrain below the camera
there.

Part of #8464

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 10:25:42 +05:00
Detimes d503874bff Let 26.2 clients join a Horizon server, off by default: handshake and packet ids
A server.toml option, [server] accept_older_clients (horizon-accept-older-clients), off
by default. On, a login handshake stating 776 opens an adapter::ClientSession on the
connection instead of being refused, and a 26.2 status ping is told its own protocol.
The session follows the connection's states as Session does, mirrored, and renumbers
packet ids both ways with the inverse of the generated 26.2 tables, in the blocking
login path and in the io_pool play path. Off, nothing changes.

Part of #10404.
2026-11-06 05:22:16 +00:00
Florian ERNSTandCodex f97e4b9527 Emit game events for armor-stand command equipment changes
Co-Authored-By: Codex <[email protected]>
2026-11-06 06:12:01 +01:00
TheMrZZandCodex gpt-6.1-sol 504b987937 Save armor stands' actual fall distance
Adopt and project the existing fall counter, including Java's per-movement float narrowing before double accumulation. Preserve travel and landing-call order, and cover the real tick/save route plus decoded saves without raw data.

Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-06 05:59:09 +01:00
masa-masa-0314andZCode c81874d772 Realms: the settings sliders' tooltips
`createDistanceOption`'s cached constant tooltips, shown while the
pointer is over either distance slider.

Co-Authored-By: ZCode <[email protected]>
2026-11-06 12:55:30 +08:00
Ludovic Marie a589a3dd9b Merge pull request #10449 from horizon-rust/fix/native-stale-engine-target
🤖 Agent: staged files now get the current time, so a Nightly's source unpacked with its commit's older mtimes can no longer pass for already-compiled crates (Windows/macOS fs::copy kept them); one-time stage rewrite for old stages.
2026-11-06 05:53:41 +01:00
Arslan ArmanulyandClaude Opus 5.5 8d38356bf4 Shader packs: smooth() keeps going through teleports, as in Iris
A custom uniform's smooth() restarted on every frame whose camera biome was
unknown, so after each teleport it started again, and it started from the
unknown frame's values (biome -1, biome_category 0, temperature 0): the
frame that restarted it also stored them, and the first frame that knew the
biome went on from there.

Now smooth() restarts only until the first frame that knows the camera's
biome, that frame included, and then keeps going for as long as the pack
runs. With a probe pack of ours, Iris 1.11.7 kept smoothing across
teleports within a dimension and started again when the player entered
the Nether.

Fixes #10450
Part of #9307

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 09:53:24 +05:00
Arslan ArmanulyandClaude Opus 5.5 4c0ef1d7df Shader packs: colortex mipmaps blend their two nearest levels, as in Iris
A shader pack's colortex read between two mipmap levels (textureLod at a
fractional level) took the nearest level: the buffers' sampler had the
default, nearest, mipmap filter. In Iris 1.11.7 our probe packs read every
colortex, in composite and in final, as the two nearest levels mixed. The
buffers now get a sampler of their own that blends them; shadowcolor and
the other textures keep theirs (not measured).

Fixes #10451
Part of #9307

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 09:53:24 +05:00
Horizon agentandClaude Opus 5.5 4b24e21a8a launcher: format the stamping test
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 05:49:06 +01:00
DUzzL ebafc91999 Use ghastling hurt and death sounds for baby HappyGhasts 2026-11-06 05:47:17 +01:00
Horizon agentandClaude Opus 5.5 1fe1d5e860 launcher: stamp staged native source files with the current time
A Nightly's source keeps its commit's mtimes when unpacked, and fs::copy
keeps them on Windows and macOS, so a changed engine file reached
native/stage/ older than the previous build and Cargo reused the old
horizon-core. sync now sets each written file's mtime to now; a stage
written before this is rebuilt once.

Fixes #10421

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 05:41:23 +01:00
masa-masa-0314andZCode 382f264b6a Realms: the Add Realm screen as vanilla's product page
The screen behind Add Realm is 26.3's `RealmsPdpScreen`: the header's
logo, the trial header, then the friends, minigames and private-server
entries with their 80x45 pictures and 182-wide text, scrolling between
the header and the footer, whose buttons are the PDP's own title (the
buy link) and Back.

Its old strings were dead: `mco.title` never existed, and 26.3's
deprecated.json removes `mco.selectServer.popup` and
`mco.selectServer.buy`, so the screen drew raw keys.

Co-Authored-By: ZCode <[email protected]>
2026-11-06 12:39:31 +08:00
Ludovic Marie 679f8c6e3f Merge pull request #10440 from horizon-rust/fix/options-emote-key
🤖 Agent: options.txt is vanilla's key list again; the emote key is saved as emoteKey in horizon-extras.txt and an old key_key.emote line is migrated, options parity passed on the desktop.
2026-11-06 05:18:23 +01:00
Horizon agentandClaude Sonnet 5.5 c6d5769e4f Options: rustfmt keybinds.rs
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-06 05:13:24 +01:00
Ben 1ad899c7b5 fix: embed Windows icon resources for cross builds 2026-11-06 04:13:14 +00:00
voidsip b9439b3508 Match Java text for tiny floats and doubles 2026-11-05 22:12:54 -06:00
Arslan ArmanulyandClaude Opus 5.5 6a8ea6fc18 Shader packs: the shadow pass draws terrain outside the pack's near and far planes
Our probe pack's Iris 1.11.7 shadow maps hold the terrain at every depth we
put it along the light (from 535 blocks beyond the shadow camera to about 120
before it), whatever shadowNearPlane and shadowFarPlane the pack sets, or
none. Horizon dropped the sections outside the shadow projection's near and
far planes; since #9768 those are the pack's own planes with no offset, so
in our probe's cave scene with the planes at 0.05 and 256 the surface toward
the light was missing from the shadow map. A shaders/ pack's shadow pass now
culls by the box's sides only; Horizon's own packs keep their planes.

Fixes #10403

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 09:06:26 +05:00
Florian ERNSTandCodex 1e1c6a900f Decode numeric damage components with Codec.INT semantics
Co-Authored-By: Codex <[email protected]>
2026-11-06 05:03:08 +01:00
voidsip ff381b5dc4 fix(server): reject unreadable lists in stopped moderation commands 2026-11-05 22:02:46 -06:00
HiuoyandClaude Opus 5.5 7dc8ba568f Native mods: entity model layers built in code, baked and drawn (#10425)
A NeoForge mod builds its entity models in code (createBodyLayer: a
MeshDefinition of CubeListBuilder boxes at PartPoses, a LayerDefinition),
registers them in EntityRenderersEvent.RegisterLayerDefinitions, bakes them
with EntityModelSet.bakeLayer and draws the ModelPart tree it posed in
setupAnim with ModelPart.render. Native mods get the same:

- the builders and ModelPart, ported from vanilla (mobs/model_layers.rs):
  box UV from texOffs, mirror, deformation, texture scale and visible faces
  as ModelPart.Cube bakes them; poses, reset_pose, get_child;
- native_layer_definitions, a collect hook asked once at start, keyed by
  ModelLayerLocation ids in the mod's own namespace;
- native_bake_layer: a registered layer, or vanilla's by its
  entity-models.json name (minecraft:zombie#main);
- native_draw_model_part: ModelPart.render under a mob's or block entity's
  frame, textured from an atlas sprite.

The humanoid mesh built with these builders bakes to exactly vanilla's
minecraft:zombie#main (an ignored test against the prepared resources).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 08:02:00 +04:00
Ludovic Marie a1710b6962 Merge pull request #10429 from horizon-rust/perf/seen-snapshot
🤖 Agent: the kept buffer is cleared at the end of every tick, so no villager Arc<villager::Seen> outlives the tick, and #[inline] on Mob::seen only changes where each Seen is written (in its slot instead of a stack copy): same values, same order. creature_prepare 1.2 → 0.8 ms in both A/B rounds.
2026-11-06 04:56:53 +01:00
voidsip e7956591f3 Match vanilla replacement decoding for packet strings 2026-11-05 21:53:19 -06:00
Horizon agentandClaude Sonnet 5.5 4b352e89c1 Options: the emote key tests read a versioned options.txt, rustfmt
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-06 04:52:55 +01:00
Horizon agent 2726d8040b Merge remote-tracking branch 'origin/main' into perf/seen-snapshot
# Conflicts:
#	crates/horizon-client/src/game/creatures.rs
2026-11-06 04:52:00 +01:00
Horizon agentandClaude Sonnet 5.5 6da74ac228 Options: the emote key lives in horizon-extras.txt, options.txt is vanilla's again
Options parity regression from #10092: key.emote was in the vanilla key list, so options.txt
gained a key_key.emote line and every key after it shifted. It is now a Horizon-only mapping
(HORIZON_MAPPINGS): bound in Controls and translated like the others, saved as emoteKey in
horizon-extras.txt. A key_key.emote line written by the broken build is read once and dropped.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-06 04:44:03 +01:00
voidsip 6716cb7407 Merge remote-tracking branch 'refs/remotes/validation/worker7-main' into fix/4545-boat-paddle-transition
# Conflicts:
#	crates/horizon-client/src/game/remote_vehicles.rs
2026-11-05 21:43:23 -06: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
voidsip 8181ce0222 Reset mob fall distance before travel 2026-11-05 21:42:45 -06:00
Ludovic Marie 76b8b4eb83 Merge pull request #10423 from horizon-rust/fix/sculk-ticking-mobs
🤖 Agent: a mob that did not tick still gets its last position refreshed (no step), so one moved by a command while out of range doesn't count a walk or a landing once it ticks again; damage and mount events stay for every mob since vanilla raises them from hurt/startRiding. Proven on vanilla 26.3 with ore_probe2.py (settled pig/item stop lighting redstone ore once the player is 6 chunks away).
2026-11-06 04:40:22 +01:00
Florian ERNSTandCodex 6303017b03 Classify the verified hostile sound families as hostile
Co-Authored-By: Codex <[email protected]>
2026-11-06 04:38:19 +01:00
Horizon agent e16a5ef0da Merge remote-tracking branch 'origin/main' into fix/sculk-ticking-mobs 2026-11-06 04:37:28 +01:00
voidsip 76addba6cc Tick remote boat paddles and stop empty boats rowing 2026-11-05 21:36:53 -06:00
Skr1zzzz cc643f4af4 Random ticks: a section's candidate mask classifies each palette entry once
Rebuilding a changed or new section's random-tick candidate mask asked
whether the block state ticks randomly for each of its 4,096 blocks. A local
palette holds a handful of states: each entry is now classified once and the
mask built from the decoded palette indices. Single-value and global
palettes keep the previous path. Same mask, same draws.
2026-11-06 06:35:23 +03:00
TheMrZZandCodex gpt-6.1-sol c843c1aa05 Save the modeled mob loot-pickup flag
Write CanPickUpLoot on every save through the existing virtual getter, preserving the Allay and sulfur-cube overrides instead of retaining stale raw bytes. Cover decoded and mutated models plus frozen actual command reads.

Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-06 04:30:58 +01:00
Skr1zzzz 283c6723c0 Natural spawning: a start's height ends at the column's first empty block
NaturalSpawner.getRandomPosWithin draws Y in [minY, getHeight(WORLD_SURFACE)
+ 1], and ChunkAccess.getHeight is getFirstAvailable - 1: the top is the
first empty block. Horizon's world_surface already returns that block and
the spawner still added one, so every start could land one block higher
than vanilla's, in the air above the surface, where vanilla's draw never
goes; nextInt's bound was one larger too, so the draws differed from
vanilla's from there on.
2026-11-06 06:22:19 +03:00
Ludovic Marie 1532c9c153 Merge pull request #10331 from horizon-rust/perf/lan-entity-state-cache
🤖 Agent: idle entities (outside the simulation-level entity-ticking range) no longer run sendChanges, as in vanilla's ChunkMap.tick, so their 60-tick MoveEntityPos stops; a bare state (no data) is never paired, and the idle check follows the tracker's viewers (placed or held). tick_lan −1.66 ms (−24 %) in 3 A/B pairs.
2026-11-06 04:22:17 +01:00
voidsip 23a880843d Preserve custom model data flags through the item render bridge 2026-11-05 21:20:17 -06:00
voidsip 7b4634a912 Fix client steering for saddled pigs and striders 2026-11-05 21:20:10 -06:00
Ludovic Marie 2daa740748 Merge pull request #10412 from horizon-rust/fix/remote-player-offhand-nametag
🤖 Agent: queued. Every tracked entity now records what it rides, so any passenger (item/block display included) hides the player's tag as vanilla's isVehicle does; one risk checked: a removed vehicle leaves a stale id in passengers' vehicle, harmless since tags look up live entities.
2026-11-06 04:19:50 +01:00
Ludovic Marie cd547654d9 Merge pull request #10406 from horizon-rust/fix/aistep-ticking-mobs
🤖 Agent: the gate is the set of mobs the loop actually ticked (Creatures::ticked), not a second ticking-area test, so a mob that walks out of range during its own tick still finishes its pickups as in vanilla; proven against a vanilla 26.3 server (pickup_probe.py: only the ticking villager and zombie take their item).
2026-11-06 04:08:51 +01:00
Horizon agentandClaude Opus 5.5 0ed3868329 Name tags: any passenger hides its vehicle's name, as isVehicle does
Vanilla's LivingEntityRenderer.shouldShowName hides a name tag for any
entity with a passenger (`!entity.isVehicle()`). The client only counted
mobs, text displays, armor stands and interactions as passengers, so a
server that hangs its own name over a player with an item display, a
block display or any other entity still got the player's tag drawn
under it: two names (#5666).

Every tracked entity now records the vehicle SetPassengers puts it on,
and the name tags read that, whatever the passenger's type. The typed
lists stay for placing the riders.

The rest of #5666 was fixed already on main: the sword drawn in the
off hand by #5111 (each remote hand renders its own stack), the double
tag of custom-named players by #8457, and text display and armor stand
riders by #9754.

Fixes #5666

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 04:07:46 +01:00
SenjinandClaude Opus 5.5 7b80ab60c7 Shader packs: free the old buffers before a resize makes the new ones
A pack with large buffers (SEUS PTGI at 3440x1440) held both sets at once while resizing and ran
out of video memory going back to fullscreen.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 04:00:58 +01:00
voidsip 6beb50ebcd Preserve boolean glint overrides in item model views 2026-11-05 20:58:38 -06:00
Horizon agent c1d16aabde Merge remote-tracking branch 'origin/main' into perf/seen-snapshot 2026-11-06 03:55:25 +01:00
Horizon agent c0c3096e85 Merge branch 'fix/aistep-ticking-mobs' into fix/sculk-ticking-mobs 2026-11-06 03:55:04 +01:00
Horizon agent 74150d5ef8 Merge remote-tracking branch 'origin/main' into fix/aistep-ticking-mobs 2026-11-06 03:54:43 +01: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
DUzzL dfb9757d97 Evoker fangs: honour scoreboard teams when deciding allies 2026-11-06 03:50:47 +01:00
Skr1zzzz a58d5700f3 Merge remote-tracking branch 'origin/main' into server-tick-spans
# Conflicts:
#	crates/horizon-client/src/game/creatures.rs
2026-11-06 05:50:36 +03:00
Skr1zzzz 9cdcf66e69 Spans: inside the server tick's mob spawning, block and creature steps
A slow tick named spawn_natural, tick_blocks and tick_creature_entities but
nothing inside them. With 100 bots they take ~11, ~21 and ~10 ms a tick, and
an index for spawn_selection's nearest-player shortlist saved nothing: the
time was elsewhere. Their steps now have laps (sn_*, tb_*, tc_*), and the
natural-spawn pass counts its attempts and spawns (sa_attempts/sa_spawns,
one note a tick). They show the attempt loop itself: ~18,850 attempts a tick
for ~3 spawns (#3910). Only when HORIZON_SPIKES is set.
2026-11-06 05:48:23 +03:00
Horizon agentandClaude Opus 5.5 c442edabd9 Merge origin/main; idle states follow the tracker's viewers (held players too)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 03:43:53 +01:00
Florian ERNSTandCodex 29f04e40ed Pass the tool to inline fishing loot
Co-Authored-By: Codex <[email protected]>
2026-11-06 03:42:10 +01:00
Horizon agentandClaude Opus 5.5 04f6bd1214 Sculk events: only mobs and items that tick step, land and flap
Steps, landings, splashes, flaps and stepping on redstone ore come from
an entity's own tick in vanilla (Entity.move, applyEffectsFromBlocks
from LivingEntity.aiStep and ItemEntity.tick), and ServerLevel.tick
ticks only the entities in entity-ticking chunks. Here every loaded mob
and dropped item on the ground stepped on the block under it each tick:
a pig or an item that had settled on redstone ore kept lighting it after
the player went six chunks away.

Those events are now for the mobs tick_creature_entities ticked (and
those standing in the ticking area, as the sculk fixture posts before
the mobs tick) and the items in the ticking area. A mob that did not
tick keeps where it is as its last position, without a step. Damage and
mounting are raised by hurt and startRiding, so they stay for every mob.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 03:29:26 +01:00
TheMrZZandCodex gpt-6.1-sol 27d093c94f Use registered XP-orb UUID in saved command reads
Reuse the existing identity getter rather than generating a UUID per read. Cover repeated saved reads, absent raw input and stale raw UUIDs through the actual Game getter and command fixture.

Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-06 03:21:06 +01:00
voidsip 36c0d3f938 Predict fishing bobber block hits before client movement 2026-11-05 20:20:54 -06:00
DUzzL 6bd507f89d Treat bubble column as liquid when validating a respawn point 2026-11-06 03:20:54 +01:00
masa-masa-0314andZCode 7a846eebc6 Realms: the settings tab's render and simulation distances
RealmsSettingsTab's two distance sliders, fed by
getRealmTierConfiguration (asked until it answers, as
tierConfigurationFetched does) and saved with updateRenderDistance /
updateSimulationDistance only when a value changed, alongside
saveSettings' existing name, description and region update.

Co-Authored-By: ZCode <[email protected]>
2026-11-06 10:17:04 +08:00
DUzzL bd152b1ecf Piglins: compare armour and weapons when both or neither item is wanted 2026-11-06 03:13:01 +01:00
Florian ERNSTandCodex 6f9981bfbf Use the baby pig passenger attachment when seating riders
Co-Authored-By: Codex <[email protected]>
2026-11-06 03:10:51 +01:00
voidsip 6cf1b6b74c Keep command tags when converting mobs 2026-11-05 20:10:33 -06:00
DUzzL 12454e54bd Port ResetUniversalAngerTargetGoal to iron golems 2026-11-06 03:08:44 +01:00
Horizon agentandClaude Opus 5.5 6b2c83656c Mob snapshot: build each Seen in its slot and keep the buffer
tick_creature_entities snapshots every loaded mob (`Seen`, ~400 bytes)
before the loop and again after each mob's tick. Mob::seen lives in the
client lib and was not inlined into the game crate, so each one was
built on the stack and copied into its slot, and the snapshot's
megabytes were allocated and freed every tick.

Mob::seen is #[inline] now, and the emptied buffer stays in Creatures
for the next tick. The snapshot holds the same values.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 03:07:34 +01:00
voidsip d7e1eaf955 Apply melee extra knockback to boats 2026-11-05 20:06:28 -06:00
CocofireHDandopencode 7c57f5f906 prepared files: treat a killed export as unused
A killed exporter left an empty gameplay-facts.json (or another table) behind.
The preparer only checked that the path exists, so "prepare again" kept the
empty file, and the launcher called the folder prepared and started the game
on it. Trust an earlier export only when it still parses, and treat a folder
with an empty top-level JSON table as not prepared so Play prepares it again.

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-06 03:01:43 +01: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
Ludovic Marie 69fab597a6 Merge pull request #10203 from Detimes/feat-10174-mod-deps
🤖 Agent: Checked on the desktop against main 9f8f6842d (merges cleanly, lock passes --locked). All 19 new lock packages are permissive (MIT/Apache/BSD-3/ISC-style webpki/Zlib/BSL option on ryu); the only build scripts (mysql, mysql_common) just pass target env vars, no downloads; TLS is rustls+ring only, flate2 uses miniz_oxide, no openssl/native-tls/-sys/cmake. cargo tree for horizon-client (default, --no-default-features, --target all) is identical to main, so players' game and native-mod compile are unchanged; the launcher's cold cargo fetch gains 21 crates / +2.5 MB once (noise in wall time). Note for later: webpki 0.22 and rustls-pemfile are unmaintained upstream; worth moving to mysql 28 when it no longer rewrites the lock.
2026-11-06 02:45: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
voidsip 790e5f14d4 docs: index design server and release guides 2026-11-05 19:35:16 -06:00
CocofireHDandopencode 3c01dce30d resources: name the JSON file a parse error came from
serde's "expected value at line 1 column 1" says nothing about which of the
prepared folder's tables is damaged, so a player with a killed export only saw
a crash report. Read the core tables through horizon_world::json_file, which
prefixes the path, and have the startup check name a top-level table left
empty before the window opens.

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-06 02:28:51 +01:00
Horizon agentandClaude Opus 5.5 66dc3229a0 Mobs outside the simulation distance pick nothing up
Mob.aiStep's looting, the villagers' item sensor and the fox, dolphin,
panda, piglin, allay and sulfur cube item passes run after the mob loop
over every loaded mob. In vanilla they are part of the mob's own tick,
and ServerLevel.tick ticks only the entities in inEntityTickingRange: a
villager six chunks from the nearest player, loaded but outside the
simulation distance, took the bread at its feet here and not there.

The loop records the mobs whose tick ran (Creatures::ticked), and those
passes leave the others alone.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 02:20:47 +01:00
AxxioandAntigravity 2948fb86ed game/maps: honour can_place_on when using a map on a banner in adventure mode
In adventure mode, vanilla allows ItemStack.useOn if the item's can_place_on component matches the clicked block. Previously, use_map_on_banner checked only !self.may_build() and returned early without checking can_place_on.

This honours can_place_on_in_adventure on the banner's block position, allowing adventure mode players with matching maps to toggle banner markers.

Co-Authored-By: Antigravity <[email protected]>
2026-11-06 02:18:05 +01:00
Ludovic Marie 9f8f6842db Merge pull request #10366 from horizon-rust/perf/sculk-entity-events
🤖 Agent: each mob footstep scanned every mob three times (two id lookups and the warden/allay listener loop); now an event with no sculk block in reach and no warden or allay within 24 blocks skips the context and the listeners. Listeners give up past 16 blocks before any side effect, so vibrations are unchanged.
2026-11-06 02:16:56 +01:00
Ludovic Marie 1525ae0e3b Merge pull request #10364 from horizon-rust/fix/founder-claim-use-checkout
🤖 Agent: launcher-only fix. Maps the service's 403 use_checkout / not_offered_here to their own messages (86 tables), and drops one_click after use_checkout so the Shop opens the free checkout. Risk: none on the hot path; only the claim's error branch changes. Launcher tests 719 passed on the desktop.
2026-11-06 02:09:16 +01:00
Ludovic Marie 09450fb6f9 Merge pull request #10361 from horizon-rust/perf/mob-loop-overhead
🤖 Agent: villager_pickups tested every loaded villager against every dropped item, looking up tags before the reach: that was 12.8 of 23 ms of the mob phase in the load test (22.7 → 8.1 ms after this). The new ItemScan keeps the full scan's order and falls back to it if the item list changes under it.
2026-11-06 02:07:26 +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
Horizon agentandClaude Opus 5.5 53bc816fa1 Launcher: rustfmt the claim refusal test
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 02:03:19 +01:00
Horizon agentandClaude Opus 5.5 1411281eea Launcher: a refused founder claim says why and leads to the checkout
The cosmetics service answers POST /v1/claim with 403 use_checkout where
Paddle sells (the set goes through its free checkout there) and 403
not_offered_here where the claim is left out. The launcher mapped neither,
so a player saw "Horizon's cosmetics service had a problem" (Discord bug
thread, 2026-11-05).

- Both codes are the Shop's own refusals now, each with its own words, in
  all 86 lang tables.
- After use_checkout the saved answer drops one_click, so the Wardrobe stops
  offering "Claim for free" and the Shop's button opens the free checkout on
  playhorizon.dev.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 01:52:42 +01:00
Horizon agent 92bc3bc40f Merge remote-tracking branch 'origin/main' into perf/sculk-entity-events 2026-11-06 01:50:16 +01:00
Horizon agent d6d7cc3e29 Merge remote-tracking branch 'origin/main' into perf/mob-loop-overhead 2026-11-06 01:49:56 +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
AxxioandAntigravity b08f1074b1 docs: remove stale "not ported" notes
Co-Authored-By: Antigravity <[email protected]>
2026-11-06 01:28:10 +01:00
AxxioandAntigravity 9a076b6a24 docs/README.md: index missing player, launcher and platform pages
Co-Authored-By: Antigravity <[email protected]>
2026-11-06 01:26:57 +01:00
Horizon agentandClaude Opus 5.5 b40c996fb9 Mob looting: walk only the items in the cells a reach meets
A mob near some dropped item tested every item on the level (2,000 in
the load test: 1.5 ms a tick). ItemAreas now keeps the items of each
cell, and ItemScan walks the list in its order from one item in a met
cell to the next, testing what the scan tested; it keeps up with the
items the walk removes, and tests every item from there on if the list
changes any other way. The villagers' pickups walk the same way.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 01:26:11 +01: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
Horizon agent 99e1caf4db Merge remote-tracking branch 'origin/main' into perf/sculk-entity-events 2026-11-06 01:22:32 +01:00
Horizon agent 400335608e Merge remote-tracking branch 'origin/main' into perf/mob-loop-overhead 2026-11-06 01:22:31 +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 89ca6821b8 test: keep docs/README.md links and benchmark index resolving
Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-06 01:10:13 +01:00
CocofireHDandopencode 4eafef1180 docs: index the benchmark and performance result pages
Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-06 01:10:11 +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
Horizon agentandClaude Opus 5.5 e82ac45192 Format the idle-entity change
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 01:04:26 +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
Horizon agentandClaude Opus 5.5 153044c095 Sculk: a mob's game event costs nothing when nothing can hear it
tick_mob_sculk_events posts every loaded mob's footsteps, landings and
splashes. Each event looked its mob up by a scan of every mob (for the
context and the supporting block) and asked every mob whether it was a
warden or an allay, so a server with thousands of mobs paid three scans
of them for each footstep near no listener.

Now, as GameEventDispatcher only visits the listeners around the event:
while the mobs' events are posted, mobs are found by id through a slot
map, the wardens and allays are listed once with their positions, and
an event with no sculk block in reach and no warden or allay within 24
blocks skips the context and the listeners. A mob that stands as it
stood with no step due (every mob outside the simulation distance)
leaves at once, and a mob riding nothing, as before, skips the mount
bookkeeping. Mods still get every event (native_game_event).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 00:43:01 +01:00
Horizon agentandClaude Opus 5.5 1d8a1699ad Mob loop: villagers test reach before item tags, no player view copies
villager_pickups tested every loaded villager against every dropped item,
looking the item up in the villager food table and tags before the reach:
on a server with hundreds of villagers and thousands of items that was
most of the mob phase. The reach comes first now, and a villager with no
item in its cells skips the scan (ItemAreas, as the looting passes do).

Mobs outside the simulation distance only check despawning, which reads
the nearest player and the difficulty: they no longer copy the player
view they see. A ticking mob borrows its guest's view for its tick (the
tick reads it through &MobWorld) instead of cloning it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 00:30:30 +01:00
Horizon agent 4f3355c151 Merge remote-tracking branch 'origin/main' into perf/lan-entity-state-cache 2026-11-06 00:26:38 +01:00
Horizon agentandClaude Opus 5.5 7dcc5b46f3 Server: send idle entities' changes as ChunkMap does, describe them bare (#10253)
ChunkMap.tick runs ServerEntity.sendChanges only for an entity that changed
section, needs sync, or is in DistanceManager.inEntityTickingRange. Horizon
ran it for every tracked entity, so an idle one sent its position every 60
ticks and advanced its update clock. EntityState::idle now carries the
range (the simulation level, Levels::in_entity_ticking_range), and the
tracker skips sendChanges for an idle entity that stayed in its section and
needs no sync; a lead change still goes out at once (Leashable).

The game then stops describing such entities in full every tick: an idle
mob or item that was in the tracker's last pass at the same place, carries
no passengers, and is already tracked by every client within its tracking
range is described bare (where it is, none of its data). The tracker never
reads its data and never pairs a bare state, so every pairing still sends
the entity as it is (game/lan_idle.rs).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 00:21:37 +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
Horizon agentandClaude Opus 5.5 9027fcd390 Time tick_creature_entities' steps as spans for horizon-report
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 23:07:47 +01: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
009378de29 Stop the underwater sky scan on kelp and seagrass.
canSeeSkyFromBelowWater stops when a block dampens light and is not liquid(). Kelp and seagrass hold water, so guardians under them were still rolling the 1-in-20 and spawned less often.

Co-Authored-By: Grok <grok-4.7> <[email protected]>
Co-authored-by: Cursor <[email protected]>
2026-11-05 22:53:40 +01:00
b9607ce998 Replace a bubble column when a bucket empties into it.
emptyContents destroys the block only when it is not liquid(). A bubble column is liquid and not a LiquidBlock, so breaking it first played destroy particles and a sculk vibration.

Co-Authored-By: Grok <grok-4.7> <[email protected]>
Co-authored-by: Cursor <[email protected]>
2026-11-05 22:53:40 +01: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
DUzzL cb7babf1cf Normalize END records whose disk numbers are equal but not zero 2026-11-05 22:32:18 +01: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
DUzzL a13106bc0b Format container tooltip lines with numbered translation arguments 2026-11-05 20:35:02 +01: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
DUzzL 878342f98e Keep a synched horse rear until its flag clears 2026-11-05 20:22:16 +01: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
DetimesandClaude Opus 5.5 6e083af988 Native mods: crates/horizon-mod-deps puts mysql, redis, tungstenite and rusty-opus in Cargo.lock (#10174)
A workspace member with no code whose dependencies are all optional: Cargo
locks them, so a native mod's crate may name them, and no build compiles them
(horizon-client's dependency tree, default and --no-default-features, is
unchanged). All four are pure Rust and use the rustls + ring already locked.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 19:11:58 +00: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 7fcf18b6df test: /forceload add loads the chunk for the next command
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 20:44:38 +02:00
KBroneZandClaude Opus 5.5 ed01ae3029 fix: handle the container loot table event in the block update probe
Goes with "keep a placed template's container loot table".

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 20:44:37 +02:00
KBroneZandClaude Opus 5.5 a9c7b82772 fix: load chunks in /forceload add, as vanilla does
`ServerLevel.setChunkForced` calls `getChunk` when a chunk becomes forced, so a
command right after `/forceload add` finds it loaded. Horizon only queued it, so
`execute in minecraft:the_nether run function` with `forceload add`, `place
jigsaw` failed with "That position is not loaded" and a skyblock pack's nether
island never appeared.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 20:44:37 +02: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 2f9ec5be67 test: a placed template's chest keeps its loot table
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 20:44:36 +02:00
KBroneZandClaude Opus 5.5 cf31690cae fix: keep a placed template's container loot table
`LevelAccess::load_block_entity` decoded a template's block entity, and that
drops `LootTable`, so a chest placed by `/place template`, `/place jigsaw` or a
structure block came out empty. A skyblock pack's starter chest has its items in
a loot table. Report the table as a level event and hand it to `take_loot_table`,
as `/setblock` and `/data` do (`RandomizableContainer.tryLoadLootTable`).

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
KBroneZandClaude Opus 5.5 5062da5567 fix: upgrade a 26.2 noise with no octaves to a silent 26.3 noise
Void packs turn off vanilla's iceberg noises with
`{"firstOctave": -1000, "amplitudes": []}`. The upgrader left this as it was and
called it "more than 32 octaves or negative amplitudes"; 26.3 cannot read it, so
the world failed to start with `invalid world generation field: base_octave`.
With no octaves the noise is 0 everywhere, which 26.3 writes as one muted octave:
`{"base_octave": 0, "amplitude_modifiers": [0.0]}`.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 20:44:34 +02:00
KBroneZandClaude Opus 5.5 69ecedfc01 fix: read default_block and default_fluid in 26.3's block state form
`state()` in the generator read only `{"Name", "Properties"}`, or the id alone,
so noise settings with `{"id", "properties"}` failed the world start with
`invalid generator resources: block state`. That is the form 26.3 uses and the
one `horizon-port-pack upgrade-datapack` writes for any non-default state. Decode
these two fields with `BlockStateJsonCodec`, as the rest of worldgen does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 20:44:34 +02:00
DUzzL f2dfddea11 Handle F3 debug keys on the title screen 2026-11-05 19:44:34 +01: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
DUzzL 4272d9ae41 Mobs on a ladder or vine are not pushable; no crit while climbing 2026-11-05 18:47:31 +01: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
479 changed files with 26734 additions and 1976 deletions

No files matched your search

Generated
+203
View File
@@ -147,6 +147,12 @@ dependencies = [
"x11rb",
]
[[package]]
name = "arcstr"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "03918c3dbd7701a85c6b9887732e2921175f26c350b4563841d0958c21d57e6d"
[[package]]
name = "arrayref"
version = "0.3.9"
@@ -303,6 +309,21 @@ dependencies = [
"objc2 0.6.5",
]
[[package]]
name = "btoi"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b5ab9db53bcda568284df0fd39f6eac24ad6f7ba7ff1168b9e76eba6576b976"
dependencies = [
"num-traits",
]
[[package]]
name = "bufstream"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40e38929add23cdf8a366df9b0e088953150724bcbe5fc330b0d8eb3b328eec8"
[[package]]
name = "bumpalo"
version = "3.20.3"
@@ -657,6 +678,15 @@ dependencies = [
"cfg-if",
]
[[package]]
name = "crossbeam-queue"
version = "0.3.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "03e8bd762f7479489c70ed6c768ddca99d7296857de437a68dcb2a94365b3fae"
dependencies = [
"crossbeam-utils",
]
[[package]]
name = "crossbeam-utils"
version = "0.8.23"
@@ -774,6 +804,17 @@ version = "0.5.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e9de72ce2ad1f90dc62fa25f0f430ef85eb4b0d8fa0be4f30373bc40a21d28e"
[[package]]
name = "derive_utils"
version = "0.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc05a5d33db20c784f873e84934ad94bb209a090987ac5f62fede2c178234f23"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.6",
]
[[package]]
name = "des"
version = "0.8.1"
@@ -1615,6 +1656,16 @@ dependencies = [
"serde_json",
]
[[package]]
name = "horizon-mod-deps"
version = "0.1.0"
dependencies = [
"mysql",
"redis",
"rusty-opus",
"tungstenite",
]
[[package]]
name = "horizon-mods"
version = "0.1.0"
@@ -2010,6 +2061,15 @@ dependencies = [
"generic-array",
]
[[package]]
name = "io-enum"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f859dc81e05e585e93cfc84dffa4fd068d0651ca229c51bca6101c056b63b93a"
dependencies = [
"derive_utils",
]
[[package]]
name = "ipconfig"
version = "0.3.4"
@@ -2257,6 +2317,12 @@ version = "0.4.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
[[package]]
name = "lru"
version = "0.16.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39"
[[package]]
name = "lz4_flex"
version = "0.14.0"
@@ -2346,6 +2412,59 @@ version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d"
[[package]]
name = "mysql"
version = "27.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "090068bc0a24762663338da78414148333d1ee246a7293c10a517ddf1548b291"
dependencies = [
"bufstream",
"bytes",
"crossbeam-queue",
"crossbeam-utils",
"flate2",
"io-enum",
"libc",
"lru",
"mysql_common",
"named_pipe",
"pem",
"percent-encoding",
"rustls",
"rustls-pemfile",
"socket2 0.6.5",
"twox-hash",
"url",
"webpki",
"webpki-roots 1.0.10",
]
[[package]]
name = "mysql_common"
version = "0.36.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "20e7f6a15265801679ddf1ddb38fd74b4555a677a78378d4561fbe5f0b1d4d72"
dependencies = [
"base64",
"bitflags 2.13.2",
"btoi",
"byteorder",
"bytes",
"crc32fast",
"flate2",
"getrandom 0.3.4",
"num-bigint",
"num-traits",
"regex",
"saturating",
"serde",
"serde_json",
"sha1",
"sha2",
"thiserror 2.0.21",
"uuid",
]
[[package]]
name = "naga"
version = "30.0.1"
@@ -2383,6 +2502,15 @@ dependencies = [
"thiserror 2.0.21",
]
[[package]]
name = "named_pipe"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ad9c443cce91fc3e12f017290db75dde490d685cdaaf508d7159d7cf41f0eb2b"
dependencies = [
"winapi",
]
[[package]]
name = "ndk"
version = "0.9.0"
@@ -2982,6 +3110,16 @@ dependencies = [
"hmac",
]
[[package]]
name = "pem"
version = "3.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be"
dependencies = [
"base64",
"serde_core",
]
[[package]]
name = "pem-rfc7468"
version = "0.7.0"
@@ -3309,6 +3447,22 @@ dependencies = [
"yasna",
]
[[package]]
name = "redis"
version = "1.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db7f3d75b5a3301746bff39a767bf4c5c42506b08513527308b3875bfb64a031"
dependencies = [
"arcstr",
"combine",
"itoa",
"percent-encoding",
"ryu",
"socket2 0.6.5",
"url",
"xxhash-rust",
]
[[package]]
name = "redox_syscall"
version = "0.4.1"
@@ -3450,6 +3604,15 @@ dependencies = [
"zeroize",
]
[[package]]
name = "rustls-pemfile"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50"
dependencies = [
"rustls-pki-types",
]
[[package]]
name = "rustls-pki-types"
version = "1.15.1"
@@ -3476,6 +3639,12 @@ version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
[[package]]
name = "rusty-opus"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46604e6809c06441da1cfdde1f0cc25a4f2bf42c7d005aa387c2875c02d78762"
[[package]]
name = "ruzstd"
version = "0.9.1"
@@ -3485,6 +3654,12 @@ dependencies = [
"twox-hash",
]
[[package]]
name = "ryu"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
[[package]]
name = "salsa20"
version = "0.10.2"
@@ -3503,6 +3678,12 @@ dependencies = [
"winapi-util",
]
[[package]]
name = "saturating"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ece8e78b2f38ec51c51f5d475df0a7187ba5111b2a28bdc761ee05b075d40a71"
[[package]]
name = "scoped-tls"
version = "1.0.1"
@@ -4111,6 +4292,18 @@ version = "0.25.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31"
[[package]]
name = "tungstenite"
version = "0.30.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e48ac77174b19c110a50ab2128b24215ac9cb40e0e12e093fb602d175c569d22"
dependencies = [
"bytes",
"log",
"rand",
"thiserror 2.0.21",
]
[[package]]
name = "twox-hash"
version = "2.1.5"
@@ -4454,6 +4647,16 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "webpki"
version = "0.22.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed63aea5ce73d0ff405984102c42de94fc55a6b75765d621c65262469b3c9b53"
dependencies = [
"ring",
"untrusted",
]
[[package]]
name = "webpki-roots"
version = "0.26.11"
+1
View File
@@ -39,6 +39,7 @@ members = [
"crates/horizon-optifine",
"crates/horizon-shader-api",
"crates/horizon-release-sign",
"crates/horizon-mod-deps",
"xtask",
]
resolver = "2"
+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),
+8 -5
View File
@@ -11,7 +11,7 @@ macro_rules! actions {
$($variant,)*
}
impl Action {
/// In `Options.keyMappings` order, the pad-only actions last.
/// In `Options.keyMappings` order, then Horizon's own mappings, the pad-only actions last.
pub const ALL: &[Action] = &[$(Action::$variant,)*];
/// The saved name: vanilla's for a mapping, `pad.*` for Horizon's own.
pub fn name(self) -> &'static str {
@@ -49,7 +49,6 @@ actions! {
PickItem, "key.pickItem", None;
Command, "key.command", Some(KeyCode::Slash);
Friends, "key.friends", Some(KeyCode::KeyO);
Emote, "key.emote", Some(KeyCode::KeyB);
SocialInteractions, "key.socialInteractions", Some(KeyCode::KeyP);
ToggleGui, "key.toggleGui", Some(KeyCode::F1);
ToggleSpectatorShaderEffects, "key.toggleSpectatorShaderEffects", Some(KeyCode::F4);
@@ -96,6 +95,7 @@ actions! {
DebugNetworkCharts, "key.debug.networkCharts", None;
DebugLightmapTexture, "key.debug.lightmapTexture", None;
DebugImprovedTransparency, "key.debug.improvedTransparency", None;
Emote, "key.emote", Some(KeyCode::KeyB);
HotbarPrevious, "pad.hotbarPrevious", None;
HotbarNext, "pad.hotbarNext", None;
}
@@ -109,21 +109,24 @@ impl Action {
#[cfg(test)]
mod tests {
use super::*;
use crate::keybinds::{self, KeyBinds, MAPPINGS};
use crate::keybinds::{self, HORIZON_MAPPINGS, KeyBinds, MAPPINGS, all_mappings};
#[test]
fn every_mapping_is_an_action_in_order() {
let names: Vec<&str> = Action::ALL.iter().map(|a| a.name()).collect();
let mappings: Vec<&str> = MAPPINGS.iter().map(|m| m.0).collect();
assert_eq!(names[..mappings.len()], mappings[..]);
for action in &names[mappings.len()..] {
let horizon: Vec<&str> = HORIZON_MAPPINGS.iter().map(|m| m.0).collect();
let end = mappings.len() + horizon.len();
assert_eq!(names[mappings.len()..end], horizon[..]);
for action in &names[end..] {
assert!(action.starts_with("pad."), "{action}");
}
}
#[test]
fn an_actions_key_is_its_mappings_default_key() {
for (name, _, category) in MAPPINGS {
for (name, _, category) in all_mappings() {
let action = Action::parse(name).unwrap();
let want = if *category == "debug" {
None
+2 -2
View File
@@ -1742,11 +1742,11 @@ bind_key.hotbar.2:north
assert_eq!(s.actions_of(Button::North), ["key.inventory"]);
assert_eq!(s.actions_of(Button::RightBumper), [HOTBAR_NEXT]);
assert!(s.actions_of(Button::Start).is_empty());
// Every default action names a vanilla mapping or a pad action.
// Every default action names a key mapping or a pad action.
for (action, _) in ACTIONS {
assert!(
action.starts_with("pad.")
|| crate::keybinds::MAPPINGS.iter().any(|m| m.0 == *action),
|| crate::keybinds::all_mappings().any(|m| m.0 == *action),
"{action}"
);
}
+26 -16
View File
@@ -23,8 +23,6 @@ pub const MAPPINGS: &[(&str, &str, &str)] = &[
("key.pickItem", "key.mouse.middle", "gameplay"),
("key.command", "key.keyboard.slash", "multiplayer"),
("key.friends", "key.keyboard.o", "multiplayer"),
// Horizon's emote wheel (docs/cosmetics.md), on a key vanilla leaves free.
("key.emote", "key.keyboard.b", "multiplayer"),
("key.socialInteractions", "key.keyboard.p", "multiplayer"),
("key.toggleGui", "key.keyboard.f1", "misc"),
(
@@ -76,6 +74,17 @@ pub const MAPPINGS: &[(&str, &str, &str)] = &[
("key.debug.lightmapTexture", "key.keyboard.4", "debug"),
("key.debug.improvedTransparency", "key.keyboard.x", "debug"),
];
/// Horizon's own key mappings: bound in Controls like the vanilla ones, but kept in
/// `horizon-extras.txt` (`emoteKey`), never in `options.txt`, which stays exactly vanilla's
/// (`MAPPINGS` is what the options parity harness compares with the pinned client).
pub const HORIZON_MAPPINGS: &[(&str, &str, &str)] = &[
// The emote wheel (docs/cosmetics.md), on a key vanilla leaves free.
("key.emote", "key.keyboard.b", "multiplayer"),
];
/// Every mapping the game binds: vanilla's in `Options.keyMappings` order, then Horizon's.
pub fn all_mappings() -> impl Iterator<Item = &'static (&'static str, &'static str, &'static str)> {
MAPPINGS.iter().chain(HORIZON_MAPPINGS)
}
/// Mappings Options builds as `ToggleKeyMapping` (hold or toggle by option).
pub fn is_toggle(name: &str) -> bool {
matches!(name, "key.sneak" | "key.sprint" | "key.use" | "key.attack")
@@ -314,8 +323,7 @@ pub struct KeyBinds {
impl Default for KeyBinds {
fn default() -> Self {
Self {
keys: MAPPINGS
.iter()
keys: all_mappings()
.map(|(n, d, _)| (*n, (*d).to_owned()))
.collect(),
}
@@ -341,8 +349,7 @@ impl KeyBinds {
.map_or("key.keyboard.unknown", String::as_str)
}
pub fn default_of(name: &str) -> &'static str {
MAPPINGS
.iter()
all_mappings()
.find(|m| m.0 == name)
.map_or("key.keyboard.unknown", |m| m.1)
}
@@ -350,16 +357,24 @@ impl KeyBinds {
self.get(name) == Self::default_of(name)
}
pub fn set(&mut self, name: &str, key: &str) {
if let Some((n, _, _)) = MAPPINGS.iter().find(|m| m.0 == name) {
if let Some((n, _, _)) = all_mappings().find(|m| m.0 == name) {
self.keys.insert(n, key.to_owned());
}
}
/// The bindings `options.txt` holds: vanilla's mappings only.
pub fn saved(&self) -> Vec<(&'static str, String)> {
MAPPINGS
.iter()
.map(|(n, _, _)| (*n, self.get(n).to_owned()))
.collect()
}
/// Horizon's own bindings, for `horizon-extras.txt`.
pub fn horizon_saved(&self) -> Vec<(&'static str, String)> {
HORIZON_MAPPINGS
.iter()
.map(|(n, _, _)| (*n, self.get(n).to_owned()))
.collect()
}
pub fn reset_all(&mut self) {
*self = Self::default();
}
@@ -369,8 +384,7 @@ impl KeyBinds {
if key == "key.keyboard.unknown" {
return Vec::new();
}
MAPPINGS
.iter()
all_mappings()
.filter(|(n, _, _)| {
*n != name && self.get(n) == key && (!self.is_default(n) || !self.is_default(name))
})
@@ -384,8 +398,7 @@ impl KeyBinds {
/// Every mapping a physical input triggers, in list order: like
/// `KeyMapping.set`/`click`, one key drives all the mappings bound to it.
pub fn mappings_for<'a>(&'a self, key: &'a str) -> impl Iterator<Item = &'static str> + 'a {
MAPPINGS
.iter()
all_mappings()
.map(|m| m.0)
.filter(move |n| self.get(n) == key)
}
@@ -395,8 +408,7 @@ impl KeyBinds {
/// key and the binding itself); their defaults (A, S, D, T, L, 1 to 4...)
/// would otherwise turn the key back into its old gameplay meaning.
fn translated_mappings<'a>(&'a self, key: &'a str) -> impl Iterator<Item = &'static str> + 'a {
MAPPINGS
.iter()
all_mappings()
.filter(|m| m.2 != "debug")
.map(|m| m.0)
.filter(move |n| self.get(n) == key)
@@ -404,9 +416,7 @@ impl KeyBinds {
/// Whether a mapping (other than a debug key) whose default is `key` was
/// bound elsewhere.
fn default_moved(&self, key: &str) -> bool {
MAPPINGS
.iter()
.any(|(n, d, c)| *d == key && *c != "debug" && !self.is_default(n))
all_mappings().any(|(n, d, c)| *d == key && *c != "debug" && !self.is_default(n))
}
/// Translate a physical key into the default key of the mapping it is
/// bound to, so game code written against vanilla defaults follows the
+3 -12
View File
@@ -6,13 +6,7 @@ use horizon_world::{
worldgen::{tags::TagDefinitions, terrain::TerrainBlockStates},
};
use serde_json::Value;
use std::{
collections::BTreeSet,
fs::File,
io::{self, BufReader},
path::Path,
sync::Arc,
};
use std::{collections::BTreeSet, io, path::Path, sync::Arc};
pub const MATERIALS: [&str; 6] = ["bedrock", "dirt", "stone", "sand", "oak_planks", "bricks"];
#[derive(Clone, Copy)]
@@ -190,15 +184,12 @@ fn invalid(message: &str) -> io::Error {
}
impl Assets {
pub fn load(root: &Path) -> Result<Self> {
let manifest: Value = serde_json::from_reader(BufReader::new(File::open(
root.join("horizon-resources.json"),
)?))?;
let manifest: Value = horizon_world::json_file::read(&root.join("horizon-resources.json"))?;
if manifest["data_version"].as_i64() != Some(i64::from(DATA_VERSION)) {
return Err(invalid("resources do not match Horizon's pinned data version").into());
}
let (registry, flags) = horizon_world::level::data::load_registry_with_flags(root)?;
let bundle: Value =
serde_json::from_reader(BufReader::new(File::open(root.join("block-tags.json"))?))?;
let bundle: Value = horizon_world::json_file::read(&root.join("block-tags.json"))?;
let mut definitions = TagDefinitions::default();
for (name, text) in bundle
.as_object()
+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]| {
+1 -1
View File
@@ -288,7 +288,7 @@ impl Biomes {
.collect();
files.sort_by_cached_key(|f| format!("{}.json", f.name));
for file in files {
let v: Value = serde_json::from_slice(file.bytes()?)?;
let v: Value = horizon_world::json_file::parse(&file.path, file.bytes()?)?;
list.push(BiomeInfo::from_json(
format!("minecraft:{}", file.name),
&v,
@@ -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", "{}")]
@@ -3,7 +3,7 @@
//! celestial angles and star brightness for the overworld day cycle.
use crate::Result;
use serde_json::Value;
use std::{collections::HashMap, fs, path::Path};
use std::{collections::HashMap, path::Path};
#[derive(Clone, Copy, Debug)]
enum Easing {
@@ -151,13 +151,13 @@ impl Environment {
/// A dimension's base attributes, optionally driven by the day timeline.
pub fn load_dimension(root: &Path, dimension: &str, day_cycle: bool) -> Result<Self> {
let timeline: Value = if day_cycle {
serde_json::from_slice(&fs::read(root.join("data/minecraft/timeline/day.json"))?)?
horizon_world::json_file::read(&root.join("data/minecraft/timeline/day.json"))?
} else {
serde_json::json!({"period_ticks": 24000, "tracks": {}})
};
let dimension: Value = serde_json::from_slice(&fs::read(
root.join(format!("data/minecraft/dimension_type/{dimension}.json")),
)?)?;
let dimension: Value = horizon_world::json_file::read(
&root.join(format!("data/minecraft/dimension_type/{dimension}.json")),
)?;
let mut tracks = HashMap::new();
for (name, track) in timeline["tracks"].as_object().ok_or("invalid timeline")? {
let easing = match &track["ease"] {
+26 -6
View File
@@ -10,6 +10,9 @@ pub const FILE: &str = "horizon-extras.txt";
pub const SORT_KEY: &str = "horizon.sort";
/// The 3D skin layers line, here and in the `options.txt` that held it first.
const SKIN_LAYERS_KEY: &str = "threeDSkinLayers";
/// The emote wheel's key, here and in the `key_key.emote` line #10092 wrote to `options.txt`.
const EMOTE_KEY: &str = "emoteKey";
const OLD_EMOTE_LINE: &str = "key_key.emote";
/// The minimap's sizes in GUI pixels, and its zooms in blocks per pixel.
pub const MINIMAP_SIZES: [(u8, &str); 3] = [(64, "Small"), (96, "Medium"), (128, "Large")];
@@ -126,6 +129,10 @@ pub struct Extras {
pub sort: bool,
/// A vanilla key name: sorts the part under the cursor.
pub sort_key: String,
/// The emote wheel's key (`key.emote`), as the game last saved it. The live binding is
/// `Settings::keys`, bound in Controls with the vanilla ones; it is only kept in this file
/// so that `options.txt` stays vanilla's (`Settings::load` hands it over, `save` takes it back).
pub emote_key: String,
/// A new chat line slides in from the left while it fades in (`Chat::layout`).
pub chat_animation: bool,
/// The indicator bar at the left of a chat line (on by default, as vanilla).
@@ -220,6 +227,7 @@ impl Default for Extras {
mouse_tweaks_wheel: true,
sort: false,
sort_key: "key.keyboard.unknown".into(),
emote_key: crate::keybinds::KeyBinds::default_of("key.emote").into(),
chat_animation: false,
chat_indicator: true,
no_ai: false,
@@ -324,6 +332,7 @@ impl Extras {
"mouseTweaksWheel" => e.mouse_tweaks_wheel = flag(e.mouse_tweaks_wheel),
"sort" => e.sort = flag(e.sort),
"sortKey" if value.starts_with("key.") => e.sort_key = value.to_owned(),
EMOTE_KEY if value.starts_with("key.") => e.emote_key = value.to_owned(),
"chatAnimation" => e.chat_animation = flag(e.chat_animation),
"chatIndicator" => e.chat_indicator = flag(e.chat_indicator),
horizon_core::no_ai::KEY => e.no_ai = flag(e.no_ai),
@@ -430,26 +439,37 @@ impl Extras {
SKIN_LAYERS_KEY,
bool_text(self.three_d_skin_layers),
);
text.push_str(&format!("{EMOTE_KEY}:{}\n", self.emote_key));
self.pvp.serialize(&mut text);
text
}
/// Adopts the settings `options.txt` held before they moved here, while
/// this file (`own`) has no line of its own: 3D skin layers was an
/// `options.txt` line. Vanilla's `Options.save` drops lines it doesn't
/// this file (`own`) has no line of its own: 3D skin layers and the emote key were
/// `options.txt` lines. Vanilla's `Options.save` drops lines it doesn't
/// know, so it is read there once, the first start after the update.
pub fn read_old_options(&mut self, own: &str, options_txt: &str) {
fn line(text: &str) -> Option<&str> {
fn line<'a>(text: &'a str, name: &str) -> Option<&'a str> {
text.lines().find_map(|l| {
l.split_once(':')
.filter(|(key, _)| *key == SKIN_LAYERS_KEY)
.filter(|(key, _)| *key == name)
.map(|(_, value)| value)
})
}
if line(own).is_none()
&& let Some(value) = line(options_txt)
if line(own, SKIN_LAYERS_KEY).is_none()
&& let Some(value) = line(options_txt, SKIN_LAYERS_KEY)
{
self.three_d_skin_layers = value == "true";
}
// The emote key was a `key_key.emote` line of options.txt (#10092): a rebound key is kept.
if line(own, EMOTE_KEY).is_none()
&& let Some(value) = line(options_txt, OLD_EMOTE_LINE).map(str::trim)
&& value.starts_with("key.")
&& (value == "key.keyboard.unknown"
|| crate::keybinds::key_code(value).is_some()
|| crate::keybinds::mouse_button(value).is_some())
{
self.emote_key = value.to_owned();
}
}
/// Binds the extra key `name` (`SORT_KEY`); false for
/// another name (a vanilla mapping).
+126 -11
View File
@@ -5795,6 +5795,31 @@ impl Renderer {
blocks.iter().map(move |block| (block, fully_inside))
})
}
/// The block entities of the sections a shader pack's shadow pass drew in the last frame
/// (`shadow_visible`), within the 64 blocks `visible_block_entities_in_frustum` keeps.
/// Our probe packs' Iris 1.11.7 shadow maps hold a block entity wherever the shadow pass
/// draws its section's terrain, whether or not the camera's view takes it in (#8464).
pub fn shadow_pass_block_entities(
&self,
camera: DVec3,
) -> impl Iterator<Item = &([i32; 3], u32)> + '_ {
block_entities_within_reach(
self.shadow_visible.iter().map(|v| v.key),
&self.block_entities,
camera,
self.layout.min_y(),
)
}
/// Whether a shader pack's shadow pass drew the section holding block `pos` in the last
/// frame (`shadow_pass_block_entities`).
pub fn shadow_pass_drew(&self, pos: [i32; 3]) -> bool {
let key = (
pos[0] >> 4,
(pos[1] - self.layout.min_y()) >> 4,
pos[2] >> 4,
);
self.shadow_visible.iter().any(|v| v.key == key)
}
/// Milliseconds since `key` was uploaded; a section not in `section_uploaded` is old.
fn section_elapsed_ms(&self, key: SectionKey, now: std::time::Instant) -> u64 {
self.section_uploaded.get(&key).map_or(u64::MAX, |t| {
@@ -6609,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)?])
});
@@ -7636,9 +7655,7 @@ impl Renderer {
if let Some(v) = &shadow_view {
let radius = (frame.detail_distance / 16.0) as i32;
let light = ShadowCull {
light: Frustum {
planes: frustum(v.cull),
},
light: shadow_box(v),
casters: v.view_casters.then(|| shadow_casters(cull_proj, v.light)),
reach: v.distance,
};
@@ -11441,6 +11458,39 @@ impl Cull for ShadowCull {
self.casters.is_none()
}
}
/// The block entities of `sections` whose box, a block wider on every side, comes within the
/// 64 blocks block entities are drawn to (`Renderer::visible_block_entities_in_frustum`).
fn block_entities_within_reach<'a>(
sections: impl Iterator<Item = SectionKey> + 'a,
block_entities: &'a FxHashMap<SectionKey, Vec<([i32; 3], u32)>>,
camera: DVec3,
min_y: i32,
) -> impl Iterator<Item = &'a ([i32; 3], u32)> + 'a {
sections
.filter_map(move |key| {
let min = (DVec3::new(
f64::from(key.0 * 16 - 1),
f64::from(min_y + key.1 * 16 - 1),
f64::from(key.2 * 16 - 1),
) - camera)
.as_vec3();
let max = min + Vec3::splat(18.0);
let near = Vec3::ZERO.max(min).min(max).length_squared() <= 64.0 * 64.0;
near.then(|| block_entities.get(&key)).flatten()
})
.flatten()
}
/// The shadow camera's box that the shadow pass draws terrain in: the sides of `view.cull`, and
/// its near and far planes only with `depth_planes`.
fn shadow_box(view: &pack_post::ShadowView) -> Frustum {
let mut planes = frustum(view.cull);
if !view.depth_planes {
// `frustum`'s faces at depth 0 and 1 take in everything.
planes[4] = Vec4::W;
planes[5] = Vec4::W;
}
Frustum { planes }
}
/// Camera-relative clip planes (`Frustum`).
#[derive(Clone, Copy, Debug)]
pub struct Frustum {
@@ -13412,6 +13462,7 @@ mod shadow_face_tests {
unculled: None,
every_face,
view_casters: true,
depth_planes: false,
}
}
@@ -13431,6 +13482,24 @@ mod shadow_face_tests {
mod shadow_caster_tests {
use super::*;
#[test]
fn the_shadow_pass_takes_the_block_entities_of_its_sections_within_64_blocks() {
// Sections 64 to 79 high (the world from -64): a chest in each of three. The shadow
// pass drew `near` and `far`; `left_out` lies as near but the pass didn't draw it, and
// `far`'s nearest block is 78.5 blocks away.
let (near, left_out, far) = ((0, 8, 0), (1, 8, 0), (5, 8, 0));
let mut block_entities = FxHashMap::default();
block_entities.insert(near, vec![([3, 70, 3], 7)]);
block_entities.insert(left_out, vec![([20, 70, 3], 7)]);
block_entities.insert(far, vec![([85, 70, 3], 7)]);
let camera = DVec3::new(0.5, 70.0, 0.5);
let drawn = [near, far].into_iter();
let taken: Vec<_> = block_entities_within_reach(drawn, &block_entities, camera, -64)
.copied()
.collect();
assert_eq!(taken, vec![([3, 70, 3], 7)]);
}
/// What the shadow pass keeps of a camera-relative section at `min`, the camera at the
/// origin looking along -X (yaw 90) with the scenes' 70 degree, 16:9 view, and the light in
/// direction `light`; the light's own box takes in everything.
@@ -13585,6 +13654,52 @@ mod shadow_caster_tests {
);
}
/// Our probe pack's Iris 1.11.7 shadow maps, under a noon sun with `shadowDistance` 128, hold
/// the terrain at every depth along the light whatever planes the pack gives (#10403): from
/// a cave at y -7, the surface's sections ~70 blocks toward the light with `shadowNearPlane`
/// 0.05 and `shadowFarPlane` 256; from y 600, the ground ~535 blocks away with no planes set;
/// and both with the planes at 200 and 210. Only the box's sides cull.
#[test]
fn the_shadow_pass_draws_terrain_outside_the_pack_s_planes() {
let kept = |near: Option<f32>, far: Option<f32>, y: f64, min: [f32; 3], depth: bool| {
let shadow = horizon_shaders::Shadow {
distance: 128.0,
near,
far,
..horizon_shaders::Shadow::default()
};
let camera = DVec3::new(13.5, y + 1.62, -13.5);
let view = pack_post::ShadowView {
cull: pack_uniforms::shadow_culling_at(Vec3::Y, camera, &shadow),
light: Vec3::Y,
distance: None,
unculled: None,
every_face: true,
view_casters: false,
depth_planes: depth,
};
let cull = ShadowCull {
light: shadow_box(&view),
casters: None,
reach: None,
};
let min = Vec3::from(min);
cull.intersects(min, min + 16.0)
};
// The section holding the surface (y 64..80) above the cave, and the ground under y 600.
let above = [-8.0, 64.0 - (-7.0 + 1.62), -8.0];
let below = [-8.0, 64.0 - (600.0 + 1.62), -8.0];
assert!(kept(Some(0.05), Some(256.0), -7.0, above, false));
assert!(kept(None, None, 600.0, below, false));
assert!(kept(Some(200.0), Some(210.0), -7.0, above, false));
assert!(kept(Some(200.0), Some(210.0), 600.0, below, false));
// A section 200 blocks off to the side is still outside the box.
let side = [200.0, 0.0, -8.0];
assert!(!kept(Some(0.05), Some(256.0), -7.0, side, false));
// Horizon's own packs keep culling by their planes.
assert!(!kept(Some(0.05), Some(256.0), -7.0, above, true));
}
/// The margin doesn't follow the field of view: from the same camera under a noon sun, our
/// probe's Iris 1.11.7 maps keep a section that needs a growth of 1.1224 blocks at a field of
/// view of 70 (77° flying) and drop ones that need 1.1252 to 1.1422 at 50 (55° flying) and
+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) =>
@@ -368,6 +368,7 @@ impl NativeFrame {
unculled: None,
every_face: false,
view_casters: false,
depth_planes: true,
})
}
@@ -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(
@@ -610,13 +677,17 @@ impl PackAtlas {
// A vertex's place in its section goes down to the 32768th of a block below it: read back
// with a probe pack of ours, Iris 1.11.7's terrain `gl_Vertex` has that for every position
// we read (water tops at 8/9 - 0.001, plants' offsets), on all three axes (#8464). A fit,
// not Iris's code. Whole and half blocks, and every 1/16, are on that grid already.
// not Iris's code. Whole and half blocks, and every 1/16, are on that grid already. A place
// up to 1/32 of a 32768th (one float step below 16) under a grid line is on it: plants whose
// random offset is 9/15 of a block read exactly on a half block in Iris, and a 32768th
// under it in Horizon (our guess, not tested: Horizon's float sum lands just under the
// line) (#8464). Also a fit.
let body = format!(
" vec4 hz_origin = {origin};\n \
uint hz_layer = hz_v_layer & 32767u;\n \
{cell}\
hz_scale = vec2({:?}, {:?}) * {side};\n \
hz_Vertex = vec4(floor(hz_v_pos * 32768.0) / 32768.0 + hz_origin.xyz, 1.0);\n \
hz_Vertex = vec4(floor(hz_v_pos * 32768.0 + 0.03125) / 32768.0 + hz_origin.xyz, 1.0);\n \
hz_uv_scale = hz_origin.w > 0.0 ? uintBitsToFloat(floatBitsToUint(hz_origin.w) & 0xff800000u) : fract(abs(hz_origin.w));\n \
hz_mid = vec2(0.5);\n \
hz_MultiTexCoord0 = vec4((hz_cell + clamp(vec2(hz_v_uv) * hz_uv_scale, 0.0, 1.0)) * hz_scale, 0.0, 1.0);\n \
@@ -637,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).
@@ -890,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();
@@ -174,7 +174,8 @@ fn flat_draws_map_uvs_onto_a_stitched_sprite() {
/// Read back with a probe pack of ours, Iris 1.11.7's terrain `gl_Vertex` (camera on the block
/// grid) puts a water top, 8/9 - 0.001 of a block up, at 29094/32768, and plants offset by 13/15
/// and 0.1 of a block at 28398/32768 and 3276/32768 (#8464): each 32768th below the position.
/// Whole blocks and sixteenths stay where they are.
/// Whole blocks and sixteenths stay where they are, and so does a place float rounding put a hair
/// below a grid line: Iris reads plants offset by 9/15 exactly on a half block.
#[test]
fn terrain_vertices_go_down_to_the_32768th_below_them() {
let instance = crate::gfx::test_gpu::instance();
@@ -204,6 +205,15 @@ fn terrain_vertices_go_down_to_the_32768th_below_them() {
([0.1, water, 13.0 / 15.0], [3276.0, 29094.0, 28398.0]),
([13.0 / 15.0, 0.1, water], [28398.0, 3276.0, 29094.0]),
([1.0, 0.5, 15.9375], [32768.0, 16384.0, 522240.0]),
// One float step below 1, 0.5 and 9.5.
(
[
1.0 - f32::EPSILON / 2.0,
0.5 - f32::EPSILON / 4.0,
9.5 - f32::EPSILON * 8.0,
],
[32768.0, 16384.0, 311296.0],
),
];
let vertices: Vec<_> = cases
.iter()
@@ -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 {
@@ -113,6 +119,8 @@ enum SamplerKind {
NearestRepeat,
/// The block atlas: nearest texels, blended between the two nearest levels.
Atlas,
/// colortex buffers: linear, blended between the two nearest levels.
Buffer,
Comparison,
}
@@ -140,15 +148,18 @@ impl SamplerKind {
(true, false) => Self::Repeat,
(false, false) => Self::NearestRepeat,
},
Source::Color(_) | Source::ShadowColor(_) | Source::Dummy | Source::Lightmap => {
Self::Linear
}
// Iris 1.11.7 blends a buffer's two nearest mipmap levels (our probe packs, #9307:
// every colortex read at levels 1.25 and 2.5 in composite and final).
Source::Color(_) => Self::Buffer,
Source::ShadowColor(_) | Source::Dummy | Source::Lightmap => Self::Linear,
}
}
fn binding_type(self) -> wgpu::SamplerBindingType {
match self {
Self::Linear | Self::Repeat | Self::Atlas => wgpu::SamplerBindingType::Filtering,
Self::Linear | Self::Repeat | Self::Atlas | Self::Buffer => {
wgpu::SamplerBindingType::Filtering
}
Self::Nearest | Self::NearestRepeat => wgpu::SamplerBindingType::NonFiltering,
Self::Comparison => wgpu::SamplerBindingType::Comparison,
}
@@ -472,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.
@@ -550,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.
@@ -660,6 +675,8 @@ pub struct PackPost {
repeat: wgpu::Sampler,
nearest_repeat: wgpu::Sampler,
atlas_sampler: wgpu::Sampler,
/// colortex buffers' (`SamplerKind::Buffer`).
buffer_sampler: wgpu::Sampler,
/// None when it would write nothing: colortex0 holds integers and there is no depth to copy
/// nor buffer to clear.
blit: Option<(wgpu::RenderPipeline, wgpu::BindGroupLayout)>,
@@ -1131,6 +1148,11 @@ pub(super) struct ShadowView {
/// maps fit keeping only those with `shadow.culling` unset (#8464): under a noon sun, only
/// the terrain above the ground the camera sees; with a low sun ahead, none behind the camera.
pub view_casters: bool,
/// Whether the projection's near and far planes in `cull` drop sections too. Our probes'
/// Iris 1.11.7 maps hold the terrain at every depth we put it along the light (from 535 blocks
/// beyond the shadow camera to about 120 before it), whatever `shadowNearPlane` and
/// `shadowFarPlane` the pack sets, or none (#10403): only its sides cull.
pub depth_planes: bool,
}
/// Set 3 of flat terrain, read by the vertex stage: the sprite layers' table (`mc_Entity`'s id
@@ -1261,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,
@@ -1372,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 {
@@ -1853,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,
@@ -2276,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())
@@ -2822,6 +2854,8 @@ impl PackPost {
terrain,
terrain_cutout,
water,
terrain_undrawn,
water_undrawn,
sky,
entities,
clouds,
@@ -2978,6 +3012,13 @@ impl PackPost {
mipmap_filter: wgpu::MipmapFilterMode::Linear,
..Default::default()
}),
buffer_sampler: device.create_sampler(&wgpu::SamplerDescriptor {
label: Some("Shader pack buffers"),
mag_filter: wgpu::FilterMode::Linear,
min_filter: wgpu::FilterMode::Linear,
mipmap_filter: wgpu::MipmapFilterMode::Linear,
..Default::default()
}),
blit,
copy,
upscale,
@@ -3189,6 +3230,7 @@ impl PackPost {
SamplerKind::Repeat => &self.repeat,
SamplerKind::NearestRepeat => &self.nearest_repeat,
SamplerKind::Atlas => &self.atlas_sampler,
SamplerKind::Buffer => &self.buffer_sampler,
SamplerKind::Comparison => shadow.map_or(&self.shadow.1, |t| &t.comparison),
};
entries.push(wgpu::BindGroupEntry {
@@ -4037,12 +4079,13 @@ impl PackPost {
},
every_face: true,
view_casters: true,
depth_planes: false,
})
}
/// 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`).
@@ -4070,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.
@@ -4122,6 +4165,14 @@ impl PackPost {
}
self.setup_pending = true;
self.rgb_unset = true;
// Free the old targets before making the new ones: a pack with large buffers can't
// hold both sets at once on a card it otherwise fits (a window resize ran out of video
// memory). The bind groups hold the old views too, and the GPU may still be using
// them, so wait for it; a resize is rare.
if self.targets.take().is_some() {
self.forget_groups();
let _ = device.poll(wgpu::PollType::wait_indefinitely());
}
let sizes: [[u32; 2]; BUFFERS] =
std::array::from_fn(|i| self.sizes[i].map_or(size, |s| s.resolve(size)));
let full_chain = |size: [u32; 2]| 32 - size[0].max(size[1]).max(1).leading_zeros();
@@ -4580,10 +4631,8 @@ impl PackPost {
.get_or_insert_with(|| super::pack_center_depth::CenterDepth::new(device))
.collect(device);
self.uniforms.update(frame, inputs, size);
// Until the camera's biome is known, `smooth` waits for the world's values.
if !inputs.biome_known {
self.custom.restart_smoothing();
}
// `smooth` starts on the first frame that knows the camera's biome.
self.custom.world_frame(inputs.biome_known);
self.uniforms.eval_custom(&mut self.custom);
if self.targets.is_none() {
return;
@@ -5679,3 +5728,6 @@ mod unknown_sampler_terrain_tests;
#[cfg(test)]
mod mipmap_box_tests;
#[cfg(test)]
mod colortex_mip_blend_tests;
@@ -0,0 +1,61 @@
//! A buffer's mipmaps are read blended between the two nearest levels: our probe packs (#9307)
//! read every colortex at levels 2.5 and 1.25 in composite and final, in Iris 1.11.7, and got the
//! two nearest levels mixed.
use super::destination_tests::Fixture;
use super::*;
use std::io::{Cursor, Write};
const VERTEX: &str = "#version 120\nvarying vec2 uv;\nvoid main() { uv=gl_MultiTexCoord0.xy; gl_Position=ftransform(); }\n";
/// composite writes 1.0 into the left half of a 4x4 colortex1 and 0 into the right half, so at
/// (0.25, 0.5) level 1 reads 1.0 and level 2 (1x1) the mean, 0.5. final shows the read at
/// level 1.5 in red and at level 1.25 in green.
fn pack() -> Post {
let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
let mut add = |name: &str, source: &str| {
zip.start_file(
format!("shaders/{name}"),
zip::write::SimpleFileOptions::default(),
)
.unwrap();
zip.write_all(source.as_bytes()).unwrap();
};
add("composite.vsh", VERTEX);
add(
"composite.fsh",
"#version 330 compatibility\n/*\nconst int colortex1Format = RGBA16F;\n*/\n\
/* RENDERTARGETS: 1 */\nlayout(location = 0) out vec4 o;\n\
void main() {\n o = vec4(gl_FragCoord.x < 2.0 ? 1.0 : 0.0, 0.0, 0.0, 1.0);\n}\n",
);
add("final.vsh", VERTEX);
add(
"final.fsh",
"#version 330 compatibility\n/*\nconst bool colortex1MipmapEnabled = true;\n*/\n\
uniform sampler2D colortex1;\n\
void main() {\n vec2 at = vec2(0.25, 0.5);\n\
gl_FragColor = vec4(textureLod(colortex1, at, 1.5).r, textureLod(colortex1, at, 1.25).r, 0.0, 1.0);\n}\n",
);
let pack = horizon_shaders::Pack::from_zip_bytes(
"original-colortex-mip-blend",
zip.finish().unwrap().into_inner(),
)
.unwrap();
let mut macros = horizon_shaders::Macros::standard();
let (properties, _) = horizon_shaders::read_properties(&pack, &mut macros);
let post = horizon_shaders::load_post(&pack, "", &properties, &macros, &Default::default());
assert_eq!(post.programs.len(), 2, "{:?}", post.notes);
post
}
#[test]
fn a_buffer_blends_its_two_nearest_levels() {
let Some(gpu) = Fixture::new() else { return };
let mut post = gpu.build(pack());
let pixels = gpu.render(&mut post, [4, 4]);
// Level 1.5: 1.0 and 0.5 mixed 1:1, 0.75; level 1.25: mixed 3:1, 0.875.
let [r, g, ..] = pixels[0];
assert!(
r.abs_diff(191) <= 2 && g.abs_diff(223) <= 2,
"levels 1.5 and 1.25: got red {r}, green {g}, expected 191 and 223"
);
}
@@ -669,6 +669,31 @@ fn shadow_depth_filtering(nearest: &str) -> Option<Vec<[u8; 4]>> {
/// `shadow_depth_filtering`'s shadow map read by `reads`: `final`'s code after its declarations
/// of shadowtex0 and shadowtex1.
fn shadow_depth_reads(nearest: &str, reads: &str) -> Option<Vec<[u8; 4]>> {
// The shadow map's depth runs from 0 at its left edge to 1 at its right.
let ramp = [
[-1., -1., 0.],
[1., -1., 0.],
[1., 1., 0.],
[-1., -1., 0.],
[1., 1., 0.],
[-1., 1., 0.],
];
shadow_depth_map_reads(
nearest,
reads,
"#version 120\nvoid main(){gl_Position=vec4(gl_Vertex.xy,gl_Vertex.x,1.0);}",
ramp,
)
}
/// A shadow map drawn by `shadow_vsh` from the two triangles `positions` (the rest keeps its
/// clear depth, the far plane), read by `reads` as in `shadow_depth_reads`.
fn shadow_depth_map_reads(
nearest: &str,
reads: &str,
shadow_vsh: &str,
positions: [[f32; 3]; 6],
) -> Option<Vec<[u8; 4]>> {
let gpu = Gpu::new()?;
let color = gpu.texture(T::Rgba8Unorm);
let depth = gpu.texture(T::Depth32Float);
@@ -704,11 +729,7 @@ fn shadow_depth_reads(nearest: &str, reads: &str) -> Option<Vec<[u8; 4]>> {
uniform sampler2D shadowtex0;\nuniform sampler2D shadowtex1;\n{reads}"
),
),
// The shadow map's depth runs from 0 at its left edge to 1 at its right.
(
"shadow.vsh".into(),
"#version 120\nvoid main(){gl_Position=vec4(gl_Vertex.xy,gl_Vertex.x,1.0);}".into(),
),
("shadow.vsh".into(), shadow_vsh.into()),
(
"shadow.fsh".into(),
format!("#version 120\n{settings}void main(){{gl_FragData[0]=vec4(1.0);}}"),
@@ -740,14 +761,6 @@ fn shadow_depth_reads(nearest: &str, reads: &str) -> Option<Vec<[u8; 4]>> {
inputs,
);
assert!(post.has_shadow(), "{:?}", post.notes);
let positions: [[f32; 3]; 6] = [
[-1., -1., 0.],
[1., -1., 0.],
[1., 1., 0.],
[-1., -1., 0.],
[1., 1., 0.],
[-1., 1., 0.],
];
let vertices = gpu
.device
.create_buffer_init(&wgpu::util::BufferInitDescriptor {
@@ -793,6 +806,40 @@ fn shadow_depth_reads_filter_directly_and_with_a_texel_offset() {
}
}
#[test]
fn shadow_depth_filter_weights_are_256ths_as_in_our_iris_readings() {
// The map is at the far plane but for texel (8, 8), which is nearer. Reads between texels
// (7, 7) and (8, 8) give (8, 8)'s weight: Iris 1.11.7's filtered reads on our NVIDIA GPU
// fit weights of 1/256 along each axis, rounded half up, and a corner's product weight
// rounded half up to 1/256 (#8464). Red: a read a + 0.25/256 of the way along x only
// moves a/256 (77). Green: at (77.25, 77.25)/256 along both, (8, 8) weighs 23/256
// (77 × 77 / 256 = 23.16; exact weights would give 23.31).
let reads = "void main() {\n\
float far = texelFetch(shadowtex0, ivec2(7, 7), 0).r;\n\
float near = texelFetch(shadowtex0, ivec2(8, 8), 0).r;\n\
float f = 77.25 / 256.0;\n\
float along = texture(shadowtex0, vec2(7.5 + f, 8.5) / 16.0).r;\n\
float corner = texture(shadowtex0, vec2(7.5 + f, 7.5 + f) / 16.0).r;\n\
float x = (far - along) / (far - near) * 256.0;\n\
float w = (far - corner) / (far - near) * 256.0;\n\
gl_FragColor = vec4(abs(x - 77.0) < 0.01 ? 1.0 : 0.0,\n\
abs(w - 23.0) < 0.01 ? 1.0 : 0.0, 0.0, 1.0);\n\
}\n";
// Texel (8, 8) of the 16² map: x and y from 0 to 1/8 in clip space, depth 0.
let bump = [
[0., 0., -1.],
[0.125, 0., -1.],
[0.125, 0.125, -1.],
[0., 0., -1.],
[0.125, 0.125, -1.],
[0., 0.125, -1.],
];
let vsh = "#version 120\nvoid main(){gl_Position=vec4(gl_Vertex.xyz,1.0);}";
if let Some(pixels) = shadow_depth_map_reads("", reads, vsh, bump) {
all_pixels(&pixels, [255, 255, 0, 255], "shadowtex filter weights");
}
}
#[test]
fn shadowtex_nearest_settings_read_the_nearest_texel() {
for (line, expected) in [
@@ -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));
}
@@ -552,7 +552,12 @@ fn invert_generic(m: Mat4) -> Mat4 {
/// `shadow_camera`'s view and projection in wgpu's clip space (depth 0..1), for culling.
pub fn shadow_culling(frame: &Frame, shadow: &Shadow) -> Mat4 {
let (view, gl) = shadow_camera(shadow_light(frame, shadow), frame.camera, shadow);
shadow_culling_at(shadow_light(frame, shadow), frame.camera, shadow)
}
/// `shadow_culling` for the direction toward the light and the camera's position.
pub fn shadow_culling_at(light: Vec3, camera: DVec3, shadow: &Shadow) -> Mat4 {
let (view, gl) = shadow_camera(light, camera, shadow);
// GL's depth -w..w to wgpu's 0..w.
let depth = Mat4::from_cols(
Vec4::X,
+3 -3
View File
@@ -93,7 +93,7 @@ pub(crate) fn prepare_with_models(
models: &crate::model_library::ParsedModels,
) -> Result<PreparedModels> {
let facts: std::collections::BTreeMap<String, Value> =
serde_json::from_slice(&std::fs::read(root.join("model-states.json"))?)?;
horizon_world::json_file::read(&root.join("model-states.json"))?;
let blocks: Vec<&str> = facts
.keys()
.filter(|name| registry.default_state(name).is_some())
@@ -158,11 +158,11 @@ pub fn load_visibility(
collision::CachedCollisionRegistry,
registry::BlockTableFile,
};
use std::{collections::BTreeMap, fs};
use std::collections::BTreeMap;
let file = BlockTableFile::open(&root.join("occlusion.nbt"), registry)?;
let occlusion = CachedCollisionRegistry::from_table(file.table("occlusion")?)?;
let raw: BTreeMap<String, Value> =
serde_json::from_slice(&fs::read(root.join("render-definitions.json"))?)?;
horizon_world::json_file::read(&root.join("render-definitions.json"))?;
let definitions = raw
.into_iter()
.map(|(name, v)| {
+97 -3
View File
@@ -9,7 +9,7 @@ pub use crate::settings::Opt;
use crate::{
gamepad::{self, GlyphStyle},
hud::{HudAtlas, HudBatch, rgba},
keybinds::{self, CATEGORIES, KeyBinds, MAPPINGS},
keybinds::{self, CATEGORIES, KeyBinds, all_mappings},
lang::{self, LanguageInfo},
settings::{SOUND_SOURCES, Settings},
text::{self, Style, Text},
@@ -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);
@@ -3339,7 +3379,7 @@ impl OptionsUi {
row: 0,
});
let mut mappings: Vec<(&str, &str)> =
MAPPINGS.iter().map(|(n, _, c)| (*n, *c)).collect();
all_mappings().map(|(n, _, c)| (*n, *c)).collect();
let query = self.settings_search.value.to_lowercase();
mappings.retain(|(name, _)| {
query.is_empty() || lang::get(name).to_lowercase().contains(&query)
@@ -3433,7 +3473,7 @@ impl OptionsUi {
}
let reset_x = scrollbar_x - 50.0 - 10.0;
let change_x = reset_x - 5.0 - 75.0;
let name_static = MAPPINGS.iter().find(|m| m.0 == name).map_or("", |m| m.0);
let name_static = all_mappings().find(|m| m.0 == name).map_or("", |m| m.0);
out.push(button(change_x, y, 75.0, message, Act::Key(name_static)));
if !conflicts.is_empty() {
out.push(Widget {
@@ -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],
]
);
}
}
+187 -5
View File
@@ -18,7 +18,7 @@ pub fn set_source_volumes(volumes: [f32; 11]) {
pub fn sound_source(event: &str) -> usize {
let e = event.trim_start_matches("minecraft:");
let index = |name: &str| SOUND_SOURCES.iter().position(|n| *n == name).unwrap_or(0);
const HOSTILE: [&str; 25] = [
const HOSTILE: [&str; 34] = [
"zombie",
"skeleton",
"creeper",
@@ -44,6 +44,16 @@ pub fn sound_source(event: &str) -> usize {
"silverfish",
"zombified_piglin",
"wither_skeleton",
"bogged",
"breeze",
"endermite",
"parched",
"warden",
"zombie_villager",
"vex",
"wither",
// Monster's common hurt/death (e.g. Giant) and other shared sounds.
"hostile",
];
if e == "block.potent_sulfur.noxious_gas" {
// PotentSulfurBlock.animateTick, its only caller, plays it as AMBIENT.
@@ -347,7 +357,7 @@ impl Settings {
.parent()
.map(|dir| crate::mod_list::ModList::load(&crate::logging::shared_dir(dir)))
.unwrap_or_default();
let settings = match fs::read(path) {
let mut settings = match fs::read(path) {
Ok(bytes) => {
let text = String::from_utf8_lossy(&bytes);
extras.read_old_options(&extras_text, &text);
@@ -367,6 +377,9 @@ impl Settings {
..Self::default()
},
};
// Horizon's own key bindings live in horizon-extras.txt, not in options.txt.
let emote = settings.extras.emote_key.clone();
settings.keys.set("key.emote", &emote);
settings.describe_for_crashes();
// Options.load: the backend asked for at startup, what a restart is measured from.
Self {
@@ -636,14 +649,17 @@ impl Settings {
pub fn with_option(&self, key: &str, value: &str) -> Result<Self, String> {
let mut file = self.to_file();
file.set_text(key, value)?;
Ok(Self {
let mut next = Self {
horizon: self.horizon.clone(),
controller: self.controller.clone(),
extras: self.extras.clone(),
mods: self.mods.clone(),
video_modes: self.video_modes.clone(),
..Self::from_file(file)
})
};
// Horizon's own bindings aren't in options.txt.
next.keys.set("key.emote", self.keys.get("key.emote"));
Ok(next)
}
/// The `options.txt` text (Options.save).
pub fn serialize(&self) -> String {
@@ -657,7 +673,9 @@ impl Settings {
if let Some(dir) = path.parent() {
self.horizon.save(dir);
self.controller.save(dir);
self.extras.save(dir);
let mut extras = self.extras.clone();
extras.emote_key = self.keys.get("key.emote").to_owned();
extras.save(dir);
self.mods.save(&crate::logging::shared_dir(dir));
}
}
@@ -856,6 +874,57 @@ mod tests {
horizon_narrator::Status::Off
);
}
/// `Monster.getSoundSource`: the verified families and inherited common sounds.
#[test]
fn hostile_families_keep_their_sound_category() {
for (event, source) in [
("entity.zombie.hurt", 5),
("entity.cow.hurt", 6),
("block.stone.break", 4),
("entity.player.hurt", 7),
("entity.arrow.hit", 7),
("music_disc.13", 2),
("item.goat_horn.sound.0", 2),
("weather.rain", 3),
("ambient.cave", 8),
] {
assert_eq!(sound_source(event), source, "{event}");
}
// Explicit sources still take precedence, including H331's arrow route.
let mut audio = crate::audio::Audio::silent(Path::new("/nonexistent/h068-audio"));
audio.capture = Some(Vec::new());
audio.play_on(6, "minecraft:entity.arrow.hit", Some([1.0; 3]), 1.0, 1.0);
audio.play_on(4, "minecraft:entity.warden.hurt", Some([1.0; 3]), 1.0, 1.0);
assert_eq!(
audio
.capture
.as_ref()
.unwrap()
.iter()
.map(|s| s.source)
.collect::<Vec<_>>(),
[6, 4]
);
for family in [
"bogged",
"breeze",
"endermite",
"parched",
"warden",
"zombie_villager",
"vex",
"wither",
"hostile",
] {
for suffix in ["hurt", "death"] {
for prefix in ["", "minecraft:"] {
let event = format!("{prefix}entity.{family}.{suffix}");
assert_eq!(sound_source(&event), 5, "{event}");
}
}
}
}
#[test]
fn three_d_skin_layers_move_from_options_txt_to_the_extras_file() {
// #8091 wrote the switch into options.txt; a player who had it on keeps
@@ -881,6 +950,119 @@ mod tests {
);
let _ = fs::remove_dir_all(&dir);
}
fn scratch(name: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!(
"horizon-{name}-{}-{:?}",
std::process::id(),
std::thread::current().id()
));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn options_txt_lists_only_vanillas_key_mappings() {
// Options.keyMappings has the friends key, then social interactions; Horizon's emote key
// (#10092 put it between them) is not in the file.
let out = Settings::default().serialize();
assert!(!out.contains("emote"), "{out}");
assert!(out.contains(
"\nkey_key.friends:key.keyboard.o\nkey_key.socialInteractions:key.keyboard.p\n"
));
let mut s = Settings::default();
s.keys.set("key.emote", "key.keyboard.g");
assert!(!s.serialize().contains("emote"));
}
#[test]
fn the_emote_key_is_kept_in_the_extras_file() {
let dir = scratch("emote-key");
let options = dir.join("options.txt");
// Untouched: no extras file is made for it.
Settings::default().save(&options);
assert!(!dir.join(crate::extras::FILE).exists());
assert_eq!(
Settings::load(&options).keys.get("key.emote"),
"key.keyboard.b"
);
// Rebound in Controls: saved to horizon-extras.txt, and options.txt stays vanilla's.
let mut s = Settings::load(&options);
s.keys.set("key.emote", "key.keyboard.g");
s.save(&options);
assert!(!fs::read_to_string(&options).unwrap().contains("emote"));
let extras = fs::read_to_string(dir.join(crate::extras::FILE)).unwrap();
assert!(
extras.lines().any(|l| l == "emoteKey:key.keyboard.g"),
"{extras}"
);
let back = Settings::load(&options);
assert_eq!(back.keys.get("key.emote"), "key.keyboard.g");
// horizon-ctl options-set rebuilds from options.txt text and keeps it.
assert_eq!(
back.with_option("fov", "90").unwrap().keys.get("key.emote"),
"key.keyboard.g"
);
// Reset to B and back to the default: the file says so.
let mut s = back;
s.keys.set(
"key.emote",
crate::keybinds::KeyBinds::default_of("key.emote"),
);
s.save(&options);
assert_eq!(
Settings::load(&options).keys.get("key.emote"),
"key.keyboard.b"
);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn the_emote_key_moves_from_options_txt_to_the_extras_file() {
// #10092 wrote `key_key.emote` into options.txt; a rebound key is kept, the next
// options.txt is vanilla's again.
let dir = scratch("emote-key-move");
let options = dir.join("options.txt");
fs::write(
&options,
"version:4000\nfov:0.5\nkey_key.emote:key.keyboard.h\nkey_key.jump:key.keyboard.j\n",
)
.unwrap();
let s = Settings::load(&options);
assert_eq!(s.keys.get("key.emote"), "key.keyboard.h");
assert_eq!(s.keys.get("key.jump"), "key.keyboard.j");
s.save(&options);
assert!(!fs::read_to_string(&options).unwrap().contains("emote"));
assert_eq!(
Settings::load(&options).keys.get("key.emote"),
"key.keyboard.h"
);
// Once horizon-extras.txt has its own line, a stale options.txt one is ignored.
fs::write(dir.join(crate::extras::FILE), "emoteKey:key.keyboard.k\n").unwrap();
fs::write(&options, "version:4000\nkey_key.emote:key.keyboard.h\n").unwrap();
assert_eq!(
Settings::load(&options).keys.get("key.emote"),
"key.keyboard.k"
);
// A value that is no key is not adopted; an unbound emote key is.
fs::remove_file(dir.join(crate::extras::FILE)).unwrap();
fs::write(&options, "version:4000\nkey_key.emote:banana\n").unwrap();
assert_eq!(
Settings::load(&options).keys.get("key.emote"),
"key.keyboard.b"
);
fs::write(
&options,
"version:4000\nkey_key.emote:key.keyboard.unknown\n",
)
.unwrap();
assert_eq!(
Settings::load(&options).keys.get("key.emote"),
"key.keyboard.unknown"
);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn an_option_set_keeps_horizon_files() {
let mut s = Settings::parse("fov:70\n");
+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]
+197 -15
View File
@@ -22,6 +22,11 @@ thread_local! {
static NOTES: RefCell<Vec<(f32, String)>> = const { RefCell::new(Vec::new()) };
}
/// The most spans a thread holds before they are taken ([`take_raw`]): a worker thread
/// that records spans (`lod_reduce`, `lt_*`) while they are on for every thread is never
/// taken from, so it drops what it held instead of growing for as long as they stay on.
const UNTAKEN_MAX: usize = 1 << 14;
/// The shortest step a [`note`] keeps, in milliseconds.
pub const NOTE_MS: f32 = 10.0;
/// The notes a thread keeps between two [`take_notes`]: its slowest steps.
@@ -53,7 +58,7 @@ pub fn record_here(on: bool) {
/// Whether this thread records spans: all of them on, or [`record_here`].
#[inline]
pub fn recording_here() -> bool {
ENABLED.load(Ordering::Relaxed) || HERE.with(Cell::get)
ENABLED.load(Ordering::Relaxed) || HERE.with(Cell::get) || trace::on()
}
/// The thread's CPU time when spans are on for every thread (`HORIZON_SPIKES`): only they
/// print it.
@@ -101,16 +106,41 @@ impl Drop for Guard {
.and_then(|start| Some((thread_cpu()?, start)))
.map_or(ms, |(end, start)| (end - start).as_secs_f32() * 1000.0);
DEPTH.with(|d| d.set(depth));
SPANS.with(|s| {
s.borrow_mut().push(Span {
name,
ms,
cpu_ms,
depth,
})
trace::span(name, started, ms);
push(Span {
name,
ms,
cpu_ms,
depth,
});
}
}
/// Keeps a finished span for the thread's next [`take_raw`].
fn push(span: Span) {
SPANS.with(|s| {
let mut s = s.borrow_mut();
if s.len() >= UNTAKEN_MAX {
s.clear();
}
s.push(span);
});
}
/// Attach per-tick counts without interpreting them as durations.
pub fn counters(counts: &[(&str, u64)]) {
if !enabled() {
return;
}
NOTES.with(|n| {
n.borrow_mut().push((
0.0,
counts
.iter()
.map(|(name, count)| format!("{name}={count}"))
.collect::<Vec<_>>()
.join(", "),
))
});
}
/// Notes that one step inside a span took `ms` milliseconds doing `what` (one player's
/// packets, say), when this thread records spans and the step took at least [`NOTE_MS`]: a
/// span says where a slow tick went, its notes whose doing it was. Only the slowest few are
@@ -163,13 +193,12 @@ impl Laps {
.zip(*cpu)
.map_or(ms, |(end, start)| (end - start).as_secs_f32() * 1000.0);
let depth = DEPTH.with(Cell::get);
SPANS.with(|s| {
s.borrow_mut().push(Span {
name,
ms,
cpu_ms,
depth,
})
trace::span(name, *started, ms);
push(Span {
name,
ms,
cpu_ms,
depth,
});
(*started, *cpu) = (now, now_cpu);
}
@@ -271,6 +300,159 @@ pub fn describe(spans: &[Span]) -> String {
list.join(", ")
}
/// The run's timeline: every span ([`time`], [`guard`], [`Laps::lap`]) on every thread, as
/// Chrome trace events that Perfetto (ui.perfetto.dev) opens as they are. `HORIZON_TRACE=FILE`
/// writes it from the start; [`start`](trace::start) and [`stop`](trace::stop) write one for a
/// while (`horizon-trace` on a dedicated server's console, `trace` on the control socket). Spans
/// show on their threads where they ran, nested by time, so what ran beside what, and what a
/// thread waited for, can be read off. Off, a span costs one relaxed load more.
pub mod trace {
use std::{
cell::Cell,
fs::File,
io::{BufWriter, Write as _},
path::Path,
sync::{
Mutex, PoisonError,
atomic::{AtomicBool, AtomicU32, Ordering},
},
time::Instant,
};
static ON: AtomicBool = AtomicBool::new(false);
static SINK: Mutex<Option<Sink>> = Mutex::new(None);
/// Counts the traces started: a thread names itself again in each one.
static TRACES: AtomicU32 = AtomicU32::new(0);
static NEXT_THREAD: AtomicU32 = AtomicU32::new(1);
thread_local! {
/// This thread's id in traces (0: none yet).
static THREAD: Cell<u32> = const { Cell::new(0) };
/// The trace this thread last named itself in.
static NAMED_IN: Cell<u32> = const { Cell::new(0) };
}
/// How often a trace's buffer goes to the file, at most: a run that ends without
/// [`stop`] loses no more than this.
const FLUSH_EVERY: std::time::Duration = std::time::Duration::from_secs(1);
struct Sink {
file: BufWriter<File>,
origin: Instant,
/// Until when it records ([`start`]'s `seconds`).
until: Option<Instant>,
flushed: Instant,
/// Events written: the next one is preceded by a comma.
events: u64,
}
impl Sink {
fn event(&mut self, event: std::fmt::Arguments) {
let comma = if self.events == 0 { "" } else { ",\n" };
let _ = write!(self.file, "{comma}{{\"pid\":1,{event}}}");
self.events += 1;
}
}
/// Whether a trace is being written.
#[inline]
pub fn on() -> bool {
ON.load(Ordering::Relaxed)
}
/// Starts a trace written to `path` (replacing a trace being written), for `seconds` or
/// until [`stop`].
pub fn start(path: &Path, seconds: Option<u32>) -> std::io::Result<()> {
let mut file = BufWriter::with_capacity(1 << 18, File::create(path)?);
file.write_all(b"[\n")?;
let now = Instant::now();
let sink = Sink {
file,
origin: now,
until: seconds.map(|s| now + std::time::Duration::from_secs(u64::from(s))),
flushed: now,
events: 0,
};
let mut slot = SINK.lock().unwrap_or_else(PoisonError::into_inner);
if let Some(old) = slot.take() {
close(old);
}
*slot = Some(sink);
TRACES.fetch_add(1, Ordering::Relaxed);
ON.store(true, Ordering::Relaxed);
Ok(())
}
/// Starts the trace `HORIZON_TRACE` names, if it names one.
pub fn start_from_env() {
if let Some(path) = std::env::var_os("HORIZON_TRACE")
&& let Err(e) = start(Path::new(&path), None)
{
log::warn!("Horizon: HORIZON_TRACE: {e}");
}
}
/// Ends the trace being written: the file is complete JSON. The events written so far
/// and whether one was being written.
pub fn stop() -> Option<u64> {
ON.store(false, Ordering::Relaxed);
let sink = SINK.lock().unwrap_or_else(PoisonError::into_inner).take()?;
let events = sink.events;
close(sink);
Some(events)
}
/// Whether the trace being written has run its time ([`start`]'s `seconds`): the caller
/// then [`stop`]s it.
pub fn due() -> bool {
on() && SINK
.lock()
.unwrap_or_else(PoisonError::into_inner)
.as_ref()
.and_then(|s| s.until)
.is_some_and(|until| Instant::now() >= until)
}
fn close(mut sink: Sink) {
let _ = sink.file.write_all(b"\n]\n");
let _ = sink.file.flush();
}
/// A span of this thread from `started`, `ms` long.
pub(super) fn span(name: &'static str, started: Instant, ms: f32) {
if !on() {
return;
}
let tid = THREAD.with(|t| {
if t.get() == 0 {
t.set(NEXT_THREAD.fetch_add(1, Ordering::Relaxed));
}
t.get()
});
let trace = TRACES.load(Ordering::Relaxed);
let mut slot = SINK.lock().unwrap_or_else(PoisonError::into_inner);
let Some(sink) = slot.as_mut() else {
return;
};
if NAMED_IN.with(|n| n.replace(trace)) != trace {
let name = std::thread::current()
.name()
.unwrap_or("unnamed")
.replace(['"', '\\'], "_");
sink.event(format_args!(
r#""ph":"M","name":"thread_name","tid":{tid},"args":{{"name":"{name}"}}"#
));
}
let ts = started.saturating_duration_since(sink.origin).as_secs_f64() * 1e6;
sink.event(format_args!(
r#""ph":"X","name":"{name}","tid":{tid},"ts":{ts:.3},"dur":{:.3}"#,
f64::from(ms) * 1e3
));
let now = Instant::now();
if now.duration_since(sink.flushed) >= FLUSH_EVERY {
let _ = sink.file.flush();
sink.flushed = now;
}
}
}
/// The start's timeline: `HORIZON_STARTUP_TRACE=FILE` writes every named step of
/// the start (resource tables, the renderer, the title, a world's open, a
/// server's join, a dimension change) on whichever thread ran it, one Chrome
+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));
}
}
+13 -1
View File
@@ -254,7 +254,11 @@ fn normalize_end(data: &mut [u8]) {
}
let disk = u16_at(data, end + 4);
let with_directory = u16_at(data, end + 6);
if disk == with_directory && u16_at(data, end + 8) == count && u16_at(data, end + 10) == count {
if disk == 0
&& with_directory == 0
&& u16_at(data, end + 8) == count
&& u16_at(data, end + 10) == count
{
return;
}
data[end + 4..end + 10].fill(0);
@@ -311,6 +315,14 @@ mod tests {
assert_eq!(zip.read("a/b.txt").unwrap(), b"hello hello hello hello");
}
#[test]
fn a_file_is_opened_with_equal_nonzero_disk_numbers() {
let mut bytes = archive();
let end = bytes.len() - END_HEADER;
bytes[end + 4..end + 8].fill(0xFF);
let zip = opened("end-disks", &bytes).unwrap();
assert_eq!(zip.read("a/b.txt").unwrap(), b"hello hello hello hello");
}
#[test]
fn a_file_that_is_no_archive_does_not_open() {
assert!(opened("empty", b"").is_err());
assert!(opened("garbage", b"PK not really").is_err());
+2 -4
View File
@@ -106,10 +106,6 @@ objc2-foundation = { version = "0.3", default-features = false, features = ["std
# DirectX 12 driver (`graphicsApi`, graphics.rs).
wgpu = { version = "30.0.1", default-features = false, features = ["gles"] }
[target.'cfg(windows)'.build-dependencies]
# The icon resource in the .exe (build.rs).
winresource = "0.1"
[features]
default = ["audio", "controllers"]
# Sound output. Without it (and `controllers`) the program links neither libasound nor libudev on
@@ -152,6 +148,8 @@ path = "examples/native_gui_overlay_probe.rs"
[build-dependencies]
horizon-mod-api-gen = { path = "../horizon-mod-api-gen" }
# The build script runs on the host, but embeds resources based on the target OS.
winresource = "0.1"
[lints]
workspace = 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.",
+6 -6
View File
@@ -9,18 +9,17 @@ fn main() {
.expect("generating native mod API");
let dir = std::env::var("CARGO_MANIFEST_DIR").unwrap_or_default();
let ico = format!("{dir}/../../packaging/icons/horizon.ico");
let target_os =
std::env::var("CARGO_CFG_TARGET_OS").expect("Cargo must provide the build target OS");
println!("cargo:rerun-if-changed={ico}");
// The game's main thread gets 8 MB of stack on Windows as on macOS and Linux (Windows gives 1
// MB): splitscreen builds a joining player's `Game`, ~67 KB, deep in the event loop.
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("windows") {
if target_os == "windows" {
let arg = match std::env::var("CARGO_CFG_TARGET_ENV").as_deref() {
Ok("msvc") => "/STACK:8388608",
_ => "-Wl,--stack,8388608",
};
println!("cargo:rustc-link-arg-bin=horizon-client={arg}");
}
#[cfg(windows)]
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("windows") {
let mut resource = winresource::WindowsResource::new();
resource.set_icon(&ico);
if std::env::var_os("CARGO_FEATURE_WINDOWS_SEGMENT_HEAP").is_some() {
@@ -30,8 +29,9 @@ fn main() {
}
if let Err(e) = resource.compile() {
// The GNU toolchain's `windres` (the launcher's mod builds use it) can't take a path
// with a space, and a Windows user name often has one: such a build goes on without
// the icon. Release builds run in CI, where the icon stays required.
// with a space, and a Windows user name often has one; a cross build may have no
// `windres` at all: such a build goes on without the icon. Release builds run in CI,
// where the icon stays required.
if std::env::var_os("CI").is_some() {
panic!("embedding Windows resources: {e}");
}
@@ -0,0 +1,43 @@
//! Command-tag data carried to a converted mob's new entity ID.
use horizon_client::entity_nbt;
use horizon_world::nbt::Tag;
use std::io::Write;
pub fn main() {
let args: Vec<_> = super::args().collect();
let input = std::fs::read_to_string(&args[1]).unwrap();
let mut out = std::fs::File::create(&args[2]).unwrap();
for line in input.lines() {
let values: Vec<String> = serde_json::from_str(line).unwrap();
let raw = Tag::compound([
(
"Tags",
Tag::List(
values
.iter()
.map(|s| Tag::String(s.as_str().into()))
.collect(),
),
),
("UUID", Tag::IntArray(vec![1, 2, 3, 4])),
("id", Tag::String("minecraft:husk".into())),
]);
let converted = entity_nbt::conversion_tags(Some(&raw))
.unwrap_or_else(|| Tag::compound([("Tags", Tag::List(Vec::new()))]));
assert!(converted.get("UUID").is_none() && converted.get("id").is_none());
let Tag::List(tags) = converted.get("Tags").unwrap() else {
panic!("missing tags")
};
let mut encoded: Vec<String> = tags
.iter()
.map(|tag| {
let Tag::String(tag) = tag else {
panic!("not a string")
};
tag.0.iter().map(|unit| format!("{unit:04x}")).collect()
})
.collect();
encoded.sort();
writeln!(out, "{}", serde_json::to_string(&encoded).unwrap()).unwrap();
}
}
@@ -33,6 +33,7 @@ mod cloud_fixture;
mod command_parse_fixture;
mod container_screen_probe;
mod container_title_fixture;
mod conversion_tags_fixture;
mod creative_mining_probe;
mod creative_search_fixture;
mod cushion_placement_probe;
@@ -98,6 +99,7 @@ mod sound_event_fixture;
mod spawn_rules_fixture;
mod sprint_probe;
mod squid_client_probe;
mod stack_foil_fixture;
mod storage_menu_fixture;
mod subtitle_fixture;
mod tab_list_fixture;
@@ -139,6 +141,7 @@ probes! {
command_parse_fixture,
container_screen_probe,
container_title_fixture,
conversion_tags_fixture,
creative_search_fixture,
emitter_particles_probe,
enchanting_fixture,
@@ -214,6 +217,7 @@ probes! {
trading_fixture,
trident_probe,
translucency_fixture,
stack_foil_fixture,
vehicle_container_fixture,
weather_fixture,
world_border_render_fixture,
@@ -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 = {
@@ -0,0 +1,114 @@
//! Actual stack decoding -> render view -> item-model foil selection, without a GPU.
use horizon_assets::item_model::{
ClientItem, Context, DisplayContext, FoilType, ItemModels, LayerContent, NoEnv,
};
use horizon_client::{item_data::decode_components, item_render::stack_view, items::ItemStack};
use horizon_world::nbt::Tag;
use serde_json::{Value, json};
use std::{collections::BTreeMap, fs};
fn model_flags(input: &Value) -> horizon_client::Result<Value> {
let text = input["nbt"].as_str().ok_or("missing NBT text")?;
let (tag, used) = horizon_world::nbt::parse_snbt(text)?;
if used != text.len() {
return Err("trailing NBT".into());
}
let mut stack = ItemStack::new("minecraft:diamond", 1);
(stack.damage, stack.extra) =
decode_components(Some(&Tag::compound([("minecraft:custom_model_data", tag)])));
let view = stack_view(&stack);
let mut models = ItemModels::default();
models.prototypes.insert(
"minecraft:diamond".into(),
BTreeMap::from([("minecraft:item_model".into(), json!("minecraft:diamond"))]),
);
models.definitions.insert("minecraft:diamond".into(), ClientItem::decode(&json!({
"model": {"type": "minecraft:condition", "property": "minecraft:custom_model_data", "index": input["index"],
"on_true": {"type": "minecraft:model", "model": "horizon:item/yes"},
"on_false": {"type": "minecraft:model", "model": "horizon:item/no"}}
}))?);
let resolved = models.resolve(
&view,
&Context {
display: DisplayContext::Gui,
owner: None,
level: None,
seed: 0,
},
&NoEnv,
);
if resolved.layers.len() != 1 {
return Err("expected one conditional model layer".into());
}
let LayerContent::Model { model } = &resolved.layers[0].content else {
return Err("expected model content".into());
};
Ok(
json!({"id": input["id"], "component": view.patch["minecraft:custom_model_data"], "selected": model}),
)
}
pub fn main() -> horizon_client::Result<()> {
let args: Vec<String> = crate::args().collect();
let mut models = ItemModels::default();
models.definitions.insert(
"horizon:fixture".into(),
ClientItem::from_json(
r#"{"model":{"type":"minecraft:model","model":"horizon:item/fixture"}}"#,
)?,
);
let mut output = String::new();
for line in fs::read_to_string(args.get(1).ok_or("missing INPUT")?)?.lines() {
let input: Value = serde_json::from_str(line)?;
if input["kind"] == "flags" {
output.push_str(&serde_json::to_string(&model_flags(&input)?)?);
output.push('\n');
continue;
}
let item = input["item"].as_str().ok_or("missing item")?;
models.prototypes.insert(
item.into(),
BTreeMap::from([
("minecraft:item_model".into(), json!("horizon:fixture")),
(
"minecraft:enchantment_glint_override".into(),
input["default_glint"].clone(),
),
]),
);
let mut stack = ItemStack::new(item, 1);
if let Some(nbt) = input.get("nbt") {
let text = nbt.as_str().ok_or("missing NBT text")?;
let (tag, used) = horizon_world::nbt::parse_snbt(text)?;
if used != text.len() {
return Err("trailing NBT".into());
}
(stack.damage, stack.extra) = decode_components(Some(&Tag::compound([(
"minecraft:enchantment_glint_override",
tag,
)])));
}
let view = stack_view(&stack);
let resolved = models.resolve(
&view,
&Context {
display: DisplayContext::Gui,
owner: None,
level: None,
seed: 0,
},
&NoEnv,
);
if resolved.layers.len() != 1 {
return Err("expected one fixture model layer".into());
}
output.push_str(&serde_json::to_string(&json!({
"id": input["id"],
"component": view.patch.get("minecraft:enchantment_glint_override").and_then(Option::as_ref),
"foil": resolved.layers[0].foil != FoilType::None,
}))?);
output.push('\n');
}
fs::write(args.get(2).ok_or("missing OUTPUT")?, output)?;
Ok(())
}
@@ -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)
})
@@ -584,6 +584,9 @@ pub const COMMANDS: &[Cmd] = &[
cmd!("bench", Scripting, "bench SECONDS", "reset frame stats, wait, reply with the summary", "", ["horizon-ctl bench 10"], &[p("seconds", Ty::Num, "how long to measure")], mcp: true, local: false),
cmd!("stats", Scripting, "stats [reset] [raw]", "frame-time summary since the last reset", "", ["horizon-ctl stats"], &[opt("reset", Ty::Bool, "start counting again"), opt("raw", Ty::Bool, "every frame time too (frame_ms, cpu_ms, gpu_ms)")], mcp: true, local: false),
cmd!("gpu-passes", Scripting, "gpu-passes [on|off]", "time Horizon's own render passes on the GPU, shown in F3 and in stats and bench", "", ["horizon-ctl gpu-passes on"], &[opt("mode", Ty::Enum(&["on", "off"]), "on or off; without it, only reports")], mcp: false, local: false),
cmd!("trace", Scripting, "trace [FILE [SECONDS] | stop]", "write every thread's timed spans to FILE as a timeline that ui.perfetto.dev opens, for SECONDS or until stop",
"Each span (ticks by system, streaming, uploads, far terrain, lighting, the renderer) shows on the thread that ran it, nested by time. Without arguments, only reports whether a trace is being written. HORIZON_TRACE=FILE writes one from the start; horizon-trace on a dedicated server's console writes one to reports/. See docs/graphics.md.",
["horizon-ctl trace /tmp/frame.json 10", "horizon-ctl trace stop"], &[], mcp: false, local: false),
cmd!("render-capture", Scripting, "render-capture on|off|DIR", "keep CPU copies of the meshes the renderer draws (on), or write the next frame's scene to DIR for the scene_replay example",
"on meshes every section and far tile again so the copies are complete (HORIZON_RENDER_CAPTURE=1 keeps them from the start). DIR gets the camera, frame inputs, settings and every resident near section and far tile mesh; entities, particles, weather, the hand and the HUD are left out. See docs/graphics.md.",
["horizon-ctl render-capture on", "horizon-ctl render-capture /tmp/scene"], &[], mcp: false, local: false),
+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!(
+318 -2
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(
@@ -1721,6 +1750,8 @@ impl ItemFrame {
/// An armor stand (`ArmorStand`).
#[derive(Clone, Debug)]
pub struct ArmorStand {
/// `Entity.firstTick`: command equipment events wait for an actual entity tick.
pub first_tick: bool,
/// `Entity.xRot`: kept for `/tp` and `Rotation`; the kind turns only in yaw.
pub pitch: f32,
/// When it joined its list ([`crate::join_seq`]); set by the list, not by the spawn site.
@@ -1767,7 +1798,8 @@ pub struct ArmorStand {
pub disabled_slots: i32,
/// Server-only `LivingEntity.lastPos` for location-based enchantments.
pub enchantment_block: Option<BlockPos>,
/// Travel accumulated for `LivingEntity.checkFallDamage`'s landing callback.
/// `Entity.fallDistance`, also used by `LivingEntity.checkFallDamage`'s landing callback.
/// Each downward movement is narrowed to float before adding to this double.
pub enchantment_fall_distance: f64,
/// Last equipment seen by `collectEquipmentChanges`; not saved to NBT.
pub enchantment_equipment: [Option<ItemStack>; 6],
@@ -1893,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)`).
@@ -1923,6 +2033,7 @@ impl ArmorStand {
}
pub fn new(id: u32, position: Vec3, yaw: f32) -> Self {
Self {
first_tick: true,
pitch: 0.0,
join: Default::default(),
fire_ticks: 0,
@@ -2569,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::*;
@@ -4243,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 {
File diff suppressed because it is too large. Load diff
+1
View File
@@ -15,6 +15,7 @@ pub fn main_with(mods: crate::mod_api::Mods) {
horizon_threads::configure_allocator();
horizon_client::telemetry::mark_start();
horizon_client::spans::startup::init(std::time::Instant::now());
horizon_client::spans::trace::start_from_env();
let args: Vec<String> = std::env::args().skip(1).collect();
let game_args = args.iter().take_while(|a| *a != "--dedicated");
let help = game_args
+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.
+336 -106
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;
@@ -173,6 +174,7 @@ mod lan_chat;
mod lan_crafting;
mod lan_entities;
mod lan_far;
mod lan_idle;
mod lan_mobs;
mod lan_mount_menu;
mod lan_particles;
@@ -1909,9 +1911,12 @@ impl LevelAccess for GameLevel<'_> {
for (k, v) in [("x", pos.x), ("y", pos.y), ("z", pos.z)] {
nbt.insert(k.into(), Tag::Int(v));
}
if let Some((key, entity)) = horizon_client::save::decode_block_entity(&Tag::Compound(nbt))
{
let tag = Tag::Compound(nbt);
if let Some((key, entity)) = horizon_client::save::decode_block_entity(&tag) {
self.world.block_entities.insert(key, entity);
if tag.get("LootTable").is_some() {
self.emit(LevelEvent::ContainerLootTable { pos, tag });
}
}
}
fn set_command_block_powered(&mut self, pos: BlockPos, powered: bool) -> Option<(bool, bool)> {
@@ -2951,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
@@ -3037,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,
@@ -3109,6 +3118,9 @@ struct Game<'a> {
water_plane_memo: std::cell::Cell<Option<f32>>,
/// The camera's block and the second `water_plane` last scanned at.
water_plane_scan: std::cell::Cell<Option<([i32; 3], i64)>>,
/// The mobs' box reach while the boats and minecarts tick, with the mob count it was
/// worked out for (`vehicle_mob_reach`).
vehicle_mob_reach: std::cell::Cell<Option<(usize, horizon_client::mobs::BoxReach)>>,
persist: persist::EntityMemory,
entity_attachments: HashMap<[i32; 4], horizon_world::attachments::Attachments>,
/// Connected to a server: the world comes from the network (game/remote.rs).
@@ -3165,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>,
@@ -5064,10 +5080,12 @@ impl Game<'_> {
/// (`ServerChunkCache.tick`'s broadcast), before the block events and the
/// block entities ran.
fn tick_blocks(&mut self) -> Result<Option<usize>> {
let mut laps = horizon_client::spans::laps();
// ServerChunkCache.tick: the tickets that time out.
self.tickets.tick_portal();
self.sync_tick_chunks();
self.start_ticking_chunks();
laps.lap("tb_tickets");
let levels = self.tickets.levels();
let bounds = levels.random_tick_bounds();
// Keep the world map's chunk order, including empty chunks
@@ -5086,6 +5104,7 @@ impl Game<'_> {
levels.random_ticking(x, z) && ticking_chunk(&self.world, x, z)
})
.collect();
laps.lap("tb_chunks");
// Entity.checkInsideBlocks for the plates, tripwires, detector rails and
// buttons under the players (the host and the LAN guests), the minecarts
// and the arrows (the mobs and other entities are added in `with_level`
@@ -5259,6 +5278,7 @@ impl Game<'_> {
}
});
self.process_level_events()?;
laps.lap("tb_entity_inside");
// ServerLevel.tick: a Debug world runs no scheduled block or fluid ticks, and
// ServerChunkCache.tickChunks no random ticks.
let debug = self.world_debug;
@@ -5299,12 +5319,17 @@ impl Game<'_> {
}
});
});
laps = horizon_client::spans::laps();
let sent = self.lan_broadcast_changes();
laps.lap("tb_broadcast");
self.with_level(|b, level, ticks| {
b.run_block_events(level, ticks);
});
laps.lap("tb_block_events");
self.process_level_events()?;
laps.lap("tb_level_events");
self.sync_moving_pistons();
laps.lap("tb_pistons");
Ok(sent)
}
/// `ServerLevel.tick`: moving block entities push after entity travel.
@@ -5347,7 +5372,10 @@ impl Game<'_> {
/// events) happen before the next tick runs, as they do inline in vanilla.
pub(super) fn tick_scheduled(&mut self) -> Result<()> {
for fluids in [false, true] {
let mut laps = horizon_client::spans::laps();
let name = if fluids { "tb_fluids" } else { "tb_scheduled" };
if !self.with_level(|b, level, ticks| b.scheduled_begin(level, ticks, fluids)) {
laps.lap(name);
continue;
}
loop {
@@ -5370,6 +5398,7 @@ impl Game<'_> {
}
}
self.with_level(|b, _, ticks| b.scheduled_finish(ticks, fluids));
laps.lap(name);
}
Ok(())
}
@@ -5677,6 +5706,14 @@ impl Game<'_> {
_ => {}
}
}
LevelEvent::ContainerLootTable { pos, tag } => {
let key = (pos.x, pos.y, pos.z);
if let Some(mut entity) = self.world.block_entities.remove(&key) {
self.take_loot_table(key, &tag, &mut entity);
self.world.block_entities.insert(key, entity);
self.world.modified.insert((pos.x >> 4, pos.z >> 4));
}
}
// A prime the mods refused is silent: no fuse sound, no game event.
LevelEvent::Sound { pos, ref sound, .. }
if self.tnt_refused == Some(pos)
@@ -7501,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)
@@ -7518,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),
@@ -7572,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;
@@ -7583,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;
@@ -7789,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,
@@ -7811,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.
@@ -8052,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());
@@ -8072,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())?;
@@ -8622,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);
@@ -8712,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,
@@ -10798,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.
@@ -11247,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(),
@@ -11277,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 {
@@ -11301,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,
@@ -11322,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,
@@ -11425,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(),
@@ -11478,8 +11580,38 @@ impl Game<'_> {
v.intersects(o - glam::Vec3::ONE, o + glam::Vec3::splat(2.0))
})
};
let dynamic_visible = |_: [i32; 3], state: u32, lo: glam::DVec3, hi: glam::DVec3| {
if off_screen(state) {
let camera_list: Vec<&([i32; 3], u32)> = r
.visible_block_entities_in_frustum(
self.settings.chunk_fade,
camera,
view,
&off_screen,
long_range,
)
.filter(|(block, section_inside)| {
let (pos, state) = **block;
self.res.block_entity_render().renders(state)
&& (off_screen(state) || *section_inside || in_view(&pos))
})
.map(|(block, _)| block)
.collect();
// A shader pack's shadow pass draws the block entities of every section it draws,
// in the view or not (`Renderer::shadow_pass_block_entities`, #8464): those the
// view leaves out go in hidden from the camera.
let shadow_only: Vec<&([i32; 3], u32)> =
if self.pack_ids.is_some() && self.pack_shadow_casters.0 {
let listed: HashSet<[i32; 3]> = camera_list.iter().map(|b| b.0).collect();
r.shadow_pass_block_entities(camera)
.filter(|(pos, state)| {
!listed.contains(pos) && self.res.block_entity_render().renders(*state)
})
.collect()
} else {
Vec::new()
};
let shadow_only_at: HashSet<[i32; 3]> = shadow_only.iter().map(|b| b.0).collect();
let dynamic_visible = |pos: [i32; 3], state: u32, lo: glam::DVec3, hi: glam::DVec3| {
if off_screen(state) || shadow_only_at.contains(&pos) {
return true;
}
if let Some(view) = view
@@ -11534,20 +11666,7 @@ impl Game<'_> {
glyphs: self.gui.atlas.font.epoch(),
dynamic_visible: Some(&dynamic_visible),
};
let visible = r
.visible_block_entities_in_frustum(
self.settings.chunk_fade,
camera,
view,
&off_screen,
long_range,
)
.filter(|(block, section_inside)| {
let (pos, state) = **block;
self.res.block_entity_render().renders(state)
&& (off_screen(state) || *section_inside || in_view(&pos))
})
.map(|(block, _)| block);
let visible = camera_list.into_iter().chain(shadow_only.iter().copied());
let block_entity_cache = CacheOptions {
shulker_boxes: self.settings.horizon.cache_shulker_boxes,
signs: self.settings.horizon.cache_signs,
@@ -11575,7 +11694,11 @@ impl Game<'_> {
Some(&section_revision),
&mut out,
|_, state, start| self.mark_block_entity(start, state),
|_, _, _, _| {},
|pos, _, start, out| {
if shadow_only_at.contains(&pos) {
self.hide_spans_from(start, out.len());
}
},
);
} else {
let cache_candidates = r
@@ -12210,7 +12333,7 @@ impl Game<'_> {
);
}
/// `Player.canCriticalAttack` for a living target at attack strength
/// `scale`: falling, off the ground, not in water, not blind (mobility
/// `scale`: falling, off the ground, not in water, not on a ladder, not blind (mobility
/// restricted), not riding, not sprinting.
pub(super) fn critical_attack(&self, scale: f32) -> bool {
let player = self.player.as_ref();
@@ -12218,6 +12341,7 @@ impl Game<'_> {
!p.ground.on_ground
&& p.fall_distance > 0.0
&& !p.fluids.was_touching_water
&& !p.on_climbable
&& !p.sprinting
});
let restricted = self.potions.effects.get("blindness").is_some() || self.riding.is_some();
@@ -13814,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;
@@ -13865,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);
@@ -13981,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();
@@ -14432,11 +14556,7 @@ impl Game<'_> {
a.fired_from_weapon = weapon;
}
// Flame: the arrow is spawned burning (EnchantmentHelper.onProjectileSpawned).
if let Some(seconds) = self.bow_projectile_ignite()
&& let Some(a) = arrow.arrow.as_mut()
{
a.ignite_for_seconds(seconds);
}
self.ignite_spawned_arrow(stack, &mut arrow);
let (velocity, on_ground) = self.player.as_ref().map_or((Vec3::ZERO, true), |pl| {
(self.known_player_movement(), pl.ground.on_ground)
});
@@ -16120,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() {
@@ -16137,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 {
@@ -16158,6 +16283,8 @@ impl Game<'_> {
upgrade,
self.cursor,
);
pr.lap("pr_loading");
drop(prepare);
return renderer.render(&loading_frame(&hud));
}
return Ok(false);
@@ -16197,6 +16324,8 @@ impl Game<'_> {
Some(&status),
progress,
);
pr.lap("pr_loading");
drop(prepare);
return renderer.render(&loading_frame(&hud));
}
return Ok(false);
@@ -16213,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);
@@ -16229,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);
@@ -16240,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.
@@ -16279,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,
@@ -16302,6 +16438,7 @@ impl Game<'_> {
renderer.start_occlusion(camera, (self.streaming.radius * 16) as f32)
});
}
pr.lap("pr_occlusion");
let biome = self
.world
.chunks
@@ -16349,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 {
@@ -16372,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();
@@ -16457,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.
@@ -16515,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();
@@ -16536,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", || {
@@ -16552,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 {
@@ -16566,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
@@ -16579,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 {
@@ -16591,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 {
@@ -16605,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 {
@@ -16614,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.
@@ -16790,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);
@@ -16797,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).
@@ -16946,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!(
@@ -17049,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);
@@ -19217,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);
@@ -19239,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);
@@ -19259,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() {
@@ -19267,6 +19442,9 @@ impl Game<'_> {
} else {
Vec::new()
};
if horizon_client::spans::trace::due() {
horizon_client::spans::trace::stop();
}
if let Some(threshold) = self.options.spikes
&& let Some(r) = self.renderer.as_ref()
{
@@ -19920,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(),
@@ -19973,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,
@@ -20012,6 +20192,7 @@ impl<'a> Game<'a> {
sky_darken_memo: Default::default(),
water_plane_memo: Default::default(),
water_plane_scan: Default::default(),
vehicle_mob_reach: Default::default(),
persist: Default::default(),
entity_attachments: Default::default(),
remote: None,
@@ -20043,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(),
@@ -20943,11 +21125,9 @@ fn gameplay_tables(
// pulls through a `BufReader` and is measurably slower on a file this
// size than one read plus `from_slice`, which is how `BlockFacts` next
// door already reads its own. Same value either way.
let behaviour_bytes =
std::fs::read(root.join("block-behaviours.json")).map_err(|e| e.to_string())?;
let behaviour_json: serde_json::Value =
serde_json::from_slice(&behaviour_bytes).map_err(|e| e.to_string())?;
drop(behaviour_bytes);
horizon_world::json_file::read(&root.join("block-behaviours.json"))
.map_err(|e| e.to_string())?;
let (facts, gameplay_projection) =
BlockFacts::load_with_projection(root, assets.registry, &behaviour_json, parallel)
.map_err(|e| e.to_string())?;
@@ -21759,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);
@@ -22311,18 +22495,48 @@ impl ApplicationHandler for App {
/// manifest is `horizon-resources.json`): without them, say where they were
/// looked for and how to get them, before a window or a loader starts.
fn missing_resources(root: &std::path::Path) -> Option<String> {
if root.join("horizon-resources.json").is_file() {
return None;
}
let shown = std::path::absolute(root).unwrap_or_else(|_| root.to_path_buf());
Some(format!(
"Horizon's prepared game files are missing: no horizon-resources.json in {}\n\
Horizon is started from its launcher: download it at {} (Download). It needs your \
own Minecraft: Java Edition; the launcher signs you in and prepares the game files.\n\
Developers: horizon-prepare --prepare, or pass --resources <folder>",
shown.display(),
horizon_client::no_resources::DOWNLOAD_URL
))
if !root.join("horizon-resources.json").is_file() {
return Some(format!(
"Horizon's prepared game files are missing: no horizon-resources.json in {}\n\
Horizon is started from its launcher: download it at {} (Download). It needs your \
own Minecraft: Java Edition; the launcher signs you in and prepares the game files.\n\
Developers: horizon-prepare --prepare, or pass --resources <folder>",
shown.display(),
horizon_client::no_resources::DOWNLOAD_URL
));
}
damaged_prepared(root).map(|problem| {
format!(
"Horizon's prepared game files are damaged: {problem} in {}\n\
Prepare them again with the launcher, or: horizon-prepare --prepare",
shown.display()
)
})
}
/// Which top-level prepared file a killed preparation or an interrupted copy
/// left unusable. The loader parses these before it can start; naming one
/// beats serde's bare "expected value at line 1 column 1".
fn damaged_prepared(root: &std::path::Path) -> Option<String> {
let manifest = root.join("horizon-resources.json");
let bytes = std::fs::read(&manifest).ok()?;
if bytes.iter().all(u8::is_ascii_whitespace) {
return Some("horizon-resources.json is empty".into());
}
if serde_json::from_slice::<serde_json::Value>(&bytes).is_err() {
return Some("horizon-resources.json is not valid JSON".into());
}
for entry in std::fs::read_dir(root).ok()?.flatten() {
let path = entry.path();
if path.extension() != Some(std::ffi::OsStr::new("json")) {
continue;
}
if std::fs::metadata(&path).is_ok_and(|meta| meta.len() == 0) {
return Some(format!("{} is empty", entry.file_name().to_string_lossy()));
}
}
None
}
/// `Minecraft.<init>`: `startedCleanly` left false means the previous start
@@ -22419,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
@@ -22485,6 +22701,7 @@ pub fn run_with() -> Result<()> {
}
mods::register_components(&vanilla_root)?;
mods::register_standalone_models();
mods::register_layer_definitions();
let mut early_settings = Settings::load(&Game::options_path());
// Minecraft.<init>: read the last start's flag, then mark this one as not
// finished until onGameLoadFinished. A dedicated server has no options.txt.
@@ -22730,6 +22947,19 @@ mod startup_cpu_tests {
std::fs::create_dir_all(&root).unwrap();
std::fs::write(root.join("horizon-resources.json"), "{}").unwrap();
assert_eq!(missing_resources(&root), None);
// A killed export leaves an empty file behind; the error names it
// instead of serde's "expected value at line 1 column 1".
std::fs::write(root.join("gameplay-facts.json"), "").unwrap();
let damaged = missing_resources(&root).expect("an empty prepared file is reported");
assert!(damaged.contains("gameplay-facts.json"), "{damaged}");
assert!(damaged.contains("damaged"), "{damaged}");
std::fs::write(root.join("gameplay-facts.json"), "{}").unwrap();
assert_eq!(missing_resources(&root), None);
std::fs::write(root.join("horizon-resources.json"), " \n").unwrap();
let damaged = missing_resources(&root).expect("an empty manifest is reported");
assert!(damaged.contains("horizon-resources.json"), "{damaged}");
std::fs::remove_dir_all(&root).unwrap();
}
@@ -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
+23 -8
View File
@@ -55,7 +55,10 @@ impl Game<'_> {
// or stops one, and the block entities run into the thousands.
let mut jukeboxes: Option<Vec<(i32, i32, i32)>> = None;
for i in 0..self.mobs.len() {
if self.mobs[i].species() != Species::Allay || !self.mobs[i].alive() {
if self.mobs[i].species() != Species::Allay
|| !self.mobs[i].alive()
|| !self.creatures.ticked.contains(&self.mobs[i].id)
{
continue;
}
let pos = self.mobs[i].position;
@@ -199,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
@@ -246,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];
+14 -9
View File
@@ -869,6 +869,8 @@ impl Game<'_> {
let a = &mut self.armor_stands[i];
a.previous = a.position;
a.previous_yaw = a.yaw;
// Entity.baseTick clears firstTick only when the entity actually ticks.
a.first_tick = false;
// aiStep's freezing step (removeFrost) makes the speed instance.
a.speed_instance = true;
let (water_height, lava_height) =
@@ -908,7 +910,9 @@ impl Game<'_> {
if in_water {
a.enchantment_fall_distance = 0.0;
} else {
a.enchantment_fall_distance += (a.previous.y - a.position.y).max(0.0);
// Entity.checkFallDamage narrows each movement before adding to the double.
a.enchantment_fall_distance +=
f64::from((a.previous.y - a.position.y).max(0.0) as f32);
}
let landed = a.on_ground && a.enchantment_fall_distance > 0.0;
if a.on_ground {
@@ -1106,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();
@@ -1625,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,
@@ -1,5 +1,5 @@
//! The native parity harness runs the actual client boat ticker without a
//! renderer, on an empty world; movement ownership changes between ticks.
//! The native parity harness runs the client boat ticker and authoritative
//! boat attacks without a renderer, on an empty world.
use super::{Game, boats::Boat};
use horizon_client::Result;
use horizon_core::geometry::Vec3;
@@ -12,7 +12,43 @@ struct Case {
name: String,
velocity: [f64; 3],
spin: f32,
#[serde(default)]
owned: Vec<bool>,
#[serde(default)]
paddles: Option<PaddleCase>,
#[serde(default)]
attack: Option<Attack>,
}
#[derive(Deserialize)]
struct Attack {
yaw: f32,
sprint: bool,
ticks: u32,
player_velocity: [f64; 3],
#[serde(default)]
knockback: u8,
#[serde(default)]
creative: bool,
}
#[derive(Deserialize)]
struct PaddleCase {
initial: [f32; 2],
steps: Vec<PaddleStep>,
}
#[derive(Deserialize)]
struct PaddleStep {
occupied: bool,
#[serde(default)]
owned: bool,
#[serde(default = "is_player")]
first_player: bool,
flags: [bool; 2],
}
fn is_player() -> bool {
true
}
impl Game<'_> {
@@ -27,7 +63,69 @@ impl Game<'_> {
boat.velocity = Vec3::new(x, y, z);
boat.delta_rotation = case.spin;
self.boats.push(boat);
if let Some(attack) = case.attack {
let mut player =
horizon_client::motion::PlayerMotion::new(self.motion, Vec3::ZERO)?;
player.velocity = Vec3::new(
attack.player_velocity[0],
attack.player_velocity[1],
attack.player_velocity[2],
);
player.set_sprinting(attack.sprint)?;
self.player = Some(player);
self.inventory.fill(None);
self.selected = 0;
if attack.knockback > 0 {
self.inventory[0] = horizon_client::item_data::parse_stack(&format!(
r#"{{id:"minecraft:stick",count:1,components:{{"minecraft:enchantments":{{"minecraft:knockback":{}}}}}}}"#,
attack.knockback
))?;
}
self.yaw = attack.yaw;
self.attack_ticks = attack.ticks;
self.creative = attack.creative;
self.strike_boat(0);
let player = self.player.as_ref().unwrap();
output.push(json!({
"name": case.name,
"velocity": self.boats.first().map(|boat| boat.velocity.components().map(|v| format!("{:016x}", v.to_bits()))),
"destroyed": self.boats.is_empty(),
"player_velocity": player.velocity.components().map(|v| format!("{:016x}", v.to_bits())),
"sprint": player.sprinting,
}));
continue;
}
let mut steps = Vec::new();
if let Some(paddles) = case.paddles {
self.riding = None;
self.boats[0].paddles = paddles.initial;
for step in paddles.steps {
self.boats[0].paddling = step.flags;
self.boats[0].paddle_controller = step.occupied && step.first_player;
self.riding = step.owned.then_some(1);
if step.owned {
self.boats[0].input = [
step.flags[1] && !step.flags[0],
step.flags[0] && !step.flags[1],
step.flags[0] && step.flags[1],
false,
];
let boat = self.tick_boat(self.boats[0].clone(), true);
self.boats[0] = boat;
}
self.tick_remote_boat_bubbles();
let boat = &self.boats[0];
let rowing = [0.0, 0.25, 0.5, 1.0].map(|a| {
[0, 1].map(|side| format!("{:08x}", boat.rowing_time(side, a).to_bits()))
});
steps.push(json!({
"phase": boat.paddles.map(|p| format!("{:08x}", p.to_bits())),
"rowing": rowing,
}));
}
output.push(json!({"name": case.name, "steps": steps}));
continue;
}
for owned in case.owned {
self.riding = owned.then_some(1);
if owned {
@@ -7,7 +7,7 @@
use super::{Game, boats::Boat, lan_mobs::guest_bounds};
use horizon_client::{
camera::wrap_degrees,
mobs::{Mob, Species, Who, drops::is_animal, equine, riders},
mobs::{BoxReach, Mob, Species, Who, drops::is_animal, equine, riders},
scoreboard::pushable_by,
};
use horizon_core::geometry::{Aabb, Vec3};
@@ -68,6 +68,23 @@ pub(super) fn boat_pushes(boat: Aabb, other: Aabb, other_is_boat: bool) -> bool
impl Game<'_> {
/// The players in boat `id`: the window's player (or the server player
/// acting as it) and the server's other players.
/// Starts or ends a stretch of vehicle ticks (`tick_boats`, `tick_carts`): while one runs,
/// a vehicle's box query tests the mobs' positions against their [`BoxReach`] first.
/// Nothing a vehicle does resizes a mob, and a mob joining or leaving works it out again.
pub(super) fn vehicles_ticking(&self, on: bool) {
self.vehicle_mob_reach
.set(on.then(|| (self.mobs.len(), BoxReach::of(&self.mobs))));
}
/// The mobs' [`BoxReach`] while vehicles tick, none otherwise.
pub(super) fn vehicle_mob_reach(&self) -> Option<BoxReach> {
let (count, reach) = self.vehicle_mob_reach.get()?;
if count == self.mobs.len() {
return Some(reach);
}
let reach = BoxReach::of(&self.mobs);
self.vehicle_mob_reach.set(Some((self.mobs.len(), reach)));
Some(reach)
}
pub(super) fn boat_players(&self, id: u32) -> usize {
let guests = self.lan.as_ref().map_or(0, |h| {
h.hub
@@ -150,11 +167,14 @@ impl Game<'_> {
/// `AbstractBoat.canVehicleCollide`: the other boats, minecarts,
/// shulkers and pushable mobs and players, but not its own passengers.
pub(super) fn boat_obstacles(&self, id: u32, area: Aabb) -> Vec<Aabb> {
let reach = self.vehicle_mob_reach();
let mut out: Vec<Aabb> = (0..self.mobs.len())
.filter(|&j| {
let m = &self.mobs[j];
pushable(m)
// The box first: it turns away nearly every mob of the level.
reach.is_none_or(|r| r.may_meet(m, area))
&& m.bounds().intersects(area)
&& pushable(m)
&& (m.extra.mount.is_none() || self.mob_root_vehicle(j) != Some(id))
})
.map(|j| self.mobs[j].bounds())
@@ -203,9 +223,32 @@ impl Game<'_> {
let pick_up = self.boat_players(id) == 0;
// canAddPassenger, which startRiding asks after the checks below.
let room = !self.boats[i].under_water();
// The box first: it turns away nearly every mob of the level, and the
// teams are only worked out when something touches the boat.
let reach = self.vehicle_mob_reach();
let sweep = area.union_bounds(bb);
let near: Vec<usize> = (0..self.mobs.len())
.filter(|&j| {
if reach.is_some_and(|r| !r.may_meet(&self.mobs[j], sweep)) {
return false;
}
let mb = self.mobs[j].bounds();
mb.intersects(area) || mb.intersects(bb)
})
.collect();
let outside = self.players_outside(id);
let touching = !near.is_empty()
|| outside
.iter()
.any(|(pb, _)| pb.intersects(area) || pb.intersects(bb));
// (mob, touched by the boat's sweep, its own pushEntities reaches the boat)
let teams = self.mob_teams();
let mobs: Vec<(u32, bool, bool)> = (0..self.mobs.len())
let teams = if touching {
self.mob_teams()
} else {
Default::default()
};
let mobs: Vec<(u32, bool, bool)> = near
.into_iter()
.filter_map(|j| {
let m = &self.mobs[j];
if !pushable(m) || m.extra.mount.is_some() && self.mob_root_vehicle(j) == Some(id) {
@@ -222,8 +265,7 @@ impl Game<'_> {
})
.collect();
let player_team = teams.of(Who::Player);
let players: Vec<(Aabb, bool, bool, bool)> = self
.players_outside(id)
let players: Vec<(Aabb, bool, bool, bool)> = outside
.into_iter()
.map(|(pb, own)| {
let team = if own { player_team } else { None };
@@ -354,10 +396,17 @@ impl Game<'_> {
/// `positionRider` and `clampRotation` for the mobs in boats: each sits
/// in its seat facing the way the boat does, an animal in a full boat
/// sideways, its head kept within its reach of its body.
pub(super) fn seat_boat_mobs(&mut self) {
pub(super) fn seat_boat_mobs(&mut self, riding: &super::passengers::Riders) {
for i in 0..self.boats.len() {
let id = self.boats[i].id;
let mobs = self.boat_mobs(id);
// `boat_mobs`, from the riders found in one pass.
let mobs: Vec<usize> = riding
.get(&id)
.into_iter()
.flatten()
.copied()
.filter(|&j| !self.mobs[j].removed && self.mobs[j].alive())
.collect();
if mobs.is_empty() {
continue;
}
@@ -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(())
}
}
+68 -14
View File
@@ -84,6 +84,8 @@ pub(super) struct Boat {
pub no_gravity: bool,
pub paddles: [f32; 2],
pub paddling: [bool; 2],
/// Whether this boat has a player controlling its paddles.
pub paddle_controller: bool,
/// left, right, up, down
pub input: [bool; 4],
pub on_ground: bool,
@@ -192,6 +194,7 @@ impl Boat {
no_gravity: false,
paddles: [0.0; 2],
paddling: [false; 2],
paddle_controller: false,
input: [false; 4],
on_ground: false,
stuck: Vec3::ZERO,
@@ -324,9 +327,14 @@ impl Boat {
fired
}
/// `getRowingTime`.
pub fn paddle_state(&self, side: usize) -> bool {
self.paddling[side] && self.paddle_controller
}
pub fn rowing_time(&self, side: usize, a: f32) -> f32 {
if self.paddling[side] {
self.paddles[side] - PI / 8.0 + a * PI / 8.0
if self.paddle_state(side) {
let current = self.paddles[side];
let previous = current - PI / 8.0;
previous + a.clamp(0.0, 1.0) * (current - previous)
} else {
0.0
}
@@ -642,6 +650,11 @@ impl Game<'_> {
b.tick_submersion();
b.hurt_time = (b.hurt_time - 1).max(0);
b.damage = (b.damage - 1.0).max(0.0);
// A remote client gets the controller from the passenger packet, even when
// this window occupies the second seat. Local worlds derive it here.
if self.remote.is_none() {
b.paddle_controller = controlled;
}
if !controlled {
b.paddling = [false; 2];
b.input = [false; 4];
@@ -776,9 +789,18 @@ impl Game<'_> {
None,
);
b.velocity = horizon_world::entity_move::scale_by_block_speed(b.velocity, factor);
Self::tick_boat_paddles(&mut b, &mut self.audio, &mut self.world.random);
b
}
/// `AbstractBoat.tick` advances the oars even when this client does not move the hull.
pub(super) fn tick_boat_paddles(
b: &mut Boat,
audio: &mut super::Audio,
random: &mut horizon_core::random::LegacyRandom,
) {
// Paddle sounds at the top of each stroke.
for i in 0..2 {
if b.paddling[i] {
if b.paddle_state(i) {
let p = b.paddles[i] % (2.0 * PI);
if p <= PI / 4.0 && (b.paddles[i] + PI / 8.0) % (2.0 * PI) >= PI / 4.0 {
let sound = match b.status {
@@ -791,8 +813,8 @@ impl Game<'_> {
if let Some(sound) = sound {
let v = view_vector(b.yaw, 0.0);
let (dx, dz) = if i == 1 { (-v[2], v[0]) } else { (v[2], -v[0]) };
let pitch = 0.8 + 0.4 * self.world.random.next_float();
self.audio.play(
let pitch = 0.8 + 0.4 * random.next_float();
audio.play(
sound,
Some([b.position.x + dx, b.position.y, b.position.z + dz]),
1.0,
@@ -805,10 +827,14 @@ impl Game<'_> {
b.paddles[i] = 0.0;
}
}
b
}
/// Every boat but the ridden one (which ticks with its rider).
pub(super) fn tick_boats(&mut self) {
self.vehicles_ticking(true);
self.tick_boats_now();
self.vehicles_ticking(false);
}
fn tick_boats_now(&mut self) {
// ServerLevel.tick: only entities in entity-ticking chunks tick.
let ticking = self.entity_ticking_area();
let mut round = self.native_tick_round();
@@ -908,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)
}
@@ -1418,16 +1444,40 @@ impl Game<'_> {
if !self.before_block(d) || self.mob_hit().is_some_and(|m| m.1 < d) {
return false;
}
self.strike_boat(i);
self.swing_arm();
true
}
/// The accepted boat target, shared by aimed attacks and headless parity.
pub(super) fn strike_boat(&mut self, i: usize) {
let area = self.boats[i].bounds();
// Player.attack's full damage against the boat's entity type.
let (kind, id) = (self.boats[i].entity_type(), self.boats[i].id as i32);
let (damage, _, _, magic) = self.melee_damage(&kind, false);
self.hurt_boat(i, damage, self.creative);
let (damage, scale, _, magic) = self.melee_damage(&kind, false);
let hurt = self.hurt_boat_result(i, damage, self.creative);
self.vehicle_attack_sounds(area, magic.then_some(id));
if damage > 0.0
&& let Some(destroyed) = hurt
{
let (knockback, _) = self.extra_knockback(&kind, scale);
if knockback > 0.0 {
// Player.causeExtraKnockback uses Entity.push for a boat,
// not LivingEntity.knockback (no resistance or velocity halving).
if !destroyed {
let (x, z) = self.extra_knockback_direction();
self.boats[i].velocity.x += f64::from(-(x as f32) * knockback);
self.boats[i].velocity.y += 0.1;
self.boats[i].velocity.z += f64::from(-(z as f32) * knockback);
}
if let Some(player) = self.player.as_mut() {
player.velocity.x *= horizon_client::attack_strength::ATTACKER_SLOWDOWN;
player.velocity.z *= horizon_client::attack_strength::ATTACKER_SLOWDOWN;
}
self.stop_sprint_after_extra_knockback();
}
}
// Player.onAttack: the hit starts the attack strength again.
self.attack_ticks = 0;
self.swing_arm();
true
}
/// `VehicleEntity.hurtServer`: wobble, add the damage and break past 40
/// (`creative`: a creative player's hit discards it straight away).
@@ -1435,11 +1485,15 @@ impl Game<'_> {
/// reason that destroys the boat (DISCARDED included), while the boat item
/// itself only drops on the death path (`VehicleEntity.destroy`), not when
/// a creative hit discards it. True when the boat was removed.
pub(super) fn hurt_boat(&mut self, i: usize, mut damage: f32, creative: bool) -> bool {
pub(super) fn hurt_boat(&mut self, i: usize, damage: f32, creative: bool) -> bool {
self.hurt_boat_result(i, damage, creative).unwrap_or(false)
}
/// None means a canceled hit; Some records whether an accepted hit destroyed it.
fn hurt_boat_result(&mut self, i: usize, mut damage: f32, creative: bool) -> Option<bool> {
let mut cancel = false;
self.native_vehicle_damage("boat", &mut damage, &mut cancel);
if cancel {
return false;
return None;
}
let b = &mut self.boats[i];
b.hurt_dir = -b.hurt_dir;
@@ -1478,7 +1532,7 @@ impl Game<'_> {
);
}
}
destroyed
Some(destroyed)
}
/// AbstractBoatRenderer.
pub(super) fn boat_geometry(&self, camera: glam::DVec3, alpha: f64, out: &mut Vec<Vertex>) {
@@ -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`
+85 -16
View File
@@ -163,8 +163,8 @@ impl Game<'_> {
self.other_to_nbt(&o, uuid)
}
Target::Orb(id) => {
let tag =
entity_nbt::orb_to_nbt(self.orbs.iter().find(|o| o.id == id)?, random_uuid());
let uuid = self.uuid_for(id);
let tag = entity_nbt::orb_to_nbt(self.orbs.iter().find(|o| o.id == id)?, uuid);
crate::game::persist::with_raw(tag, self.persist.raw.get(&id))
}
Target::Projectile(id) => {
@@ -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();
@@ -394,8 +383,10 @@ impl Game<'_> {
}
}
Target::Stand(id) => {
let new = entity_nbt::armor_stand_from_nbt(&tag, id).ok_or_else(stale)?;
let mut new = entity_nbt::armor_stand_from_nbt(&tag, id).ok_or_else(stale)?;
if let Some(slot) = self.armor_stands.iter_mut().find(|a| a.id == id) {
// Entity.load keeps the existing entity's firstTick state.
new.first_tick = slot.first_tick;
*slot = new;
}
}
@@ -1208,6 +1199,84 @@ impl Game<'_> {
}
}
#[cfg(test)]
mod orb_uuid_tests {
use super::Target;
use crate::game::{Audio, Game, Options, Progress, Settings, load_resources};
use horizon_world::nbt::Tag;
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn saved_orb_uuid_comes_from_registered_identity_without_or_despite_raw() {
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();
game.build_registries();
let uuid = [0, 0, 0, 2];
let tag = Tag::compound([
("UUID", Tag::IntArray(uuid.to_vec())),
(
"Pos",
Tag::List(vec![
Tag::Double(4.5_f64.to_bits()),
Tag::Double(200.0_f64.to_bits()),
Tag::Double(4.5_f64.to_bits()),
]),
),
("Value", Tag::Short(1)),
("Age", Tag::Short(0)),
("Health", Tag::Short(5)),
("NoGravity", Tag::Byte(1)),
]);
game.load_orb(tag, Some(uuid));
assert_eq!(game.orbs.len(), 1);
let id = game.orbs[0].id;
assert_eq!(game.persist.uuids.get(&id), Some(&uuid));
let time = game.world.game_time;
for raw in [
None,
Some(Tag::compound([("UUID", Tag::IntArray(vec![9, 9, 9, 9]))])),
] {
if let Some(raw) = &raw {
game.persist.raw.insert(id, raw.clone());
} else {
game.persist.raw.remove(&id);
}
for _ in 0..3 {
let saved = game.entity_nbt_in_level(Target::Orb(id)).unwrap();
assert_eq!(saved.get("UUID"), Some(&Tag::IntArray(uuid.to_vec())));
assert_eq!(game.persist.uuids.get(&id), Some(&uuid));
assert_eq!(game.persist.raw.get(&id), raw.as_ref());
assert_eq!(game.world.game_time, time);
}
}
}
}
#[cfg(test)]
mod wolf_live_anger_tests {
use super::Target;
+147 -14
View File
@@ -592,21 +592,52 @@ impl Game<'_> {
}
true
}
Slot::Stand(id, i) => match self
.armor_stands
.iter_mut()
.find(|a| a.id == *id)
.and_then(|a| a.equipment.get_mut(*i))
{
Some(slot) => {
*slot = stack;
true
Slot::Stand(id, i) => {
// LivingEntity.onEquipItem: all stand slots emit; the new
// stack's EQUIPPABLE presence chooses equip versus unequip.
let equippable = stack.as_ref().is_some_and(|s| {
let data = s.data();
!data.is_some_and(|d| d.other("!minecraft:equippable").is_some())
&& (data.is_some_and(|d| d.other("minecraft:equippable").is_some())
|| self
.items()
.get(&s.item)
.is_some_and(|i| i.equip_slot.is_some()))
});
let Some(a) = self.armor_stands.iter_mut().find(|a| a.id == *id) else {
return false;
};
let Some(slot) = a.equipment.get_mut(*i) else {
return false;
};
let same = match (slot.as_ref(), stack.as_ref()) {
(None, None) => true,
(Some(old), Some(new)) => old.same_item_same_components(new),
_ => false,
};
let emit = !a.first_tick && !same;
let at = a.position;
*slot = stack;
if emit && self.remote.is_none() {
self.post_game_event(
if equippable {
"minecraft:equip"
} else {
"minecraft:unequip"
},
[at.x, at.y, at.z],
horizon_client::sculk::Context {
source: Some(horizon_client::mobs::Who::Mob(*id)),
living: true,
..Default::default()
},
);
}
None => false,
},
Slot::Frame(id) => match self.item_frames.iter_mut().find(|f| f.id == *id) {
Some(f) => {
f.item = stack;
true
}
Slot::Frame(id) => match self.item_frames.iter().position(|f| f.id == *id) {
Some(i) => {
self.set_frame_item(i, stack);
true
}
None => false,
@@ -1390,3 +1421,105 @@ fn list_operation(m: &serde_json::Value, old: Vec<Tag>, new: Vec<Tag>) -> Vec<Ta
out.truncate(256);
out
}
#[cfg(test)]
mod stand_equip_event_tests {
use crate::game::{Audio, Game, Options, Progress, Settings, load_resources};
use horizon_client::{chunk::Chunk, containers::BlockEntity, mobs::Who, sculk::Info};
use horizon_core::position::ChunkPos;
use std::sync::Arc;
fn candidate(game: &Game<'_>) -> Option<Info> {
let Some(BlockEntity::Sculk(sensor)) = game.world.block_entities.get(&(9, 200, 10)) else {
panic!("actual sensor must be registered")
};
sensor.vibration.selector.current.map(|(info, _)| info)
}
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn commands_post_stand_equipment_events_after_its_first_tick() {
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-h130-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.build_registries();
game.set_allow_commands(true);
let registry = game.pack.assets.registry;
let sensor = registry.default_state("minecraft:sculk_sensor").unwrap();
let mut chunk = Chunk::empty(0, 0);
chunk.set(9, 200, 10, sensor.0);
game.world.chunks.insert((0, 0), Arc::new(chunk));
game.block_ticks
.add_empty_chunk(ChunkPos::new(0, 0))
.unwrap();
assert!(game.run_command("forceload add 0 0"));
assert!(game.run_command("summon armor_stand 6.5 200 10.5 {UUID:[I;130,0,0,1],NoGravity:1b,attributes:[{id:\"minecraft:movement_speed\",base:0.1d}]}"));
let id = game.armor_stands[0].id;
assert_eq!(game.uuid_for(id), [130, 0, 0, 1]);
assert!(
game.armor_stands[0].speed_instance,
"a loaded attribute is not firstTick"
);
game.sculk_prepare();
game.sculk_sync_index();
assert!(candidate(&game).is_none());
let prefix = "item replace entity 00000082-0000-0000-0000-000000000001 ";
for item in ["iron_helmet", "air"] {
assert!(game.run_command(&format!("{prefix}armor.head with {item}")));
assert!(candidate(&game).is_none(), "first-tick suppression");
}
game.tick_armor_stands().unwrap();
assert!(
game.run_command(
"data merge entity 00000082-0000-0000-0000-000000000001 {ShowArms:1b}"
)
);
for (slot, item, event) in [
("armor.head", "iron_helmet", Some("minecraft:equip")),
("armor.head", "iron_helmet", None),
("armor.head", "air", Some("minecraft:unequip")),
("armor.head", "air", None),
("weapon.mainhand", "stick", Some("minecraft:unequip")),
] {
let Some(BlockEntity::Sculk(sensor)) = game.world.block_entities.get_mut(&(9, 200, 10))
else {
unreachable!()
};
sensor.vibration = Default::default();
assert!(game.run_command(&format!("{prefix}{slot} with {item}")));
let heard = candidate(&game);
assert_eq!(heard.map(|info| info.event), event, "{slot} -> {item}");
if let Some(info) = heard {
assert_eq!(info.source, Some(Who::Mob(id)));
assert_eq!(info.pos, [6.5, 200.0, 10.5]);
assert!(info.owner.is_none());
}
}
}
}
@@ -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;
};
@@ -9,6 +9,9 @@ use horizon_client::{
};
use horizon_core::{position::ChunkPos, random::RandomSource};
/// How long `/forceload add` waits for its chunks, as the game tests' loads do.
const FORCED_CHUNK_WAIT: std::time::Duration = std::time::Duration::from_secs(120);
fn num(n: impl std::fmt::Display) -> Text {
Text::literal(n.to_string())
}
@@ -42,6 +45,23 @@ impl Game<'_> {
let p = self.resolve_coords(&c);
Ok([p[0].floor() as i32, p[2].floor() as i32])
}
/// `ServerLevel.setChunkForced`'s `getChunk`: a command right after
/// `/forceload add` (`/place`, `/setblock`) finds the chunks loaded.
fn wait_for_forced_chunks(&mut self, min: [i32; 2], max: [i32; 2]) {
let missing = |g: &Self| {
(min[0]..=max[0])
.any(|x| (min[1]..=max[1]).any(|z| !g.world.chunks.contains_key(&(x, z))))
};
let deadline = std::time::Instant::now() + FORCED_CHUNK_WAIT;
while missing(self) {
if std::time::Instant::now() >= deadline {
log::warn!("Horizon: /forceload: chunks {min:?}..{max:?} are not loaded yet");
return;
}
let _ = self.streaming.update(&mut self.world);
self.streaming.wait(std::time::Duration::from_millis(5));
}
}
pub(super) fn cmd_forceload(&mut self, id: &str, ctx: &Context) -> R<i32> {
let dim = Text::literal(self.dim.id());
match id {
@@ -128,6 +148,7 @@ impl Game<'_> {
}
if add {
self.streaming.rank_forced(cx0, cz0, cx1, cz1);
self.wait_for_forced_chunks([cx0, cz0], [cx1, cz1]);
}
let (lo, hi) = (chunk_text(cx0, cz0), chunk_text(cx1, cz1));
let verb = if add { "added" } else { "removed" };
@@ -518,12 +539,28 @@ impl Game<'_> {
let (tag, _) = horizon_world::nbt::parse_snbt(&table)
.map_err(|e| err("argument.nbt.invalid", &[&e]))?;
let json = horizon_client::scoreboard::tag_to_json(&tag);
// LootCommand's omitted fishing tool is EMPTY, not mainhand.
let held = if id == "loot.fish"
&& (ctx.get("tool").is_some() || ctx.has("mainhand") || ctx.has("offhand"))
{
self.tool_stack(ctx)?
} else {
None
};
let drops = match json["random_sequence"].as_str() {
Some(sequence) => {
let random = self.rand_sequences.get(sequence, self.level.seed);
world_items!(self).inline_loot_table(&json, random)
world_items!(self).inline_loot_table_with_tool(
&json,
held.as_ref(),
random,
)
}
None => world_items!(self).inline_loot_table(&json, &mut self.world.random),
None => world_items!(self).inline_loot_table_with_tool(
&json,
held.as_ref(),
&mut self.world.random,
),
};
(drops, None)
} else {
+26
View File
@@ -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))
}
@@ -435,6 +440,27 @@ impl Game<'_> {
}
Ok(Some(json!({ "ok": true, "gpu_passes": self.gpu_passes })))
}
// Every thread's spans as a timeline Perfetto opens (`spans::trace`).
"trace" => match words.get(1).copied() {
None => Ok(Some(
json!({ "ok": true, "tracing": horizon_client::spans::trace::on() }),
)),
Some("stop") => Ok(Some(
json!({ "ok": true, "events": horizon_client::spans::trace::stop() }),
)),
Some(file) => {
let seconds = match words.get(2) {
None => None,
Some(s) => Some(
s.parse::<u32>()
.map_err(|_| "trace [FILE [SECONDS] | stop]")?,
),
};
horizon_client::spans::trace::start(std::path::Path::new(file), seconds)
.map_err(|e| format!("trace {file}: {e}"))?;
Ok(Some(json!({ "ok": true, "tracing": true, "file": file })))
}
},
"render-capture" => match words.get(1).copied() {
None => {
let r = self.renderer.as_ref().ok_or("no renderer")?;
+271 -48
View File
@@ -105,6 +105,14 @@ pub(super) struct Creatures {
pub lan_explosions: Vec<([f64; 3], bool, f32, usize, Vec<(u32, [f64; 3])>)>,
/// Ticks left of the elder guardian's apparition (MobAppearanceParticle).
pub elder_apparition: i32,
/// The snapshot's buffer between ticks, empty: ~400 bytes a mob, so
/// thousands of mobs would allocate and free megabytes every tick.
seen_buf: Vec<Seen>,
/// The mobs whose own tick ran in the last `tick_creature_entities`:
/// `ServerLevel.tick` ticks only the entities in entity-ticking range, so
/// what the passes after the loop do for a mob's `aiStep`, goals or brain
/// (pickups, item searches) is for these alone.
pub ticked: horizon_client::fast_hash::FxHashSet<u32>,
/// The wandering trader spawner's clock.
pub trader: super::wandering_trader::Spawner,
/// When each player last put a parrot on a shoulder (`timeEntitySatOnShoulder`).
@@ -136,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)
});
@@ -702,6 +713,9 @@ impl Game<'_> {
rider.extra.mount = Some(id);
}
}
if let Some(raw) = horizon_client::entity_nbt::conversion_tags(self.persist_raw(from.id)) {
self.persist.raw.insert(id, raw);
}
self.mobs.push(mob);
self.native_mobs_joined(self.mobs.len() - 1, false);
let transformed_kind = self
@@ -964,6 +978,8 @@ impl Game<'_> {
/// The entity phase of `tick_creatures`: every mob's tick, deaths, and the
/// projectiles and effects that follow from them.
pub(super) fn tick_creature_entities(&mut self) -> Result<()> {
// Its steps as spans for `horizon-report` (nothing is read while spans are off).
let mut laps = horizon_client::spans::laps();
// LivingEntity.die runs when the killing blow lands: mobs killed
// since the last tick (by the player, explosions...) die first.
let killed: Vec<Mob> = self
@@ -996,15 +1012,21 @@ impl Game<'_> {
}
let uuid = self.level.player_uuid;
let level_id = self.dim.id();
let mut snapshot: Vec<Seen> = self.mobs.iter().map(|m| seen(m, uuid)).collect();
let mut snapshot = std::mem::take(&mut self.creatures.seen_buf);
snapshot.extend(self.mobs.iter().map(|m| seen(m, uuid)));
let mut events = Vec::new();
let mut mobs = std::mem::take(&mut self.mobs);
let mut dead = Vec::new();
let mut corpses = Vec::new();
let mut died = std::mem::take(&mut self.died);
let mut ticked = std::mem::take(&mut self.creatures.ticked);
ticked.clear();
let ticking = self.entity_ticking_area();
// With guests on, each mob sees its nearest player (game/lan_mobs.rs).
let guests = self.lan_guest_views();
let mut guests = self.lan_guest_views();
// What stands in a guest's slot while a mob holds that guest's view for
// its tick (see `lent` below).
let mut spare = guests.first().map(|g| g.1.clone());
let players = self.level_players();
let nearest = super::natural_spawning::NearestPlayers::new(&players);
let mut bindings = match self.lan.as_mut() {
@@ -1059,6 +1081,7 @@ impl Game<'_> {
villager_slots.push(i as u32);
}
}
laps.lap("creature_prepare");
let mut cached_difficulty: Option<((i32, i32), spawning::LocalDifficulty)> = None;
for i in 0..mobs.len() {
let m = &mut mobs[i];
@@ -1085,6 +1108,29 @@ impl Game<'_> {
let nearest_player = nearest
.nearest(m.position.x, m.position.y, m.position.z)
.map(|p| p.position);
// ServerLevel.tick: every loaded entity checks despawning, but
// only those in entity-ticking chunks (within the simulation
// distance) tick. The despawn check reads the nearest player
// and the difficulty, not the player the mob sees.
if !ticking(bx, bz) {
let w = MobWorld {
others: &snapshot,
others_grid: Some(&grid),
others_index: Some(&index),
others_villagers: Some(&villager_slots),
nearest_player,
player: None,
teams: std::sync::Arc::clone(&base.teams),
..base
};
let kept = horizon_client::mobs::ai::despawn_check(m, &w);
if !kept {
died.remove(&m.id);
snapshot[i].alive = false;
}
keep.push(kept);
continue;
}
let controlling_player_movement = horizon_client::mobs::equine::controlled(m)
.then(|| {
base.player
@@ -1099,36 +1145,6 @@ impl Game<'_> {
})
})
.flatten();
// ServerLevel.tick: every loaded entity checks despawning, but
// only those in entity-ticking chunks (within the simulation
// distance) tick.
if !ticking(bx, bz) {
let active_player = match seen_guest {
Some(conn) => guests.iter().find(|g| g.0 == conn).map(|g| g.1.clone()),
None => base.player.take(),
};
let mut w = MobWorld {
others: &snapshot,
others_grid: Some(&grid),
others_index: Some(&index),
others_villagers: Some(&villager_slots),
nearest_player,
player: active_player,
teams: std::sync::Arc::clone(&base.teams),
..base
};
w.controlling_player_movement = controlling_player_movement;
let kept = horizon_client::mobs::ai::despawn_check(m, &w);
if seen_guest.is_none() {
base.player = w.player;
}
if !kept {
died.remove(&m.id);
snapshot[i].alive = false;
}
keep.push(kept);
continue;
}
// A mod canceled its tick: it stays as it is.
if round.canceled(m.id) {
keep.push(true);
@@ -1149,9 +1165,16 @@ impl Game<'_> {
horizon_client::mobs::apply_mob_pushes(m, &mut pushes);
horizon_client::mobs::blocks_changed(m, &path_changes);
let from = events.len();
let active_player = match seen_guest {
Some(conn) => guests.iter().find(|g| g.0 == conn).map(|g| g.1.clone()),
None => base.player.take(),
// The guest's view is lent to the mob for its tick, not copied:
// the tick only reads it (`&MobWorld`), and it goes back to the
// guest's slot straight after.
let lent = seen_guest.and_then(|conn| guests.iter().position(|g| g.0 == conn));
let active_player = match (seen_guest, lent) {
(None, _) => base.player.take(),
(Some(_), Some(k)) => spare
.take()
.map(|stand_in| std::mem::replace(&mut guests[k].1, stand_in)),
(Some(_), None) => None,
};
let mut w = MobWorld {
others: &snapshot,
@@ -1165,6 +1188,7 @@ impl Game<'_> {
..base
};
w.controlling_player_movement = controlling_player_movement;
ticked.insert(mobs[i].id);
// `horizon-report`'s cost by mob type (tick_profile.rs).
let sampled = super::tick_profile::start().map(|t| (t, mobs[i].kind.clone()));
let kept =
@@ -1175,6 +1199,8 @@ impl Game<'_> {
let m = &mut mobs[i];
if seen_guest.is_none() {
base.player = w.player;
} else if let Some(k) = lent {
spare = w.player.map(|v| std::mem::replace(&mut guests[k].1, v));
}
// A killed mob's body is removed: its effects that act on
// death run (`LivingEntity.remove` with KILLED).
@@ -1225,6 +1251,7 @@ impl Game<'_> {
grid.update(i, snapshot[i].bounds);
horizon_client::mobs::apply_brain_events(&mut mobs, &mut events, from);
}
laps.lap("creature_loop");
let mut flags = keep.into_iter();
mobs.retain(|_| flags.next().unwrap_or(true));
for m in &mut mobs {
@@ -1252,6 +1279,9 @@ impl Game<'_> {
self.mobs.append(&mut mobs);
}
self.died = died;
snapshot.clear();
self.creatures.seen_buf = snapshot;
self.creatures.ticked = ticked;
self.native_tick_post_batch(round);
// ... and entity event 60 goes out as it is: the poof.
for (id, at, width, height) in gone {
@@ -1259,6 +1289,7 @@ impl Game<'_> {
self.lan_entity_event(id as i32, 60, at);
self.death_poof(at, width, height);
}
laps.lap("creature_retain");
self.tick_boarding();
self.sync_riders();
self.tick_skeleton_traps();
@@ -1267,6 +1298,7 @@ impl Game<'_> {
self.villager_reputation_tick();
self.villager_item_sense();
self.villager_pickups();
laps.lap("creature_villagers");
for m in dead {
self.mob_died(&m);
}
@@ -1282,9 +1314,11 @@ impl Game<'_> {
}
self.handle_mob_events(events)?;
self.lan_guest_mob_events(guest_events);
laps.lap("creature_events");
self.ordered_before(super::ordered_tick::Batch::Projectiles)?;
self.tick_projectiles()?;
self.tick_fangs();
laps.lap("creature_projectiles");
self.tick_allays();
self.tick_pandas();
self.tick_foxes();
@@ -1295,12 +1329,14 @@ impl Game<'_> {
self.piglin_pickups();
self.sulfur_cube_pickups();
self.sulfur_cube_power_priming();
laps.lap("creature_systems");
// Hurt and death sounds of damage dealt outside the mob ticks.
let mut queued = Vec::new();
for m in &mut self.mobs {
queued.append(&mut m.outbox);
}
self.handle_mob_events(queued)?;
laps.lap("creature_outbox");
Ok(())
}
/// `LivingEntity.makePoofParticles` (entity event 60) about a mob's box.
@@ -2086,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,
@@ -2128,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!(
@@ -2139,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);
@@ -7104,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
@@ -7365,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
@@ -7903,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;
@@ -8454,3 +8576,104 @@ mod dye_mix_tests {
assert!(kept == orange || kept == cyan, "kept {kept}");
}
}
#[cfg(test)]
mod conversion_tag_tests {
use super::*;
use crate::game::{Audio, Options, Progress, Settings, commands::Target, load_resources};
use horizon_world::nbt::Tag;
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn converted_mobs_keep_command_tags_at_the_new_id_and_on_save() {
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();
game.build_registries();
for (number, (from, to)) in [
("husk", "zombie"),
("zombie", "drowned"),
("skeleton", "stray"),
]
.into_iter()
.enumerate()
{
let uuid = [10374, 1, 0, number as i32];
let tags = Tag::List(vec![
Tag::String("subject".into()),
Tag::String(from.into()),
]);
let raw = Tag::compound([
(
"id",
Tag::String(format!("minecraft:{from}").as_str().into()),
),
("UUID", Tag::IntArray(uuid.to_vec())),
(
"Pos",
Tag::List(vec![
Tag::Double(0.5_f64.to_bits()),
Tag::Double(70.0_f64.to_bits()),
Tag::Double(0.5_f64.to_bits()),
]),
),
("NoAI", Tag::Byte(1)),
("NoGravity", Tag::Byte(1)),
("Tags", tags.clone()),
("horizon:old_entity_only", Tag::Byte(1)),
]);
let old_id = game.load_mob(raw, None).unwrap();
assert_eq!(game.tags_of(Target::Mob(old_id)), ["subject", from]);
let source = game.mobs.iter().find(|m| m.id == old_id).unwrap().clone();
let new_id = game
.convert_to(
&source,
&format!("minecraft:{to}"),
horizon_client::mobs::conversion::Params::KEEP_ALL,
)
.unwrap();
assert_ne!(old_id, new_id);
assert_eq!(
game.tags_of(Target::Mob(new_id)),
["subject", from],
"converted ID must keep tags"
);
let index = game.mobs.iter().position(|m| m.id == new_id).unwrap();
let saved = game.mob_tree_nbt(index);
assert_eq!(saved.get("Tags"), Some(&tags));
assert_eq!(
horizon_client::entity_nbt::kind(&saved).as_deref(),
Some(format!("minecraft:{to}").as_str())
);
assert_ne!(horizon_client::entity_nbt::uuid(&saved), Some(uuid));
assert!(saved.get("horizon:old_entity_only").is_none());
game.mobs.remove(index);
let reloaded = game.load_mob(saved, None).unwrap();
assert_eq!(game.tags_of(Target::Mob(reloaded)), ["subject", from]);
}
}
}
@@ -371,6 +371,8 @@ impl Game<'_> {
a.fired_from_weapon = Some(weapon.clone());
a.pierce = pierce;
}
// Flame: the arrow is spawned burning (EnchantmentHelper.onProjectileSpawned).
self.ignite_spawned_arrow(&weapon, &mut arrow);
arrow.shoot(&mut self.world.random, dir.to_array(), power, 1.0);
self.add_projectile(arrow);
let (player, name, projectile) = (
@@ -488,6 +490,39 @@ mod tests {
assert_eq!(at, vec![feet]);
}
/// `AbstractArrow`'s constructor runs `EnchantmentHelper.onProjectileSpawned`
/// for the weapon the arrow is fired from, bow or crossbow, so a Flame
/// crossbow shoots burning arrows: Flame's 5 second ignite, 100 ticks, on
/// every arrow of a Multishot volley (#10482).
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn a_flame_crossbow_shoots_burning_arrows() {
let root = std::path::PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES").unwrap());
let (mut game, _) = game_with_player(&root);
// Shoots a crossbow with `enchantments` and one arrow loaded per shot,
// and returns how long each new arrow burns for.
let mut shoot = |enchantments: &[&str], shots: usize| -> Vec<i32> {
let mut crossbow = ItemStack::new(CROSSBOW, 1);
for id in enchantments {
crossbow.enchant(id, 1);
}
crossbow.data_mut().charged = vec![ItemStack::new(ARROW, 1); shots];
*game.held_mut() = Some(crossbow);
let before = game.creatures.projectiles.len();
game.shoot_crossbow();
game.creatures.projectiles[before..]
.iter()
.map(|p| p.arrow.as_ref().expect("an arrow").fire_ticks)
.collect()
};
assert_eq!(shoot(&[], 1), vec![0]);
assert_eq!(shoot(&["minecraft:flame"], 1), vec![100]);
assert_eq!(
shoot(&["minecraft:flame", "minecraft:multishot"], 3),
vec![100; 3]
);
}
/// A charge that crosses a point between two ticks plays that point's
/// sound, and one a mod's tick jumps over (`native_living_entity_use_item_tick`
/// cutting the ticks left) still plays every point it passed, as
+3 -3
View File
@@ -67,9 +67,6 @@ impl Game<'_> {
/// own handling. Returns true when it is used up (a debug combination,
/// F3 itself, F3+Escape).
pub(super) fn debug_key(&mut self, key: KeyCode, action: KeyAction) -> bool {
if self.title.is_some() {
return false;
}
let Some(name) = keybinds::key_name(key) else {
return false;
};
@@ -713,6 +710,9 @@ impl Game<'_> {
self.debug_charts.toggle_lightmap(&mut self.debug);
true
}
// The debug options screen is not wired over the title screen yet:
// leave F6 unhandled there rather than half-open it.
DebugAction::DebugOptions if self.title.is_some() => false,
DebugAction::DebugOptions => {
self.toggle_debug_options();
true
@@ -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,
@@ -1119,6 +1132,11 @@ impl Game<'_> {
.raw
.get(horizon_client::lan::HORIZON_CLIENTS_ONLY)
.is_some_and(|v| v.trim().eq_ignore_ascii_case("true")),
accept_older_clients: props
.raw
.get(horizon_client::lan::ACCEPT_OLDER_CLIENTS)
.is_some_and(|v| v.trim().eq_ignore_ascii_case("true")),
older_clients_play: false,
lod_distance: horizon_client::lod_net::distance_property(
props.raw.get(horizon_client::lod_net::PROPERTY),
),
@@ -1338,6 +1356,14 @@ impl Game<'_> {
{
self.finish_report(recording, state.view, state.max_players);
}
if horizon_client::spans::trace::due()
&& let Some(events) = horizon_client::spans::trace::stop()
{
log(
"INFO",
&format!("Trace written: {events} events (reports/)"),
);
}
// DedicatedServer.sendLowDiskSpaceWarning: the operators hear of it
// (no window drains the toasts here).
if horizon_client::chunk_io::take().low_disk_space
@@ -1640,6 +1666,38 @@ impl Game<'_> {
}
}
}
// Horizon's own, on the console only: every thread's spans as a timeline (report.rs).
"horizon-trace" if rest.first() == Some(&"stop") => {
match horizon_client::spans::trace::stop() {
Some(events) => log("INFO", &format!("Trace stopped: {events} events written")),
None => log("INFO", "No trace is being written"),
}
}
"horizon-trace" => {
let seconds = match rest.first().map(|s| s.parse::<u32>()) {
None => Some(30),
Some(Ok(s)) if (1..=report::TRACE_MAX_SECONDS).contains(&s) => Some(s),
Some(_) => None,
};
match seconds.map(report::start_trace) {
None => log(
"INFO",
&format!(
"horizon-trace [SECONDS | stop]: 1 to {} seconds, 30 by default",
report::TRACE_MAX_SECONDS
),
),
Some(Ok(path)) => log(
"INFO",
&format!(
"Writing a trace for {} s to {} (open it in ui.perfetto.dev)",
seconds.unwrap_or_default(),
path.display()
),
),
Some(Err(e)) => log("ERROR", &format!("Could not start a trace: {e}")),
}
}
"save-all" => {
self.console_reply(Text::translate("commands.save.saving", &[]));
let saved = self.save_game();
@@ -560,6 +560,20 @@ fn process_limits() -> Value {
})
}
/// The longest `horizon-trace` writes for.
pub(super) const TRACE_MAX_SECONDS: u32 = 300;
/// `horizon-trace SECONDS`: every thread's spans for `seconds`, to `reports/trace-<time>.json`
/// (Perfetto opens it).
pub(super) fn start_trace(seconds: u32) -> std::io::Result<PathBuf> {
let dir = Path::new("reports");
std::fs::create_dir_all(dir)?;
let stamp = chrono::Utc::now().format("%Y-%m-%d_%H-%M-%SZ");
let path = dir.join(format!("trace-{stamp}.json"));
spans::trace::start(&path, Some(seconds))?;
Ok(path)
}
fn write_report(dir: &Path, report: &Value, text: &str) -> std::io::Result<PathBuf> {
std::fs::create_dir_all(dir)?;
let stamp = chrono::Utc::now().format("%Y-%m-%d_%H-%M-%SZ");
+77 -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
@@ -3580,6 +3590,8 @@ impl Game<'_> {
vertical_collision: false,
horizontal_collision: false,
needs_sync: false,
idle: false,
bare: false,
leash: None,
spectator: false,
watching: false,
@@ -5135,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");
}
}
@@ -11,6 +11,7 @@ use horizon_client::{
enchant::{EffectContext, Enchantments, level_value},
items::ItemStack,
mobs::Who,
projectiles::Projectile,
};
use horizon_core::{position::BlockPos, random::RandomSource};
use horizon_world::{fluid::FluidKind, level::LevelAccess, registry::BlockStateId};
@@ -654,17 +655,27 @@ impl Game<'_> {
) == 0.0;
(f64::from(power), infinity)
}
/// `EnchantmentHelper.onProjectileSpawned` ignite effects of the held bow
/// (flame): the seconds the new arrow burns for.
pub(super) fn bow_projectile_ignite(&mut self) -> Option<f32> {
let bow = self.held().cloned()?;
/// `EnchantmentHelper.onProjectileSpawned`, which `AbstractArrow`'s
/// constructor runs for every arrow fired from a weapon, bow or crossbow:
/// the weapon's `projectile_spawned` ignite effects (Flame) spawn the arrow
/// burning.
pub(super) fn ignite_spawned_arrow(&mut self, weapon: &ItemStack, arrow: &mut Projectile) {
if let Some(seconds) = self.projectile_spawned_ignite(weapon)
&& let Some(a) = arrow.arrow.as_mut()
{
a.ignite_for_seconds(seconds);
}
}
/// The `projectile_spawned` ignite effects of `weapon` (Flame): the seconds
/// the new arrow burns for, the longest when several apply.
fn projectile_spawned_ignite(&mut self, weapon: &ItemStack) -> Option<f32> {
let ctx = EffectContext {
this: Some("minecraft:arrow"),
items: Some(world_items!(self)),
..Default::default()
};
let mut ignite: Option<f32> = None;
for (id, level) in bow.enchantments() {
for (id, level) in weapon.enchantments() {
let Some(def) = world_stations!(self).enchantments.get(id) else {
continue;
};
+109 -15
View File
@@ -187,28 +187,51 @@ impl Game<'_> {
};
bite(self);
self.each_guest_hit(bite);
// `AbstractIllager.considersEntityAsAlly`: with no teams, the illager
// tag is allied; `Evoker.considersEntityAsAlly` adds a vex whose
// owner is allied (itself or another illager). Teams are not modelled.
let illager = |s: Species| {
matches!(
s,
Species::Evoker | Species::Illusioner | Species::Pillager | Species::Vindicator
)
};
// `Entity.isAlliedTo`: the owner's or the target's
// `considersEntityAsAlly`, teams included.
let teams = self.mob_teams();
let illagers: Vec<u32> = self
.mobs
.iter()
.filter(|m| illager(m.species()))
.filter(|m| is_illager(m.species()))
.map(|m| m.id)
.collect();
// A missing owner hurts everyone (`currentOwner == null`).
let owner_known = illagers.contains(&f.owner);
let team_of = |id: u32| teams.of(horizon_client::mobs::Who::Mob(id));
let allies: Vec<u32> = if owner_known {
self.mobs
.iter()
.filter(|m| {
let other = Party {
illager: is_illager(m.species()),
team: team_of(m.id),
};
let root = (m.species() == Species::Vex)
.then_some(m.extra.counter2 as u32)
.filter(|r| illagers.contains(r))
.map(|r| {
(
r == f.owner,
Party {
illager: true,
team: team_of(r),
},
)
});
let owner = Party {
illager: true,
team: team_of(f.owner),
};
fangs_allied(owner, other, root)
})
.map(|m| m.id)
.collect()
} else {
Vec::new()
};
for m in &mut self.mobs {
let ally = owner_known
&& (illager(m.species())
|| m.species() == Species::Vex
&& illagers.contains(&(m.extra.counter2 as u32)));
let ally = allies.contains(&m.id);
if m.id != f.owner && !ally && m.alive() && m.bounds().intersects(b) {
m.damage(
6.0,
@@ -293,12 +316,41 @@ impl Game<'_> {
}
}
}
/// `EntityTypeTags.ILLAGER` members, the types of `ILLAGER_FRIENDS`.
fn is_illager(s: Species) -> bool {
matches!(
s,
Species::Evoker | Species::Illusioner | Species::Pillager | Species::Vindicator
)
}
/// One side of an ally check: whether it is an illager and its team.
#[derive(Clone, Copy)]
struct Party<'a> {
illager: bool,
team: Option<&'a horizon_client::scoreboard::Team>,
}
/// `AbstractIllager.considersEntityAsAlly(other)`: the base rule (the teams
/// are allied), or an illager friend while both have no team.
fn illager_considers(me: Party, other: Party) -> bool {
horizon_client::scoreboard::teams_allied(me.team, other.team)
|| other.illager && me.team.is_none() && other.team.is_none()
}
/// `owner.isAlliedTo(other)` for an evoker owner: `Evoker.considersEntityAsAlly`
/// either way round. `vex_root` is the root owner of a vex target (whether it
/// is the owner itself, and its party); the owner treats such a vex as an ally
/// when its root owner is.
fn fangs_allied(owner: Party, other: Party, vex_root: Option<(bool, Party)>) -> bool {
illager_considers(owner, other)
|| other.illager && illager_considers(other, owner)
|| !other.illager && horizon_client::scoreboard::teams_allied(other.team, owner.team)
|| vex_root.is_some_and(|(is_owner, root)| is_owner || illager_considers(owner, root))
}
fn p_zrot(p: &mut Poses, part: &str, v: f32) {
p.zrot(part, v);
}
#[cfg(test)]
mod tests {
use super::{fang_attack_pitch, fang_crit};
use super::{Party, fang_attack_pitch, fang_crit, fangs_allied};
use horizon_core::random::LegacyRandom;
#[test]
@@ -334,4 +386,46 @@ mod tests {
);
}
}
fn party(illager: bool, team: Option<&horizon_client::scoreboard::Team>) -> Party<'_> {
Party { illager, team }
}
#[test]
fn fangs_allies_follow_teams_and_the_illager_tag() {
let mut board = horizon_client::scoreboard::Scoreboard::default();
board.add_team("red");
board.add_team("blue");
let red = board.team("red").unwrap();
let blue = board.team("blue").unwrap();
let evoker = |t| party(true, t);
// Teamless illagers are friends.
assert!(fangs_allied(evoker(None), party(true, None), None));
// Different teams: the illager tag no longer applies.
assert!(!fangs_allied(
evoker(Some(red)),
party(true, Some(blue)),
None
));
// One side with a team: still not allied.
assert!(!fangs_allied(evoker(Some(red)), party(true, None), None));
// The same team allies a non-illager mob.
assert!(fangs_allied(
evoker(Some(red)),
party(false, Some(red)),
None
));
assert!(!fangs_allied(evoker(None), party(false, None), None));
// A vex follows its root owner.
assert!(fangs_allied(
evoker(None),
party(false, None),
Some((true, evoker(None)))
));
assert!(!fangs_allied(
evoker(Some(red)),
party(false, None),
Some((false, party(true, Some(blue))))
));
}
}
+61 -26
View File
@@ -349,6 +349,34 @@ impl Game<'_> {
"vz": hook.velocity.z.to_bits() as i64, "nibble": hook.nibble, "next": hook.random.next_long(),
}));
}
// The client must take the same block hit before moving as the server;
// otherwise it lands farther away and slides back on the next update.
let stone = self
.pack
.assets
.registry
.default_state("minecraft:stone")
.unwrap();
self.level_set(block, stone).unwrap();
for (case, motion) in [
Vec3::new(0.2, -0.4, 0.1),
Vec3::new(-0.2, -0.4, 0.1),
Vec3::new(0.0, -0.6, -0.2),
]
.into_iter()
.enumerate()
{
let mut state = (Vec3::new(x, 301.1, z), motion, State::Flying, false);
let mut random = LegacyRandom::new(0);
for tick in 0..5 {
state = self.client_hook_step(state, &mut random);
rows.push(serde_json::json!({
"case": case, "tick": tick,
"position": [state.0.x - f64::from(block[0]), state.0.y, state.0.z - f64::from(block[2])],
"motion": [state.1.x, state.1.y, state.1.z],
}));
}
}
self.hook = old_hook;
self.offhand = old_offhand;
self.player.as_mut().unwrap().position = old_position;
@@ -874,27 +902,8 @@ impl Game<'_> {
// checkCollision: find the first block and entity on the movement
// segment so a fast hook cannot tunnel or catch through a wall.
let to = h.position.add_vector(h.velocity);
let block_hit = {
let mut scene = SceneCollisions {
world: &self.world,
binding: self.collision,
subject: None,
};
traverse_blocks(h.position, to, 1024, |p: BlockPos| {
let state = self.world.block(p.x, p.y, p.z);
if self.res.facts().is_air(state) {
return None;
}
let shape = self
.collision
.collision_shape(&mut scene, state, [p.x, p.y, p.z])
.ok()?;
shape.clip(h.position, to, p)
})
.ok()
.flatten()
};
let entity_end = block_hit.map_or(to, |hit| hit.location);
let block_hit = self.hook_block_hit(h.position, to);
let entity_end = block_hit.unwrap_or(to);
let owner = self.lan_acting;
let own_vehicle = self.own_vehicle();
let hit = first_entity_hit(
@@ -968,9 +977,9 @@ impl Game<'_> {
self.projectile_event(
"minecraft:projectile_land",
Some(horizon_client::mobs::Who::Player),
[hit.location.x, hit.location.y, hit.location.z],
[hit.x, hit.y, hit.z],
);
h.velocity = rebound(h.velocity, hit.location, h.position);
h.velocity = rebound(h.velocity, hit, h.position);
}
}
State::Hooked(id) => {
@@ -1060,7 +1069,7 @@ impl Game<'_> {
}
}
if !in_water && !h.on_ground {
h.velocity.y -= 0.03;
h.velocity.y -= f64::from(0.03_f32);
}
let mut scene = SceneCollisions {
world: &self.world,
@@ -1584,6 +1593,28 @@ impl Game<'_> {
self.remote_hooks = hooks;
}
/// `FishingHook.checkCollision` clips blocks on both sides of the connection.
fn hook_block_hit(&self, from: Vec3, to: Vec3) -> Option<Vec3> {
let mut scene = SceneCollisions {
world: &self.world,
binding: self.collision,
subject: None,
};
traverse_blocks(from, to, 1024, |p: BlockPos| {
let state = self.world.block(p.x, p.y, p.z);
if self.res.facts().is_air(state) {
return None;
}
self.collision
.collision_shape(&mut scene, state, [p.x, p.y, p.z])
.ok()?
.clip(from, to, p)
})
.ok()
.flatten()
.map(|hit| hit.location)
}
/// `FishingHook.tick`: one tick of movement without the fishing (the
/// server's): position, motion, state and whether it rests on the ground.
fn client_hook_step(
@@ -1608,7 +1639,11 @@ impl Game<'_> {
State::Flying if velocity == Vec3::ZERO && on_ground => {
return (position, velocity, state, on_ground);
}
State::Flying => {}
State::Flying => {
if let Some(hit) = self.hook_block_hit(position, position.add_vector(velocity)) {
velocity = rebound(velocity, hit, position);
}
}
State::Hooked(id) => {
// updateHookedEntity: at 0.8 of the hooked entity's height.
let hooked = if self
@@ -1661,7 +1696,7 @@ impl Game<'_> {
}
}
if !in_water && !on_ground {
velocity.y -= 0.03;
velocity.y -= f64::from(0.03_f32);
}
let mut scene = SceneCollisions {
world: &self.world,
+10 -2
View File
@@ -20,7 +20,11 @@ impl Game<'_> {
let mut near = super::item_areas::ItemAreas::default();
for i in 0..self.mobs.len() {
let m = &self.mobs[i];
if m.species() != Species::Fox || !m.alive() || m.removed {
if m.species() != Species::Fox
|| !m.alive()
|| m.removed
|| !self.creatures.ticked.contains(&m.id)
{
continue;
}
// `Mob.aiStep`: the items in the box grown by (1, 0, 1), in order.
@@ -82,7 +86,11 @@ impl Game<'_> {
let mut near = super::item_areas::ItemAreas::default();
for i in 0..self.mobs.len() {
let m = &self.mobs[i];
if m.species() != Species::Dolphin || !m.alive() || m.removed {
if m.species() != Species::Dolphin
|| !m.alive()
|| m.removed
|| !self.creatures.ticked.contains(&m.id)
{
continue;
}
// `Mob.aiStep`: the items in the box grown by (1, 0, 1); `Dolphin.pickUpItem`
+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,
+196 -26
View File
@@ -2,8 +2,9 @@
//! test each mob's reach against every item (`Mob.aiStep`'s looting, the
//! foxes' and dolphins' item searches). A reach that meets no cell holding an
//! item meets no item, so its scan, which would find nothing, is skipped; a
//! reach near an item scans as before, in the same order, with the same tests.
use horizon_client::{entities::ItemEntity, fast_hash::FxHashSet, join_seq::JoinSeq};
//! reach near an item scans as before, in the same order, with the same tests,
//! jumping over the items of the cells it doesn't meet ([`ItemScan`]).
use horizon_client::{entities::ItemEntity, fast_hash::FxHashMap, join_seq::JoinSeq};
use horizon_core::geometry::Aabb;
/// Cells of 8 blocks on each axis.
@@ -11,7 +12,8 @@ const SHIFT: i32 = 3;
#[derive(Default)]
pub(super) struct ItemAreas {
cells: FxHashSet<[i32; 3]>,
/// The items in each cell, by index, in the list's order.
cells: FxHashMap<[i32; 3], Vec<u32>>,
/// What the cells were built from: the list's length, the last entity id
/// the game handed out and the next join number. An item spawned or
/// removed since changes one of them, and the cells are built again.
@@ -21,40 +23,137 @@ pub(super) struct ItemAreas {
impl ItemAreas {
/// Whether an item of `items` may meet `area`: false only when none can.
pub(super) fn may_meet(&mut self, items: &[ItemEntity], next_entity: u32, area: Aabb) -> bool {
let key = (items.len(), next_entity, JoinSeq::upcoming().get());
if self.built != Some(key) {
self.cells.clear();
self.cells.extend(items.iter().map(|e| {
let p = e.position;
[cell(p.x), cell(p.y), cell(p.z)]
}));
self.built = Some(key);
}
self.build(items, next_entity);
if self.cells.is_empty() {
return false;
}
let (lo, hi) = (area.min_position(), area.max_position());
if !lo.is_finite() || !hi.is_finite() {
let Some((from, to)) = cell_span(area) else {
return true;
}
// An item's box spans its width sideways and upwards from its
// position; a margin of a whole width keeps rounding on the safe side.
let m = ItemEntity::WIDTH;
let from = [cell(lo.x - m), cell(lo.y - 2.0 * m), cell(lo.z - m)];
let to = [cell(hi.x + m), cell(hi.y + m), cell(hi.z + m)];
let span = (0..3)
.map(|a| i64::from(to[a]) - i64::from(from[a]) + 1)
.product::<i64>();
if span > self.cells.len() as i64 {
};
if span(from, to) > self.cells.len() as i64 {
return self
.cells
.iter()
.keys()
.any(|c| (0..3).all(|a| (from[a]..=to[a]).contains(&c[a])));
}
(from[2]..=to[2]).any(|z| {
(from[1]..=to[1]).any(|y| (from[0]..=to[0]).any(|x| self.cells.contains(&[x, y, z])))
(from[1]..=to[1])
.any(|y| (from[0]..=to[0]).any(|x| self.cells.contains_key(&[x, y, z])))
})
}
/// A scan of `items` for `area`, which jumps over the items that can't
/// meet it; empty when none can ([`Self::may_meet`] false).
pub(super) fn scan(&mut self, items: &[ItemEntity], next_entity: u32, area: Aabb) -> ItemScan {
let all = ItemScan {
near: None,
passed: 0,
len: items.len(),
};
if !self.may_meet(items, next_entity, area) {
return ItemScan {
near: Some(Vec::new()),
..all
};
}
let Some((from, to)) = cell_span(area) else {
return all;
};
if span(from, to) > self.cells.len() as i64 {
return all;
}
let mut near = Vec::new();
for z in from[2]..=to[2] {
for y in from[1]..=to[1] {
for x in from[0]..=to[0] {
if let Some(list) = self.cells.get(&[x, y, z]) {
near.extend(list.iter().map(|&i| i as usize));
}
}
}
}
near.sort_unstable();
ItemScan {
near: Some(near),
..all
}
}
fn build(&mut self, items: &[ItemEntity], next_entity: u32) {
let key = (items.len(), next_entity, JoinSeq::upcoming().get());
if self.built == Some(key) {
return;
}
self.cells.clear();
for (i, e) in items.iter().enumerate() {
let p = e.position;
let at = [cell(p.x), cell(p.y), cell(p.z)];
self.cells.entry(at).or_default().push(i as u32);
}
self.built = Some(key);
}
}
/// One reach's walk over the items, in the list's order: [`Self::next_from`] gives
/// the next index to test, past the items that lie in cells the reach doesn't
/// meet. Told of each item the walk removes, it keeps up; if the list changes
/// any other way (an item joins), the walk tests every item from there on.
pub(super) struct ItemScan {
/// The items in the cells the reach meets, by index; `None`: every item.
near: Option<Vec<usize>>,
/// The first of `near` not yet passed.
passed: usize,
/// The list's length as the walk knows it.
len: usize,
}
impl ItemScan {
/// Whether no item can meet the reach.
pub(super) fn is_empty(&self) -> bool {
self.near.as_ref().is_some_and(Vec::is_empty)
}
/// The first index from `j` the walk must test (`len` or more: done).
pub(super) fn next_from(&mut self, j: usize, len: usize) -> usize {
if len != self.len {
self.near = None;
}
let Some(near) = &self.near else {
return j;
};
while near.get(self.passed).is_some_and(|&i| i < j) {
self.passed += 1;
}
near.get(self.passed).copied().unwrap_or(len).max(j)
}
/// The walk removed item `j`: the items after it moved down one.
pub(super) fn removed(&mut self, j: usize) {
self.len -= 1;
if let Some(near) = &mut self.near {
for i in near.iter_mut().filter(|i| **i > j) {
*i -= 1;
}
}
}
}
/// The cells an item meeting `area` may lie in: an item's box spans its width
/// sideways and upwards from its position, and a margin of a whole width
/// keeps rounding on the safe side. `None` for an area that isn't finite.
fn cell_span(area: Aabb) -> Option<([i32; 3], [i32; 3])> {
let (lo, hi) = (area.min_position(), area.max_position());
if !lo.is_finite() || !hi.is_finite() {
return None;
}
let m = ItemEntity::WIDTH;
let from = [cell(lo.x - m), cell(lo.y - 2.0 * m), cell(lo.z - m)];
let to = [cell(hi.x + m), cell(hi.y + m), cell(hi.z + m)];
Some((from, to))
}
fn span(from: [i32; 3], to: [i32; 3]) -> i64 {
(0..3)
.map(|a| i64::from(to[a]) - i64::from(from[a]) + 1)
.product()
}
fn cell(v: f64) -> i32 {
@@ -136,6 +235,77 @@ mod tests {
assert!(!cells.may_meet(&[], 1, near));
}
/// The items a walk tests that meet `area`, by position: what the scans
/// act on. `take` removes the item it names when the walk meets it.
fn walk(items: &mut Vec<ItemEntity>, area: Aabb, take: Option<f64>) -> Vec<f64> {
let mut cells = ItemAreas::default();
let mut scan = cells.scan(items, 1, area);
let (mut met, mut j) = (Vec::new(), 0);
loop {
j = scan.next_from(j, items.len());
let Some(e) = items.get(j) else {
break;
};
if !area.intersects(e.bounds()) {
j += 1;
continue;
}
met.push(e.position.x);
if take == Some(e.position.x) {
items.remove(j);
scan.removed(j);
} else {
j += 1;
}
}
met
}
#[test]
fn a_walk_meets_what_a_full_scan_meets_in_the_same_order() {
let mut items = Vec::new();
for i in 0..60 {
let x = -40.0 + f64::from(i) * 1.37;
items.push(item_at(
x,
64.0 + f64::from(i % 3) * 0.6,
(x * 0.7).sin() * 9.0,
));
}
for k in 0..40 {
let x = -42.0 + f64::from(k) * 2.11;
let area = Aabb::new(Vec3::new(x, 63.5, -10.0), Vec3::new(x + 3.3, 65.5, 10.0));
let full: Vec<f64> = items
.iter()
.filter(|e| area.intersects(e.bounds()))
.map(|e| e.position.x)
.collect();
assert_eq!(walk(&mut items.clone(), area, None), full, "{area:?}");
}
}
#[test]
fn a_walk_keeps_up_with_the_items_it_removes() {
let xs = [1.0, 30.0, 1.5, 31.0, 2.0, 2.5];
let mut items: Vec<ItemEntity> = xs.iter().map(|&x| item_at(x, 64.0, 1.0)).collect();
let area = Aabb::new(Vec3::new(0.0, 63.0, 0.0), Vec3::new(3.0, 65.0, 2.0));
assert_eq!(walk(&mut items, area, Some(1.5)), vec![1.0, 1.5, 2.0, 2.5]);
assert_eq!(items.len(), 5);
}
#[test]
fn a_walk_tests_an_item_that_joins_during_it() {
let mut items = vec![item_at(1.0, 64.0, 1.0), item_at(40.0, 64.0, 1.0)];
let area = Aabb::new(Vec3::new(0.0, 63.0, 0.0), Vec3::new(3.0, 65.0, 2.0));
let mut cells = ItemAreas::default();
let mut scan = cells.scan(&items, 1, area);
assert_eq!(scan.next_from(0, items.len()), 0);
// One joins at the end, inside the reach, after the walk began.
items.push(item_at(2.0, 64.0, 1.0));
assert_eq!(scan.next_from(1, items.len()), 1);
assert_eq!(scan.next_from(2, items.len()), 2);
}
#[test]
fn an_item_spawned_after_the_cells_were_built_is_seen() {
let mut items = vec![item_at(100.5, 64.0, 100.5)];
+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:?}");
}
}
+101 -20
View File
@@ -268,6 +268,8 @@ pub(super) struct LanHost {
/// The entity id counter when this tick's states were taken for guests
/// (`lan_note_joined`), None when no guest tracked them.
pub(super) states_next_entity: Option<u32>,
/// Last tick's states of the idle mobs and items (game/lan_idle.rs).
pub(super) idle_states: super::lan_idle::IdleStates,
}
impl Game<'_> {
@@ -1448,6 +1450,7 @@ impl Game<'_> {
sleep_ignored: Default::default(),
states: Default::default(),
states_next_entity: None,
idle_states: Default::default(),
});
self.audio.capture = Some(Vec::new());
Ok(())
@@ -1896,7 +1899,9 @@ impl Game<'_> {
/// The entities guests track: mobs, items and the rest (game/lan_entities.rs).
/// `mob_players` is the guest each mob sees as its player (game/lan_mobs.rs).
/// Only the entities whose id is above `after` (0: all of them).
/// Only the entities whose id is above `after` (0: all of them). With
/// `kept`, idle mobs and items no client can pair with are described bare
/// (game/lan_idle.rs).
fn lan_entities(
&mut self,
mob_players: &horizon_client::fast_hash::FxHashMap<u32, u32>,
@@ -1904,16 +1909,24 @@ impl Game<'_> {
out: &mut States,
drain_minecart_steps: bool,
after: u32,
mut kept: Option<&mut super::lan_idle::IdlePass<'_>>,
) {
out.clear();
let mut speeds = super::lan_attributes::SpeedByType::default();
// ChunkMap.tick sends the changes of an entity outside the
// entity-ticking range only when it changed section or needs sync.
let levels = self.tickets.levels();
let idle_at = |p: horizon_core::geometry::Vec3| {
!levels.in_entity_ticking_range((p.x.floor() as i32) >> 4, (p.z.floor() as i32) >> 4)
};
for m in self.mobs.iter().filter(|m| !m.removed && m.id > after) {
// The lead's holder: the player the mob sees, another mob or a
// fence knot (game/lan_entities.rs).
let player_entity = mob_players
.get(&m.id)
.and_then(|conn| hub.guest(*conn))
.map(|g| g.entity_id);
let player_entity = || {
(mob_players.get(&m.id))
.and_then(|conn| hub.guest(*conn))
.map(|g| g.entity_id)
};
let leash = m.extra.leash.and_then(|h| match h {
Holder::Player => match m.extra.leash_player {
Some(u) => hub
@@ -1921,21 +1934,37 @@ impl Game<'_> {
.iter()
.find(|g| playerdata::uuid_ints(g.uuid) == u)
.map(|g| g.entity_id),
None => player_entity,
None => player_entity(),
},
Holder::Mob(id) | Holder::Boat(id) => Some(id as i32),
Holder::Knot(p) => self.knot_ids.get(&p).map(|&id| id as i32),
});
let e = out.add(m.id as i32, entity_uuid(m.id), m.kind.id.as_str());
e.position = [m.position.x, m.position.y, m.position.z];
(e.yaw, e.pitch, e.head_yaw) = (m.yaw, m.head_pitch, m.head_yaw);
e.on_ground = m.on_ground;
e.velocity = [m.velocity.x, m.velocity.y, m.velocity.z];
let idle = idle_at(m.position);
let place = super::lan_idle::Place {
position: [m.position.x, m.position.y, m.position.z],
rotation: (m.yaw, m.head_pitch, m.head_yaw),
on_ground: m.on_ground,
velocity: [m.velocity.x, m.velocity.y, m.velocity.z],
};
let kind = m.kind.id.as_str();
let bare = idle
&& (kept.as_deref_mut()).is_some_and(|k| k.bare(hub, m.id as i32, kind, &place))
&& !m.needs_sync;
let e = out.add(m.id as i32, entity_uuid(m.id), kind);
e.position = place.position;
(e.yaw, e.pitch, e.head_yaw) = place.rotation;
e.on_ground = place.on_ground;
e.velocity = place.velocity;
e.needs_sync = m.needs_sync;
(e.idle, e.bare) = (idle, bare);
e.leash = leash;
if bare {
continue;
}
mob_meta(
m,
hub,
player_entity,
player_entity(),
self.world.game_time,
&mut e.meta,
&mut e.defaults,
@@ -1946,14 +1975,28 @@ impl Game<'_> {
mob_freeze_meta(m, &mut e.meta, &mut e.defaults);
mob_equipment(m, hub, &mut e.equipment);
self.mob_attributes(m, &mut speeds, &mut e.attributes);
e.leash = leash;
}
for d in self.dropped.iter().filter(|d| d.id > after) {
let stack = hub.stack(&Some(d.stack.clone()));
let idle = idle_at(d.position);
let place = super::lan_idle::Place {
position: [d.position.x, d.position.y, d.position.z],
rotation: (0.0, 0.0, 0.0),
on_ground: d.on_ground,
velocity: [d.velocity.x, d.velocity.y, d.velocity.z],
};
let bare = idle
&& (kept.as_deref_mut())
.is_some_and(|k| k.bare(hub, d.id as i32, "minecraft:item", &place))
&& !d.needs_sync;
let e = out.add(d.id as i32, entity_uuid(d.id), "minecraft:item");
e.position = [d.position.x, d.position.y, d.position.z];
e.on_ground = d.on_ground;
e.velocity = [d.velocity.x, d.velocity.y, d.velocity.z];
e.position = place.position;
e.on_ground = place.on_ground;
e.velocity = place.velocity;
(e.idle, e.bare) = (idle, bare);
if bare {
continue;
}
let stack = hub.stack(&Some(d.stack.clone()));
// `ServerEntity.getRequiresPrecisePosition` is false for an item:
// `needs_sync` asks for its data again, not for an exact position.
// The collision case is `full_precision_required` in the tracker.
@@ -1970,6 +2013,7 @@ impl Game<'_> {
for o in self.orbs.iter().filter(|o| o.id > after) {
let e = out.add(o.id as i32, entity_uuid(o.id), "minecraft:experience_orb");
e.position = [o.position.x, o.position.y, o.position.z];
e.idle = idle_at(o.position);
e.on_ground = o.on_ground;
e.velocity = [o.velocity.x, o.velocity.y, o.velocity.z];
// ExperienceOrb.DATA_VALUE.
@@ -2460,7 +2504,14 @@ impl Game<'_> {
self.lan_assign_ids();
if self.next_entity != next {
let mut states = States::default();
self.lan_entities(&host.mob_players, &mut host.hub, &mut states, false, next);
self.lan_entities(
&host.mob_players,
&mut host.hub,
&mut states,
false,
next,
None,
);
self.lan_hook_states(&host.play, &host.hub, &mut states);
self.lan_entity_uuids(&mut states);
host.hub.note_joined(states.as_slice());
@@ -2537,8 +2588,31 @@ impl Game<'_> {
.filter_map(|g| Some((g.vehicle?, g.entity_id)))
.collect();
self.lan_riding(&guests, &mut host.hub.riding);
// Idle states are kept only when the players' moves are in:
// the clients that could pair are known. The range scale is
// the one the tracker gets below.
let mut kept = if self.level_pass.packets_drained {
let percent = if host.hub.config.integrated {
(self.settings.entity_distance * 100.0).round() as i32
} else {
host.hub.entity_range_percent
};
host.idle_states.pass(&host.hub, percent)
} else {
None
};
horizon_client::spans::time("lan_entity_states", || {
self.lan_entities(&host.mob_players, &mut host.hub, &mut states, true, 0);
self.lan_entities(
&host.mob_players,
&mut host.hub,
&mut states,
true,
0,
kept.as_mut(),
);
if let Some(kept) = kept {
kept.finish();
}
self.lan_hook_states(&host.play, &host.hub, &mut states);
self.lan_entity_uuids(&mut states);
});
@@ -2803,7 +2877,14 @@ impl Game<'_> {
return;
}
// A dedicated server has no host player.
self.lan_entities(&host.mob_players, &mut host.hub, &mut theirs, false, 0);
self.lan_entities(
&host.mob_players,
&mut host.hub,
&mut theirs,
false,
0,
None,
);
self.lan_hook_states(&host.play, &host.hub, &mut theirs);
self.lan_entity_uuids(&mut theirs);
theirs.as_slice()
+196 -10
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);
@@ -3333,7 +3355,10 @@ impl Game<'_> {
return;
}
let (old_yaw, old_pitch) = (host.hub.guests[gi].yaw, host.hub.guests[gi].pitch);
if self.look_at_entity(host, gi, id, None).is_none() {
let boat = self.boats.iter().position(|boat| boat.id as i32 == id);
// A packet names its target already. Keep the attacker's actual yaw
// for the boat's knockback instead of aiming a ray at its center.
if boat.is_none() && self.look_at_entity(host, gi, id, None).is_none() {
return;
}
// A mace's smash sets the attacker's velocity and sends it at once, before the
@@ -3344,7 +3369,12 @@ impl Game<'_> {
.player
.as_mut()
.is_some_and(|p| std::mem::take(&mut p.health.sync_velocity));
g.attack();
if let Some(index) = boat {
g.strike_boat(index);
g.swing_arm();
} else {
g.attack();
}
let p = g.player.as_mut()?;
let sent = std::mem::replace(&mut p.health.sync_velocity, marked);
sent.then_some(p.velocity)
@@ -3680,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;
}
@@ -3780,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,
@@ -4718,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.
@@ -4873,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 } => {
@@ -4892,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.
@@ -6002,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();
}
}
@@ -50,6 +50,8 @@ fn state(id: u32, kind: &str, position: [f64; 3], yaw: f32, on_ground: bool) ->
vertical_collision: false,
horizontal_collision: false,
needs_sync: false,
idle: false,
bare: false,
leash: None,
spectator: false,
watching: false,
@@ -525,7 +527,8 @@ impl Game<'_> {
/// selectors use, so a client can name it back (the crosshair UUID that
/// `ClientSuggestionProvider.getSelectedEntities` suggests).
pub(super) fn lan_entity_uuids(&mut self, states: &mut States) {
for e in states.as_mut_slice() {
// A bare state is never paired: its UUID is never read.
for e in states.as_mut_slice().iter_mut().filter(|e| !e.bare) {
e.uuid = ints_uuid(self.uuid_for(e.id as u32));
}
}
+154
View File
@@ -0,0 +1,154 @@
//! Idle mobs and items that need no new description this tick (#10253).
//!
//! An entity outside the entity-ticking range does not tick, and `ChunkMap.tick` does not send
//! its changes (`EntityState::idle`) unless it changed section or needs sync. While it stays
//! where it was, its state is read only for where it is, and to pair it with a client that
//! starts tracking it. So such an entity is described bare (`EntityState::bare`: where it is,
//! none of its data) when no client could pair with it this tick: it was in the tracker's last
//! pass, has not moved, carries no passengers, and every client within its tracking range
//! already tracks it. Otherwise it is described in full, so a pairing always sends the entity
//! as it is.
use horizon_client::fast_hash::FxHashMap;
use horizon_client::lan::{hub::Hub, tracking};
/// Per level, each idle entity's place and the tracking pass it was last described in.
#[derive(Default)]
pub(super) struct IdleStates {
levels: FxHashMap<String, FxHashMap<i32, (u32, Place)>>,
}
/// Where an entity is: its position, rotation (yaw, pitch, head yaw), whether
/// it is on the ground, and its velocity.
#[derive(Clone, Copy, PartialEq)]
pub(super) struct Place {
pub position: [f64; 3],
pub rotation: (f32, f32, f32),
pub on_ground: bool,
pub velocity: [f64; 3],
}
/// The idle entities of the level in place, for one pass.
pub(super) struct IdlePass<'a> {
kept: &'a mut FxHashMap<i32, (u32, Place)>,
/// The level's tracking passes so far (`Hub::tracking_ticks`). An entity
/// described one pass earlier was in the tracker's last pass.
now: u32,
/// The players the level's tracker runs for (`Hub::watches`: their
/// index), where they are horizontally, and their view distance in blocks:
/// the clients that could pair.
viewers: Vec<(usize, [f64; 2], i32)>,
/// `entity-broadcast-range-percentage`, as the tracker scales ranges.
percent: i32,
}
impl IdleStates {
/// The level in place's idle entities, or None before its tracking ran.
pub(super) fn pass(&mut self, hub: &Hub, percent: i32) -> Option<IdlePass<'_>> {
let now = hub.tracking_ticks()?;
let viewers = (hub.guests.iter().enumerate())
.filter(|(_, g)| hub.watches(g))
.map(|(i, g)| (i, [g.position[0], g.position[2]], g.view_distance * 16))
.collect();
let kept = self.levels.entry(hub.config.dimension.clone()).or_default();
Some(IdlePass {
kept,
now,
viewers,
percent,
})
}
}
impl IdlePass<'_> {
/// Whether the idle entity `id` of type `kind`, now at `at`, can be
/// described bare: it was in the tracker's last pass at the same place,
/// carries no passengers (whose ranges widen its own), and every client
/// within its tracking range already tracks it. Notes it either way.
pub(super) fn bare(&mut self, hub: &Hub, id: i32, kind: &str, at: &Place) -> bool {
self.unchanged(id, kind, at, |i| hub.guests[i].tracker.is_tracking(id))
&& hub.riding.passengers(id).is_empty()
}
/// [`Self::bare`] but for the passengers, with whether the player `i`
/// tracks the entity.
fn unchanged(
&mut self,
id: i32,
kind: &str,
at: &Place,
tracks: impl Fn(usize) -> bool,
) -> bool {
// A new one reads as noted two passes ago.
let now = self.now;
let noted = self.kept.entry(id).or_insert((now.wrapping_sub(2), *at));
let still = noted.0.wrapping_add(1) == now && noted.1 == *at;
*noted = (now, *at);
if !still {
return false;
}
// The tracker's `sees`, but for the chunk it also asks the client to have.
let range = self.percent * (i32::from(tracking::settings(kind).0) * 16) / 100;
!self.viewers.iter().any(|&(i, [x, z], view)| {
let range = f64::from(range.min(view));
let (dx, dz) = (x - at.position[0], z - at.position[2]);
dx * dx + dz * dz <= range * range && !tracks(i)
})
}
/// Forget the entities this pass did not note: they left, or tick again.
pub(super) fn finish(self) {
let now = self.now;
self.kept.retain(|_, (stamp, _)| *stamp == now);
}
}
#[cfg(test)]
mod tests {
use super::*;
fn place(x: f64) -> Place {
Place {
position: [x, 64.0, 0.0],
rotation: (0.0, 0.0, 0.0),
on_ground: true,
velocity: [0.0; 3],
}
}
fn pass(kept: &mut FxHashMap<i32, (u32, Place)>, now: u32) -> IdlePass<'_> {
// One client at the origin, one 400 blocks away; both see 10 chunks.
IdlePass {
kept,
now,
viewers: vec![(0, [0.0, 0.0], 160), (1, [400.0, 0.0], 160)],
percent: 100,
}
}
/// An idle cow (tracked 10 chunks away) is described bare while it stays
/// put and every client in its range tracks it; in full when a client
/// there could pair with it, when it moved, and after a pass it was not in.
#[test]
fn an_idle_entity_is_bare_until_a_client_could_pair_or_it_moves() {
let mut kept = FxHashMap::default();
let cow = "minecraft:cow";
let at = place(100.0);
// Its first pass describes it in full.
assert!(!pass(&mut kept, 5).unchanged(7, cow, &at, |_| true));
// Tracked by the client in range; the far one can't pair.
let tracked = |i| i == 0;
assert!(pass(&mut kept, 6).unchanged(7, cow, &at, tracked));
// The client in range doesn't track it yet: it could pair.
assert!(!pass(&mut kept, 7).unchanged(7, cow, &at, |_| false));
// 100 blocks is past an item's 6 chunks: no client could pair.
assert!(pass(&mut kept, 8).unchanged(7, "minecraft:item", &at, |_| false));
// It moved.
assert!(!pass(&mut kept, 9).unchanged(7, cow, &place(100.5), tracked));
// A pass went by without it.
assert!(!pass(&mut kept, 11).unchanged(7, cow, &place(100.5), tracked));
// Only what a pass noted stays.
pass(&mut kept, 12).finish();
assert!(kept.is_empty());
}
}
+2 -5
View File
@@ -68,13 +68,10 @@ pub(super) fn player_for(
if m.extra.leash == Some(horizon_client::mobs::leash::Holder::Player)
&& let Some(holder) = m.extra.leash_player
{
if host.is_some_and(|v| v.alive && !v.spectator && v.uuid == holder) {
if host.is_some_and(|v| v.uuid == holder) {
return None;
}
if let Some((conn, _)) = guests
.iter()
.find(|(_, v)| v.alive && !v.spectator && v.uuid == holder)
{
if let Some((conn, _)) = guests.iter().find(|(_, v)| v.uuid == holder) {
return Some(*conn);
}
}
@@ -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.
@@ -561,6 +561,7 @@ impl Game<'_> {
};
if let Some(b) = self.boats.iter_mut().find(|b| b.id == vehicle) {
b.paddling = paddles;
b.paddle_controller = true;
}
}
}
+5 -2
View File
@@ -430,8 +430,11 @@ impl Game<'_> {
self.mobs[index].extra.leash = Some(Holder::Player);
self.mobs[index].extra.leash_player = Some(holder);
self.lead_sound("tied", at);
// The lead is always used up.
if let Some(stack) = self.held_mut().as_mut() {
// `Player.interactOn` restores the count of a player with
// infinite materials (creative); others spend the lead.
if !self.creative
&& let Some(stack) = self.held_mut().as_mut()
{
stack.count -= 1;
if stack.count == 0 {
*self.held_mut() = None;
@@ -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"
);
}
}
+104 -3
View File
@@ -1171,9 +1171,7 @@ impl Game<'_> {
let Some(id) = map_id(stack) else {
return false;
};
// ItemStack.useOn passes in adventure mode (its `can_place_on`
// exception is not ported).
if !self.may_build() {
if !self.may_build() && !self.can_place_on_in_adventure(hit.pos) {
return false;
}
let [x, y, z] = hit.pos;
@@ -2012,4 +2010,107 @@ mod tests {
assert_eq!(clamp_map_coordinate(-70.0), -128);
assert_eq!(clamp_map_coordinate(10.25), 21);
}
/// `ItemStack.useOn` / `MapItem.useOn`: in adventure mode, a map whose
/// `can_place_on` component includes the banner block toggles that banner's
/// marker, while one without does not.
#[test]
fn map_on_banner_in_adventure_mode_honours_can_place_on() {
use crate::game::{Audio, Game, Options, Progress, Settings, load_resources};
use horizon_client::{chunk::Chunk, command::args::GameMode};
use std::sync::Arc;
let Some(root) = std::env::var_os("HORIZON_TEST_RESOURCES") else {
return;
};
let root = std::path::PathBuf::from(root);
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 pos = [0, 64, 0];
game.world
.chunks
.insert((0, 0), Arc::new(Chunk::empty(0, 0)));
let banner_state = game
.pack
.assets
.registry
.default_state("minecraft:red_banner")
.unwrap();
game.world
.set(pos[0], pos[1], pos[2], banner_state)
.unwrap();
game.world.block_entities.insert(
(pos[0], pos[1], pos[2]),
horizon_client::containers::BlockEntity::Banner(horizon_client::containers::Banner {
layers: Vec::new(),
name: None,
components: Default::default(),
}),
);
let map_id = 42;
game.maps
.data
.insert(map_id, MapData::fresh(0.0, 0.0, 0, "minecraft:overworld"));
game.set_mode(GameMode::Adventure);
let hit = super::super::Hit {
pos,
face: [0, 1, 0],
point: [
pos[0] as f64 + 0.5,
pos[1] as f64 + 0.5,
pos[2] as f64 + 0.5,
],
};
let mut stack = ItemStack::new("minecraft:filled_map", 1);
stack.data_mut().map_id = Some(map_id);
// Without can_place_on, use_map_on_banner fails in adventure mode.
*game.held_mut() = Some(stack.clone());
assert!(!game.use_map_on_banner(&hit, &stack));
assert!(game.map(map_id).unwrap().banners.is_empty());
// With can_place_on listing the banner block, the marker toggles.
stack.data_mut().other.push((
"minecraft:can_place_on".into(),
Tag::compound([(
"blocks",
Tag::List(vec![Tag::String("minecraft:red_banner".into())]),
)]),
));
*game.held_mut() = Some(stack.clone());
assert!(game.use_map_on_banner(&hit, &stack));
assert_eq!(game.map(map_id).unwrap().banners.len(), 1);
assert_eq!(game.map(map_id).unwrap().banners[0].pos, pos);
assert_eq!(game.map(map_id).unwrap().banners[0].color, "red");
// Second use toggles the marker off.
assert!(game.use_map_on_banner(&hit, &stack));
assert!(game.map(map_id).unwrap().banners.is_empty());
}
}
+15 -7
View File
@@ -585,6 +585,7 @@ impl Game<'_> {
// turns nearly all of them away: it is tested first, before a mob's
// pushable state, and only the boxes kept are collected.
let close = |b: &Aabb| b.intersects(near);
let reach = self.vehicle_mob_reach();
let mut boxes: Vec<Aabb> = self
.carts
.iter()
@@ -600,10 +601,12 @@ impl Game<'_> {
.chain(
self.mobs
.iter()
.filter(|m| !m.removed && m.extra.mount != Some(c.id))
.filter(|m| reach.is_none_or(|r| r.may_meet(m, near)))
.map(|m| (m, m.bounds()))
.filter(|(m, b)| {
close(b)
&& !m.removed
&& m.extra.mount != Some(c.id)
&& (m.pushable()
|| m.species() == horizon_client::mobs::Species::Shulker
&& m.alive())
@@ -1752,17 +1755,17 @@ impl Game<'_> {
}
/// `positionRider` for the mobs in minecarts: each sits on the cart's
/// passenger attachment, less its own vehicle attachment.
pub(super) fn seat_cart_mobs(&mut self) {
pub(super) fn seat_cart_mobs(&mut self, riding: &super::passengers::Riders) {
for i in 0..self.carts.len() {
let id = self.carts[i].id;
let Some(js) = riding.get(&id) else {
continue;
};
let (pos, prev) = (self.carts[i].position, self.carts[i].previous);
let seats: Vec<(usize, f64)> = self
.mobs
let seats: Vec<(usize, f64)> = js
.iter()
.enumerate()
.filter(|(_, m)| m.extra.mount == Some(id))
// Minecart's box is 0.98 wide.
.map(|(j, m)| (j, riders::vehicle_attachment(m, 0.98)))
.map(|&j| (j, riders::vehicle_attachment(&self.mobs[j], 0.98)))
.collect();
for (j, offset) in seats {
let m = &mut self.mobs[j];
@@ -1793,6 +1796,11 @@ impl Game<'_> {
}
/// Every cart but the ridden one (which ticks with its rider).
pub(super) fn tick_carts(&mut self) {
self.vehicles_ticking(true);
self.tick_carts_now();
self.vehicles_ticking(false);
}
fn tick_carts_now(&mut self) {
self.push_carts();
// ServerLevel.tick: only entities in entity-ticking chunks tick.
let ticking = self.entity_ticking_area();
+29 -23
View File
@@ -28,18 +28,26 @@ impl Game<'_> {
let mut near = super::item_areas::ItemAreas::default();
for i in 0..self.mobs.len() {
let m = &self.mobs[i];
if !m.can_pick_up_loot() || !m.alive() || m.removed {
if !m.can_pick_up_loot()
|| !m.alive()
|| m.removed
|| !self.creatures.ticked.contains(&m.id)
{
continue;
}
// `Mob.ITEM_PICKUP_REACH`.
let reach = m.bounds().inflate(Vec3::new(1.0, 0.0, 1.0));
if !near.may_meet(&self.dropped, self.next_entity, reach) {
let mut scan = near.scan(&self.dropped, self.next_entity, reach);
if scan.is_empty() {
continue;
}
let (species, baby, passenger) = (m.species(), m.is_baby(), m.extra.passenger);
let mut j = 0;
while j < self.dropped.len() {
let e = &self.dropped[j];
loop {
j = scan.next_from(j, self.dropped.len());
let Some(e) = self.dropped.get(j) else {
break;
};
if e.pickup_delay > 0 || !reach.intersects(e.bounds()) {
j += 1;
continue;
@@ -70,6 +78,7 @@ impl Game<'_> {
e.stack.count -= taken;
if e.stack.count == 0 {
self.remove_dropped(j);
scan.removed(j);
} else {
j += 1;
}
@@ -132,8 +141,22 @@ impl Game<'_> {
if species == Species::Drowned && &*current == "minecraft:nautilus_shell" {
return false;
}
let new = self.stack_facts(m, stack, slot);
let worn = self.stack_facts(m, &worn_stack, slot);
let preferred = equip::preferred_weapon_tag(species).map(|tag| {
(
self.items().item_in_tag(tag, &stack.item),
self.items().item_in_tag(tag, &current),
)
});
equip::can_replace_current_item(&new, Some(&worn), slot, preferred)
}
/// The numbers `Mob.compareArmor` and `compareWeapons` read off `stack`
/// in `slot` for the mob `m`.
pub(super) fn stack_facts(&self, m: &Mob, stack: &ItemStack, slot: Slot) -> Facts {
let (armor, toughness, attack) = self.attributes(m, &stack.item, slot);
let new = Facts {
Facts {
armor,
armor_toughness: toughness,
attack_damage: attack,
@@ -143,24 +166,7 @@ impl Game<'_> {
// `EnchantmentEffectComponents.PREVENT_ARMOR_CHANGE` is the Curse of
// Binding's, the only enchantment that carries one.
prevents_armor_change: stack.enchantment_level("minecraft:binding_curse") > 0,
};
let (armor, toughness, attack) = self.attributes(m, &current, slot);
let worn = Facts {
armor,
armor_toughness: toughness,
attack_damage: attack,
enchantments: worn_stack.enchantments().len(),
damage: worn_stack.damage as i32,
custom_name: worn_stack.custom_name().is_some(),
prevents_armor_change: worn_stack.enchantment_level("minecraft:binding_curse") > 0,
};
let preferred = equip::preferred_weapon_tag(species).map(|tag| {
(
self.items().item_in_tag(tag, &stack.item),
self.items().item_in_tag(tag, &current),
)
});
equip::can_replace_current_item(&new, Some(&worn), slot, preferred)
}
}
/// `Mob.getApproximateAttributeWith` for the item `id` in `slot`: each
+11 -2
View File
@@ -79,6 +79,13 @@ pub(super) fn register_standalone_models() {
Game::native_standalone_models(&mut names);
horizon_client::models::register_standalone(names);
}
/// The native mods' entity model layers (`native_layer_definitions`), kept
/// once at start for `native_bake_layer`.
pub(super) fn register_layer_definitions() {
let mut layers = Vec::new();
Game::native_layer_definitions(&mut layers);
horizon_client::mobs::model_layers::register(layers);
}
/// `root` with the content mods that are on layered over it
/// (docs/modpacks.md): their blocks, items, data and assets. Read once at
@@ -667,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");
@@ -718,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");
+1 -1
View File
@@ -31,7 +31,7 @@ fn stick_on_break(stack: &ItemStack, wear: u32, max: u32) -> Option<ItemStack> {
Some(rod)
}
fn steering_item(species: Species, main: Option<&str>, offhand: Option<&str>) -> bool {
pub(super) fn steering_item(species: Species, main: Option<&str>, offhand: Option<&str>) -> bool {
let item = match species {
Species::Pig => "minecraft:carrot_on_a_stick",
Species::Strider => "minecraft:warped_fungus_on_a_stick",
+264 -35
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 {
@@ -158,9 +257,7 @@ impl Game<'_> {
let local_entity_id = self.remote.as_ref().map(|r| r.entity_id as u32);
let ridden = collect_vehicle_ids(
&self.mobs,
self.text_displays.values().filter_map(|d| d.vehicle),
self.armor_stands.iter().filter_map(|a| a.vehicle),
self.interactions.values().filter_map(|i| i.vehicle),
self.remote_vehicle_ids(),
self.riding,
local_entity_id,
);
@@ -471,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
@@ -488,6 +587,7 @@ impl Game<'_> {
NAME_TAG_UNIT,
out,
tag.marks,
Some(&mut layouts),
));
continue;
}
@@ -505,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(
@@ -517,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
@@ -547,6 +651,7 @@ impl Game<'_> {
unit,
out,
Marks::default(),
None,
)
}
@@ -565,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;
@@ -597,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.
@@ -662,7 +778,9 @@ impl Game<'_> {
}
/// Collect entity IDs of all vehicles: entities that have passengers
/// (mobs, players, text displays, armor stands, or interactions riding them).
/// (`passenger_vehicles`: what every entity the server sent rides, text displays,
/// armor stands, item displays and interactions included; vanilla's `isVehicle` is
/// any passenger at all).
///
/// On multiplayer servers (e.g. `mcpvp.club`), server plugins hang custom nametags
/// (ranks, prefixes, stats) over players using `TextDisplay` or `ArmorStand` passengers.
@@ -674,9 +792,7 @@ impl Game<'_> {
/// is ridden and suppresses the duplicate tag (#9477).
fn collect_vehicle_ids(
mobs: &[Mob],
text_display_vehicles: impl IntoIterator<Item = u32>,
armor_stand_vehicles: impl IntoIterator<Item = u32>,
interaction_vehicles: impl IntoIterator<Item = u32>,
passenger_vehicles: impl IntoIterator<Item = u32>,
local_riding: Option<u32>,
local_entity_id: Option<u32>,
) -> std::collections::HashSet<u32> {
@@ -684,9 +800,7 @@ fn collect_vehicle_ids(
.iter()
.filter_map(|o| o.extra.mount)
.chain(mobs.iter().filter_map(|m| m.extra.rider.map(|_| m.id)))
.chain(text_display_vehicles)
.chain(armor_stand_vehicles)
.chain(interaction_vehicles)
.chain(passenger_vehicles)
.collect();
if let Some(riding) = local_riding {
ridden.insert(riding);
@@ -781,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 {
@@ -903,14 +1146,7 @@ mod tests {
let mobs = vec![remote_player];
// 1. TextDisplay riding remote player 42
let ridden = collect_vehicle_ids(
&mobs,
vec![42],
std::iter::empty(),
std::iter::empty(),
None,
Some(100),
);
let ridden = collect_vehicle_ids(&mobs, vec![42], None, Some(100));
assert!(
ridden.contains(&42),
"remote player 42 is ridden by text display"
@@ -919,14 +1155,7 @@ mod tests {
assert!(!ridden.contains(&0), "local model 0 has no passenger");
// 2. ArmorStand riding local player 100 -> model 0 must be in ridden
let ridden_local = collect_vehicle_ids(
&mobs,
std::iter::empty(),
vec![100],
std::iter::empty(),
None,
Some(100),
);
let ridden_local = collect_vehicle_ids(&mobs, vec![100], None, Some(100));
assert!(
ridden_local.contains(&100),
"local player 100 is ridden by armor stand"
Loaded 100 of 479 files, more files were not shown because too many files have changed in this diff. Show more