Compare commits

...
Author SHA1 Message Date
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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 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
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
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
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
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
DUzzL cb7babf1cf Normalize END records whose disk numbers are equal but not zero 2026-11-05 22:32:18 +01:00
DUzzL a13106bc0b Format container tooltip lines with numbered translation arguments 2026-11-05 20:35:02 +01:00
DUzzL 878342f98e Keep a synched horse rear until its flag clears 2026-11-05 20:22:16 +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
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 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 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
DUzzL 4272d9ae41 Mobs on a ladder or vine are not pushable; no crit while climbing 2026-11-05 18:47:31 +01:00
274 changed files with 10673 additions and 776 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"
+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()
+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,
@@ -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).
+124 -3
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| {
@@ -7636,9 +7661,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 +11464,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 +13468,7 @@ mod shadow_face_tests {
unculled: None,
every_face,
view_casters: true,
depth_planes: false,
}
}
@@ -13431,6 +13488,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 +13660,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
@@ -368,6 +368,7 @@ impl NativeFrame {
unculled: None,
every_face: false,
view_casters: false,
depth_planes: true,
})
}
@@ -610,13 +610,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 \
@@ -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()
@@ -113,6 +113,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 +142,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,
}
@@ -660,6 +665,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 +1138,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
@@ -2978,6 +2990,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 +3208,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,6 +4057,7 @@ impl PackPost {
},
every_face: true,
view_casters: true,
depth_planes: false,
})
}
@@ -4122,6 +4143,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 +4609,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 +5706,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 [
@@ -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)| {
@@ -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},
@@ -3339,7 +3339,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 +3433,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 {
+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");
+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
+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
+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,
@@ -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(())
}
@@ -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),
+5 -1
View File
@@ -1721,6 +1721,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 +1769,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],
@@ -1923,6 +1926,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,
+128 -2
View File
@@ -22,6 +22,16 @@ use horizon_world::{
};
use std::{collections::HashMap, sync::Arc};
/// Saved data carried by `ConversionType.convertCommon` outside the modelled
/// mob fields. The converted entity has its own identity and type-specific data;
/// only its command tags are inherited from the old entity's raw compound.
pub fn conversion_tags(raw: Option<&Tag>) -> Option<Tag> {
let Tag::List(tags) = raw?.get("Tags")? else {
return None;
};
(!tags.is_empty()).then(|| Tag::compound([("Tags", Tag::List(tags.clone()))]))
}
fn string(s: &str) -> Tag {
Tag::String(s.into())
}
@@ -366,6 +376,8 @@ pub fn mob_to_nbt(
"DeathTime",
Tag::Short(m.death_time.clamp(0, i32::from(i16::MAX)) as i16),
),
// Mob.addAdditionalSaveData writes the current virtual getter, not raw input.
("CanPickUpLoot", Tag::Byte(i8::from(m.can_pick_up_loot()))),
("PersistenceRequired", Tag::Byte(i8::from(m.persistent))),
] {
put(&mut tag, key, value);
@@ -527,7 +539,6 @@ pub fn mob_to_nbt(
for (key, value) in [
("AbsorptionAmount", Tag::Float(0)),
("FallFlying", Tag::Byte(0)),
("CanPickUpLoot", Tag::Byte(i8::from(m.can_pick_up_loot()))),
("current_impulse_context_reset_grace_time", Tag::Int(0)),
(
"Brain",
@@ -3242,7 +3253,7 @@ pub fn armor_stand_to_nbt(a: &ArmorStand, uuid: [i32; 4], raw: Option<&Tag>) ->
motion: a.velocity,
rotation: [a.yaw, a.pitch],
on_ground: a.on_ground,
fall_distance: 0.0,
fall_distance: a.enchantment_fall_distance,
};
let mut tag = base.tag(raw);
let equipment: Vec<(&str, Tag)> = ArmorStand::SLOTS
@@ -3341,6 +3352,7 @@ pub fn armor_stand_to_nbt(a: &ArmorStand, uuid: [i32; 4], raw: Option<&Tag>) ->
const SPEED_ATTRIBUTE: &str = "minecraft:movement_speed";
pub fn armor_stand_from_nbt(tag: &Tag, id: u32) -> Option<ArmorStand> {
let mut a = ArmorStand::new(id, position(tag)?, rotation(tag)[0]);
a.enchantment_fall_distance = float(tag.get("fall_distance")).unwrap_or(0.0);
// AttributeMap.load makes an instance for each saved attribute.
a.speed_instance = matches!(tag.get("attributes"), Some(Tag::List(list))
if list.iter().any(|attr| text(attr.get("id")).as_deref() == Some(SPEED_ATTRIBUTE)));
@@ -3671,6 +3683,35 @@ mod tests {
use super::*;
use crate::items::ItemStack;
#[test]
fn conversion_keeps_only_the_old_mobs_command_tags() {
let tags = Tag::List(vec![
Tag::String("subject".into()),
Tag::String("quest".into()),
]);
let raw = Tag::compound([
("id", Tag::String("minecraft:husk".into())),
("UUID", Tag::IntArray(vec![1, 2, 3, 4])),
("HuskConversionTime", Tag::Int(1)),
("Tags", tags.clone()),
]);
let original = raw.clone();
assert_eq!(
conversion_tags(Some(&raw)),
Some(Tag::compound([("Tags", tags)]))
);
assert_eq!(raw, original);
assert_eq!(conversion_tags(None), None);
assert_eq!(
conversion_tags(Some(&Tag::compound([("Tags", Tag::List(Vec::new()))]))),
None
);
assert_eq!(
conversion_tags(Some(&Tag::Compound(Default::default()))),
None
);
}
#[test]
fn cubes_omit_supplied_age_and_pigs_keep_modeled_age() {
use crate::mobs::Species;
@@ -7026,3 +7067,88 @@ mod piglin_live_anger_memory_tests {
assert_unrelated(&saved);
}
}
#[cfg(test)]
mod pickup_loot_save_tests {
use super::*;
use crate::mobs::Species;
#[test]
fn pickup_save_uses_live_state_instead_of_raw_and_preserves_overrides() {
for (species, id) in [
(Species::Pig, "minecraft:pig"),
(Species::Allay, "minecraft:allay"),
(Species::SulfurCube, "minecraft:sulfur_cube"),
] {
let mut kind = crate::mobs::sensor_scan_tests::kind();
let ty = Arc::make_mut(&mut kind);
ty.id = id.into();
ty.species = species;
let types = HashMap::from([(id.to_string(), kind)]);
for supplied in [None, Some(false), Some(true)] {
let mut raw = Tag::compound([
("id", string(id)),
("Pos", doubles(&[4.5, 200.0, 4.5])),
("audit:unrelated", Tag::Int(7)),
]);
if let Some(on) = supplied {
put(&mut raw, "CanPickUpLoot", Tag::Byte(i8::from(on)));
}
let mut mob = mob_from_nbt(&raw, &types, 1, None).unwrap();
for live in [false, true] {
mob.extra.pick_up_loot = live;
mob.extra.main_hand = live.then(|| "minecraft:stone".into());
mob.extra.body = live.then(|| "minecraft:stone".into());
let expected = if species == Species::SulfurCube {
!live
} else {
live
};
for input in [None, Some(&raw)] {
let saved = mob_to_nbt(&mob, [46, 0, 0, 1], input, None);
assert_eq!(
saved.get("CanPickUpLoot"),
Some(&Tag::Byte(i8::from(expected))),
"{id} supplied={supplied:?} live={live} raw={}",
input.is_some()
);
if input.is_some() {
assert_eq!(saved.get("audit:unrelated"), Some(&Tag::Int(7)));
}
}
}
}
}
}
}
#[cfg(test)]
mod stand_fall_save_tests {
use super::*;
#[test]
fn decoded_stand_fall_distance_survives_save_without_raw_or_with_stale_raw() {
let stale = Tag::compound([("fall_distance", Tag::Double(99.0_f64.to_bits()))]);
for no_gravity in [false, true] {
for distance in [0.0_f64, 1.25, 17.5] {
let input = Tag::compound([
("Pos", doubles(&[4.5, 205.0, 4.5])),
("NoGravity", Tag::Byte(i8::from(no_gravity))),
("fall_distance", Tag::Double(distance.to_bits())),
]);
let stand = armor_stand_from_nbt(&input, 1).unwrap();
for raw in [None, Some(&stale)] {
let saved = armor_stand_to_nbt(&stand, [28, 0, 0, 1], raw);
assert_eq!(
saved.get("fall_distance"),
Some(&Tag::Double(distance.to_bits())),
"distance={distance} no_gravity={no_gravity} raw={}",
raw.is_some()
);
assert_eq!(saved.get("Pos"), input.get("Pos"));
assert_eq!(stand.no_gravity, no_gravity);
}
}
}
}
}
+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
+132 -40
View File
@@ -173,6 +173,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 +1910,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)> {
@@ -3109,6 +3113,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).
@@ -5064,10 +5071,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 +5095,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 +5269,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 +5310,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 +5363,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 +5389,7 @@ impl Game<'_> {
}
}
self.with_level(|b, _, ticks| b.scheduled_finish(ticks, fluids));
laps.lap(name);
}
Ok(())
}
@@ -5677,6 +5697,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)
@@ -11478,8 +11506,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 +11592,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 +11620,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 +12259,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 +12267,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();
@@ -14432,11 +14482,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)
});
@@ -19267,6 +19313,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()
{
@@ -20012,6 +20061,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,
@@ -20943,11 +20993,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())?;
@@ -22311,18 +22359,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
@@ -22485,6 +22563,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 +22809,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();
}
+4 -1
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;
@@ -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 {
@@ -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;
}
+67 -13
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();
@@ -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>) {
@@ -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) => {
@@ -394,8 +394,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 +1210,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;
+144 -11
View File
@@ -592,18 +592,49 @@ 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,
},
true
}
Slot::Frame(id) => match self.item_frames.iter_mut().find(|f| f.id == *id) {
Some(f) => {
f.item = stack;
@@ -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());
}
}
}
}
@@ -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 {
+21
View File
@@ -435,6 +435,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")?;
+169 -35
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`).
@@ -702,6 +710,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 +975,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 +1009,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 +1078,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 +1105,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 +1142,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 +1162,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 +1185,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 +1196,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 +1248,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 +1276,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 +1286,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 +1295,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 +1311,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 +1326,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.
@@ -8454,3 +8487,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
@@ -1119,6 +1119,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 +1343,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 +1653,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");
+2
View File
@@ -3580,6 +3580,8 @@ impl Game<'_> {
vertical_collision: false,
horizontal_collision: false,
needs_sync: false,
idle: false,
bare: false,
leash: None,
spectator: false,
watching: false,
@@ -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`
+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)];
+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()
+10 -2
View File
@@ -3333,7 +3333,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 +3347,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)
@@ -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);
}
}
@@ -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;
+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
+7
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
+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",
+8 -26
View File
@@ -158,9 +158,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,
);
@@ -662,7 +660,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 +674,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 +682,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);
@@ -903,14 +899,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 +908,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"
@@ -18,6 +18,7 @@ use horizon_client::{
hud::Rect,
items::ItemStack,
mesher::Vertex,
mobs::model_layers::{self, ModelPart},
text::Text,
};
use horizon_world::registry::BlockStateId;
@@ -705,6 +706,53 @@ impl Game<'_> {
true
}
/// Draws model part `part` and the parts under it under `draw`'s pose, as
/// vanilla's `ModelPart.render` does: each part moved to its place (in
/// texels, 16 to a block), turned about z, then y, then x, and scaled,
/// its faces textured from `sprite` (`mymod:entity/golem`, a content
/// mod's `assets/mymod/textures/entity/golem.png`, or vanilla's
/// `entity/zombie/zombie`) times the 0xRRGGBB `tint`. A part that isn't
/// `visible` draws nothing, one that skips drawing (`skip_draw`) only its
/// children. A mob's model is built in the model's own frame (+y down,
/// its front towards -z, its feet 24 texels down): in `native_mob_submit`,
/// `draw.scale([1.0, -1.0, -1.0])` then `draw.translate([0.0, -1.501,
/// 0.0])` first, as `LivingEntityRenderer` does. False for an unknown
/// sprite. API @API:model_layers@.
pub(super) fn native_draw_model_part(
&self,
draw: &mut BlockEntityDraw,
part: &ModelPart,
sprite: &str,
tint: u32,
) -> bool {
let Some(rect) = self.gui.atlas.sprites.get(sprite).copied() else {
return false;
};
draw_model_part(draw, part, rect, tint);
true
}
/// Entity model layer `layer` baked into its `ModelPart` tree, each part
/// at its pose, as `EntityModelSet.bakeLayer` bakes it: one a native mod
/// registered (`native_layer_definitions`), or vanilla's, by the name
/// `entity-models.json` gives it (`minecraft:zombie#main`,
/// `minecraft:player#main`). The tree is the caller's own copy: bake it
/// once, as a renderer does, and set its parts' poses each frame. None
/// for a layer neither has. A vanilla layer no mob has (a player's, a
/// chest's) is read from the resources each call, so slow. API
/// @API:model_layers@.
pub(super) fn native_bake_layer(&self, layer: &str) -> Option<ModelPart> {
if let Some(definition) = model_layers::registered(layer) {
return Some(definition.bake_root());
}
let key = layer.strip_prefix("minecraft:")?;
if let Some(part) = self.res.mob_types().values().find_map(|t| t.layer(key)) {
return Some(ModelPart::from_part(part));
}
let models = horizon_client::mobs::EntityModelDocument::load(&self.vanilla_root()).ok()?;
models.model(layer).map(|part| ModelPart::from_part(&part))
}
/// Draws `stack` under `draw`'s pose as an item is drawn in the world
/// (`ItemModelResolver.updateForTopItem` and `ItemStackRenderState.submit`), centred on the
/// pose's origin, in its model's `display` transform: `Fixed` as a
@@ -762,6 +810,45 @@ impl Game<'_> {
}
}
/// `ModelPart.render`: `part`'s faces under its own pose
/// (`translateAndRotate`), then its children under theirs.
fn draw_model_part(draw: &mut BlockEntityDraw, part: &ModelPart, sprite: Rect, tint: u32) {
if !part.visible || (part.is_empty() && part.children().next().is_none()) {
return;
}
draw.push();
draw.apply(part_transform(part));
if !part.skip_draw {
for polygon in &part.polygons {
let corners = polygon.vertices.map(|v| v.pos.map(|c| c / 16.0));
let uv = polygon.vertices.map(|v| [v.u, v.v]);
draw.quad(corners, uv, sprite, tint);
}
}
for (_, child) in part.children() {
draw_model_part(draw, child, sprite, tint);
}
draw.pop();
}
/// `ModelPart.translateAndRotate`: to the part's place in blocks, turned
/// about z, then y, then x (`rotationZYX`), then scaled.
fn part_transform(part: &ModelPart) -> Mat4 {
let mut m = Mat4::from_translation(Vec3::new(part.x, part.y, part.z) / 16.0);
if part.x_rot != 0.0 || part.y_rot != 0.0 || part.z_rot != 0.0 {
m *= Mat4::from_quat(Quat::from_euler(
glam::EulerRot::ZYX,
part.z_rot,
part.y_rot,
part.x_rot,
));
}
if part.x_scale != 1.0 || part.y_scale != 1.0 || part.z_scale != 1.0 {
m *= Mat4::from_scale(Vec3::new(part.x_scale, part.y_scale, part.z_scale));
}
m
}
#[cfg(test)]
mod tests {
use super::*;
@@ -811,6 +898,73 @@ mod tests {
assert_eq!(p, Vec3::new(12.0, 4.0, 2.0));
}
/// An arm a block east of the frame's origin, turned a quarter about z,
/// with a hand a block below its pivot: `translateAndRotate` turns each
/// part about its own pivot, then moves it, and a child goes with its
/// parent. Expected corners are worked out by hand from JOML's
/// `rotationZYX` (a right-handed quarter turn takes +x to +y).
#[test]
fn a_model_part_draws_turned_about_its_pivot_and_carries_its_children() {
use horizon_client::mobs::model_layers::{
CubeListBuilder, LayerDefinition, MeshDefinition, PartPose,
};
use horizon_core::geometry::Direction as Face;
let mut mesh = MeshDefinition::new();
let root = mesh.root();
let cube = |face| CubeListBuilder::create().add_box_faces([0.0; 3], [16.0; 3], &[face]);
let arm = mesh.add_or_replace_child(
root,
"arm",
cube(Face::South),
PartPose::offset_and_rotation(16.0, 0.0, 0.0, 0.0, 0.0, std::f32::consts::FRAC_PI_2),
);
mesh.add_or_replace_child(
arm,
"hand",
cube(Face::North),
PartPose::offset(0.0, 16.0, 0.0),
);
let mut model = LayerDefinition::create(mesh, 64, 64).bake_root();
let drawn = |model: &ModelPart| {
let mut d = draw();
draw_model_part(&mut d, model, sprite(), 0xFF_FFFF);
d.into_vertices()
};
let near = |v: &Vertex, at: [f32; 3]| {
assert!(
Vec3::from(v.pos).abs_diff_eq(Vec3::from(at), 1e-5),
"{:?} not {at:?}",
v.pos
);
};
let all = drawn(&model);
assert_eq!(all.len(), 8);
// The arm's south face, (0, 0, 1) (1, 0, 1) (1, 1, 1) (0, 1, 1) in
// its frame, turned and moved a block east, then the frame's 10.
for (v, at) in all.iter().zip([
[11.0, 0.0, 1.0],
[11.0, 1.0, 1.0],
[10.0, 1.0, 1.0],
[10.0, 0.0, 1.0],
]) {
near(v, at);
}
// Its net's last strip, 48 to 64 across and 16 to 32 down, in the sprite.
assert_eq!(all[0].uv, [0.75, 0.3125]);
// The hand's north face starts at its (1, 0, 0), the arm's (1, 1, 0).
near(&all[4], [10.0, 1.0, 0.0]);
near(&all[5], [10.0, 0.0, 0.0]);
let arm = model.get_child_mut("arm").unwrap();
arm.skip_draw = true;
let hand_only = drawn(&model);
assert_eq!(hand_only.len(), 4);
near(&hand_only[0], [10.0, 1.0, 0.0]);
model.get_child_mut("arm").unwrap().visible = false;
assert!(drawn(&model).is_empty());
}
#[test]
fn a_quad_lands_in_its_sprite_lit_and_shaded() {
let mut d = draw();
+12 -1
View File
@@ -999,7 +999,7 @@ use horizon_client::mod_api::{
self, NativeCamera, NativeCommand, NativeDispensed, NativeLifecycleContext,
NativeNoteBlockChange, NativeServerPlayer, RecipeBookCategory, ServerTickPhase, WorldPhase,
};
use horizon_client::mod_api::{AttachmentResult, Attachments, Holder, Suggestion};
use horizon_client::mod_api::{AttachmentResult, Attachments, Holder, LayerDefinition, Suggestion};
pub(crate) use horizon_client::mod_api::{
ClientboundPlayPacket, NativeBlockRim, NativeColoredLight, NativeLootDrops, NativeMinimap,
NativeMod, NativePanelEvent, NativeScreen, NativeScreenView, NativeStack, NativeZoom,
@@ -3035,6 +3035,17 @@ impl Game<'_> {
pub(super) fn native_standalone_models(models: &mut Vec<String>) {
mod_api::dispatch_standalone_models(mod_api::mods(), models);
}
/// Entity model layers built in code (NeoForge's
/// `EntityRenderersEvent.RegisterLayerDefinitions`): push each one's id,
/// `<mod id>:<model>#<layer>` as a `ModelLayerLocation` names it
/// (`mymod:golem#main`), and its `LayerDefinition`, as the mod's
/// `createBodyLayer()` builds it. `native_bake_layer` bakes one into the
/// `ModelPart` tree `native_draw_model_part` draws. Asked once at start,
/// on every side. An id in vanilla's namespace, not of that form, or
/// registered already is logged and left out. API @API:model_layers@.
pub(super) fn native_layer_definitions(layers: &mut Vec<(String, LayerDefinition)>) {
mod_api::dispatch_layer_definitions(mod_api::mods(), layers);
}
/// Options > Mods lists the native mods: push yours.
pub(super) fn native_mods(&mut self, mods: &mut Vec<NativeMod>) {
mod_api::dispatch_mods(self, mod_api::mods(), mods);
@@ -749,15 +749,18 @@ impl<'a> Game<'a> {
if self.world_debug {
return;
}
let mut laps = horizon_client::spans::laps();
let players = self.level_players();
if players.is_empty() {
return;
}
let mut scratch = std::mem::take(&mut self.spawn_scratch);
let spawnable = self.spawning_chunks(&players, &mut scratch);
laps.lap("sn_candidates");
for &key in &scratch.candidates {
*self.world.inhabited.entry(key).or_insert(0) += 1;
}
laps.lap("sn_inhabited");
let spawn_mobs = self.ui.rules.bool("spawn_mobs");
let spawn_enemies = self.ui.rules.bool("spawn_monsters");
let mut random = std::mem::replace(&mut self.world.random, LegacyRandom::new(0));
@@ -765,6 +768,7 @@ impl<'a> Game<'a> {
let last_state;
{
let level = self.natural_level();
laps.lap("sn_level");
let counted: Vec<Counted> = self
.mobs
.iter()
@@ -788,20 +792,25 @@ impl<'a> Game<'a> {
spectator: p.spectator,
})
.collect();
laps.lap("sn_materialize");
let mut state = SpawnState::new(&level, spawnable, players, &counted, |cx, cz| {
self.world.chunks.contains_key(&(cx, cz))
});
laps.lap("sn_state");
last_state = state.counts();
let categories = if spawn_mobs {
state.filtered_categories(spawn_enemies, self.world.game_time % 400 == 0)
} else {
Vec::new()
};
laps.lap("sn_categories");
// Util.shuffle.
for i in (1..scratch.candidates.len()).rev() {
let j = random.next_int_bound(i as i32 + 1).unwrap_or(0) as usize;
scratch.candidates.swap(i, j);
}
laps.lap("sn_shuffle");
spawn_selection::attempt_counters(false);
if !categories.is_empty() {
for chunk in scratch.candidates.drain(..) {
let mut spawn = |l: &GameLevel, r: &SpawnRequest| {
@@ -823,11 +832,14 @@ impl<'a> Game<'a> {
}
}
}
spawn_selection::attempt_counters(true);
laps.lap("sn_attempts");
self.world.random = random;
// ServerChunkCache.lastSpawnState, for the debug overlay.
self.spawn_counts.insert(self.dim, last_state);
self.add_spawned(pending, false);
self.spawn_scratch = scratch;
laps.lap("sn_finalize");
}
/// `NoiseBasedChunkGenerator.spawnOriginalMobs`: creature packs for a
/// freshly generated chunk, from the biome at its corner column's top,
+2 -1
View File
@@ -28,7 +28,8 @@ impl Game<'_> {
let griefing = self.ui.rules.bool("mob_griefing");
for i in 0..self.mobs.len() {
let m = &self.mobs[i];
if m.species() != Species::Panda || !m.alive() {
if m.species() != Species::Panda || !m.alive() || !self.creatures.ticked.contains(&m.id)
{
continue;
}
let id = m.id;
+15 -3
View File
@@ -7,6 +7,9 @@ use horizon_client::mobs::{Species, riders};
use horizon_core::{geometry::Vec3, random::RandomSource};
use std::collections::HashMap;
/// The mobs riding each vehicle, by the vehicle's id: their indices, in the mobs' order.
pub(super) type Riders = HashMap<u32, Vec<usize>>;
/// `Zombie.finalizeSpawn`: a baby zombie takes a chicken one time in twenty,
/// and another one time in twenty of the rest.
const CHICKEN_JOCKEY_ODDS: f32 = 0.05;
@@ -125,9 +128,18 @@ impl Game<'_> {
/// its mob. A server's client places them itself too, as the server sends
/// no passenger's position.
pub(super) fn position_riders(&mut self) {
self.seat_boat_mobs();
self.seat_cart_mobs();
self.seat_tnt_mobs();
// Every vehicle's riders in one pass over the mobs, not one pass per
// boat, minecart and primed TNT. Seating moves the riders but changes
// no one's vehicle, so it holds for all three.
let mut riders = Riders::default();
for (j, m) in self.mobs.iter().enumerate() {
if let Some(v) = m.extra.mount {
riders.entry(v).or_default().push(j);
}
}
self.seat_boat_mobs(&riders);
self.seat_cart_mobs(&riders);
self.seat_tnt_mobs(&riders);
let index: HashMap<u32, usize> = self
.mobs
.iter()
+31 -2
View File
@@ -227,6 +227,20 @@ enum RiddenBody {
Living(f32),
}
/// Whether a shadow pass that draws the player (`shadow_draws_player`: shadowPlayer, or a
/// native pack's shadow) takes the player's own model in first person. A shader pack's shadow
/// pass does, except for a spectator: in our probe packs' readings of Iris 1.11.7's shadow map
/// in spectator mode, the texels at the first-person camera hold the terrain below it, where
/// Horizon drew the spectator's model (vanilla's `PlayerModel` hides a spectator's body:
/// `showBody = !state.isSpectator`). A native pack's shadow keeps it.
fn shadow_pass_takes_own_model(
shadow_draws_player: bool,
shader_pack: bool,
spectator: bool,
) -> bool {
shadow_draws_player && !(shader_pack && spectator)
}
/// `LivingEntityRenderer.solveBodyRot` for the rider of a living vehicle: the
/// body faces the vehicle's body, the head turning at most 85 degrees from it,
/// and past 50 degrees the body turns a fifth of the head's offset along.
@@ -654,8 +668,12 @@ impl Game<'_> {
/// Whether the player's own model shows in the world view (its body or,
/// while invisible, only its armour and held items).
pub(super) fn player_model_shown(&self) -> bool {
// A shader pack's shadow pass draws the player in first person too (shadowPlayer).
(self.own_body_in_view() || self.pack_shadow_casters.1)
(self.own_body_in_view()
|| shadow_pass_takes_own_model(
self.pack_shadow_casters.1,
self.pack_ids.is_some(),
self.spectator(),
))
&& self
.player
.as_ref()
@@ -1391,6 +1409,17 @@ fn player_walk_distance(from: Vec3, to: Vec3) -> f32 {
mod preview_tests {
use super::*;
#[test]
fn a_spectators_own_model_stays_out_of_a_shader_packs_shadow_pass() {
// First person, a shader pack whose shadow pass draws the player: a spectator's head
// isn't in Iris's map (#8464); the other game modes keep the model, as before.
assert!(!shadow_pass_takes_own_model(true, true, true));
assert!(shadow_pass_takes_own_model(true, true, false));
// A native pack's shadow keeps the first-person model, a spectator's too.
assert!(shadow_pass_takes_own_model(true, false, true));
// No shadow pass that draws the player: nothing to take.
assert!(!shadow_pass_takes_own_model(false, true, false));
}
#[test]
fn the_player_model_rises_with_the_camera_at_levitation_255() {
// LivingEntity.travel at Levitation 255: 0.05 * 255 blocks a tick.
let from = Vec3::new(0.5, 80.0, 0.5);
+160 -10
View File
@@ -6,6 +6,7 @@ use horizon_client::{
items::ItemStack,
mobs::{
Mob, Species,
equip::{self, Slot},
finalize::{
SLOT_NAMES, item_stack, set_drop_chance, set_item, set_item_stack,
set_slot_enchantments, slot_enchantments, stored_drop_chance,
@@ -43,15 +44,37 @@ impl Game<'_> {
.unwrap_or("mainhand");
SLOT_NAMES.iter().position(|n| *n == slot).unwrap_or(0)
}
/// `Piglin.canReplaceCurrentItem`: an empty slot takes anything, a wanted
/// item replaces an unwanted one and never the other way round. Vanilla's
/// armour and weapon comparison for two equal-interest items is left out
/// (the piglin keeps what it has).
fn piglin_can_replace(&self, m: &Mob, item: &str, slot: usize) -> bool {
let Some(current) = horizon_client::mobs::finalize::item(m, slot) else {
/// `Piglin.canReplaceCurrentItem`: an empty slot takes anything, a
/// `PREVENT_ARMOR_CHANGE` piece stays, a wanted item replaces an unwanted
/// one and never the other way round; when both or neither are wanted,
/// `Mob.canReplaceCurrentItem` compares armour or weapons.
fn piglin_can_replace(&self, m: &Mob, stack: &ItemStack, slot: usize) -> bool {
let Some(current) = item_stack(m, slot) else {
return true;
};
self.piglin_wanted(m, item) && !self.piglin_wanted(m, &current)
let new_wanted = self.piglin_wanted(m, &stack.item);
let current_wanted = self.piglin_wanted(m, &current.item);
let slot = [
Slot::MainHand,
Slot::OffHand,
Slot::Feet,
Slot::Legs,
Slot::Chest,
Slot::Head,
]
.get(slot)
.copied()
.unwrap_or(Slot::MainHand);
let new = self.stack_facts(m, stack, slot);
let worn = self.stack_facts(m, &current, slot);
// `getPreferredWeaponType` is the preferred-weapons tag for adults.
let preferred = (!m.is_baby()).then(|| {
(
self.item_in_tag("minecraft:piglin_preferred_weapons", &stack.item),
self.item_in_tag("minecraft:piglin_preferred_weapons", &current.item),
)
});
piglin_replace(new_wanted, current_wanted, &new, &worn, slot, preferred)
}
/// `SimpleContainer.canAddItem` on the piglin's eight slots.
fn piglin_can_add(&self, m: &Mob, stack: &ItemStack) -> bool {
@@ -86,7 +109,7 @@ impl Game<'_> {
} else if self.item_in_tag("minecraft:piglin_food", item) {
m.extra.ints[0] <= 0 && space
} else if !self.piglin_loved(item) {
self.piglin_can_replace(m, item, self.piglin_slot(item))
self.piglin_can_replace(m, stack, self.piglin_slot(item))
} else {
!holds_loved && space
}
@@ -98,7 +121,7 @@ impl Game<'_> {
let item = &*stack.item;
let mut slot = self.piglin_slot(item);
let m = &self.mobs[index];
let mut replace = self.piglin_can_replace(m, item, slot);
let mut replace = self.piglin_can_replace(m, stack, slot);
if slot > 1 && !replace {
slot = 0;
replace = m.extra.main_hand.is_none();
@@ -194,7 +217,11 @@ impl Game<'_> {
}
for i in 0..self.mobs.len() {
let m = &self.mobs[i];
if m.species() != Species::Piglin || !m.alive() || m.extra.using_item > 0 {
if m.species() != Species::Piglin
|| !m.alive()
|| m.extra.using_item > 0
|| !self.creatures.ticked.contains(&m.id)
{
continue;
}
let pos = m.position;
@@ -445,3 +472,126 @@ impl Game<'_> {
.is_some_and(|t| t.contains(name))
}
}
/// The decision of `Piglin.canReplaceCurrentItem` once both stacks are known.
fn piglin_replace(
new_wanted: bool,
current_wanted: bool,
new: &equip::Facts,
worn: &equip::Facts,
slot: Slot,
preferred: Option<(bool, bool)>,
) -> bool {
if worn.prevents_armor_change {
return false;
}
if new_wanted != current_wanted {
return new_wanted;
}
equip::can_replace_current_item(new, Some(worn), slot, preferred)
}
#[cfg(test)]
mod tests {
use super::*;
fn armor(points: f64, binding: bool) -> equip::Facts {
equip::Facts {
armor: points,
armor_toughness: 0.0,
attack_damage: 0.0,
enchantments: 0,
damage: 0,
custom_name: false,
prevents_armor_change: binding,
}
}
#[test]
fn better_helmet_replaces_worse_one() {
// Diamond (3) over iron (2), neither loved.
let (diamond, iron) = (armor(3.0, false), armor(2.0, false));
assert!(piglin_replace(
false,
false,
&diamond,
&iron,
Slot::Head,
None
));
assert!(!piglin_replace(
false,
false,
&iron,
&diamond,
Slot::Head,
None
));
}
#[test]
fn binding_curse_keeps_the_worn_piece() {
let (diamond, cursed) = (armor(3.0, false), armor(1.0, true));
assert!(!piglin_replace(
false,
false,
&diamond,
&cursed,
Slot::Head,
None
));
assert!(!piglin_replace(
true,
false,
&diamond,
&cursed,
Slot::Head,
None
));
}
#[test]
fn wanted_beats_comparison() {
let (gold, diamond) = (armor(2.0, false), armor(3.0, false));
assert!(piglin_replace(
true,
false,
&gold,
&diamond,
Slot::Head,
None
));
assert!(!piglin_replace(
false,
true,
&diamond,
&gold,
Slot::Head,
None
));
}
#[test]
fn preferred_weapon_comparison_by_damage() {
let mut strong = armor(0.0, false);
strong.attack_damage = 7.0;
let mut weak = armor(0.0, false);
weak.attack_damage = 4.0;
assert!(piglin_replace(
false,
false,
&strong,
&weak,
Slot::MainHand,
None
));
assert!(!piglin_replace(
false,
false,
&weak,
&strong,
Slot::MainHand,
None
));
}
}
+69
View File
@@ -261,6 +261,24 @@ struct Tracked {
kind: String,
position: [f64; 3],
silent: bool,
/// `Entity.vehicle`: the entity it rides (`SetPassengers`), whatever
/// either one's type.
vehicle: Option<u32>,
}
/// `Entity.startRiding` / `removePassenger` for a `SetPassengers` packet: the
/// listed entities ride `vehicle`, the ones that rode it and are no longer
/// listed got off. Every tracked entity counts, whatever its type, so a
/// server's item display or marker on a player hides that player's name tag
/// as vanilla's `isVehicle` does (#5666).
fn seat_passengers(entities: &mut HashMap<i32, Tracked>, vehicle: u32, passengers: &[i32]) {
for (id, t) in entities.iter_mut() {
if passengers.contains(id) {
t.vehicle = Some(vehicle);
} else if t.vehicle == Some(vehicle) {
t.vehicle = None;
}
}
}
/// Smooth an entity toward a new position over three ticks (InterpolationHandler).
#[derive(Clone, Copy)]
@@ -2652,6 +2670,18 @@ impl Game<'_> {
}
}
}
/// `Entity.isVehicle` on a client: the entities a tracked entity rides.
pub(super) fn remote_vehicle_ids(&self) -> impl Iterator<Item = u32> + '_ {
self.remote
.iter()
.flat_map(|r| r.entities.values().filter_map(|t| t.vehicle))
}
/// The `SetPassengers` bookkeeping of the tracked entities.
pub(super) fn remote_seat_passengers(&mut self, vehicle: u32, passengers: &[i32]) {
if let Some(r) = self.remote.as_mut() {
seat_passengers(&mut r.entities, vehicle, passengers);
}
}
/// `recreateFromPacket`: yRot and xRot from the packet, the body and head
/// both at its head yaw (LivingEntity).
fn spawn_entity(&mut self, id: i32, kind: &str, position: [f64; 3], rotation: [f32; 3]) {
@@ -2706,6 +2736,7 @@ impl Game<'_> {
kind: kind.to_owned(),
position,
silent: false,
vehicle: None,
},
);
}
@@ -11585,6 +11616,42 @@ mod tests {
"minecraft:entity.player.hurt"
);
}
/// `ClientPacketListener.handleSetEntityPassengersPacket`: the vehicle
/// ejects its passengers, then each listed entity `startRiding`s it, so
/// `isVehicle` holds while the list is not empty, whatever the passenger's
/// type: a server's item display riding a player hides that player's name
/// tag like a text display does (#5666).
#[test]
fn any_tracked_passenger_makes_its_vehicle_a_vehicle() {
use std::collections::{HashMap, HashSet};
let tracked = |kind: &str| super::Tracked {
kind: kind.to_owned(),
position: [0.0, 64.0, 0.0],
silent: false,
vehicle: None,
};
let mut entities = HashMap::from([
(42, tracked("minecraft:player")),
(43, tracked("minecraft:player")),
(9, tracked("minecraft:item_display")),
(10, tracked("minecraft:block_display")),
]);
let vehicles = |e: &HashMap<i32, super::Tracked>| -> HashSet<u32> {
e.values().filter_map(|t| t.vehicle).collect()
};
super::seat_passengers(&mut entities, 42, &[9, 10]);
assert_eq!(vehicles(&entities), HashSet::from([42]));
// The list replaces the old one: 10 got off, 9 still rides.
super::seat_passengers(&mut entities, 42, &[9]);
assert_eq!(entities[&10].vehicle, None);
assert_eq!(entities[&9].vehicle, Some(42));
// startRiding leaves the old vehicle first.
super::seat_passengers(&mut entities, 43, &[9]);
assert_eq!(vehicles(&entities), HashSet::from([43]));
// An empty list: nobody rides 43 any more.
super::seat_passengers(&mut entities, 43, &[]);
assert!(vehicles(&entities).is_empty());
}
/// `ClientboundSoundEntityPacket`: the sound is heard where the entity it
/// names is — the local player included, which is no tracked entity — and
/// an id this level doesn't have plays nothing at all.
@@ -11598,6 +11665,7 @@ mod tests {
kind: "minecraft:cow".to_owned(),
position: [8.5, 64.0, -3.5],
silent: false,
vehicle: None,
},
);
let player = Some((2, [0.5, 70.0, 0.5], false));
@@ -11640,6 +11708,7 @@ mod tests {
kind: "minecraft:arrow".to_owned(),
position: [8.5, 64.0, -3.5],
silent: true,
vehicle: None,
},
);
let state = super::entity_sound_state(7, None, None, &tracked, None).unwrap();
+175 -14
View File
@@ -9,7 +9,9 @@
//! server (`LocalPlayer.tick`: `ServerboundMoveVehiclePacket`,
//! `AbstractBoat.tick`: `ServerboundPaddleBoatPacket`). So is a saddled horse,
//! donkey, mule or camel: its rider's client moves it and reports the moves,
//! and tells the server of a riding jump (`START_RIDING_JUMP`). A minecart
//! and tells the server of a riding jump (`START_RIDING_JUMP`). Pigs and striders
//! also move on their rider's client when saddled and steered with the right stick.
//! A minecart
//! moves on the server with the rider's input, and the rider sits in it.
use super::{Game, Keys, ray_box};
use crate::Result;
@@ -154,6 +156,16 @@ impl Game<'_> {
return;
};
let vehicle_id = vehicle as u32;
self.remote_seat_passengers(vehicle_id, passengers);
let player_controls = passengers.first().is_some_and(|&id| {
self.remote
.as_ref()
.is_some_and(|r| r.level_player(id).is_some())
});
if let Some(boat) = self.boats.iter_mut().find(|b| b.id == vehicle_id) {
boat.paddle_controller = player_controls;
}
for m in &mut self.mobs {
// `Entity.isVehicle` (the saddle reins of a ridden horse): the
// server sends no rider on the mob itself, only this list.
@@ -399,7 +411,7 @@ impl Game<'_> {
/// boat on a client: `status`, the two `applyEffectsFromBlocks` (a boat
/// above a column may splash; the boat the player drives is lifted or
/// dragged by a column it is inside) and `tickBubbleColumn`'s wobble from
/// the synched bubble time.
/// the synched bubble time, followed by the oars on boats this client does not drive.
pub(super) fn tick_remote_boat_bubbles(&mut self) {
for i in 0..self.boats.len() {
let driven = self.riding == Some(self.boats[i].id);
@@ -423,6 +435,13 @@ impl Game<'_> {
}
}
self.boats[i].tick_bubble_wobble();
if !driven {
Self::tick_boat_paddles(
&mut self.boats[i],
&mut self.audio,
&mut self.world.random,
);
}
}
}
@@ -431,7 +450,19 @@ impl Game<'_> {
/// saddle (`isLocalInstanceAuthoritative`).
fn drives(&self, m: &Mob) -> bool {
self.riding == Some(m.id)
&& client_moves(m, self.remote.as_ref().is_some_and(|r| r.first_passenger))
&& client_moves(
m,
self.remote.as_ref().is_some_and(|r| r.first_passenger),
self.steers_mount(m),
)
}
fn steers_mount(&self, m: &Mob) -> bool {
super::mounts::steering_item(
m.species(),
self.held().map(|s| s.item.as_ref()),
self.offhand.as_ref().map(|s| s.item.as_ref()),
)
}
/// The mount this client moves, if the player rides one.
@@ -452,6 +483,7 @@ impl Game<'_> {
}
let strafe = f32::from(u8::from(keys.left)) - f32::from(u8::from(keys.right));
let forward = f32::from(u8::from(keys.forward)) - f32::from(u8::from(keys.backward));
let steer = self.steers_mount(&self.mobs[i]);
let m = &mut self.mobs[i];
if jump > 0.0 && equine::is_equine(m.species()) {
// LocalPlayer.aiStep also runs `onPlayerJump` here: the mount slides
@@ -470,7 +502,7 @@ impl Game<'_> {
yaw: self.yaw,
pitch: self.pitch,
jump: if jump > 0.0 { jump } else { pending },
steer: false,
steer,
sprint: sprinting,
jumping: keys.jump,
client: false,
@@ -498,12 +530,15 @@ impl Game<'_> {
/// (`HappyGhast.getControllingPassenger`: wearing body armor, player on
/// top, not on a still timeout — the rider steers it with `travelRidden`
/// on their own client, and the mount goes to the server as `MoveVehicle`).
fn client_moves(m: &Mob, first_passenger: bool) -> bool {
fn client_moves(m: &Mob, first_passenger: bool, steer: bool) -> bool {
if !first_passenger || !m.alive() {
return false;
}
if equine::moved_by_rider(m.species()) && m.extra.saddled {
return true;
return !matches!(
m.species(),
horizon_client::mobs::Species::Pig | horizon_client::mobs::Species::Strider
) || steer;
}
m.species() == horizon_client::mobs::Species::HappyGhast
&& happy_ghast::harnessed_adult(m)
@@ -536,6 +571,129 @@ mod tests {
use super::*;
use horizon_client::mobs::MobTypes;
#[test]
#[ignore = "requires prepared resources and the vanilla mount-control reference"]
fn steering_mount_control_matches_vanilla() {
use super::super::{
Audio, Options, Progress, Settings, execution_role::ExecutionRole, load_resources,
};
use horizon_client::{items::ItemStack, motion::PlayerMotion};
use horizon_protocol::connection::Pipe;
let root = std::path::PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES").unwrap());
let reference = std::env::var_os("HORIZON_MOUNT_CONTROL_REFERENCE").unwrap();
let rows: Vec<serde_json::Value> =
serde_json::from_slice(&std::fs::read(reference).unwrap()).unwrap();
assert_eq!(rows.len(), 32, "missing vanilla control cases");
let settings = Settings::default();
let loaded = load_resources(
root.clone(),
None,
&root,
&settings,
std::time::Instant::now(),
false,
true,
&Progress::new(),
&mut |_, _| unreachable!("no menu handoff"),
)
.unwrap();
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
options.resources = root.clone();
options.execution_role = ExecutionRole::Client;
let mut game = Game::new(
&loaded.resources,
loaded.pack,
loaded.gui.unwrap(),
options,
settings,
Audio::silent(&root),
None,
)
.unwrap();
let (client, _server) = Pipe::pair();
game.connect_local(client, "SteeringTest").unwrap();
game.remote.as_mut().unwrap().joined = true;
game.player = Some(PlayerMotion::new(game.motion, Vec3::new(0.5, 300.0, 0.5)).unwrap());
let types = MobTypes::load(&root).unwrap();
for row in rows {
let kind = row["kind"].as_str().unwrap();
let t = std::sync::Arc::new(types.types[&format!("minecraft:{kind}")].clone());
let mut m = Mob::new(7, t, Vec3::new(0.5, 300.0, 0.5), 0.0);
if row["saddled"].as_bool().unwrap() {
m.set_saddle(Some(ItemStack::new("minecraft:saddle", 1)));
}
let stick = if kind == "pig" {
"minecraft:carrot_on_a_stick"
} else {
"minecraft:warped_fungus_on_a_stick"
};
let wrong = if kind == "pig" {
"minecraft:warped_fungus_on_a_stick"
} else {
"minecraft:carrot_on_a_stick"
};
let hand = row["hand"].as_str().unwrap();
game.inventory[game.selected] = match hand {
"main" => Some(ItemStack::new(stick, 1)),
"wrong" => Some(ItemStack::new(wrong, 1)),
_ => None,
};
game.offhand = (hand == "off").then(|| ItemStack::new(stick, 1));
game.mobs.clear();
game.mobs.push(m);
game.riding = Some(7);
game.remote.as_mut().unwrap().first_passenger = row["first"].as_bool().unwrap();
let controlled = row["controlled"].as_bool().unwrap();
assert_eq!(game.driven_mount().is_some(), controlled, "{row}");
let packets = game.remote_vehicle_packets();
assert_eq!(packets.len(), usize::from(controlled), "{row}");
if controlled {
assert!(matches!(packets[0], S::MoveVehicle(_)), "{row}");
let before = game.mobs[0].position;
assert!(game.remote_ride(Keys::default(), 0).unwrap());
let m = &game.mobs[0];
assert_eq!(
f64::from(m.ai.zza),
row["forward"].as_f64().unwrap(),
"{row}"
);
assert!(m.position.z > before.z, "{row}: mount did not move forward");
match &game.remote_vehicle_packets()[0] {
S::MoveVehicle(p) => assert_eq!(
p.position,
[m.position.x, m.position.y, m.position.z],
"{row}"
),
packet => panic!("wrong movement packet: {packet:?}"),
}
}
// The server reads the same held-item requirement, then waits for
// this controlling client's MoveVehicle rather than traveling twice.
if row["first"].as_bool().unwrap() {
assert!(game.tick_mount_riding(Keys::default()));
game.mobs[0].rider.as_mut().unwrap().client = true;
} else {
game.mobs[0].rider = None;
}
assert_eq!(
equine::moved_by_client(&game.mobs[0]),
!row["server_simulates"].as_bool().unwrap(),
"{row}"
);
if controlled {
let mut server_mount = game.mobs[0].clone();
let before = server_mount.position;
let mut random = game.world.random.clone();
let mut events = Vec::new();
server_mount.tick(&game.mob_world_base(&[]), &mut random, &mut events);
assert_eq!(
server_mount.position, before,
"server traveled twice: {row}"
);
}
}
}
/// A mob of the prepared resources' type `kind` (None without them).
fn mob(kind: &str) -> Option<Mob> {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
@@ -547,7 +705,7 @@ mod tests {
}
/// #2920: on a vanilla server the rider's client moves a saddled horse,
/// donkey, mule or camel; a pig, a llama, an unsaddled horse or a second
/// donkey, mule or camel; a pig without its stick, a llama, an unsaddled horse or a second
/// passenger's ride is the server's.
#[test]
fn the_rider_moves_a_saddled_horse_on_its_client() {
@@ -555,14 +713,17 @@ mod tests {
let Some(mut m) = mob(&format!("minecraft:{kind}")) else {
return;
};
assert!(!client_moves(&m, true), "{kind} without a saddle");
assert!(!client_moves(&m, true, false), "{kind} without a saddle");
m.set_saddle(Some(horizon_client::items::ItemStack::new(
"minecraft:saddle",
1,
)));
let horse = matches!(kind, "horse" | "donkey" | "mule" | "camel");
assert_eq!(client_moves(&m, true), horse, "{kind}");
assert!(!client_moves(&m, false), "{kind} behind another rider");
assert_eq!(client_moves(&m, true, false), horse, "{kind}");
assert!(
!client_moves(&m, false, false),
"{kind} behind another rider"
);
}
}
@@ -574,12 +735,12 @@ mod tests {
let Some(mut m) = mob("minecraft:happy_ghast") else {
return;
};
assert!(!client_moves(&m, true), "ghast without a harness");
assert!(!client_moves(&m, true, false), "ghast without a harness");
m.extra.body = Some("minecraft:white_happy_ghast_harness".into());
assert!(client_moves(&m, true), "harnessed adult");
assert!(!client_moves(&m, false), "behind another rider");
assert!(client_moves(&m, true, false), "harnessed adult");
assert!(!client_moves(&m, false, false), "behind another rider");
m.extra.age = -1;
assert!(!client_moves(&m, true), "baby ghast");
assert!(!client_moves(&m, true, false), "baby ghast");
}
/// #2920: the client reports where it moved the mount, and a released
@@ -69,6 +69,14 @@ const HORIZONTAL: [[i32; 3]; 8] = [
[-1, 0, 1],
[1, 0, 1],
];
/// Blocks vanilla defines with `.liquid()`: water, lava and the bubble column.
fn is_liquid_block_name(name: &str) -> bool {
matches!(
name,
"minecraft:water" | "minecraft:lava" | "minecraft:bubble_column"
)
}
fn respawn_offsets() -> impl Iterator<Item = [i32; 3]> {
HORIZONTAL
.into_iter()
@@ -413,7 +421,7 @@ impl Game<'_> {
name.ends_with("_banner")
|| f.sign_form().is_some()
|| name.ends_with("_pressure_plate")
|| !(f.legacy_solid || matches!(name, "minecraft:water" | "minecraft:lava"))
|| !(f.legacy_solid || is_liquid_block_name(name))
}
/// `RespawnAnchorBlock.findStandUpPosition`: a safe spot around the
/// anchor, else any spot.
@@ -539,6 +547,14 @@ fn wrap_degrees(degrees: f64) -> f64 {
mod tests {
use super::*;
#[test]
fn bubble_column_counts_as_liquid_for_respawn() {
assert!(is_liquid_block_name("minecraft:bubble_column"));
assert!(is_liquid_block_name("minecraft:water"));
assert!(is_liquid_block_name("minecraft:lava"));
assert!(!is_liquid_block_name("minecraft:kelp"));
}
#[test]
fn waking_up_next_to_a_bed_looks_toward_the_bed_head() {
let head = [10, 64, 5];
+162 -26
View File
@@ -97,6 +97,8 @@ pub(super) struct SculkState {
/// The vehicle each rider had last tick and where it stood.
mounts: FxHashMap<Who, (u32, [f64; 3])>,
damaged: FxHashMap<u32, i64>,
/// Set while the mobs' events are posted (`tick_mob_sculk_events`).
mob_slots: Option<MobSlots>,
last_health: Option<f32>,
/// The darkness effect's blend factor, previous and current tick.
pub darkness: (f32, f32),
@@ -106,6 +108,27 @@ pub(super) struct SculkState {
pub tracker_loaded: bool,
}
/// How far from a warden's or an allay's feet an event may be heard: their
/// listeners reach 16 blocks from the eye's block (`warden::listen`,
/// `allay::listen`), the eye stands under 3 blocks up, and flooring to blocks
/// adds under 2. Past this they do nothing with it.
const LISTENING_MOB_REACH: f64 = 24.0;
fn dist_sqr_f(a: [f64; 3], b: [f64; 3]) -> f64 {
(0..3).map(|i| (a[i] - b[i]) * (a[i] - b[i])).sum()
}
/// Where each mob is in `Game::mobs` while the mobs' events are posted, for
/// the thousands of lookups by id they make, and whether any mob listens.
struct MobSlots {
/// The first slot of each id, as a scan would find.
by_id: FxHashMap<u32, usize>,
/// The wardens and allays (the mobs that listen): slot, id and position.
listeners: Vec<(usize, u32, [f64; 3])>,
/// `Game::next_entity` when they were taken: unchanged, no mob joined.
next_entity: u32,
}
impl SculkState {
pub(super) fn swap_player(&mut self, other: &mut PlayerEvents) {
let steps = self.steps.remove(&Who::Player);
@@ -551,7 +574,19 @@ impl Game<'_> {
}
/// `GameEvent.Context.of(mob, state)`.
pub(super) fn mob_context(&self, id: u32, state: Option<BlockStateId>) -> Context {
self.context_of_mob(self.mobs.iter().find(|m| m.id == id), id, state)
self.context_of_mob(self.mob_by_id(id), id, state)
}
/// The first mob with this id, from the slots taken for the mobs' events
/// while the mob is still there (mobs are only appended meanwhile), else
/// by a scan.
fn mob_by_id(&self, id: u32) -> Option<&Mob> {
if let Some(slots) = &self.sculk.mob_slots
&& let Some(&i) = slots.by_id.get(&id)
&& self.mobs.get(i).is_some_and(|m| m.id == id)
{
return self.mobs.get(i);
}
self.mobs.iter().find(|m| m.id == id)
}
fn context_of_mob(&self, mob: Option<&Mob>, id: u32, state: Option<BlockStateId>) -> Context {
Context {
@@ -581,6 +616,18 @@ impl Game<'_> {
/// `Level.gameEvent`: tell the listeners within reach.
pub(super) fn post_game_event(&mut self, event: &'static str, pos: [f64; 3], ctx: Context) {
self.post_game_event_with(event, pos, |_| ctx);
}
/// [`Self::post_game_event`] with the context worked out only when some
/// listener may hear the event (`GameEventDispatcher.post` visits only the
/// listeners registered in the sections around it): most of a server's
/// mobs are near none.
fn post_game_event_with(
&mut self,
event: &'static str,
pos: [f64; 3],
ctx: impl FnOnce(&Self) -> Context,
) {
self.native_game_event(event, pos);
super::dispense_fixture::record(|| {
format!(
@@ -596,18 +643,50 @@ impl Game<'_> {
let at = block_of(pos);
let radius = sculk::notification_radius(event).min(16);
let near = self.sculk.index.within(at, radius);
// The wardens and allays that may hear it, in the mobs' order, while
// the slots taken for the mobs' events hold; else every mob is asked.
let mobs = self.sculk.mob_slots.as_ref().and_then(|s| {
let mut near = Vec::new();
for &(i, id, from) in &s.listeners {
if self.mobs.get(i).is_none_or(|m| m.id != id) {
return None;
}
let d = dist_sqr_f(from, pos);
if d.is_nan() || d <= LISTENING_MOB_REACH * LISTENING_MOB_REACH {
near.push(i);
}
}
(s.next_entity == self.next_entity).then_some(near)
});
if near.is_empty() && mobs.as_ref().is_some_and(Vec::is_empty) {
return;
}
let ctx = ctx(self);
for key in near {
self.sculk_listen(key, event, pos, &ctx);
}
for i in 0..self.mobs.len() {
if self.mobs[i].species() == Species::Warden && self.mobs[i].alive() {
self.warden_listen(i, event, pos, &ctx);
match mobs {
Some(mobs) => {
for i in mobs {
self.mob_listen(i, event, pos, &ctx);
}
}
if self.mobs[i].species() == Species::Allay && self.mobs[i].alive() {
self.allay_listen(i, event, pos, &ctx);
None => {
for i in 0..self.mobs.len() {
self.mob_listen(i, event, pos, &ctx);
}
}
}
}
/// The mob at `index` hears a game event, if it is a warden or an allay.
fn mob_listen(&mut self, index: usize, event: &'static str, pos: [f64; 3], ctx: &Context) {
if self.mobs[index].species() == Species::Warden && self.mobs[index].alive() {
self.warden_listen(index, event, pos, ctx);
}
if self.mobs[index].species() == Species::Allay && self.mobs[index].alive() {
self.allay_listen(index, event, pos, ctx);
}
}
/// A game event at a block (`Level.gameEvent(source, event, pos)`).
pub(super) fn post_block_game_event(
&mut self,
@@ -640,14 +719,11 @@ impl Game<'_> {
.as_ref()
.map(|p| [p.position.x, p.position.y, p.position.z]),
Who::Mob(id) => self
.mobs
.iter()
.find(|m| m.id == id)
.mob_by_id(id)
.map(|m| [m.position.x, m.position.y, m.position.z]),
};
if let Some(at) = at {
let ctx = self.who_context(Some(who), None);
self.post_game_event(event, at, ctx);
self.post_game_event_with(event, at, |g| g.who_context(Some(who), None));
}
}
/// `LevelEvent::GameEvent` from the block behaviour library.
@@ -1669,11 +1745,15 @@ impl Game<'_> {
let present: HashSet<u32> = self.dropped.iter().map(|item| item.id).collect();
self.sculk.falling_items.retain(|id| present.contains(id));
}
// Dropped items are entities too: Entity.applyEffectsFromBlocks.
// Dropped items are entities too: Entity.applyEffectsFromBlocks, from
// ItemEntity.tick, so only for the items in entity-ticking chunks.
let ticking = self.entity_ticking_area();
let items: Vec<[f64; 3]> = self
.dropped
.iter()
.filter(|i| i.on_ground)
.filter(|i| {
i.on_ground && ticking(i.position.x.floor() as i32, i.position.z.floor() as i32)
})
.map(|i| [i.position.x, i.position.y, i.position.z])
.collect();
for at in items {
@@ -1893,15 +1973,54 @@ impl Game<'_> {
)
})
.collect();
let mut by_id = FxHashMap::default();
for (i, m) in self.mobs.iter().enumerate() {
by_id.entry(m.id).or_insert(i);
}
let listeners = self
.mobs
.iter()
.enumerate()
.filter(|(_, m)| matches!(m.species(), Species::Warden | Species::Allay))
.map(|(i, m)| (i, m.id, [m.position.x, m.position.y, m.position.z]))
.collect();
self.sculk.mob_slots = Some(MobSlots {
by_id,
listeners,
next_entity: self.next_entity,
});
// Steps, landings, splashes, flaps and stepping on redstone ore come
// from the mob's own tick (Entity.move, LivingEntity.aiStep's
// applyEffectsFromBlocks), which ServerLevel.tick runs only in
// entity-ticking chunks: for the mobs `tick_creature_entities` ticked,
// and those standing in that area (the sculk fixture posts before the
// mobs tick). Damage and mounting are raised by `hurt` and
// `startRiding`, ticking or not.
let ticking = self.entity_ticking_area();
for (id, at, on_ground, in_water, species, damaged, vehicle, age, resting) in snapshot {
if on_ground {
let ticked = self.creatures.ticked.contains(&id)
|| ticking(at[0].floor() as i32, at[2].floor() as i32);
if on_ground && ticked {
self.step_on_redstone_ore(at, false, false);
}
// A mob first seen already riding raised its event when it spawned.
let seen = self.sculk.steps.contains_key(&Who::Mob(id));
self.mount_events(Who::Mob(id), vehicle, seen);
let emits = !matches!(species, Species::Shulker);
if emits {
if emits && !ticked {
// Where it is now, without a move: a mob put elsewhere while
// it did not tick (a command) walked nowhere.
let entry = self.sculk.steps.entry(Who::Mob(id)).or_insert(Steps {
move_dist: 0.0,
next_step: 1.0,
last: at,
on_ground,
in_water,
});
entry.last = at;
entry.on_ground = on_ground;
entry.in_water = in_water;
} else if emits {
let warden = species == Species::Warden;
self.entity_motion_events(
Who::Mob(id),
@@ -1926,13 +2045,14 @@ impl Game<'_> {
}
}
}
if let Some(t) = damaged {
let seen = self.sculk.damaged.insert(id, t);
if seen != Some(t) {
self.post_entity_event("minecraft:entity_damage", Who::Mob(id));
}
if let Some(t) = damaged
&& self.sculk.damaged.get(&id) != Some(&t)
{
self.sculk.damaged.insert(id, t);
self.post_entity_event("minecraft:entity_damage", Who::Mob(id));
}
}
self.sculk.mob_slots = None;
let alive: FxHashSet<u32> = self.mobs.iter().map(|m| m.id).collect();
self.sculk.damaged.retain(|id, _| alive.contains(id));
self.sculk.steps.retain(|who, _| match who {
@@ -1946,7 +2066,7 @@ impl Game<'_> {
}
/// Where the vehicle `id` stands: a mob, boat, minecart or cushion.
fn vehicle_position(&self, id: u32) -> Option<[f64; 3]> {
let p = if let Some(m) = self.mobs.iter().find(|m| m.id == id) {
let p = if let Some(m) = self.mob_by_id(id) {
m.position
} else if let Some(b) = self.boats.iter().find(|b| b.id == id) {
b.position
@@ -1963,6 +2083,10 @@ impl Game<'_> {
/// for a rider first seen riding.
fn mount_events(&mut self, who: Who, vehicle: Option<u32>, announce: bool) {
let last = self.sculk.mounts.get(&who).copied();
// Riding nothing, as last tick: nothing to tell or keep.
if vehicle.is_none() && last.is_none() {
return;
}
let now = vehicle.and_then(|id| self.vehicle_position(id));
if last.map(|l| l.0) != vehicle {
if let Some((_, at)) = last {
@@ -2022,7 +2146,7 @@ impl Game<'_> {
match who {
Who::Player => self.player.as_ref()?.ground.supporting_block,
Who::Mob(id) => {
let mob = self.mobs.iter().find(|m| m.id == id)?;
let mob = self.mob_by_id(id)?;
self.supporting_block(mob.position, mob.bounds())
}
}
@@ -2039,6 +2163,16 @@ impl Game<'_> {
warden: bool,
) {
self.sculk_prepare();
// Standing as it stood with no step due (a mob outside the simulation
// distance, or one at rest): nothing below would change or be heard.
if let Some(e) = self.sculk.steps.get(&who)
&& e.last == at
&& e.on_ground == on_ground
&& e.in_water == in_water
&& e.move_dist <= e.next_step
{
return;
}
let entry = self.sculk.steps.entry(who).or_insert(Steps {
move_dist: 0.0,
next_step: 1.0,
@@ -2066,8 +2200,9 @@ impl Game<'_> {
at[2].floor() as i32,
),
};
let ctx = self.who_context(Some(who), Some(state));
self.post_game_event("minecraft:hit_ground", at, ctx);
self.post_game_event_with("minecraft:hit_ground", at, |g| {
g.who_context(Some(who), Some(state))
});
}
// Entity.updateInWaterStateAndDoFluidPushing: entering water splashes.
if in_water && !previous.in_water {
@@ -2118,8 +2253,9 @@ impl Game<'_> {
} else {
(move_dist as i32).wrapping_add(1) as f32
};
let ctx = self.who_context(Some(who), Some(state));
self.post_game_event("minecraft:step", at, ctx);
self.post_game_event_with("minecraft:step", at, |g| {
g.who_context(Some(who), Some(state))
});
} else if in_water {
entry.next_step = (move_dist as i32).wrapping_add(1) as f32;
self.post_entity_event("minecraft:swim", who);
+16
View File
@@ -499,11 +499,27 @@ impl Game<'_> {
) {
let atlas = &self.gui.atlas;
let lookup = |name: &str| atlas.sprites.get(name).map(|r| [r.u0, r.v0, r.u1, r.v1]);
// A shader pack's shadow pass draws a block entity only in the sections it draws
// (`Renderer::shadow_pass_block_entities`, #8464): a spawner's mob out of the view and
// out of those sections isn't drawn, where it would have gone to the shadow pass alone.
let shadow_sections = self
.renderer
.as_ref()
.filter(|_| self.pack_ids.is_some() && self.pack_shadow_casters.0);
for (key, (_, mob)) in &self.spawners.displays {
let at = glam::DVec3::new(f64::from(key.0), f64::from(key.1), f64::from(key.2));
if at.distance_squared(camera) >= 64.0 * 64.0 {
continue;
}
if let Some(r) = shadow_sections
&& !r.shadow_pass_drew([key.0, key.1, key.2])
&& self.view_frustum.is_some_and(|v| {
let o = (at - camera).as_vec3();
!v.intersects(o - glam::Vec3::ONE, o + glam::Vec3::splat(2.0))
})
{
continue;
}
let (spin, scale) = match self.world.block_entities.get(key) {
Some(BlockEntity::Spawner(s)) => {
s.display_pose(alpha as f32, mob.width(), mob.height())
@@ -223,7 +223,7 @@ impl Game<'_> {
let griefing = self.ui.rules.bool("mob_griefing");
for i in 0..self.mobs.len() {
let m = &self.mobs[i];
if m.species() != Species::SulfurCube {
if m.species() != Species::SulfurCube || !self.creatures.ticked.contains(&m.id) {
continue;
}
if !m.alive() || m.is_baby() || sulfur_cube::has_body(m) || m.extra.ints[3] > 0 {
+5 -4
View File
@@ -484,17 +484,18 @@ impl Game<'_> {
impl Game<'_> {
/// `positionRider` for the mobs riding primed TNT: the passenger attachment
/// (the top of its 0.98 box), less the rider's own vehicle attachment.
pub(super) fn seat_tnt_mobs(&mut self) {
pub(super) fn seat_tnt_mobs(&mut self, riders: &super::passengers::Riders) {
for i in 0..self.tnts.len() {
let (id, pos, prev) = (
self.tnts[i].id,
self.tnts[i].position,
self.tnts[i].previous,
);
if id == 0 {
let Some(js) = riders.get(&id).filter(|_| id != 0) else {
continue;
}
for m in self.mobs.iter_mut().filter(|m| m.extra.mount == Some(id)) {
};
for &j in js {
let m = &mut self.mobs[j];
// Primed TNT's box is 0.98 wide.
let offset = horizon_client::mobs::riders::vehicle_attachment(m, 0.98);
m.position = tnt_seat(pos, offset);
+36 -6
View File
@@ -247,7 +247,10 @@ impl Game<'_> {
pub(super) fn villager_item_sense(&mut self) {
if self.dropped.is_empty() {
for m in &mut self.mobs {
if m.species() == Species::Villager && villager_brain::take_item_scan(m) {
if m.species() == Species::Villager
&& self.creatures.ticked.contains(&m.id)
&& villager_brain::take_item_scan(m)
{
villager_brain::set_wanted_item(m, None);
}
}
@@ -261,7 +264,10 @@ impl Game<'_> {
.cloned();
let mut due = Vec::new();
for (i, m) in self.mobs.iter_mut().enumerate() {
if m.species() == Species::Villager && villager_brain::take_item_scan(m) {
if m.species() == Species::Villager
&& self.creatures.ticked.contains(&m.id)
&& villager_brain::take_item_scan(m)
{
due.push(i);
}
}
@@ -314,26 +320,49 @@ impl Game<'_> {
.item_tags
.get("minecraft:villager_picks_up")
.cloned();
// A villager with no item near skips its scan; one near an item scans
// the items of the cells it meets as before, testing the cheap reach
// first: a server's villagers and dropped items each run into the
// hundreds or thousands.
let mut near_items = super::item_areas::ItemAreas::default();
for i in 0..self.mobs.len() {
let m = &self.mobs[i];
if m.species() != Species::Villager || !m.alive() {
if m.species() != Species::Villager
|| !m.alive()
|| !self.creatures.ticked.contains(&m.id)
{
continue;
}
let (pos, half) = (m.position, f64::from(m.width()) / 2.0 + 1.0);
let top = f64::from(m.height());
let reach = horizon_core::geometry::Aabb::new(
Vec3::new(pos.x - half, pos.y - 0.25, pos.z - half),
Vec3::new(pos.x + half, pos.y + top, pos.z + half),
);
let mut scan = near_items.scan(&self.dropped, self.next_entity, reach);
if scan.is_empty() {
continue;
}
let requested = villager::requested_items(m);
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;
};
let near = (e.position.x - pos.x).abs() <= half
&& (e.position.z - pos.z).abs() <= half
&& e.position.y >= pos.y - 0.25
&& e.position.y <= pos.y + top;
if !near || e.pickup_delay > 0 {
j += 1;
continue;
}
let item = e.stack.item.clone();
let wants = villager::food_points(&item) > 0
|| picks_up.as_ref().is_some_and(|t| t.contains(&*item))
|| requested.contains(&&*item);
if !near || e.pickup_delay > 0 || !wants {
if !wants {
j += 1;
continue;
}
@@ -355,6 +384,7 @@ impl Game<'_> {
match rest {
None => {
self.remove_dropped(j);
scan.removed(j);
}
Some(rest) => {
self.dropped[j].stack.count = rest.count;
+175 -2
View File
@@ -1036,6 +1036,34 @@ pub fn tag_json(tag: &Tag) -> Value {
Tag::LongArray(a) => Value::Array(a.iter().map(|v| Value::from(*v)).collect()),
}
}
/// Custom model flags use Codec.BOOL even though NBT stores them as numbers.
/// Keep the other custom-model fields, and generic NBT JSON, numeric.
fn custom_model_data_json(tag: &Tag) -> Value {
let mut value = tag_json(tag);
let flags: Option<Vec<Value>> = match tag.get("flags") {
Some(Tag::List(flags)) => flags
.iter()
.map(|flag| crate::item_data::component_bool(flag).map(Value::Bool))
.collect(),
Some(Tag::ByteArray(flags)) => flags
.iter()
.map(|flag| crate::item_data::component_bool(&Tag::Byte(*flag as i8)).map(Value::Bool))
.collect(),
Some(Tag::IntArray(flags)) => flags
.iter()
.map(|flag| crate::item_data::component_bool(&Tag::Int(*flag)).map(Value::Bool))
.collect(),
Some(Tag::LongArray(flags)) => flags
.iter()
.map(|flag| crate::item_data::component_bool(&Tag::Long(*flag)).map(Value::Bool))
.collect(),
_ => None,
};
if let (Some(flags), Some(object)) = (flags, value.as_object_mut()) {
object.insert("flags".into(), Value::Array(flags));
}
value
}
/// The resolver's view of a client stack: its item, count and component patch.
pub fn stack_view(stack: &crate::items::ItemStack) -> StackView {
let mut view = StackView::new(&stack.item, stack.count as i32);
@@ -1045,7 +1073,20 @@ pub fn stack_view(stack: &crate::items::ItemStack) -> StackView {
let key = k.to_string_lossy();
match key.strip_prefix('!') {
Some(removed) => view.patch.insert(removed.to_owned(), None),
None => view.patch.insert(key, Some(tag_json(v))),
None => {
// The component codec turns NBT numeric booleans into JSON booleans.
// Generic NBT conversion must keep unrelated bytes numeric.
let value = if key == "minecraft:enchantment_glint_override" {
crate::item_data::component_bool(v)
.map(Value::Bool)
.unwrap_or_else(|| tag_json(v))
} else if key == "minecraft:custom_model_data" {
custom_model_data_json(v)
} else {
tag_json(v)
};
view.patch.insert(key, Some(value))
}
};
}
}
@@ -1172,6 +1213,65 @@ pub fn cluster_poses(state: &ItemRenderState, pose: Mat4, amount: u32, seed: i64
mod tests {
use super::*;
#[test]
fn custom_model_flags_select_their_model_after_stack_nbt_decoding() {
let mut actual = Vec::new();
for flags in [
Tag::List(vec![Tag::Byte(1), Tag::Byte(0)]),
Tag::ByteArray(vec![1, 0]),
] {
let mut stack = crate::items::ItemStack::new("minecraft:diamond", 1);
let component = Tag::compound([
("flags", flags),
("floats", Tag::List(vec![Tag::Float(1.25f32.to_bits())])),
("colors", Tag::List(vec![Tag::Int(0x12_34_56)])),
("strings", Tag::List(vec![Tag::String("keep".into())])),
]);
(stack.damage, stack.extra) = crate::item_data::decode_components(Some(
&Tag::compound([("minecraft:custom_model_data", component)]),
));
let view = stack_view(&stack);
for index in 0..3 {
let mut models = ItemModels::default();
models.prototypes.insert(
"minecraft:diamond".into(),
BTreeMap::from([(
"minecraft:item_model".into(),
serde_json::json!("minecraft:diamond"),
)]),
);
models.definitions.insert("minecraft:diamond".into(), ClientItem::decode(&serde_json::json!({
"model": {"type": "minecraft:condition", "property": "minecraft:custom_model_data", "index": index,
"on_true": {"type": "minecraft:model", "model": "horizon:item/yes"},
"on_false": {"type": "minecraft:model", "model": "horizon:item/no"}}
})).unwrap());
let resolved = models.resolve(
&view,
&Context {
display: DisplayContext::Gui,
owner: None,
level: None,
seed: 0,
},
&horizon_assets::item_model::NoEnv,
);
assert_eq!(resolved.layers.len(), 1);
actual.push(
resolved.layers[0].content
== LayerContent::Model {
model: "horizon:item/yes".into(),
},
);
}
// Non-boolean fields must retain their codec's numeric/string values.
let component = view.patch["minecraft:custom_model_data"].as_ref().unwrap();
assert_eq!(component["floats"], serde_json::json!([1.25]));
assert_eq!(component["colors"], serde_json::json!([0x12_34_56]));
assert_eq!(component["strings"], serde_json::json!(["keep"]));
}
assert_eq!(actual, [true, false, false, true, false, false]);
}
#[test]
fn posed_item_bounds_include_each_cluster_transform() {
let state = ItemRenderState {
@@ -1332,12 +1432,85 @@ mod tests {
("a", Tag::Int(3)),
("b", Tag::List(vec![Tag::String("x".into())])),
("c", Tag::Float(1.5f32.to_bits())),
("d", Tag::Byte(1)),
]);
assert_eq!(
tag_json(&tag),
serde_json::json!({"a": 3, "b": ["x"], "c": 1.5})
serde_json::json!({"a": 3, "b": ["x"], "c": 1.5, "d": 1})
);
}
#[test]
fn stack_glint_override_reaches_the_resolved_item_layers() {
use horizon_assets::item_model::NoEnv;
let mut models = ItemModels::default();
models.definitions.insert(
"horizon:fixture".into(),
ClientItem::from_json(
r#"{"model":{"type":"minecraft:model","model":"horizon:item/fixture"}}"#,
)
.unwrap(),
);
for default in [false, true] {
models.prototypes.insert(
"minecraft:diamond".into(),
BTreeMap::from([
(
"minecraft:item_model".into(),
serde_json::json!("horizon:fixture"),
),
(
"minecraft:enchantment_glint_override".into(),
Value::Bool(default),
),
]),
);
for (tag, flag) in [
(Tag::Byte(1), true),
(Tag::Byte(0), false),
(Tag::Byte(-1), true),
(Tag::Short(256), true),
(Tag::Int(1), true),
(Tag::Long(1 << 40), true),
(Tag::Float(0.5_f32.to_bits()), true),
(Tag::Float((-0.0_f32).to_bits()), false),
(Tag::Double(0.5_f64.to_bits()), true),
(Tag::Double((-0.0_f64).to_bits()), false),
(Tag::Float(f32::NAN.to_bits()), true),
(Tag::Double(f64::INFINITY.to_bits()), true),
] {
let mut stack = crate::items::ItemStack::new("minecraft:diamond", 1);
(stack.damage, stack.extra) = crate::item_data::decode_components(Some(
&Tag::compound([("minecraft:enchantment_glint_override", tag)]),
));
assert_eq!(stack.has_foil(default), flag);
let view = stack_view(&stack);
assert_eq!(
view.patch["minecraft:enchantment_glint_override"],
Some(Value::Bool(flag))
);
let resolved = models.resolve(
&view,
&Context {
display: DisplayContext::Gui,
owner: None,
level: None,
seed: 0,
},
&NoEnv,
);
assert_eq!(resolved.layers.len(), 1);
assert_eq!(
resolved.layers[0].foil,
if flag {
FoilType::Standard
} else {
FoilType::None
}
);
}
}
}
#[test]
fn palette_trns_partial_alpha_counts_as_translucent() {
// Slime/honey (and ice) are indexed PNGs with a tRNS chunk. Decode must
+11 -6
View File
@@ -1054,16 +1054,15 @@ pub fn container_lines(
let mut lines: Vec<String> = slots
.iter()
.take(5)
.map(|(_, s)| {
count
.replacen("%s", &s.count.to_string(), 1)
.replacen("%s", &name(s), 1)
})
.map(|(_, s)| horizon_protocol::text::format_java(&count, &[s.count.to_string(), name(s)]))
.collect();
if slots.len() > 5 {
let more =
translate("item.container.more_items").unwrap_or_else(|| "and %s more...".into());
lines.push(more.replacen("%s", &(slots.len() - 5).to_string(), 1));
lines.push(horizon_protocol::text::format_java(
&more,
&[(slots.len() - 5).to_string()],
));
}
lines
}
@@ -1568,6 +1567,12 @@ mod tests {
]
);
assert!(container_lines(&ItemStack::new("minecraft:chest", 1), &name, &lang).is_empty());
// German writes the count after the name: "%2$s× %1$s".
let german = |k: &str| match k {
"item.container.item_count" => Some("%2$s× %1$s".to_owned()),
other => lang(other),
};
assert_eq!(container_lines(&chest, &name, &german)[0], "stone× 1");
}
#[test]
+44 -4
View File
@@ -489,7 +489,7 @@ fn serve_connection(
// ServerHandshakePacketListenerImpl: a server that does not reply
// to status requests closes the connection.
handshake::STATUS if !shared.enable_status() => Ok(()),
handshake::STATUS => serve_status(&mut c, &shared),
handshake::STATUS => serve_status(&mut c, &shared, intention.protocol),
handshake::TRANSFER if !shared.accepts_transfers() => {
login_disconnect(
&mut c,
@@ -498,7 +498,9 @@ fn serve_connection(
Ok(())
}
handshake::LOGIN | handshake::TRANSFER => {
if intention.protocol != PROTOCOL_VERSION {
if let Some(version) = older_client(&shared, intention.protocol) {
c.serve_older(version);
} else if intention.protocol != PROTOCOL_VERSION {
let key = if intention.protocol < 754 {
"multiplayer.disconnect.outdated_client"
} else {
@@ -520,7 +522,18 @@ fn serve_connection(
}
}
fn serve_status(c: &mut Connection, shared: &Shared) -> Result<(), String> {
/// The older release a client's handshake states, when this server lets it join
/// (`horizon-accept-older-clients`): 26.2 only, for now.
fn older_client(
shared: &Shared,
protocol: i32,
) -> Option<horizon_protocol::adapter::ServerVersion> {
use horizon_protocol::adapter::ServerVersion;
let version = ServerVersion::from_protocol(protocol)?;
(shared.config.accept_older_clients && version == ServerVersion::V26_2).then_some(version)
}
fn serve_status(c: &mut Connection, shared: &Shared, protocol: i32) -> Result<(), String> {
let e = |e: horizon_protocol::Error| e.to_string();
let mut requested = false;
loop {
@@ -530,7 +543,11 @@ fn serve_status(c: &mut Connection, shared: &Shared) -> Result<(), String> {
status::Serverbound::StatusRequest(_) if requested => return Ok(()),
status::Serverbound::StatusRequest(_) => {
requested = true;
let json = shared.status_json();
// A client this server lets join is told its own protocol, so its list shows it joinable.
let json = match older_client(shared, protocol) {
Some(_) => shared.status_json_for(protocol),
None => shared.status_json(),
};
c.send(
&status::Clientbound::StatusResponse(status::StatusResponse { json }).encode(),
)
@@ -962,6 +979,24 @@ fn login_and_configure(
_ => {}
}
};
// An older client's packet ids are translated, not yet what is inside them (#10404): the registry data,
// tags and play packets would reach it in 26.3's ids and layouts, so it is turned away before them.
if let Some(version) = c.older_client()
&& !shared.config.older_clients_play
{
let text = super::OLDER_CLIENTS_UNFINISHED;
let reason = horizon_world::nbt::Tag::String(horizon_world::nbt::JavaString::from(text));
let _ = send(c, cfg::Clientbound::Disconnect(cfg::Disconnect { reason }));
if let Some(log) = &shared.config.log {
let line = format!(
"Disconnecting {name}: its client is on protocol {} ({text})",
version.protocol()
);
(log.write)("Server thread", "INFO", &line);
}
lost(shared, &format!("{name} ({})", uuid_string(uuid)), text);
return Ok(None);
}
// Custom component IDs need Horizon's registry check. The server may also
// require Horizon clients even when it has no custom components.
if brand != "horizon"
@@ -1599,6 +1634,11 @@ pub(super) fn frame_body(
body: &[u8],
out: &mut Vec<u8>,
) -> horizon_protocol::Result<()> {
// Renumbered first for a client of an older release; one with no such packet gets nothing.
let Some(body) = writer.for_client(body)? else {
return Ok(());
};
let body = &body[..];
match writer.threshold {
Some(t) if body.len() >= t.max(SHARED_FRAME_MIN) => {
writer.frame_into(&SharedFrames::get().contents(body, t)?, out);
+127 -9
View File
@@ -75,6 +75,14 @@ pub struct EntityState {
pub horizontal_collision: bool,
/// `Entity.needsSync`: send its changes this tick, due or not.
pub needs_sync: bool,
/// Outside `DistanceManager.inEntityTickingRange`: `ChunkMap.tick` sends
/// its changes only if it changed section or needs sync (its lead aside,
/// which `Leashable` sends at once).
pub idle: bool,
/// Where it is, and none of its data: an idle entity no client can pair
/// with this tick, whose data the tracker then never reads (game/lan_idle.rs).
/// It is never paired.
pub bare: bool,
/// `Leashable.getLeashHolder`: the entity holding its lead.
pub leash: Option<i32>,
/// `ServerPlayer.isSpectator`: a player in spectator mode, whom only the
@@ -111,6 +119,8 @@ impl EntityState {
self.vertical_collision = false;
self.horizontal_collision = false;
self.needs_sync = false;
self.idle = false;
self.bare = false;
self.leash = None;
self.spectator = false;
self.watching = false;
@@ -410,14 +420,7 @@ impl ServerEntity {
self.passengers.clear();
self.passengers.extend_from_slice(passengers);
}
// Leashable.setLeashedTo / dropLeash: sendToTrackingPlayers at once.
if e.leash != self.leash {
out.push(play::Clientbound::SetEntityLink(SetEntityLink {
source: e.id,
dest: e.leash.unwrap_or(0),
}));
self.leash = e.leash;
}
self.send_leash(e, out);
// SynchedEntityData.isDirty: once for every client.
let dirty = e.meta != self.meta;
let due = self.interval != 0 && self.tick.is_multiple_of(self.interval);
@@ -555,6 +558,17 @@ impl ServerEntity {
self.send_equipment(e, out);
}
/// Leashable.setLeashedTo / dropLeash: sendToTrackingPlayers at once.
fn send_leash(&mut self, e: &EntityState, out: &mut Vec<play::Clientbound>) {
if e.leash != self.leash {
out.push(play::Clientbound::SetEntityLink(SetEntityLink {
source: e.id,
dest: e.leash.unwrap_or(0),
}));
self.leash = e.leash;
}
}
/// ServerEntity.handleMinecartPosRot: send every recorded substep in order.
/// Only an empty queue uses the fallback endpoint (e.g. a stationary cart
/// teleported by a command); this never replaces a curved track's steps.
@@ -959,6 +973,11 @@ impl Entities {
self.states.is_empty()
}
/// How many passes the level's tracking has run.
pub fn ticks(&self) -> u32 {
self.tick
}
/// Whether the entity `id` is tracked (it was in the last tick's pass).
pub fn tracks(&self, id: i32) -> bool {
self.states.contains_key(&id)
@@ -1241,7 +1260,14 @@ impl Entities {
.iter()
.any(|&(after, through)| e.id > after && e.id <= through);
if !s.added || synced {
s.send_changes(e, riding, &mut self.packets);
// ChunkMap.tick: sendChanges for an entity that changed
// section, needs sync or is in entity-ticking range. An idle
// one's update clock stands still and its changes wait.
if e.idle && !self.is_moved[index] && !e.needs_sync {
s.send_leash(e, &mut self.packets);
} else {
s.send_changes(e, riding, &mut self.packets);
}
}
s.added = false;
// Nothing to send: no changes, and nobody starts tracking it.
@@ -1262,6 +1288,7 @@ impl Entities {
}
v.out.extend(self.bodies.iter().cloned());
} else if f & SEES != 0
&& !e.bare
&& let Some(kind) = s.kind
{
let bodies = pairing.get_or_insert_with(|| {
@@ -1329,6 +1356,8 @@ mod tests {
vertical_collision: false,
horizontal_collision: false,
needs_sync: false,
idle: false,
bare: false,
leash: None,
spectator: false,
watching: false,
@@ -1894,6 +1923,95 @@ mod tests {
assert_eq!(c.out[0], c.out[1]);
}
/// ChunkMap.tick runs sendChanges only for an entity in entity-ticking
/// range, one that changed section or one that needs sync. An idle
/// zombie sends nothing, not even the position every 60 ticks (its
/// update clock stands still); a data change waits until it is back in
/// range, and then goes out as vanilla sends it. A lead goes out at once
/// (Leashable), needsSync and a section change send its changes now.
#[test]
fn an_idle_entity_sends_only_what_chunk_map_sends() {
let mut level = Entities::default();
let mut c = Clients::new();
let mut e = state(15.5);
c.tick(&mut level, &[&e]);
assert!(c.trackers.iter().all(|t| t.is_tracking(7)));
// Its first update: tick 0's position.
c.tick(&mut level, &[&e]);
assert_eq!(c.out[0].len(), 1);
e.idle = true;
for _ in 0..100 {
c.tick(&mut level, &[&e]);
assert!(c.out.iter().all(Vec::is_empty));
}
let health = |h: f32| {
body(play::Clientbound::SetEntityData(SetEntityData {
id: 7,
values: vec![(9, serializer::FLOAT, Value::Float(h))],
}))
};
e.meta.push((9, serializer::FLOAT, Value::Float(5.0)));
c.tick(&mut level, &[&e]);
assert!(c.out.iter().all(Vec::is_empty));
// Back in range: the change alone (update tick 1 is not a position one).
e.idle = false;
c.tick(&mut level, &[&e]);
assert_eq!(c.out, [vec![health(5.0)], vec![health(5.0)]]);
e.idle = true;
e.meta[0].2 = Value::Float(4.0);
e.needs_sync = true;
c.tick(&mut level, &[&e]);
assert_eq!(c.out[0], [health(4.0)]);
e.needs_sync = false;
e.leash = Some(3);
c.tick(&mut level, &[&e]);
let link = body(play::Clientbound::SetEntityLink(SetEntityLink {
source: 7,
dest: 3,
}));
assert_eq!(c.out[0], [link]);
// Into the next section: a move.
e.position[0] = 16.5;
c.tick(&mut level, &[&e]);
let moved = MoveEntityPos(MoveEntity {
id: 7,
delta: Some(VecDelta::Linear([4096, 0, 0])),
rotation: None,
on_ground: true,
});
assert_eq!(c.out[0], [body(play::Clientbound::MoveEntityPos(moved))]);
c.tick(&mut level, &[&e]);
assert!(c.out.iter().all(Vec::is_empty));
}
/// A bare state carries none of the entity's data, so it is never paired:
/// a client that could pair is sent the entity once it is described in full.
#[test]
fn a_bare_state_is_never_paired() {
let mut level = Entities::default();
let mut c = Clients::new();
c.positions[1] = [500.0, 64.0, 0.0];
let mut e = state(0.0);
e.meta.push((9, serializer::FLOAT, Value::Float(5.0)));
c.tick(&mut level, &[&e]);
e.idle = true;
let bare = EntityState {
meta: Vec::new(),
bare: true,
..e.clone()
};
c.positions[1] = [0.0, 64.0, 0.0];
c.tick(&mut level, &[&bare]);
assert!(!c.trackers[1].is_tracking(7) && c.out[1].is_empty());
c.tick(&mut level, &[&e]);
assert!(c.trackers[1].is_tracking(7));
let data = body(play::Clientbound::SetEntityData(SetEntityData {
id: 7,
values: e.meta.clone(),
}));
assert!(c.out[1].contains(&data));
}
/// ChunkMap.addEntity pairs an entity as it joined the level: one the
/// game added after the last tick's pass (an item a landing falling
/// block drops) is paired from where it joined, not from where its first
+9
View File
@@ -836,6 +836,13 @@ impl Hub {
pub fn here(&self, g: &Guest) -> bool {
g.placed && g.dimension == self.config.dimension
}
/// How many passes the level in place's entity tracking has run (None
/// before its first).
pub fn tracking_ticks(&self) -> Option<u32> {
self.entities
.get(&self.config.dimension)
.map(Entities::ticks)
}
/// Whether a guest is sent the level in place: the players placed in it, and
/// the held ones, which look at it without being part of it.
pub fn watches(&self, g: &Guest) -> bool {
@@ -3060,6 +3067,8 @@ impl Hub {
vertical_collision: false,
horizontal_collision: false,
needs_sync: false,
idle: false,
bare: false,
minecart_steps: None,
leash: None,
spectator: false,
+22 -4
View File
@@ -150,6 +150,12 @@ pub const HORIZON_CLIENTS_ONLY: &str = "horizon-clients-only";
/// What another client is told.
pub const HORIZON_CLIENTS_ONLY_REASON: &str =
"This server is for the Horizon client: playhorizon.dev";
/// The server.properties key (server.toml's `server.accept_older_clients`) that lets 26.2 clients join
/// (off unless "true").
pub const ACCEPT_OLDER_CLIENTS: &str = "horizon-accept-older-clients";
/// What a 26.2 client is told while only its packet ids are translated (#10404).
pub const OLDER_CLIENTS_UNFINISHED: &str =
"This server lets 26.2 join, but Horizon cannot translate 26.2 yet: join with 26.3";
/// How the server side of the network behaves: an integrated server's LAN
/// uses the defaults, a dedicated server takes them from server.properties.
@@ -210,6 +216,15 @@ pub struct NetConfig {
/// client whose `minecraft:brand` isn't Horizon's is turned away during
/// configuration. A brand can be spoofed: a gate, not security.
pub horizon_clients_only: bool,
/// `horizon-accept-older-clients` (off): a client of 26.2 joins too, its packets renumbered
/// (horizon_protocol::adapter::ClientSession). Off, it is turned away as vanilla turns it away.
pub accept_older_clients: bool,
/// Lets an older client past configuration although only its packet ids are translated yet
/// (#10404's later parts translate the registry ids and the packets whose layout changed): for tests
/// of the id renumbering, never in server.toml. False, such a client is told
/// [`OLDER_CLIENTS_UNFINISHED`] before any registry data, tag or play packet would reach it in 26.3's
/// ids and layouts.
pub older_clients_play: bool,
/// `horizon-lod-distance` (Horizon's own server.properties key, 64): how
/// far, in chunks, the server samples far terrain for Horizon clients
/// (`lod_net`); 0 serves none.
@@ -258,6 +273,8 @@ impl NetConfig {
code_of_conducts: Vec::new(),
server_links: Vec::new(),
horizon_clients_only: false,
accept_older_clients: false,
older_clients_play: false,
lod_distance: crate::lod_net::DEFAULT_DISTANCE,
levels: crate::chunk::LevelShapes::VANILLA,
owner_profile: None,
@@ -481,6 +498,10 @@ impl Shared {
/// ServerStatus.CODEC): description, players with up to 12 shuffled
/// names, version, favicon and enforcesSecureChat when true.
pub fn status_json(&self) -> String {
self.status_json_for(PROTOCOL_VERSION)
}
/// [`Self::status_json`] stating `protocol`: an older client's own, when it may join.
pub fn status_json_for(&self, protocol: i32) -> String {
use crate::server::json::Json;
let motd = self.motd.lock().unwrap().clone();
let online: Vec<(u128, String)> = self.online.lock().unwrap().clone();
@@ -522,10 +543,7 @@ impl Shared {
"version".to_owned(),
Json::Object(vec![
("name".to_owned(), Json::String(GAME_VERSION.to_owned())),
(
"protocol".to_owned(),
Json::Number(PROTOCOL_VERSION.to_string()),
),
("protocol".to_owned(), Json::Number(protocol.to_string())),
]),
),
];
+41 -1
View File
@@ -56,6 +56,7 @@ pub mod landing;
pub mod leash;
pub mod living;
pub mod llama;
pub mod model_layers;
pub mod monster_brain;
pub mod nautilus;
pub mod nav;
@@ -1316,6 +1317,39 @@ impl Extra {
}
}
/// How far the boxes of a set of mobs reach from their positions: the widest half width and the
/// tallest height among them. A box query over many mobs (a vehicle's, against every mob of the
/// level) tests each mob's position against this first, and works out the box ([`Mob::bounds`],
/// which reads the mob's whole size) only for the mobs whose position comes close.
#[derive(Clone, Copy, Debug, Default)]
pub struct BoxReach {
half_width: f64,
height: f64,
}
impl BoxReach {
pub fn of(mobs: &[Mob]) -> Self {
mobs.iter().fold(Self::default(), |r, m| {
let (width, height, _) = m.dims();
Self {
half_width: r.half_width.max(width / 2.0),
height: r.height.max(height),
}
})
}
/// Whether the box of `m`, one of the mobs this was worked out for, can meet `area`: a
/// superset, the caller still tests the box.
#[inline]
pub fn may_meet(self, m: &Mob, area: Aabb) -> bool {
let (lo, hi, p) = (area.min_position(), area.max_position(), m.position);
p.x - self.half_width <= hi.x
&& p.x + self.half_width >= lo.x
&& p.z - self.half_width <= hi.z
&& p.z + self.half_width >= lo.z
&& p.y <= hi.y
&& p.y + self.height >= lo.y
}
}
#[derive(Clone, Debug)]
pub struct Mob {
/// When it joined its list ([`crate::join_seq`]); set by the list, not by the spawn site.
@@ -1403,6 +1437,9 @@ pub struct Mob {
/// way it was travelling. None for any other direct entity.
pub projectile_motion: Option<[f64; 2]>,
pub climbing: bool,
/// `LivingEntity.onClimbable` as of the last mob tick, for `isPushable`
/// checks made outside the mob world (minecarts, boats).
pub on_climbable: bool,
pub fire_ticks: i32,
pub ticks_frozen: i32,
pub glowing: bool,
@@ -2490,7 +2527,9 @@ impl Mob {
}
/// What other mobs see of this one this tick (`player`: the player's
/// UUID, which villagers' reputation is keyed by).
/// UUID, which villagers' reputation is keyed by). Inline: the game's
/// snapshot of every mob writes each one in its slot, not through a copy.
#[inline]
pub fn seen(&self, player: Option<[i32; 4]>) -> Seen {
let m = self;
let villager = m.species() == Species::Villager;
@@ -3062,6 +3101,7 @@ impl Mob {
attacker_aim: None,
projectile_motion: None,
climbing: false,
on_climbable: false,
fire_ticks: 0,
ticks_frozen: 0,
glowing: false,
+89 -3
View File
@@ -681,6 +681,7 @@ pub fn tick(
super::villager_brain::woken(m, w.game_time);
}
if m.no_ai {
reset_effect_fall_distance(m);
// Mob.aiStep still runs without AI (only serverAiStep is skipped),
// so undead still burn in daylight and the endermite's life keeps
// counting.
@@ -1541,6 +1542,14 @@ fn ageable_step(
}
}
/// `LivingEntity.aiStep`: both flight effects discard accumulated fall before
/// travel, even when NoAI skips the server AI step.
fn reset_effect_fall_distance(m: &mut Mob) {
if m.has_effect("minecraft:slow_falling") || m.has_effect("minecraft:levitation") {
m.fall_distance = 0.0;
}
}
/// LivingEntity.aiStep (and Mob/AgeableMob/Animal additions).
fn ai_step(m: &mut Mob, w: &MobWorld, level_random: &mut LegacyRandom, events: &mut Vec<MobEvent>) {
if m.species() == Species::Parrot {
@@ -1602,6 +1611,7 @@ fn ai_step(m: &mut Mob, w: &MobWorld, level_random: &mut LegacyRandom, events: &
f64::from(m.ai.yya),
f64::from(m.ai.zza),
);
reset_effect_fall_distance(m);
if super::equine::moved_by_client(m) {
// LivingEntity.travelRidden: the rider's client moves it
// (handleMoveVehicle), so the server only stops it.
@@ -1624,11 +1634,12 @@ fn ai_step(m: &mut Mob, w: &MobWorld, level_random: &mut LegacyRandom, events: &
}
impl Mob {
/// LivingEntity.isPushable (and the push it takes from `Entity.push(Entity)`):
/// bats and shulkers are never moved by others.
/// bats and shulkers are never moved by others, nor is a mob on a climbable
/// block (`onClimbable`, cached by the last tick).
pub fn pushable(&self) -> bool {
self.alive()
&& !matches!(self.species(), Species::Bat | Species::Shulker)
&& (!self.kind.flags.climber || !self.climbing)
&& !self.on_climbable
&& (self.species() != Species::Creaking || super::creaking::can_move(self))
&& (self.species() != Species::Warden || !super::warden::digging_or_emerging(self))
}
@@ -1653,6 +1664,7 @@ fn root_vehicle(w: &MobWorld, id: u32, vehicle: Option<u32>) -> u32 {
/// pushed apart from this one (`doPush(e)` = `e.push(this)`); pushes of other
/// mobs are `Push` events their tick picks up.
fn push_entities(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
m.on_climbable = on_climbable(m, w);
if m.species() == Species::Bat {
return;
}
@@ -1660,7 +1672,7 @@ fn push_entities(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
// The pusher's own isPushable, checked by `e.push(this)` before moving it.
let self_pushable = m.alive()
&& m.species() != Species::Shulker
&& !on_climbable(m, w)
&& !m.on_climbable
&& (m.species() != Species::Creaking || super::creaking::can_move(m))
&& (m.species() != Species::Warden || !super::warden::digging_or_emerging(m))
&& !super::equine::is_vehicle(m);
@@ -2127,6 +2139,8 @@ pub fn voice_sounds(m: &Mob) -> [&'static str; 3] {
Species::Nautilus | Species::ZombieNautilus => super::nautilus::sounds(m),
Species::SulfurCube => super::sulfur_cube::sounds(m),
Species::Armadillo => super::armadillo::sounds(m),
// HappyGhast.getHurtSound and getDeathSound: the ghastling's by age.
Species::HappyGhast => super::happy_ghast::sounds(m),
// Panda.getAmbientSound.
Species::Panda => {
let ambient = if super::panda::is_aggressive(m) {
@@ -4969,3 +4983,75 @@ mod air_tests {
assert_eq!(air, 300);
}
}
#[cfg(test)]
mod flight_effect_fall_tests {
use super::*;
use std::sync::Arc;
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn flight_effects_reset_accumulated_fall_distance_even_without_ai() {
let root = std::path::PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES").unwrap());
let ran = crate::mobs::world::with_resource_test_world(
&root,
&[],
true,
true,
|world, _| {
world
.chunks
.insert((0, 0), Arc::new(crate::chunk::Chunk::empty(0, 0)));
},
|world, types| {
let mut failures = Vec::new();
let mut rows = Vec::new();
for effect in [
None,
Some("minecraft:levitation"),
Some("minecraft:slow_falling"),
] {
for no_ai in [false, true] {
for velocity_y in [0.0, 0.2, -0.2] {
let kind = Arc::new(types.types["minecraft:cow"].clone());
let mut mob = Mob::new(1, kind, Vec3::new(2.5, 64.0, 2.5), 0.0);
mob.no_ai = no_ai;
mob.no_gravity = true;
mob.persistent = true;
mob.fall_distance = 12.0;
mob.velocity.y = velocity_y;
if let Some(effect) = effect {
mob.add_effect(effect, 100, 0);
}
let mut random = LegacyRandom::new(0);
assert!(tick(&mut mob, world, &mut random, &mut Vec::new()));
// The pinned Java aiStep resets before travel. A
// downward move then adds 0.2F of NEW fall distance;
// NoAI skips movement but must still erase the old fall.
let expected = match (effect.is_some(), no_ai, velocity_y < 0.0) {
(false, false, true) => 12.200000002980232,
(true, false, true) => 0.20000000298023224,
(false, _, _) => 12.0,
(true, _, _) => 0.0,
};
let name = effect.unwrap_or("none").trim_start_matches("minecraft:");
rows.push(format!(
"{name} {no_ai} {:016x} {:016x}",
velocity_y.to_bits(),
mob.fall_distance.to_bits()
));
if mob.fall_distance != expected {
failures.push((effect, no_ai, velocity_y, mob.fall_distance));
}
}
}
}
if let Some(path) = std::env::var_os("HORIZON_MOB_FALL_RESET_OUTPUT") {
std::fs::write(path, rows.join("\n") + "\n").unwrap();
}
assert!(failures.is_empty(), "fall distance differs: {failures:?}");
},
);
assert!(ran, "prepared resources must run the integration fixture");
}
}
+33 -4
View File
@@ -285,12 +285,12 @@ pub fn controlled(m: &Mob) -> bool {
_ => m.extra.saddled,
}
}
/// `AbstractHorse`s (camels too, not llamas, which no player steers): the
/// player controlling one moves it on its own client
/// Saddled equines (not llamas), pigs and striders: the controlling player
/// moves the mount on their own client
/// (`Entity.isLocalInstanceAuthoritative`, `LivingEntity.travelRidden`) and
/// sends the moves (`ServerboundMoveVehiclePacket`).
pub fn moved_by_rider(s: Species) -> bool {
is_equine(s) && !super::llama::is_llama(s)
matches!(s, Species::Pig | Species::Strider) || (is_equine(s) && !super::llama::is_llama(s))
}
/// A server's mount that its rider's client moves: `travelRidden` without
/// `canSimulateMovement` turns it (`tickRidden`) and stops it there.
@@ -892,10 +892,20 @@ pub fn client_tick(m: &mut Mob) {
m.anim.vo = m.anim.v;
animate(m);
}
/// How long a client counts a rear the server's flags say is on. A client
/// never runs `standCounter` (`standIfPossible` only sets it on the server);
/// `isStanding` is just the synched flag. The mount a client drives does run
/// `stand_tick`, so a counter of 1 would end the rear a tick after the flag
/// arrived, long before the jump lands. The next flags without the bit end it.
const CLIENT_STANDING: i32 = 1 << 20;
/// `AbstractHorse.DATA_ID_FLAGS` on a client: eating 16 and standing 32.
pub fn set_flags(m: &mut Mob, flags: u8) {
m.extra.flag = flags & 16 != 0;
m.extra.counter = i32::from(flags & 32 != 0);
if flags & 32 == 0 {
m.extra.counter = 0;
} else if m.extra.counter == 0 {
m.extra.counter = CLIENT_STANDING;
}
}
/// Whether `ai_step` above runs this species' grazing and rearing, so that
/// the server's flags 16 and 32 mean eating and standing.
@@ -1117,6 +1127,25 @@ mod tests {
assert!((60..=140).contains(&n), "{n} of 300 hits reared");
}
#[test]
fn a_synched_rear_lasts_until_the_flag_goes() {
// The mount a client drives ticks `stand_tick` itself; the synched
// standing flag, not that counter, decides when the rear is over.
let mut m = equine(1, Species::Donkey, "minecraft:donkey");
set_flags(&mut m, 32);
for _ in 0..25 {
stand_tick(&mut m);
animate(&mut m);
}
assert!(m.extra.counter > 0, "still standing");
assert_eq!(m.anim.v[STAND], 1.0);
set_flags(&mut m, 4);
assert_eq!(m.extra.counter, 0);
stand_tick(&mut m);
animate(&mut m);
assert!(m.anim.v[STAND] < 1.0, "falls once the flag is gone");
}
#[test]
fn the_rear_ends_after_twenty_ticks() {
// AbstractHorse.tick: standCounter runs down, then clearStanding.
+78 -2
View File
@@ -20,8 +20,43 @@ use horizon_core::{geometry::Vec3, random::RandomSource};
pub(super) fn golem_prey(s: &super::Seen) -> bool {
s.hostile && s.species != Species::Creeper
}
/// `IronGolem.registerGoals`, without `ResetUniversalAngerTargetGoal` (that
/// goal is not ported).
/// `ResetUniversalAngerTargetGoal` with `alertOthersOfSameType` false (the
/// iron golem's): with the `universal_anger` rule on, a golem whose latest
/// attacker is a player, hit after the one it last handled, forgets its
/// target and starts its anger timer over.
#[derive(Clone, Debug, Default)]
pub struct ResetUniversalAnger {
/// `lastHurtByPlayerTimestamp`.
timestamp: i32,
}
impl Goal for ResetUniversalAnger {
goal_clone!();
fn name(&self) -> &'static str {
"ResetUniversalAngerTargetGoal"
}
fn flags(&self) -> u8 {
0
}
/// The rule and `wasHurtByPlayer`.
fn can_use(&mut self, m: &mut Mob, cx: &mut Cx) -> bool {
cx.w.universal_anger
&& m.ai.last_hurt_by == Some(Who::Player)
&& m.ai.last_hurt_by_time > self.timestamp
}
/// `NeutralMob.forgetCurrentTargetAndRefreshUniversalAnger`:
/// `stopBeingAngry`, then `startPersistentAngerTimer`.
fn start(&mut self, m: &mut Mob, _: &mut Cx) {
self.timestamp = m.ai.last_hurt_by_time;
// setLastHurtByMob(null) also stamps the time.
m.ai.last_hurt_by = None;
m.ai.last_hurt_by_time = m.age as i32;
m.ai.target = None;
m.extra.angry_at = None;
// `TimeUtil.rangeOfSeconds(20, 39)`.
m.extra.anger = 400 + m.random.next_int_bound(381).unwrap_or(0);
}
}
/// `IronGolem.registerGoals`.
pub fn register_iron(m: &mut Mob) {
let g = &mut m.ai.goals;
g.add(1, MeleeAttack::new(1.0, true));
@@ -67,6 +102,7 @@ pub fn register_iron(m: &mut Mob) {
3,
NearestTarget::new(Prey::Filter(golem_prey), false).interval(5),
);
m.ai.targets.add(4, ResetUniversalAnger::default());
}
/// `SectionPos.of`: the section a position is in.
fn section_of(p: Vec3) -> [i32; 3] {
@@ -634,6 +670,46 @@ mod tests {
});
}
/// `ResetUniversalAngerTargetGoal`: with the rule on, a golem hurt by a
/// player forgets its target once and not again until hurt again; with
/// the rule off it does nothing.
#[test]
fn a_golem_hurt_by_a_player_forgets_its_target_once_under_universal_anger() {
for rule in [true, false] {
with_test_world(&[], |w, _| {
let mut w = w.clone();
w.universal_anger = rule;
let mut golem = mob(1, Species::IronGolem, Vec3::new(0.5, 64.0, 0.5));
golem.age = 50;
golem.ai.target = Some(Who::Mob(2));
golem.extra.anger = 5;
golem.ai.last_hurt_by = Some(Who::Player);
golem.ai.last_hurt_by_time = 10;
let mut goal = ResetUniversalAnger::default();
let mut events = Vec::new();
let mut lr = horizon_core::random::LegacyRandom::new(0);
let mut cx = Cx {
w: &w,
lr: &mut lr,
events: &mut events,
};
assert_eq!(goal.can_use(&mut golem, &mut cx), rule);
if !rule {
return;
}
goal.start(&mut golem, &mut cx);
assert_eq!(golem.ai.target, None);
assert_eq!(golem.ai.last_hurt_by, None);
assert!((400..=780).contains(&golem.extra.anger));
assert!(!goal.can_use(&mut golem, &mut cx), "not again");
// Hurt again: a newer hit by the player.
golem.ai.last_hurt_by = Some(Who::Player);
golem.ai.last_hurt_by_time = 60;
assert!(goal.can_use(&mut golem, &mut cx));
});
}
}
/// `IronGolem.canAttack` refuses creepers, and `HurtByTargetGoal.canUse`
/// asks it: the creeper that hurt the golem is never fought back, while
/// a zombie that hurts it is.
+14
View File
@@ -327,6 +327,20 @@ pub fn tick(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
});
return;
}
// Leashable.tickLeash: a holder that cannot interact with the level (dead
// or spectating, `Entity.canInteractWithLevel`) drops the lead.
if holder == Holder::Player
&& let Some(p) = w.player.as_ref()
&& m.extra.leash_player.is_none_or(|u| p.uuid == u)
&& (p.spectator || !p.alive)
{
events.push(MobEvent::LeashBreak {
mob: m.id,
drop: true,
sound: false,
});
return;
}
let state = holder_state(w, holder).filter(|_| {
holder != Holder::Player
|| m.extra
-5
View File
@@ -1382,11 +1382,6 @@ impl Mob {
{
y += (0.05 * f64::from(amp + 1) - movement.y) * 0.2;
} else {
// LivingEntity.travelInAir: a Slow Falling mob on its way down lands without
// having fallen.
if self.velocity.y <= 0.0 && self.has_effect("minecraft:slow_falling") {
self.fall_distance = 0.0;
}
y -= self.effective_gravity();
}
// LivingEntity.shouldDiscardFriction (a breeze's, a frog's or a goat's
@@ -0,0 +1,954 @@
//! Entity model layers built in code, as vanilla's
//! `net.minecraft.client.model.geom.builders` build them: a mod's
//! `createBodyLayer()` (a [`MeshDefinition`] of named parts, each a
//! [`CubeListBuilder`]'s boxes at a [`PartPose`], made a [`LayerDefinition`]
//! with its texture's size) bakes into a [`ModelPart`] tree
//! (`LayerDefinition.bakeRoot`). A mod turns its parts each frame, as
//! `setupAnim` does, and draws it with `native_draw_model_part`
//! (`ModelPart.render`). Mods register their layers by id
//! (`native_layer_definitions`, NeoForge's
//! `EntityRenderersEvent.RegisterLayerDefinitions`), and `native_bake_layer`
//! bakes one of them or a vanilla layer (`EntityModelSet.bakeLayer`).
use super::Part;
use horizon_core::geometry::Direction;
use std::{collections::HashMap, sync::OnceLock};
/// `CubeDeformation`: how far a box grows past its size on each side, in
/// texels, with its texture unchanged: a hat or an armour layer over a head.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct CubeDeformation {
pub grow_x: f32,
pub grow_y: f32,
pub grow_z: f32,
}
impl CubeDeformation {
pub const NONE: Self = Self::xyz(0.0, 0.0, 0.0);
/// The same growth on every axis (`new CubeDeformation(grow)`).
pub const fn new(grow: f32) -> Self {
Self::xyz(grow, grow, grow)
}
pub const fn xyz(grow_x: f32, grow_y: f32, grow_z: f32) -> Self {
Self {
grow_x,
grow_y,
grow_z,
}
}
/// This growth and `by` more on every axis (`extend`).
pub fn extend(self, by: f32) -> Self {
self.extend_xyz(by, by, by)
}
pub fn extend_xyz(self, x: f32, y: f32, z: f32) -> Self {
Self::xyz(self.grow_x + x, self.grow_y + y, self.grow_z + z)
}
}
/// `PartPose`: where a part sits in its parent, in texels from the parent's
/// pivot (+y down, as a model's frame is), its rotation in radians (about z,
/// then y, then x, as `ModelPart.translateAndRotate` turns it) and its scale.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct PartPose {
pub x: f32,
pub y: f32,
pub z: f32,
pub x_rot: f32,
pub y_rot: f32,
pub z_rot: f32,
pub x_scale: f32,
pub y_scale: f32,
pub z_scale: f32,
}
impl Default for PartPose {
fn default() -> Self {
Self::ZERO
}
}
impl PartPose {
pub const ZERO: Self = Self::offset_and_rotation(0.0, 0.0, 0.0, 0.0, 0.0, 0.0);
pub const fn offset(x: f32, y: f32, z: f32) -> Self {
Self::offset_and_rotation(x, y, z, 0.0, 0.0, 0.0)
}
pub const fn rotation(x_rot: f32, y_rot: f32, z_rot: f32) -> Self {
Self::offset_and_rotation(0.0, 0.0, 0.0, x_rot, y_rot, z_rot)
}
pub const fn offset_and_rotation(
x: f32,
y: f32,
z: f32,
x_rot: f32,
y_rot: f32,
z_rot: f32,
) -> Self {
Self {
x,
y,
z,
x_rot,
y_rot,
z_rot,
x_scale: 1.0,
y_scale: 1.0,
z_scale: 1.0,
}
}
/// This pose moved by `x`, `y` and `z` texels (`translated`).
pub fn translated(self, x: f32, y: f32, z: f32) -> Self {
Self {
x: self.x + x,
y: self.y + y,
z: self.z + z,
..self
}
}
/// This pose at `scale` on every axis (`withScale`).
pub fn with_scale(self, scale: f32) -> Self {
Self {
x_scale: scale,
y_scale: scale,
z_scale: scale,
..self
}
}
}
/// One box of a part (`CubeDefinition`), as a [`CubeListBuilder`] adds it.
#[derive(Clone, Debug, PartialEq)]
pub struct CubeDefinition {
/// Its corner at the least x, y and z, in texels from the part's pivot.
pub origin: [f32; 3],
/// Its size in texels, which is also the size of its faces on the texture.
pub dimensions: [f32; 3],
pub grow: CubeDeformation,
/// Drawn with its texture flipped left to right, as a left limb draws a
/// right one's.
pub mirror: bool,
/// The top left corner of its faces on the texture, in texels (`texOffs`).
pub tex_coord: [i32; 2],
/// How many of the texture's texels one of its own texels takes, 1 for most.
pub tex_scale: [f32; 2],
/// The faces it has, in [`Direction::ALL`]'s order; all six for most.
pub visible_faces: [bool; 6],
}
/// `CubeListBuilder`: the boxes of one part, each placed on the texture from
/// the last `tex_offs` and mirrored after `mirror`.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct CubeListBuilder {
cubes: Vec<CubeDefinition>,
tex: [i32; 2],
mirror: bool,
}
impl CubeListBuilder {
pub fn create() -> Self {
Self::default()
}
/// Where the next boxes' faces start on the texture, in texels (`texOffs`).
pub fn tex_offs(mut self, u: i32, v: i32) -> Self {
self.tex = [u, v];
self
}
/// The next boxes are mirrored (`mirror()`).
pub fn mirror(self) -> Self {
self.set_mirror(true)
}
/// Whether the next boxes are mirrored (`mirror(boolean)`).
pub fn set_mirror(mut self, mirror: bool) -> Self {
self.mirror = mirror;
self
}
/// A box at `origin` of `size` texels (`addBox(x, y, z, w, h, d)`).
pub fn add_box(self, origin: [f32; 3], size: [f32; 3]) -> Self {
self.add_box_with(origin, size, CubeDeformation::NONE)
}
/// A box grown by `grow` (`addBox(x, y, z, w, h, d, deformation)`).
pub fn add_box_with(self, origin: [f32; 3], size: [f32; 3], grow: CubeDeformation) -> Self {
self.add_box_tex_scaled(origin, size, grow, [1.0, 1.0])
}
/// A box mirrored or not by `mirror`, whatever `mirror()` said
/// (`addBox(x, y, z, w, h, d, mirror)`).
pub fn add_box_mirrored(mut self, origin: [f32; 3], size: [f32; 3], mirror: bool) -> Self {
let tex = self.tex;
self.cubes
.push(cube(origin, size, CubeDeformation::NONE, mirror, tex));
self
}
/// A box with only `faces` (`addBox(x, y, z, w, h, d, visibleFaces)`).
pub fn add_box_faces(mut self, origin: [f32; 3], size: [f32; 3], faces: &[Direction]) -> Self {
let mut cube = cube(origin, size, CubeDeformation::NONE, self.mirror, self.tex);
cube.visible_faces = Direction::ALL.map(|d| faces.contains(&d));
self.cubes.push(cube);
self
}
/// A box whose texels take `tex_scale` of the texture's
/// (`addBox(x, y, z, w, h, d, deformation, texScaleU, texScaleV)`).
pub fn add_box_tex_scaled(
mut self,
origin: [f32; 3],
size: [f32; 3],
grow: CubeDeformation,
tex_scale: [f32; 2],
) -> Self {
let mut cube = cube(origin, size, grow, self.mirror, self.tex);
cube.tex_scale = tex_scale;
self.cubes.push(cube);
self
}
/// A box with its own place on the texture, leaving `tex_offs` as it is
/// (`addBox(comment, x, y, z, w, h, d, deformation, texU, texV)`).
pub fn add_box_at(
mut self,
origin: [f32; 3],
size: [f32; 3],
grow: CubeDeformation,
tex: [i32; 2],
) -> Self {
self.cubes.push(cube(origin, size, grow, self.mirror, tex));
self
}
/// The boxes added so far (`getCubes`).
pub fn cubes(&self) -> &[CubeDefinition] {
&self.cubes
}
}
fn cube(
origin: [f32; 3],
dimensions: [f32; 3],
grow: CubeDeformation,
mirror: bool,
tex_coord: [i32; 2],
) -> CubeDefinition {
CubeDefinition {
origin,
dimensions,
grow,
mirror,
tex_coord,
tex_scale: [1.0, 1.0],
visible_faces: [true; 6],
}
}
impl CubeDefinition {
/// `ModelPart.Cube`: the box's faces on a `texture` of that many texels,
/// in vanilla's order (down, up, west, north, east, south), each face's
/// corners and texture net as `Cube`'s constructor makes them.
fn bake_into(&self, texture: [f32; 2], out: &mut Vec<ModelPolygon>) {
let [w, h, d] = self.dimensions;
let [mut x0, mut y0, mut z0] = self.origin;
let (mut x1, mut y1, mut z1) = (x0 + w, y0 + h, z0 + d);
let g = self.grow;
x0 -= g.grow_x;
y0 -= g.grow_y;
z0 -= g.grow_z;
x1 += g.grow_x;
y1 += g.grow_y;
z1 += g.grow_z;
if self.mirror {
std::mem::swap(&mut x0, &mut x1);
}
// The constructor's eight vertices, v7 v v1 v2 v3 v4 v5 v6.
let corner = [
[x0, y0, z0],
[x1, y0, z0],
[x1, y1, z0],
[x0, y1, z0],
[x0, y0, z1],
[x1, y0, z1],
[x1, y1, z1],
[x0, y1, z1],
];
let tex_w = texture[0] * self.tex_scale[0];
let tex_h = texture[1] * self.tex_scale[1];
let [u, v] = self.tex_coord.map(|t| t as f32);
let (u1, u2, u3) = (u + d, u + d + w, u + d + w + w);
let (u4, u5) = (u + d + w + d, u + d + w + d + w);
let (v1, v2) = (v + d, v + d + h);
let faces = [
(Direction::Down, [5, 4, 0, 1], [u1, v, u2, v1]),
(Direction::Up, [2, 3, 7, 6], [u2, v1, u3, v]),
(Direction::West, [0, 4, 7, 3], [u, v1, u1, v2]),
(Direction::North, [1, 0, 3, 2], [u1, v1, u2, v2]),
(Direction::East, [5, 1, 2, 6], [u2, v1, u4, v2]),
(Direction::South, [4, 5, 6, 7], [u4, v1, u5, v2]),
];
for (direction, corners, [ua, va, ub, vb]) in faces {
let shown = Direction::ALL
.iter()
.position(|d| *d == direction)
.is_some_and(|i| self.visible_faces[i]);
if !shown {
continue;
}
// `Polygon`'s remap: right top, left top, left bottom, right bottom.
let uv = [(ub, va), (ua, va), (ua, vb), (ub, vb)];
let mut vertices = std::array::from_fn(|k| ModelVertex {
pos: corner[corners[k]],
u: uv[k].0 / tex_w,
v: uv[k].1 / tex_h,
});
let [nx, ny, nz] = direction.normal();
let mut normal = [nx as f32, ny as f32, nz as f32];
if self.mirror {
vertices.reverse();
normal[0] = -normal[0];
}
out.push(ModelPolygon { vertices, normal });
}
}
}
/// A part of a [`MeshDefinition`], as `add_or_replace_child` gives it
/// (`PartDefinition`): a handle into that mesh only.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct PartId(usize);
#[derive(Clone, Debug, PartialEq)]
struct PartNode {
cubes: Vec<CubeDefinition>,
pose: PartPose,
children: Vec<(String, usize)>,
}
/// `MeshDefinition`: a model's parts, from a root part with no boxes.
#[derive(Clone, Debug, PartialEq)]
pub struct MeshDefinition {
parts: Vec<PartNode>,
}
impl Default for MeshDefinition {
fn default() -> Self {
Self::new()
}
}
impl MeshDefinition {
pub fn new() -> Self {
Self {
parts: vec![PartNode {
cubes: Vec::new(),
pose: PartPose::ZERO,
children: Vec::new(),
}],
}
}
/// The root part (`getRoot`).
pub fn root(&self) -> PartId {
PartId(0)
}
/// Adds part `name` to `parent`, with `cubes` at `pose`
/// (`PartDefinition.addOrReplaceChild`). A part of that name already
/// there is replaced and its children kept.
pub fn add_or_replace_child(
&mut self,
parent: PartId,
name: &str,
cubes: CubeListBuilder,
pose: PartPose,
) -> PartId {
let node = PartNode {
cubes: cubes.cubes,
pose,
children: Vec::new(),
};
if let Some(at) = self.child(parent, name) {
let old = std::mem::replace(&mut self.parts[at.0], node);
self.parts[at.0].children = old.children;
return at;
}
self.parts.push(node);
let at = self.parts.len() - 1;
self.parts[parent.0].children.push((name.to_owned(), at));
PartId(at)
}
/// `parent`'s part `name` (`getChild`).
pub fn child(&self, parent: PartId, name: &str) -> Option<PartId> {
self.parts[parent.0]
.children
.iter()
.find(|(n, _)| n == name)
.map(|&(_, at)| PartId(at))
}
fn bake(&self, at: usize, texture: [f32; 2]) -> ModelPart {
let node = &self.parts[at];
let mut polygons = Vec::new();
for cube in &node.cubes {
cube.bake_into(texture, &mut polygons);
}
let children = node
.children
.iter()
.map(|(name, child)| (name.clone(), self.bake(*child, texture)))
.collect();
ModelPart::new(polygons, children, node.pose)
}
}
/// `LayerDefinition`: a mesh and the size of the texture its boxes are on.
#[derive(Clone, Debug, PartialEq)]
pub struct LayerDefinition {
pub mesh: MeshDefinition,
pub texture_width: u32,
pub texture_height: u32,
}
impl LayerDefinition {
/// `LayerDefinition.create(mesh, 64, 64)`.
pub fn create(mesh: MeshDefinition, texture_width: u32, texture_height: u32) -> Self {
Self {
mesh,
texture_width,
texture_height,
}
}
/// The part tree, each part at its pose (`bakeRoot`).
pub fn bake_root(&self) -> ModelPart {
let texture = [self.texture_width as f32, self.texture_height as f32];
self.mesh.bake(0, texture)
}
}
/// A corner of a baked face (`ModelPart.Vertex`): in texels in its part's
/// frame, and where it falls on the texture, 0 to 1 across it.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct ModelVertex {
pub pos: [f32; 3],
pub u: f32,
pub v: f32,
}
/// A baked face (`ModelPart.Polygon`), with the normal it faces in its
/// part's frame.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct ModelPolygon {
pub vertices: [ModelVertex; 4],
pub normal: [f32; 3],
}
/// `ModelPart`: a baked part, its children by name, and the pose a mod sets
/// each frame before drawing it (`setupAnim`), from the pose it was baked
/// with (`reset_pose`).
#[derive(Clone, Debug, PartialEq)]
pub struct ModelPart {
/// Where it sits in its parent, in texels.
pub x: f32,
pub y: f32,
pub z: f32,
/// Its rotation in radians, applied about z, then y, then x.
pub x_rot: f32,
pub y_rot: f32,
pub z_rot: f32,
pub x_scale: f32,
pub y_scale: f32,
pub z_scale: f32,
/// False draws neither it nor its children.
pub visible: bool,
/// True draws its children but not its own faces (`skipDraw`).
pub skip_draw: bool,
/// Its boxes' faces.
pub polygons: Vec<ModelPolygon>,
children: Vec<(String, ModelPart)>,
initial_pose: PartPose,
}
impl ModelPart {
/// A part with `polygons` and `children` at `pose`, which it is reset to.
pub fn new(
polygons: Vec<ModelPolygon>,
children: Vec<(String, ModelPart)>,
pose: PartPose,
) -> Self {
let mut part = Self {
x: 0.0,
y: 0.0,
z: 0.0,
x_rot: 0.0,
y_rot: 0.0,
z_rot: 0.0,
x_scale: 1.0,
y_scale: 1.0,
z_scale: 1.0,
visible: true,
skip_draw: false,
polygons,
children,
initial_pose: pose,
};
part.load_pose(pose);
part
}
/// A vanilla layer's part tree from `entity-models.json`.
pub fn from_part(part: &Part) -> Self {
let p = part.pose;
let pose = PartPose {
x: p[0],
y: p[1],
z: p[2],
x_rot: p[3],
y_rot: p[4],
z_rot: p[5],
x_scale: p[6],
y_scale: p[7],
z_scale: p[8],
};
let polygons = part
.polygons
.iter()
.map(|poly| ModelPolygon {
vertices: poly.vertices.map(|v| ModelVertex {
pos: [v[0], v[1], v[2]],
u: v[3],
v: v[4],
}),
normal: poly.normal,
})
.collect();
let children = part
.children
.iter()
.map(|c| (c.name.clone(), Self::from_part(c)))
.collect();
Self::new(polygons, children, pose)
}
/// Its part `name` (`getChild`).
pub fn get_child(&self, name: &str) -> Option<&ModelPart> {
self.children
.iter()
.find(|(n, _)| n == name)
.map(|(_, c)| c)
}
pub fn get_child_mut(&mut self, name: &str) -> Option<&mut ModelPart> {
self.children
.iter_mut()
.find(|(n, _)| n == name)
.map(|(_, c)| c)
}
/// Its parts by name, in the order they draw.
pub fn children(&self) -> impl Iterator<Item = (&str, &ModelPart)> {
self.children.iter().map(|(n, c)| (n.as_str(), c))
}
pub fn children_mut(&mut self) -> impl Iterator<Item = (&str, &mut ModelPart)> {
self.children.iter_mut().map(|(n, c)| (n.as_str(), c))
}
/// Whether it has no faces of its own (`isEmpty`).
pub fn is_empty(&self) -> bool {
self.polygons.is_empty()
}
/// Its pose now (`storePose`).
pub fn store_pose(&self) -> PartPose {
PartPose {
x: self.x,
y: self.y,
z: self.z,
x_rot: self.x_rot,
y_rot: self.y_rot,
z_rot: self.z_rot,
x_scale: self.x_scale,
y_scale: self.y_scale,
z_scale: self.z_scale,
}
}
/// Puts it at `pose` (`loadPose`).
pub fn load_pose(&mut self, pose: PartPose) {
self.x = pose.x;
self.y = pose.y;
self.z = pose.z;
self.x_rot = pose.x_rot;
self.y_rot = pose.y_rot;
self.z_rot = pose.z_rot;
self.x_scale = pose.x_scale;
self.y_scale = pose.y_scale;
self.z_scale = pose.z_scale;
}
/// The pose it was baked with, unless set since (`getInitialPose`).
pub fn initial_pose(&self) -> PartPose {
self.initial_pose
}
pub fn set_initial_pose(&mut self, pose: PartPose) {
self.initial_pose = pose;
}
/// Puts it back at its initial pose (`resetPose`), its children as they are.
pub fn reset_pose(&mut self) {
self.load_pose(self.initial_pose);
}
/// Puts it and every part under it back at their initial poses, as
/// `Model.resetPose` does before each frame's `setupAnim`.
pub fn reset_all_poses(&mut self) {
self.reset_pose();
for (_, child) in &mut self.children {
child.reset_all_poses();
}
}
/// Takes `other`'s position, rotation and scale (`copyFrom`).
pub fn copy_from(&mut self, other: &ModelPart) {
self.load_pose(other.store_pose());
}
pub fn set_pos(&mut self, x: f32, y: f32, z: f32) {
(self.x, self.y, self.z) = (x, y, z);
}
pub fn set_rotation(&mut self, x_rot: f32, y_rot: f32, z_rot: f32) {
(self.x_rot, self.y_rot, self.z_rot) = (x_rot, y_rot, z_rot);
}
/// Moves it by `by` texels (`offsetPos`), as keyframe animations do.
pub fn offset_pos(&mut self, by: [f32; 3]) {
self.x += by[0];
self.y += by[1];
self.z += by[2];
}
/// Turns it by `by` radians (`offsetRotation`).
pub fn offset_rotation(&mut self, by: [f32; 3]) {
self.x_rot += by[0];
self.y_rot += by[1];
self.z_rot += by[2];
}
/// Scales it by `by` more (`offsetScale`: added, as vanilla adds it).
pub fn offset_scale(&mut self, by: [f32; 3]) {
self.x_scale += by[0];
self.y_scale += by[1];
self.z_scale += by[2];
}
}
static REGISTERED: OnceLock<HashMap<String, LayerDefinition>> = OnceLock::new();
/// Keeps the layers native mods registered (`native_layer_definitions`),
/// once at start. An id is `<namespace>:<path>#<layer>` (NeoForge's
/// `ModelLayerLocation`) in a namespace other than vanilla's; one that isn't,
/// or that another layer has already, is logged and left out.
pub fn register(layers: Vec<(String, LayerDefinition)>) {
let mut kept = HashMap::new();
for (id, layer) in layers {
if !is_mod_layer_id(&id) {
log::warn!("model layer {id:?} is not <namespace>:<path>#<layer> in a mod's namespace");
continue;
}
if kept.contains_key(&id) {
log::warn!("model layer {id} is registered twice; the first is kept");
continue;
}
kept.insert(id, layer);
}
if REGISTERED.set(kept).is_err() {
log::warn!("model layers were registered already; these are left out");
}
}
/// The layer a native mod registered as `id`.
pub fn registered(id: &str) -> Option<&'static LayerDefinition> {
REGISTERED.get()?.get(id)
}
fn is_mod_layer_id(id: &str) -> bool {
let Some((location, layer)) = id.split_once('#') else {
return false;
};
let Some((namespace, path)) = location.split_once(':') else {
return false;
};
let ok = |s: &str, extra: &[char]| {
!s.is_empty()
&& s.chars().all(|c| {
c.is_ascii_lowercase()
|| c.is_ascii_digit()
|| "_.-".contains(c)
|| extra.contains(&c)
})
};
namespace != "minecraft"
&& ok(namespace, &[])
&& ok(path, &['/'])
&& !layer.is_empty()
&& !layer.contains(['#', ':'])
}
#[cfg(test)]
mod tests {
use super::*;
fn polygon(part: &ModelPart, i: usize) -> ([[f32; 3]; 4], [[f32; 2]; 4], [f32; 3]) {
let p = &part.polygons[i];
(
p.vertices.map(|v| v.pos),
p.vertices.map(|v| [v.u, v.v]),
p.normal,
)
}
/// One 8-texel head box on a 64 by 64 texture (`HumanoidModel`'s head),
/// worked out by hand from `ModelPart.Cube`'s constructor: its top face
/// is the net's first strip, 8 to 16 across and 0 to 8 down, at the
/// box's least y (a model's +y is down).
#[test]
fn a_box_bakes_its_faces_on_the_texture_as_vanilla_cubes_do() {
let mut mesh = MeshDefinition::new();
let root = mesh.root();
let cubes = CubeListBuilder::create()
.tex_offs(0, 0)
.add_box([-4.0, -8.0, -4.0], [8.0, 8.0, 8.0]);
mesh.add_or_replace_child(root, "head", cubes, PartPose::offset(0.0, 2.0, 0.0));
let baked = LayerDefinition::create(mesh, 64, 64).bake_root();
let head = baked.get_child("head").unwrap();
assert_eq!(head.polygons.len(), 6);
assert_eq!((head.x, head.y, head.z), (0.0, 2.0, 0.0));
let (pos, uv, normal) = polygon(head, 0);
assert_eq!(normal, [0.0, -1.0, 0.0]);
assert_eq!(
pos,
[
[4.0, -8.0, 4.0],
[-4.0, -8.0, 4.0],
[-4.0, -8.0, -4.0],
[4.0, -8.0, -4.0]
]
);
assert_eq!(
uv,
[[0.25, 0.0], [0.125, 0.0], [0.125, 0.125], [0.25, 0.125]]
);
// The face, north (-z): 8 to 16 across, 8 to 16 down.
let (pos, uv, normal) = polygon(head, 3);
assert_eq!(normal, [0.0, 0.0, -1.0]);
assert_eq!(
pos,
[
[4.0, -8.0, -4.0],
[-4.0, -8.0, -4.0],
[-4.0, 0.0, -4.0],
[4.0, 0.0, -4.0]
]
);
assert_eq!(
uv,
[[0.25, 0.125], [0.125, 0.125], [0.125, 0.25], [0.25, 0.25]]
);
// The back, south: the net's last strip, 24 to 32 across.
let (_, uv, normal) = polygon(head, 5);
assert_eq!(normal, [0.0, 0.0, 1.0]);
assert_eq!(
uv,
[[0.5, 0.125], [0.375, 0.125], [0.375, 0.25], [0.5, 0.25]]
);
}
#[test]
fn mirroring_swaps_the_sides_and_growing_keeps_the_texture() {
let plain = CubeListBuilder::create()
.tex_offs(40, 16)
.add_box([-1.0, -2.0, -2.0], [4.0, 12.0, 4.0]);
let mirrored = CubeListBuilder::create()
.tex_offs(40, 16)
.mirror()
.add_box([-1.0, -2.0, -2.0], [4.0, 12.0, 4.0]);
let grown = CubeListBuilder::create().tex_offs(40, 16).add_box_with(
[-1.0, -2.0, -2.0],
[4.0, 12.0, 4.0],
CubeDeformation::new(0.25),
);
let bake = |cubes| {
let mut mesh = MeshDefinition::new();
let root = mesh.root();
mesh.add_or_replace_child(root, "arm", cubes, PartPose::ZERO);
LayerDefinition::create(mesh, 64, 64)
.bake_root()
.get_child("arm")
.unwrap()
.clone()
};
let (plain, mirrored, grown) = (bake(plain), bake(mirrored), bake(grown));
// West (index 2): mirrored, its corners sit at the other x and
// wind the other way, and it faces east; its net stays the same.
let (pos, uv, normal) = polygon(&plain, 2);
let (mpos, muv, mnormal) = polygon(&mirrored, 2);
assert_eq!(normal, [-1.0, 0.0, 0.0]);
assert_eq!(mnormal, [1.0, 0.0, 0.0]);
assert_eq!(pos[0], [-1.0, -2.0, -2.0]);
assert_eq!(mpos[3], [3.0, -2.0, -2.0]);
assert_eq!(muv, [uv[3], uv[2], uv[1], uv[0]]);
// Grown a quarter texel on each side, with the same texture.
let (gpos, guv, _) = polygon(&grown, 2);
assert_eq!(gpos[0], [-1.25, -2.25, -2.25]);
assert_eq!(guv, uv);
}
#[test]
fn hidden_faces_and_texture_scale_change_what_bakes() {
let cubes = CubeListBuilder::create()
.tex_offs(0, 0)
.add_box_faces([0.0; 3], [2.0, 2.0, 2.0], &[Direction::North])
.add_box_tex_scaled([0.0; 3], [2.0, 2.0, 2.0], CubeDeformation::NONE, [2.0, 2.0]);
let mut mesh = MeshDefinition::new();
let root = mesh.root();
mesh.add_or_replace_child(root, "p", cubes, PartPose::ZERO);
let part = LayerDefinition::create(mesh, 16, 16).bake_root();
let part = part.get_child("p").unwrap();
// One face of the first box, six of the second.
assert_eq!(part.polygons.len(), 7);
assert_eq!(part.polygons[0].normal, [0.0, 0.0, -1.0]);
// North of the second: 2 to 4 across, on a texture taken as 32 wide.
let (_, uv, _) = polygon(part, 4);
assert_eq!(uv[0], [4.0 / 32.0, 2.0 / 32.0]);
}
#[test]
fn a_replaced_part_keeps_its_children_and_poses_reset() {
let mut mesh = MeshDefinition::new();
let root = mesh.root();
let body =
mesh.add_or_replace_child(root, "body", CubeListBuilder::create(), PartPose::ZERO);
mesh.add_or_replace_child(
body,
"arm",
CubeListBuilder::create(),
PartPose::offset(5.0, 2.0, 0.0),
);
let again = mesh.add_or_replace_child(
root,
"body",
CubeListBuilder::create().add_box([0.0; 3], [1.0; 3]),
PartPose::rotation(0.5, 0.0, 0.0),
);
assert_eq!(again, body);
assert!(mesh.child(body, "arm").is_some());
let mut model = LayerDefinition::create(mesh, 64, 32).bake_root();
let body = model.get_child_mut("body").unwrap();
assert_eq!(body.polygons.len(), 6);
assert_eq!(body.x_rot, 0.5);
body.x_rot = 1.5;
let arm = body.get_child_mut("arm").unwrap();
arm.offset_pos([1.0, 0.0, 0.0]);
assert_eq!(arm.x, 6.0);
model.reset_all_poses();
let body = model.get_child("body").unwrap();
assert_eq!(body.x_rot, 0.5);
assert_eq!(body.get_child("arm").unwrap().x, 5.0);
}
#[test]
fn layer_ids_are_a_mods_own_model_and_layer() {
assert!(is_mod_layer_id("mymod:golem#main"));
assert!(is_mod_layer_id("my_mod:mobs/golem.v2#outer_armor"));
for bad in [
"minecraft:zombie#main",
"mymod:golem",
"golem#main",
"mymod:#main",
"mymod:golem#",
"MyMod:golem#main",
"mymod:golem#a#b",
] {
assert!(!is_mod_layer_id(bad), "{bad}");
}
}
/// `HumanoidModel.createMesh(CubeDeformation.NONE, 0)` built with these
/// builders bakes what vanilla baked for the zombie
/// (`minecraft:zombie#main` in the prepared `entity-models.json`):
/// `cargo test -p horizon-client-lib --lib model_layers -- --ignored`
/// with HORIZON_RESOURCES set to the prepared resources.
#[test]
#[ignore = "needs the prepared resources"]
fn the_humanoid_mesh_bakes_as_vanillas_zombie() {
let Some(root) = std::env::var_os("HORIZON_RESOURCES").map(std::path::PathBuf::from) else {
return;
};
let vanilla =
super::super::load_model(&root, "minecraft:zombie#main").expect("zombie#main");
let vanilla = ModelPart::from_part(&vanilla);
// `HumanoidModel.createMesh`: (part, parent, texOffs, mirror, origin, size, grow, pose).
let half = CubeDeformation::new(0.5);
let none = CubeDeformation::NONE;
let (z4, limb) = ([-4.0, -8.0, -4.0], [4.0, 12.0, 4.0]);
#[rustfmt::skip]
let parts = [
("head", "", (0, 0), false, z4, [8.0; 3], none, [0.0; 3]),
("hat", "head", (32, 0), false, z4, [8.0; 3], half, [0.0; 3]),
("body", "", (16, 16), false, [-4.0, 0.0, -2.0], [8.0, 12.0, 4.0], none, [0.0; 3]),
("right_arm", "", (40, 16), false, [-3.0, -2.0, -2.0], limb, none, [-5.0, 2.0, 0.0]),
("left_arm", "", (40, 16), true, [-1.0, -2.0, -2.0], limb, none, [5.0, 2.0, 0.0]),
("right_leg", "", (0, 16), false, [-2.0, 0.0, -2.0], limb, none, [-1.9, 12.0, 0.0]),
("left_leg", "", (0, 16), true, [-2.0, 0.0, -2.0], limb, none, [1.9, 12.0, 0.0]),
];
let mut mesh = MeshDefinition::new();
for (name, parent, (u, v), mirror, origin, size, grow, [x, y, z]) in parts {
let parent = mesh.child(mesh.root(), parent).unwrap_or(mesh.root());
let cubes = CubeListBuilder::create()
.tex_offs(u, v)
.set_mirror(mirror)
.add_box_with(origin, size, grow);
mesh.add_or_replace_child(parent, name, cubes, PartPose::offset(x, y, z));
}
let ours = LayerDefinition::create(mesh, 64, 64).bake_root();
fn same(path: &str, ours: &ModelPart, vanilla: &ModelPart) {
assert_eq!(ours.store_pose(), vanilla.store_pose(), "{path} pose");
assert_eq!(ours.polygons, vanilla.polygons, "{path} faces");
let mut names: Vec<_> = ours.children().map(|(n, _)| n).collect();
let mut theirs: Vec<_> = vanilla.children().map(|(n, _)| n).collect();
names.sort_unstable();
theirs.sort_unstable();
assert_eq!(names, theirs, "{path} children");
for (name, child) in ours.children() {
same(
&format!("{path}/{name}"),
child,
vanilla.get_child(name).unwrap(),
);
}
}
same("root", &ours, &vanilla);
}
}
+31
View File
@@ -60,6 +60,7 @@ pub fn happy_ghast_attachment(index: usize) -> [f64; 3] {
let [x, y, z] = SEATS[index.min(SEATS.len() - 1)];
pt(x, y, z)
}
/// `Pig.getDefaultDimensions` and other species' age-specific passenger attachments.
fn base_attachment(v: &Mob) -> [f64; 3] {
let baby = v.is_baby();
// Players and mannequins have no `passengerAttachments`: their box top.
@@ -74,6 +75,7 @@ fn base_attachment(v: &Mob) -> [f64; 3] {
Species::Donkey => up(1.1125),
Species::Mule => up(1.2125),
Species::SkeletonHorse | Species::ZombieHorse => up(1.31875),
Species::Pig if baby => up(0.5),
Species::Pig => up(0.86875),
// `Hoglin.BABY_DIMENSIONS` and `EntityType.HOGLIN`.
Species::Hoglin if baby => up(0.875),
@@ -572,6 +574,35 @@ mod tests {
1.312_500_059_604_645
));
}
/// `Pig.getDefaultDimensions`: BABY_DIMENSIONS overrides PASSENGER.
#[test]
fn a_baby_pig_uses_its_own_passenger_attachment() {
let mut kind = (*crate::mobs::sensor_scan_tests::kind()).clone();
kind.id = "minecraft:pig".into();
kind.species = Species::Pig;
let kind = std::sync::Arc::new(kind);
let mut vehicle = Mob::new(1, kind.clone(), Vec3::new(4.5, 200.0, 4.5), 0.0);
let rider = Mob::new(2, kind, Vec3::ZERO, 0.0);
for (scale, baby_y, adult_y) in [
(0.0, 0.5, 0.868_749_976_158_142_1),
(1.0, 0.5, 0.868_749_976_158_142_1),
(2.0, 1.0, 1.737_499_952_316_284_2),
] {
vehicle.extra.model_scale = scale;
for (age, expected) in [(-24000, baby_y), (-1, baby_y), (0, adult_y), (42, adult_y)] {
vehicle.extra.age = age;
let attachment = passenger_attachment(&vehicle);
assert_eq!(attachment, [0.0, expected, 0.0], "age={age}, scale={scale}");
let offset = vehicle_attachment(&rider, vehicle.width());
assert_eq!(offset, 0.0);
assert_eq!(
seat(attachment, offset, vehicle.position, vehicle.yaw),
Vec3::new(4.5, 200.0 + expected, 4.5)
);
}
}
}
#[test]
fn archers_keep_their_distance_and_brawlers_close_in() {
assert!(ranged(Species::Skeleton) && ranged(Species::Pillager));
+92 -1
View File
@@ -119,9 +119,18 @@ fn can_see_sky_from_below_water<L: SpawnLevel>(l: &L, x: i32, y: i32, z: i32) ->
}
((y + 1)..sea).all(|yy| {
let f = l.facts().get(l.state(x, yy, z));
f.dampening == 0 || f.fluid != Fluid::None
// `BlockState.liquid()` is water, lava and the bubble column. Kelp,
// seagrass and waterlogged blocks hold water and still stop the scan.
f.dampening == 0 || liquid_block(&f.name)
})
}
/// `BlockState.liquid()`: the three blocks whose property is set.
fn liquid_block(name: &str) -> bool {
matches!(
name,
"minecraft:water" | "minecraft:lava" | "minecraft:bubble_column"
)
}
/// `EntityType.fireImmune`.
pub fn fire_immune(s: Species) -> bool {
@@ -618,3 +627,85 @@ impl SpawnLevel for super::MobWorld<'_> {
})
}
}
#[cfg(test)]
mod underwater_sky_tests {
use super::*;
use crate::mobs::world::with_built_test_world;
use horizon_core::random::LegacyRandom;
use horizon_world::{block_state_properties::BlockProperties, chunk::BlockState};
fn with_props(
registry: &horizon_world::registry::BlockRegistry,
name: &str,
props: &[(&str, &str)],
) -> horizon_world::registry::BlockStateId {
let mut properties = BlockProperties::new();
for (key, value) in props {
properties.insert((*key).to_owned(), (*value).to_owned());
}
registry
.resolve(&BlockState {
name: format!("minecraft:{name}"),
properties,
})
.unwrap()
}
/// `Guardian.checkGuardianSpawnRules`: below sea level the 1-in-20 roll is
/// skipped when `canSeeSkyFromBelowWater` is false. Kelp (dampening 1, not
/// `liquid()`) makes it false. A bubble column is `liquid()`, so the scan
/// continues and the roll is still required.
#[test]
fn kelp_hides_the_sky_from_a_guardian_and_a_bubble_column_does_not() {
let ran = with_built_test_world(
&[],
|world, registry| {
world.chunks.insert(
(0, 0),
std::sync::Arc::new(crate::chunk::Chunk::empty(0, 0)),
);
let water = registry.default_state("minecraft:water").unwrap();
let kelp = with_props(registry, "kelp", &[("age", "0")]);
let column = with_props(registry, "bubble_column", &[("drag", "true")]);
for x in [0, 1] {
world.set(x, 49, 0, water).unwrap();
world.set(x, 50, 0, water).unwrap();
}
world.set(0, 55, 0, kelp).unwrap();
world.set(1, 55, 0, column).unwrap();
},
|w, _| {
// Sea level in this world is 63, so y=55 sits in the scan.
assert_eq!(w.sea_level(), 63);
let mut probe = LegacyRandom::new(1);
assert_ne!(
probe.next_int_bound(20).unwrap(),
0,
"seed 1 must miss the 1-in-20"
);
let mut random = LegacyRandom::new(1);
assert!(
check_spawn_rules(Species::Guardian, w, Reason::Natural, &mut random, 0, 50, 0),
"kelp between the guardian and the sea hides the sky"
);
let mut random = LegacyRandom::new(1);
assert!(
!check_spawn_rules(
Species::Guardian,
w,
Reason::Natural,
&mut random,
1,
50,
0
),
"a bubble column is liquid(), so the sky still shows"
);
},
);
// Without the vanilla data under .local/horizon-client the world
// cannot be built: the test has nothing to check.
let _ = ran;
}
}
@@ -25,6 +25,29 @@ use std::{borrow::Cow, collections::HashMap, fs, path::Path};
pub use crate::biome::{SpawnCost, parse_costs, parse_entries};
thread_local! {
static ATTEMPT_COUNTS: std::cell::Cell<[u64; 2]> = const { std::cell::Cell::new([0; 2]) };
}
/// Reset or publish once per natural-spawn tick, not once per chunk.
pub fn attempt_counters(publish: bool) {
if !crate::spans::enabled() {
return;
}
let counts = ATTEMPT_COUNTS.with(|c| c.replace([0; 2]));
if publish {
crate::spans::counters(&[("sa_attempts", counts[0]), ("sa_spawns", counts[1])]);
}
}
fn attempt_count(index: usize) {
if crate::spans::enabled() {
ATTEMPT_COUNTS.with(|c| {
let mut counts = c.get();
counts[index] += 1;
c.set(counts);
});
}
}
/// `MobCategory`.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum Category {
@@ -1070,14 +1093,19 @@ pub fn spawn_category_for_chunk<L: NaturalLevel, R: RandomSource>(
) {
let x = chunk.0 * 16 + random.next_int_bound(16).unwrap_or(0);
let z = chunk.1 * 16 + random.next_int_bound(16).unwrap_or(0);
let top = l.heightmap(Heightmap::WorldSurface, x, z) + 1;
let min = l.min_y();
let y = min + random.next_int_bound(top - min + 1).unwrap_or(0);
let y = random_start_y(random, min, l.heightmap(Heightmap::WorldSurface, x, z));
trace(Event::Start(category, [x, y, z]));
if y > min {
spawn_category_for_position(l, random, state, category, chunk, [x, y, z], spawn, trace);
}
}
// NaturalSpawner.getRandomPosWithin adds one to ChunkAccess.getHeight's
// highest occupied Y. NaturalLevel.heightmap already returns the first empty Y.
fn random_start_y(random: &mut impl RandomSource, min: i32, first_empty: i32) -> i32 {
min + random.next_int_bound(first_empty - min + 1).unwrap_or(0)
}
/// `NaturalSpawner.spawnCategoryForPosition` with the spawn state's
/// `canSpawn` and `afterSpawn`.
#[allow(clippy::too_many_arguments)]
@@ -1111,6 +1139,7 @@ pub fn spawn_category_for_position<L: NaturalLevel, R: RandomSource>(
z += random.next_int_bound(6).unwrap_or(0) - random.next_int_bound(6).unwrap_or(0);
let (xx, zz) = (f64::from(x) + 0.5, f64::from(z) + 0.5);
trace(Event::Try(x, z));
attempt_count(0);
let Some(distance) = nearest.distance_sqr(l, xx, f64::from(y), zz) else {
continue;
};
@@ -1177,6 +1206,7 @@ pub fn spawn_category_for_position<L: NaturalLevel, R: RandomSource>(
let Some(result) = spawn(l, &request) else {
continue;
};
attempt_count(1);
group_index += 1;
spawned.push(result.bounds);
cluster += 1;
@@ -1458,6 +1488,28 @@ fn top_non_colliding<L: NaturalLevel>(l: &L, entity: &str, x: i32, z: i32) -> [i
mod tests {
use super::*;
#[test]
fn random_start_uses_first_empty_y_as_inclusive_ceiling() {
use horizon_core::random::LegacyRandom;
// Vanilla getRandomPosWithin: a solid column through Y=63 has
// topEmptyY=64, so [-64, 64] contains 129 possible starts, not 130.
for seed in 0..1024 {
let mut actual = LegacyRandom::new(seed);
let mut vanilla = LegacyRandom::new(seed);
let expected = -64 + vanilla.next_int_bound(129).unwrap();
assert_eq!(random_start_y(&mut actual, -64, 64), expected);
assert_eq!(actual.next_long(), vanilla.next_long());
}
// An empty column must still consume nextInt(1), then fail the
// spawnCategoryForChunk minimum-height gate without any group draws.
let mut actual = LegacyRandom::new(42);
let mut vanilla = LegacyRandom::new(42);
vanilla.next_int_bound(1).unwrap();
assert_eq!(random_start_y(&mut actual, -64, -64), -64);
assert_eq!(actual.next_long(), vanilla.next_long());
}
/// The grid finds the players whose chunk is within 8 of a chunk on both
/// axes, in their order, as a look at every player does.
#[test]
+2 -2
View File
@@ -612,8 +612,8 @@ mod tests {
#[test]
fn water_speed_is_cut_away_from_home_and_for_babies() {
// updateSpeed in water near home: no cut, the lerp climbs towards
// 0.25, and it lifts the turtle 0.005 a tick. (Only the control's
// speed: Turtle.travelInWater is not ported.)
// 0.25, and it lifts the turtle 0.005 a tick. (Tests only the move
// control's speed; Turtle.travelInWater is ported in travel().)
let mut m = strolling_turtle();
m.on_ground = false;
m.fluids.in_water = true;
+4
View File
@@ -41,6 +41,10 @@ pub use crate::items::ItemStack;
pub use crate::merchant::MerchantOffer;
pub use crate::mesher::TintSource;
pub use crate::mobs::goals::{Cx, Goal, GoalSelector};
pub use crate::mobs::model_layers::{
CubeDefinition, CubeDeformation, CubeListBuilder, LayerDefinition, MeshDefinition, ModelPart,
ModelPolygon, ModelVertex, PartId, PartPose,
};
pub use crate::mobs::{Mob, MobWorld};
pub use crate::music::{Music, MusicLayer};
pub use crate::particles::{ParticleOptions, ParticleType};
+14 -6
View File
@@ -38,7 +38,7 @@ use horizon_world::{
};
use std::{
collections::{BTreeMap, BTreeSet},
fs, io,
io,
path::Path,
sync::Arc,
};
@@ -94,13 +94,13 @@ impl MotionResources {
assets: &Assets,
facts: &crate::blocks::BlockFacts,
) -> Result<Self> {
let attributes = Arc::new(Catalog::from_json(&serde_json::from_slice(&fs::read(
root.join("attributes.json"),
)?)?)?);
let attributes = Arc::new(Catalog::from_json(&horizon_world::json_file::read(
&root.join("attributes.json"),
)?)?);
let file = BlockTableFile::open(&root.join("movement-properties.nbt"), assets.registry)?;
let properties = MovementProperties::from_table(file.table("movement")?)?;
let bundle: BTreeMap<String, String> =
serde_json::from_slice(&fs::read(root.join("block-tags.json"))?)?;
horizon_world::json_file::read(&root.join("block-tags.json"))?;
let mut definitions = TagDefinitions::default();
for (name, text) in bundle {
definitions.apply_json(&name, "vanilla", &text)?;
@@ -374,6 +374,8 @@ pub struct PlayerMotion {
/// move.
pub nudge: [Option<f64>; 2],
pub last_climbable: Option<BlockPos>,
/// Result of the latest `LivingEntity.onClimbable` query.
pub on_climbable: bool,
/// `Entity.moveDist`, `flyDist` and `nextStep`: footsteps come once per
/// metre walked, not once per move.
pub steps: MovementDistances,
@@ -602,6 +604,7 @@ impl PlayerMotion {
item_use_slows_sprint: false,
nudge: [None; 2],
last_climbable: None,
on_climbable: false,
steps: MovementDistances {
next_step: 1.0,
..MovementDistances::default()
@@ -1096,10 +1099,15 @@ impl MotionWorld<'_, '_, '_> {
/// matching ladder. It remembers the block.
pub fn climbable(&mut self) -> Result<bool> {
if self.player.flying {
self.player.on_climbable = false;
return Ok(false);
}
let resources = self.resources;
climbing::on_climbable(self, &resources.climbing).map_err(|e| e.to_string().into())
let found: Result<bool> =
climbing::on_climbable(self, &resources.climbing).map_err(|e| e.to_string().into());
let climbable = found?;
self.player.on_climbable = climbable;
Ok(climbable)
}
pub fn travel_in_air(&mut self, input: Vec3) -> Result<()> {
living_air::travel_in_air(self, input)
@@ -104,7 +104,7 @@ impl RandomTickSections {
key: (i32, i32),
chunk: &Arc<Chunk>,
facts: &BlockFacts,
candidate: impl FnMut(u32) -> bool,
mut candidate: impl FnMut(u32) -> bool,
from_y: i32,
) -> Option<(i32, Rc<RandomTickMask>)> {
let cached = Self::refresh(&mut self.chunks, key, chunk, &mut |id| {
@@ -113,12 +113,32 @@ impl RandomTickSections {
let y = cached.sections.from(from_y)?;
let index = ((y - cached.sections.base) >> 4) as usize;
let mask = cached.masks[index].get_or_insert_with(|| {
let mut blocks = [0u32; 4096];
chunk.sections[index].blocks.unpack(&mut blocks);
Rc::new(RandomTickMask::of(&blocks, candidate))
Rc::new(Self::candidate_mask(&chunk.sections[index], &mut candidate))
});
Some((y, mask.clone()))
}
fn candidate_mask(section: &Section, mut candidate: impl FnMut(u32) -> bool) -> RandomTickMask {
let blocks = &section.blocks;
let mut ids = [0u16; 4096];
if let Some(palette) = blocks.unpack_ids(&mut ids) {
// ServerLevel.tickChunk's per-state eligibility is constant
// across a section: resolve it once per palette entry, not
// once per block when an edited section rebuilds its mask.
let mut candidates = [false; 256];
for (i, &state) in palette.iter().enumerate() {
candidates[i] = candidate(state);
}
let mut bits = [0u64; 64];
for (i, &id) in ids.iter().enumerate() {
bits[i >> 6] |= u64::from(candidates[id as usize]) << (i & 63);
}
RandomTickMask(bits)
} else {
let mut states = [0u32; 4096];
blocks.unpack(&mut states);
RandomTickMask::of(&states, candidate)
}
}
fn refresh<'a>(
chunks: &'a mut FxHashMap<(i32, i32), Cached>,
key: (i32, i32),
@@ -166,6 +186,78 @@ mod tests {
use crate::chunk::SECTIONS;
use crate::chunk::Section;
#[test]
fn palette_masks_preserve_positions_and_rng() {
use horizon_core::position::BlockPos;
use horizon_world::level::Ticks;
// Alternating states defeat the old mask builder's consecutive-state
// shortcut. Include single, local and global palettes and unused IDs.
for distinct in [1, 2, 256, 300] {
let states = std::array::from_fn::<_, 4096, _>(|i| (i % distinct) as u32);
let mut section = Section {
blocks: Section::from_blocks(&states),
..Default::default()
};
if distinct == 2 {
section.set(0, 999);
section.set(0, states[0]);
}
let candidate = |id| id % 3 == 0;
let reference = RandomTickMask::of(&states, candidate);
let mut calls = 0;
let optimized = RandomTickSections::candidate_mask(&section, |id| {
calls += 1;
candidate(id)
});
assert_eq!(reference, optimized);
if distinct == 2 {
assert_eq!(calls, 3); // two live states and one stale palette entry
}
let (mut before, mut after) = (Ticks::new(12345), Ticks::new(12345));
let mut expected = Vec::new();
let mut actual = Vec::new();
// Non-ticking sections consume no draws; each ticking section
// gets three, even if none hits a candidate.
for (y, ticking) in [(-64, false), (-48, true), (-32, false), (16, true)] {
if !ticking {
continue;
}
for _ in 0..3 {
let p = before.block_random_pos(-32, y, 48, 15);
if reference.may_tick(p) {
expected.push(p);
}
let p = after.block_random_pos(-32, y, 48, 15);
if optimized.may_tick(p) {
actual.push(p);
}
}
}
assert_eq!(expected, actual);
assert_eq!(before.random_tick_value, after.random_tick_value);
// Independent wrapping i32 LCG oracle, including coordinate bits.
let mut value = 12345u32;
let mut oracle = Vec::new();
for y in [-48, 16] {
for _ in 0..3 {
value = value.wrapping_mul(3).wrapping_add(1_013_904_223);
let p = BlockPos::new(
-32 + ((value >> 2) & 15) as i32,
y + ((value >> 18) & 15) as i32,
48 + ((value >> 10) & 15) as i32,
);
let i = ((p.y & 15) << 8 | (p.z & 15) << 4 | (p.x & 15)) as usize;
if candidate(states[i]) {
oracle.push(p);
}
}
}
assert_eq!(actual, oracle);
assert_eq!(after.random_tick_value as u32, value);
}
}
#[test]
fn a_tall_chunk_ticks_its_sections_near_the_top() {
let layout = crate::chunk::ColumnLayout::new(-64, 2096).unwrap();
Loaded 100 of 274 files, more files were not shown because too many files have changed in this diff. Show more