Compare commits

..
Author SHA1 Message Date
Ludovic Marie 3b691ea10b Merge pull request #13833 from horizon-rust/perf/glass-mods-cull
🤖 Agent: virtualises the launcher's mods list (2000 mods: 28 → 60 fps scrolling)
2026-11-09 13:02:59 +01:00
Ludovic Marie 64353830de Merge pull request #13698 from horizon-rust/perf/tracker-slab
🤖 Agent: ServerEntity slab found in last tick's order instead of states.entry per entity (40 % of the tracker): lan_track_entities 6.26→3.75, 7.07→4.74, 6.29→4.68 ms/tick (3 interleaved rounds on playhorizon, 122 players, live SMP entity mix); ~-1.6 ms projected live. Scratch differential test vs main's tracker (300 random levels x 150 ticks x 5 clients): packets byte-identical. Union with #13720: 284 harnesses pass incl. dedicated_server, server_entities, late_entity_pair; 22 fail identically on main.
2026-11-09 12:59:13 +01:00
Ludovic Marie 2dd69539cb Merge pull request #13720 from horizon-rust/perf/idle-pass-order
🤖 Agent: idle-pass notes found in last pass order instead of a hash probe per frozen entity, players sorted by x, last type's range kept: lan_entity_states 8.67→5.25, 8.98→5.30, 8.91→4.96 ms/tick (3 interleaved rounds on playhorizon, 122 players, live SMP entity mix with ~21k frozen items); ~-2.6 ms projected live. Union with #13698: 284/306 selected harnesses pass, the 22 others fail identically on main ba0fdb381.
2026-11-09 12:59:08 +01:00
Horizon agentandClaude Sonnet 5.5 f9673db9a5 Launcher: the glass Mods results and Home's Worlds list build only the rows near the view
The glass Mods page built a row for every result it had (they add up behind
More), and Home's Worlds tab one for every world: with 2000 rows a scroll
frame cost about 36 ms. Both now go through cull::Cull, as the instance's mod
list, the releases and the classic Mods page already do: rows near the view
are built, the rest are one spacer each. The lists stay in the page's scroll
area, so the sticky project card, hover and click handling are unchanged. A
world being worked on or with its menu open stays built.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-09 12:41:03 +01:00
Ludovic Marie 0c60c83bcd Merge pull request #13709 from vnsmoreira/fix/falling-block-fall-float
🤖 Agent: I merged main; the falling block fall float case matches 26/26 on this branch alone, and only failed in a union where #13712's case unloaded chunk 250 two columns away (#13825, queued first, moves that case).
2026-11-09 12:26:37 +01:00
Ludovic Marie 7659d540f4 Merge pull request #13646 from MSedgeMC/fix/vex-owner-nbt
🤖 Agent: mob_ai passed (2868 s) on union19b with this branch and main; the vex's summoner survives the chunk save without changing its charge goal's draws.
2026-11-09 12:26:33 +01:00
Ludovic Marie 0492680f66 Merge pull request #13714 from NikitaMGrimm/parity/ravager-speed-save
🤖 Agent: your main merge drops the immobile ravager from made_at_load as Ravager.readAdditionalSaveData reads only the three counters (checked with rea_vanilla); ravager saved movement speed matches 32/32 and mob_ai (2868 s), mob_attributes and attribute pass on union19b with the same resolution.
2026-11-09 12:26:29 +01:00
Ludovic Marie 8c438cc3c7 Merge pull request #13708 from rdbtCVS/perf/hx-explosion-steps
🤖 Agent: reusing a step's take within the same block is exact: vanilla adds the block on the first step whose power stays above 0, and a step with no power left ends the ray, so a later step there can only re-add it; explosion_items passes and the command_exec explosion cases match on this branch alone.
2026-11-09 12:23:38 +01:00
Ludovic Marie c15ab62594 Merge pull request #13825 from horizon-rust/fix/corpus-projectile-fall-chunk
🤖 Agent: tooling only: moves #13712's case off chunk 250 so its unload no longer keeps vanilla's chunks 248/252 from entity ticking; command_exec on main fails explosion infested blocks 11/16, on this branch it matches (only the known zombie villager flake).
2026-11-09 12:23:34 +01:00
Horizon-agent cfcf8489a6 Merge main into #13709
Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	tools/command_exec_corpus.py
2026-11-09 12:20:16 +01:00
Horizon agentandClaude Opus 5.5 2183fc4a18 command_exec: run the projectile fall distance case in its own area
Its forceload remove at chunk 250 left vanilla's chunks 248 and 252
(two columns away) short of entity ticking for a few ticks while 250
unloaded, so the explosion infested blocks case (and #13709's falling
block case) saw no entity tick on the Java side.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 12:06:03 +01:00
Ludovic Marie 77cee50f49 Merge pull request #13712 from vnsmoreira/fix/projectile-fall-distance
🤖 Agent: I merged main again after #13645 landed (the merger stopped on the conflict); the projectile fall distance cases match vanilla 15/15 and fall_damage passes.
2026-11-09 11:58:22 +01:00
Horizon-agent 5b8dc6d632 Merge main into #13712
Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	tools/command_exec_corpus.py
2026-11-09 11:47:25 +01:00
Ludovic Marie dc4e681d37 Merge pull request #13645 from hunixcode/fix/item-escape-own-random
🤖 Agent: I merged main after #13558: ItemEntity::new starts the random at seed 0 like this PR's other constructions, and Game::drop and a server's item set the own random that drew their UUID and bobOffs; the escape tests and the item harnesses (max stack, health load, numeric uuid, lava cauldron, held drop) pass on union19b.
2026-11-09 11:38:52 +01:00
Ludovic Marie 28b8b6e232 Merge pull request #13719 from MSedgeMC/test/lodestone-armor
🤖 Agent: the armour slots now get the same lodestone check as the inventory, as LivingEntity.tick ticks EntityEquipment; lodestone_default (with its new armour case) passes on union19b.
2026-11-09 11:38:48 +01:00
Ludovic Marie 6b4e3f59f8 Merge pull request #13700 from rdbtCVS/perf/hx-display-cache
🤖 Agent: I keyed the kept display data by level: the server thread tracks each dimension's others in turn, and one shared cache made each pass drop the other level's displays and rebuild them every tick; text_display, display_save, server_entities and lan pass on union19b.
2026-11-09 11:38:44 +01:00
Horizon agentandClaude Opus 5.5 3fa7bcdd69 Merge main into #13645: the own random rides on ItemEntity::new
#13558 builds items through ItemEntity::new; it starts the random at seed 0
as this PR's other constructions do, and the two spawn paths (Game::drop
and a server's item) set the own random that drew their UUID and bobOffs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 11:01:32 +01:00
Ludovic Marie 599ed14267 Merge pull request #13713 from vnsmoreira/packs/polytone-environment-fog
🤖 Agent: I merged the fixed #13704 in; with the default environment the underwater brightening is water_vision_over(600) × 1, the same ramp as before, so the vanilla look is unchanged.
2026-11-09 11:00:57 +01:00
Ludovic Marie 3303f4f1cc Merge pull request #13704 from vnsmoreira/packs/polytone-effect-colors
🤖 Agent: I merged main and pointed #13703's banner::dye_rgb at the renamed pack_palette (the land check stopped on pack_dyes); a pack's effect colour also reaches the hub's entity_effect particles, as an integrated Java server sharing MobEffect would.
2026-11-09 11:00:53 +01:00
Ludovic Marie bb7652f721 Merge pull request #13778 from horizon-rust/fix/mods-tests-isolation
🤖 Agent: isolates the remaining MODS-setting tests (same helper as #13733)
2026-11-09 10:39:43 +01:00
Horizon-agentandClaude Opus 5.5 f9e704effb Merge #13704 (main merged in) into #13713
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 10:38:30 +01:00
Horizon-agentandClaude Opus 5.5 ac0e2cb6be Banner layers read the renamed pack_palette
#13703 added banner::dye_rgb on pack_dyes, which this PR renames.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 10:38:29 +01:00
Horizon-agentandClaude Opus 5.5 54034799d2 Merge main into #13704
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 10:38:29 +01:00
Ludovic Marie 8a1e413e1f Merge pull request #13656 from vnsmoreira/fix/explosion-infested-silverfish
🤖 Agent: I merged main (the corpus keeps both sides' cases); explosion_items passed in union16, the explosion command_exec cases match, union18 explosion tests pass.
2026-11-09 10:29:53 +01:00
Ludovic Marie ce5571cac2 Merge pull request #13558 from vnsmoreira/fix/item-fall-distance
🤖 Agent: I merged main again; the merge keeps main's mod item_no_physics beside the new item_fall module, and the item fall distance cases matched vanilla in command_exec (union16: only the known zombie PersistenceRequired flake differs).
2026-11-09 10:29:48 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 55ec392c64 rustfmt: made_at_load's speed condition fits one line
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 10:14:06 +01:00
Horizon-agentandClaude Opus 5.5 edef540e08 Merge main into #13700
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 10:10:27 +01:00
Horizon-agent 6187e0745d Merge main into #13712
Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	tools/command_exec_corpus.py
2026-11-09 10:10:14 +01:00
Horizon-agent 8f616098e8 Merge main into #13709
Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	tools/command_exec_corpus.py
2026-11-09 10:09:58 +01:00
Horizon-agent fb05f85337 Merge main into #13656
Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	tools/command_exec_corpus.py
2026-11-09 10:04:32 +01:00
Horizon-agent 1e3b7a1ce8 Merge main into #13558
Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	tools/command_exec_corpus.py
2026-11-09 10:04:25 +01:00
Horizon agentandClaude Sonnet 5.5 2f2c59c53b Run every MODS-setting test in its own process
mod_api::MODS is set once per process, so the tests that install trait
mods in the same test binary failed at random depending on which ran
first. Move #13733's in_own_process helper to mod_api and use it in the
remaining eight game tests and the mod_api test that set MODS.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-09 09:50:28 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 934d6a185f Merge origin/main into parity/ravager-speed-save
#13660 moved the attribute instances a load makes into finalize::made_at_load and
kept #12680's ravager parts there (the immobile ravager's speed instance, and the
saved speed base overwritten from the model). Main's structure stays; the ravager
parts go again, since the ravager's speed is now an attribute base that its
aiStep sets and the save writes like any other.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 09:48:34 +01:00
Horizon-agent 8b3e3b9177 Merge main into #13656
Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	tools/command_exec_corpus.py
2026-11-09 09:41:36 +01:00
Horizon-agentandClaude Opus 5.5 4f0c8a4b37 Merge main into #13558
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 09:35:17 +01:00
Horizon-agent 4c96fd0ca9 Merge the author's rebase of #13558 (same change) into the main-merged branch
Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	tools/command_exec_corpus.py
2026-11-09 09:35:17 +01:00
Horizon agentandClaude Opus 5.5 0f901e30a1 Keep tracked displays per level
The server thread tracks each dimension in turn (levels.rs swaps others and dim), and the
cleanup compared one shared cache with the level in place: with players in two dimensions
each pass dropped the other level's displays and they were built again every tick. The
cache is now keyed by the level in place, so the cleanup only sees its own displays.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 09:03:04 +01:00
Horizon agent 0fa1ee3a3c Merge remote-tracking branch 'origin/main' into x-13700 2026-11-09 09:01:31 +01:00
Horizon agent 2b42fac0cc Merge remote-tracking branch 'origin/main' into x-13712
# Conflicts:
#	tools/command_exec_corpus.py
2026-11-09 09:00:28 +01:00
Horizon agent f9964c0cdf Merge remote-tracking branch 'origin/main' into x-13709
# Conflicts:
#	tools/command_exec_corpus.py
2026-11-09 08:59:46 +01:00
Horizon-agent 0cf703b72b Merge main into #13558: keep both corpus cases
Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	tools/command_exec_corpus.py
2026-11-09 08:53:24 +01:00
MSedgeMC 65f5c93f41 fix: tick compasses worn in a player's armour slots
LodestoneTracker.tick runs from ItemStack.inventoryTick, which
LivingEntity.tick calls for every EntityEquipment slot as well as the
inventory. Horizon only ticked the inventory and offhand, so a compass
worn in an armour slot kept its target after the lodestone was broken.
Tick the four armour slots too, and extend the saved-default comparison
with an armour family and --no-armor.
2026-11-09 07:35:18 +00:00
Horizon-agentandClaude Opus 5.5 f36db58664 Restore mod item_no_physics that the main merge dropped
#13573 added the module; the merge into #13558 kept only the PR's mod line.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 08:33:45 +01:00
Vinícius MoreiraandClaude Opus 5.5 61876b21de A dropped item keeps, saves and loads its fall distance
ItemEntity had no fall distance: it saved fall_distance: 0d and dropped a
loaded value. Entity.move now resets it on a fast move past a
fall-resetting block or water, checkFallDamage adds each step down
(narrowed to a float) outside water and ends it on the ground, water and
makeStuckInBlock reset it, lava halves it, and Entity.save / load keep it.

Fixes #13203

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 04:33:02 -03:00
Vinícius MoreiraandClaude Opus 5.5 a7c2266c95 A projectile keeps the fall_distance it is given
Entity.load reads fall_distance for every entity and saveWithoutId
writes it; a projectile moves by setPos, never through Entity.move, so in
the air it keeps the loaded value. Horizon's Projectile had no fall
distance: fireballs, wither skulls and thrown projectiles saved 0.0d.

The projectile's Entity field helpers move to entity_nbt/projectile_fields.rs,
keeping entity_nbt.rs from growing.

Fixes #13702

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 04:30:36 -03:00
Vinícius MoreiraandClaude Opus 5.5 36c40227dc A falling block adds each step of its fall narrowed to a float
Entity.checkFallDamage adds fallDistance -= (float)ya for a move down
outside water, and a falling block never runs baseTick, so every step
counts. Horizon added the double difference of the two positions, which
differs from vanilla in the low bits from the first tick.

Fixes #13701

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 04:30:11 -03:00
Vinícius MoreiraandClaude Opus 5.5 cfc164308f A blast lets an infested block's silverfish out
Block.onExplosionHit runs spawnAfterBreak before the drops, and
InfestedBlock.spawnAfterBreak spawns its silverfish while block_drops is
on and the tool does not prevent it. Horizon spawned it only for a block
a player mines. Both paths now share spawn_infestation.

Part of #11330

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 04:30:08 -03:00
Horizon-agentandClaude Opus 5.5 2499cd929a Merge main into #13645
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 08:29:36 +01:00
Horizon-agentandClaude Opus 5.5 4b11e24b12 Merge main into #13558: keep both corpus cases (#13574 landed)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 08:29:21 +01:00
Horizon-agentandClaude Opus 5.5 a57a829572 Merge main into #13645: the escape speed keeps the item's own random at #13573's new noPhysics spot
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 08:28:22 +01:00
Horizon agentandClaude Opus 5.5 f3dc477663 Merge main into #13558: keep both sides' stations tests
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 08:26:35 +01:00
Vinícius MoreiraandClaude Opus 5.5 706b936a77 Polytone: environment fog settings
environment.powder_snow_fog, blindness_fog_distance, darkness_fog_distance,
boss_fog_distance, water_fog_brightening and water_fog_brightening_time replace the fog
values vanilla hardcodes, read once with the packs. Measured on the real mod (powder snow,
blindness, darkness, water); star_size and star_count change nothing there.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 04:25:45 -03:00
Nikita Maximilian Grimm 3908880998 Merge remote-tracking branch 'origin/main' into parity/ravager-speed-save 2026-11-09 08:18:23 +01:00
Vinícius MoreiraandClaude Opus 5.5 baa73603e9 Polytone: effect, particle and redstone colours
effect.<id> recolours potions, tipped arrows and effect particles (the server's, as
Polytone changes the effect for the whole game in single player); particle.<id> is a
particle modifier that sets only the colour; redstone.<power> recolours the wire and its
dust. Measured on the real mod; effect.water and effect.empty change nothing there.
The dye table becomes pack_palette, which also holds the redstone colours.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 03:54:45 -03:00
Horizon agentandClaude Opus 5.5 f2543c9c55 perf(lan): idle pass keeps the last type's tracking range
tracking::settings (a binary search over the type names) was a quarter of
the idle pass once its lookups were gone; the entities of a type come in
runs, so the last type's scaled range is kept.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 07:53:08 +01:00
Horizon agentandClaude Opus 5.5 b607e0e0fd test(lan): late pairing tests read the slab by id
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 07:33:57 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 a6cd0a67a7 Keep entity_nbt.rs from growing: one loop for the two ravager cases
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 07:11:35 +01:00
MSedgeMC ff59d29516 refactor: keep the owner comment on one line
mobs.rs is over the 3000-line limit and may not grow (docs/layers.md#file-size).
2026-11-09 06:00:27 +00:00
MSedgeMC 9ba2f28221 refactor: keep the vex's save code in vex.rs
`entity_nbt.rs` is over the 3000-line limit and may not grow (docs/layers.md
#file-size), so the vex save arm and its read side move into `mobs/vex.rs`
beside the tests and `owner`. `entity_nbt` keeps the one-line calls.
2026-11-09 05:54:28 +00:00
MSedgeMC e5817a0217 port_map: a vex's owner is saved and read
`tools/port_map/game2-003.tsv` had no answer for `Vex.addAdditionalSaveData` or
`Vex.readAdditionalSaveData`. Both now name
`entity_nbt::mob_to_nbt` / `mob_from_nbt`: `owner` as the owner's UUID, written
only for a vex that has one, with `bound_pos` and `life_ticks` beside it.
2026-11-09 05:46:44 +00:00
MSedgeMC 1a451f670f test: a vex's owner through the chunk save
`mob_from_nbt` and `mob_to_nbt` are the pair `Game::load_mob` and
`Game::mob_tree_nbt` go through, and `mobs::vex::owner` is what
`VexCopyOwnerTargetGoal` asks for the owner. A summoned vex (owner id 7, UUID
[3,1,4,1]) saves `owner: IntArray([3,1,4,1])`; the reloaded vex keeps the UUID
and has no id; a stub `WhoLookup` that answers the UUID with `Who::Mob(42)` gives
that owner (so an evoker that loaded first is found), while an empty level, a
UUID naming the player and a world with no lookups give none. A save naming no
owner writes no `owner` key even when the compound it came in with named one.

Without the save pair the key is missing (left: `None`, right:
`Some(IntArray([3, 1, 4, 1]))`) and the reloaded vex keeps no reference
(left: `None`, right: `Some([9, 9, 9, 9])` for the compound's own).
2026-11-09 05:46:44 +00:00
MSedgeMC d56cbb35f2 fix: keep a vex's owner across its chunk save
`Vex.addAdditionalSaveData` stores the reference to the mob that summoned it,
and `readAdditionalSaveData` reads it back:

    EntityReference.store(this.owner, output, "owner");   // UUIDUtil.CODEC, nothing when null
    ...
    this.owner = EntityReference.read(input, "owner");    // null when absent

`Vex.setOwner` takes a `Mob`, and `getOwner` resolves the reference through the
level every time it is asked (`EntityReference.getEntity`), so a vex that
reloaded before its evoker still copies that evoker's target once it is there.

Horizon models the owner as the owner's mob id (`extra.counter2`, which the
evoker sets when it summons the vex) and saved neither the id nor a UUID:
`VexCopyOwnerTargetGoal` had nothing to read after a reload, so a summoned vex
stopped copying its evoker's target and stood there once its chunk had been
reloaded.

The save now writes `owner` as the owner's UUID - the level's answer for an id
still in it, and the reference the vex was loaded with when the owner is gone -
and the load keeps that UUID in `extra.owner`, because an id would name
whatever mob holds it after the reload. `mobs::vex::owner` is `Vex.getOwner`:
the id while the evoker is in the level, the UUID resolved through
`MobWorld::who_by_uuid` (`WhoLookup`, #10365's seam that `Game::who_by_uuid`
backs) otherwise. A UUID naming the player is no owner, as `setOwner(Mob)`
takes a mob.
2026-11-09 05:46:44 +00:00
hunixandClaude Opus 5.5 a8e0fab5aa Items: escape a solid block at the item's own random speed
Entity.moveTowardsClosestSpace draws the push speed from the entity's own
random (`this.random.nextFloat() * 0.2F + 0.1F`), the one an item's
constructor drew its UUID, bobOffs, yaw and motion from. Horizon drew it
from the level's random, so the item left the block at another speed and
the level's stream was one draw ahead of vanilla's from then on.

ItemEntity keeps its own random now: a spawned item keeps the one its
constructor drew from, a loaded one the one that drew its UUID, bobOffs and
yaw, and /data keeps the entity's. Two small test modules move to their own
files (entities/experience_tests.rs, entity_nbt/spell_ticks_nbt_tests.rs),
unchanged, so entities.rs and entity_nbt.rs shrink instead of growing.

Fixes #13043

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 19:34:26 -10:00
Horizon agentandClaude Opus 5.5 8331f8cad8 perf(lan): tracked entities in a slab found in last tick's order
Entities::tick looked every tracked entity up by id in a hash map of
ServerEntity on every tick: with ~27k entities (most of them items and
mobs frozen past the simulation distance) nearly every probe missed the
cache, ~2.3 ms of a 105 ms tick at 122 players. The ServerEntitys now sit
in a slab; each tick notes the order it met them in, and the next tick
finds each entity at or just past where it found the one before. Entities
that joined or come out of order are looked up by id, as before. The send
pass takes each entity's slot from that order instead of a second lookup.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 06:27:59 +01:00
Horizon agentandClaude Opus 5.5 48d52eba1b perf(lan): idle-entity notes in pass order, players sorted by x
The tracker's idle pass (game/lan_idle.rs) looked each idle mob and item up
in a per-level hash map and tested it against every player of the level, on
every tick: about 5 ms of a 105 ms tick at 122 players with ~21k frozen items.
The game meets its entities in the same order each tick, so the notes are
kept in that order and found at or just past the last one found; one found
nowhere near is described in full, which is always right. The players are
sorted by x so an entity tests only the ones within its range along x.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 06:25:37 +01:00
Nikita Maximilian Grimm 273883af4c Merge remote-tracking branch 'origin/main' into parity/ravager-speed-save 2026-11-09 06:15:59 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 6d05e015d2 A ravager keeps its saved movement speed and saves none before its first tick
Ravager.aiStep sets the movement_speed base (0.0 while biting, stunned or
roaring, then a lerp back toward 0.3), so the base is what AttributeMap.pack
saves and AttributeMap.apply restores. The ravager now writes that base, and
the special cases from #12680 are gone: the base_value short-circuit that made
a loaded base fall back to 0.3, the instance saved before the first tick, and
the 0.0 speed set at load.

Fixes #13621.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 06:15:57 +01:00
rdbtCVSandClaude Opus 5.5 5119526d69 Tracked displays: keep each display's built data while its compound stays
The synched data of the text, block and item displays a server keeps
unsimulated was built from their compound every tick: text components,
block states and items parsed again for every hologram and menu. It is now
kept per display and used while the display's kind, compound and block
registry are unchanged; an item display's stack is still asked of the hub
each time, as a build does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 04:04:19 +00:00
rdbtCVSandClaude Opus 5.5 d78b319c35 Explosions: a ray's step in the block its last step was in reuses that lookup
A blast's 1,352 rays step 0.3 blocks at a time, about three steps a block, and
each step looked its block, fluid, rails and resistance up again and put the
block in the set again. A step in the same block as the last now takes what
that one took (nothing changes while rays cast), and the block is already in
the set if it is to be.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 03:48:47 +00:00
Vinícius MoreiraandClaude Opus 5.5 c59ebc38c4 A dropped item keeps, saves and loads its fall distance
ItemEntity had no fall distance: it saved fall_distance: 0d and dropped a
loaded value. Entity.move now resets it on a fast move past a
fall-resetting block or water, checkFallDamage adds each step down
(narrowed to a float) outside water and ends it on the ground, water and
makeStuckInBlock reset it, lava halves it, and Entity.save / load keep it.

Fixes #13203

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 00:29:14 -03:00
65 changed files with 2054 additions and 580 deletions

No files matched your search

+24 -4
View File
@@ -1,7 +1,10 @@
//! The `MobEffects` and `Potions` registries (code in vanilla, ported as
//! tables): each effect's colour and category, each potion's effects, and the
//! potion colour mix. horizon-client re-exports them from `brewing`.
use std::{collections::HashMap, sync::OnceLock};
use std::{
collections::HashMap,
sync::{OnceLock, RwLock},
};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Category {
@@ -18,6 +21,22 @@ pub struct EffectDef {
pub category: Category,
pub instant: bool,
}
/// The colours resource packs give effects (Polytone's `effect.<id>`), by the table's id.
static PACK_COLORS: RwLock<Option<HashMap<String, u32>>> = RwLock::new(None);
/// Takes the packs' effect colours (none: vanilla's).
pub fn set_pack_colors(colors: Vec<(String, u32)>) {
let colors = (!colors.is_empty()).then(|| colors.into_iter().collect());
*PACK_COLORS.write().unwrap_or_else(|e| e.into_inner()) = colors;
}
impl EffectDef {
/// `MobEffect.getColor` as the packs show it: potions, tipped arrows and effect particles.
pub fn shown_color(&self) -> u32 {
let pack = PACK_COLORS.read().unwrap_or_else(|e| e.into_inner());
pack.as_ref()
.and_then(|colors| colors.get(self.id))
.map_or(self.color, |c| c & 0xFF_FFFF)
}
}
const fn fx(id: &'static str, color: u32, category: Category) -> EffectDef {
EffectDef {
id,
@@ -309,9 +328,10 @@ pub fn effects_color<'a>(effects: impl Iterator<Item = (&'a str, i32)>) -> u32 {
for (e, amp) in effects {
let Some(e) = effect(e) else { continue };
let w = amp as u32 + 1;
r += w * (e.color >> 16 & 255);
g += w * (e.color >> 8 & 255);
b += w * (e.color & 255);
let color = e.shown_color();
r += w * (color >> 16 & 255);
g += w * (color >> 8 & 255);
b += w * (color & 255);
total += w;
}
match (
@@ -575,14 +575,14 @@ fn classify(name: &str, prop: &dyn Fn(&str) -> Option<String>) -> Kind {
"banner" => {
let segment: f32 = prop("rotation").and_then(|r| r.parse().ok()).unwrap_or(0.0);
return Kind::Banner {
color: crate::pack_dyes::diffuse(dye),
color: crate::pack_palette::diffuse(dye),
wall: false,
angle: segment * 22.5,
};
}
"wall_banner" => {
return Kind::Banner {
color: crate::pack_dyes::diffuse(dye),
color: crate::pack_palette::diffuse(dye),
wall: true,
angle: y_rot(facing()),
};
+1 -1
View File
@@ -14,7 +14,7 @@ use std::{collections::HashSet, path::Path};
pub use horizon_client_core::potions::{
BASE_POTION_COLOR, Category, EFFECTS, EffectDef, POTIONS, PotionDef, all_effects,
description_id, effect, is_mod_effect, mod_effects, namespaced, potion, potion_color,
register_mod_effects,
register_mod_effects, set_pack_colors,
};
/// `MobEffectUtil.formatDuration` at 20 TPS.
pub fn format_duration(ticks: i32) -> String {
+68 -7
View File
@@ -110,10 +110,15 @@ impl FogState {
}
/// `LocalPlayer.getWaterVision`.
pub fn water_vision(&self, eye_in_water: bool) -> f32 {
self.water_vision_over(eye_in_water, 600.0)
}
/// The water vision a pack's `water_fog_brightening_time` gives: vanilla's ramp over that
/// many ticks, of the 600 the game counts (more never reaches full).
pub fn water_vision_over(&self, eye_in_water: bool, ticks: f32) -> f32 {
if !eye_in_water {
return 0.0;
}
let t = self.water_vision_time as f32;
let t = self.water_vision_time as f32 * 600.0 / ticks;
if t >= 600.0 {
return 1.0;
}
@@ -226,6 +231,8 @@ pub struct FogInputs<'a> {
/// `GameRenderer.nightVisionScale` while Night Vision is on without
/// Darkness, else 0 (`FogRenderer.computeFogColor`'s brightenFactor).
pub night_vision: f32,
/// The packs' Polytone `environment` fog settings.
pub environment: horizon_polytone::colors::Environment,
}
/// `FogRenderer.setupFog` + `computeFogColor`.
@@ -241,7 +248,7 @@ pub fn setup_fog(tables: &FogTables, state: &FogState, input: &FogInputs) -> Fog
// Base colour of the applicable environment.
let mut color = match input.fog_type {
FogType::Lava => rgb(0x99_1A00),
FogType::PowderSnow => rgb(0x9F_BACC),
FogType::PowderSnow => rgb(input.environment.powder_snow_fog.unwrap_or(0x9F_BACC)),
FogType::Water => input.water_fog_color,
FogType::Atmospheric => {
let mut fog = sky.fog_color;
@@ -299,8 +306,11 @@ pub fn setup_fog(tables: &FogTables, state: &FogState, input: &FogInputs) -> Fog
color[1] += (color[1] * 0.6 - color[1]) * t;
color[2] += (color[2] * 0.6 - color[2]) * t;
}
let brighten = if eye_in_water {
let env = &input.environment;
let brighten = if eye_in_water && input.full_water_vision {
water_vision
} else if eye_in_water {
state.water_vision_over(true, env.water_fog_brightening_time) * env.water_fog_brightening
} else {
input.night_vision
};
@@ -342,10 +352,11 @@ pub fn setup_fog(tables: &FogTables, state: &FogState, input: &FogInputs) -> Fog
}
_ if input.blindness.is_some() => {
let (duration, infinite) = input.blindness.unwrap_or((0, true));
let end = env.blindness_fog_distance;
let distance = if infinite {
5.0
end
} else {
rd + (5.0 - rd) * (duration as f32 / 20.0).min(1.0)
rd + (end - rd) * (duration as f32 / 20.0).min(1.0)
};
fog.environmental_start = distance * 0.25;
fog.environmental_end = distance;
@@ -355,7 +366,7 @@ pub fn setup_fog(tables: &FogTables, state: &FogState, input: &FogInputs) -> Fog
_ if input.darkness.is_some() => {
// DarknessFogEnvironment.setupFog: closing in to 15 blocks.
let blend = input.darkness.unwrap_or(0.0);
let distance = rd + (15.0 - rd) * blend;
let distance = rd + (env.darkness_fog_distance - rd) * blend;
fog.environmental_start = distance * 0.75;
fog.environmental_end = distance;
fog.sky_end = distance;
@@ -380,7 +391,7 @@ pub fn setup_fog(tables: &FogTables, state: &FogState, input: &FogInputs) -> Fog
fog.cloud_end = ((input.cloud_range_chunks * 16) as f32).min(sky.cloud_fog_end);
if input.boss_world_fog {
fog.environmental_start = fog.environmental_start.min(10.0);
fog.environmental_end = fog.environmental_end.min(96.0);
fog.environmental_end = fog.environmental_end.min(env.boss_fog_distance);
fog.sky_end = fog.environmental_end;
fog.cloud_end = fog.environmental_end;
}
@@ -483,6 +494,7 @@ mod tests {
fire_resistance: false,
full_water_vision: false,
night_vision: 0.0,
environment: Default::default(),
}
}
#[test]
@@ -560,6 +572,55 @@ mod tests {
assert!(fog.environmental_start >= 1e9 && fog.render_distance_start >= 1e9);
}
#[test]
fn a_packs_environment_moves_effect_fog_and_its_colours() {
let sky = Sky {
fog_start: 10.0,
fog_end: 96.0,
..Sky::default()
};
let tables = FogTables {
water_fog_end: vec![1.0],
};
let environment = horizon_polytone::colors::Environment {
powder_snow_fog: Some(0xFF0000),
blindness_fog_distance: 40.0,
water_fog_brightening: 0.0,
..Default::default()
};
let mut input = inputs(&sky);
input.blindness = Some((-1, true));
input.environment = environment;
assert_eq!(
setup_fog(&tables, &FogState::default(), &input).environmental_end,
40.0
);
input.blindness = None;
input.fog_type = FogType::PowderSnow;
let fog = setup_fog(&tables, &FogState::default(), &input);
assert_eq!(fog.color, [1.0, 0.0, 0.0, 1.0]);
// Under water for the full 600 ticks: vanilla washes the fog out to a full channel; a
// brightening of 0 keeps the colour given.
let mut state = FogState::default();
for _ in 0..600 {
state.tick(true, false);
}
input.fog_type = FogType::Water;
input.water_fog_color = [0.1, 0.2, 0.4];
let washed = setup_fog(&tables, &state, &inputs_with(&input, Default::default())).color;
assert_eq!(washed[2], 1.0);
let kept = setup_fog(&tables, &state, &input).color;
assert_eq!(&kept[..3], &[0.1, 0.2, 0.4]);
}
fn inputs_with<'a>(
input: &FogInputs<'a>,
environment: horizon_polytone::colors::Environment,
) -> FogInputs<'a> {
FogInputs {
environment,
..*input
}
}
#[test]
fn fog_off_keeps_fluid_and_effect_fog() {
let sky = Sky {
fog_start: 10.0,
+1 -1
View File
@@ -86,8 +86,8 @@ pub mod options_fixes;
pub mod options_ui;
pub mod overlay;
pub mod pack_browser;
pub mod pack_dyes;
pub mod pack_options_ui;
pub mod pack_palette;
pub mod pack_screen;
pub mod pack_tags;
pub mod packs;
+4 -1
View File
@@ -766,7 +766,10 @@ fn stem_color(age: u8) -> u32 {
(a * 32) << 16 | (255 - a * 8) << 8 | (a * 4)
}
fn redstone_color(power: u8) -> u32 {
// RedstoneWireBlock.COLORS
// RedstoneWireBlock.COLORS, or a pack's.
if let Some(color) = crate::pack_palette::redstone(power) {
return color;
}
let f = f32::from(power) / 15.0;
let r = f * 0.6 + if f > 0.0 { 0.4 } else { 0.3 };
let g = (f * f * 0.7 - 0.5).clamp(0.0, 1.0);
@@ -1,6 +1,7 @@
//! Polytone's dye and sheep colours (`dye.<name>.diffuse|text`, `sheep.<name>`), for drawing only:
//! the pack recolours collars, tropical fish, banners, wool and sign text, while gameplay (crafting,
//! item data, the beacon) keeps vanilla's `DyeColor`. Lock-free: the draw paths read it per entity.
//! Polytone's dye, sheep and redstone colours (`dye.<name>.diffuse|text`, `sheep.<name>`,
//! `redstone.<power>`), for drawing only: the pack recolours collars, tropical fish, banners, wool,
//! sign text and redstone wire and dust, while gameplay (crafting, item data, the beacon) keeps
//! vanilla's `DyeColor`. Lock-free: the draw paths and the mesher's workers read it per face.
use std::sync::atomic::{AtomicU64, Ordering::Relaxed};
use crate::block_entity_render::DYES;
@@ -11,6 +12,7 @@ use horizon_polytone::colors::PackColors;
static DIFFUSE: [AtomicU64; 16] = [const { AtomicU64::new(0) }; 16];
static TEXT: [AtomicU64; 16] = [const { AtomicU64::new(0) }; 16];
static SHEEP: [AtomicU64; 16] = [const { AtomicU64::new(0) }; 16];
static REDSTONE: [AtomicU64; 16] = [const { AtomicU64::new(0) }; 16];
/// Takes the colours of the packs just loaded (vanilla's where they set none).
pub fn set(colors: &PackColors) {
@@ -24,6 +26,7 @@ pub fn set(colors: &PackColors) {
store(&DIFFUSE, i, colors.dye(name, "diffuse"));
store(&TEXT, i, colors.dye(name, "text"));
store(&SHEEP, i, colors.sheep(name));
store(&REDSTONE, i, colors.redstone(i as u8));
}
}
@@ -42,6 +45,11 @@ pub fn text(dye: usize) -> u32 {
read(&TEXT, dye).unwrap_or(DYE_TEXT[dye & 15].1)
}
/// A pack's `RedStoneWireBlock.COLORS` entry for this power (RGB), when it sets one.
pub fn redstone(power: u8) -> Option<u32> {
read(&REDSTONE, usize::from(power))
}
/// `ColorLerper.Type.SHEEP` wool colour: a pack's `sheep.<name>`, else white 0xE6E6E6 and the
/// others 75% of vanilla's dye (a pack's `dye.*.diffuse` doesn't reach wool).
pub fn sheep_wool(dye: u8) -> u32 {
@@ -69,7 +77,7 @@ mod tests {
std::fs::create_dir_all(&polytone).unwrap();
std::fs::write(
polytone.join("colors.json"),
r#"{"dye": {"cyan": {"diffuse": "00ff00", "text": "0000ff"}}, "sheep": {"pink": "2040ff"}}"#,
r#"{"dye": {"cyan": {"diffuse": "00ff00", "text": "0000ff"}}, "sheep": {"pink": "2040ff"}, "redstone": {"7": "0x123456"}}"#,
)
.unwrap();
set(&PackColors::read(&dir));
@@ -81,7 +89,9 @@ mod tests {
assert_eq!(sheep_wool(cyan as u8), 0x107575);
assert_eq!(diffuse(purple), 0x8932B8);
assert_eq!(text(purple), 0xA020F0);
assert_eq!((redstone(7), redstone(15)), (Some(0x123456), None));
set(&PackColors::default());
assert_eq!(redstone(7), None);
assert_eq!(diffuse(cyan), 0x169C9C);
assert_eq!(sheep_wool(pink as u8), 0xB6687F);
std::fs::remove_dir_all(&dir).ok();
@@ -168,6 +168,15 @@ impl Registry {
let mut modifiers = Vec::new();
let mut entity_modifiers = Vec::new();
let mut particle_modifiers = Vec::new();
// Custom colours' `particle.<id>`: a modifier that sets only the colour, below the files'.
for (id, color) in horizon_polytone::colors::PackColors::read(pack_root).particles() {
let json = serde_json::json!({"targets": [id], "color": color});
match ParticleModifier::parse(&json, None) {
Ok(Some(m)) => particle_modifiers.push((m, None, None)),
Ok(None) => {}
Err(e) => log::warn!("colors particle.{id}: {e}"),
}
}
let namespaces = std::fs::read_dir(pack_root.join("assets"))
.into_iter()
.flatten()
@@ -2104,6 +2113,23 @@ mod tests {
std::fs::remove_dir_all(root).unwrap();
}
#[test]
fn custom_colours_recolour_their_particle_type() {
let root = pack(&[(
"assets/obs/polytone/colors.json",
serde_json::json!({"particle": {"minecraft:flame": "0x0000ff"}}),
)]);
let registry = Registry::read(&root, Default::default(), blocks());
let colour = |kind: &str| {
let mut p = Particle::blank(ParticleType::Flame, [0.0; 3]);
modify(&registry, kind, None, &mut p);
p.rgba
};
assert_eq!(colour("minecraft:flame"), [0.0, 0.0, 1.0, 1.0]);
assert_eq!(colour("minecraft:smoke"), [1.0; 4]);
std::fs::remove_dir_all(root).unwrap();
}
#[test]
fn particle_modifiers_scale_power_and_recolour_their_particles_once() {
let root = pack(&[
+1 -1
View File
@@ -85,7 +85,7 @@ impl SignText {
}
/// `DyeColor.getTextColor` of this side.
pub fn text_color(&self) -> u32 {
crate::pack_dyes::text(usize::from(self.color.min(15)))
crate::pack_palette::text(usize::from(self.color.min(15)))
}
/// `SignText.hasAnyClickCommands`: a line whose style has a `run_command`
/// click event.
@@ -51,6 +51,7 @@ pub fn main() -> horizon_client::Result<()> {
.ok_or("missing cauldron")?,
)?;
let mut item = ItemEntity {
random: horizon_core::random::LegacyRandom::new(0),
rotation: [0.0; 2],
join: Default::default(),
health: 5,
@@ -73,6 +74,7 @@ pub fn main() -> horizon_client::Result<()> {
no_gravity: true,
vertical_collision: false,
horizontal_collision: false,
fall_distance: 0.0,
};
output.push_str(f[0]);
for _ in 0..2 {
+4 -1
View File
@@ -276,8 +276,11 @@ fn concrete_powder_color(dye: &str) -> u32 {
_ => 0x191919,
}
}
/// `RedstoneWireBlock.COLORS`.
/// `RedstoneWireBlock.COLORS`, or a pack's.
fn redstone_color(power: u8) -> [f32; 3] {
if let Some(c) = crate::pack_palette::redstone(power) {
return [16, 8, 0].map(|shift| ((c >> shift) & 255) as f32 / 255.0);
}
let f = f32::from(power) / 15.0;
let r = f * 0.6 + if f > 0.0 { 0.4 } else { 0.3 };
let g = (f * f * 0.7 - 0.5).clamp(0.0, 1.0);
+2 -2
View File
@@ -190,12 +190,12 @@ pub fn banner_color(item: &str) -> Option<&'static str> {
DYES.iter().map(|d| d.0).find(|d| *d == name)
}
/// `DyeColor.getTextureDiffuseColor` (RGB) by dye name, as the resource
/// packs draw it (`pack_dyes`): banner layers, banner and shield items and the
/// packs draw it (`pack_palette`): banner layers, banner and shield items and the
/// loom follow a pack's dye colours as the banner base does.
pub fn dye_rgb(name: &str) -> u32 {
DYES.iter()
.position(|d| d.0 == name)
.map_or(0xFFFFFF, crate::pack_dyes::diffuse)
.map_or(0xFFFFFF, crate::pack_palette::diffuse)
}
/// The layers of a banner or shield stack.
pub fn layers(stack: &ItemStack) -> &[BannerLayer] {
+2 -2
View File
@@ -19,8 +19,8 @@ pub use horizon_client_render::{
hdr_calibration, hud, hud_kit, hud_layout, hud_overlays, inventory_effects, java_text,
jigsaw_block, json_folder, level_loading, light, lod, logging, menu_blur, mesher, minimap,
mipmap, mod_browser, mod_list, model_library, models, multiline_edit, native_emissive,
options_file, options_fixes, options_ui, overlay, pack_browser, pack_dyes, pack_screen, packs,
pad_cursor, pad_glyph, par, particles, poi_sections, polytone_colors, polytone_post,
options_file, options_fixes, options_ui, overlay, pack_browser, pack_palette, pack_screen,
packs, pad_cursor, pad_glyph, par, particles, poi_sections, polytone_colors, polytone_post,
post_chain, potent_sulfur, preedit, providers, pvp, pvp_hud, random_source, screen_draw,
screen_effects, sculk, section_mask, server_terrain, settings, shader_options,
shader_pack_list, signs, skins, social, sound_engine, sound_events, sound_preview, spans,
+58 -85
View File
@@ -82,6 +82,7 @@ fn slime_at(world: &World, facts: &BlockFacts, position: BlockPos) -> bool {
}
mod fluids;
mod item_fall;
mod item_no_physics;
pub use fluids::{fluid_flow, update_entity_fluids, water_clip};
@@ -234,6 +235,12 @@ pub struct ItemEntity {
pub vertical_collision: bool,
/// `Entity.horizontalCollision`: the last move was stopped on x or z.
pub horizontal_collision: bool,
/// `Entity.random`, after the constructor's draws: `moveTowardsClosestSpace`
/// takes an item's escape speed from it.
pub random: horizon_core::random::LegacyRandom,
/// `Entity.fallDistance` (`fall_distance` in its save): what the item has
/// fallen since it last touched ground or water.
pub fall_distance: f64,
}
/// What `Entity.doWaterSplashEffect` asks the level to do for a dropped item.
/// Particles are drawn on the client (`Level.addParticle` is empty on the
@@ -365,6 +372,10 @@ pub struct ItemStep {
did_move: bool,
/// The contact block for a clipped vertical move; idle ticks have none.
pub vertical_contact: Option<BlockPos>,
/// `Entity.move` with physics: where it started, what it moved, and
/// whether the move was applied. `None` while idle or without physics,
/// where `checkFallDamage` does not run.
pub fall_move: Option<(Vec3, Vec3, bool)>,
}
#[cfg(test)]
impl ItemEntity {
@@ -384,6 +395,7 @@ impl ItemEntity {
) -> bool {
let (water, lava) = self.update_fluids(world, fluids);
self.water_entry(water > 0.0, self.ticks == 0, &mut |_| {});
self.lava_fall(lava > 0.0);
let step = self.tick_move(
world,
facts,
@@ -547,14 +559,17 @@ impl ItemEntity {
false,
)
}
/// `Entity.updateFluidInteraction`: entering water after the first tick
/// runs `doWaterSplashEffect`.
/// `Entity.updateFluidInteraction`: water resets the fall, and entering
/// it after the first tick runs `doWaterSplashEffect`.
pub fn water_entry<F: FnMut(ItemSplash)>(
&mut self,
in_water: bool,
first_tick: bool,
splash: &mut F,
) {
if in_water {
self.fall_distance = 0.0;
}
if in_water && !self.was_touching_water && !first_tick {
let mut random = horizon_core::random::LegacyRandom::new(
(self.id as i64)
@@ -684,6 +699,7 @@ impl ItemEntity {
binding: collision,
subject: None,
};
let from = self.position;
let mut wanted = self.velocity;
// Entity.move: a block the item sits in (cobweb, powder snow)
// scales this move once and stops it.
@@ -713,7 +729,8 @@ impl ItemEntity {
)
.unwrap_or(Vec3::ZERO)
};
if move_applies(wanted, moved) {
let applied = move_applies(wanted, moved);
if applied {
self.position = Vec3::new(
self.position.x + moved.x,
self.position.y + moved.y,
@@ -755,6 +772,7 @@ impl ItemEntity {
old_velocity,
did_move: true,
vertical_contact,
fall_move: (!no_physics).then_some((from, moved, applied)),
};
}
// applyEffectsFromBlocksForLastMovements: still lift/drag while idle.
@@ -765,6 +783,7 @@ impl ItemEntity {
old_velocity,
did_move: false,
vertical_contact: None,
fall_move: None,
}
}
@@ -828,7 +847,14 @@ impl ItemEntity {
horizon_world::entity_move::scale_by_block_speed(self.velocity, factor);
}
self.apply_honey_slide(world, facts);
self.stuck = self.stuck_multiplier(world, facts).unwrap_or(Vec3::ZERO);
self.stuck = match self.stuck_multiplier(world, facts) {
// Entity.makeStuckInBlock resets the fall.
Some(stuck) => {
self.fall_distance = 0.0;
stuck
}
None => Vec3::ZERO,
};
}
}
/// The rest of `ItemEntity.tick`, after the bubble column's push: slime,
@@ -2766,35 +2792,7 @@ impl ArmorStand {
}
#[cfg(test)]
mod experience_tests {
use super::*;
#[test]
fn death_reward_uses_levels_and_caps_the_recoverable_points() {
for (level, reward) in [(0, 0), (1, 7), (14, 98), (15, 100), (30, 100)] {
let xp = Experience {
level,
progress: 0.75,
total: 10_000,
};
assert_eq!(xp.death_reward(false), reward);
assert_eq!(xp.death_reward(true), 0);
}
}
#[test]
fn respawn_clears_all_xp_unless_it_is_kept() {
let mut xp = Experience::default();
xp.give(155);
let before = (xp.level, xp.progress, xp.total);
xp.respawn(true);
assert_eq!((xp.level, xp.progress, xp.total), before);
xp.respawn(false);
assert_eq!((xp.level, xp.progress, xp.total), (0, 0.0, 0));
xp.give(7);
assert_eq!((xp.level, xp.progress, xp.total), (1, 0.0, 7));
}
}
mod experience_tests;
#[cfg(test)]
mod fire_environment_tests {
@@ -2823,34 +2821,19 @@ mod fire_environment_tests {
assets.registry.default_state("minecraft:lava").unwrap(),
)
.unwrap();
let make = |id: &str| ItemEntity {
rotation: [0.0, 0.0],
join: Default::default(),
no_gravity: false,
id: 1,
thrower: None,
target: None,
stack: ItemStack {
item: std::sync::Arc::from(id),
count: 1,
damage: 0,
extra: None,
},
health: 5,
fire_ticks: 0,
position: at,
previous: at,
velocity: Vec3::ZERO,
on_ground: false,
vertical_collision: false,
horizontal_collision: false,
age: 0,
pickup_delay: 0,
bob_offset: 0.0,
ticks: 0,
needs_sync: false,
stuck: Vec3::ZERO,
was_touching_water: false,
let make = |id: &str| {
ItemEntity::new(
1,
ItemStack {
item: std::sync::Arc::from(id),
count: 1,
damage: 0,
extra: None,
},
at,
Vec3::ZERO,
0.0,
)
};
let mut wood = make("minecraft:oak_planks");
let mut metal = make("minecraft:netherite_ingot");
@@ -2958,30 +2941,7 @@ mod destroyed_bundle_environment_tests {
ItemStack::new("minecraft:stone", 3),
ItemStack::new("minecraft:diamond", 1),
];
let mut bundle = ItemEntity {
rotation: [0.0, 0.0],
join: Default::default(),
no_gravity: false,
id: 1,
thrower: None,
target: None,
stack,
health: 5,
fire_ticks: 0,
position: at,
previous: at,
velocity: Vec3::ZERO,
on_ground: false,
vertical_collision: false,
horizontal_collision: false,
age: 0,
pickup_delay: 0,
bob_offset: 0.0,
ticks: 0,
needs_sync: false,
stuck: Vec3::ZERO,
was_touching_water: false,
};
let mut bundle = ItemEntity::new(1, stack, at, Vec3::ZERO, 0.0);
// 5 health against 4 a tick: the first tick leaves 1 and nothing out.
let first = bundle.environment_tick(&world, &facts, assets.registry, &items);
assert!(first.alive && first.spilled.is_empty());
@@ -3165,6 +3125,7 @@ mod merge_tests {
fn item(id: &str, count: u32, x: f64, age: i32) -> ItemEntity {
let at = Vec3::new(x, 64.0, 0.5);
ItemEntity {
random: horizon_core::random::LegacyRandom::new(0),
rotation: [0.0, 0.0],
join: Default::default(),
no_gravity: false,
@@ -3185,6 +3146,7 @@ mod merge_tests {
on_ground: true,
vertical_collision: false,
horizontal_collision: false,
fall_distance: 0.0,
age,
pickup_delay: 0,
bob_offset: 0.0,
@@ -3684,6 +3646,7 @@ mod honey_slide_tests {
pub(super) fn falling_item(x: f64, y: f64, z: f64, vx: f64, vy: f64, vz: f64) -> ItemEntity {
let at = Vec3::new(x, y, z);
ItemEntity {
random: horizon_core::random::LegacyRandom::new(0),
rotation: [0.0, 0.0],
join: Default::default(),
no_gravity: false,
@@ -3704,6 +3667,7 @@ mod honey_slide_tests {
on_ground: false,
vertical_collision: false,
horizontal_collision: false,
fall_distance: 0.0,
age: 10,
pickup_delay: 0,
bob_offset: 0.0,
@@ -3835,6 +3799,7 @@ mod slime_bounce_tests {
fn grounded_item() -> ItemEntity {
let at = Vec3::new(0.5, 2.0, 0.5);
ItemEntity {
random: horizon_core::random::LegacyRandom::new(0),
rotation: [0.0, 0.0],
join: Default::default(),
no_gravity: false,
@@ -3855,6 +3820,7 @@ mod slime_bounce_tests {
on_ground: true,
vertical_collision: false,
horizontal_collision: false,
fall_distance: 0.0,
age: 10,
pickup_delay: 0,
bob_offset: 0.0,
@@ -3906,6 +3872,7 @@ mod slime_bounce_tests {
old_velocity: item.velocity,
did_move: true,
vertical_contact: None,
fall_move: None,
},
);
assert_eq!(item.velocity.x.to_bits(), 0x3fb9_1687_3333_3334);
@@ -4747,6 +4714,7 @@ mod item_destroyed_tests {
.map(|(id, count)| ItemStack::new(id, *count))
.collect();
ItemEntity {
random: horizon_core::random::LegacyRandom::new(0),
rotation: [0.0, 0.0],
join: Default::default(),
no_gravity: false,
@@ -4762,6 +4730,7 @@ mod item_destroyed_tests {
on_ground: false,
vertical_collision: false,
horizontal_collision: false,
fall_distance: 0.0,
age: 0,
pickup_delay: 0,
bob_offset: 0.0,
@@ -4982,6 +4951,7 @@ mod dropped_item_light_tests {
fn item(previous_y: f64, y: f64) -> ItemEntity {
ItemEntity {
random: horizon_core::random::LegacyRandom::new(0),
rotation: [0.0, 0.0],
join: Default::default(),
no_gravity: false,
@@ -5002,6 +4972,7 @@ mod dropped_item_light_tests {
on_ground: false,
vertical_collision: false,
horizontal_collision: false,
fall_distance: 0.0,
age: 0,
pickup_delay: 0,
bob_offset: 0.0,
@@ -5044,6 +5015,7 @@ mod dropped_item_light_tests {
assert_eq!(falling.light_probe(1.0)[1].floor(), 65.0);
// Across x and z too, not just down.
let sliding = ItemEntity {
random: horizon_core::random::LegacyRandom::new(0),
position: Vec3::new(1.5, 64.0, 0.5),
previous: Vec3::new(0.5, 64.0, 0.5),
..item(64.0, 64.0)
@@ -5061,6 +5033,7 @@ mod dropped_item_light_tests {
assert_eq!(cold.render_light(light(15, 0)), (15 * 16) as u32);
assert_eq!(cold.render_light(light(0, 9)), ((9 * 16) << 8) as u32);
let burning = ItemEntity {
random: horizon_core::random::LegacyRandom::new(0),
fire_ticks: 160,
..item(64.0, 64.0)
};
@@ -0,0 +1,27 @@
use super::*;
#[test]
fn death_reward_uses_levels_and_caps_the_recoverable_points() {
for (level, reward) in [(0, 0), (1, 7), (14, 98), (15, 100), (30, 100)] {
let xp = Experience {
level,
progress: 0.75,
total: 10_000,
};
assert_eq!(xp.death_reward(false), reward);
assert_eq!(xp.death_reward(true), 0);
}
}
#[test]
fn respawn_clears_all_xp_unless_it_is_kept() {
let mut xp = Experience::default();
xp.give(155);
let before = (xp.level, xp.progress, xp.total);
xp.respawn(true);
assert_eq!((xp.level, xp.progress, xp.total), before);
xp.respawn(false);
assert_eq!((xp.level, xp.progress, xp.total), (0, 0.0, 0));
xp.give(7);
assert_eq!((xp.level, xp.progress, xp.total), (1, 0.0, 7));
}
@@ -338,6 +338,7 @@ mod tests {
}
let at = Vec3::new(20.5, 4.0, 20.5);
let item = ItemEntity {
random: horizon_core::random::LegacyRandom::new(0),
rotation: [0.0, 0.0],
join: Default::default(),
no_gravity: false,
@@ -358,6 +359,7 @@ mod tests {
on_ground: false,
vertical_collision: false,
horizontal_collision: false,
fall_distance: 0.0,
age: 0,
pickup_delay: 0,
bob_offset: 0.0,
@@ -0,0 +1,62 @@
//! A dropped item's construction and its fall distance (`Entity.fallDistance`).
use super::{ItemEntity, ItemStep};
use crate::items::ItemStack;
use horizon_core::geometry::Vec3;
impl ItemEntity {
/// A fresh `ItemEntity` at `position` moving at `velocity`: full health,
/// no age, delay or owner, not yet ticked.
pub fn new(id: u32, stack: ItemStack, position: Vec3, velocity: Vec3, bob_offset: f32) -> Self {
Self {
rotation: [0.0, 0.0],
join: Default::default(),
no_gravity: false,
health: 5,
fire_ticks: 0,
thrower: None,
target: None,
id,
stack,
position,
previous: position,
velocity,
on_ground: false,
vertical_collision: false,
horizontal_collision: false,
fall_distance: 0.0,
age: 0,
pickup_delay: 0,
bob_offset,
ticks: 0,
needs_sync: false,
stuck: Vec3::ZERO,
was_touching_water: false,
// The caller gives it the own random that drew its UUID and bobOffs.
random: horizon_core::random::LegacyRandom::new(0),
}
}
/// `Entity.move`'s fall bookkeeping after the item's move: `reset_clip`
/// is the cast of a move of a block or more over fall-resetting blocks
/// and water (the fall it leaves), then `checkFallDamage` adds the drop,
/// narrowed to a float, outside water and ends the fall on the ground.
pub fn check_fall(&mut self, step: &ItemStep, reset_clip: impl FnOnce(f64, Vec3, Vec3) -> f64) {
let Some((from, moved, applied)) = step.fall_move else {
return;
};
if applied {
self.fall_distance = reset_clip(self.fall_distance, from, moved);
}
if !self.was_touching_water && moved.y < 0.0 {
self.fall_distance -= f64::from(moved.y as f32);
}
if self.on_ground {
self.fall_distance = 0.0;
}
}
/// `Entity.baseTick`: lava halves the fall each tick.
pub fn lava_fall(&mut self, in_lava: bool) {
if in_lava {
self.fall_distance *= 0.5;
}
}
}
+44 -156
View File
@@ -22,6 +22,8 @@ use horizon_world::{
registry::{BlockRegistry, BlockStateId},
};
use std::{collections::HashMap, sync::Arc};
mod projectile_fields;
use projectile_fields::{load_projectile_entity_fields, put_projectile_entity_fields};
mod saved_attributes;
/// Saved data carried by `ConversionType.convertCommon` outside the modelled
@@ -1362,14 +1364,7 @@ pub fn mob_to_nbt(
map.insert("StunTick".into(), Tag::Int(m.extra.counter2));
map.insert("RoarTick".into(), Tag::Int(m.extra.anim_ticks));
}
"minecraft:vex" => {
if let Some(h) = m.extra.home {
map.insert("bound_pos".into(), Tag::IntArray(h.to_vec()));
}
if m.extra.egg_time > 0 {
map.insert("life_ticks".into(), Tag::Int(m.extra.egg_time));
}
}
"minecraft:vex" => crate::mobs::vex::write_nbt(m, map, &mut uuid_of),
"minecraft:endermite" => {
map.insert("Lifetime".into(), Tag::Int(m.extra.counter));
}
@@ -1562,11 +1557,9 @@ pub fn mob_to_nbt(
.iter_mut()
.find(|t| text(t.get("id")).as_deref() == Some(attr_id.as_str()))
{
// The model owns these bases: a ravager's speed, a wolf's health.
if matches!(
(species, attr),
(S::Ravager, Attr::MovementSpeed) | (S::Wolf, Attr::MaxHealth)
) && let Tag::Compound(c) = existing
// The model owns a wolf's health base.
if (species, attr) == (S::Wolf, Attr::MaxHealth)
&& let Tag::Compound(c) = existing
{
c.insert("base".into(), Tag::Double(base.to_bits()));
}
@@ -2642,22 +2635,12 @@ pub fn mob_from_nbt_with_random(
m.extra.counter = number(tag.get("AttackTick")).unwrap_or(0) as i32;
m.extra.counter2 = number(tag.get("StunTick")).unwrap_or(0) as i32;
m.extra.anim_ticks = number(tag.get("RoarTick")).unwrap_or(0) as i32;
if crate::mobs::ravager::immobile(&m)
&& !matches!(tag.get("attributes"), Some(Tag::List(l)) if l.iter().any(|a| text(a.get("id")).as_deref() == Some("minecraft:movement_speed")))
{
m.attrs.speed = 0.0;
}
}
if m.species() == crate::mobs::Species::Endermite {
m.extra.counter = number(tag.get("Lifetime")).unwrap_or(0) as i32;
}
if m.species() == crate::mobs::Species::Vex {
if let Some(Tag::IntArray(v)) = tag.get("bound_pos")
&& v.len() == 3
{
m.extra.home = Some([v[0], v[1], v[2]]);
}
m.extra.egg_time = number(tag.get("life_ticks")).map_or(-1, |v| v as i32);
crate::mobs::vex::read_nbt(&mut m, tag);
}
// Bogged.readAdditionalSaveData: unsheared when absent.
if m.species() == crate::mobs::Species::Bogged {
@@ -2822,7 +2805,7 @@ fn item_entity_to_nbt_saved(e: &ItemEntity, uuid: [i32; 4], raw: Option<&Tag>, i
motion: e.velocity,
rotation: e.rotation,
on_ground: e.on_ground,
fall_distance: 0.0,
fall_distance: e.fall_distance,
};
let mut tag = base.non_mob_tag(raw);
for (key, value) in [
@@ -2998,6 +2981,8 @@ pub fn item_entity_from_nbt(tag: &Tag, id: u32, bob_offset: f32) -> Option<ItemE
on_ground: flag(tag, "OnGround"),
vertical_collision: false,
horizontal_collision: false,
// Entity.load: getDoubleOr, any number widened.
fall_distance: float(tag.get("fall_distance")).unwrap_or(0.0),
age: number(tag.get("Age")).unwrap_or(0) as i32,
pickup_delay: number(tag.get("PickupDelay")).unwrap_or(0),
bob_offset,
@@ -3005,6 +2990,8 @@ pub fn item_entity_from_nbt(tag: &Tag, id: u32, bob_offset: f32) -> Option<ItemE
needs_sync: false,
stuck: Vec3::ZERO,
was_touching_water: false,
// The caller gives it the own random that drew its UUID and bobOffs.
random: horizon_core::random::LegacyRandom::new(0),
})
}
@@ -3285,7 +3272,7 @@ pub fn fireball_to_nbt(
motion: p.velocity,
rotation: [p.yaw, p.pitch],
on_ground: p.on_ground,
fall_distance: 0.0,
fall_distance: p.fall_distance,
};
let mut tag = base.tag(raw);
put(
@@ -3312,27 +3299,6 @@ pub fn fireball_to_nbt(
Some(tag)
}
/// `Entity.load`: fire is read as a short, air as an int and the visual
/// fire flag as a byte-valued boolean. Portal state lives on the level.
fn load_projectile_entity_fields(p: &mut Projectile, tag: &Tag) {
p.fire_ticks = i32::from(tag.get("Fire").and_then(int_value).unwrap_or(0) as i16);
p.air_supply = tag.get("Air").and_then(int_value).unwrap_or(p.air_supply);
p.has_visual_fire = flag(tag, "HasVisualFire");
}
/// `Entity.saveWithoutId`: save the current fire and air as shorts, and
/// only write the optional visual fire flag when it is true.
fn put_projectile_entity_fields(tag: &mut Tag, p: &Projectile) {
put(tag, "Fire", Tag::Short(p.fire_ticks as i16));
put(tag, "Air", Tag::Short(p.air_supply as i16));
if let Tag::Compound(map) = tag {
map.remove(&"HasVisualFire".into());
}
if p.has_visual_fire {
put(tag, "HasVisualFire", Tag::Byte(1));
}
}
/// `Projectile.addAdditionalSaveData`: Owner, LeftOwner only when set, and
/// the explicit HasBeenShot flag.
fn put_projectile(tag: &mut Tag, p: &Projectile, owner: Option<[i32; 4]>) {
@@ -3436,7 +3402,7 @@ pub fn throwable_to_nbt(
motion: p.velocity,
rotation: [p.yaw, p.pitch],
on_ground: p.on_ground,
fall_distance: 0.0,
fall_distance: p.fall_distance,
};
let mut tag = base.tag(raw);
put_projectile(&mut tag, p, owner);
@@ -3622,6 +3588,7 @@ pub fn fireball_from_nbt(tag: &Tag, player: Option<[i32; 4]>) -> Option<Projecti
load_projectile_entity_fields(&mut p, tag);
}
p.on_ground = flag(tag, "OnGround");
p.fall_distance = float(tag.get("fall_distance")).unwrap_or(0.0);
p.acceleration = float(tag.get("acceleration_power")).unwrap_or(0.1);
if let Some(item) = tag.get("Item").and_then(item_from_nbt) {
p.set_item(item);
@@ -4541,74 +4508,7 @@ mod tnt_power_tests {
}
#[cfg(test)]
mod spell_ticks_nbt_tests {
use super::*;
use crate::mobs::Species;
fn caster(species: Species, name: &str) -> Mob {
let mut kind = crate::mobs::sensor_scan_tests::kind();
let ty = Arc::make_mut(&mut kind);
ty.species = species;
ty.id = format!("minecraft:{name}");
Mob::new(1, kind, Vec3::new(0.5, 64.0, 0.5), 0.0)
}
/// `SpellcasterIllager.readAdditionalSaveData`: bind loaded values to the
/// counter already used by casting goals and customServerAiStep.
#[test]
fn spell_ticks_load_into_the_live_counter() {
for (species, name) in [
(Species::Evoker, "evoker"),
(Species::Illusioner, "illusioner"),
] {
let m = caster(species, name);
let types = HashMap::from([(m.kind.id.clone(), m.kind.clone())]);
for value in [None, Some(30), Some(0), Some(-2)] {
let mut raw = Tag::compound([
("id", string(&m.kind.id)),
("Pos", doubles(&[0.5, 64.0, 0.5])),
]);
if let Some(value) = value {
put(&mut raw, "SpellTicks", Tag::Int(value));
}
let loaded = mob_from_nbt(&raw, &types, 2, None).unwrap();
assert_eq!(
loaded.extra.counter,
value.unwrap_or(0),
"{name}: {value:?}"
);
let saved = mob_to_nbt(&loaded, [1, 2, 3, 4], None, None, |_| None);
assert_eq!(saved.get("SpellTicks"), Some(&Tag::Int(value.unwrap_or(0))));
}
}
}
/// `SpellcasterIllager.addAdditionalSaveData`: live state wins even when
/// no raw input exists or the original raw counter has become stale.
#[test]
fn spell_ticks_save_the_mutated_model_without_a_raw_fallback() {
for (species, name) in [
(Species::Evoker, "evoker"),
(Species::Illusioner, "illusioner"),
] {
let mut m = caster(species, name);
let raw = Tag::compound([
("SpellTicks", Tag::Int(30)),
("audit:unrelated", Tag::Int(7)),
]);
for value in [29, 27, 20, 0, -2] {
m.extra.counter = value;
for raw in [None, Some(&raw)] {
let saved = mob_to_nbt(&m, [1, 2, 3, 4], raw, None, |_| None);
assert_eq!(saved.get("SpellTicks"), Some(&Tag::Int(value)), "{name}");
if raw.is_some() {
assert_eq!(saved.get("audit:unrelated"), Some(&Tag::Int(7)));
}
}
}
}
}
}
mod spell_ticks_nbt_tests;
#[cfg(test)]
mod tests {
@@ -5721,33 +5621,25 @@ mod tests {
#[test]
fn item_entities_round_trip() {
let e = ItemEntity {
rotation: [0.0, 0.0],
join: Default::default(),
no_gravity: false,
health: 2,
fire_ticks: 73,
thrower: Some([5, 6, 7, 8]),
target: None,
id: 7,
stack: ItemStack {
item: Arc::from("minecraft:diamond_pickaxe"),
count: 1,
damage: 5,
extra: None,
},
position: Vec3::new(100.5, 64.0, -31.5),
previous: Vec3::ZERO,
velocity: Vec3::new(0.0, -0.04, 0.0),
on_ground: true,
vertical_collision: false,
horizontal_collision: false,
age: 120,
pickup_delay: 10,
ticks: 0,
bob_offset: 0.0,
needs_sync: false,
stuck: Vec3::ZERO,
was_touching_water: false,
..ItemEntity::new(
7,
ItemStack {
item: Arc::from("minecraft:diamond_pickaxe"),
count: 1,
damage: 5,
extra: None,
},
Vec3::new(100.5, 64.0, -31.5),
Vec3::new(0.0, -0.04, 0.0),
0.0,
)
};
let tag = item_entity_to_nbt(&e, [1, 2, 3, 4], None);
assert_eq!(kind(&tag).as_deref(), Some("minecraft:item"));
@@ -10511,25 +10403,21 @@ mod equine_temper_preservation_tests {
);
Arc::make_mut(&mut kind).species = Species::Ravager;
let types = [("minecraft:ravager".into(), kind)].into();
let mut ravager = mob_from_nbt(
&Tag::compound([
("id", string("minecraft:ravager")),
("Pos", doubles(&[0.0, 0.0, 0.0])),
("RoarTick", Tag::Int(10)),
]),
&types,
2,
None,
)
.unwrap();
ravager.age = 1;
let saved = mob_to_nbt(&ravager, [0; 4], None, None, |_| None);
assert_eq!(
saved.get("attributes"),
Some(&Tag::List(vec![Tag::compound([
("id", string("minecraft:movement_speed")),
("base", Tag::Double(0.0f64.to_bits()))
])]))
);
// Before its first aiStep a roaring ravager has no movement_speed
// instance; a saved base (mid-ramp after a roar) loads back as is.
let speed = Tag::List(vec![Tag::compound([
("id", string("minecraft:movement_speed")),
("base", Tag::Double(0.10317f64.to_bits())),
])]);
for (key, value, saved) in [
("RoarTick", Tag::Int(10), Tag::List(vec![])),
("attributes", speed.clone(), speed),
] {
let pos = ("Pos", doubles(&[0.0, 0.0, 0.0]));
let tag = Tag::compound([("id", string("minecraft:ravager")), pos, (key, value)]);
let ravager = mob_from_nbt(&tag, &types, 2, None).unwrap();
let nbt = mob_to_nbt(&ravager, [0; 4], None, None, |_| None);
assert_eq!(nbt.get("attributes"), Some(&saved));
}
}
}
@@ -0,0 +1,28 @@
//! The `Entity` fields a projectile keeps beside `Projectile`'s own: fire,
//! air, the visual fire flag and the fall distance.
use super::{flag, float, int_value, put};
use crate::projectiles::Projectile;
use horizon_world::nbt::Tag;
/// `Entity.load`: fire is read as a short, air as an int and the visual
/// fire flag as a byte-valued boolean. Portal state lives on the level.
pub(super) fn load_projectile_entity_fields(p: &mut Projectile, tag: &Tag) {
p.fire_ticks = i32::from(tag.get("Fire").and_then(int_value).unwrap_or(0) as i16);
p.air_supply = tag.get("Air").and_then(int_value).unwrap_or(p.air_supply);
p.has_visual_fire = flag(tag, "HasVisualFire");
// Entity.load: getDoubleOr, any number widened.
p.fall_distance = float(tag.get("fall_distance")).unwrap_or(0.0);
}
/// `Entity.saveWithoutId`: save the current fire and air as shorts, and
/// only write the optional visual fire flag when it is true.
pub(super) fn put_projectile_entity_fields(tag: &mut Tag, p: &Projectile) {
put(tag, "Fire", Tag::Short(p.fire_ticks as i16));
put(tag, "Air", Tag::Short(p.air_supply as i16));
if let Tag::Compound(map) = tag {
map.remove(&"HasVisualFire".into());
}
if p.has_visual_fire {
put(tag, "HasVisualFire", Tag::Byte(1));
}
}
@@ -0,0 +1,66 @@
use super::*;
use crate::mobs::Species;
fn caster(species: Species, name: &str) -> Mob {
let mut kind = crate::mobs::sensor_scan_tests::kind();
let ty = Arc::make_mut(&mut kind);
ty.species = species;
ty.id = format!("minecraft:{name}");
Mob::new(1, kind, Vec3::new(0.5, 64.0, 0.5), 0.0)
}
/// `SpellcasterIllager.readAdditionalSaveData`: bind loaded values to the
/// counter already used by casting goals and customServerAiStep.
#[test]
fn spell_ticks_load_into_the_live_counter() {
for (species, name) in [
(Species::Evoker, "evoker"),
(Species::Illusioner, "illusioner"),
] {
let m = caster(species, name);
let types = HashMap::from([(m.kind.id.clone(), m.kind.clone())]);
for value in [None, Some(30), Some(0), Some(-2)] {
let mut raw = Tag::compound([
("id", string(&m.kind.id)),
("Pos", doubles(&[0.5, 64.0, 0.5])),
]);
if let Some(value) = value {
put(&mut raw, "SpellTicks", Tag::Int(value));
}
let loaded = mob_from_nbt(&raw, &types, 2, None).unwrap();
assert_eq!(
loaded.extra.counter,
value.unwrap_or(0),
"{name}: {value:?}"
);
let saved = mob_to_nbt(&loaded, [1, 2, 3, 4], None, None, |_| None);
assert_eq!(saved.get("SpellTicks"), Some(&Tag::Int(value.unwrap_or(0))));
}
}
}
/// `SpellcasterIllager.addAdditionalSaveData`: live state wins even when
/// no raw input exists or the original raw counter has become stale.
#[test]
fn spell_ticks_save_the_mutated_model_without_a_raw_fallback() {
for (species, name) in [
(Species::Evoker, "evoker"),
(Species::Illusioner, "illusioner"),
] {
let mut m = caster(species, name);
let raw = Tag::compound([
("SpellTicks", Tag::Int(30)),
("audit:unrelated", Tag::Int(7)),
]);
for value in [29, 27, 20, 0, -2] {
m.extra.counter = value;
for raw in [None, Some(&raw)] {
let saved = mob_to_nbt(&m, [1, 2, 3, 4], raw, None, |_| None);
assert_eq!(saved.get("SpellTicks"), Some(&Tag::Int(value)), "{name}");
if raw.is_some() {
assert_eq!(saved.get("audit:unrelated"), Some(&Tag::Int(7)));
}
}
}
}
}
+13 -36
View File
@@ -3893,8 +3893,7 @@ impl Game<'_> {
velocity: Option<[f64; 3]>,
pickup_delay: u32,
) -> [f64; 3] {
let item = stack.item.clone();
let count = stack.count as i32;
let (item, count) = (stack.item.clone(), stack.count as i32);
self.next_entity += 1;
let p = Vec3::new(position[0], position[1], position[2]);
// The item's own random, in vanilla's order: Entity's UUID, ItemEntity's
@@ -3910,27 +3909,15 @@ impl Game<'_> {
});
self.dropped.push(ItemEntity {
rotation: [yaw, 0.0],
join: Default::default(),
no_gravity: false,
health: 5,
fire_ticks: 0,
thrower: None,
target: None,
id: self.next_entity,
stack,
position: p,
previous: p,
velocity: Vec3::new(velocity[0], velocity[1], velocity[2]),
on_ground: false,
vertical_collision: false,
horizontal_collision: false,
age: 0,
pickup_delay: i64::from(pickup_delay),
bob_offset: bob,
ticks: 0,
needs_sync: false,
stuck: Vec3::ZERO,
was_touching_water: false,
random: own,
..ItemEntity::new(
self.next_entity,
stack,
p,
Vec3::new(velocity[0], velocity[1], velocity[2]),
bob,
)
});
// ServerLevel.addFreshEntity.
if self.native_joins_flush(false, self.next_entity) {
@@ -5583,20 +5570,10 @@ impl Game<'_> {
}
self.trigger_bee_nest_destroyed(broken, held.as_ref(), bees);
}
// InfestedBlock.spawnAfterBreak: its silverfish comes out unless the
// tool has silk touch (#prevents_infested_spawns) or blocks don't drop.
if facts.name.starts_with("minecraft:infested_")
&& !silk
&& self.ui.rules.bool("block_drops")
{
let at = [
f64::from(pos[0]) + 0.5,
f64::from(pos[1]),
f64::from(pos[2]) + 0.5,
];
if let Some(id) = self.spawn_mob_raw("minecraft:silverfish", at, false) {
self.mob_poof(id);
}
// InfestedBlock.spawnAfterBreak, unless the tool has silk touch
// (#prevents_infested_spawns).
if facts.name.starts_with("minecraft:infested_") && !silk {
self.spawn_infestation(pos);
}
// Block.tryDropExperience samples before Silk Touch removes the XP.
// SpawnerBlock.spawnAfterBreak ignores Silk Touch. Both sample even
+1 -1
View File
@@ -677,7 +677,7 @@ impl Game<'_> {
0.0
}
}
/// `Entity.move`'s fall reset for a vehicle that keeps a fall and moves on
/// `Entity.move`'s fall reset for an entity that keeps a fall and moves on
/// by `moved` from `from`: a move of a block or more casts up to 8 blocks
/// of it (`ClipContext.Block.FALLDAMAGE_RESETTING`, `ClipContext.Fluid.WATER`)
/// and a hit resets the fall, before the move's own drop counts.
@@ -7,8 +7,15 @@ use std::sync::{Arc, Mutex};
/// Exercise the actual Game keyboard/mouse handlers and generated mod dispatch.
#[test]
#[ignore = "needs prepared resources and a fresh HORIZON_HOME; run in isolation"]
#[ignore = "needs prepared resources and a fresh HORIZON_HOME"]
fn chat_completion_reaches_mods_from_keyboard_and_mouse() {
crate::mod_api::in_own_process(
"game::chat::suggestion_completion_tests::chat_completion_reaches_mods_from_keyboard_and_mouse",
chat_completion_reaches_mods_from_keyboard_and_mouse_in_own_process,
);
}
fn chat_completion_reaches_mods_from_keyboard_and_mouse_in_own_process() {
let root = PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES").unwrap());
let scratch = PathBuf::from(std::env::var_os("HORIZON_HOME").unwrap()).join("suggestion-game");
std::fs::create_dir_all(&scratch).unwrap();
@@ -471,9 +471,10 @@ impl Game<'_> {
Target::Item(id) => {
let new = entity_nbt::item_entity_from_nbt(&tag, id, 0.0).ok_or_else(stale)?;
if let Some(slot) = self.dropped.iter_mut().find(|e| e.id == id) {
let bob = slot.bob_offset;
*slot = new;
slot.bob_offset = bob;
// `/data` loads into the same entity: its bobOffs and own random stay.
let kept = std::mem::replace(slot, new);
slot.bob_offset = kept.bob_offset;
slot.random = kept.random;
}
}
Target::Fangs(id) => {
@@ -13,6 +13,7 @@ use std::sync::Arc;
fn item(id: u32, at: Vec3) -> ItemEntity {
ItemEntity {
random: horizon_core::random::LegacyRandom::new(0),
rotation: [0.0, 0.0],
join: Default::default(),
no_gravity: true,
@@ -33,6 +34,7 @@ fn item(id: u32, at: Vec3) -> ItemEntity {
on_ground: false,
vertical_collision: false,
horizontal_collision: false,
fall_distance: 0.0,
age: 97,
pickup_delay: 0,
bob_offset: 0.0,
+4
View File
@@ -592,6 +592,7 @@ impl Game<'_> {
let heights = item.push_by_fluid(&self.world, &self.res.motion().fluids);
// Entity.baseTick splashes before moving, except on the first tick.
item.water_entry(heights.0 > 0.0, item.ticks == 0, splash);
item.lava_fall(heights.1 > 0.0);
let step = item.tick_move_before_block_effects(
&self.world,
self.res.facts(),
@@ -603,6 +604,9 @@ impl Game<'_> {
Some(self.border.geometry()),
Some(&self.res.motion().blocks),
);
item.check_fall(&step, |fall, from, moved| {
self.vehicle_fall_reset(fall, from, moved)
});
if let Some(pos) = step.vertical_contact {
let state = self.world.block(pos.x, pos.y, pos.z);
let position = item.position;
+173 -28
View File
@@ -303,6 +303,43 @@ impl Game<'_> {
}
hits as f32 / count as f32
}
/// What a ray's step through the block at `pos` takes from its power
/// (`ExplosionDamageCalculator.getBlockExplosionResistance`), and whether
/// the block is spared.
fn blast_step<S: std::hash::BuildHasher>(
&self,
pos: [i32; 3],
resists: Option<([i32; 3], f32)>,
cap: Option<f32>,
wither_immune: Option<&std::collections::HashSet<String, S>>,
) -> (f32, bool) {
let state = self.world.block(pos[0], pos[1], pos[2]);
let f = self.res.facts().get(state);
let fluid = if f.fluid != Fluid::None {
100.0f32
} else {
0.0
};
// A primed TNT minecart's rails and the blocks they
// stand on: no resistance, and never blown up.
let spared = self.combat.rail_proof_blast
&& (self.rail(pos[0], pos[1], pos[2]).is_some()
|| self.rail(pos[0], pos[1] + 1, pos[2]).is_some());
let taken = match resists {
Some((at, resistance)) if at == pos => (resistance + 0.3) * 0.3,
_ if !self.res.facts().is_air(state) || f.fluid != Fluid::None => {
let base = if spared {
0.0
} else {
f.explosion_resistance.max(fluid)
};
let immune = wither_immune.is_some_and(|t| t.contains(&*f.name));
(blast_resistance(base, cap, immune) + 0.3) * 0.3
}
_ => 0.0,
};
(taken, spared)
}
/// `ServerExplosion.calculateExplodedPositions`; `resists` overrides one
/// block's resistance (`ExplosionDamageCalculator.getBlockExplosionResistance`).
fn exploded_positions(
@@ -341,6 +378,11 @@ impl Game<'_> {
zd /= d;
let mut power = radius * (0.7 + self.world.random.next_float() * 0.6);
let (mut xp, mut yp, mut zp) = (center[0], center[1], center[2]);
// A ray's steps are 0.3 long, about three to a block: a
// step in the block the last one was in takes what that
// one took (the level does not change while rays cast),
// and that block is in `set` already if it is to be.
let mut last: Option<([i32; 3], f32, bool)> = None;
while power > 0.0 {
let pos = [xp.floor() as i32, yp.floor() as i32, zp.floor() as i32];
if pos[1] < min_y
@@ -350,35 +392,17 @@ impl Game<'_> {
{
break;
}
let state = self.world.block(pos[0], pos[1], pos[2]);
let f = self.res.facts().get(state);
let fluid = if f.fluid != Fluid::None {
100.0f32
} else {
0.0
let (taken, spared, again) = match last {
Some((at, taken, spared)) if at == pos => (taken, spared, true),
_ => {
let (taken, spared) =
self.blast_step(pos, resists, cap, wither_immune.as_ref());
last = Some((pos, taken, spared));
(taken, spared, false)
}
};
// A primed TNT minecart's rails and the blocks they
// stand on: no resistance, and never blown up.
let spared = self.combat.rail_proof_blast
&& (self.rail(pos[0], pos[1], pos[2]).is_some()
|| self.rail(pos[0], pos[1] + 1, pos[2]).is_some());
match resists {
Some((at, resistance)) if at == pos => {
power -= (resistance + 0.3) * 0.3;
}
_ if !self.res.facts().is_air(state) || f.fluid != Fluid::None => {
let base = if spared {
0.0
} else {
f.explosion_resistance.max(fluid)
};
let immune =
wither_immune.as_ref().is_some_and(|t| t.contains(&*f.name));
power -= (blast_resistance(base, cap, immune) + 0.3) * 0.3;
}
_ => {}
}
if power > 0.0 && !spared {
power -= taken;
if power > 0.0 && !spared && !again {
set.put(Key(pos), ());
}
xp += xd * f64::from(0.3f32);
@@ -1047,6 +1071,22 @@ impl Game<'_> {
self.lan_explode(center, small, radius, block_count, pushed);
Ok(())
}
/// `InfestedBlock.spawnAfterBreak` without a tool that prevents it: while
/// blocks drop, its silverfish (`spawnInfestation`) comes out at the
/// bottom centre of the block, with its spawn animation.
pub(super) fn spawn_infestation(&mut self, pos: [i32; 3]) {
if !self.ui.rules.bool("block_drops") {
return;
}
let at = [
f64::from(pos[0]) + 0.5,
f64::from(pos[1]),
f64::from(pos[2]) + 0.5,
];
if let Some(id) = self.spawn_mob_raw("minecraft:silverfish", at, false) {
self.mob_poof(id);
}
}
/// `BlockBehaviour.onExplosionHit` (TNT: `wasExploded` primes a short fuse).
fn explosion_hit_block(
&mut self,
@@ -1091,6 +1131,10 @@ impl Game<'_> {
} else if let Some(tag) = super::spill::saved_entity(&self.world, pos) {
self.pending.record_saved(pos, &tag);
}
// Block.onExplosionHit runs spawnAfterBreak before the drops.
if !is_tnt && name.starts_with("minecraft:infested_") {
self.spawn_infestation(pos);
}
if !is_tnt {
let dynamic = self.pending.dynamic(pos);
// LootContextParams.EXPLOSION_RADIUS under DESTROY_WITH_DECAY: the
@@ -1260,6 +1304,107 @@ fn blast_resistance(base: f32, cap: Option<f32>, wither_immune: bool) -> f32 {
mod tests {
use super::*;
/// `exploded_positions` before rays reused their last block's lookup:
/// every step looked its block up.
fn exploded_positions_stepwise(
game: &mut Game<'_>,
center: [f64; 3],
radius: f32,
resists: Option<([i32; 3], f32)>,
) -> Vec<[i32; 3]> {
let (min_y, max_y) = game.with_level(|_, level, _| {
use horizon_world::level::LevelAccess;
(level.min_y(), level.max_y())
});
let mut set: JavaHashMap<Key, ()> = JavaHashMap::default();
for xx in 0..16 {
for yy in 0..16 {
for zz in 0..16 {
if !(xx == 0 || xx == 15 || yy == 0 || yy == 15 || zz == 0 || zz == 15) {
continue;
}
let mut xd = f64::from(xx as f32 / 15.0 * 2.0 - 1.0);
let mut yd = f64::from(yy as f32 / 15.0 * 2.0 - 1.0);
let mut zd = f64::from(zz as f32 / 15.0 * 2.0 - 1.0);
let d = (xd * xd + yd * yd + zd * zd).sqrt();
(xd, yd, zd) = (xd / d, yd / d, zd / d);
let mut power = radius * (0.7 + game.world.random.next_float() * 0.6);
let (mut xp, mut yp, mut zp) = (center[0], center[1], center[2]);
while power > 0.0 {
let pos = [xp.floor() as i32, yp.floor() as i32, zp.floor() as i32];
if pos[1] < min_y || pos[1] >= max_y {
break;
}
let state = game.world.block(pos[0], pos[1], pos[2]);
let f = game.res.facts().get(state);
let fluid = if f.fluid != Fluid::None {
100.0f32
} else {
0.0
};
match resists {
Some((at, resistance)) if at == pos => {
power -= (resistance + 0.3) * 0.3;
}
_ if !game.res.facts().is_air(state) || f.fluid != Fluid::None => {
let base = f.explosion_resistance.max(fluid);
power -= (blast_resistance(base, None, false) + 0.3) * 0.3;
}
_ => {}
}
if power > 0.0 {
set.put(Key(pos), ());
}
xp += xd * f64::from(0.3f32);
yp += yd * f64::from(0.3f32);
zp += zd * f64::from(0.3f32);
power -= 0.225_000_01;
}
}
}
}
set.entries().map(|(k, _)| k.0).collect()
}
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn rays_reusing_their_last_block_explode_what_each_step_did() {
let mut game = crate::game::native_machines::tests::server_game();
let registry = &game.pack.assets.registry;
let states: Vec<BlockStateId> = [
"minecraft:stone",
"minecraft:air",
"minecraft:water",
"minecraft:dirt",
"minecraft:obsidian",
"minecraft:air",
"minecraft:oak_planks",
"minecraft:glass",
]
.iter()
.map(|n| registry.default_state(n).unwrap())
.collect();
// A jumble of blocks of every resistance around the blast, in the
// loaded chunk at the origin.
for x in 0..16 {
for z in 0..16 {
for y in 56..74 {
let i = ((x * 7 + y * 3 + z * 5) as usize) % states.len();
game.world.set(x, y, z, states[i]).unwrap();
}
}
}
let center = [8.3, 64.6, 7.9];
for (radius, resists) in [(4.0, None), (6.0, None), (4.0, Some(([8, 64, 8], 1200.0)))] {
game.world.random = horizon_core::random::LegacyRandom::new(7);
let expected = exploded_positions_stepwise(&mut game, center, radius, resists);
game.world.random = horizon_core::random::LegacyRandom::new(7);
let got = game.exploded_positions(center, radius, resists);
assert!(expected.len() > 20, "{radius}: {}", expected.len());
assert_eq!(got, expected, "radius {radius}, {resists:?}");
}
}
#[test]
#[ignore = "requires prepared resources; tools/test_explosion_items_parity.py"]
fn explosion_items_parity_fixture() {
@@ -105,8 +105,10 @@ impl Game<'_> {
);
let on_ground = wanted.y < 0.0 && movement.y != wanted.y;
f.on_ground = on_ground;
if next.y < f.position.y {
f.fall_distance += f.position.y - next.y;
// Entity.checkFallDamage: each step down, narrowed to a float (a
// falling block never runs baseTick, so it is never in water).
if movement.y < 0.0 {
f.fall_distance -= f64::from(movement.y as f32);
}
let from = f.position;
f.position = next;
@@ -2176,6 +2176,7 @@ mod tests {
#[test]
fn reeling_an_item_requests_an_immediate_motion_update() {
let mut item = ItemEntity {
random: horizon_core::random::LegacyRandom::new(0),
rotation: [0.0, 0.0],
join: Default::default(),
no_gravity: false,
@@ -2191,6 +2192,7 @@ mod tests {
on_ground: true,
vertical_collision: false,
horizontal_collision: false,
fall_distance: 0.0,
age: 0,
pickup_delay: 0,
bob_offset: 0.0,
@@ -37,8 +37,15 @@ const FISH: [&str; 4] = [
];
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES and an isolated mod registry"]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn a_bobber_floats_bites_and_rolls_the_table_of_a_mods_medium() {
crate::mod_api::in_own_process(
"game::fishing::medium_tests::a_bobber_floats_bites_and_rolls_the_table_of_a_mods_medium",
a_bobber_floats_bites_and_rolls_the_table_of_a_mods_medium_in_own_process,
);
}
fn a_bobber_floats_bites_and_rolls_the_table_of_a_mods_medium_in_own_process() {
let root = PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES").unwrap());
let settings = Settings::default();
let loaded = load_resources(
@@ -26,8 +26,15 @@ impl Hooks for Arc<Probe> {
}
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES and an isolated mod registry"]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn a_changed_inventory_is_heard_once_with_its_41_slots() {
crate::mod_api::in_own_process(
"game::advancements::inventory_changed_tests::a_changed_inventory_is_heard_once_with_its_41_slots",
a_changed_inventory_is_heard_once_with_its_41_slots_in_own_process,
);
}
fn a_changed_inventory_is_heard_once_with_its_41_slots_in_own_process() {
let root = PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES").unwrap());
let settings = Settings::default();
let loaded = load_resources(
@@ -168,6 +168,7 @@ mod tests {
fn item_at(x: f64, y: f64, z: f64) -> ItemEntity {
let p = Vec3::new(x, y, z);
ItemEntity {
random: horizon_core::random::LegacyRandom::new(0),
rotation: [0.0, 0.0],
join: Default::default(),
health: 5,
@@ -190,6 +191,7 @@ mod tests {
no_gravity: false,
vertical_collision: false,
horizontal_collision: false,
fall_distance: 0.0,
}
}
@@ -32,8 +32,15 @@ impl Hooks for Arc<Probe> {
}
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES and an isolated mod registry"]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn the_active_stack_survives_a_partial_contact_and_consumption_removes_the_entity() {
crate::mod_api::in_own_process(
"game::item_contact_tests::the_active_stack_survives_a_partial_contact_and_consumption_removes_the_entity",
the_active_stack_survives_a_partial_contact_and_consumption_removes_the_entity_in_own_process,
);
}
fn the_active_stack_survives_a_partial_contact_and_consumption_removes_the_entity_in_own_process() {
let root = PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES").unwrap());
let settings = Settings::default();
let loaded = load_resources(
@@ -82,7 +82,8 @@ impl Game<'_> {
&nearby,
Some(self.border.geometry()),
);
let escape_speed = no_physics.then(|| self.world.random.next_float() * 0.2 + 0.1);
// `moveTowardsClosestSpace` draws the speed from the item's own random.
let escape_speed = no_physics.then(|| item.random.next_float() * 0.2 + 0.1);
let mut entered = Vec::new();
let (alive, discarded) = self.tick_item_with_block_contacts(
item,
@@ -573,3 +574,47 @@ impl Game<'_> {
}
}
}
#[cfg(test)]
mod escape_tests {
use super::super::native_machines::tests::server_game;
use horizon_client::chunk::Chunk;
use horizon_client::items::ItemStack;
use horizon_core::random::RandomSource;
use std::sync::Arc;
/// `Entity.moveTowardsClosestSpace` takes an item's escape speed from the
/// item's own random, the one its constructor drew from, and leaves the
/// level's random as it was (#13043).
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn an_item_escapes_a_block_at_its_own_randoms_speed() {
let mut game = server_game();
let feet = horizon_core::geometry::Vec3::new(8.5, 70.0, 8.5);
game.player = Some(horizon_client::motion::PlayerMotion::new(game.motion, feet).unwrap());
game.world
.chunks
.insert((0, 0), Arc::new(Chunk::empty(0, 0)));
game.update_chunk_tickets();
assert!(game.entity_ticking_area()(8, 8));
let stone = game
.pack
.assets
.registry
.default_state("minecraft:stone")
.unwrap();
game.world.set(8, 64, 8, stone).unwrap();
let stack = ItemStack::new("minecraft:stone", 2);
game.spawn_item(stack, [8.5, 64.4, 8.5], [0.0, 0.0, 0.0], 10);
let item = game.dropped.last().unwrap();
let expected = f64::from(item.random.clone().next_float() * 0.2 + 0.1);
let mut level = game.world.random.clone();
game.ticked_entities_through = u32::MAX;
game.tick_items();
let item = game.dropped.last().unwrap();
// Up is the nearest open side from the middle of the block.
// The push replaces the fall's velocity; then the 0.98 drag.
assert_eq!(item.velocity.y, expected * 0.98);
assert_eq!(game.world.random.next_float(), level.next_float());
}
}
@@ -75,8 +75,15 @@ fn target(id: i32) -> Option<InteractTarget> {
}
#[test]
#[ignore = "private prepared resources; process-wide mods, run this test alone"]
#[ignore = "private prepared resources in HORIZON_TEST_RESOURCES"]
fn actual_item_interact_mob_fallback_priority_and_filters() {
crate::mod_api::in_own_process(
"game::item_interact_mob_tests::actual_item_interact_mob_fallback_priority_and_filters",
actual_item_interact_mob_fallback_priority_and_filters_in_own_process,
);
}
fn actual_item_interact_mob_fallback_priority_and_filters_in_own_process() {
let root = PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES").unwrap());
let settings = Settings::default();
let loaded = load_resources(
@@ -35,8 +35,15 @@ fn porkchops(game: &Game<'_>) -> usize {
}
#[test]
#[ignore = "run alone with prepared resources in HORIZON_TEST_RESOURCES"]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn a_vetoed_kill_keeps_the_mob_and_a_later_kill_dies_once() {
crate::mod_api::in_own_process(
"game::kill_die_cancel_tests::a_vetoed_kill_keeps_the_mob_and_a_later_kill_dies_once",
a_vetoed_kill_keeps_the_mob_and_a_later_kill_dies_once_in_own_process,
);
}
fn a_vetoed_kill_keeps_the_mob_and_a_later_kill_dies_once_in_own_process() {
let probe = Arc::new(Probe::default());
assert!(
mod_api::MODS
+163 -1
View File
@@ -485,10 +485,93 @@ impl Game<'_> {
e.pitch = o.pitch;
e.head_yaw = o.yaw;
if let Some(tag) = self.persist.raw.get(&o.id) {
self.lan_display_data(tag, hub, e);
self.lan_display_data_kept(o.id, tag, hub, e);
}
}
// Only this level's displays: the others are kept for their own level's pass.
KEPT_DISPLAYS.with(|kept| {
let mut kept = kept.borrow_mut();
let Some(kept) = kept.get_mut(&self.dim) else {
return;
};
if kept.len() > self.others.len() + 64 {
let ids: horizon_client::fast_hash::FxHashSet<u32> =
self.others.iter().map(|o| o.id).collect();
kept.retain(|id, _| ids.contains(id));
}
});
}
/// [`Self::lan_display_data`] for the display `id`, built again only when
/// its compound changed: a server's holograms and menus are text displays
/// whose text, item and transformation were parsed again every tick.
fn lan_display_data_kept(&self, id: u32, tag: &Tag, hub: &mut Hub, e: &mut EntityState) {
if !matches!(
e.kind.as_str(),
"minecraft:text_display" | "minecraft:block_display" | "minecraft:item_display"
) {
return self.lan_display_data(tag, hub, e);
}
// Block states are read through the pack's registry: a kept build is
// only the same for the same one.
let registry = std::ptr::from_ref(self.pack.assets.registry) as usize;
KEPT_DISPLAYS.with(|kept| {
let mut kept = kept.borrow_mut();
let kept = kept.entry(self.dim).or_default();
let same =
|k: &KeptDisplay| k.registry == registry && k.kind == e.kind && k.tag == *tag;
if let Some(k) = kept.get(&id).filter(|k| same(k)) {
e.meta.clone_from(&k.meta);
e.defaults.clone_from(&k.defaults);
// The stack's ids are the hub's: asked each time, as a build does.
if let Some((at, item)) = &k.item
&& let Some(slot) = e.meta.get_mut(*at)
{
slot.2 = Value::Item(hub.stack(item));
}
return;
}
self.lan_display_data(tag, hub, e);
let item = (e.kind == "minecraft:item_display").then(|| {
let at = e
.meta
.iter()
.position(|m| m.0 == 23 && m.1 == serializer::ITEM_STACK);
let item = tag
.get("item")
.and_then(horizon_client::save::item_from_nbt);
(at.unwrap_or(usize::MAX), item)
});
kept.insert(
id,
KeptDisplay {
registry,
kind: e.kind.clone(),
tag: tag.clone(),
meta: e.meta.clone(),
defaults: e.defaults.clone(),
item,
},
);
});
}
}
/// A display's synched data as [`Game::lan_display_data`] built it from
/// `tag`, kept for the display's id on the thread that tracks entities, per
/// level: a server thread tracks each dimension's `others` in turn.
struct KeptDisplay {
registry: usize,
kind: String,
tag: Tag,
meta: Vec<(u8, i32, Value)>,
defaults: Vec<(u8, i32, Value)>,
/// An item display's item and the index in `meta` its stack goes in.
item: Option<(usize, Option<horizon_client::items::ItemStack>)>,
}
thread_local! {
static KEPT_DISPLAYS: std::cell::RefCell<
horizon_client::fast_hash::FxHashMap<super::Dim, horizon_client::fast_hash::FxHashMap<u32, KeptDisplay>>,
> = Default::default();
}
impl Game<'_> {
@@ -935,6 +1018,85 @@ fn push_arrow_meta(
mod tests {
use super::*;
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn kept_display_data_is_what_a_build_gives() {
let game = crate::game::native_machines::tests::server_game();
let sync = std::sync::Arc::new(horizon_client::lan::data::SyncData::empty());
let net = horizon_client::lan::NetConfig {
listen: false,
..horizon_client::lan::NetConfig::loopback(0, String::new(), 2)
};
let lan = horizon_client::lan::Lan::start_with(sync.clone(), net).unwrap();
let encoder = horizon_client::lan::chunks::ChunkEncoder::new(
[] as [(std::sync::Arc<str>, bool); 0],
&horizon_protocol::registry::Builtin::default(),
&[],
&[],
);
let config = horizon_client::lan::hub::HubConfig {
default_game_mode: 0,
guest_commands: false,
force_game_mode: false,
hardcore: false,
view_distance: 10,
simulation_distance: 10,
dimension: "minecraft:overworld".into(),
allow_flight: false,
seed: 0,
max_players: 20,
integrated: true,
levels: Default::default(),
};
let mut hub = Hub::new(lan, sync, encoder, config);
let snbt = |s: &str| horizon_client::command::snbt::parse_fully(s).unwrap();
let displays: [(&str, Tag, Tag); 3] = [
(
"minecraft:text_display",
snbt(r#"{text:{text:"Shop",color:"gold"},billboard:"center",line_width:120}"#),
snbt(r#"{text:{text:"Sold out",bold:1b},billboard:"center",background:0}"#),
),
(
"minecraft:block_display",
snbt(r#"{block_state:{Name:"minecraft:oak_stairs",Properties:{facing:"east"}}}"#),
snbt(r#"{block_state:{Name:"minecraft:glass"},brightness:{sky:15,block:3}}"#),
),
(
"minecraft:item_display",
snbt(r#"{item:{id:"minecraft:diamond",count:3},item_display:"gui"}"#),
snbt(
r#"{item:{id:"minecraft:apple",count:1},item_display:"head",glow_color_override:255}"#,
),
),
];
let state = |kind: &str| EntityState {
kind: kind.into(),
..Default::default()
};
for (id, (kind, first, second)) in displays.iter().enumerate() {
let id = 900 + id as u32;
// Ticks with the first compound, then the second, then the first
// again: each answer is a fresh build's.
for tag in [first, first, first, second, second, first] {
let mut built = state(kind);
game.lan_display_data(tag, &mut hub, &mut built);
assert!(!built.meta.is_empty(), "{kind}");
let mut kept = state(kind);
game.lan_display_data_kept(id, tag, &mut hub, &mut kept);
assert_eq!(
format!("{:?}", kept.meta),
format!("{:?}", built.meta),
"{kind}"
);
assert_eq!(
format!("{:?}", kept.defaults),
format!("{:?}", built.defaults),
"{kind}"
);
}
}
}
#[test]
fn summoned_ids_are_unique_and_survive_the_tracking_pass() {
let mut next = 40;
+160 -46
View File
@@ -12,12 +12,31 @@
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.
/// Per level, the idle entities the last pass noted (`Kept`).
#[derive(Default)]
pub(super) struct IdleStates {
levels: FxHashMap<String, FxHashMap<i32, (u32, Place)>>,
levels: FxHashMap<String, Kept>,
}
/// One level's idle entities as the last tracking pass noted them, in the
/// order it met them, and that pass. The game meets its entities in the same
/// order every tick (its lists keep their order), so a pass finds each one's
/// note at or just past where it found the last one: no lookup by id. One it
/// does not find there is described in full, which is always right.
#[derive(Default)]
struct Kept {
pass: u32,
ids: Vec<i32>,
places: Vec<Place>,
/// This pass's notes, which replace them when it finishes.
next_ids: Vec<i32>,
next_places: Vec<Place>,
}
/// How many notes past the last one found a pass looks: entities that left
/// the level since are skipped over.
const LOOK_AHEAD: usize = 4;
/// Where an entity is: its position, rotation (yaw, pitch, head yaw), whether
/// it is on the ground, and its velocity.
#[derive(Clone, Copy, PartialEq)]
@@ -30,16 +49,24 @@ pub(super) struct Place {
/// The idle entities of the level in place, for one pass.
pub(super) struct IdlePass<'a> {
kept: &'a mut FxHashMap<i32, (u32, Place)>,
kept: &'a mut Kept,
/// Whether the kept notes are the last pass's.
last: bool,
/// Where the next note is looked for.
cursor: usize,
/// The level's tracking passes so far (`Hub::tracking_ticks`). An entity
/// described one pass earlier was in the tracker's last pass.
/// noted 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.
/// the clients that could pair. Sorted by x, so an entity looks only at
/// the ones within its range along x.
viewers: Vec<(usize, [f64; 2], i32)>,
/// `entity-broadcast-range-percentage`, as the tracker scales ranges.
percent: i32,
/// The last type asked about and its scaled tracking range: the
/// entities of a type come in runs (a farm, a pile of items).
last_kind: Option<(String, i32)>,
}
impl IdleStates {
@@ -51,16 +78,46 @@ impl IdleStates {
.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,
})
Some(IdlePass::new(kept, now, viewers, percent))
}
}
impl IdlePass<'_> {
impl<'a> IdlePass<'a> {
fn new(
kept: &'a mut Kept,
now: u32,
mut viewers: Vec<(usize, [f64; 2], i32)>,
percent: i32,
) -> Self {
// A player somewhere not finite is within no range: the distance
// test below fails for it.
viewers.retain(|(_, [x, z], _)| x.is_finite() && z.is_finite());
viewers.sort_by(|a, b| a.1[0].total_cmp(&b.1[0]));
kept.next_ids.clear();
kept.next_places.clear();
IdlePass {
last: kept.pass.wrapping_add(1) == now,
kept,
cursor: 0,
now,
viewers,
percent,
last_kind: None,
}
}
/// The type's tracking range in blocks, scaled as the tracker scales it.
fn range(&mut self, kind: &str) -> i32 {
match &mut self.last_kind {
Some((last, range)) if last.as_str() == kind => *range,
last => {
let range = self.percent * (i32::from(tracking::settings(kind).0) * 16) / 100;
*last = Some((kind.to_owned(), range));
range
}
}
}
/// 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
@@ -70,6 +127,16 @@ impl IdlePass<'_> {
&& hub.riding.passengers(id).is_empty()
}
/// The place the last pass noted for `id`, if it is found where the
/// pass's order says it would be.
fn noted(&mut self, id: i32) -> Option<Place> {
let ids = &self.kept.ids;
let end = ids.len().min(self.cursor + LOOK_AHEAD);
let k = (self.cursor..end).find(|&k| ids[k] == id)?;
self.cursor = k + 1;
Some(self.kept.places[k])
}
/// [`Self::bare`] but for the passengers, with whether the player `i`
/// tracks the entity.
fn unchanged(
@@ -79,27 +146,36 @@ impl IdlePass<'_> {
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 {
let noted = if self.last { self.noted(id) } else { None };
self.kept.next_ids.push(id);
self.kept.next_places.push(*at);
if noted != Some(*at) {
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)
})
let range = self.range(kind);
// Only a client within `range` along x can be within it: a block
// more spares the rounding of the squares.
let reach = f64::from(range) + 1.0;
let x = at.position[0];
let from = self.viewers.partition_point(|v| v.1[0] < x - reach);
!self.viewers[from..]
.iter()
.take_while(|v| v.1[0] <= x + reach)
.any(|&(i, [vx, vz], view)| {
let range = f64::from(range.min(view));
let (dx, dz) = (vx - at.position[0], vz - at.position[2]);
dx * dx + dz * dz <= range * range && !tracks(i)
})
}
/// Forget the entities this pass did not note: they left, or tick again.
/// The notes of this pass replace the last one's: the entities it did not
/// note left, or tick again.
pub(super) fn finish(self) {
let now = self.now;
self.kept.retain(|_, (stamp, _)| *stamp == now);
let kept = self.kept;
std::mem::swap(&mut kept.ids, &mut kept.next_ids);
std::mem::swap(&mut kept.places, &mut kept.next_places);
kept.pass = self.now;
}
}
@@ -116,14 +192,22 @@ mod tests {
}
}
fn pass(kept: &mut FxHashMap<i32, (u32, Place)>, now: u32) -> IdlePass<'_> {
/// One pass `now` over `entities` (id, type, place), each answered with
/// whether the player `i` tracks it; what each could be described as.
fn pass(
kept: &mut Kept,
now: u32,
entities: &[(i32, &str, Place)],
tracks: impl Fn(i32, usize) -> bool,
) -> Vec<bool> {
// 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,
}
let viewers = vec![(1, [400.0, 0.0], 160), (0, [0.0, 0.0], 160)];
let mut pass = IdlePass::new(kept, now, viewers, 100);
let bare = (entities.iter())
.map(|(id, kind, at)| pass.unchanged(*id, kind, at, |i| tracks(*id, i)))
.collect();
pass.finish();
bare
}
/// An idle cow (tracked 10 chunks away) is described bare while it stays
@@ -131,24 +215,54 @@ mod tests {
/// 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 mut kept = Kept::default();
let cow = |at| [(7, "minecraft:cow", at)];
let at = place(100.0);
// Its first pass describes it in full.
assert!(!pass(&mut kept, 5).unchanged(7, cow, &at, |_| true));
assert_eq!(pass(&mut kept, 5, &cow(at), |_, _| true), [false]);
// 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));
let tracked = |_, i| i == 0;
assert_eq!(pass(&mut kept, 6, &cow(at), tracked), [true]);
// The client in range doesn't track it yet: it could pair.
assert!(!pass(&mut kept, 7).unchanged(7, cow, &at, |_| false));
assert_eq!(pass(&mut kept, 7, &cow(at), |_, _| false), [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));
let item = [(7, "minecraft:item", at)];
assert_eq!(pass(&mut kept, 8, &item, |_, _| false), [true]);
// It moved.
assert!(!pass(&mut kept, 9).unchanged(7, cow, &place(100.5), tracked));
assert_eq!(pass(&mut kept, 9, &cow(place(100.5)), tracked), [false]);
// 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());
assert_eq!(pass(&mut kept, 11, &cow(place(100.5)), tracked), [false]);
// The client 400 blocks away is in range of a cow next to it.
let far = [(7, "minecraft:cow", place(300.0))];
pass(&mut kept, 12, &far, |_, _| true);
assert_eq!(pass(&mut kept, 13, &far, |_, i| i == 0), [false]);
assert_eq!(pass(&mut kept, 14, &far, |_, i| i == 1), [true]);
}
/// Each entity's note is found in the last pass's order, past the ones
/// that left since and after one that joined.
#[test]
fn notes_are_found_in_the_last_pass_order() {
let mut kept = Kept::default();
let item = |id| (id, "minecraft:item", place(1000.0 + f64::from(id)));
let all = |ids: &[i32]| ids.iter().map(|&id| item(id)).collect::<Vec<_>>();
let first = all(&(1..=20).collect::<Vec<_>>());
pass(&mut kept, 1, &first, |_, _| false);
// 2 to 4 left, 30 joined in the middle, 12 to 14 left.
let ids = [1, 5, 6, 30, 7, 8, 9, 10, 11, 15, 16, 17, 18, 19, 20];
let bare = pass(&mut kept, 2, &all(&ids), |_, _| false);
let full: Vec<i32> = ids
.iter()
.zip(&bare)
.filter(|(_, b)| !**b)
.map(|(id, _)| *id)
.collect();
assert_eq!(full, [30]);
// One that moved out of order beyond the look-ahead is described in
// full, which is always right, and the next pass finds it again.
let moved = [20, 1, 5, 6, 7];
let bare = pass(&mut kept, 3, &all(&moved), |_, _| false);
assert_eq!(bare, [false, true, true, true, true]);
assert_eq!(pass(&mut kept, 4, &all(&moved), |_, _| false), [true; 5]);
}
}
@@ -1732,6 +1732,7 @@ mod item_column_tests {
fn item(id: u32, x: f64, y: f64, z: f64) -> ItemEntity {
let at = Vec3::new(x, y, z);
ItemEntity {
random: horizon_core::random::LegacyRandom::new(0),
rotation: [0.0, 0.0],
join: Default::default(),
no_gravity: false,
@@ -1747,6 +1748,7 @@ mod item_column_tests {
on_ground: true,
vertical_collision: false,
horizontal_collision: false,
fall_distance: 0.0,
age: 0,
pickup_delay: 0,
bob_offset: 0.0,
@@ -731,8 +731,15 @@ mod tests {
use super::*;
#[test]
#[ignore = "prepared resources in HORIZON_TEST_RESOURCES; run alone to isolate the mod registry"]
#[ignore = "prepared resources in HORIZON_TEST_RESOURCES"]
fn the_standard_helpers_route_to_native_owned_storage() {
crate::mod_api::in_own_process(
"game::machines::native_fluids::tests::the_standard_helpers_route_to_native_owned_storage",
the_standard_helpers_route_to_native_owned_storage_in_own_process,
);
}
fn the_standard_helpers_route_to_native_owned_storage_in_own_process() {
use crate::game::{Audio, Options, Progress, Settings, World, load_resources};
use crate::mod_api::{
self, CustomBlockFluidsEvent, CustomFluidDrainEvent, CustomFluidFillEvent,
+15
View File
@@ -1334,6 +1334,21 @@ impl Game<'_> {
);
}
}
// `LivingEntity.tick` ticks `EntityEquipment` (the armour slots) as
// well as the inventory, so a compass worn there loses its target too.
let gone = self.armor.each_ref().map(|stack| {
stack
.as_ref()
.is_some_and(|s| lodestone_target_gone(self, s, dim))
});
for (stack, gone) in self.armor.iter_mut().zip(gone) {
if gone && let Some(stack) = stack {
stack.data_mut().set_other(
"minecraft:lodestone_tracker",
Some(Tag::Compound(Default::default())),
);
}
}
}
fn give_or_drop_stack(&mut self, stack: ItemStack) {
if let Some(rest) = self.give(stack)
@@ -24,8 +24,15 @@ impl Hooks for Arc<Probe> {
}
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES and an isolated mod registry"]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn a_mod_toast_queues_unless_a_mod_cancels_it() {
crate::mod_api::in_own_process(
"game::advancements::mod_toast_tests::a_mod_toast_queues_unless_a_mod_cancels_it",
a_mod_toast_queues_unless_a_mod_cancels_it_in_own_process,
);
}
fn a_mod_toast_queues_unless_a_mod_cancels_it_in_own_process() {
let root = PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES").unwrap());
let settings = Settings::default();
let loaded = load_resources(
+4 -4
View File
@@ -655,13 +655,13 @@ impl Game<'_> {
/// A saved dropped item.
pub(super) fn load_item(&mut self, tag: Tag, uuid: Option<[i32; 4]>) {
self.next_entity += 1;
// `ItemEntity(EntityType, Level)` before the load: the item's own random
// makes its UUID (kept unless the tag has one) and bobOffs, not the level's.
// Its yaw, the next draw, is always replaced by the saved rotation.
// `ItemEntity(EntityType, Level)` first: its own random draws UUID, bobOffs and yaw.
let mut own = random_source::entity();
let fresh = horizon_client::save::insecure_uuid(&mut own);
let bob = own.next_float() * std::f32::consts::PI * 2.0;
if let Some(e) = entity_nbt::item_entity_from_nbt(&tag, self.next_entity, bob) {
own.next_float();
if let Some(mut e) = entity_nbt::item_entity_from_nbt(&tag, self.next_entity, bob) {
e.random = own;
self.remember(e.id, Some(uuid.unwrap_or(fresh)), tag);
self.dropped.push(e);
}
@@ -1,5 +1,5 @@
//! Needs private prepared resources: installs process-wide trait mods, so it runs in a process of its
//! own (`in_own_process`) and passes whatever else runs beside it.
//! own (`mod_api::in_own_process`) and passes whatever else runs beside it.
use super::super::*;
use crate::mod_api::{
self, Hooks, Host, NativeRecipeCategory, NativeRecipeLookup, NativeStack,
@@ -45,36 +45,11 @@ fn stack(item: &str) -> NativeStack {
}
}
/// Runs `body` in a fresh process of this test binary: `mod_api::MODS` is set once per process, and
/// another test that set it first would make this one's `set` fail. The parent starts the binary again
/// for just this test (`--exact`, `--ignored`) and requires that exactly one test ran and passed; the
/// child sees `OWN_PROCESS` and runs `body` itself.
fn in_own_process(name: &str, body: impl FnOnce()) {
const OWN_PROCESS: &str = "HORIZON_TEST_OWN_PROCESS";
if std::env::var_os(OWN_PROCESS).is_some_and(|v| v == name) {
return body();
}
let out = std::process::Command::new(std::env::current_exe().unwrap())
.args([name, "--exact", "--ignored", "--test-threads=1"])
.env(OWN_PROCESS, name)
.output()
.unwrap();
let report = format!(
"{}{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
assert!(
out.status.success() && report.contains("test result: ok. 1 passed;"),
"{name} failed in its own process:\n{report}"
);
}
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn lookups_wait_for_the_next_frame_and_stop_at_the_viewer_that_takes_them() {
const NAME: &str = "game::native_recipes::recipe_viewer_tests::lookups_wait_for_the_next_frame_and_stop_at_the_viewer_that_takes_them";
in_own_process(NAME, lookups_wait_for_the_next_frame);
mod_api::in_own_process(NAME, lookups_wait_for_the_next_frame);
}
fn lookups_wait_for_the_next_frame() {
+4 -25
View File
@@ -6256,32 +6256,11 @@ impl Game<'_> {
};
let at = Vec3::new(p[0], p[1], p[2]);
// ItemEntity's constructor draws bobOffs from the item's own random.
let bob_offset = horizon_client::mobs::entity_random(id as u32, at).next_float()
* std::f32::consts::PI
* 2.0;
let mut random = horizon_client::mobs::entity_random(id as u32, at);
let bob_offset = random.next_float() * std::f32::consts::PI * 2.0;
self.dropped.push(ItemEntity {
rotation: [0.0, 0.0],
join: Default::default(),
no_gravity: false,
health: 5,
fire_ticks: 0,
thrower: None,
target: None,
id: id as u32,
stack,
position: at,
previous: at,
velocity,
on_ground: false,
vertical_collision: false,
horizontal_collision: false,
age: 0,
pickup_delay: 0,
bob_offset,
ticks: 0,
needs_sync: false,
stuck: Vec3::ZERO,
was_touching_water: false,
random,
..ItemEntity::new(id as u32, stack, at, velocity, bob_offset)
});
}
/// `Entity.lerpMotion` (and `recreateFromPacket`'s motion) for the
@@ -167,6 +167,7 @@ impl Game<'_> {
|| self.settings.extras.pvp.tweaks.clear_lava,
full_water_vision: self.settings.extras.pvp.tweaks.clear_water,
night_vision: self.fog_night_vision(),
environment: self.pack.polytone_colors.env,
};
let mut fog = horizon_client::fog::setup_fog(tables, &self.fog, &input);
if !self.settings.horizon.fog {
@@ -294,6 +294,7 @@ fn pack_particles(
/// The packs' custom colours; their dye and sheep colours go to the draw paths' table.
fn read_colors(root: &std::path::Path) -> horizon_client::polytone_colors::PackColors {
let colors = horizon_client::polytone_colors::PackColors::read(root);
horizon_client::pack_dyes::set(&colors);
horizon_client::pack_palette::set(&colors);
horizon_client::brewing::set_pack_colors(colors.effects());
colors
}
+27 -10
View File
@@ -952,7 +952,7 @@ pub struct Entities {
/// moves. One the entities' own ticks added waits for the next pass, as
/// vanilla's waits for the next tick.
pub synced: Vec<(i32, i32)>,
states: crate::fast_hash::FxHashMap<i32, ServerEntity>,
states: slab::ServerEntities,
/// The level tick, stamped on every entity still in the level.
tick: u32,
/// This tick's entities as the range checks read them, the chunks they
@@ -1072,7 +1072,7 @@ impl Entities {
self.states.len()
}
pub fn is_empty(&self) -> bool {
self.states.is_empty()
self.states.len() == 0
}
/// How many passes the level's tracking has run.
@@ -1160,12 +1160,29 @@ impl Entities {
self.quiet.resize(entities.len(), false);
// The states stamped this tick: when that is all of them, none left.
let mut stamped = 0;
self.states.begin();
for (index, e) in entities.iter().enumerate() {
let mut added = false;
let s = self.states.entry(e.id).or_insert_with(|| {
added = true;
ServerEntity::new(e, kind(&e.kind), riding)
});
let s = match self.states.meet(e.id) {
Some((slot, by_id)) => {
let s = self.states.at(slot);
// Found out of order: the next one is looked for after
// where this one was in the last tick's order.
if by_id && s.seen == now.wrapping_sub(1) {
let last = s.index;
self.states.resume_after(last);
}
self.states.at_mut(slot)
}
None => {
added = true;
let slot = self
.states
.insert(e.id, ServerEntity::new(e, kind(&e.kind), riding));
self.states.met_new(e.id, slot);
self.states.at_mut(slot)
}
};
if s.seen != now {
stamped += 1;
}
@@ -1217,6 +1234,7 @@ impl Entities {
hidden: e.hidden,
});
}
self.states.end();
// TrackedEntity.getEffectiveRange: a vehicle is tracked as far as its
// widest indirect passenger, so a rider never leaves before its mount.
for (&vehicle, passengers) in &riding.passengers {
@@ -1390,9 +1408,7 @@ impl Entities {
if self.quiet[index] && !self.pairs[index] {
continue;
}
let Some(s) = self.states.get_mut(&e.id) else {
continue;
};
let s = self.states.at_mut(self.states.slot_of_index(index));
self.packets.clear();
// Paired as it joined, then its first changes go to everyone
// tracking it, those just paired with it included. Arrows wait for the
@@ -1466,13 +1482,14 @@ impl Entities {
}
// ChunkMap.removeEntity for the ones no longer in the level.
if self.states.len() > stamped {
self.states.retain(|_, s| s.seen == now);
self.states.retain(|s| s.seen == now);
}
}
}
#[cfg(test)]
mod late_pair_tests;
mod slab;
#[cfg(test)]
mod tests {
@@ -42,7 +42,7 @@ fn late_pairing_does_not_duplicate_or_spend_the_next_arrow_update() {
let mut e = trident();
level.pair_after_tick(std::slice::from_ref(&e), &riding, &mut viewers, |_| Some(0));
assert_eq!(level.tick, 1);
assert_eq!(level.states[&37].tick, 0);
assert_eq!(level.states.get(&37).unwrap().tick, 0);
assert!(viewers[0].tracker.is_tracking(37));
assert_eq!(
viewers[0]
@@ -57,14 +57,14 @@ fn late_pairing_does_not_duplicate_or_spend_the_next_arrow_update() {
level.pair_after_tick(std::slice::from_ref(&e), &riding, &mut viewers, |_| Some(0));
assert!(viewers[0].out.is_empty());
assert!(viewers[0].tracker.take_events().is_empty());
assert_eq!(level.states[&37].tick, 0);
assert_eq!(level.states.get(&37).unwrap().tick, 0);
// It hits a block between its introduction and the next tracking pass.
e.position[0] += 0.25;
e.velocity = [0.0; 3];
e.meta.push((10, serializer::BOOLEAN, Value::Bool(true)));
level.tick(&[&e], &riding, &mut viewers, |_| Some(0));
assert_eq!(level.tick, 2);
assert_eq!(level.states[&37].tick, 1);
assert_eq!(level.states.get(&37).unwrap().tick, 1);
assert!(
!viewers[0]
.out
@@ -0,0 +1,194 @@
//! A level's `ServerEntity`s by id (`ChunkMap.entityMap`), found without hashing on most ticks.
//!
//! A level tracks tens of thousands of entities, most of them items and mobs frozen past the
//! simulation distance, and looked each one up by id on every tick: one probe of a large table
//! for each, missing the cache nearly every time. The game describes its entities in the same
//! order every tick (its lists keep their order), so each tick notes the order it met them in,
//! and the next one finds each entity at or just past where it found the one before. Only an
//! entity that joined, or one met out of order, is looked up by id.
use super::ServerEntity;
use crate::fast_hash::FxHashMap;
/// How many entries past the last one found an entity is looked for: the entities that left
/// the level since are skipped over.
const LOOK_AHEAD: usize = 4;
#[derive(Default)]
pub(super) struct ServerEntities {
/// The entities and their ids; None in a slot that is free.
slots: Vec<Option<(i32, ServerEntity)>>,
free: Vec<u32>,
by_id: FxHashMap<i32, u32>,
/// The last tick's entities in the order it met them (id, slot), this tick's so far, and
/// where the next one is looked for.
order: Vec<(i32, u32)>,
next_order: Vec<(i32, u32)>,
cursor: usize,
}
impl ServerEntities {
pub(super) fn len(&self) -> usize {
self.by_id.len()
}
pub(super) fn contains_key(&self, id: &i32) -> bool {
self.by_id.contains_key(id)
}
pub(super) fn get(&self, id: &i32) -> Option<&ServerEntity> {
self.by_id.get(id).map(|&slot| self.at(slot))
}
pub(super) fn at(&self, slot: u32) -> &ServerEntity {
&self.slots[slot as usize].as_ref().expect("a live slot").1
}
pub(super) fn at_mut(&mut self, slot: u32) -> &mut ServerEntity {
&mut self.slots[slot as usize].as_mut().expect("a live slot").1
}
/// Adds the entity `id` (not tracked yet), returning its slot.
pub(super) fn insert(&mut self, id: i32, s: ServerEntity) -> u32 {
debug_assert!(!self.by_id.contains_key(&id));
let slot = match self.free.pop() {
Some(slot) => {
self.slots[slot as usize] = Some((id, s));
slot
}
None => {
self.slots.push(Some((id, s)));
(self.slots.len() - 1) as u32
}
};
self.by_id.insert(id, slot);
slot
}
/// Starts meeting a tick's entities in order.
pub(super) fn begin(&mut self) {
self.cursor = 0;
self.next_order.clear();
}
/// The slot of the entity `id`, met next in this tick's order, and whether it was found by
/// id (not where the last tick's order had it). Notes it in this tick's order.
pub(super) fn meet(&mut self, id: i32) -> Option<(u32, bool)> {
let end = self.order.len().min(self.cursor + LOOK_AHEAD);
let found = match (self.cursor..end).find(|&k| self.order[k].0 == id && self.holds(k, id)) {
Some(k) => {
self.cursor = k + 1;
Some((self.order[k].1, false))
}
None => self.by_id.get(&id).map(|&slot| (slot, true)),
};
if let Some((slot, _)) = found {
self.next_order.push((id, slot));
}
found
}
/// Whether the last tick's `k`-th slot still holds the entity `id`.
fn holds(&self, k: usize, id: i32) -> bool {
(self.slots[self.order[k].1 as usize].as_ref()).is_some_and(|(held, _)| *held == id)
}
/// Notes an entity met this tick that was not tracked before it.
pub(super) fn met_new(&mut self, id: i32, slot: u32) {
self.next_order.push((id, slot));
}
/// The next entity is looked for after the last tick's `index`-th: where the one just
/// found by id was then.
pub(super) fn resume_after(&mut self, index: usize) {
self.cursor = index + 1;
}
/// This tick's order is the one the next tick looks in; the slot of the tick's
/// `index`-th entity, until then.
pub(super) fn end(&mut self) {
std::mem::swap(&mut self.order, &mut self.next_order);
}
pub(super) fn slot_of_index(&self, index: usize) -> u32 {
self.order[index].1
}
/// Keeps only the entities `keep` says to.
pub(super) fn retain(&mut self, mut keep: impl FnMut(&ServerEntity) -> bool) {
for (slot, entry) in self.slots.iter_mut().enumerate() {
if let Some((id, s)) = entry
&& !keep(s)
{
self.by_id.remove(id);
*entry = None;
self.free.push(slot as u32);
}
}
}
}
#[cfg(test)]
mod tests {
use super::super::{EntityState, Riding};
use super::*;
fn entity(id: i32) -> ServerEntity {
let e = EntityState {
id,
kind: "minecraft:item".into(),
..Default::default()
};
ServerEntity::new(&e, Some(1), &Riding::default())
}
/// One tick meeting `ids` in order: each one's slot and whether it was
/// found by id; the new ones are added.
fn tick(all: &mut ServerEntities, ids: &[i32]) -> Vec<(u32, bool)> {
all.begin();
let met = (ids.iter())
.map(|&id| {
all.meet(id).unwrap_or_else(|| {
let slot = all.insert(id, entity(id));
all.met_new(id, slot);
(slot, true)
})
})
.collect();
all.end();
met
}
/// The entities met in last tick's order are found there, past the ones
/// that left; the rest by id, each in its own slot, freed ones reused.
#[test]
fn entities_are_found_in_last_ticks_order_or_by_id() {
let mut all = ServerEntities::default();
let first = tick(&mut all, &[10, 11, 12, 13, 14, 15]);
assert_eq!(
first.iter().map(|m| m.0).collect::<Vec<_>>(),
[0, 1, 2, 3, 4, 5]
);
// 11 and 12 left; 20 joined between 13 and 14.
let met = tick(&mut all, &[10, 13, 20, 14, 15]);
assert_eq!(
met,
[(0, false), (3, false), (6, true), (4, false), (5, false)]
);
// 15 out of order is found by id; the next ones in order again.
let met = tick(&mut all, &[15, 10, 20]);
assert_eq!(met, [(5, true), (0, false), (6, false)]);
// Only those three are left in the level: the others' slots go to
// the next ones to join.
for (slot, _) in &met {
all.at_mut(*slot).seen = 1;
}
all.retain(|s| s.seen == 1);
assert_eq!(all.len(), 3);
assert!(!all.contains_key(&13) && all.get(&20).is_some());
let met = tick(&mut all, &[15, 10, 20, 30, 31, 32]);
let slots: Vec<u32> = met.iter().map(|m| m.0).collect();
assert_eq!(slots, [5, 0, 6, 4, 3, 2]);
assert_eq!(all.slot_of_index(4), 3);
}
}
+1 -1
View File
@@ -3318,7 +3318,7 @@ impl Hub {
continue;
};
let alpha = if ambient { 38 } else { 255 };
let argb = (alpha << 24) | def.color as i32;
let argb = (alpha << 24) | def.shown_color() as i32;
("entity_effect", Some(ParticleData::Color(argb)))
}
};
+1 -1
View File
@@ -1173,7 +1173,7 @@ pub struct Extra {
pub sitting: bool,
/// `Parrot.partyParrot` and `jukebox`: the playing jukebox it dances to.
pub party_jukebox: Option<[i32; 3]>,
/// The owner's UUID (tamed animals).
/// The owner's UUID: a tamed animal's, or the reference a vex was loaded with.
pub owner: Option<[i32; 4]>,
/// `Vindicator.isJohnny`: a custom name that was ever `Johnny`, which
/// `setCustomName` latches and nothing clears.
+2 -10
View File
@@ -510,9 +510,6 @@ pub fn base_value(m: &Mob, a: Attr) -> f64 {
/// [`base_value`] with the type's default given (`default_value`), for a
/// caller that already looked it up for the mob's type.
pub fn base_value_with(m: &Mob, a: Attr, default: impl FnOnce() -> Option<f64>) -> f64 {
if a == Attr::MovementSpeed && m.species() == Species::Ravager {
return m.attrs.speed;
}
m.extra
.attributes
.as_ref()
@@ -532,8 +529,7 @@ pub fn base_value_with(m: &Mob, a: Attr, default: impl FnOnce() -> Option<f64>)
/// growing up) is saved apart; these are the rest:
/// - `movement_speed`: Zombie and Piglin.setBaby (the load calls it even for an
/// adult), `Enderman.setTarget` (the load ends in `readPersistentAngerSaveData`,
/// which calls it), a Ravager whose load leaves it immobile, and the first tick of
/// any mob (`LivingEntity.removeFrost`).
/// which calls it), and the first tick of any mob (`LivingEntity.removeFrost`).
/// - `max_health`: `Wolf.applyTamingSideEffects`, through `setTame(false, true)`
/// when the load finds no owner (a wolf that is tamed in game makes it too).
/// - `attack_damage`: `Rabbit.setVariant`, and a baby Goat or Hoglin, whose load's
@@ -541,11 +537,7 @@ pub fn base_value_with(m: &Mob, a: Attr, default: impl FnOnce() -> Option<f64>)
pub fn made_at_load(m: &Mob) -> Vec<Attr> {
let s = m.species();
let mut made = Vec::new();
if super::doors::is_zombie(s)
|| matches!(s, Species::Piglin | Species::Enderman)
|| (s == Species::Ravager && super::ravager::immobile(m))
|| m.age > 0
{
if super::doors::is_zombie(s) || matches!(s, Species::Piglin | Species::Enderman) || m.age > 0 {
made.push(Attr::MovementSpeed);
}
if s == Species::Wolf {
+7 -9
View File
@@ -95,18 +95,16 @@ pub fn ai_step(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
if !m.alive() {
return;
}
let max = if immobile(m) {
0.0
} else if m.ai.target.is_some() {
0.35
} else {
0.3
};
m.attrs.speed = if max == 0.0 {
// The speed lives in the attribute's base, which the save writes and the
// load restores (AttributeMap.pack and apply).
let speed = if immobile(m) {
0.0
} else {
m.attrs.speed + (max - m.attrs.speed) * 0.1
let max = if m.ai.target.is_some() { 0.35 } else { 0.3 };
let base = super::finalize::base_value(m, super::finalize::Attr::MovementSpeed);
base + (max - base) * 0.1
};
super::finalize::set_base(m, super::finalize::Attr::MovementSpeed, speed);
if m.horizontal_collision && w.mob_griefing {
let b = m
.bounds()
+1 -1
View File
@@ -152,7 +152,7 @@ pub fn armor_tint(undyed: Option<u32>, stack: Option<&crate::items::ItemStack>)
.unwrap_or(undyed);
abgr(dyed)
}
pub use crate::pack_dyes::{diffuse as dye, sheep_wool as sheep_wool_rgb};
pub use crate::pack_palette::{diffuse as dye, sheep_wool as sheep_wool_rgb};
/// `ColorLerper.getLerpedColor(ColorLerper.Type.SHEEP, ageInTicks)`: the 16
/// wool colours in DyeColor order, one per 25 ticks, lerped in between. The
/// colour a `jeb_` sheep's wool takes.
+168 -9
View File
@@ -143,6 +143,21 @@ impl Goal for RandomMove {
}
}
}
/// `Vex.getOwner`: the mob the vex is bound to. `counter2` holds its id while
/// the evoker that summoned it is in the level; a vex loaded from a save knows
/// only the UUID it was saved with, and vanilla's `EntityReference.getEntity`
/// asks the level for that entity every time it is called
/// (`MobWorld::who_by_uuid`), so an owner that loaded later is found too.
pub fn owner(m: &Mob, lookup: Option<&dyn super::WhoLookup>) -> Option<u32> {
if m.extra.counter2 != 0 {
return Some(m.extra.counter2 as u32);
}
match lookup?.who_by_uuid(m.extra.owner?)? {
Who::Mob(id) => Some(id),
// `Vex.setOwner` takes a `Mob`: a UUID naming the player is no owner.
Who::Player => None,
}
}
/// `VexCopyOwnerTargetGoal`: the evoker's target.
#[derive(Clone, Debug)]
struct CopyOwnerTarget;
@@ -152,26 +167,24 @@ impl Goal for CopyOwnerTarget {
super::goals::TARGET
}
fn can_use(&mut self, m: &mut Mob, cx: &mut Cx) -> bool {
let owner = m.extra.counter2 as u32;
owner != 0
&& cx
.w
.others
let seen = owner(m, cx.w.who_lookup).and_then(|owner| {
cx.w.others
.iter()
.find(|s| s.id == owner)
.and_then(|s| s.target)
.is_some_and(|t| t != Who::Mob(m.id))
});
seen.is_some_and(|t| t != Who::Mob(m.id))
}
fn can_continue(&mut self, m: &mut Mob, cx: &mut Cx) -> bool {
m.target_info(cx.w).is_some_and(|t| t.alive)
}
fn start(&mut self, m: &mut Mob, cx: &mut Cx) {
let owner = m.extra.counter2 as u32;
m.ai.target =
m.ai.target = owner(m, cx.w.who_lookup).and_then(|owner| {
cx.w.others
.iter()
.find(|s| s.id == owner)
.and_then(|s| s.target);
.and_then(|s| s.target)
});
}
}
/// `VexMoveControl`: accelerate straight at the wanted point.
@@ -275,3 +288,149 @@ pub fn texture(m: &Mob) -> &'static str {
"entity/illager/vex"
}
}
/// `Vex.addAdditionalSaveData`: the bound origin, the limited life and the
/// owner reference's UUID (`EntityReference.store`, left out when there is no
/// owner). The level answers for an owner still in it; a reference the vex was
/// loaded with stands when the owner is gone.
pub fn write_nbt(
m: &Mob,
map: &mut horizon_world::nbt::Compound,
uuid_of: &mut impl FnMut(u32) -> Option<[i32; 4]>,
) {
use horizon_world::nbt::Tag;
if let Some(h) = m.extra.home {
map.insert("bound_pos".into(), Tag::IntArray(h.to_vec()));
}
if m.extra.egg_time > 0 {
map.insert("life_ticks".into(), Tag::Int(m.extra.egg_time));
}
let resolved = if m.extra.counter2 != 0 {
crate::entity_nbt::who_uuid(Who::Mob(m.extra.counter2 as u32), uuid_of)
} else {
None
};
match resolved.or(m.extra.owner) {
Some(uuid) => {
map.insert("owner".into(), Tag::IntArray(uuid.to_vec()));
}
None => {
map.remove(&"owner".into());
}
}
}
/// `Vex.readAdditionalSaveData`: the bound origin, the life left (-1 when
/// absent) and the owner's UUID, kept as the UUID it is - an id would name
/// whatever mob has it now - and resolved through the level when the vex asks
/// for its owner (`owner`).
pub fn read_nbt(m: &mut Mob, tag: &horizon_world::nbt::Tag) {
use horizon_world::nbt::Tag;
if let Some(Tag::IntArray(v)) = tag.get("bound_pos")
&& v.len() == 3
{
m.extra.home = Some([v[0], v[1], v[2]]);
}
m.extra.egg_time = crate::save::number(tag.get("life_ticks")).map_or(-1, |v| v as i32);
m.extra.owner = crate::save::uuid_from_tag(tag.get("owner"));
}
#[cfg(test)]
mod owner_save_tests {
use super::*;
use crate::entity_nbt::{mob_from_nbt, mob_to_nbt};
use crate::mobs::WhoLookup;
use horizon_world::nbt::Tag;
use std::collections::HashMap;
use std::sync::Arc;
/// A vex with the id `EVOKER` in the level: what `Vex.setOwner` records
/// when an evoker summons it.
const EVOKER: u32 = 7;
fn vex() -> Mob {
let mut kind = crate::mobs::sensor_scan_tests::kind();
let ty = Arc::make_mut(&mut kind);
ty.id = "minecraft:vex".into();
ty.species = Species::Vex;
Mob::new(1, kind, Vec3::ZERO, 0.0)
}
/// `Level.getEntity(uuid)` for the test: `Game` answers this with
/// `Game::who_by_uuid`.
struct Level(HashMap<[i32; 4], Who>);
impl WhoLookup for Level {
fn who_by_uuid(&self, uuid: [i32; 4]) -> Option<Who> {
self.0.get(&uuid).copied()
}
}
/// `Vex.addAdditionalSaveData` stores its owner reference's UUID
/// (`EntityReference.store`, nothing at all without an owner) and
/// `readAdditionalSaveData` reads it back (`EntityReference.read`), so a
/// summoned vex keeps the evoker whose target it copies across a chunk
/// save. Horizon models the owner as the owner's mob id and wrote neither,
/// so a reloaded vex had no owner to copy a target from.
#[test]
fn a_vexs_owner_uuid_survives_the_chunk_save() {
let evoker_uuid = [3, 1, 4, 1];
let mut summoned = vex();
assert_eq!(owner(&summoned, None), None, "a vex nobody summoned");
summoned.extra.counter2 = EVOKER as i32;
assert_eq!(owner(&summoned, None), Some(EVOKER), "the evoker's id");
let uuid_of = |id: u32| (id == EVOKER).then_some(evoker_uuid);
let saved = mob_to_nbt(&summoned, [0; 4], None, None, uuid_of);
assert_eq!(
saved.get("owner"),
Some(&Tag::IntArray(evoker_uuid.to_vec()))
);
// The loaded vex knows the UUID: no id survives a save, and its owner
// is found again only through the level (`EntityReference.getEntity`).
let types = [("minecraft:vex".into(), summoned.kind.clone())].into();
let back = mob_from_nbt(&saved, &types, 2, None).unwrap();
assert_eq!(back.extra.owner, Some(evoker_uuid));
assert_eq!(back.extra.counter2, 0);
let level = Level(HashMap::from([(evoker_uuid, Who::Mob(42))]));
assert_eq!(owner(&back, Some(&level)), Some(42), "the evoker, found");
// An owner the level does not have, and a UUID naming the player, are
// no owner: `Vex.setOwner` takes a `Mob`.
assert_eq!(owner(&back, Some(&Level(HashMap::new()))), None);
let player = Level(HashMap::from([(evoker_uuid, Who::Player)]));
assert_eq!(owner(&back, Some(&player)), None);
assert_eq!(owner(&back, None), None, "a level with no UUIDs");
// A save read again keeps the reference even when nothing in the level
// answers for it, as `EntityReference` does.
let again = mob_to_nbt(&back, [0; 4], None, None, |_| None);
assert_eq!(
again.get("owner"),
Some(&Tag::IntArray(evoker_uuid.to_vec()))
);
}
/// `EntityReference.store` writes nothing for a vex with no owner, and a
/// save that names none loads with none - neither the `owner` key a loaded
/// compound carried nor an id left over may stand.
#[test]
fn a_vex_with_no_owner_saves_no_owner_key() {
let mut loose = vex();
let stale = Tag::compound([
("id", Tag::String("minecraft:vex".into())),
(
"Pos",
Tag::List(vec![
Tag::Double(0.5_f64.to_bits()),
Tag::Double(64.0_f64.to_bits()),
Tag::Double(0.5_f64.to_bits()),
]),
),
("owner", Tag::IntArray(vec![9, 9, 9, 9])),
]);
let types = [("minecraft:vex".into(), loose.kind.clone())].into();
let loaded = mob_from_nbt(&stale, &types, 3, None).unwrap();
assert_eq!(loaded.extra.owner, Some([9, 9, 9, 9]));
loose.extra.owner = None;
loose.extra.counter2 = 0;
let saved = mob_to_nbt(&loose, [0; 4], Some(&stale), None, |_| None);
assert_eq!(saved.get("owner"), None, "no owner, no key");
}
}
+33
View File
@@ -1221,6 +1221,32 @@ pub use generated::Hooks;
#[doc(hidden)]
pub use generated::*;
/// Runs `body` in a fresh process of this test binary: `mod_api::MODS` is set once per process, and
/// another test that set it first would make this one's `set` fail. The parent starts the binary again
/// for just this test (`--exact`, `--include-ignored`) and requires that exactly one test ran and passed; the
/// child sees `OWN_PROCESS` and runs `body` itself.
#[cfg(test)]
pub(crate) fn in_own_process(name: &str, body: impl FnOnce()) {
const OWN_PROCESS: &str = "HORIZON_TEST_OWN_PROCESS";
if std::env::var_os(OWN_PROCESS).is_some_and(|v| v == name) {
return body();
}
let out = std::process::Command::new(std::env::current_exe().unwrap())
.args([name, "--exact", "--include-ignored", "--test-threads=1"])
.env(OWN_PROCESS, name)
.output()
.unwrap();
let report = format!(
"{}{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
assert!(
out.status.success() && report.contains("test result: ok. 1 passed;"),
"{name} failed in its own process:\n{report}"
);
}
#[cfg(test)]
mod tests {
use super::*;
@@ -1233,6 +1259,13 @@ mod tests {
/// turned on; a test's own list is never switched.
#[test]
fn a_mod_turned_off_is_skipped_until_turned_on() {
crate::mod_api::in_own_process(
"mod_api::tests::a_mod_turned_off_is_skipped_until_turned_on",
a_mod_turned_off_is_skipped_until_turned_on_in_own_process,
);
}
fn a_mod_turned_off_is_skipped_until_turned_on_in_own_process() {
name_mods(&["quiet", "broken"]);
let _ = MODS.set(Arc::from(vec![
Box::new(Quiet) as Box<dyn Hooks>,
+5 -1
View File
@@ -255,6 +255,9 @@ pub struct Projectile {
pub air_supply: i32,
/// `Entity.hasVisualFire`: an optional saved flag independent of burning.
pub has_visual_fire: bool,
/// `Entity.fallDistance` (`fall_distance` in its save): a projectile sets
/// its position without `Entity.move`, so it keeps what it was given.
pub fall_distance: f64,
/// `Entity.DATA_SHARED_FLAGS_ID`'s fire bit, set by the server's base tick.
pub synced_fire: bool,
/// `Fireball.DATA_ITEM_STACK`: the item a fireball is drawn as, a fire
@@ -473,6 +476,7 @@ impl Projectile {
fire_ticks: 0,
air_supply: 300,
has_visual_fire: false,
fall_distance: 0.0,
synced_fire: false,
item,
arrow: kind_is_arrow.then(|| {
@@ -675,7 +679,7 @@ impl Projectile {
return -1;
}
let rgb = match (effect, &self.arrow) {
(Some((id, _)), _) => crate::brewing::effect(id).map(|e| e.color),
(Some((id, _)), _) => crate::brewing::effect(id).map(|e| e.shown_color()),
(None, Some(a)) if &*a.pickup_item.item == "minecraft:tipped_arrow" => a
.pickup_item
.potion()
+166
View File
@@ -8,10 +8,41 @@ use std::path::Path;
use serde_json::Value as Json;
/// The `environment` fog settings vanilla hardcodes, as the packs set them (vanilla's by default).
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Environment {
/// `powder_snow_fog`: the fog colour inside powder snow (RGB).
pub powder_snow_fog: Option<u32>,
/// `blindness_fog_distance`, `darkness_fog_distance`, `boss_fog_distance`: the fog ends of
/// Blindness (5), Darkness (15) and bosses that add world fog (96).
pub blindness_fog_distance: f32,
pub darkness_fog_distance: f32,
pub boss_fog_distance: f32,
/// `water_fog_brightening`: how much the underwater fog colour washes out (1 vanilla, 0 none).
pub water_fog_brightening: f32,
/// `water_fog_brightening_time`: the ticks under water it takes (vanilla's 600).
pub water_fog_brightening_time: f32,
}
impl Default for Environment {
fn default() -> Self {
Self {
powder_snow_fog: None,
blindness_fog_distance: 5.0,
darkness_fog_distance: 15.0,
boss_fog_distance: 96.0,
water_fog_brightening: 1.0,
water_fog_brightening_time: 600.0,
}
}
}
/// Every pack's custom colours, by dotted key.
#[derive(Clone, Debug, Default)]
pub struct PackColors {
entries: BTreeMap<String, Json>,
/// [`Self::environment`], read once with the packs: the fog reads it every frame.
pub env: Environment,
}
impl PackColors {
@@ -38,6 +69,7 @@ impl PackColors {
}
}
}
colors.env = colors.environment();
colors
}
@@ -93,6 +125,91 @@ impl PackColors {
self.color(&format!("sheep.{name}"))
}
/// `redstone.<power>`: the wire's and its dust particles' colour at that power (0 to 15).
pub fn redstone(&self, power: u8) -> Option<u32> {
self.color(&format!("redstone.{power}"))
}
/// `effect.<id>` or `effect.<id>.color` (the deprecated `potion.<id>` too): mob effects'
/// colours by id without vanilla's namespace, as the effects table names them. `water` and
/// `empty` are left out: they change nothing on the real mod.
pub fn effects(&self) -> Vec<(String, u32)> {
let mut out = Vec::new();
for (key, value) in &self.entries {
let Some(rest) = key
.strip_prefix("effect.")
.or_else(|| key.strip_prefix("potion."))
else {
continue;
};
let id = rest.strip_suffix(".color").unwrap_or(rest);
if id.ends_with(".particle") || matches!(id, "water" | "empty" | "minecraft:water") {
continue;
}
if let Some(color) = parse_color(value) {
let id = id.strip_prefix("minecraft:").unwrap_or(id);
// `effect.<id>.color` wins over the plain key whichever comes first.
out.retain(|(other, _)| other != id || !key.ends_with(".color"));
if !out.iter().any(|(other, _)| other == id) {
out.push((id.to_owned(), color));
}
}
}
out
}
/// `particle.<id>`: a colour or an expression each new particle of that type takes, by
/// namespaced type id.
pub fn particles(&self) -> Vec<(String, String)> {
self.entries
.iter()
.filter_map(|(key, value)| {
let id = key.strip_prefix("particle.")?;
let id = if id.contains(':') {
id.to_owned()
} else {
format!("minecraft:{id}")
};
let value = match value {
Json::String(s) => s.trim().to_owned(),
Json::Number(n) => n.to_string(),
_ => return None,
};
Some((id, value))
})
.collect()
}
/// `environment.*`: the fog settings a pack replaces.
pub fn environment(&self) -> Environment {
let number = |key: &str, default: f32| {
match self.entries.get(&format!("environment.{key}")) {
Some(Json::Number(n)) => n.as_f64().map(|v| v as f32),
Some(Json::String(s)) => s.trim().parse().ok(),
_ => None,
}
.unwrap_or(default)
};
let vanilla = Environment::default();
Environment {
powder_snow_fog: self
.color("environment.powder_snow_fog")
.map(|c| c & 0xFF_FFFF),
blindness_fog_distance: number(
"blindness_fog_distance",
vanilla.blindness_fog_distance,
),
darkness_fog_distance: number("darkness_fog_distance", vanilla.darkness_fog_distance),
boss_fog_distance: number("boss_fog_distance", vanilla.boss_fog_distance),
water_fog_brightening: number("water_fog_brightening", vanilla.water_fog_brightening),
water_fog_brightening_time: number(
"water_fog_brightening_time",
vanilla.water_fog_brightening_time,
)
.max(1.0),
}
}
/// `entity_shadows`: false hides every entity's round shadow, whatever the video option says.
pub fn entity_shadows(&self) -> bool {
match self.entries.get("entity_shadows") {
@@ -161,6 +278,55 @@ mod tests {
assert_eq!(p.xp_bar(), [Some(0x00ff22), Some(u32::MAX)]);
}
#[test]
fn effects_particles_and_redstone_by_id() {
let mut c = PackColors::default();
c.add_json(
"",
&json!({
"effect": {"minecraft:speed": {"color": "ff0000", "particle": "minecraft:poof"}, "water": "00ff00", "poison": 7},
"particle": {"minecraft:flame": "0x0000ff", "poof": 0},
"redstone": {"15": "00ff00"}
}),
);
let mut effects = c.effects();
effects.sort();
assert_eq!(
effects,
[("poison".to_owned(), 7), ("speed".to_owned(), 0xff0000)]
);
let mut particles = c.particles();
particles.sort();
assert_eq!(
particles,
[
("minecraft:flame".to_owned(), "0x0000ff".to_owned()),
("minecraft:poof".to_owned(), "0".to_owned())
]
);
assert_eq!((c.redstone(15), c.redstone(0)), (Some(0x00ff00), None));
}
#[test]
fn environment_keeps_vanillas_where_unset() {
let mut c = PackColors::default();
assert_eq!(c.environment(), Environment::default());
c.add_json(
"",
&json!({"environment": {"powder_snow_fog": "#c0c0d0", "blindness_fog_distance": 40, "water_fog_brightening_time": "200"}}),
);
let e = c.environment();
assert_eq!(e.powder_snow_fog, Some(0xc0c0d0));
assert_eq!(
(e.blindness_fog_distance, e.darkness_fog_distance),
(40.0, 15.0)
);
assert_eq!(
(e.water_fog_brightening, e.water_fog_brightening_time),
(1.0, 200.0)
);
}
#[test]
fn dyes_name_their_colour_kind() {
let mut c = PackColors::default();
+28 -5
View File
@@ -76,11 +76,34 @@ impl Launcher {
}
// Pictures line up: a world without one gets a plain tile when another has one.
let pictures = list.iter().any(|w| w.icon.is_some());
for w in list {
let row = self.glass_world_row(&w, pictures, cx);
body = body.child(row).children(self.world_packs_panel(&w, cx));
}
Some(body)
// A long saves folder: only the rows near the view are built (`cull`). The world being
// worked on and the one whose ⋯ menu is open stay built, wherever the page is scrolled.
let kept: Vec<bool> = list
.iter()
.map(|w| {
let key = format!("world-{}", w.folder);
self.worlds.busy.as_ref() == Some(&w.folder)
|| self.worlds.confirm_delete.as_ref() == Some(&w.folder)
|| self.list_menu.as_ref().is_some_and(|(k, _)| **k == *key)
})
.collect();
let (cull, scroll) = (self.worlds.cull.clone(), self.content_scroll.handle.clone());
let rows = cull.column(
&scroll,
list.len(),
0.,
|i| kept[i],
|i| {
let w = &list[i];
let row = self.glass_world_row(w, pictures, cx);
div()
.flex()
.flex_col()
.child(row)
.children(self.world_packs_panel(w, cx))
},
);
Some(body.child(rows))
}
fn glass_world_row(&mut self, w: &World, pictures: bool, cx: &mut Context<Self>) -> Div {
+77 -69
View File
@@ -421,8 +421,13 @@ impl Launcher {
.child(line);
}
let picked = self.mods_picked().map(|p| p.id.clone());
let mut list = div().flex().flex_col();
for i in 0..self.mods.results.len() {
// Registry pages add up behind More: only the rows near the view are built (`cull`).
let (rows, scroll) = (
self.mods.results_list.clone(),
self.content_scroll.handle.clone(),
);
let total = self.mods.results.len();
let row = |i: usize| {
let p = self.mods.results[i].clone();
let icon = self.mods_icon(&p, 40.);
let m = &self.mods;
@@ -484,73 +489,76 @@ impl Launcher {
.child(tr(support.label()))
};
let id = p.id.clone();
list = list.child(
ui::row(
SharedString::from(format!("mods-row-{}", p.id)),
picked.as_deref() == Some(p.id.as_str()),
12.,
&t,
)
.px(px(12.))
.py(px(10.))
.child(
div()
.flex_none()
.size(px(40.))
.rounded(px(8.))
.overflow_hidden()
.child(icon),
)
.child(
div()
.flex()
.flex_col()
.flex_1()
.min_w_0()
.child(
div()
.flex()
.items_baseline()
.gap(px(8.))
.min_w_0()
.child(
ui::text(14., FontWeight::MEDIUM, ink::FULL)
.truncate()
.child(p.title.clone()),
)
.child(ui::text(13., FontWeight::NORMAL, 0.5).truncate().child(
match p.kind {
PackKind::Mod | PackKind::Shader => p.author.clone(),
_ => tr(kind_name(p.kind)).to_string(),
},
)),
)
.child(
ui::text(13., FontWeight::NORMAL, 0.6)
.truncate()
.child(p.summary.clone()),
)
.children(
m.works
.get(&p.id)
.filter(|_| p.source == SourceId::Horizon)
.map(|(works, line)| {
ui::text(12., FontWeight::NORMAL, ink::FULL)
.truncate()
.text_color(if *works { ui::live() } else { white(0.4) })
.child(line.clone())
}),
),
)
.child(right.flex_none())
.child(action)
.on_click(cx.listener(move |this, _: &ClickEvent, _, cx| {
this.mods.picked = Some(id.clone());
this.mods.confirm_remove = None;
cx.notify();
})),
);
}
ui::row(
SharedString::from(format!("mods-row-{}", p.id)),
picked.as_deref() == Some(p.id.as_str()),
12.,
&t,
)
.px(px(12.))
.py(px(10.))
.child(
div()
.flex_none()
.size(px(40.))
.rounded(px(8.))
.overflow_hidden()
.child(icon),
)
.child(
div()
.flex()
.flex_col()
.flex_1()
.min_w_0()
.child(
div()
.flex()
.items_baseline()
.gap(px(8.))
.min_w_0()
.child(
ui::text(14., FontWeight::MEDIUM, ink::FULL)
.truncate()
.child(p.title.clone()),
)
.child(ui::text(13., FontWeight::NORMAL, 0.5).truncate().child(
match p.kind {
PackKind::Mod | PackKind::Shader => p.author.clone(),
_ => tr(kind_name(p.kind)).to_string(),
},
)),
)
.child(
ui::text(13., FontWeight::NORMAL, 0.6)
.truncate()
.child(p.summary.clone()),
)
.children(
m.works
.get(&p.id)
.filter(|_| p.source == SourceId::Horizon)
.map(|(works, line)| {
ui::text(12., FontWeight::NORMAL, ink::FULL)
.truncate()
.text_color(if *works { ui::live() } else { white(0.4) })
.child(line.clone())
}),
),
)
.child(right.flex_none())
.child(action)
.on_click(cx.listener(move |this, _: &ClickEvent, _, cx| {
this.mods.picked = Some(id.clone());
this.mods.confirm_remove = None;
cx.notify();
}))
};
let mut list =
div()
.flex()
.flex_col()
.child(rows.column(&scroll, total, 0., |_| false, row));
if self.mods.more {
let searching = self.mods.searching;
let offset = self.mods.results.len();
+4 -1
View File
@@ -11,11 +11,13 @@ pub(super) struct Worlds {
/// read again, so the page doesn't jump.
pub(super) home: Option<PathBuf>,
pub(super) list: Vec<World>,
/// Which of the list's rows Home's glass Worlds tab has built and measured (`cull`).
pub(super) cull: cull::Cull,
fresh: bool,
reading: bool,
/// The world being backed up, copied or deleted now.
pub(super) busy: Option<String>,
confirm_delete: Option<String>,
pub(super) confirm_delete: Option<String>,
/// What the last action did (a backup's zip), or why it failed.
pub(super) done: Option<Result<(String, Option<PathBuf>), String>>,
}
@@ -73,6 +75,7 @@ impl Launcher {
// Another instance may have been picked meanwhile.
if this.worlds.home.as_ref() == Some(&home) {
this.worlds.list = list;
this.worlds.cull.reset();
this.worlds.fresh = true;
this.worlds.reading = false;
}
+157
View File
@@ -1105,6 +1105,41 @@ def vehicle_saved_ground_and_fall():
f'/forceload remove {x0} {z0}')
def ravager_saved_movement_speed():
"""Ravager.aiStep sets the movement_speed base to 0.0 while biting, stunned or roaring, then lerps it back
toward 0.3. The instance exists only from that first aiStep (AttributeMap.getInstance makes it lazily), and
AttributeMap.pack saves it with the current base, so a reloaded ravager keeps its speed. Read after
summoning, after one and five ticks, after an unload and reload (frozen), and two ticks later.
"""
x0, y0, z0 = 3904, 300, 3904
chunk = [[x0 >> 4, z0 >> 4]]
subjects = ['RoarTick:7', 'RoarTick:1', 'StunTick:3', 'AttackTick:5', 'AttackTick:0']
ops = [cmd(f'/forceload add {x0} {z0}', False), {'op': 'wait_chunks', 'chunks': chunk},
cmd(f'/fill {x0} {y0 - 1} {z0} {x0 + 15} {y0 - 1} {z0 + 15} stone', expect_result=256)]
for n, body in enumerate(subjects):
ops.append(cmd(f'/summon ravager {x0 + 2 + 3 * n}.5 {y0} {z0 + 8}.5 '
f'{{Tags:["rav_speed_{n}"],PersistenceRequired:1b,{body}}}', expect_result=1))
def reads():
for n in range(len(subjects)):
ops.append(cmd(f'/data get entity @e[tag=rav_speed_{n},limit=1] attributes'))
reads()
ops.append({'op': 'tick', 'n': 1})
reads()
ops.append({'op': 'tick', 'n': 4})
reads()
ops += [cmd('/tick freeze', False), cmd(f'/forceload remove {x0} {z0}', False), {'op': 'tick', 'n': 60},
cmd(f'/execute unless loaded {x0} {y0} {z0}', expect_result=1),
cmd(f'/forceload add {x0} {z0}', False), {'op': 'wait_chunks', 'chunks': chunk}]
reads()
ops += [cmd('/tick unfreeze', False), {'op': 'tick', 'n': 2}]
reads()
return ops + setup(f'/kill @e[type=!player,x={x0},y={y0 - 8},z={z0},dx=16,dy=24,dz=16]',
f'/fill {x0} {y0 - 1} {z0} {x0 + 15} {y0 - 1} {z0 + 15} air',
f'/forceload remove {x0} {z0}')
def vehicle_ground_and_fall():
"""Entity.load reads OnGround (getBooleanOr) and fall_distance (getDoubleOr) for every entity,
and Entity.saveWithoutId writes the live values: boats, rafts and minecarts keep both. Frozen
@@ -6527,6 +6562,52 @@ def primed_tnt_fall():
return ops
def item_fall_distance():
"""#13203: a dropped item is an `Entity`: `checkFallDamage` adds each step down,
narrowed to a float, to its double `fall_distance` outside water, the ground and water
reset it, and `Entity.save` / `load` keep it. A fast fall past a vine on a wall resets it
(`Entity.move`'s FALLDAMAGE_RESETTING cast) and a cobweb makes it stuck, which resets it."""
sel = '@e[type=minecraft:item,tag=item_fall,limit=1]'
ops = [cmd('/forceload add 32 32', False), {'op': 'wait_chunks', 'chunks': [[2, 2]]}]
def track(ticks):
out = []
for _ in range(ticks):
out.append({'op': 'tick', 'n': 1})
for path in ('Pos', 'Motion', 'fall_distance'):
out.append(cmd(f'/data get entity {sel} {path}'))
return out
item = 'Item:{id:"minecraft:stone",count:1},PickupDelay:32767,Tags:["item_fall"]'
# block at 32 210 32, summon y, fields, ticks recorded. The landing is read once: the
# resting item's next Motion still differs (a separate gap).
for block, y, fields, ticks in (
('air', 216, 'Motion:[0.0d,-0.3d,0.0d]', 4),
('stone', 211.1, 'fall_distance:3.0d,Motion:[0.0d,-1.0d,0.0d]', 1),
('water', 213, 'fall_distance:2.0d,Motion:[0.0d,-0.5d,0.0d]', 6),
('cobweb', 212, 'Motion:[0.0d,-0.5d,0.0d]', 5)):
ops += setup('/kill @e[type=!player]', '/fill 28 205 28 36 220 36 air',
'/fill 30 209 30 34 209 34 stone', f'/setblock 32 210 32 {block}',
f'/summon minecraft:item 32.5 {y} 32.5 {{{fields},{item}}}')
ops += track(ticks)
# Fast past a vine on the wall beside it: the cast of the move resets the fall first.
ops += setup('/kill @e[type=!player]', '/fill 28 205 28 36 220 36 air',
'/fill 30 209 30 34 209 34 stone', '/fill 31 212 32 31 219 32 stone',
'/setblock 32 215 32 vine[west=true]',
f'/summon minecraft:item 32.5 218 32.5 {{fall_distance:5.0d,Motion:[0.0d,-1.5d,0.0d],{item}}}')
ops += track(3)
# Load and /data write the field; a frozen item keeps it.
ops += setup('/kill @e[type=!player]', '/fill 28 205 28 36 220 36 air',
f'/summon minecraft:item 32.5 216 32.5 {{fall_distance:4.5f,NoGravity:1b,{item}}}')
ops.append(cmd(f'/data get entity {sel} fall_distance'))
ops.append(cmd(f'/data modify entity {sel} fall_distance set value 2.25d'))
ops.append(cmd(f'/data get entity {sel} fall_distance'))
ops.append(cmd(f'/data merge entity {sel} {{NoGravity:0b,Motion:[0.0d,-0.5d,0.0d]}}'))
ops += track(2)
return ops + setup('/kill @e[type=!player]', '/fill 28 205 28 36 220 36 air', '/forceload remove 32 32')
def loaded_dead_mob():
"""#13199: `LivingEntity.readAdditionalSaveData` reads `Health` through `setHealth`, which
clamps a negative value to 0 and keeps the body: a mob summoned dead is there, `/data` reads
@@ -6556,6 +6637,54 @@ def loaded_dead_mob():
return ops + setup(f'/kill @e[type=!player,x={x0},y={y0 - 8},z={z0},dx=16,dy=32,dz=16]',
f'/fill {x0} {y0 - 1} {z0} {x0 + 15} {y0 - 1} {z0 + 15} air',
f'/forceload remove {x0} {z0}')
def falling_block_fall_float():
"""#13701: `Entity.checkFallDamage` adds each step down narrowed to a float (`fallDistance -= (float)ya`);
a falling block never runs `baseTick`, so it is never in water and every step counts."""
x0, y0, z0 = 4096 - 64, 260, 3648
ops = [cmd(f'/forceload add {x0} {z0}', False), {'op': 'wait_chunks', 'chunks': [[x0 >> 4, z0 >> 4]]},
cmd(f'/fill {x0} {y0 - 1} {z0} {x0 + 15} {y0 - 1} {z0 + 15} stone', False)]
sel = '@e[type=minecraft:falling_block,tag=fb_fall,limit=1]'
for block, y in (('sand', y0 + 6.5), ('anvil', y0 + 3.5)):
ops.append(cmd(f'/summon falling_block {x0 + 8}.5 {y} {z0 + 8}.5 '
f'{{BlockState:{{Name:"minecraft:{block}"}},Time:1,Tags:["fb_fall"]}}', expect_result=1))
for _ in range(6):
ops.append({'op': 'tick', 'n': 1})
ops.append(cmd(f'/data get entity {sel} fall_distance'))
ops.append(cmd(f'/data get entity {sel} Pos[1]'))
ops.append(cmd(f'/kill @e[type=minecraft:falling_block,tag=fb_fall]', False))
ops.append(cmd(f'/fill {x0} {y0} {z0} {x0 + 15} {y0 + 8} {z0 + 15} air', False))
return ops + setup(f'/fill {x0} {y0 - 1} {z0} {x0 + 15} {y0 + 8} {z0 + 15} air', f'/forceload remove {x0} {z0}')
def explosion_infested_blocks():
"""#11330: `Block.onExplosionHit` calls `spawnAfterBreak` before the drops, so a blast lets an
infested block's silverfish out (`InfestedBlock.spawnInfestation`) while `block_drops` is on. The
difficulty is not peaceful, where the silverfish would despawn on their first tick. The blast without
drops goes first: the explosion's rays draw from the level's random, and a mob's spawn draws differ."""
x0, y0, z0 = 3968, 260, 3648
ops = [cmd(f'/forceload add {x0} {z0}', False), {'op': 'wait_chunks', 'chunks': [[x0 >> 4, z0 >> 4]]},
cmd('/difficulty normal', False)]
for drops in ('false', 'true'):
ops += [cmd(f'/gamerule block_drops {drops}', False),
cmd(f'/fill {x0} {y0 - 1} {z0} {x0 + 15} {y0 + 6} {z0 + 15} air', False),
cmd(f'/fill {x0} {y0 - 1} {z0} {x0 + 15} {y0 - 1} {z0 + 15} bedrock', False),
# Four blocks right under the TNT, which every ray breaks: how many edge blocks resist
# depends on each ray's random intensity.
cmd(f'/fill {x0 + 7} {y0} {z0 + 7} {x0 + 8} {y0} {z0 + 7} infested_stone', expect_result=2),
cmd(f'/fill {x0 + 7} {y0} {z0 + 8} {x0 + 8} {y0} {z0 + 8} infested_cobblestone', expect_result=2),
cmd(f'/summon tnt {x0 + 8} {y0 + 1} {z0 + 8} {{fuse:0}}', expect_result=1),
cmd(f'/execute if entity @e[type=tnt,x={x0},y={y0 - 2},z={z0},dx=16,dy=12,dz=16]'),
{'op': 'tick', 'n': 1},
cmd(f'/execute if entity @e[type=tnt,x={x0},y={y0 - 2},z={z0},dx=16,dy=12,dz=16]'),
cmd(f'/execute if entity @e[type=silverfish,x={x0},y={y0 - 2},z={z0},dx=16,dy=12,dz=16]'),
cmd(f'/execute if block {x0 + 7} {y0} {z0 + 7} infested_stone'),
{'op': 'tick', 'n': 3},
cmd(f'/execute if entity @e[type=silverfish,x={x0},y={y0 - 2},z={z0},dx=16,dy=12,dz=16]'),
cmd(f'/kill @e[type=!player,x={x0},y={y0 - 2},z={z0},dx=16,dy=12,dz=16]', False)]
return ops + setup('/gamerule block_drops true',
f'/fill {x0} {y0 - 1} {z0} {x0 + 15} {y0 + 6} {z0 + 15} air', f'/forceload remove {x0} {z0}')
def jigsaw_default_joint():
"""#13654: `JigsawBlockEntity.loadAdditional` reads `joint`, else `StructureTemplate.getDefaultJointType`:
`aligned` when the block's front faces sideways, `rollable` when it faces up or down."""
@@ -6646,6 +6775,29 @@ def zombie_villager_summoned_data():
f'/forceload remove {x0} {z0}')
def projectile_fall_distance():
"""#13702: `Entity.load` reads `fall_distance` for every entity and `saveWithoutId` writes it; a projectile
moves by `setPos`, never through `Entity.move`, so in the air it keeps what it was given."""
# Away from the cases at z 3648: unloading this chunk at the end would keep vanilla's chunks two
# columns away from entity ticking for a few ticks (the explosion and falling block cases next).
x0, y0, z0 = 4096 - 96, 280, 3392
ops = [cmd(f'/forceload add {x0} {z0}', False), {'op': 'wait_chunks', 'chunks': [[x0 >> 4, z0 >> 4]]},
cmd('/tick freeze', False)]
kinds = [('fireball', ''), ('wither_skull', ''), ('small_fireball', ''), ('snowball', ''),
('fireball', ',fall_distance:2.5f')]
for n, (kind, extra) in enumerate(kinds, 1):
fields = extra.lstrip(',') if extra else 'fall_distance:1.0d'
uuid = f'000035f0-0000-0000-0000-{n:04x}00000001'
ops += [cmd(f'/summon {kind} {x0 + 2 * n}.5 {y0} {z0 + 8}.5 {{UUID:[I;13808,0,{n},1],{fields},'
f'Motion:[0.0d,0.0d,0.0d],acceleration_power:0.0d}}', expect_result=1),
cmd(f'/data get entity {uuid} fall_distance')]
ops += [cmd('/tick unfreeze', False), {'op': 'tick', 'n': 1}, cmd('/tick freeze', False)]
for n in range(1, len(kinds) + 1):
ops.append(cmd(f'/data get entity 000035f0-0000-0000-0000-{n:04x}00000001 fall_distance'))
return ops + setup('/tick unfreeze', f'/kill @e[type=!player,x={x0},y={y0 - 8},z={z0},dx=16,dy=32,dz=16]',
f'/forceload remove {x0} {z0}')
def loaded_ticket_removal(blocks=False):
"""Entity-ticking command visibility after forced tickets leave; FULL is separate."""
targets = [(d, x, z) for d in range(2, 7)
@@ -7224,9 +7376,13 @@ def groups():
('piston entity tick order', piston_entity_tick_order()),
('primed tnt box', primed_tnt_box()),
('primed tnt fall', primed_tnt_fall()),
('projectile fall distance', projectile_fall_distance()),
('item fall distance', item_fall_distance()),
('beacon levels load', beacon_levels_load()),
('zombie villager summoned data', zombie_villager_summoned_data()),
('loaded dead mob', loaded_dead_mob()),
('falling block fall float', falling_block_fall_float()),
('explosion infested blocks', explosion_infested_blocks()),
('jigsaw default joint', jigsaw_default_joint()),
('command block auto load', command_block_auto_load()),
('item block escape', item_block_escape()),
@@ -7323,6 +7479,7 @@ def groups():
('rocket data merge', rocket_data_merge()),
('vehicle ground and fall', vehicle_ground_and_fall()),
('vehicle saved ground and fall', vehicle_saved_ground_and_fall()),
('ravager saved movement speed', ravager_saved_movement_speed()),
# Last: it leaves the player in other food than it found.
(player_food.GROUP, player_food.cases(cmd, setup)),
]
+2
View File
@@ -17,6 +17,8 @@ Strider.StriderPathNavigation.createPathFinder port crates/horizon-client/src/mo
Strider.StriderPathNavigation.isStableDestination port crates/horizon-client/src/mobs/nav.rs::Mob::stable_destination lava counts as stable for lava walkers
Vex.registerGoals port crates/horizon-client/src/mobs/vex.rs::register float, charge, random move, look goals, owner and player targets
Vex.restoreFrom missing no mob `restoreFrom`: a vex that changes dimension keeps no owner link
Vex.addAdditionalSaveData inline crates/horizon-client/src/entity_nbt.rs::mob_to_nbt owner is written as the owner's UUID, nothing when the vex has no owner; bound_pos only when bound, life_ticks only with a limited life
Vex.readAdditionalSaveData inline crates/horizon-client/src/entity_nbt.rs::mob_from_nbt bound_pos, life_ticks (no limit when absent) and owner, kept as the UUID the save names
Vex.getVexFlag inline crates/horizon-client/src/mobs/vex.rs::Charge::can_continue the charging bit is the plain extra.flag; there is no flags byte
Vex.setVexFlag inline crates/horizon-client/src/mobs/vex.rs::Charge::start start sets extra.flag; stop and tick clear it
Vex.finalizeSpawn port crates/horizon-client/src/mobs/finalize.rs::Ctx::finalize Vex arm: populate (sword) then enchant
1 Strider.checkStriderSpawnRules port crates/horizon-client/src/mobs/spawn_rules.rs::check_spawn_rules strider arm: climbs lava upward, then needs air above
17 Strider.StriderPathNavigation.isStableDestination port crates/horizon-client/src/mobs/nav.rs::Mob::stable_destination lava counts as stable for lava walkers
18 Vex.registerGoals port crates/horizon-client/src/mobs/vex.rs::register float, charge, random move, look goals, owner and player targets
19 Vex.restoreFrom missing no mob `restoreFrom`: a vex that changes dimension keeps no owner link
20 Vex.addAdditionalSaveData inline crates/horizon-client/src/entity_nbt.rs::mob_to_nbt owner is written as the owner's UUID, nothing when the vex has no owner; bound_pos only when bound, life_ticks only with a limited life
21 Vex.readAdditionalSaveData inline crates/horizon-client/src/entity_nbt.rs::mob_from_nbt bound_pos, life_ticks (no limit when absent) and owner, kept as the UUID the save names
22 Vex.getVexFlag inline crates/horizon-client/src/mobs/vex.rs::Charge::can_continue the charging bit is the plain extra.flag; there is no flags byte
23 Vex.setVexFlag inline crates/horizon-client/src/mobs/vex.rs::Charge::start start sets extra.flag; stop and tick clear it
24 Vex.finalizeSpawn port crates/horizon-client/src/mobs/finalize.rs::Ctx::finalize Vex arm: populate (sword) then enchant
+72 -10
View File
@@ -1,5 +1,13 @@
#!/usr/bin/env python3
"""Saved LodestoneTracker default with stationary real item entities, no world steps.
"""Saved LodestoneTracker default with stationary real item entities, and world
steps for a compass worn in the player's armour slots.
The saved-default corpus has no world steps. The armour family adds one player
tick: a compass in the head armour slot whose lodestone is removed must lose
its target, as `ItemStack.inventoryTick` / `LodestoneTracker.tick` do from
`LivingEntity.tick`'s `EntityEquipment` pass. More ticks cover a loaded
target, an untracked tracker and a target in another dimension. `--no-armor`
leaves that family out and reproduces the corpus before it.
--full-diagnostic records whole entity bodies and absolute clocks too. Those
commands remain traced in the scoped program. All saved-component, typed identity,
@@ -13,11 +21,11 @@ import sys
import test_command_exec_parity as driver
def program(full=False):
def program(full=False, armor=True):
ops = []
def cmd(text, label, result=None, callback=None, admission=True, diagnostic=False):
op = {'op': 'cmd', 'cmd': text, 'label': label, 'admission': admission}
def cmd(text, label, result=None, callback=None, admission=True, diagnostic=False, record=True):
op = {'op': 'cmd', 'cmd': text, 'label': label, 'admission': admission, 'record': record}
if result is not None:
op['expect_result'] = result
if callback is not None:
@@ -94,6 +102,51 @@ def program(full=False):
cmd('/execute store result score now h329 run time query gametime', 'clock-end', diagnostic=True)
cmd('/scoreboard players operation now h329 -= start h329', 'clock-delta-operation', 0)
cmd('/scoreboard players get now h329', 'clock-delta', 0)
if armor:
def equip(label, pos, tracked=None, dim='minecraft:overworld'):
# The equippable component is what lets a non-armour item into an
# armour slot; the compass itself still ticks its tracker. The
# command's own feedback names the item, which is another suite's
# subject, so only the reads below are compared.
tracker = f'target:{{dimension:"{dim}",pos:[I;{pos},200,12]}}'
if tracked is not None:
tracker += f',tracked:{tracked}'
item = (f'minecraft:compass[minecraft:lodestone_tracker={{{tracker}}},'
'minecraft:equippable={slot:"head"},minecraft:custom_data={armor:1}]')
cmd(f'/item replace entity @s armor.head with {item} 1', label + '-equip',
admission=False, record=False)
def tick(label):
ops.append({'op': 'player_loop', 'n': 1, 'label': label, 'record': True})
def tracker(label):
cmd('/data get entity @s equipment.head.components."minecraft:lodestone_tracker"', label)
cmd('/execute if data entity @s equipment.head.components."minecraft:lodestone_tracker".target',
label + '-target')
cmd('/data get entity @s equipment.head.id', label + '-id', 17)
cmd('/setblock 14 200 12 lodestone', 'armor-stone-setup', 1)
equip('armor-air', 12)
tracker('armor-air-before')
cmd('/setblock 12 200 12 air', 'armor-air-remove', 1)
tick('armor-air-tick')
tracker('armor-air-after')
cmd('/setblock 14 200 12 stone', 'armor-stone-replace', 1)
equip('armor-stone', 14)
tracker('armor-stone-before')
tick('armor-stone-tick')
tracker('armor-stone-after')
cmd('/setblock 12 200 12 lodestone', 'armor-loaded-setup', 1)
equip('armor-loaded', 12)
tick('armor-loaded-tick')
tracker('armor-loaded-after')
equip('armor-untracked', 12, tracked='false')
cmd('/setblock 12 200 12 air', 'armor-untracked-remove', 1)
tick('armor-untracked-tick')
tracker('armor-untracked-after')
equip('armor-dimension', 14, dim='minecraft:the_nether')
tick('armor-dimension-tick')
tracker('armor-dimension-after')
return ops
@@ -108,12 +161,16 @@ def main():
del sys.argv[i:i + 2]
if '--work-dir' not in sys.argv:
sys.argv += ['--work-dir', str(driver.lab.LOCAL / 'lodestone-default-parity')]
armor = '--no-armor' not in sys.argv
if not armor:
sys.argv.remove('--no-armor')
build = Path(sys.argv[sys.argv.index('--work-dir') + 1]).resolve()
ops = program(full)
ops = program(full, armor)
driver.corpus_module.groups = lambda: [('saved lodestone tracker default', ops)]
sys.argv.append('--allow-gaps')
driver.main()
recorded = [op for op in ops if op['op'] == 'cmd' and op.get('record', True)]
recorded = [op for op in ops if (op['op'] == 'cmd' and op.get('record', True))
or (op['op'] in ('tick', 'player_loop') and op.get('record'))]
java = [json.loads(s) for s in (build / 'vanilla.jsonl').read_text().splitlines()]
for op, row in zip(recorded, java):
if 'expect_results' in op:
@@ -132,11 +189,16 @@ def main():
'reference_sha256': hashlib.sha256(source.read_bytes()).hexdigest(),
'comparison': 'complete raw records, no masks/tolerances', 'differences': differences}
(build / 'strict-report.json').write_text(json.dumps(report, indent=2) + '\n')
if not full:
for op, a, b in zip(recorded, expected, native):
if op['admission']:
assert a == b, ('actual fixture admission', op, a, b)
if armor:
slots = ('air', 'stone')
matched = sum(1 for slot in slots
if not any(item['label'].startswith(f'armor-{slot}-') for item in differences))
print(f'Armor tracking: {matched}/{len(slots)} cases match')
print(f'Strict raw records: {report["passed"]}/{report["records"]}')
if not full and not differences:
for op, a, b in zip(recorded, expected, native):
if op.get('admission'):
assert a == b, ('actual fixture admission', op, a, b)
if differences:
raise SystemExit(1)