Compare commits

...
Author SHA1 Message Date
Ludovic Marie 11102a5651 Merge pull request #12516 from RINNECODER/up5/port-armor-tags
🤖 Agent: The declaration passes horizon-mods' item check: max_stack_size 1 is the field (1..=99), and minecraft:equippable with only slot is a valid 26.3 Equippable. The #tag walk stops at 8 levels and reads only the carried jar's tags, so the oracle path and untagged items are unchanged.
2026-11-08 14:28:04 +01:00
Ludovic Marie 0cf6116d8d Merge pull request #12505 from sluisr/sec/protocol-recursion-and-threads-soundness
🤖 Agent: Same diff as #12490, which I reviewed before its author closed it (its verify failure was a linker crash on a full-disk runner). A server's registry ids can no longer walk out of the resources folder (no ., .., empty or upper-case segments), predicate components get the same nesting guard as potions, and banner layer counts are bounded by the bytes left (2 per layer).
2026-11-08 14:28:00 +01:00
Ludovic Marie bf53e59fae Merge pull request #12511 from MSedgeMC/port_map/debug-profile
🤖 Agent: DebugEntries has load, set_status, toggle_status, enabled and save, and the F3+F6 editor is game/debug_options.rs, so the 'no editor' skip note was out of date.
2026-11-08 14:27:57 +01:00
Ludovic Marie affb4cbc98 Merge pull request #12510 from MSedgeMC/port_map/multiplayer-allowed
🤖 Agent: AccountStatus::allows_multiplayer in ban_notice.rs is the account check the title screen reads; a skip:service with no note was stale.
2026-11-08 14:27:52 +01:00
Ludovic Marie ab6fe1802d Merge pull request #12509 from MSedgeMC/port_map/world-dimensions-stable
🤖 Agent: dimension_is_vanilla_like in save.rs holds the three stable arms, so the isStable* rows are inline there and isVanillaLike is the port.
2026-11-08 14:27:47 +01:00
Ludovic Marie c23c62df76 Merge pull request #12508 from MSedgeMC/port_map/equipment-is-armor
🤖 Agent: Slot::is_armor exists in mobs/equip.rs (head, chest, legs, feet, not the hands), so the stale skip:other becomes the port.
2026-11-08 14:27:42 +01:00
Ludovic Marie 67093685db Merge pull request #12458 from glitchsnake/fix/11807-summon-duplicate-uuid
🤖 Agent: PersistentEntitySectionManager refuses a UUID already in the level, so a duplicate inside a summoned tree stays a saved passenger under Passengers. Cost to watch: target_with_uuid scans every known UUID, now once per loaded entity with a UUID. I pushed #[ignore] on its second test, which unwraps HORIZON_TEST_RESOURCES like its ignored sibling (verify failed on it).
2026-11-08 14:27:39 +01:00
Ludovic Marie 012391f4aa Merge pull request #12405 from TaquitoSuabe/perf/cheap-in-order-chunks
🤖 Agent: Columns decode on up to 4 workers and are delivered strictly in arrival order (seq). I pushed a fix: the attachments retain runs after the column's block entities load again, so a resent column keeps its block entities' attachments.
2026-11-08 14:23:38 +01:00
Ludovic Marie 4757e2ddca Merge pull request #12507 from MSedgeMC/port_map/weather-layers
🤖 Agent: Builder::weather_layers and weather_layer are in horizon-world's environment_system.rs on main; environment.rs no longer has apply_weather, so the two rows point at the code that runs.
2026-11-08 14:23:35 +01:00
Ludovic Marie c1e8fc5d4b Merge pull request #12495 from MSedgeMC/fix/non-tame-prey-recheck
🤖 Agent: NonTameRandomTargetGoal: canUse needs an untamed mob, and the target keeps matching the selector (Turtle.BABY_ON_LAND_SELECTOR now checks !isInWater), so a baby turtle that reaches water is let go.
2026-11-08 14:22:01 +01:00
Ludovic Marie aa3d35ebc7 Merge pull request #12492 from sluisr/perf/entity-pool-and-meshing-allocs
🤖 Agent: pack_lighting reuses one stack array (every row below h is fully written, the rows above refilled), the server's object states reuse the pooled States, and the guest turn skips the 5-stack clone when the gear is unchanged.
2026-11-08 14:21:58 +01:00
Ludovic Marie d178e80ea5 Merge pull request #12484 from TheMrZZ/fix/h182-trader-llama-discard
🤖 Agent: TraderLlama.maybeDespawn discards in aiStep, so a removed trader llama leaves the mob list in that tick (tick returns false); tame or persistent ones stay.
2026-11-08 14:21:54 +01:00
Ludovic Marie 7f117f484e Merge pull request #12465 from TheMrZZ/fix/armor-stand-lodestone-inventory-tick
🤖 Agent: EntityEquipment.tick runs each equipped item's inventoryTick, so a stand's lodestone compass loses its target when the lodestone goes; only compass stacks pay the lookup.
2026-11-08 14:21:50 +01:00
Ludovic Marie d3910293e0 Merge pull request #12457 from Joansitoh/fix/11886-trident-falls
🤖 Agent: AbstractArrow.tick: a stuck trident falls when the block at its position differs from lastState (the hit block) and nothing is within 0.06; startFalling's three nextFloat()*0.2F draws scale x, y, z in that order.
2026-11-08 14:21:47 +01:00
Ludovic Marie c3d6c640ac Merge pull request #12383 from TheMrZZ/fix/h259-unused-feature-startup
🤖 Agent: An unused invalid fill_layer in a selected pack now fails world preparation through the existing leaf codec, as vanilla's registry load does, instead of the dedicated server falling back to a flat generator; its capture harness (--kind/--output) runs by name only (pushed).
2026-11-08 14:21:44 +01:00
Ludovic Marie 37e9e82dfd Merge pull request #12501 from MSedgeMC/fix/spider-vehicle-attack
🤖 Agent: SpiderAttackGoal.canUse is super.canUse() && !isVehicle(): Mob.rider (a player on its back, set by mounts.rs and the remote/LAN vehicle paths) now counts beside extra.rider (a mob jockey).
2026-11-08 14:21:40 +01:00
Ludovic Marie f4eebc417e Merge pull request #12500 from MSedgeMC/fix/hurt-by-universal-anger
🤖 Agent: HurtByTargetGoal.canUse returns false for a player's hit when universal_anger is on (checked after the timestamp, before the ignore classes, as vanilla); Who has only Player/Mob, so guests' hits take the same path.
2026-11-08 14:21:36 +01:00
Ludovic Marie 0e83631f9d Merge pull request #12499 from TheMrZZ/fix/entity-predicate-movement
🤖 Agent: MovementPredicate.matches as vanilla: per-tick velocity × 20, speed and horizontal_speed against squared bounds (a negative bound squares, as MinMaxBounds.Doubles does), vertical_speed on |y|, exact bounds with no tolerance; only an unmounted, unridden mob gets the snapshot, so other subjects keep today's false.
2026-11-08 14:21:33 +01:00
Ludovic Marie a2140a9ef6 Merge pull request #12502 from TheMrZZ/fix/golem-defense-reputation
🤖 Agent: DefendVillage reads the villager's gossips by the selected player's own UUID instead of level.player_uuid, so a hated guest on a server is attacked and a hated host no longer turns the golem on a guest. I pushed a commit dropping a doc paragraph and a constant copied from another test file.
2026-11-08 14:21:30 +01:00
Ludovic Marie 35b2938c47 Merge pull request #12503 from vnsmoreira/packs/polytone-particle-runtime
🤖 Agent: Merged main in (game.rs frame block and the add/add on horizon-polytone particles.rs, which matches main plus tick_source); the merged diff equals the last commit. Without a Polytone pack, polytone_animate/polytone_entity/make return at the first `let Some(registry)`, so the animate tick and spawn paths pay nothing.
2026-11-08 14:21:26 +01:00
Ludovic Marie 236b13f290 Merge pull request #12389 from Vaspyyy/armor-stand-entity-collisions
🤖 Agent: Stands collide with shulkers, boats and a still adult happy ghast through Entity.collide's getEntityCollisions, with an obstacle grid built only when a stand moves, and Shulker.makeBoundingBox's lid lifts what rests on it.
2026-11-08 14:21:22 +01:00
Ludovic Marie 838ae17615 Merge pull request #12504 from horizon-rust/cg72/grindstone-default-max-damage
🤖 Agent: Grindstone max_damage fix for main's menu_click 7998/8000 (#11434): writes the merged MAX_DAMAGE only when it differs from the item's own, as PatchedDataComponentMap.set does; unit test with the failing case
2026-11-08 14:21:18 +01:00
RINNECODERandClaude Opus 5.5 bdc8a23f21 Port pack: without the oracle, armour the vanilla armour tags name is worn in its slot
A carry without the oracle's facts declared every item that has no block as
a stick, so a mod's helmets and chestplates could not be worn. Items the
carried minecraft:{head,chest,leg,foot}_armor tags name (directly or through
the mod's own tags) now get minecraft:equippable with that slot and a stack
size of 1.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 07:11:06 -06:00
Horizon agentandClaude Opus 5.5 087ce1708b Merge origin/main into armor-stand-entity-collisions
ObstacleGrid keeps main's reused scratch list (near takes &mut self) with
this branch's roots, near_except and huge-area path; the shulker keeps one
progress_aabb with a scale and bullet_origin is the centre of bounds_at;
the shulker shell tick and main's trader llama tick both run.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 14:03:16 +01:00
MSedgeMC 61b7fa5b26 port_map: the three stable-dimension helpers are arms of dimension_is_vanilla_like 2026-11-08 13:01:34 +00:00
MSedgeMC 1df93f9739 port_map: the rest of the debug profile editor rows are ports 2026-11-08 13:00:39 +00:00
MSedgeMC 17e2982614 port_map: the debug profile load and save rows are ports
Both methods are implemented in debug_entries.rs and used by the F3+F6
DebugOptionsScreen; the rows' "no editor" note is stale.
2026-11-08 12:59:18 +00:00
Horizon agentandClaude Opus 5.5 eb74ad2a63 Merge origin/main into packs/polytone-particle-runtime
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 13:59:14 +01:00
MSedgeMC 34aa927de4 port_map: Minecraft.allowsMultiplayer is allows_multiplayer
The row skipped the account check as a service. AccountStatus::allows_multiplayer
is documented as the method and the title screen enables its Multiplayer and
Realms buttons from it.
2026-11-08 12:58:48 +00:00
MSedgeMC 491403897f port_map: WorldDimensions.isVanillaLike is dimension_is_vanilla_like
The row skipped the predicate, but save::dimension_is_vanilla_like is
documented as the method and its caller world_gen_unstable drives the
experimental-settings warning the note mentions.
2026-11-08 12:58:14 +00:00
MSedgeMC 3f7e6970ae port_map: EquipmentSlot.isArmor is Slot::is_armor
The row skipped the predicate as a plain constant, but Slot::is_armor is
documented as EquipmentSlot.isArmor and read by slot_for and mob loot
replacement.
2026-11-08 12:57:49 +00:00
Horizon agentandClaude Opus 5.5 d609f56d42 golem_defense_tests: drop a doc paragraph and a constant copied from another test
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 13:57:20 +01:00
MSedgeMC 37ed06402d port_map: the weather layer rows name environment_system
apply_weather is gone; the weather layers live in horizon-world's
environment_system: Builder::weather_layers adds one per attribute rain or
thunder changes, and Builder::weather_layer registers the entries weather
runs.
2026-11-08 12:57:19 +00:00
sluisr e5043bb090 fix(security): bound item predicate recursion, validate registry paths, and fix threads UB
- Guard recursive `Component::read` calls in `skip::component_matchers`,
  `gameplay::block_predicate`, and `v1_20_3::from_tag` with `Nested::enter()`
  to prevent stack overflow DoS via deeply nested `CAN_PLACE_ON` / `CAN_BREAK`
  exact component matchers, and tighten `banner_layers` pre-allocation cap.
- Validate registry and entry resource location segments in
  `net::fill_known_entries` before joining paths and reading local files on
  `Clientbound::RegistryData`.
- Fix data race UB in `horizon_threads::heap_census` (`slot` / `slot_name`)
  by publishing `NAME_PTR` with `Release` and loading with `Acquire`, and
  guard `CensusAllocator::alloc` / `realloc` against size addition overflow.
- Fix provenance UB in `horizon_threads::boot_paint` where an immutable
  reference `&count` was passed as Win32 `AddFontMemResourceEx`'s mutable
  `pNumFonts` out-pointer, and require `'static` font byte slices.
2026-11-08 13:55:29 +01:00
Horizon agentandClaude Opus 5.5 d035060e66 Grindstone: a merged max damage equal to the item's own writes no component
GrindstoneMenu.mergeItems sets MAX_DAMAGE to the larger of the two, and
PatchedDataComponentMap.set drops a value equal to the prototype's. Since
#11434 Horizon always wrote it, so merging two plain diamond pickaxes gave
one carrying max_damage:1561 (menu_click 7998/8000 on main).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 13:50:25 +01:00
MSedgeMC afb2ce0f1e A spider carrying a player does not start its attack
`SpiderAttackGoal.canUse` is `super.canUse() && !isVehicle()`, and
`isVehicle()` is true for any rider. Horizon's `SpiderAttack` checked only
mob riders (`extra.rider`), so a spider with a player on its back began
the melee goal.
2026-11-08 12:48:48 +00:00
MSedgeMC 2ffc8b5f56 Universal anger leaves a player's hit to the anger goals
`HurtByTargetGoal.canUse` refuses a player's hit when the
`universal_anger` game rule is on, leaving that hit to the anger target
goals. Horizon's `HurtByTarget` always retaliated against the player, so
the rule changed the anger bookkeeping but not the direct retaliation.
2026-11-08 12:48:16 +00:00
Ludovic Marie 447e9d277d Merge pull request #12477 from vnsmoreira/packs/polytone-particles
🤖 Agent: Includes #12475 and #12476. Particle and emitter files are parsed with every expression compiled at load (a typo fails the load); nothing spawns them yet, so no runtime cost.
2026-11-08 13:42:09 +01:00
TheMrZZandCodex d0d9e050d3 Evaluate movement predicates for supported Mob query snapshots
Preserve global ranges and unsupported provider contexts.

Co-Authored-By: Codex <[email protected]>
2026-11-08 13:41:10 +01:00
MSedgeMC 678dfde14b A non-tame predator stops hunting prey its selector no longer matches
`NonTameRandomTargetGoal.canContinueToUse` re-tests the goal's
`targetConditions` while hunting, so the `Turtle.BABY_ON_LAND_SELECTOR`
(a baby turtle out of water) drops out as soon as the turtle enters
water. Horizon's `NearestTarget` only ran the base continue rules, and its
turtle filter never checked the water, so a wolf or cat kept hunting the
turtle.
2026-11-08 12:37:25 +00:00
Ludovic Marie 1597853de7 Merge pull request #12486 from echo-ilovech3ss/fix/12287-windows-save-roundtrip-paths
🤖 Agent: Tooling only: save_roundtrip compares relative .dat paths as POSIX on every OS, so Windows' backslashes no longer make every file look renamed.
2026-11-08 13:36:22 +01:00
Ludovic Marie 7d0f866a47 Merge pull request #12481 from RINNECODER/up4/upgrade-surface-rule-biomes
🤖 Agent: A surface rule's biome_is keeps vanilla's biomes and the pack's own; others (a "comment" entry, another mod's biome) are dropped and reported as lossy, since 26.3 refuses the pack over them.
2026-11-08 13:36:18 +01:00
Vinícius MoreiraandClaude Opus 5.5 600fd5f04a Polytone: run custom particles and block and entity particle emitters
Resource packs' custom particle types now run in the particle engine: the
initializer sets the particle up, the ticker moves, recolours and removes it,
its emitters spawn more, emitter values seed the particles they spawn, and
`limit` caps how many live at once. Block modifiers' emitters run from the
block animate tick, the way torches spawn flames; entity modifiers' emitters
run on the player each client tick (fog, dust and weather around them).
Expressions see the particle, the blocks and biome around it, the player,
the camera, the time and weather.

Model and GPU particles aren't drawn yet (model ones still tick and emit);
additive blending and rotation modes fall back to the plain translucent
billboard; entity emitters ignore bones.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 09:33:09 -03:00
Vinícius MoreiraandClaude Opus 5.5 2145f26929 Polytone: read custom particles and particle emitters
Reads what a pack's particles do (wiki "Custom Particle Types", "Block
Properties Modifiers", "Entity Modifiers"): a custom particle type's kind
(custom, semi custom copying a vanilla one, GPU), render type, liquid
affinity, physics and despawn flags, limit, tick interval, exclusion
radius, light level and rotation mode; its initializer and ticker
expressions with their named variables; and the particle emitters that
particles, blocks and entity bones carry (the particle by id or with its
options, chance, count, position, motion, colour, size and customs, the
state predicate, biomes, spawn location, bone, max distance). Every
expression is compiled when the pack loads, so a typo is a load error.
Nothing spawns them yet.

Part of #11486

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 09:33:09 -03:00
Vinícius MoreiraandClaude Opus 5.5 36e962a58c Polytone: colormap values in attribute layers
An attribute value or argument can name a Polytone colormap (wiki
"Colormaps"): an image read at the column and row two expressions pick,
each 0 to 1 with 1 the top-left corner, rounded to the nearest pixel by
default, the row scaled by the column when triangular. Without a .json
the axes are the biome's temperature and downfall, which the frame now
passes along. The colour is the modifier's argument whenever the layer
applies; a transparent pixel falls back to the value before the pack (the
entry the modifier replaced), as fallback_colormap's default "original".

Colormaps load once per pack reload, shared by the three dimensions'
stacks, and only those a modifier names, decoded to RGBA ints as on
Java. The biome_id axis isn't supported yet.

Part of #11486

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 09:33:09 -03:00
Vinícius MoreiraandClaude Opus 5.5 3ce27e636b Polytone: an expression value out of range is clamped, not dropped
A number an expression gives outside its attribute's range goes through the range as the sampled
value does (AttributeRange.sanitize) instead of failing validation and leaving the value as it
was. A pack whose fog end goes below zero deep underground (14 * view distance - 128 under the
surface) now gets its thickest fog there, as on the real mod, where the cave was left with no fog.

Part of #11486

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 09:33:09 -03:00
Ludovic Marie db2b5ff76b Merge pull request #12479 from NikitaMGrimm/hdr-resolve-gone
🤖 Agent: HDR output now takes its gain from Hdr::draw_params instead of the removed mark-resolve lane; the change stays behind HDR, which is off by default, and frame_identity's test covers the no-pack path.
2026-11-08 13:29:59 +01:00
Florian ErnstandClaude Opus 5.5 06fe2cc687 An iron golem reads the reputation of the player it selects
DefendVillageTargetGoal asks the villagers in the golem's box for the
reputation of the player (Villager.getPlayerReputation: the gossips
about that player's UUID). Horizon read one reputation per villager,
keyed by the level's own player, while each golem's goal looks at the
player it selects, often a nearby guest: on a server, a hated guest was
never attacked, and a hated host could turn the golem on a guest it
did not hate. The goal now reads the villager's gossips about the
selected player's UUID.

Fixes #12487

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 13:27:39 +01:00
Ludovic Marie a9033e8c41 Merge pull request #12483 from RINNECODER/up4/upgrade-biome-effects
🤖 Agent: 1.21.1 biome effects move to 26.3's visual/audio attributes (colours as #rrggbb), carvers become the air list, and liquid carvers are reported as lossy.
2026-11-08 13:26:43 +01:00
Ludovic Marie c3a1e1293f Merge pull request #12478 from glitchsnake/fix/command-components-expected-equal
🤖 Agent: A bare component in an item argument ([custom_data]) now fails with expected '=' as ItemParser's reader.expect does, instead of being ignored; !removals are unchanged.
2026-11-08 13:26:40 +01:00
Ludovic Marie 1e3e77270d Merge pull request #12470 from oloion/perf/client-culling-and-chunk-streaming
🤖 Agent: Frustum select picks the same corner as the branches did (-0.0 >= 0 both ways) and the resident bounds are origin()'s arithmetic inline. I pushed a fix: upload order measured the camera section as floor(y/16) while keys count from the world's bottom, four sections off in the overworld.
2026-11-08 13:26:36 +01:00
Ludovic Marie 138616afdb Merge pull request #12475 from vnsmoreira/packs/polytone-expression-values
🤖 Agent: Pack expressions are compiled at load and evaluated against the frame only when a pack's environment stack is loaded (pack.environments), so the vanilla path sets no frame and keeps folding constant layers.
2026-11-08 13:23:43 +01:00
Florian ErnstandClaude Opus 5.5 1863a04353 Measure a golem's defence against the player it selects (test checkpoint)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 13:22:56 +01:00
Shreyas 8634b05990 save_roundtrip: normalize relative dat paths to POSIX slashes on Windows (#12287) 2026-11-08 17:51:06 +05:30
Ludovic Marie bf928658ef Merge pull request #12474 from horizon-rust/cg72/loot-mine-named-only
🤖 Agent: Tooling only: loot_mine_location_block asserts its .local/h079/capture is new, so it fails at startup on the Mac runner after one run (#12405); it joins the other capture harnesses in named_only.
2026-11-08 13:20:28 +01:00
Ludovic Marie 8c58505b3c Merge pull request #12456 from martipops/ui/hud-room
🤖 Agent: The room is rebuilt each frame from the boss bars (BossHealthOverlay: y 12, step 19); with 0-1 bar the notice stays at y 18, and HudBatch::new there allocates nothing. I merged main (client_lib re-export list: hud_kit beside main's social).
2026-11-08 13:18:14 +01:00
Ludovic Marie d3151e0cc2 Merge pull request #12418 from RINNECODER/up4/port-item-place-block
🤖 Agent: A block item whose block has another name (seeds) is carried with place_block; one whose block is left out stays in items_unknown. I merged main (both branches added a test at the same spot; both kept).
2026-11-08 13:18:10 +01:00
Vinícius MoreiraandClaude Opus 5.5 2017a3b977 Polytone: read custom particles and particle emitters
Reads what a pack's particles do (wiki "Custom Particle Types", "Block
Properties Modifiers", "Entity Modifiers"): a custom particle type's kind
(custom, semi custom copying a vanilla one, GPU), render type, liquid
affinity, physics and despawn flags, limit, tick interval, exclusion
radius, light level and rotation mode; its initializer and ticker
expressions with their named variables; and the particle emitters that
particles, blocks and entity bones carry (the particle by id or with its
options, chance, count, position, motion, colour, size and customs, the
state predicate, biomes, spawn location, bone, max distance). Every
expression is compiled when the pack loads, so a typo is a load error.
Nothing spawns them yet.

Part of #11486

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 09:16:21 -03:00
Vinícius MoreiraandClaude Opus 5.5 a3b56d6218 Polytone: colormap values in attribute layers
An attribute value or argument can name a Polytone colormap (wiki
"Colormaps"): an image read at the column and row two expressions pick,
each 0 to 1 with 1 the top-left corner, rounded to the nearest pixel by
default, the row scaled by the column when triangular. Without a .json
the axes are the biome's temperature and downfall, which the frame now
passes along. The colour is the modifier's argument whenever the layer
applies; a transparent pixel falls back to the value before the pack (the
entry the modifier replaced), as fallback_colormap's default "original".

Colormaps load once per pack reload, shared by the three dimensions'
stacks, and only those a modifier names, decoded to RGBA ints as on
Java. The biome_id axis isn't supported yet.

Part of #11486

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 09:16:21 -03:00
Vinícius MoreiraandClaude Opus 5.5 30aee13f1d Polytone: an expression value out of range is clamped, not dropped
A number an expression gives outside its attribute's range goes through the range as the sampled
value does (AttributeRange.sanitize) instead of failing validation and leaving the value as it
was. A pack whose fog end goes below zero deep underground (14 * view distance - 128 under the
surface) now gets its thickest fog there, as on the real mod, where the cave was left with no fog.

Part of #11486

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 09:16:20 -03:00
sluisr fec26a0044 perf: reuse server States tracking pool and reduce meshing/lighting allocations
- Reuse the pooled EntityState entries in States (`out.add`) across all 21
  object and projectile loops in `lan_entities.rs`, and push vehicle, armor
  stand pose, and arrow metadata directly into `e.meta`.
- Look up vehicle driver mobs via `self.mob_index` in `lan_riding` instead
  of a linear scan over `self.mobs`.
- Compare `play.equipped` by reference before cloning 5 `ItemStack`s on
  every `enter_guest` turn, and reuse `play.craft_grid` via `std::mem::swap`.
- Eliminate per-fluid-cell `Vec::new()` allocations in `mesher/fluid.rs` and
  deduplicate animated fluid layers directly into `out.sprite_layers`.
- Reuse `SectionMesh::packed` capacity and a thread-local scratch buffer in
  `mesher/packed.rs` during `sort_solid`.
- Pack uniform/single-channel lighting from a stack buffer in `chunk.rs`
  (`Light::from_slice`) instead of allocating and dropping a 4 KiB `Box`.
- Check `!self.saves_beside_final` and neighbor readiness before cloning
  `Chunk` in `Streaming::update`.
2026-11-08 13:15:43 +01:00
LokiandAntigravity 9fd2c8c339 items: require '=' for non-removal command components
Java 26.3 `ItemParser.State.readComponents` expects '=' after a component
type unless it begins with '!'. Return an error for bare components rather
than silently ignoring them.

Co-Authored-By: Antigravity <[email protected]>
2026-11-08 21:08:01 +09:00
Ludovic Marie 3f58a9511b Merge pull request #12461 from MSedgeMC/port_map/creative-screen
🤖 Agent: draw_creative, click_creative and creative_tooltip_tabs moved to game/creative_screen.rs on main; the rows follow them.
2026-11-08 13:06:07 +01:00
Ludovic Marie 2d087e4826 Merge pull request #12460 from MSedgeMC/port_map/shear-saddle
🤖 Agent: Game::shear_saddle exists on main and takes a saddle off any saddleable mob, so attemptToShearEquipment is a port row now.
2026-11-08 13:06:03 +01:00
Horizon agentandClaude Opus 5.5 d426f98ae2 parity: run loot_mine_location_block by name only
It writes .local/h079/capture and asserts the capture is new, so every automatic
run on a checkout that ran it once fails at startup (the Mac mini runner).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 13:04:57 +01:00
RINNECODERandClaude Opus 5.5 48f02115ed Port pack: 1.21.1 biomes upgrade their effects to 26.3 attributes and their carvers to a list
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 06:02:45 -06:00
RINNECODERandClaude Opus 5.5 06b5147e56 Port pack: surface rules drop the biomes nothing defines, as 1.21.1 never matched them
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 06:02:32 -06:00
Ludovic Marie 9fc6abb08b Merge pull request #12468 from kacpherek/fix/server-sword-blocking
🤖 Agent: A consumable or blocks_attacks the server's patch adds (components 24 and 37) now starts a use like the item's own (getUseDuration reads the stack); items with their own consumable are unchanged.
2026-11-08 13:01:32 +01:00
Horizon agentandClaude Opus 5.5 1a9663f21a uploads: measure the camera's section from the world's bottom
Pending upload keys count sections from min_y, so floor(y / 16) put the camera
four sections low in the overworld.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 12:58:30 +01:00
Ludovic Marie eaca6fe37a Merge pull request #12467 from masa-masa-0314/fix/realms-terms
🤖 Agent: Realms answers 6002 for a new account: RealmsTermsScreen shows (Agree posts /mco/tos/agreed, then the join resumes; Disagree goes back), with vanilla aka.ms/MinecraftRealmsTerms and mco.terms.* keys.
2026-11-08 12:53:12 +01:00
Vinícius MoreiraandClaude Opus 5.5 c52446638a Polytone: expression values in attribute layers
A pack's attribute value or argument can be a scripting expression
(wiki "Environment Attributes"): fog that closes in with the rain and
with depth, a cloud height scaled from the value the layers before gave.
Each one is compiled when the pack loads and evaluated whenever its layer
applies, against what the frame sets: g (rain, time, day time, sky type,
dimension, render distance, moon phase), p (position, in water), c
(position, view distance in blocks), o (the camera's position, sky and
block light), r (random), v (the value so far, colours packed ARGB) and
the pack's config(id). Its result is the modifier's argument; one that
fails leaves the value as it was. A dynamic entry is never folded into
the base value, since it changes every frame.

Expressions are compiled with Arc instead of Rc so a pack's stacks can be
built on the loading worker.

Part of #11486

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 08:51:58 -03:00
Ludovic Marie e8acedb5c2 Merge pull request #12401 from Web-mc1/studio-screen-bridge
🤖 Agent: Screen::Studio is opened only by `studio screen open` and draws nothing itself (no chat behind it); Escape closes it. I pushed the rustfmt fix verify failed on.
2026-11-08 12:50:37 +01:00
Horizon agentandClaude Opus 5.5 a0d1a9f0e3 Merge origin/main into up4/port-item-place-block
Both branches added a test before the Windows-separator one: both kept.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 12:48:19 +01:00
Ludovic Marie 4b2cba530a Merge pull request #12451 from apeirs/fix/fxc-entity-written-color
🤖 Agent: FXC (DX12) failed on discards one call below the entry point; fs_entity now computes entity_color itself and hands it on, same colour on every backend.
2026-11-08 12:48:16 +01:00
Ludovic Marie 4b99e9a701 Merge pull request #12441 from RINNECODER/up3/far-tile-walk
🤖 Agent: Boxing the far tile meshes shrinks Tile for the per-frame walk; release paths unbox each mesh once, nothing double-released.
2026-11-08 12:48:12 +01:00
Horizon agentandClaude Opus 5.5 4497f18b31 Merge origin/main into ui/hud-room
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 12:47:26 +01:00
Ludovic Marie e761e6394a Merge pull request #12463 from MSedgeMC/port_map/edge-walk
🤖 Agent: VoxelShape::edges exists in horizon-core's shape.rs, so forAllEdges is a port now rather than skip:data.
2026-11-08 12:45:18 +01:00
Ludovic Marie bced9e4ecd Merge pull request #12462 from MSedgeMC/port_map/watcher-home
🤖 Agent: PackSelectionScreen.Watcher spans datapack.rs::folder_stamp and options_ui.rs's watch, so a home row fits better than a single port target.
2026-11-08 12:43:01 +01:00
Ludovic Marie 4dd22ecd6a Merge pull request #12459 from MSedgeMC/port_map/infested-silverfish
🤖 Agent: spawn_infested, player_infested and mobs.rs::infested_silverfish all exist on main; the rows now name where the roll and the spawn happen.
2026-11-08 12:38:53 +01:00
Ludovic Marie ba041dc3bb Merge pull request #12439 from RINNECODER/up3/pack-halflives
🤖 Agent: Pack half-lives replace OptiFine's defaults in the same units (600/200/10/1). I pushed a scripted-clock version of its test: backdating u.last was the wall-clock flake #12354 removed.
2026-11-08 12:34:49 +01:00
Ludovic Marie 706aad56c0 Merge pull request #12455 from Evan-lhm/fix/release-sums-windows-test
🤖 Agent: Git Bash's sha256sum writes `*name` (binary mode); the test strips the star, nothing else changes.
2026-11-08 12:29:15 +01:00
Ludovic Marie 564f031294 Merge pull request #12445 from RINNECODER/up3/lod-dispatch-incremental
🤖 Agent: Mesh jobs' samples are kept per 8x8 shard and synced at every active/ready change; debug builds assert the copy matches active ∩ ready.
2026-11-08 12:29:12 +01:00
Ludovic Marie f18c761edc Merge pull request #12443 from RINNECODER/up3/mod-content-copy
🤖 Agent: Plain files are copied with the same size and path checks read() made; tags, atlases, declarations, biome modifiers, worldgen and structures are still read and parsed.
2026-11-08 12:29:08 +01:00
Ludovic Marie c2661a333d Merge pull request #12410 from masa-masa-0314/feat/friends-service
🤖 Agent: The friends worker never holds the lock across HTTP (reads the etag, unlocks, fetches, relocks), and nothing starts unless the account's FRIENDS_ENABLED flag is set.
2026-11-08 12:29:05 +01:00
Ludovic Marie c09321478d Merge pull request #12408 from m-uvex/pluto/add-flake
🤖 Agent: The flake builds the launcher from the repo itself, so it follows main, unlike #11200's pinned tarball; both outputs evaluate on NixOS (horizon-launcher-0.2.0.drv, nix-shell.drv); CI does not build it.
2026-11-08 12:29:01 +01:00
Horizon agentandClaude Opus 5.5 519d9b6d97 persist: ignore the second summon UUID test without prepared resources
It unwraps HORIZON_TEST_RESOURCES like its sibling, which is already #[ignore]d.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 12:27:29 +01:00
TheMrZZandCodex gpt-6.1-sol fe3ebc3b8d Tick lodestone compasses equipped by armor stands
Reuse the existing loaded-target predicate for actual stand equipment inside
the entity-ticking guard. Preserve player behavior and bounded tracker rules.

Fixes #12395

Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-08 12:26:24 +01:00
MSedgeMC 98e37bbb84 port_map: the edge-walk rows name VoxelShape::edges
forAllEdges and forAllAxisEdges were skipped as data, but the walk is in
crates/horizon-core/src/geometry/shape.rs: VoxelShape::edges is documented
as forAllEdges, and its inner axis scan is forAllAxisEdges.
2026-11-08 11:24:22 +00:00
Kacper 7965bc432e Merge pull request #12447 from RINNECODER/up3/mount-tick-order
Riding: the driven mount ticks before its rider, on the input the rider's last tick applied
2026-11-08 12:24:13 +01:00
Kacper 50f53a5a2b Merge pull request #12437 from RINNECODER/up3/mount-air-speed
Mounts: a player-steered mount moves through the air at a tenth of its speed (LivingEntity.getFlyingSpeed)
2026-11-08 12:24:06 +01:00
Kacper 113ac28e79 Merge pull request #12426 from RINNECODER/up4/port-food
Port pack: food items carry over, as 26.3's food, consumable and use_remainder
2026-11-08 12:23:59 +01:00
Kacper 509deae34e Merge pull request #12424 from RINNECODER/up4/upgrade-weird-scaled-sampler
upgrade-datapack: weird_scaled_sampler becomes 26.3's interval_select of scaled noises
2026-11-08 12:23:52 +01:00
Kacper 156f335169 Merge pull request #12422 from RINNECODER/up4/upgrade-nether-vegetation
upgrade-datapack: nether_forest_vegetation becomes 26.3's spread simple_block
2026-11-08 12:23:45 +01:00
Kacper f4cbc6311e Merge pull request #12416 from RINNECODER/up4/port-farmland-template
Port pack: carried models on 1.21.1's farmland template use 26.3's indented cube
2026-11-08 12:23:38 +01:00
MSedgeMC 7a4012d00f port_map: PackSelectionScreen.Watcher is a home row
The class row was written with a `port` verdict and the function that
represents it. Class rows take `home` with the files that represent the
class; list datapack.rs (folder_stamp) and options_ui.rs (watch_data_packs).
2026-11-08 11:23:32 +00:00
Kacper dca8dbddd1 Merge pull request #12414 from RINNECODER/up4/port-recipe-conditions
Port pack: recipes with NeoForge conditions are upgraded when NeoForge loaded them
2026-11-08 12:23:31 +01:00
Kacper 6410d3f446 Merge pull request #12412 from RINNECODER/up4/port-tag-namespaces
Port pack: tags alone claim no namespace
2026-11-08 12:23:24 +01:00
Kacper 059eb482c4 Merge pull request #12400 from Solenopsisbot/perf/obstacle-grid-scratch
perf: reuse obstacle grid query scratch
2026-11-08 12:23:13 +01:00
MSedgeMC 3835921949 port_map: the creative screen rows name game/creative_screen.rs
The creative inventory screen moved out of game.rs into its own module.
draw_creative, creative_tooltip_tabs and click_creative are unchanged; only
the module path in the six rows that name them was left behind.
2026-11-08 11:23:10 +00:00
Kacper f8be508301 Merge pull request #12397 from Solenopsisbot/perf/creature-path-writes
perf: move creature path writes without copying
2026-11-08 12:23:06 +01:00
Kacper 8b06e4ba1e Merge pull request #12386 from TheMrZZ/fix/stand-command-hurt
/damage breaks, lights or burns an armor stand as its hurt does
2026-11-08 12:22:59 +01:00
Kacper 197a6ef7dc Merge pull request #12381 from svetch/discovery-texture-uris
parity: take valid texture URIs from the services discovery document
2026-11-08 12:22:52 +01:00
Kacper ba54ecff05 Merge pull request #12375 from TheMrZZ/fix/test-run-cross-dimension-coordinate-feedback
fix: send completed test coordinate feedback to its command source
2026-11-08 12:22:45 +01:00
MSedgeMC 44a27421ca port_map: the equipment shearing rows name shear_saddle
`Game::shear_pig_saddle` was renamed and generalized to `Game::shear_saddle`
when shears began taking the saddle off any saddleable mob. Its doc names
`Mob.attemptToShearEquipment` and `Mob.shearItem`; re-point the two rows.
2026-11-08 11:22:40 +00:00
Kacper eb8f8e38b5 Merge pull request #12374 from TheMrZZ/fix/h064-vibration-source-uuid
Save the source and projectile owner UUIDs of a vibration
2026-11-08 12:22:38 +01:00
Kacper d276c60075 Merge pull request #12371 from glitchsnake/fix/11382-vehicle-entity-random
game: use entity random for boat, minecart and item frame operations (fixes #11382)
2026-11-08 12:22:31 +01:00
Kacper ee91d5907a Merge pull request #12366 from vnsmoreira/packs/polytone-environment
Polytone: biome and dimension modifiers as attribute layers
2026-11-08 12:22:24 +01:00
Kacper 1cec3b3751 Merge pull request #12365 from vnsmoreira/packs/polytone-overlays
Polytone: pack configs and conditional overlays
2026-11-08 12:22:17 +01:00
MSedgeMC 04ead01cb4 port_map: the infested hurt rows name spawn_infested and player_infested
`Game::infested_hurt` was split. `Game::spawn_infested` spawns a silverfish
per drawn velocity at half the bearer's height with the hurt sound, and
`Game::player_infested` is the player's roll and spawn. Re-point the two
rows that still name the old function.
2026-11-08 11:22:13 +00:00
Kacper 447341b6a4 Merge pull request #12364 from vnsmoreira/attributes/integration
Environment: sample the sky through the vanilla attribute stack
2026-11-08 12:22:10 +01:00
Kacper 2bb7df1bf9 Merge pull request #12352 from k3yh/potion-contents-names
A potion or tipped arrow is named by its contents
2026-11-08 12:22:02 +01:00
Kacper 13c56dd065 Merge pull request #12345 from Vaspyyy/title-icon-tooltips
Show the title screen icon buttons' tooltips while hovered like vanilla
2026-11-08 12:21:55 +01:00
Kacper cceab3b2b5 Merge pull request #12344 from Vaspyyy/shulker-bullet-parity
Fire shulker bullets from the box centre and turn them at standable blocks
2026-11-08 12:21:48 +01:00
Kacper 6e00e1bbf1 Merge pull request #12341 from Vaspyyy/vehicle-container-scatter
A destroyed chest boat or container minecart scatters its contents like vanilla
2026-11-08 12:21:40 +01:00
Kacper fc14c6fc28 Merge pull request #12336 from k3yh/loot-give-full-inventory
/loot give to a full inventory throws nothing out
2026-11-08 12:21:33 +01:00
Kacper d03fbd593a Merge pull request #12260 from Akirabane/fix/9335-passengers
Riders turn like vanilla's: vehicle body only where vanilla copies it
2026-11-08 12:21:26 +01:00
Kacper 0335218910 Merge pull request #11682 from TheMrZZ/fix/loot-mine-location-block-state
Evaluate loaded block-state offsets in loot-mine location checks
2026-11-08 12:21:19 +01:00
Kacper 3b76fcb951 Merge pull request #11642 from TheMrZZ/fix/inline-armor-trim-holders
Preserve fully inline armor-trim holders in item stacks
2026-11-08 12:21:11 +01:00
masa-masa-0314andZCode a7f89f1c62 Realms: the terms screen before a new account's first join
A new account's first join gets error 6002; GetServerDetailsTask shows
RealmsTermsScreen instead of the generic error. Port it:

- The title, the first sentence and the underlined terms link at
  row(5), with the hover colours and the link's measured hit box
  (extractRenderState's onLink). A click opens
  CommonLinks.REALMS_TERMS (https://aka.ms/MinecraftRealmsTerms).
- Agree (width/4) and Disagree (width/2 + 4), each width/4 - 2 wide at
  row(12). Agree posts mco/tos/agreed (RealmsClient.agreeToTos) on a
  worker, then runs GetServerDetailsTask again from the same screen;
  a failure logs and stays. Disagree and Escape return to the screen
  that started the join (GetServerDetailsTask.lastScreen, now carried
  by Connecting).

Co-Authored-By: ZCode <[email protected]>
2026-11-08 19:21:06 +08:00
Kacper 73b2d7434f Merge pull request #11588 from TheMrZZ/fix/named-predicate-context
Resolve named predicate references through the Game context
2026-11-08 12:21:04 +01:00
Kacper e19018da1c Merge pull request #11564 from TheMrZZ/fix/h157-player-save-projection
fix: project live player motion fields into saved data
2026-11-08 12:20:57 +01:00
Kacper 5a94e4c188 Merge pull request #11488 from TheMrZZ/fix/h181-noai-trader-llama-delay
Advance eligible TraderLlama despawn delay with NoAI
2026-11-08 12:20:51 +01:00
Kacper 6db9351111 Merge pull request #11301 from TheMrZZ/fix/h052-vehicle-container-save
Respect class-owned container fields in vehicle saves
2026-11-08 12:20:43 +01:00
Horizon agentandClaude Opus 5.5 7d2f225c85 pack uniforms: drive the half-life test with a scripted clock
Backdating u.last measured the gap with the wall clock; a few ms of runner delay
moved wetness past the 1e-3 tolerance (the flake #12354 fixed in the Iris test).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 12:12:58 +01:00
Horizon agentandClaude Opus 5.5 f15f153836 remote chunks: drop block-entity attachments after the column's block entities load
Moving the retain before the block entities loaded dropped every attachment of a
resent column, and skipping it for new columns kept those of forgotten ones.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 12:12:19 +01:00
JoansitohandClaude Sonnet 5.5 c9f62a1572 Trident: a stuck trident falls when its block is destroyed
AbstractArrow.tick: if lastState != blockState && shouldFall() startFalling().

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-08 12:11:48 +01:00
LokiandAntigravity 067a6a1db1 game: skip in-tree duplicate UUIDs in summoned passenger trees (fixes #11807)
Co-Authored-By: Antigravity <[email protected]>
2026-11-08 20:11:37 +09:00
Horizon agentandClaude Opus 5.5 7ba6ef0912 studio screen: rustfmt the control replies
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 12:11:35 +01:00
Kacpherek 34c91f0c34 Hold a server-patched consumable or blocks_attacks use on a server
A server that adds blocks_attacks and a long consumable to a sword (1.8
style sword blocking, #11817) sends both components in the stack patch,
which the client reads past. The client never started the use itself and
remote_tick_using dropped the use the server's living flags started on
the next tick, so the right click kept restarting the use and the input
slowdown flickered (GrimAC NoSlow).

Read the added components from the wire patch, as Item.use does from the
stack's components: Consumable.startConsuming / startUsingItem start the
use, Item.getUseDuration takes the consumable's consumeTicks (else 72000
for blocks_attacks), and the use is held until the button lets go or the
server stops it, as LivingEntity.updateUsingItem never completes a use on
the client (LocalPlayer.onSyncedDataUpdated keeps it running).
2026-11-08 12:10:32 +01:00
Evan-lhmandClaude Opus 5.5 0f36e33ccf Tests: read SHA256SUMS names with Git Bash's binary-mode * (#12454)
On Windows, Git Bash's sha256sum writes `<digest> *<name>`, so the
optional release assets test kept the `*` in every name and failed for
all four masks. Both forms are valid sha256sum output; accept them.

Fixes #12454

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 11:59:54 +01:00
apeirs 94e2918668 DX12: keep entity_color's discards one call below fs_entity, so FXC compiles it
Since #12282, fs_entity called entity_written_color, which called entity_color.
FXC (every DX12 game: Horizon ships no dxcompiler.dll) fails on that with
"internal error: argument pulled into unrelated predicate" while creating the
first entity pipeline, and the game crashes at start. entity_written_color now
takes entity_color's result instead of calling it.
2026-11-08 11:46:09 +01:00
m_uvexandPluto ecc9a21e18 packaging: build Horizon launcher from source in flake
Addresses review feedback from #11200:
- Builds horizon-launcher from repository source via rustPlatform.buildRustPackage
  instead of fetching a pinned binary tarball by hash.
- Native build support for both x86_64-linux and aarch64-linux.
- Added essential POSIX tools to devShells.default for pr_ready test suite.

Co-Authored-By: Pluto <[email protected]>
2026-11-08 13:41:26 +03:00
TaquitoSuabe 00127a51c7 remote_chunks: decode chunk packets concurrently with strict in-order retirement
- Parallelize chunk decompression across a bounded worker pool while enforcing sequential retirement (FIFO arrival order) via monotonic sequence ordering.
- Filter resident neighbors and batch section dirtying with column masks.
- Skip attachment and block entity cleanup scans for fresh (non-resident) chunks.
- Enable TCP_QUICKACK on Linux streams and reset empty reader buffers to prevent drift.
- Elevate network worker thread QoS on supported platforms.
2026-11-08 03:40:59 -07:00
Ludovic Marie 92fd45976d Merge pull request #12393 from Solenopsisbot/perf/worldgen-decoration-biomes
🤖 Agent: Same fixed [u64; 128] bitset with spill as the generator's (#12346): spilled words keep index 128 + offset, so the decorated chunk's biomes stay ascending, and the exact reserve avoids regrowth.
2026-11-08 11:39:18 +01:00
SolenopsisbotandCodex 3bf71f9926 perf: reuse obstacle grid query scratch
Co-Authored-By: Codex <[email protected]>
2026-11-08 21:33:40 +11:00
Web-mc1 df92a7cbc5 Open and close Screen::Studio from horizon via studio screen, with test 2026-11-08 23:29:06 +13:00
Web-mc1 b7215560ba Add Screen::Studio so Studio windows don't borrow the chat slot 2026-11-08 23:28:48 +13:00
Ludovic Marie 79bd301973 Merge pull request #12388 from kacpherek/fix/remote-push-tick-order
🤖 Agent: ClientLevel.tickEntities ticks in add order: after login (and after a respawn re-adds the player last) a mob added later pushes from where both ended the tick, so the push reaches the player's next move, which is what GrimAC's Simulation check expects.
2026-11-08 11:27:31 +01:00
SolenopsisbotandCodex ec73a1af84 perf: move creature path writes without copying
Co-Authored-By: Codex <[email protected]>
2026-11-08 21:24:37 +11:00
Horizon agentandClaude Opus 5.5 bbbbfa6dd2 Parity: unused_feature_startup runs by name only
It needs --kind, --output and --binary, which an automatic parity run
doesn't pass (#12372).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 11:23:29 +01:00
Ludovic Marie 33facd006a Merge pull request #12361 from adiraj66132/security/audit-hardening
🤖 Agent: client_mod_command is the chat-side mod hook; the SMP and other server mods use native_perform_command, so gating guests (lan_acting) out of it removes a level-0 path without touching server commands. The 4 GiB zip budget is per opened archive, and every Zip lives for one load.
2026-11-08 11:22:13 +01:00
Ludovic Marie 0ad6f93762 Merge pull request #12280 from NikitaMGrimm/portal-ticket-persistence
🤖 Agent: PORTAL tickets are saved in chunk_tickets.dat with level and ticks left, reload on restart (prepareLevels), and do not expire while their chunk is still loading (canTicketExpire). I fixed the main merge: #12232's ticket fast path compared (chunk, level) pairs with this PR's PortalTicket (LAND-STOP: E0308); it compares (ticket.chunk, ticket.level) now.
2026-11-08 11:22:09 +01:00
SolenopsisbotandCodex 1467568676 perf: reuse decorated biome discovery scratch
Co-Authored-By: Codex <[email protected]>
2026-11-08 21:18:11 +11:00
masa-masa-0314andZCode da428abd4d Friends: use ? for the presence request's missing session and endpoint
Co-Authored-By: ZCode <[email protected]>
2026-11-08 18:15:47 +08:00
Ludovic Marie 02deae8774 Merge pull request #12377 from Solenopsisbot/perf/station-defaults-reuse
🤖 Agent: Smithing results and banner defaults now share the shared() snapshot StationData loads once; the standalone loaders keep reading their own.
2026-11-08 11:14:56 +01:00
Ludovic Marie 90267f9863 Merge pull request #12359 from Solenopsisbot/perf/items-enchant-defaults
🤖 Agent: item_defaults::shared is the cached item_defaults::read, so the enchantment properties read the same snapshot Items already decoded; the standalone loader still reads its own.
2026-11-08 11:12:04 +01:00
martipopsandClaude Opus 5.5 88dd9e8d1e Document the HUD kit's Room and Plate
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 05:09:02 -05:00
martipopsandClaude Opus 5.5 8ce5ba0e52 Keep the controller notice and the splitscreen join strip off the boss bars (#12342)
Game::hud_room gathers the boss bars the HUD shows each frame; hud empties the room every
frame first. The notice and the join strip go below the bars when they would cover them,
and stay at y 18 otherwise.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 05:09:02 -05:00
martipopsandClaude Opus 5.5 9fb2965c3a HUD kit: Room keeps Horizon's overlays below the boss bars, and Plate
hud_kit::room holds a view's keep-outs, today the boss bars, and tells a centred overlay
where it goes: where it wanted unless the bars reach past it. boss_bars_bottom follows
BossHealthOverlay's layout. hud_kit::plate draws the notice's box.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 05:09:01 -05:00
Kacpherek 5c616363b1 Push the local player by mobs in ClientLevel's tick order
Vanilla's ClientLevel ticks entities in the order they were added: after
login the player comes first, so a mob added later pushes it from where
both ended the tick, and the push reaches the player's next move. After a
respawn the new player is added after what the level holds, so those
entities tick before it. Horizon applied every mob's push before the
player's move, from where the mobs had just moved: walking into a mob
the server is pushing away tripped anticheats (GrimAC Simulation 0.02-0.04
on every push).
2026-11-08 11:01:33 +01:00
SolenopsisbotandCodex 58140be02b fix: satisfy station defaults lint
Co-Authored-By: Codex <[email protected]>
2026-11-08 21:00:32 +11:00
SolenopsisbotandCodex 4fab87e3ce perf: reuse station item defaults
Co-Authored-By: Codex <[email protected]>
2026-11-08 21:00:32 +11:00
Ludovic Marie d61235cb39 Merge pull request #12370 from Solenopsisbot/perf/stream-decoration-scratch
🤖 Agent: The 9-key array and Box<[Proto; 9]> build the same region in the same order; the removed try_into fallback could never fail (always 9 keys).
2026-11-08 10:59:28 +01:00
Ludovic Marie 38d670c0ac Merge pull request #12379 from Solenopsisbot/perf/block-tick-scratch
🤖 Agent: The spawner and trial-spawner lists keep their tick order and are taken around the client effects, so a client tick can't see a half-filled list.
2026-11-08 10:59:24 +01:00
Horizon agentandClaude Opus 5.5 e5cad94537 Zip budget test: pass the budget by shared reference
clippy (-D warnings): read_within takes &AtomicU64, so &mut was needless.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 10:56:41 +01:00
Ludovic Marie 1c17df10b4 Merge pull request #12367 from Solenopsisbot/perf/remote-tick-scratch
🤖 Agent: Indexing the retained buffers by mob index is safe: nothing between the fluid loop and the rain loop adds or removes remote mobs, and an early return only loses the warm capacity.
2026-11-08 10:55:57 +01:00
Ludovic Marie d1b5000930 Merge pull request #12360 from Solenopsisbot/perf/outline-scratch
🤖 Agent: The outline strokes keep their order (black then bright) and the custom PvP colour; both scratch vectors are bounded by MAX_OUTLINE_EDGES, so the retained capacity stays small.
2026-11-08 10:55:53 +01:00
Florian ErnstandClaude Opus 5.5 83d9347f4b Merge origin/main into fix/h259-unused-feature-startup
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 10:55:15 +01:00
SolenopsisbotandCodex 0dab8b30f5 perf: reuse item defaults for enchantments
Co-Authored-By: Codex <[email protected]>
2026-11-08 20:53:56 +11:00
masa-masa-0314andZCode 5740311a36 Friends: the service's empty answer reads as three empty lists
The live getFriends answer an account with no friends gets is
{"empty":true,"friends":[],"incomingRequests":[],"outgoingRequests":[]};
the parser reads the three lists and ignores the flag. Recorded as a
regression case beside the populated answer.

Co-Authored-By: ZCode <[email protected]>
2026-11-08 17:53:30 +08:00
Florian ErnstandClaude Opus 5.5 17f3a0ff81 /damage breaks, lights or burns an armor stand as its hurt does
ArmorStand.hurtServer returns false for an explosion, fire that ignites
armor stands and burning, after it has acted on the stand: a blast breaks
it (its equipment pops, then it is killed), in_fire lights a stand that
is not burning for 5 seconds, and on_fire burns 4 health off one with
more than 0.5. The command route had no armor stand arm, so /damage left
the stand as it was. It now runs those sources with the stand's guards
and still fails as vanilla's command does.

Part of #12380

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 10:53:20 +01:00
Horizon agentandClaude Opus 5.5 c0f9600544 Parity: run_coordinate_feedback runs by name only
It needs --binary, --work-dir and --reference-dir, which an automatic
parity run doesn't pass (#12372).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 10:50:57 +01:00
VaspyyyandClaude Opus 5.5 f1be49b7df Lid pushes follow vanilla's tick order, and boats reach an open lid
- BoxReach (the boats' and carts' mob pre-filter) is worked out from each
  mob's real box, so a shulker whose open lid grows its box sideways or
  below its position is still found: a boat runs into the lid and picks
  the shulker up, as in vanilla.
- ArmorStand::shove makes up for Horizon ticking stands before mobs: a
  stand that joined after its shulker gets vanilla's push-then-travel
  ground and speed, one that joined before gets Entity.move's own.
- /data merge keeps a mob's and a stand's join order (Entity.load changes
  the entity in place).
- A shulker a boat or a minecart picks up turns to AttachFace DOWN
  (Shulker.startRiding).
- The stand's box uses the float height 1.975F, canStayAt the shulker's
  scale.
- The harness adds wall, ceiling and squeezed lids, with the stand joined
  after and before its shulker, and the boat that runs into an open lid.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 10:47:00 +01:00
VaspyyyandClaude Opus 5.5 a1ecb7f6de An opening shulker lid lifts the armor stand on it
Shulker.onPeekAmountChange moves every entity with physics in the lid's
path with Entity.move(MoverType.SHULKER), armor stands included, and
Shulker.makeBoundingBox grows the shell's box by the lid's physical peek.
Horizon pushed only the player and mobs and sized the box from the
dimensions, so a stand resting on a normal shulker sank into it the first
time the lid opened.

- The lid sends MobEvent::LidPush; ArmorStand::shove moves each stand with
  physics through the collision resolver. Mob::bounds_at gives a shulker
  its makeBoundingBox, scaled and grown by getPhysicalPeek in float.
- Shulker.tick's own part (keep a face, ease the lid) runs without AI
  too, and not for a shell that rides something (isPassenger).
- /data merge keeps a shulker's eased currentPeekAmount: Entity.load reads
  only the raw Peek.
- A stand leaves out the boxes of mobs riding it
  (isPassengerOfSameVehicle), so a stand carrying a /ride shulker rises.
- ObstacleGrid::near tests each box once when the query spans more cells
  than there are boxes, and a stand's loaded Motion drops a component past
  10 (Entity.load), so a huge Motion no longer stalls the server.
- A rider sits on a stand's float passenger point (1.975F widened).
- The harness adds a stand carrying a riding shulker and the lid opening
  and shutting under the first stand.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 10:47:00 +01:00
VaspyyyandClaude Opus 5.5 6880103394 Armor stands land on and stop at shulkers and boats like vanilla
ArmorStand.travel moves with Entity.move, whose Entity.collide adds
EntityGetter.getEntityCollisions: the entities that canBeCollidedWith the
stand (a living shulker, a boat, a happy ghast held still) stop it as
blocks do. Horizon resolved a stand's move against blocks and the world
border only, so a stand dropped onto a shulker sank into it, one dropped
onto a boat fell through to the ground, and one pushed into a shulker
went through it (#11877).

The stand tick now gathers those boxes once, when the first stand
travels (the same set that stops a dropped item, primed TNT or a falling
block), and passes the ones meeting each stand's box stretched by its
movement into the move. It runs in the server's tick, so single player,
LAN hosts and dedicated servers all get it.

The new stand_entity_collision harness drops stands onto a NoAI shulker,
a boat and the stone, slides one into a shulker's side, and compares
their Pos, Motion, OnGround and fall_distance with vanilla's tick by
tick. Without the fix 27 of its 198 records differ.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 10:47:00 +01:00
RINNECODERandClaude Opus 5.5 4d5de18e40 Riding: the driven mount ticks before its rider (ClientLevel.tickNonPassenger) on the input the rider's last tick applied (xxa/zza through modifyInput); it moved a tick ahead of the server's view (GrimAC Simulation on a horse, 27 flags)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 03:46:41 -06:00
RINNECODERandClaude Opus 5.5 340d4d8b22 Mounts: LivingEntity.getFlyingSpeed, a player-steered mount moves through the air at a tenth of its speed, not 0.02 (a horse's jump)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 03:46:41 -06:00
RINNECODERandClaude Opus 5.5 d3c183549e shader packs: honour wetnessHalflife, drynessHalflife, eyeBrightnessHalflife, centerDepthHalflife
Packs set these const directives (ShaderDoc, OptiFine's shaders.txt);
Horizon always used the defaults (600/200/10/1 ticks). IterationT sets
wetnessHalflife 200 and drynessHalflife 50, so under Horizon its sky
stayed sunny for a minute after rain started (wetness rose 3x too
slowly) and stayed wet 4x too long after it stopped. The pack log now
names the half-lives a pack sets.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 03:46:41 -06:00
RINNECODERandClaude Opus 5.5 f711009645 Far tiles: an FxHashMap, not SipHash, for the renderer's tiles
draw_frame looks every visible tile up 3-4 times a frame and upload_lods walks them all; with
lodDistance 256 (~8.5k tiles) std's SipHash was 8-10% of the main thread.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 03:46:40 -06:00
RINNECODERandClaude Opus 5.5 9cde3d1f9a Far tiles: keep mesh jobs' samples as tiles change, not gathered per batch
`Lod::dispatch_meshes` collected every active tile's ready samples into a new
map for each batch of mesh jobs (thousands of entries, rehashed as it grew):
5-10% of the main thread in flight. The map is now kept up to date where a
tile turns active or inactive and where its samples change
(`Lod::sync_sample`), in shards of 8 by 8 tiles behind `Arc`s: a batch copies
the shards' handles, and a change copies only its shard while a job still
reads it. Debug builds check it against the full gather on every dispatch.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 03:46:40 -06:00
RINNECODERandClaude Opus 5.5 c7c143cbf1 Mod content: copy plain files into the overlay instead of reading and rewriting them
Building a mod set's resource root read every content file and wrote it back out; with the
14 Popular Mods (Chipped's 10k textures) that was about two thirds of a 37 s first start
(sample: open/read/write on the main thread). Files the build does not parse are now copied
with fs::copy (a clone on APFS), checked as reading them was (name, 16 MiB limit).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 03:46:40 -06:00
RINNECODERandClaude Opus 5.5 cd313d031f Far tiles: box a tile's meshes so the per-frame tile walk stays small
draw_frame tests every far tile against the frustum each frame. A Tile held two SectionGpu
inline (~0.5 KB), so with lodDistance 256 (~8.5k tiles) the walk read ~9 MB a frame and was
the main thread's top cost (17-23% in sample). Boxed, a tile is ~90 bytes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 03:46:40 -06:00
TheMrZZandCodex 9bf65b95b4 Validate unused fill-layer entries before world readiness
Partial H259; retain other registry validation as open scope.

Co-Authored-By: Codex <[email protected]>
2026-11-08 10:46:38 +01:00
RINNECODERandClaude Opus 5.5 4efbd5ea91 Port pack: 1.21.1 weird_scaled_sampler densities upgrade to 26.3's interval_select of scaled noises
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 03:45:56 -06:00
RINNECODERandClaude Opus 5.5 09d0f6eb7c Port pack: 1.21.1 nether_forest_vegetation upgrades to 26.3's spread simple_block
As 26.3 wrote crimson and warped forest vegetation: a simple block on nylium,
tried spread_width² times at a trapezoid offset, where there is air. Terralith's
last feature left as it was.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 03:45:56 -06:00
RINNECODERandClaude Opus 5.5 ebec6fdd1c Port pack: 1.21.1 random_patch and flower features upgrade to 26.3's counted, offset placement
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 03:45:56 -06:00
RINNECODERandClaude Opus 5.5 dabd3c04cc Port pack: an item that places a block of another name (seeds) binds that block
A BlockItem whose block has another name (Farmer's Delight's cabbage seeds
place `cabbages`, Supplementaries' way signs `way_sign_wall`) was left out
as "a block item without a block": 35 items over the 8 mods. It is now
carried with `place_block` naming its block when that block is carried
(vanilla's or one of the mod's declared ones), and left out as "its block
is left out" otherwise. That check looked the item's own name up in the
left-out blocks instead of its block's.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 03:45:55 -06:00
RINNECODERandClaude Opus 5.5 cec040fef1 Port pack: food items carry over, as 26.3's food, consumable and use_remainder
Food was left out ("no food template yet"): 82 items over the eight mods tried, most of Farmer's
Delight. 1.21.1's minecraft:food held what 26.3 splits into food (numbers), consumable (eating
time, on_consume_effects: one apply_effects per effect with its own chance) and use_remainder
(the bowl), as 26.3's golden apple and mushroom stew have them. NeoForge's cures are dropped, and an
effect of the mod's own is left out and reported (the game would refuse the item).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 03:45:55 -06:00
RINNECODERandClaude Opus 5.5 a2d300a03f Port pack: carried models on 1.21.1's farmland template use 26.3's indented cube
26.3 has no `block/template_farmland`: its farmland model is
`template_cube_bottom_top_indented` with `bottom`, `side` and `top`, where
1.21.1's template read `dirt` for the bottom and sides. A carried model on
the old template (Supplementaries' raked gravel) now names the new one with
its dirt as side and bottom, instead of drawing the missing model.

`minecraft:block/rope` (Supplementaries' bunting particle) is in neither
1.21.1 nor 26.3: not a rename, left as is.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 03:45:55 -06:00
RINNECODERandClaude Opus 5.5 b2e0010655 Port pack: recipes with NeoForge conditions are upgraded when NeoForge loaded them
A vanilla-type recipe with neoforge:conditions was kept as 1.21.1 wrote it, and 26.3 refused its
old ingredients: Quark's 1,466, most of Supplementaries'. The oracle's recipes are the ones whose
conditions passed (a mod's own conditions, such as config flags, are only known there): those
lose the conditions and are upgraded; the others go outside, as NeoForge left them out. Without
the oracle, NeoForge's own conditions are read against the mods ported along.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 03:45:55 -06:00
RINNECODERandClaude Opus 5.5 d0815a9d0b Port pack: tags alone claim no namespace (Farmer's Delight and Supplementaries both added to create's tags and clashed)
A mod's tag additions under another mod's namespace (data/create/tags/...) made it claim that namespace
when its own files named it; two such mods in one pack then refused to load together (Popular Mods:
Supplementaries off). Tags alone now stay outside the carry, as other compat files do.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 03:45:55 -06:00
Florian Ernst 217affdc9e Merge origin/main into fix/h064-vibration-source-uuid
Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	crates/horizon-client/src/game.rs
#	crates/horizon-client/src/game/sculk.rs
2026-11-08 10:44:34 +01:00
Florian ErnstandClaude Opus 5.5 5674b63b57 Measure /damage side effects on armor stands (test checkpoint)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 10:42:38 +01:00
Florian Ernst 255d45aa56 Merge origin/main into fix/test-run-cross-dimension-coordinate-feedback
Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	crates/horizon-client/src/game/commands/test_runs.rs
#	crates/horizon-client/src/game/game_tests.rs
2026-11-08 10:42:37 +01:00
SolenopsisbotandCodex 5f599617e6 perf: reuse block entity tick scratch
Co-Authored-By: Codex <[email protected]>
2026-11-08 20:37:03 +11:00
Ludovic Marie 668555dd33 Merge pull request #12376 from horizon-rust/ci/command-exec-dead-flag
🤖 Agent: Main fix: three lines a union merge brought back read a flag #12242 removed, so every command_exec-driven harness stopped with AttributeError (80 of 173 on #12344's run). Nothing else in the driver uses an undefined args attribute; the corpus builds its 161 groups.
2026-11-08 10:36:50 +01:00
Horizon agentandClaude Opus 5.5 db09adeca9 command_exec harness: drop the seed-sync gaps block a merge brought back
#12242 removed --seed-sync-gaps-diagnostic and the block that read it.
The union merge that landed TheMrZZ's series (#12160) kept the block from
an older base without the flag, so every command_exec run (and every
harness that drives it) stopped at
AttributeError: 'Namespace' object has no attribute 'seed_sync_gaps_diagnostic'.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 10:35:08 +01:00
Román Benjámin e46c586cec parity: take valid texture URIs from the services discovery document 2026-11-08 10:34:11 +01:00
Ludovic Marie 1bc5012f4f Merge pull request #12372 from horizon-rust/ci/named-only-capture-harnesses
🤖 Agent: Tooling only: 17 capture-preserving harnesses that fail at startup under parity.py (no --work-dir / a capture folder that must be new) move to named_only; they still run by name and in the nightly --all audit.
2026-11-08 10:33:02 +01:00
Ludovic Marie 7976203343 Merge pull request #12241 from MSedgeMC/fix/tame-panic-damage
🤖 Agent: A cat or parrot panics from any #panic_causes damage and only a wolf keeps #panic_environmental_causes, as TamableAnimalPanicGoal is built per species; the old fire_ticks gate is gone. I made its test return early without extracted mob data (CI compared None with Some(true)).
2026-11-08 10:27:28 +01:00
TheMrZZandCodex gpt-6.1-sol aaf53aa0bd fix: retain test-run source for completion coordinate feedback
Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-08 10:21:52 +01:00
AkirabaneandClaude Opus 5.5 6ff8c950e4 Merge origin/main into fix/9335-passengers
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 10:19:33 +01:00
Ludovic Marie f8f7d2574b Merge pull request #12231 from NikitaMGrimm/tnt-size-eye
🤖 Agent: Primed TNT's box is 0.98F and its eye height 0.15F, widened from float as EntityType.TNT's sized() gives them; the command_exec case reads them back. Merged main (command_exec corpus group order).
2026-11-08 10:16:27 +01:00
Horizon agentandClaude Opus 5.5 0d27a2a38f Parity: keep the capture-preserving harnesses out of automatic runs
Seventeen harnesses need --work-dir (a fresh capture folder) and
--reference-dir, or assert that their fixed capture folder is new.
parity.py passes neither, so whenever --changed selects them they fail
at startup, whatever the pull request changed: 14 of the 18 failures on
#12336's parity run were these. List them under named_only: they still
run when named and in the nightly --all audit.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 10:16:25 +01:00
SolenopsisbotandCodex 728141ad4f fix: retain remote tick vectors after iteration
Co-Authored-By: Codex <[email protected]>
2026-11-08 20:13:42 +11:00
Loki 329aae2ae3 Merge remote-tracking branch 'origin/main' into fix/11382-vehicle-entity-random 2026-11-08 18:09:56 +09:00
LokiandAntigravity db270fecca game: use entity random for boat, minecart and item frame operations (fixes #11382)
Co-Authored-By: Antigravity <[email protected]>
2026-11-08 18:09:48 +09:00
SolenopsisbotandCodex 4bf7247bcb perf: reuse decoration scheduling scratch
Co-Authored-By: Codex <[email protected]>
2026-11-08 20:07:21 +11:00
adiraj66132 92fc757a73 security: harden guest commands, the JSON parser and zip totals
- A LAN/dedicated guest's command no longer runs the host's client-side
  native mod commands (they ran before any permission check; client mod
  commands belong to the local player only).
- The server-files JSON parser refuses documents nested past 128 levels
  instead of recursing unbounded.
- The zip reader caps the total inflated bytes across entries (4 GiB per
  archive), not just per entry.

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

# Conflicts:
#	crates/horizon-client-render/src/zip.rs
2026-11-08 14:37:10 +05:30
Florian ErnstandClaude Opus 5.5 8a964bbb79 Drop a discarded trader llama from the mob list in the same tick
TraderLlama.maybeDespawn discards an eligible llama in its aiStep when its
DespawnDelay runs out, so it leaves the level's entity list in that tick.
Horizon's countdown set m.removed, but ai::tick still returned true, so
the creature tick kept the llama in game.mobs until the next tick. During
that extra tick the next mob snapshot and the native leave bookkeeping
still saw it. Commands, the tracker and collisions already skip removed
mobs, so commands showed no difference.

The final return of ai::tick now reports a trader llama that its own tick
removed, and the creature tick drops it in that tick, as it does for a
despawned mob. The rest of the llama's tick still runs, and other species
are unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 10:06:51 +01:00
masa-masa-0314andZCode 8a84813b69 Friends: the account's friends service and its worker
Port the service behind vanilla 26.3's friends list, without its screens:

- authlib's FriendsService over the discovery document: getFriends,
  updateFriends, updateAttributes and updatePresence with the account's
  bearer token, If-None-Match/ETag caches, Retry-After poll intervals and
  the ten-second read cooldown (MinecraftServicesFriendsService).
- RemoteFriendListUpdateHandler's worker and state: one read a minute
  (five in the background), a forced read after a successful action, the
  result-code-to-state mapping and the cached lists.
- PlayerSocialManager's flags and accessors, with no screen using them.
- PresenceHandler: the sharePresence option picks the public status, the
  client supplies getPresenceStatus each tick, and closing posts OFFLINE.

Minecraft's constructor decides whether the worker starts from the
account's friendsPreferences (UserFlag.FRIENDS_ENABLED /
ACCEPT_FRIEND_INVITES); title.poll_account now does the same after its
getAttributes answer. The overlay, the Online options switches and the
title/pause icon changes stay for later parts, so no player-visible UI
changes here. A joined Realm is carried to the presence status through
ServerData.isRealm's equivalent (Action::Connect.realm).

No new harness: the list and presence parsers, the state mapping, the
sharePresence levels and Retry-After are unit-tested.

Co-Authored-By: ZCode <[email protected]>
2026-11-08 17:01:47 +08:00
Florian ErnstandClaude Opus 5.5 6fefa6ec54 Save the source and projectile owner UUIDs of a vibration
VibrationInfo keeps the UUIDs of its source entity and of a projectile's
owner, and VibrationInfo.CODEC saves them as source and projectile_owner.
The sculk listener now takes them from the save layer's UUID of the
mob the event names, writes them, and reads them back. Loaded vibrations
keep no live entity; a player source stays unnamed.

Fixes #12186

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 10:01:27 +01:00
Florian Ernst 362e77ba6c Merge origin/main into fix/loot-mine-location-block-state
Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	crates/horizon-client/src/game/commands/terrain_cmds.rs
#	crates/horizon-items/src/items.rs
2026-11-08 09:59:18 +01:00
SolenopsisbotandCodex 65f1235daf perf: reuse remote entity tick scratch
Co-Authored-By: Codex <[email protected]>
2026-11-08 19:56:39 +11:00
Florian Ernst cdfe986e8c Merge origin/main into fix/h052-vehicle-container-save
Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	crates/horizon-client/src/game/commands/data.rs
#	tools/command_exec_corpus.py
#	tools/test_command_exec_parity.py
2026-11-08 09:56:29 +01:00
Florian Ernst b56f598a71 Merge origin/main into fix/h181-noai-trader-llama-delay
Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	crates/horizon-client/src/mobs/ai.rs
#	tools/command_exec_corpus.py
2026-11-08 09:56:20 +01:00
Florian Ernst b778599d5f Merge origin/main into fix/h157-player-save-projection
Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	crates/horizon-client/src/game.rs
2026-11-08 09:56:11 +01:00
Florian Ernst 1699b82741 Merge origin/main into fix/named-predicate-context
Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	crates/horizon-client/src/advancements.rs
#	crates/horizon-client/src/game/functions.rs
2026-11-08 09:55:53 +01:00
Florian Ernst 79723cb13e Merge origin/main into fix/inline-armor-trim-holders
Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	crates/horizon-client/src/item_sync.rs
#	crates/horizon-client/src/lan/guest.rs
#	crates/horizon-protocol/src/item.rs
2026-11-08 09:55:26 +01:00
Horizon agent ee61f0ea71 Merge remote-tracking branch 'origin/main' into HEAD 2026-11-08 09:55:20 +01:00
Florian ErnstandClaude Opus 5.5 84e22207fb Measure saved vibration source UUIDs (H064 test checkpoint)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 09:48:41 +01:00
SolenopsisbotandCodex 83de5c24d6 perf: reuse selection outline scratch
Co-Authored-By: Codex <[email protected]>
2026-11-08 19:48:00 +11:00
Ludovic Marie 2d22e6a7e3 Merge pull request #12311 from voidsipp/fix/round6-12063
🤖 Agent: Server configuration dialogs keep the panorama behind them (DialogScreen inherits Screen.extractBackground) instead of the flat 20/20/20 fill.
2026-11-08 09:43:43 +01:00
Ludovic Marie e1315b7e55 Merge pull request #12357 from umbx-dot/launcher/mod-version-picker
🤖 Agent: Includes #12355. A version picked in the Versions tab is kept (chosen-versions.json) until the player installs the recommended one, so the pre-build swap and the automatic updates no longer undo a deliberate pick.
2026-11-08 09:43:38 +01:00
dd8daeb619 HDR float frame: remove the mark resolve and lane state
Float writers already store colour and coverage. Remove the unused mark
resolve pipeline and shader, and keep the world scratch and parameters for
the tone curve and HUD composite that still use them.

Choose no-pack HDR gain explicitly without storing a frame lane. Keep the
uniform ABI and existing light arithmetic. TAA tracks baked gain and its
settings, preserves compatibility history when gain is off, and rejects
history when it resumes after a shader pack.

Use a non-float legacy oracle for writer tests and restore exact float,
OIT and visibility-buffer sessions in the identity harness. Preserve the
compatibility mark paths and the non-layered hand brightness sentinel.

Co-Authored-By: GPT 6.1 Sol <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 09:37:53 +01:00
Ludovic Marie 6f856b2c0a Merge pull request #12343 from Solenopsisbot/perf/lz4-decode-scratch-v2
🤖 Agent: Each stream now starts from 21 zeroed bytes (clear + resize, pushed): a bare resize left the previous stream's bytes past a short block, which the decompressor may read, so corrupt-stream checks could differ from a fresh buffer.
2026-11-08 09:37:00 +01:00
Ludovic Marie b0ad9abd0c Merge pull request #12333 from Solenopsisbot/perf/pack-page-scratch
🤖 Agent: The retained page list is cleared after the shader pack's post passes (pushed): it keeps its capacity, but no longer holds clones of arena page buffers until the next pack frame, which would keep freed pages alive on the GPU once the pack is off.
2026-11-08 09:36:57 +01:00
Ludovic Marie c5c53d4c6c Merge pull request #12354 from horizon-rust/ci/pack-uniforms-scripted-clock
🤖 Agent: Test-only: the smoothed-uniforms test steps PackInputs::frame_timing instead of backdating the wall clock, so a slow runner can no longer push centerDepthSmooth past its 1e-3 tolerance (failed verify on #12241 and #12311).
2026-11-08 09:33:49 +01:00
Ludovic Marie 3bca17f46a Merge pull request #12160 from TheMrZZ/fix/h248-hurt-cooldown-incoming
🤖 Agent: This head holds all 13 layers of TheMrZZ's series (12010 already merged; 11732 … 12133 below it), so landing it merges the rest. The top layer's own point: inside the hurt cooldown a hit is compared with `lastHurt` by its incoming amount, before armor and Resistance, and only the difference goes on to `actuallyHurt`, as `LivingEntity.hurtServer` does.
2026-11-08 09:26:53 +01:00
Ludovic Marie 7e306e4b25 Merge pull request #12337 from NikitaMGrimm/hdr-marks-b
🤖 Agent: Stacked on #12330: landing it lands the chain. Only the float frame (HORIZON_HDR_FRAME=float) changes its alpha; 8- and 16-bit frames keep their pipelines and marks.
2026-11-08 09:26:49 +01:00
Ludovic Marie 26d73e3f28 Merge pull request #12346 from Solenopsisbot/perf/worldgen-biome-bitset
🤖 Agent: A spilled word keeps its index (128 + spill offset) in the chained iteration, so the ids stay ascending as with the Vec bitset; 1 KB on the stack per generated chunk.
2026-11-08 09:26:46 +01:00
Ludovic Marie 929cb85c85 Merge pull request #12340 from Solenopsisbot/perf/worldgen-empty-blender
🤖 Agent: Blender has no interior mutability (two OpenMaps and an Option, read only), so one shared empty Arc is safe for every chunk without old-world data, and non-empty blenders stay per chunk.
2026-11-08 09:26:42 +01:00
Ludovic Marie e3966b586a Merge pull request #12338 from Solenopsisbot/perf/game-cpu-fanout
🤖 Agent: The ten handoff loaders share only the resource root and the parsed entity document, both immutable; a panic in a worker is resumed on the caller and Creative's error keeps its message, so startup fails the same way. No wall-clock number yet: the gain is the slowest loader instead of the sum.
2026-11-08 09:26:39 +01:00
umbx-dotandClaude Opus 5.5 7a1fe7f702 launcher: a native mod's Versions tab says which version fits, and picks one
A registry native mod's Versions tab lists each version's date and how it
fits the Horizon played (the fit line the trust text already uses), marks
the one the launcher would pick Recommended, and has Install on every
version but the installed one.

A version the player picks there stays as it is: no automatic update, no
offered update and no swap before a build, until the player installs the
mod again or picks its recommended version (mod-browser/chosen-versions.json).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 09:23:15 +01:00
Ludovic Marie 443f9b0642 Merge pull request #12330 from NikitaMGrimm/hdr-marks-a
🤖 Agent: On the float frame the renderer is rebuilt when the HUD layering changes, not only the format; still behind HORIZON_HDR_FRAME=float.
2026-11-08 09:19:46 +01:00
Horizon agentandClaude Opus 5.5 79738505fb LZ4 scratch: zero the header bytes for each stream, as a new buffer had them
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 09:19:36 +01:00
Horizon agentandClaude Opus 5.5 851bda5b5e Shader pack pages: drop the buffer clones after the frame, keep the capacity
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 09:19:04 +01:00
Horizon agent ce322efe53 Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	tools/command_exec_corpus.py
2026-11-08 09:17:08 +01:00
umbx-dotandClaude Opus 5.5 238bf31f77 launcher: automatic updates keep a native mod on a version that builds
The start's automatic update moved native mods to their newest version, which the build then swapped back when it doesn't build on the Horizon played. It now updates them only to the newest version that builds there, when that is known.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 09:16:44 +01:00
Horizon agentandClaude Opus 5.5 aa1f9c5dc9 Shader pack test: drive the smoothed uniforms with a scripted clock
smoothed_uniforms_take_iris_half_lives backdated the wall clock and let
update() measure the gap, so any delay between the two calls moved
centerDepthSmooth (0.1 s half-life) past its 1e-3 tolerance. It failed
verify on busy runners for unrelated PRs (#12241, #12311). Use
PackInputs::frame_timing, which update() already takes as the frame's
delta, so every step is exact.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 09:15:28 +01:00
k3yhandClaude Opus 5.5 b4ed614faa A potion or tipped arrow is named by its contents
PotionItem.getName and TippedArrowItem.getName name these items by
their contents (PotionContents.getName: the contents' custom_name, the
potion, else empty), over item_name. brewing::contents_name_key gives
that key, and the five functions that name a stack take it after the
custom_name component and before item_name.

New command corpus group: potion contents names.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 03:13:32 -05:00
SolenopsisbotandCodex e3082a0281 fix: satisfy LZ4 clippy
Co-Authored-By: Codex <[email protected]>
2026-11-08 19:13:15 +11:00
Horizon agent 013fb324e6 Merge remote-tracking branch 'origin/main' into HEAD 2026-11-08 09:08:18 +01:00
Horizon agentandClaude Opus 5.5 0a871808fa Ticket fast path: compare the portal tickets with their level, as the sources store them
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 09:08:18 +01:00
Vinícius MoreiraandClaude Opus 5.5 0e6d23f59b Polytone: biome and dimension modifiers as attribute layers
Resource packs' Polytone biome and dimension modifiers now change the
environment attribute stack the frame draws (wiki "Environment
Attributes"): a dimension modifier's base group changes the dimension's
own entries, rain and thunder change the weather's, post_process adds a
layer after all others; a biome modifier's base group changes its target
biomes' entries (ids, #tags through the biome tags, patterns) and
post_process adds a biome layer after the weather. REMOVE drops an
entry; modifiers apply lowest priority first. Values that are scripting
expressions or colormaps are left for the next steps.

The stacks are built when packs load, only when a pack has modifiers, and
only the frame reads them: gameplay (sky darkening, the server's sun
angle) keeps vanilla's. Without such a pack nothing changes.

With such a pack on, its biome and dimension modifiers load and apply;
the environment_render and environment_system harnesses still match
vanilla.

Part of #11486

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 05:08:03 -03:00
Ludovic Marie 170cfb468c Merge pull request #12321 from voidsipp/fix/round6-12128
🤖 Agent: LAN join and respawn packets carry the world spawn and its rotation, not the acting player's personal spawn, and /setworldspawn now updates connected players in every dimension (SetWorldSpawnCommand.setSpawn, PlayerList.sendLevelInfo).
2026-11-08 09:07:49 +01:00
Vinícius MoreiraandClaude Opus 5.5 0d0b58a960 Polytone: pack configs and conditional overlays
Polytone packs keep most of their effects in pack overlays that a
polytone_condition turns on: a scripting expression over the pack's own
config entries (config('pack:fog') == 'ON', modOn('minecraft', range)),
or an object with require_config, require_mods and override. A pack's
config entries (assets/<namespace>/polytone/config_entries/*.json) are
now read at their default values, and an overlay with a condition is
active when it holds: an expression decides alone (packs pair it with
formats no version matches), an object only alongside the formats unless
it overrides them. Overlays without one work as before.

Part of #11486

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 05:07:42 -03:00
Vinícius MoreiraandClaude Opus 5.5 92e3012389 Environment: sample the sky through the vanilla attribute stack
Environment now builds the dimension's EnvironmentAttributeSystem as
addDefaultLayers does: the dimension type's attributes, a biome layer for
every attribute some biome sets, the timelines its tag lists (in order),
and the rain and thunder layers where Level.canHaveWeather. The frame
samples it with the camera's Gaussian biome mix (the probe's order) and
the biome at the camera, so every attribute blends and changes with the
weather the vanilla way, not only the sky and fog colours, and the
hand-written timeline sampling, biome colour blending and apply_weather
are gone. Biomes keep their attribute maps for the biome layer.

environment_render now passes with no tolerance except one rounding in
Sky's light level fraction (was 218 inexact cases), and covers cloud
height.

Part of #11486

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 05:07:42 -03:00
SolenopsisbotandCodex 0427c41bde perf: keep biome id bitsets on the stack
Co-Authored-By: Codex <[email protected]>
2026-11-08 19:05:34 +11:00
umbx-dotandClaude Opus 5.5 54bedee730 launcher: use the native mod version that builds on your Horizon
Installing a registry native mod took its newest version, and a build
left out any mod made for another Horizon, though the registry often has
an older version made for the Horizon the player picked.

native_fit::best picks the newest version that builds on the Horizon
played, else the newest that moves the build to its own commit without
clashing. Installs and updates use it, and before each build every mod
turned on whose installed version doesn't build is swapped for that
version, with a line saying which version is used.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 09:04:54 +01:00
Kacper 23bd1b7627 Merge pull request #12318 from TheApple05/codex/parity-12181
Keep resource-pack parity paths intact with JSON headers
2026-11-08 09:00:21 +01:00
Kacper 920346fb43 Merge pull request #12010 from TheMrZZ/fix/h236-clone-dimensions
Resolve /clone's source and target dimensions
2026-11-08 09:00:13 +01:00
Kacper 632d6cd4f4 Merge pull request #11654 from MSedgeMC/fix/set-custom-model-data
A loot table's set_custom_model_data writes the item's model data
2026-11-08 09:00:01 +01:00
Kacper 98342f1bd0 Merge pull request #11476 from baytyone/xp-orbs-riding-merge-water
Experience orbs: pickup over the vehicle, a kept following player, merges before the move, eye-level water
2026-11-08 08:59:50 +01:00
Kacper d2cadb1c39 Merge pull request #11473 from MSedgeMC/fix/vindicator-johnny
A vindicator that was named Johnny stays Johnny
2026-11-08 08:59:39 +01:00
Kacper cab807c9d7 Merge pull request #11463 from MSedgeMC/fix/palette-pack
Chunk storage: pack a section's palette as vanilla's PalettedContainer.pack does
2026-11-08 08:59:32 +01:00
SolenopsisbotandCodex 70e0a37264 fix: satisfy pack page lint
Co-Authored-By: Codex <[email protected]>
2026-11-08 18:56:55 +11:00
SolenopsisbotandCodex 58b2eaa55e fix: preserve LZ4 scratch stream length
Co-Authored-By: Codex <[email protected]>
2026-11-08 18:51:32 +11:00
SolenopsisbotandCodex 104fe56b30 perf: reuse LZ4 decode scratch
Co-Authored-By: Codex <[email protected]>
2026-11-08 18:51:32 +11:00
SolenopsisbotandCodex 98d9f172bd perf: share empty worldgen blenders
Co-Authored-By: Codex <[email protected]>
2026-11-08 18:43:32 +11:00
Ludovic Marie b49f32e4ab Merge pull request #12332 from martipops/settings/shell-choice-step
🤖 Agent: Left and Right step a focused choice back and on in the settings shell, as Shift-press and press do; other rows keep their keys.
2026-11-08 08:41:32 +01:00
Ludovic Marie 32613f5637 Merge pull request #12331 from Vaspyyy/biome-id-past-table
🤖 Agent: A biome id past the table after a proxy's server switch names plains instead of panicking (#12148: "the len is 67 but the index is 67"), and the reconfiguring client plays menu music as ServerReconfigScreen lets it.
2026-11-08 08:41:29 +01:00
Ludovic Marie 3f604c3028 Merge pull request #12329 from Solenopsisbot/perf/vegetation-scratch
🤖 Agent: Vegetation patches iterate their position sets directly instead of copying them, and multiface growth shuffles fixed arrays; the shuffle draws the same number of randoms in the same order.
2026-11-08 08:41:25 +01:00
Ludovic Marie ed4077ec1d Merge pull request #12328 from Solenopsisbot/perf/creature-vegetation-scratch
🤖 Agent: The creature tick's MobEvent buffer is kept between ticks and drained, returning its capacity even when a handler errors.
2026-11-08 08:41:22 +01:00
Ludovic Marie f9b605a934 Merge pull request #12327 from Solenopsisbot/perf/pack-resource-fanout
🤖 Agent: Pack names, clouds, the forcefield and custom sky load on four scoped threads; panics still propagate (resume_unwind) and the values are the same.
2026-11-08 08:41:18 +01:00
Ludovic Marie fbd34a0343 Merge pull request #12326 from J5Casey/braid/fishing-medium
🤖 Agent: The fishing_medium native hook lets a mod make a bobber float and fish in another block, with its own loot table and lure range; in water the vanilla path is untouched.
2026-11-08 08:41:14 +01:00
Ludovic Marie e9208d8d86 Merge pull request #12325 from Solenopsisbot/perf/displacement-frame-scratch
🤖 Agent: The displacement pass collects its nearby sections into a SmallVec sized to its 64-section cap and reuses its section table, removing the per-frame heap allocations; the GPU writes are unchanged.
2026-11-08 08:41:10 +01:00
Ludovic Marie e0081cb2ae Merge pull request #12324 from k3yh/loot-components-modelled
🤖 Agent: A loot set_components patch for a component Horizon models (potion_contents and the rest) is read into its field as a saved stack's is, so a rolled potion gives its effect; damage and custom_name keep their own paths.
2026-11-08 08:41:06 +01:00
Ludovic Marie 9990881f3a Merge pull request #12291 from masa-masa-0314/feat/realms-account-uuid
🤖 Agent: The single-player owner is the signed-in account's UUID and older worlds move their owner to it on open; I made the migration keep the offline player files as .dat_offline backups instead of deleting them.
2026-11-08 08:41:01 +01:00
k3yhandClaude Opus 5.5 24a3e509bd /loot give to a full inventory throws nothing out
The give branch of cmd_loot spawned what give couldn't fit at the
command's position and counted every drop. LootCommand.playerGive puts
each drop in with Inventory.add: what fits stays, the rest is lost, and
the drop counts only when add returns true, which it does when all of
it went in or for a creative player.

New command corpus group: loot give full inventory.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 02:39:11 -05:00
SolenopsisbotandCodex 7dda0df3c0 perf: fan out game CPU resource loads
Co-Authored-By: Codex <[email protected]>
2026-11-08 18:38:23 +11:00
Ludovic Marie 6539a95ef6 Merge pull request #12322 from masa-masa-0314/feat/realms-create-world
🤖 Agent: Generate New World opens Create World and uploads the temporary world it writes, as RealmsCreateWorldFlow.createWorld does.
2026-11-08 08:37:45 +01:00
Ludovic Marie 4a1adf44fd Merge pull request #12295 from glitchsnake/fix/11854-mob-health-boost
🤖 Agent: A mob's max_health is refreshed whenever Health Boost's amplifier changes or it ends (load, conversion, milk, death protection), and health is clamped to the new maximum, as refreshDirtyAttributes does. I merged main: #12255 already refreshes Health Boost in apply_death_effect and the trader's drink, so this PR's copies of those blocks were dropped and its test kept.
2026-11-08 08:37:41 +01:00
Ludovic Marie 4cf8cd0957 Merge pull request #12257 from voidsipp/fix/round6-12230
🤖 Agent: Piston block events and sculk charge cursors check the block-ticking ticket range (ServerLevel.shouldTickBlocksAt), not chunk ticking readiness, so they run in a freshly ticketed chunk; moving pistons still wait for readiness, as the oracle shows. Merged main (isPositionTickingWithEntitiesLoaded's doc now names the entities).
2026-11-08 08:33:21 +01:00
Nikita Maximilian GrimmandGPT 6.1 Sol eb835292f5 HDR float frame: store colour and coverage without light marks
Choose float alpha storage independently of the no-pack HDR gain. Plain
draws and the mirror keep their RGB; opaque finishes write alpha 1 and
blended draws keep coverage. Keep the existing written-light arithmetic.

Run light-mark redraws and hand unmark/remark only on compatibility
frames. Preserve the non-layered 0.2 hand's UI-brightness sentinel. Stop
resolving float alpha into light, while retaining the resolve oracle and
lane state for the next cleanup.

Check real shader outputs on the GPU and preserve the existing pictures
with differential HDR, pack, OIT, visibility-buffer and hand captures.

Co-Authored-By: GPT 6.1 Sol <[email protected]>
2026-11-08 08:32:06 +01:00
SolenopsisbotandCodex ddaada5bb9 perf: reuse shader pack page scratch
Co-Authored-By: Codex <[email protected]>
2026-11-08 18:31:27 +11:00
VaspyyyandClaude Opus 5.5 6b33c13380 Divide MenuTooltipPositioner's widget offset as floats like vanilla
getOffset divides the pointer's distance from the widget edge by the
widget height as floats: 26.3's bytecode is iload_3, i2f, iload_2, i2f,
fdiv before Mth.lerp and Math.round(float). The decompiled text without
the casts made widget_offset an int division, so a hovered widget's
tooltip stayed height - 3 below its edge until the pointer was a whole
height away, then jumped. On a 20 px button it sat up to 10 px lower than
vanilla's. The offset now follows the pointer as vanilla's does, rounded
with jmath::round_f32, and the test expectations are the values the real
26.3 MenuTooltipPositioner returns for the same inputs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 08:28:21 +01:00
VaspyyyandClaude Opus 5.5 b03828a569 Show the title screen icon buttons' tooltips while hovered like vanilla
The title screen's icon row (Friends, Language, Accessibility) drew its
sprites but no tooltip: the tooltip pass after the button loop only knew
Create World, Select World and Multiplayer. Vanilla gives these buttons a
Tooltip (CommonButtons.language and accessibility, FriendsButton while
friends are available), which WidgetTooltipHolder draws while the button
is hovered, placed by MenuTooltipPositioner and split at 170 px. The
accessibility onboarding screen builds its Accessibility Settings... and
Language... buttons with the same CommonButtons, so they lacked the same
tooltips.

Title::button_tooltip now names each screen's button tooltips, and both
the narration hints and the draw loop read it. The draw loop remembers
the hovered button's tooltip and draws it after the widgets (and after
the onboarding fade, which vanilla applies to widgets only) with the same
positioner (ui::tooltip_menu), before the toast so the toast stays on top.
Fixes #12067.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 08:28:21 +01:00
VaspyyyandClaude Opus 5.5 99bd306532 Scatter a destroyed chest boat's or container minecart's contents like vanilla
AbstractChestBoat.remove and AbstractMinecartContainer.remove spill the
container through Containers.dropContents for every removal reason that
destroys the vehicle: the kill of VehicleEntity.destroy and a creative
hit's discard alike. That spill has no entity_drops gate; only the hull
item and chestVehicleDestroyed's second, empty spill follow the rule.

Horizon's damage path got both vehicles wrong. A chest boat dropped each
stack whole at its position with an item's own motion and a pickup
delay of 10, and never drew chestVehicleDestroyed's per-slot spots. A
container minecart scattered its contents only with entity_drops on, so
with the rule off (or from a creative hit with it off) the contents
vanished.

Both now share one Containers.dropContents helper (the /kill path used a
second copy): the contents scatter from a spot in the vehicle's block in
pieces of 10 to 30 with no pickup delay, before the ENTITY_DIE event and
before a boat's lead, as Entity.kill and AbstractBoat.remove order them.
The hull item and the empty spill still need entity_drops and a
non-creative hit. Every damage source (players, LAN guests, explosions,
lightning, /damage) goes through these two functions, so single player,
LAN and dedicated servers all get it.

The vehicle_command_damage harness now breaks a chest boat with /damage,
and a chest minecart with entity_drops off both by /damage and by a
creative player's hit, against the real 26.3 server. Each break must
leave the 5 diamonds inside the vehicle's block with no pickup delay,
and the 40 dirt as piles in that block whose counts add up to 40. A
gameplay test seeds the level random, breaks a chest boat and a chest
minecart, and checks that the random ends where vanilla's two
Containers.dropContents calls leave it (the empty second spill's draws
included).

The port map rows for AbstractChestBoat.remove and
AbstractMinecartContainer.remove point at the shared helper instead of
the removed /kill copy. The chestVehicleDestroyed row now says what is
left there: a player's hit on a container minecart angers no piglins yet.

Fixes #12121

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 08:27:26 +01:00
Horizon-agent 2fae2a9101 Merge remote-tracking branch 'origin/main' into HEAD 2026-11-08 08:21:34 +01:00
VaspyyyandClaude Opus 5.5 6d72b0375e Fire shulker bullets from the box centre and turn them at standable blocks
A shulker's bullet started at the centre of the shulker's block cell.
Vanilla's ShulkerBullet constructor starts it at
owner.getBoundingBox().getCenter(), and Shulker.makeBoundingBox grows
that box toward the open side by the lid's physical peek (times the
scale), so an open floor shulker fires from the top of its block
(#11974). shulker::bullet_origin now builds that box with
getProgressDeltaAabb at the shulker's scale, from the peek the goal
sees (the attack goal runs before Shulker.tick eases the peek, in both
games).

In flight, a bullet turned only before a full collision cube. Vanilla
asks Level.loadedAndEntityCanStandOn(next, bullet): the top face of the
block's collision shape, in the bullet's collision context, is full
(Block.isFaceFull). Top slabs, upside-down stairs, closed top trapdoors
and the like now turn it too, instead of the bullet flying into them
(#11970). collisions::projectile_can_stand_on implements the test, and
ProjectileWorld::solid becomes can_stand_on(pos, entity_y), since
scaffolding reads the bullet's height.

New harness shulker_bullet: the real ShulkerBullet constructor for all
six attach faces, every peek of an opening lid and four scales, and
loadedAndEntityCanStandOn over forty block states at four bullet
heights, compared bit for bit with the Rust probe.

Fixes #11974
Fixes #11970

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 08:17:17 +01:00
Horizon agentandClaude Opus 5.5 4f71890226 Ticket fast path: compare the portal tickets' chunks after the main merge
#12232's portal_matches read self.portal as (chunk, level) pairs; here it holds
PortalTicket, so it compares ticket.chunk.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 08:16:49 +01:00
martipops 47a832db72 Merge remote-tracking branch 'upstream/main' into settings/shell-choice-step 2026-11-08 01:56:53 -05:00
Horizon agentandClaude Opus 5.5 3627361d25 Merge main into #12295
#12255 already refreshes Health Boost and clamps absorption in apply_death_effect
and the wandering trader's drink; main's blocks are kept, this PR's copies of them
dropped, and its clear-all-effects test kept.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 07:55:18 +01:00
Horizon agent db1e3398ce Merge remote-tracking branch 'origin/main' into HEAD 2026-11-08 07:54:46 +01:00
SolenopsisbotandCodex 5288273f01 perf: reuse vegetation placement scratch
Co-Authored-By: Codex <[email protected]>
2026-11-08 17:50:24 +11:00
SolenopsisbotandCodex e55b51a4b0 perf: reuse creature event scratch
Co-Authored-By: Codex <[email protected]>
2026-11-08 17:47:51 +11:00
SolenopsisbotandCodex b7468c0196 perf: fan out independent pack resource loads
Co-Authored-By: Codex <[email protected]>
2026-11-08 17:43:18 +11:00
Horizon agent 282b7b3085 Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	crates/horizon-client/src/game.rs
2026-11-08 07:42:55 +01:00
Horizon agent 7d269ccea1 Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	crates/horizon-client/src/realms.rs
2026-11-08 07:42:42 +01:00
Horizon agent c072d21438 Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	tools/command_exec_corpus.py
2026-11-08 07:42:38 +01:00
SolenopsisbotandCodex 94bd299a9a perf: reuse displacement frame scratch
Co-Authored-By: Codex <[email protected]>
2026-11-08 17:39:59 +11:00
bf6a7d910c HDR float frame: rebuild when the HUD layering changes
Choose the frame format and HUD layering together. No-pack float HDR needs
the HUD layer, so devices without it keep the compatibility frame. A 0.2
pack still gets its float frame in SDR and HDR.

Rebuild when picking or dropping a pack changes the layering even if the
format stays float, so the renderer always has the layering a fresh start
would give it.

Co-Authored-By: GPT 6.1 Sol <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 07:39:39 +01:00
Horizon agentandClaude Opus 5.5 944a3e1c80 Keep the offline player files as backups when the owner moves
The migration renamed nothing back if it went wrong: it deleted the offline
.dat and .dat_old after writing the account's file. They now stay beside it as
.dat_offline and .dat_old_offline.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 07:36:14 +01:00
Horizon agent f57866e72f Merge remote-tracking branch 'origin/main' into HEAD 2026-11-08 07:35:55 +01:00
k3yhandClaude Opus 5.5 8c8615875e A potion a loot table rolls with set_components gives its effect
StackData::set_loot_component kept every component but trim in other,
its saved form, so a potion a chest or a mob's loot rolled with a
set_components potion_contents gave no effect: brewing::contents_effects
reads potion and potion_extra. A modelled component is now read into
its field the way a saved stack's is (decode_components). Damage stays
in other, as the stack holds it, and custom_name keeps its own reading.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 01:34:11 -05:00
VaspyyyandClaude Opus 5.5 dde2b2abff Start the menu music while reconfiguring and test the #12148 path
Vanilla's ServerReconfigScreen is a plain Screen, not a
LevelLoadingScreen, so MusicManager.tick starts the MENU music that
getSituationalMusic picks once the player is gone. Horizon treated the
reconfiguration as loading (no player, no title, no credits) and stayed
silent until the next world; a reconfiguring connection no longer
counts as loading.

A Game test (prepared resources, ignored by default) puts a column
holding a biome id one past the table at the origin, reads it through
biome_at and biome_name_at (plains), then checks that a reconfiguring
client picks and starts the menu music and that the level probe after
it reads plains' music without a panic.

Biomes::name's doc now says that Biomes::get clamps such an id to the
last entry instead.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 07:31:01 +01:00
VaspyyyandClaude Opus 5.5 85b61b6f6e Stop the client crashing on a biome id past its table after a server switch
A proxy that moves the player to another server reconfigures the
connection. Horizon keeps the last server's columns until the next
Login clears them, but Reconfigured already swaps in the next server's
biome table. When the last server had a biome the game lacks (added to
the table as id 67) and the next one does not, those columns hold an id
past the 67-entry table. situational_music still probed the level at
the origin during the reconfiguration, and biome_at indexed the table
with that id: "index out of bounds: the len is 67 but the index is 67".

Vanilla never reads that level: handleConfigurationStart calls
clearClientLevel, which nulls the player, so getSituationalMusic
returns the menu music. The music probe now asks Remote::has_level
(Login seen, no reconfiguration since) instead of logged_in.

The three places that indexed the biome table directly (biome_at for
music, ambience and custom skies, biome_name_at for random textures and
fishing, and /fillbiome's filter) now go through Biomes::name, which
names plains for an id past the table, as biome_at already did for a
missing column and Biomes::id does for an unknown name.

Fixes #12148

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 07:30:57 +01:00
Horizon-agent 24c3cabed3 Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	tools/test_command_exec_parity.py
2026-11-08 07:20:51 +01:00
Horizon agentandClaude Opus 5.5 61d43cf005 Skip the tamable panic test without extracted mob data
with_built_test_world runs nothing where the data is missing (CI), and the
test then compared None with Some(true); it now returns early there, as the
other with_built_test_world tests do.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 07:16:00 +01:00
Horizon agent b62553197b Merge remote-tracking branch 'origin/main' into HEAD 2026-11-08 07:15:37 +01:00
TheApple05andCodex GPT-6 911bab7e0f Preserve resource-pack parity paths with JSON headers
Co-Authored-By: Codex GPT-6 <[email protected]>
2026-11-08 06:11:54 +00:00
Horizon-agent 030e7e6ff6 Merge commit '02ff63cf4bc4f243391974eb957afa247f4c5a01' into HEAD 2026-11-08 07:05:41 +01:00
masa-masa-0314andZCode 32cc9c56e0 Realms: Generate New World opens Create World and uploads what it makes
`RealmsResetWorldScreen`'s Generate New World tile is enabled: it opens the Create
New World screen (`RealmsCreateWorldFlow.createWorld`), and its Create builds the
temporary world the upload packs instead of entering a local one.

- The tile returns the Realm, slot and reset kind to the title, which shows Create New
  World with the default options. Create (`create_or_upload`) builds a `Level` from
  those options (name, seed, game type, difficulty, cheats, game rules, feature flags
  and data packs) and hands it back to the Realms screen; Back/Escape returns to the
  configuration screen (or the list for a new Realm).
- `realms::create_temporary_world` mirrors `createTemporaryWorldFolder`: a temporary
  folder with level.dat from `WorldData.createTag(null)` (no `singleplayer_uuid`), the
  world generation and game rules, and the Create World screen's data packs moved into
  `datapacks/`. No chunks: the Realm generates them.
- The upload runs the same `RealmCreationTask` → `SwitchSlotTask` → `packAndUpload`
  chain as a local-world upload, with `RealmsWorldOptions.createFromSettings` (slot
  name, game mode, difficulty, the game's version) and the hardcore setting. While it
  runs `AlertScreen` (`mco.create.world.reset.title`) shows a Cancel; success returns
  to the configuration screen, or plays the Realm just created
  (`RealmsMainScreen.play`); cancel returns to the reset screen; a failure shows
  `mco.create.world.failed`.
- `Level::write_upload` writes only the upload's files (no player file, weather or
  clocks), with `singleplayer_uuid` omitted as `createTag(null)` writes it.

Co-Authored-By: ZCode <[email protected]>
2026-11-08 14:05:35 +08:00
Horizon-agent 02ff63cf4b Merge commit '1cccd4167d8525b125ac6cc74f0dcc235683e767' into HEAD 2026-11-08 07:04:51 +01:00
Horizon-agent 1cccd4167d Merge commit '849c2915611feced73975c8310f5d5f603f747af' into HEAD 2026-11-08 07:03:58 +01:00
Horizon-agent 849c291561 Merge commit 'ca441c3ea06bbff7c2cae41cd4b3f0cf3ea88813' into HEAD
# Conflicts:
#	tools/command_exec_corpus.py
2026-11-08 07:03:46 +01:00
Horizon-agent ca441c3ea0 Merge commit '5ee77de8c82449a4830f716a1be8d7553fdd65d4' into HEAD 2026-11-08 07:02:48 +01:00
martipopsandClaude Opus 5.5 dba8453681 Settings: Left and Right step a focused choice back and on
On the settings shell (Extras, Graphics), a value between `<` `>` could
only be stepped back with Shift-click or, with an Extras setting on, the
wheel: a pad or keyboard player could only go forward. Left and Right on a
focused choice now step it back and on, as its arrows show, from the D-pad
and the arrow keys alike. They are a Shift press and a press, the same path
as a click, so Undo and saving follow.

They step at once rather than after A arms the row, as sliders do: A keeps
its step forward and there is no mode to find, and up and down passing over
a choice never change it. Up and down leave the row, and LB and RB reach the
page list.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 01:02:15 -05:00
Horizon-agent 5ee77de8c8 Merge commit '35ab3f27b058be561260e09d9676d289cde52565' into HEAD 2026-11-08 07:01:55 +01:00
voidsip ba5f08c10c Keep respawn fallback and spawn rotations consistent with world data 2026-11-08 00:01:37 -06:00
Horizon-agent 35ab3f27b0 Merge commit 'ed1d96c9117c54ea7a6d74556f34fa08d05aeb8a' into HEAD 2026-11-08 07:00:52 +01:00
Horizon-agent ed1d96c911 Merge commit 'd7c560ec78d66a2c022a494be7c084f5aa7dfad9' into HEAD 2026-11-08 06:59:58 +01:00
Horizon-agent d7c560ec78 Merge commit 'dc41d4bc93996ede221a19266f924b30f683e39b' into HEAD 2026-11-08 06:59:13 +01:00
Horizon-agent dc41d4bc93 Merge commit '826fdf6306a88ad931520b0a9f34801675b76660' into HEAD 2026-11-08 06:57:09 +01:00
Horizon-agent 826fdf6306 Merge commit '1c878671de71a6827708ed1eb51141b32da22d2a' into HEAD
# Conflicts:
#	tools/command_exec_corpus.py
2026-11-08 06:56:51 +01:00
Horizon-agent 1c878671de Merge commit '414fcee500187355e0a27c64819b2d9823a93a3f' into HEAD
# Conflicts:
#	tools/command_exec_corpus.py
#	tools/test_command_exec_parity.py
2026-11-08 06:56:24 +01:00
J5CaseyandClaude Opus 5.5 b1a75237d0 Native hook: fishing_medium, a bobber that floats and fishes outside water
A mod answers `native_fishing_medium(dimension, hook, position, owner, medium)`
for a bobber that is flying or floating, not in water and not hooked to an
entity. `Some(NativeFishingMedium)` makes the spot a medium: in the block of
its `surface` or below, the bobber floats, waits, bites and is reeled in as
in still water; the catch rolls its `loot_table` (vanilla's
`minecraft:gameplay/fishing` when None) and `lure_ticks` replaces vanilla's
100 to 600 tick wait when set. It is asked on the server and on the clients
that draw the bobber, so both move it alike. No answer keeps vanilla, and
water is never asked about, so vanilla fishing is unchanged.

`horizon-ctl status` shows the hook's loot table. Fixes #12300.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01KtNXhvTSpY15Rjzwuqh8Nf
2026-11-07 23:53:12 -06:00
Horizon-agent 414fcee500 Merge commit '2d29c7f8e954fad2e3eafa4c18c35b4a1147c807' into HEAD 2026-11-08 06:53:10 +01:00
Horizon agent f62c4b546e Merge commit 'ff0ca9ba176d9a12a9d2d0fbf540446942315988' into HEAD 2026-11-08 06:41:09 +01:00
Horizon agent ff0ca9ba17 Merge commit 'c60ed93aff' into HEAD
# Conflicts:
#	crates/horizon-client/src/game.rs
2026-11-08 06:40:57 +01:00
Horizon agentandClaude Opus 5.5 c60ed93aff Drop the duplicated orb_data_tests module line from the stack merge
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 06:37:45 +01:00
LokiandAntigravity 0538f95dd8 lan: clamp mob health and refresh max_health on effect changes (fixes #11854)
Co-Authored-By: Antigravity <[email protected]>
2026-11-08 14:32:01 +09:00
Horizon agentandClaude Opus 5.5 8f6ef43b0d Sort the test modules (rustfmt)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 06:32:00 +01:00
Horizon agent 8ee2a1ab6a Merge commit '13217d3b4c0bbaf153871a85477b4010e83ad1f5' into HEAD
# Conflicts:
#	crates/horizon-client/src/game.rs
2026-11-08 06:30:06 +01:00
Horizon agentandClaude Opus 5.5 13217d3b4c Sort the test modules after the main merge (rustfmt)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 06:29:53 +01:00
Horizon agent 82ed0f3ac8 Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	tools/command_exec_corpus.py
#	tools/test_command_exec_parity.py
2026-11-08 06:29:14 +01:00
masa-masa-0314andZCode 96f8110001 Worlds: the single-player owner is the account's UUID, old worlds migrate
`IntegratedServer`'s constructor sets `setSingleplayerProfile(minecraft.getGameProfile())`,
and `ServerLoginPacketListenerImpl.handleHello` gives a login whose name matches that
profile the profile itself; the local memory hello names the account's profile id
(`Minecraft.handleClientLogin`'s `ServerboundHelloPacket(getUser().getName(),
getUser().getProfileId())`). The integrated server's owner, its gate/op entry and its
login are now the account's UUID; an offline start keeps the offline profile.

`MinecraftServer.saveAllChunks` saves with `saveDataTag(worldData,
getSingleplayerProfile().id())`: level.dat's `singleplayer_uuid` follows the owner on
every save. A world without one (a Realms download; `FileDownload.untarGzipArchive`
drops it) then loads the owner's `players/data/<account uuid>.dat` instead of starting
a new player.

Worlds older Horizon builds made name their player by the offline UUID everywhere:
level.dat's `singleplayer_uuid`, the player file and its `UUID`, and entity references
(`TamableAnimal`/`AbstractHorse`/`Projectile`/`AreaEffectCloud`/`EvokerFangs` `Owner`,
`ItemEntity`'s `Owner`/`Thrower`, `Fox`'s `Trusted`, `NeutralMob`'s `angry_at`,
`Leashable`'s `leash.UUID`). The first open under the account moves the player file to
the account's UUID (the newer of the two when both exist), rewrites level.dat before the
first session save, and rewrites each entity as its chunk loads, so the next save keeps
the account's UUID. A world renamed since it was made stays as it is: the old name its
offline UUID came from is not stored, so it cannot be identified.

Co-Authored-By: ZCode <[email protected]>
2026-11-08 13:06:42 +08:00
Nikita Maximilian Grimm 89d7b8e65b Merge remote-tracking branch 'origin/main' into portal-ticket-persistence 2026-11-08 06:03:18 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 0f69036171 Stream restored portal tickets' chunks during the startup wait
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 05:56:00 +01:00
voidsip 2ed136b137 Keep LAN default-spawn packets tied to the world spawn 2026-11-07 22:55:02 -06:00
Horizon agent e299e931f5 Merge commit '4cbca5702609168ee27fff1645b29b5fa196a974' into HEAD
# Conflicts:
#	tools/test_command_exec_parity.py
2026-11-08 05:54:38 +01:00
Horizon agent 4cbca57026 Merge commit '183513311337bd8c4d95f9f915f03084b58534c5' into HEAD
# Conflicts:
#	crates/horizon-client/src/game.rs
2026-11-08 05:54:33 +01:00
Horizon agent 1835133113 Merge commit '1d533463ca8f620c87474a32d1f817e4c4f5489c' into HEAD
# Conflicts:
#	crates/horizon-client/src/game.rs
2026-11-08 05:54:25 +01:00
Horizon agent 1d533463ca Merge commit '87dfb297ef73a16d1967e981f4732fda1c5cc0e2' into HEAD
# Conflicts:
#	crates/horizon-client/src/game.rs
2026-11-08 05:54:15 +01:00
Horizon agentandClaude Opus 5.5 87dfb297ef Sort the test modules (rustfmt)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 05:54:04 +01:00
voidsip 45d46d46f4 Keep the panorama behind server configuration dialogs 2026-11-07 22:37:41 -06:00
Horizon agent 4635eed508 Merge commit '01c1e661687793318d5487918b18ae4dfc5b05c7' into HEAD
# Conflicts:
#	crates/horizon-client/src/game.rs
2026-11-08 05:29:11 +01:00
Horizon agent 01c1e66168 Merge commit 'c7fc1c994f9569dc99674cdc40522f2b25826697' into HEAD
# Conflicts:
#	crates/horizon-client/src/game/persist.rs
#	tools/test_command_exec_parity.py
2026-11-08 05:29:07 +01:00
Horizon agent c7fc1c994f Merge commit '4eba8a6991243314c98098e99f32ea0a929e387c' into HEAD
# Conflicts:
#	crates/horizon-client/src/game/persist.rs
#	tools/test_command_exec_parity.py
2026-11-08 05:29:04 +01:00
Horizon agent 4eba8a6991 Merge commit '01fcfc484950ac5a1417be5b5f82488184a7785e' into HEAD
# Conflicts:
#	crates/horizon-client/src/game/persist.rs
#	tools/test_command_exec_parity.py
2026-11-08 05:29:01 +01:00
Horizon agent 01fcfc4849 Merge commit '13dc865c9a591265d0a2d12bbd2845f813a6642a' into HEAD
# Conflicts:
#	crates/horizon-client/src/game/persist.rs
#	tools/test_command_exec_parity.py
2026-11-08 05:28:51 +01:00
AkirabaneandClaude Opus 5.5 391da75bf3 Riders turn like vanilla's: vehicle body only where vanilla copies it
position_riders gave every mob rider of a mob its vehicle's body yaw each
tick. 26.3 does so only in AbstractHorse.positionRider and
Chicken.positionRider, and in AbstractSkeleton.rideTick and Drowned.rideTick
for a rider steering a PathfinderMob; any other rider's body turns on its
own. A rider of a living vehicle is drawn by LivingEntityRenderer.solveBodyRot
from the vehicle's body, which only the window's player model did: mobs now
go through the same function.

Fixes #9335.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 05:28:25 +01:00
Horizon agent 13dc865c9a Merge commit '80d929f7d64a7fd92ede64042392f676df4387b7' into HEAD
# Conflicts:
#	crates/horizon-client/src/game/persist.rs
2026-11-08 05:28:07 +01:00
Horizon agent 80d929f7d6 Merge commit '75cd96be536365ec99573e48e08411832ab01701' into HEAD
# Conflicts:
#	tools/command_exec_corpus.py
#	tools/test_command_exec_parity.py
2026-11-08 05:27:48 +01:00
Horizon agent 75cd96be53 Merge commit '5bf7ce508d86204c6fb42a528a2ee05ce85da43a' into HEAD
# Conflicts:
#	crates/horizon-client/src/game/commands.rs
#	tools/command_exec_corpus.py
#	tools/test_command_exec_parity.py
2026-11-08 05:27:34 +01:00
Horizon agent 5bf7ce508d Merge commit '4dbb00502ff162b4ac3472a3e7c85c5d8d88883e' into HEAD 2026-11-08 05:27:16 +01:00
Horizon agent a869493ff4 Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	tools/command_exec_corpus.py
2026-11-08 05:21:14 +01:00
voidsip ed656ba6cf Run block events on ticketed chunks before they start ticking 2026-11-07 22:21:02 -06:00
Horizon agent 1e743c812e Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	crates/horizon-client/src/game/commands.rs
#	tools/command_exec_corpus.py
#	tools/test_command_exec_parity.py
2026-11-08 04:59:32 +01:00
Horizon agent 7cec42ea0b Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	tools/command_exec_corpus.py
#	tools/test_command_exec_parity.py
2026-11-08 04:58:44 +01:00
Horizon agent 75a2643501 Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	crates/horizon-client/src/game/persist.rs
#	tools/test_command_exec_parity.py
2026-11-08 04:58:37 +01:00
Horizon agent 6e4c79f33b Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	crates/horizon-client/src/game/persist.rs
#	tools/test_command_exec_parity.py
2026-11-08 04:57:46 +01:00
Horizon agent 65b2833bb9 Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	crates/horizon-client/src/game.rs
2026-11-08 04:57:14 +01:00
Nikita Maximilian Grimm 0972bbea7b Merge remote-tracking branch 'origin/main' into portal-ticket-persistence
# Conflicts:
#	crates/horizon-client/src/game/levels.rs
2026-11-08 04:57:09 +01:00
Horizon agent 5452da616f Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	crates/horizon-client/src/game.rs
2026-11-08 04:56:54 +01:00
Horizon agent c5b9f07b5d Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	crates/horizon-client/src/game.rs
#	crates/horizon-client/src/game/commands.rs
#	crates/horizon-client/src/game/commands/data.rs
#	crates/horizon-client/src/game/persist.rs
2026-11-08 04:56:48 +01:00
Horizon agent 64821ce0bb Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	crates/horizon-client/src/mobs.rs
2026-11-08 04:56:42 +01:00
MSedgeMC eaceaeda73 A cat hit by a player panics, and a wolf freezes into one
`TamableAnimal.TamableAnimalPanicGoal` passes a damage tag to `PanicGoal`:
`Wolf` gives it `#panic_environmental_causes`, while `Cat` and `Parrot`
keep the default `#panic_causes`. Horizon's `TamePanic` gated all three on
`fire_ticks > 0`, so only a burning pet panicked: a cat hit by a player or
a wolf freezing stood still where vanilla flees.

`TamePanic` now delegates to the `Panic` it wraps. The wolf's panic is
marked environmental (fire, freezing, cactus, lightning, lava); the cat's
and parrot's take any panic-causing damage.
2026-11-08 03:56:11 +00:00
Horizon agent e67b56eec6 Merge remote-tracking branch 'origin/main' into HEAD 2026-11-08 04:25:27 +01:00
Horizon agent f9b2840d07 Merge remote-tracking branch 'origin/main' into HEAD 2026-11-08 04:24:42 +01:00
Nikita Maximilian Grimm 0b30eba4e0 Merge remote-tracking branch 'origin/main' into tnt-size-eye 2026-11-08 04:14:43 +01:00
Horizon agent 0270b0133d Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	crates/horizon-client/src/game.rs
2026-11-08 04:03:56 +01:00
Horizon agent 1b7cec46d9 Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	crates/horizon-client/src/game/persist.rs
#	tools/test_command_exec_parity.py
2026-11-08 04:03:52 +01:00
Horizon agent 6922878d94 Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	crates/horizon-client/src/game/persist.rs
#	tools/test_command_exec_parity.py
2026-11-08 04:03:51 +01:00
Horizon agent 9a442e5382 Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	crates/horizon-client/src/game/persist.rs
#	tools/test_command_exec_parity.py
2026-11-08 04:03:42 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 2294609404 TNT's rider seat test expects the float height
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 03:59:15 +01:00
Horizon agentandClaude Opus 5.5 092ae508ba rustfmt game.rs (verify failed on it)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 03:45:11 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 44caa43238 Primed TNT's box and eye height are vanilla's floats
EntityTypes.TNT is sized 0.98F with an explicit eyeHeight(0.15F). The box
now widens the float size, so TNT stopped by a wall or ceiling ends where
Java's does, and the fluid eye height is the type's 0.15F instead of the
default 0.85 of the height.

Fixes #12020

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 03:30:52 +01:00
Horizon agent 325c627cdb Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	crates/horizon-client/src/game/persist.rs
2026-11-08 03:27:59 +01:00
Nikita Maximilian Grimm 5bea37b4be Merge remote-tracking branch 'origin/main' into portal-ticket-persistence
# Conflicts:
#	crates/horizon-client/src/game/levels.rs
2026-11-08 02:10:48 +01:00
Horizon agentandClaude Opus 5.5 4f56dfc42b Merge #11813 into #11831 (both change take_mob_passengers)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 01:52:32 +01:00
Horizon agentandClaude Opus 5.5 f1af021d5c Merge main into #11732
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 01:49:11 +01:00
Horizon agentandClaude Opus 5.5 fcd33abc9e Merge main into #12133
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 01:48:46 +01:00
Horizon agentandClaude Opus 5.5 8a218ed405 Merge main into #12127
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 01:47:55 +01:00
Horizon agentandClaude Opus 5.5 9b6074a363 Merge main into #12094
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 01:46:44 +01:00
Horizon agentandClaude Opus 5.5 8fd45d810e Merge main into #12041
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 01:46:31 +01:00
Horizon agentandClaude Opus 5.5 2183972c00 Merge main into #12031
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 01:45:32 +01:00
Horizon agentandClaude Opus 5.5 03df68c41d Merge main into #11991
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 01:44:45 +01:00
Horizon agentandClaude Opus 5.5 a77d401f53 Merge main into #12010
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 01:44:45 +01:00
Horizon agentandClaude Opus 5.5 1769fff834 Merge main into #11964
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 01:44:42 +01:00
Horizon agentandClaude Opus 5.5 e5d8856821 Merge main into #11831
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 01:43:42 +01:00
Horizon agentandClaude Opus 5.5 93bb7f3230 Merge main into #11813
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 01:43:24 +01:00
Horizon agentandClaude Opus 5.5 816d917134 Merge main into #11797
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 01:43:08 +01:00
Florian ErnstandClaude Opus 5.5 aee1b8b81c Adopt /data edits on a live interaction entity
EntityDataAccessor.setData loads the edited compound into the same entity,
and Interaction.readAdditionalSaveData reads its width, height, response
and the attack and interaction records. Horizon kept an interaction only
as a generic entity with its loaded compound, and its live data write
took markers alone, so every /data edit answered "Nothing changed".

A server interaction now holds its settings (InteractionState: width,
height, response and the two PlayerAction records) next to its position.
It reads them when it loads or is summoned, saves from them
(addAdditionalSaveData, a record only when set), and a /data edit decodes
the edited compound into the same entity, which keeps its id and UUID.
Every other kind is unchanged.

Fixes #12131.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 00:29:55 +01:00
Oloion 2a33c013d7 perf: branchless frustum culling, zero-copy Arc meshing regions and 3D upload priority 2026-11-08 02:24:31 +03:00
Nikita Maximilian GrimmandClaude Opus 5.5 b58576b523 Reload saved portal tickets and pause their expiry while their chunk loads
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 00:24:26 +01:00
Florian ErnstandClaude Opus 5.5 31d39aec80 Count /loot kill under execute as a connected player as that player's kill
LootCommand.dropKillLoot takes the command source's entity as the killer:
a player is the last damage player, so killed_by_player holds, and its
own weapon counts for looting. Under `execute as <connected player>` from
another player, Horizon counted no player kill and used the issuer's
held stack.

For a connected player as the source, /loot kill now reads that player's
selected stack with it in place (for_player); an empty hand stays empty.
The loot roll, the output, the feedback and the result run as before,
after the issuer is back. Other sources keep their current rule.

Fixes #12122.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 00:11:21 +01:00
Florian ErnstandClaude Opus 5.5 8f9e984d63 Compare a mob's hurt cooldown on the incoming damage
LivingEntity.hurtServer compares a hit inside the hurt cooldown
(invulnerableTime > 10) with lastHurt by its incoming amount, before
armor and Resistance, and keeps that amount as lastHurt. actuallyHurt then
reduces only the difference. Horizon reduced the hit first, then compared
and stored the reduced amount. Under full Resistance every hit reduced to 0,
so a stronger hit inside the cooldown was refused. With armor, a second
hit dealt reduced(8) - reduced(4), not reduced(8 - 4).

The comparison now uses the amount after the helmet, freeze and infinity
changes. The existing armor, Resistance, protection, witch and wolf armor
steps get the accepted difference, and last_hurt keeps the incoming amount.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 00:07:47 +01:00
Florian ErnstandClaude Opus 5.5 39701a9334 Adopt /data edits on a live experience orb
EntityDataAccessor.setData keeps the entity's UUID around Entity.load of
the edited compound, and ExperienceOrb.readAdditionalSaveData reads its
Age, Health, Value and Count into the same orb. Horizon had no experience
orb case, so `data merge entity <orb> {Age:42s}` answered "Nothing
changed" and the orb kept its age.

The orb case decodes the edited compound with the existing orbs_from_nbt
and copies its saved fields into the resident orb, which keeps its
runtime id, join order, tick count and UUID. As Entity.load does, the
saved PortalCooldown is read while a live portal process stays. The
edited compound is kept like the other cases', so Tags follow it.

A falling block's Time is handled separately (#11424).

Fixes #12090.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 23:04:52 +01:00
Florian ErnstandClaude Opus 5.5 7339c9057a Run a /kill-ed mob's death during the command
LivingEntity.kill is a generic_kill hit that calls LivingEntity.die before
the command returns. Horizon set the mob's health to 0 and left the death
to the next tick's creature phase, so its entity_die vibration reached a
sculk sensor one tick late.

The /kill mob arm now runs the existing death routine (mob_died) at once
for a mob that this kill takes from alive to dead, behind the existing
`died` guard, as a frog's bite already does. The native living_death veto,
the vibration, the catalyst, loot, experience and kill credit now happen
during /kill instead of in the next creature phase. A second kill and the
next tick do not run it again. The body's removal timer is unchanged.

Fixes #12037.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 21:04:50 +01:00
Florian ErnstandClaude Opus 5.5 2979cf024d Resolve /clone's source and target dimensions
/clone from <sourceDimension> ... to <targetDimension> ignored both
arguments: an unknown name cloned in the current level, and a valid
other dimension wrote into the current level instead of its own world.

As CloneCommands does, sourceDimension is resolved as begin is read and
targetDimension as destination is read, each position checked loaded in
its own level; an unknown name fails with argument.dimension.invalid
(the built-in dimensions, as execute in checks them), the source first.
The overlap check applies within one level only. The blocks are read in
the source level and placed in the target level through the existing
level swap (enter_command_level), each entry checked; the coordinates
are resolved in the command's own context first. The command's own
level is put back on every path, before the command reports.

Fixes #11997

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 20:34:36 +01:00
Florian ErnstandClaude Opus 5.5 46c6bdc8ac Let /attribute add and remove see a baby zombie's minecraft:baby modifier
Zombie and Piglin.setBaby put a transient minecraft:baby speed modifier
on a baby. AttributeInstance refuses to add an id it already holds and
removes a modifier whatever its kind; the next setBaby removes
minecraft:baby again and, for a baby, adds the transient one back. The
transient modifier was computed from the baby state, so /attribute
modifier add stacked a permanent one on it and modifier remove found
nothing to remove.

The add now counts the transient modifier, and the remove takes it away
until the next setBaby (a flag on the mob's attributes, not saved: a
load runs setBaby again). setBaby clears that flag and removes a
permanent minecraft:baby, as vanilla's does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 20:15:12 +01:00
Florian ErnstandClaude Opus 5.5 9f0b1d99c1 Save a mob's movement_speed instance only once it has ticked
AttributeMap.pack saves the attribute instances that exist. A fresh
living entity has none (attributes:[]); its first tick creates
movement_speed (LivingEntity.aiStep -> removeFrost -> getAttribute).
The mob writer added movement_speed to every save, so a mob that had
not ticked yet already saved it.

The writer's invented movement_speed entry now waits for the mob's first
tick (its tick count). The instances the load itself makes stay:
Zombie and Piglin setBaby the speed, Rabbit setVariant the attack damage.
Instances the mob was loaded with are kept as before, and the list is
written even when empty.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 19:34:14 +01:00
Florian ErnstandClaude Opus 5.5 08ce93b6f1 Keep the shared inventory helpers crate-private and test the delegation
add_to_container and load_container are only used inside the crate. Two
unit tests pin VillagerState::add_item as add_to_container with eight
slots, and a later same-kind stack merging while eight kinds fill the
slots.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 19:06:20 +01:00
Florian ErnstandClaude Opus 5.5 181ca3d833 Merge origin/main into fix/h052-vehicle-container-save
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 18:35:40 +01:00
Florian ErnstandClaude Opus 5.5 b2d87410fc Merge a Piglin's and a Villager's saved inventory stacks on load
InventoryCarrier.readInventoryFromTag loads the saved stacks with
SimpleContainer.fromItemList: clear, then addItem each one, which fills
earlier stacks of the same item and components up to the limit before it
takes an empty slot, and drops what does not fit in the eight. Piglins
and villagers took the first eight saved stacks as they were, so 2 + 3
stone stayed two stacks and 40 + 40 did not become 64 + 16.

The villager's existing SimpleContainer.addItem port becomes a free
add_to_container, unchanged; both loads add each decoded stack through it
at the type's stack limit.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 18:33:30 +01:00
Florian ErnstandClaude Opus 5.5 a191dcd2b2 Merge origin/main into fix/h052-vehicle-container-save
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 17:14:59 +01:00
Florian ErnstandClaude Opus 5.5 1bf526ca7f Merge origin/main into fix/h193-effective-component-presence
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 17:14:48 +01:00
Florian ErnstandClaude Opus 5.5 635f14ea0e Merge origin/main into fix/h151-boat-passenger-command
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 17:14:47 +01:00
Florian ErnstandClaude Opus 5.5 4adf2afffb Drop passenger entries that load nothing, keep riders through /data merge
EntityType.loadPassengersRecursive mounts only the Passengers entries that
load, and Entity.saveWithoutId saves the riders the entity has.
take_mob_passengers kept every non-mob entry raw, so an unknown or missing
id, or a non-compound value, stayed in the saved Passengers. It now drops
an entry that is not a compound or has no registered type (entity-types,
EntityType.by), and keeps valid non-mob entries raw as before.

Entity.load does not read Passengers either. apply_entity_nbt stored the
whole merged compound as raw, so a mob, boat, minecart or TNT saved copies
of its live riders and any merged entries after /data merge. It now keeps
the Passengers the load kept.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 16:09:01 +01:00
Florian ErnstandClaude Opus 5.5 5440872e93 Refuse /summon trees that hold a live entity's UUID
ServerLevel.tryAddFreshEntityWithPassengers checks every UUID of the
summoned tree against the loaded entities before anything joins, and
fails with commands.summon.failed.uuid. summon_with_nbt now walks the
same tree the loaders admit (the root, then the mob riders that
take_mob_passengers takes under mob, boat, minecart and TNT roots) and
refuses it when an entity in the level holds one of its UUIDs.

Duplicate UUIDs inside the new tree are still admitted (vanilla skips
the later one but keeps it under its vehicle's Passengers); the
command harness keeps them as --summon-uuid-internal-diagnostic and
the Game test as a known-unfixed ignored test.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 15:39:15 +01:00
TheMrZZandCodex gpt-6.1-sol ce3a7a7587 Project live boat passengers in command NBT
Reuse the existing runtime tree projection before the live leash writer. Preserve the original full-body diagnostic and add an independently admitted typed-tree command gate.

Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-07 15:07:30 +01:00
Horizon agent 9f6aa5f2f6 Merge origin/main into PR #11682
Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	crates/horizon-client/src/game/commands/terrain_cmds.rs
#	crates/horizon-items/src/items.rs
2026-11-07 14:19:54 +01:00
Horizon agentandClaude Opus 5.5 c3f5770bb9 Merge origin/main into #11301 (main builds again after #11767)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 14:10:28 +01:00
Horizon agentandClaude Opus 5.5 5a07a7edfe Merge origin/main into #11301 (keep both data.rs test modules and command_exec groups)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 13:59:39 +01:00
Horizon agentandClaude Opus 5.5 3cbe777d44 Merge origin/main into fix/set-custom-model-data
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 13:08:36 +01:00
Horizon agentandClaude Opus 5.5 28299018c7 Merge origin/main into fix/vindicator-johnny
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 13:02:07 +01:00
TheMrZZandCodex gpt-6.1-sol 7e8c7a27c1 Use effective stack prototypes for component presence
Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-07 12:38:41 +01:00
Horizon agentandClaude Opus 5.5 08f2880b2a Merge main (clippy as_chunks lint fix)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 11:52:14 +01:00
Horizon agentandClaude Opus 5.5 03d1f86f4a Merge main (clippy as_chunks lint fix)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 11:50:56 +01:00
Horizon agentandClaude Opus 5.5 65d3e70f5d Merge main (clippy as_chunks lint fix)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 11:47:54 +01:00
Horizon agentandClaude Opus 5.5 14428f3233 Merge main (clippy as_chunks lint fix)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 11:46:07 +01:00
Horizon agentandClaude Opus 5.5 4a697d3689 Merge main (clippy as_chunks lint fix)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 11:44:19 +01:00
Horizon agentandClaude Opus 5.5 baab15d42d Fail location_check on unloaded counterparts
Vanilla's LocationPredicate checks Level.isLoaded before the block predicate, so an offset into an unloaded chunk fails instead of holding.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 11:35:59 +01:00
Horizon agentandClaude Opus 5.5 71c7dfaf97 Merge main; fail location_check on unloaded counterparts
Vanilla's LocationPredicate checks Level.isLoaded before the block predicate, so an offset into an unloaded chunk fails instead of holding. Drops the docs/parity run logs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 11:35:26 +01:00
Horizon agentandClaude Opus 5.5 d12d6262bb Drop the docs/parity run logs and their one-off capture script
Run logs are not docs; the PR body keeps the numbers. tools/inline_trim_packet_capture.py only produced the dropped packet log and nothing calls it; the protocol test keeps the captured payload.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 11:25:02 +01:00
Horizon agentandClaude Opus 5.5 c36df3986f Merge origin/main into fix/inline-armor-trim-holders
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 11:25:01 +01:00
Horizon agentandClaude Opus 5.5 38f887eab2 Merge #11655 (with #11643) into #11654
The three loot functions add arms and tests at the same spots in loot.rs; all kept. Land #11643, then #11655, then this.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 11:15:02 +01:00
Horizon agentandClaude Opus 5.5 71c1240617 Drop the docs/parity run logs
Run logs are not docs; the PR body keeps the numbers.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 11:14:40 +01:00
Horizon agentandClaude Opus 5.5 14100456d1 Merge origin/main into fix/named-predicate-context
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 11:14:40 +01:00
Horizon agentandClaude Opus 5.5 32109b87b6 Merge #11643 (set_book_cover) into #11655
Both add a set_loot_component arm, a loot.rs function arm and a loot test at the same spots; both kept. Land #11643 first.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 11:14:10 +01:00
Horizon agentandClaude Opus 5.5 66ecf65d4e Merge origin/main into fix/h157-player-save-projection
Keep main's vibration_tests module beside the new test module; the test's Joined literal gains main's new configured field.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 11:08:46 +01:00
Horizon agentandClaude Opus 5.5 1315c0e4db Vindicator: write Johnny only while the flag is set
Vindicator.addAdditionalSaveData writes Johnny: 1b only when isJohnny is true; a vindicator that is not Johnny saves no key (and drops a stale Johnny: 0b from its raw compound).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 11:07:23 +01:00
Horizon agentandClaude Opus 5.5 4169af3fa6 Merge origin/main into fix/h181-noai-trader-llama-delay
Keep main's craft_map_take import beside the trader llama corpus import.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 11:01:55 +01:00
Horizon agentandClaude Opus 5.5 306f9425e8 Merge origin/main into xp-orbs-riding-merge-water
ordered_tick: main's obstacle grid for the orb move kept after the
merge scan and the followed-player pick. A kept followingPlayer now
stays at exactly 8 blocks: ExperienceOrb.followNearbyPlayer re-picks
only past distanceToSqr > 64. Orb merge fixture keeps merge_new_orb.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 11:01:54 +01:00
Horizon agentandClaude Opus 5.5 b0047b5518 Merge origin/main into #11301
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 09:51:22 +01:00
Horizon agentandClaude Opus 5.5 1990e4aecc Merge origin/main into #11301
Keep main's non_mob_tag base with the PR's class-owned container fields,
and both new test modules and corpus groups.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 09:35:57 +01:00
Florian ERNSTandCodex 16d77f41d4 Bind loaded block locations in loot-mine conditions
Co-Authored-By: Codex <[email protected]>
2026-11-07 09:17:11 +01:00
Florian ERNSTandCodex 7f21b95989 Adapt new equipment wear test to named trim holders
Co-Authored-By: Codex <[email protected]>
2026-11-07 08:15:48 +01:00
Florian ERNSTandCodex 76123f5638 Preserve inline armor-trim holders through item state and sync
Co-Authored-By: Codex <[email protected]>
2026-11-07 08:13:34 +01:00
MSedgeMC 3b8ac89d2c port_map: SetBannerPatternFunction.run is ported
The row said layers from loot are not set and no vanilla table uses the
function; a datapack table does, and the row now names the arm and the item-side
parse that puts the layers on the banner.
2026-11-07 06:49:34 +00:00
MSedgeMC f8aa8e0e0d test: the loot arm appends or replaces, and the item wears the layers
The loot table test fills a chest with a white banner through set_components
(a red stripe at the bottom) and then set_banner_pattern with append: the
component carries both layers, in that order; with append false it carries only
the function's blue border. Without the arm the append case keeps the single
red layer.

The item test hands set_loot_component a banner_patterns list and reads
`banner_patterns` back as two BannerLayers (pattern and dye), and a null
component empties them again. Without the arm the list is empty - the banner
wears nothing.
2026-11-07 06:49:34 +00:00
MSedgeMC 6ec1865ac1 fix: a looted banner_patterns component dresses the banner
set_loot_component only knew the trim, so a rolled banner_patterns list landed
in `other` as a raw tag: the renderer and the tooltip read
`StackData::banner_patterns`, which stayed empty, and the banner from a
datapack table (or a set_components function) came out undyed. The arm parses
the component with the same `layers_from_nbt` the item's own component uses, so
the layers - pattern and dye - reach the fields the game reads.
2026-11-07 06:49:12 +00:00
MSedgeMC 18cb5eb6d0 fix: set_banner_pattern writes a banner's layers
SetBannerPatternFunction sets the stack's banner_patterns to the function's
layers, or with append adds them to the end of the component's own list
(BannerPatternLayers.Builder.addAll twice). A layer is a pattern id and a dye
name, so the list the table wrote is the component's list as it stands. The arm
was missing, so a datapack's banner pattern changed nothing: the banner came out
bare, or with only the layers an earlier function wrote.
2026-11-07 06:49:12 +00:00
MSedgeMC 66931c6958 port_map: the two set_custom_model_data rows are ported
apply names the ListOperation helper its four lists go through, and run names
the arm that draws the floats and colours and keeps the component the stack
already had. The rows said "no vanilla loot table uses it", which was true but
not a reason for a table that does to change nothing.
2026-11-07 06:46:00 +00:00
MSedgeMC 0ba45ef93e test: the four model-data lists each follow their own operation
A table writes custom_model_data through set_components (floats [1.0], strings
["old"]) and then set_custom_model_data: floats appended from a constant
provider, flags and strings replaced, a colour inserted first. The rolled stack
carries 1.0 and 2.0, [true], ["new"] and [16711680] - without the arm the
component keeps the first function's lists and has no colours at all.

The helper's own test covers what a table rarely reaches: a replace_section of
one element, one with no size (the new values' length), an insert past the end
that changes nothing, and a mode the codec would refuse reading as a
replacement.
2026-11-07 06:46:00 +00:00
MSedgeMC 68e1549ebd fix: set_custom_model_data writes the item's four model-data lists
SetCustomModelDataFunction reads the stack's custom_model_data (EMPTY when it
has none) and replaces each of its four lists the function names - floats and
colors from number providers drawn with the loot random, flags and strings as
written - by that list's ListOperation over what was there: replace_all,
replace_section (offset and an optional size), insert at an offset, or append.
The arm was missing, so a datapack's model data changed nothing at all.

ListOperation itself is a small helper beside the number readers; an offset
past the end leaves the list as it was, as vanilla logs and ignores it there.
The component then reaches the item through set_loot_component's generic arm,
which keeps it in `other` where the model selection, the CIT rules and the LAN
sync already read it - that half needed nothing new.
2026-11-07 06:46:00 +00:00
MSedgeMC b2c9470eb7 port_map: SetBookCoverFunction.apply is ported
The row said no vanilla loot table uses set_book_cover, so nothing was missing;
a datapack's table does reach it, and it now names the arm that applies the
cover and the item-side parse that reads the component back.
2026-11-07 06:22:12 +00:00
MSedgeMC dc8f47b2e8 test: a loot table's cover titles the book, and the item reads it
The loot table test fills a chest with a written book through two modifiers: one
set_components with a title, author and page, then set_book_cover with another
title, an author and generation 2. The cover replaces the title, author and
generation, and the page the other function wrote is still the book's. Without
the arm the component keeps the first title.

The item test hands set_loot_component a written_book_content as the loot engine
builds it and reads the book back: title, author, generation and both pages, and
a null component leaves none. Without the arm the book has no content at all.
2026-11-07 06:22:12 +00:00
MSedgeMC 02dc69348d fix: a looted written_book_content reaches the book's fields
A component a loot function writes goes through StackData::set_loot_component
when the rolled stack becomes an item, and only the trim had an arm: a written
book's content landed in `other` as a raw tag nobody reads, so set_book_cover
(or set_components) titled a book whose tooltip and reading screen still showed
nothing. The content is now parsed the way the item's own component is - the
parse moves out of from_nbt into written_book_from_tag, shared by both - so the
title, author, generation and pages come out of a rolled book.
2026-11-07 06:22:11 +00:00
MSedgeMC 65e6b5ce95 fix: set_book_cover writes a written book's title, author and generation
SetBookCoverFunction updates the stack's written_book_content, over the
component it already carries: the title as a Filterable (the raw text, and the
filtered text when a server filtered it), the author, and the generation, while
the pages and the resolved flag stay as the earlier functions left them. A loot
table's written book got none of that: the modifier ran and changed nothing, so
a datapack book came out with no cover at all.
2026-11-07 06:22:11 +00:00
Florian ERNSTandCodex 3db0da6dd2 fix: resolve named predicates through the Game context
Bind loaded predicate references without changing source identity or origin. Defensively reject invalid reference graphs before boolean evaluation while keeping registry admission separate.

Co-Authored-By: Codex <[email protected]>
2026-11-07 04:24:58 +01:00
TheMrZZandCodex gpt-6.1-sol bca4a22e08 fix: project live player motion fields into saved data
Carry the active Player grace time and fall-particle flag through the existing
save context, restore owner snapshots after guest calls, and write the official
empty player Brain schema. Keep tracker projection outside this change.

Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-07 02:51:56 +01:00
TheMrZZandCodex gpt-6.1-sol 8bb5a572ec Advance eligible TraderLlama despawn delay with NoAI
Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-07 00:42:42 +01:00
baytyoneandZCode 91b3a7410e Experience orbs: pickup over the vehicle, a kept following player, merges before the move, eye-level water
Vanilla's Player.aiStep widens the pickup area to the union with the vehicle's box while riding; ExperienceOrb.tick keeps its followingPlayer until they leave 8 blocks, runs scanForMerges in its own turn before the move, and floats by the eye's fluid rather than the feet's block. Fixes #11389.

Co-Authored-By: ZCode (GLM) <[email protected]>
2026-11-07 04:12:29 +05:00
MSedgeMC b918309242 port_map: a vindicator's Johnny flag is latched, saved and read
`tools/port_map/game2-004.tsv` recorded
`Vindicator.addAdditionalSaveData` as `missing` and `Vindicator.setCustomName`'s
gap as "the Johnny goal reads the current custom name each tick; vanilla latches
the flag once a name is Johnny". Both rows now name what the port does, with a
row for `readAdditionalSaveData` beside them.
2026-11-06 23:04:15 +00:00
MSedgeMC 68f5909ef6 test: a vindicator's Johnny latch through a rename and a save
`vindicator_server_ai_step` is the AI step, and `mob_to_nbt`/`mob_from_nbt` are
the pair `Game::mob_tree_nbt` and `Game::load_mob` go through. A vindicator
named Bob is not Johnny; named Johnny it is; renamed to Bob it stays Johnny.
The save writes `Johnny: 1b`, the reloaded vindicator is still Johnny with the
name Bob, and a compound with no `Johnny` (its `CustomName` reading Johnny)
loads false and saves `Johnny: 0b`.

Without the latch the test stops at the first name: `left: false`, `right:
true`.
2026-11-06 23:04:15 +00:00
MSedgeMC 995a99b984 fix: a vindicator that was named Johnny stays Johnny
`Vindicator.setCustomName` latches a flag the first time the name it is given is
`Johnny`, and nothing ever clears it:

    public void setCustomName(@Nullable Component name) {
       super.setCustomName(name);
       if (!this.isJohnny && name != null && name.getString().equals("Johnny")) {
          this.isJohnny = true;
       }
    }

`VindicatorJohnnyAttackGoal` starts for `isJohnny` (`addAdditionalSaveData`
writes it, `readAdditionalSaveData` reads it back with `getBooleanOr("Johnny",
false)`), so a vindicator renamed from Johnny to Bob keeps attacking every
living entity, and one whose save says `Johnny: 1b` does so after a reload.

Horizon read the live custom name in the goal's `when` each tick and saved no
flag, so a renamed vindicator stopped attacking everything, and a reloaded
vindicator that had been named Johnny but is now Bob never started.

`extra.johnny` is the latch, taken from the live name in
`vindicator_server_ai_step` (the AI step is where a name set since the last step
is seen, and it never clears), read by the goal, written as `Johnny` and read
back from it. A save with no `Johnny` loads false, whatever the name in the
compound, as `getBooleanOr` does.
2026-11-06 23:04:14 +00:00
MSedgeMC 6a5fc04295 test: a re-saved section writes only the states a cell holds
Both cases read the emitted tag, so they fail on the live palette: 342 longs
of `data` where vanilla's `SimpleBitStorage(4, 4096)` is 256, a palette of
`[0, 1, … 16]` where first use is `[1, … 15, 0]`, and `(2, Some(256))` where a
section edited back to one state is `(1, None)`.
2026-11-06 22:49:30 +00:00
MSedgeMC 5dc59396d6 fix: save a chunk section's palette as vanilla packs it
`PalettedContainer.pack` re-encodes the cells into a fresh palette before a
section is written: a state no cell holds any more is dropped, the entries are
in first-use order, and the bit count is `getConfigurationForPaletteSize` of
the states that remain. `PalettedContainer::to_nbt` wrote the live palette
instead, which keeps the slot of every state a block edit replaced (the `free`
list needs it) and packs at that wider count: a section whose 17th state was
edited away saved 342 longs where vanilla writes 256, and a section edited back
to a single state saved a 2-entry palette with a 256-long `data` where vanilla
writes one entry and no `data`.
2026-11-06 22:47:00 +00:00
TheMrZZandCodex gpt-6.1-sol fec7a5f9e6 Respect class-owned container fields in vehicle saves
Keep plain vehicles free of container fields and save pending boat tables
instead of Items. Normalize recognized pending keys while preserving the
existing cart loot restoration path.

Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-06 20:28:32 +01:00
m_uvexandPluto 41d699a458 packaging: include native mod build dependencies in launcher FHS
Adds gcc, glibc.dev, pkg-config, alsa-lib.dev, and udev.dev to the FHS
environment so native mod compilation works on NixOS without external shell.

Co-Authored-By: Pluto <[email protected]>
2026-11-06 22:10:15 +03:00
m_uvexandPluto 160e997e4a packaging: add flake.nix for Horizon launcher and devShell
Provides a Nix flake packaging the Horizon launcher in an FHS environment
along with a development shell containing dependencies for building the project.

Co-Authored-By: Pluto <[email protected]>
2026-11-06 18:21:05 +03:00
435 changed files with 28299 additions and 2420 deletions

No files matched your search

+1 -1
View File
@@ -162,7 +162,7 @@ state stays separate from the library tests. This compiles gameplay twice for ex
| Player actions | game.rs (`fn use_hand`, `fn tick_mining`, `fn break_block`); G: `item_actions.rs` (`fn use_item`), `melee.rs` (`fn attack`), `hand.rs`, `combat.rs`, `consume.rs`, `using_item.rs` (an item in use, tick by tick), `potions.rs`, `shield.rs`, `crossbow.rs`, `trident.rs`, `fishing.rs`; L: `motion.rs` + `motion/` (movement), `collisions.rs`; crate `horizon-items`: `items.rs`, `item_data.rs`, `enchant.rs`, `blocks.rs` |
| Inventory and containers | L: `menu.rs` (`AbstractContainerMenu.clicked`), `container_screen.rs`, `containers.rs`, `creative.rs`, `crafting.rs`, `recipe_book*.rs`, `stations.rs`, `merchant.rs`; game.rs `fn menu_ops`, `fn click_inventory`; G: `stations.rs`, `trading.rs`, `recipe_book.rs`, `machines.rs`, `inventory_input.rs` (keys and clicks over a container), `inventory_slots.rs` (slot layout and drawing) |
| Commands | parse: crate `horizon-command` (`args.rs`, `selector.rs`, `tree_*.rs`, `vanilla.rs`); run: G: `commands.rs` (dispatch) + `commands/*.rs` (`admin`, `execute`, `items`, `players`, `world_cmds`...); L: `scoreboard_cmd.rs`, `data_cmd.rs`; `horizon-client-core`: `nbt_path.rs`, `scoreboard.rs`; G: `functions.rs`, `command_blocks.rs` |
| UI screens | `Screen` enum in game.rs; L: `title.rs` + `title/`, `options_ui.rs`, `multiplayer.rs`, `pack_screen.rs`, `graphics_ui.rs`, `screen_draw.rs`, `ui.rs` (widgets), `hud.rs`, `hud_layout.rs`, `hud_overlays.rs`, `chat.rs`, `tab_list.rs`; G: `startup_menu.rs`, `loading_screen.rs` (the loading screen, the pre-game `App`), `screens.rs` (pause, death), `options.rs`, `lan_screen.rs`, `chat.rs`, `sign_edit.rs`, `books.rs`, `creative_screen.rs` |
| UI screens | `Screen` enum in game.rs; L: `title.rs` + `title/`, `options_ui.rs`, `multiplayer.rs`, `pack_screen.rs`, `graphics_ui.rs`, `screen_draw.rs`, `ui.rs` (widgets), `hud.rs`, `hud_layout.rs`, `hud_overlays.rs`, `hud_kit/` (Horizon overlays' `Room` and `Plate`, [docs/ui-kit.md](docs/ui-kit.md)), `chat.rs`, `tab_list.rs`; G: `startup_menu.rs`, `loading_screen.rs` (the loading screen, the pre-game `App`), `screens.rs` (pause, death), `options.rs`, `lan_screen.rs`, `chat.rs`, `sign_edit.rs`, `books.rs`, `creative_screen.rs` |
| Multiplayer client | L: `net.rs` (network thread); G: `remote.rs` (`fn handle_packet`, `fn tick_remote`), `remote_*.rs` (mob data, objects, projectiles, vehicles, stations) |
| LAN host / server side | L: `lan/` (`conn.rs` handshake + login threads, `hub.rs` + `hub/` play state, `chunks.rs`, `entity.rs`, `tracking.rs`, `guest.rs`, `playerdata.rs`); G: `lan.rs` (`LanHost`), `lan_actions.rs` (`fn as_guest`, `fn tick_guests`), `lan_*.rs` per feature, `lan_entities.rs` (tracked entities) |
| Integrated server | G: `integrated.rs` (the server thread), `levels.rs` (one level per dimension, `fn tick_server`), `server_travel.rs` |
Generated
+2
View File
@@ -1588,6 +1588,7 @@ dependencies = [
"horizon-mod-api-gen",
"horizon-narrator",
"horizon-optifine",
"horizon-polytone",
"horizon-protocol",
"horizon-shader-api",
"horizon-shaders",
@@ -1775,6 +1776,7 @@ dependencies = [
name = "horizon-polytone"
version = "0.1.0"
dependencies = [
"log",
"regex",
"serde_json",
]
+1
View File
@@ -23,6 +23,7 @@ horizon-narrator = { path = "../horizon-narrator" }
# OptiFine-format pack files: random entity textures (hud.rs), connected textures
# (connected.rs) and custom item texture rules (cit.rs).
horizon-optifine = { path = "../horizon-optifine" }
horizon-polytone = { path = "../horizon-polytone" }
horizon-protocol = { path = "../horizon-protocol" }
horizon-shader-api = { path = "../horizon-shader-api" }
horizon-shaders = { path = "../horizon-shaders" }
+6 -1
View File
@@ -191,13 +191,18 @@ pub fn discovery_url() -> String {
.unwrap_or_else(|| DEFAULT_DISCOVERY.to_owned())
}
fn agent() -> ureq::Agent {
pub(crate) fn agent() -> ureq::Agent {
ureq::AgentBuilder::new()
.timeout(Duration::from_secs(15))
.user_agent(&format!("Horizon/{}", horizon_core::release::VERSION))
.build()
}
/// Whether `HORIZON_API_DISCOVERY` names another services environment.
pub fn discovery_overridden() -> bool {
discovery_url() != DEFAULT_DISCOVERY
}
/// The discovery document, fetched once per game (a failure is retried next time).
pub(crate) fn discovery() -> Result<Arc<serde_json::Value>, JoinError> {
static CACHE: Mutex<Option<Arc<serde_json::Value>>> = Mutex::new(None);
+36 -2
View File
@@ -17,11 +17,15 @@ pub struct BanDetails {
pub reason_message: Option<String>,
}
/// authlib's `UserProperties`, as far as the title screen reads it.
/// authlib's `UserProperties`, as far as the title screen and the friends service read it.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct UserProperties {
/// `UserFlag.SERVERS_ALLOWED` (`privileges.multiplayerServer.enabled`).
pub servers_allowed: bool,
/// `UserFlag.FRIENDS_ENABLED` (`friendsPreferences.friends`).
pub friends_enabled: bool,
/// `UserFlag.ACCEPT_FRIEND_INVITES` (`friendsPreferences.acceptInvites`).
pub accept_friend_invites: bool,
/// `bannedScopes.get("MULTIPLAYER")`.
pub multiplayer_ban: Option<BanDetails>,
}
@@ -30,6 +34,8 @@ impl Default for UserProperties {
fn default() -> Self {
Self {
servers_allowed: true,
friends_enabled: false,
accept_friend_invites: false,
multiplayer_ban: None,
}
}
@@ -209,6 +215,13 @@ pub fn parse_properties(body: &str) -> Option<UserProperties> {
.pointer("/privileges/multiplayerServer/enabled")
.and_then(|e| e.as_bool())
.unwrap_or(false);
let toggle = |name: &str| {
v.pointer(&format!("/friendsPreferences/{name}"))
.and_then(|f| f.as_str())
.is_some_and(|f| f.eq_ignore_ascii_case("ENABLED"))
};
let friends_enabled = toggle("friends");
let accept_friend_invites = toggle("acceptInvites");
let multiplayer_ban = v
.pointer("/banStatus/bannedScopes/MULTIPLAYER")
.filter(|b| b.is_object())
@@ -222,6 +235,8 @@ pub fn parse_properties(body: &str) -> Option<UserProperties> {
});
Some(UserProperties {
servers_allowed,
friends_enabled,
accept_friend_invites,
multiplayer_ban,
})
}
@@ -275,6 +290,24 @@ mod tests {
assert!(clean.servers_allowed && clean.multiplayer_ban.is_none());
}
#[test]
fn friends_preferences_read_their_two_flags() {
let body = r#"{"privileges":{"multiplayerServer":{"enabled":true}},
"friendsPreferences":{"friends":"ENABLED","acceptInvites":"DISABLED"}}"#;
let p = parse_properties(body).unwrap();
assert!(p.friends_enabled);
assert!(!p.accept_friend_invites);
let other = parse_properties(
r#"{"friendsPreferences":{"friends":"DISABLED","acceptInvites":"ENABLED"}}"#,
)
.unwrap();
assert!(!other.friends_enabled);
assert!(other.accept_friend_invites);
// The offline fallback has neither flag, as `OFFLINE_PROPERTIES` does.
assert!(!UserProperties::default().friends_enabled);
assert!(!UserProperties::default().accept_friend_invites);
}
#[test]
fn parses_profile_actions() {
let a = parse_profile_actions(
@@ -297,6 +330,7 @@ mod tests {
properties: UserProperties {
servers_allowed: true,
multiplayer_ban: Some(ban.clone()),
..Default::default()
},
actions: ProfileActions {
forced_name_change: true,
@@ -317,7 +351,7 @@ mod tests {
let no_servers = AccountStatus {
properties: UserProperties {
servers_allowed: false,
multiplayer_ban: None,
..Default::default()
},
..Default::default()
};
+34
View File
@@ -112,6 +112,9 @@ pub struct BiomeInfo {
/// `BiomeGenerationSettings.features`: the placed feature ids of each
/// `GenerationStep.Decoration` step, in order.
pub features: Vec<Vec<String>>,
/// The biome's environment attributes (`Biome.getAttributes`): its layer in the
/// environment attribute stack.
pub attributes: horizon_world::environment_attributes::AttributeMap,
}
impl BiomeInfo {
fn ambient_particles(v: &Value) -> Vec<(ParticleType, f32)> {
@@ -277,6 +280,11 @@ impl BiomeInfo {
.collect()
})
.collect(),
attributes: horizon_world::environment_attributes::AttributeMap::parse(attributes)
.unwrap_or_else(|e| {
log::warn!("biome attributes: {e}");
Default::default()
}),
name,
}
}
@@ -393,6 +401,17 @@ impl Biomes {
pub fn get(&self, id: u16) -> &BiomeInfo {
&self.list[usize::from(id).min(self.list.len() - 1)]
}
/// The name of biome `id`, plains (as for an unknown name) when the id is
/// past this table: a column a server sent under its earlier, longer
/// registry (a proxy's previous server) keeps its ids until the next
/// login replaces it. `get` clamps such an id to the last entry instead,
/// so for that stale column the colours and this name can come from
/// different biomes until the login.
pub fn name(&self, id: u16) -> &str {
self.list
.get(usize::from(id))
.map_or("minecraft:plains", |b| b.name.as_str())
}
pub fn grass(&self, id: u16, x: f64, z: f64) -> u32 {
let b = self.get(id);
match b.modifier {
@@ -624,6 +643,21 @@ mod pack_tests {
assert_eq!(other.get(2).water, 0x3F76E4);
assert!(!other.has_packs());
}
/// A column keeps the ids of the table it was decoded with. When a
/// proxy's next server lacks a biome the last one added, that id is past
/// the next table, and it names plains instead of indexing past the end
/// (#12148: "the len is 67 but the index is 67").
#[test]
fn an_id_past_the_table_names_plains() {
let next = builtin(&["minecraft:meadow", "minecraft:plains", "minecraft:river"]);
let last = next.with_packs([("limbo:void", &json!({}))]);
let stale = last.id("limbo:void");
assert_eq!((stale, last.name(stale)), (3, "limbo:void"));
assert_eq!(next.name(stale), "minecraft:plains");
assert_eq!(next.name(u16::MAX), "minecraft:plains");
assert_eq!(next.name(2), "minecraft:river");
}
}
#[cfg(test)]
@@ -37,6 +37,37 @@ pub fn potion_name_key(item: &str, potion: Option<&str>) -> String {
let suffix = potion.and_then(self::potion).map_or("empty", |p| p.name);
format!("item.minecraft.{prefix}.effect.{suffix}")
}
/// `PotionItem.getName` and `TippedArrowItem.getName`: a potion or a tipped
/// arrow is named by its contents (`PotionContents.getName`: their own
/// `custom_name`, else the potion's name, else `empty`), whatever its
/// `item_name`. None for other items, and for a stack whose contents were
/// taken off (`!minecraft:potion_contents`), which takes the item's name.
pub fn contents_name_key(stack: &ItemStack) -> Option<String> {
use horizon_world::nbt::Tag;
let item = &*stack.item;
let named = matches!(
item,
"minecraft:potion"
| "minecraft:splash_potion"
| "minecraft:lingering_potion"
| "minecraft:tipped_arrow"
);
let data = stack.data();
if !named || data.is_some_and(|d| d.other("!minecraft:potion_contents").is_some()) {
return None;
}
let prefix = item.trim_start_matches("minecraft:");
let own = data
.and_then(|d| d.potion_extra.iter().find(|(k, _)| k == "custom_name"))
.and_then(|(_, name)| match name {
Tag::String(s) => Some(s.to_string_lossy()),
_ => None,
});
Some(match own {
Some(suffix) => format!("item.minecraft.{prefix}.effect.{suffix}"),
None => potion_name_key(item, stack.potion()),
})
}
/// `PotionContents.getAllEffects`: the potion's effects, then the stack's
/// `custom_effects` (id, duration, amplifier). Unknown effect ids are dropped.
pub fn contents_effects(stack: &ItemStack) -> Vec<(&'static str, i32, i32)> {
@@ -469,6 +500,40 @@ mod tests {
potion_name_key("minecraft:splash_potion", Some("minecraft:long_swiftness")),
"item.minecraft.splash_potion.effect.swiftness"
);
// A potion or a tipped arrow is named by its contents, over its
// item_name; one whose contents were taken off is not.
use horizon_world::nbt::Tag;
let mut swift =
ItemStack::new("minecraft:potion", 1).with_potion("minecraft:long_swiftness");
swift
.data_mut()
.set_other("minecraft:item_name", Some(Tag::String("Bottle".into())));
assert_eq!(
contents_name_key(&swift).as_deref(),
Some("item.minecraft.potion.effect.swiftness")
);
assert_eq!(
contents_name_key(&ItemStack::new("minecraft:splash_potion", 1)).as_deref(),
Some("item.minecraft.splash_potion.effect.empty")
);
let mut own = ItemStack::new("minecraft:tipped_arrow", 1).with_potion("minecraft:poison");
own.data_mut()
.potion_extra
.push(("custom_name".into(), Tag::String("luck".into())));
assert_eq!(
contents_name_key(&own).as_deref(),
Some("item.minecraft.tipped_arrow.effect.luck")
);
let mut taken = ItemStack::new("minecraft:potion", 1);
taken.data_mut().set_other(
"!minecraft:potion_contents",
Some(Tag::compound(Vec::<(&str, Tag)>::new())),
);
assert_eq!(contents_name_key(&taken), None);
assert_eq!(
contents_name_key(&ItemStack::new("minecraft:stone", 1)),
None
);
assert_eq!(format_duration(9600), "08:00");
assert_eq!(format_duration(450), "00:22");
assert_eq!(format_duration(140), "00:07");
+25 -12
View File
@@ -357,6 +357,24 @@ impl Light {
}
/// The smallest form of packed light per block.
pub fn packed(light: Box<[u8; 4096]>) -> Self {
match Self::classify(&light) {
(false, false, first) => Self::Uniform(first),
(true, false, first) => Self::Sky(Self::pack_layer(&light, 4), first & 15),
(false, true, first) => Self::Block(Self::pack_layer(&light, 0), first >> 4),
(true, true, _) => Self::Full(light),
}
}
/// The smallest form of packed light per block, allocating a 4 KiB box only
/// when both sky and block light vary within the section.
pub fn from_slice(light: &[u8; 4096]) -> Self {
match Self::classify(light) {
(false, false, first) => Self::Uniform(first),
(true, false, first) => Self::Sky(Self::pack_layer(light, 4), first & 15),
(false, true, first) => Self::Block(Self::pack_layer(light, 0), first >> 4),
(true, true, _) => Self::Full(Box::new(*light)),
}
}
fn classify(light: &[u8; 4096]) -> (bool, bool, u8) {
let first = light[0];
let (mut sky_varies, mut block_varies) = (false, false);
for &v in light.iter() {
@@ -364,19 +382,14 @@ impl Light {
sky_varies |= d & 0xF0 != 0;
block_varies |= d & 15 != 0;
}
let layer = |shift: u32| {
let mut layer = Box::new([0u8; 2048]);
for (out, pair) in layer.iter_mut().zip(light.as_chunks::<2>().0) {
*out = (pair[0] >> shift & 15) | (pair[1] >> shift & 15) << 4;
}
layer
};
match (sky_varies, block_varies) {
(false, false) => Self::Uniform(first),
(true, false) => Self::Sky(layer(4), first & 15),
(false, true) => Self::Block(layer(0), first >> 4),
(true, true) => Self::Full(light),
(sky_varies, block_varies, first)
}
fn pack_layer(light: &[u8; 4096], shift: u32) -> Box<[u8; 2048]> {
let mut layer = Box::new([0u8; 2048]);
for (out, pair) in layer.iter_mut().zip(light.as_chunks::<2>().0) {
*out = (pair[0] >> shift & 15) | (pair[1] >> shift & 15) << 4;
}
layer
}
/// The packed light of blocks `at..at + out.len()`, into `out`.
#[inline]
@@ -515,6 +515,11 @@ pub struct Info {
pub pos: [f64; 3],
pub source: Option<Who>,
pub owner: Option<Who>,
/// `uuid`: the source's saved identity (`source`), taken when the event
/// was posted. `source` is the live entity and comes back `None` on load.
pub source_uuid: Option<[i32; 4]>,
/// `projectileOwnerUuid` (`projectile_owner`), likewise beside `owner`.
pub owner_uuid: Option<[i32; 4]>,
}
/// `VibrationSelector`: the closest, then highest-frequency candidate of a tick.
#[derive(Clone, Debug, Default, PartialEq)]
@@ -356,6 +356,38 @@ where
)
.unwrap_or(false)
}
/// `Level.loadedAndEntityCanStandOn(pos, projectile)` with the projectile's
/// feet at `entity_y` (`loadedAndEntityCanStandOnFace` with `UP`):
/// `Block.isFaceFull(state.getCollisionShape(level, pos,
/// CollisionContext.of(projectile)), UP)`. A projectile is not living and never
/// falls (it moves by `setPos`), so powder snow and liquids hold nothing up
/// and only scaffolding reads the height. An unloaded or out-of-world block
/// reads as air.
pub fn projectile_can_stand_on(
world: &World,
binding: &BlockCollisionRegistry<'_>,
pos: BlockPos,
entity_y: f64,
) -> bool {
use horizon_core::geometry::{Direction, ShapeOp};
let mut scene = SceneCollisions {
world,
binding,
subject: None,
};
let state = world.block(pos.x, pos.y, pos.z);
let context = CollisionContext::position(entity_y);
binding
.collision_shape_with_context(&mut scene, state, pos, &context)
.is_ok_and(|shape| {
shape.is_block()
|| shape.face_shape(Direction::Up).is_ok_and(|face| {
VoxelShape::block()
.join_is_not_empty(&face, ShapeOp::XOR)
.is_ok_and(|differs| !differs)
})
})
}
fn no_collision_in<W, E>(world: &mut W, bounds: Aabb, fluids: bool) -> bool
where
W: BlockCollisionWorld<Error = E> + EntityMovementWorld<Error = E>,
+6 -4
View File
@@ -33,7 +33,7 @@ struct Globals {
hdr_windows: array<vec4<f32>, 2>,
// HDR lighting: x how strongly the sky lights the world now (`Sky::sky_light_factor`),
// y what the sky and fog get: the same, but none under water.
// w: this entire frame writes light, so alpha is coverage rather than a mark.
// w: apply the no-pack HDR gain; HDR_WRITE_LIGHT independently keeps float alpha as coverage.
hdr_light: vec4<f32>,
hdr_settings: vec4<f32>,
hdr_shares: vec4<f32>,
@@ -279,10 +279,11 @@ fn crumbling_uv(uv: vec2<f32>, packed_origin: vec2<f32>, sprite_size: vec2<f32>,
}
// CRUMBLING_UV_END
@fragment fn fs_entity(in: Out, @builtin(front_facing) front: bool) -> @location(0) vec4<f32> {
return entity_written_color(in, front);
return entity_written_color(in, entity_color(in, front));
}
fn entity_written_color(in: Out, front: bool) -> vec4<f32> {
let color = entity_color(in, front);
// Takes entity_color's result rather than calling it: FXC (DX12) fails with "argument pulled into
// unrelated predicate" when entity_color's discards sit a function call below the entry point.
fn entity_written_color(in: Out, color: vec4<f32>) -> vec4<f32> {
if HDR_WRITE_LIGHT && globals.hdr_light.w != 0.0 && in.overlay != 11u && in.overlay != 12u {
var mark = hdr_entity_mark(in);
if in.overlay == 4u { mark = hdr_scene_alpha(globals.hdr_light.y); }
@@ -455,6 +456,7 @@ fn celestial_color(in: Out) -> vec4<f32> {
if HDR_WRITE_LIGHT && globals.hdr_light.w != 0.0 {
return vec4(hdr_written(added, select(1.0, hdr_mark(tier, y), full), globals.hdr_settings, globals.hdr_shares), color.a);
}
if HDR_WRITE_LIGHT { return vec4(added, color.a); }
if !full { return vec4(added, 0.0); }
return vec4(added, 1.0 - hdr_mark(tier, y));
}
File diff suppressed because it is too large. Load diff
+306 -165
View File
@@ -35,8 +35,12 @@ fn entity_shader() -> String {
crate::overlay::wgsl_constants() + include_str!("hdr_light.wgsl") + include_str!("entity.wgsl")
}
fn written_lane(layered: bool, format: wgpu::TextureFormat, pack: bool, world: bool) -> bool {
layered && format == LINEAR_FRAME && !pack && world
fn no_pack_hdr_gain(layered: bool, pack: bool, world: bool) -> bool {
layered && !pack && world
}
fn light_marks(format: wgpu::TextureFormat, hdr: bool) -> bool {
hdr && format != LINEAR_FRAME
}
fn terrain_shader() -> String {
@@ -88,10 +92,19 @@ pub fn outline_params(width: u32, height: u32) -> [f32; 4] {
/// Camera-relative segment endpoints; no shape expansion in vanilla 26.3.
pub fn outline_vertices(edges: &[[f64; 6]], camera: glam::DVec3) -> Vec<[f32; 6]> {
edges
.iter()
.map(|e| std::array::from_fn(|i| (e[i] - camera.to_array()[i % 3]) as f32))
.collect()
let mut out = Vec::with_capacity(edges.len());
outline_vertices_into(edges, camera, &mut out);
out
}
fn outline_vertices_into(edges: &[[f64; 6]], camera: glam::DVec3, out: &mut Vec<[f32; 6]>) {
let camera = camera.to_array();
out.clear();
out.extend(
edges
.iter()
.map(|e| std::array::from_fn(|i| (e[i] - camera[i % 3]) as f32)),
);
}
/// Whether the renderer should initialize its occlusion culling pass (`cull`).
@@ -440,7 +453,8 @@ struct Globals {
/// full (`hdr::Hdr::windows`); zero without HDR.
hdr_windows: [[f32; 4]; 2],
hdr_light: [f32; 4],
/// Headroom, effective scene gain, world/look and debug, from the resolve pass's snapshot.
/// Headroom, effective scene gain, world/look and debug, as HDR output's pass has them
/// (`Hdr::draw_params`).
hdr_settings: [f32; 4],
hdr_shares: [f32; 4],
}
@@ -1389,7 +1403,7 @@ pub struct Renderer {
outline: wgpu::Buffer,
depth: wgpu::TextureView,
sections: resident::Resident<SectionGpu>,
lods: HashMap<crate::lod::TileKey, lod::Tile>,
lods: FxHashMap<crate::lod::TileKey, lod::Tile>,
/// Far tiles' occlusion culling slots (`lod::Tile::cull_slot`): those
/// free, and the next never used.
lod_cull_free: Vec<u32>,
@@ -1504,8 +1518,9 @@ pub struct Renderer {
/// The no-pack float HDR lane, with independent blending and an R8 coverage target.
hud_coverage: bool,
/// What `frame_format` was picked from besides the settings and the pack: whether the
/// surface offers HDR output, the device draws the 16-bit frame, and it draws the float one.
frame_device: (bool, bool, bool),
/// surface offers HDR output, the device draws the 16-bit and float frames, and it supports
/// the HUD layer. The layer must follow the pack even when the format stays float.
frame_device: (bool, bool, bool, bool),
/// Whether a pack picked or dropped since the renderer was made has been told it runs on
/// the frame that exists (`set_native_shader_pack`).
frame_kept_logged: bool,
@@ -1514,7 +1529,6 @@ pub struct Renderer {
hdr_marker: Option<std::path::PathBuf>,
/// The HDR Lighting setting, kept for the HDR output made when HDR turns on.
hdr_lighting_setting: bool,
hdr_written: bool,
/// The shader pack's post-processing, and the dimension (legacy id) it was loaded for.
pack_post: Option<(i32, Option<pack_post::PackPost>)>,
/// A `shaders/` pack whose pipelines build on a thread of their own (`load_shader_pack`).
@@ -1543,6 +1557,12 @@ pub struct Renderer {
pack_terrain_flat: bool,
/// OptiFine/Iris separate ambient occlusion: pass vertex alpha to hz_Color.a.
pub separate_ao: bool,
/// Shader-pack terrain pages used by post processing, retained between frames.
pack_pages: Vec<(u32, wgpu::Buffer)>,
/// Camera-relative selection outline vertices, retained between frames.
outline_edges_scratch: Vec<[f32; 6]>,
/// Selection outline line vertices, retained between frames.
outline_lines_scratch: Vec<[f32; 8]>,
/// Lava's sprite layers, which a pack's solid terrain draws (`pack_atlas::terrain_parts`).
pack_lava_layers: Vec<u16>,
pub hud: HudRenderer,
@@ -2160,6 +2180,38 @@ fn frame_format(
}
}
fn frame_configuration(
wide_frame: bool,
device: (bool, bool, bool, bool),
swapchain: wgpu::TextureFormat,
linear_pack: bool,
hdr_float: bool,
) -> (wgpu::TextureFormat, bool) {
let (hdr_offered, wide_ok, linear_ok, coverage_ok) = device;
let layered = !linear_pack && hdr_float && hdr_offered && linear_ok && coverage_ok;
(
frame_format(
wide_frame,
hdr_offered,
wide_ok,
swapchain,
(linear_pack, layered, linear_ok),
),
layered,
)
}
fn frame_follows(
current: (wgpu::TextureFormat, bool),
wide_frame: bool,
device: (bool, bool, bool, bool),
swapchain: wgpu::TextureFormat,
linear_pack: bool,
hdr_float: bool,
) -> bool {
current == frame_configuration(wide_frame, device, swapchain, linear_pack, hdr_float)
}
/// Opens a device on the first of `backend_order`'s backends that gives one.
/// Offscreen rendering needs no surface, so it can use a GPU the window's
/// display cannot present from (a headless compositor).
@@ -2526,7 +2578,7 @@ impl Renderer {
let vertices: u64 = self
.sections
.values()
.chain(self.lods.values().map(|tile| &tile.mesh))
.chain(self.lods.values().map(|tile| &*tile.mesh))
.map(|s| s.solid_slots + u64::from(s.translucent) * 4)
.sum();
let translucent = self.translucent_arena.as_ref();
@@ -2797,20 +2849,19 @@ impl Renderer {
"Horizon renderer: the device can't draw a float frame; the shader API 0.2 pack runs on the 8-bit one"
);
}
let frame_format = frame_format(
wide_frame,
hdr_output.is_some(),
wide_ok,
config.format,
linear,
);
let hud_coverage = hud_coverage::supported(
linear,
hdr_output.is_some(),
let coverage_ok = hud_coverage::supported(
device.limits().max_color_attachments,
adapter.get_downlevel_capabilities().flags,
adapter.get_texture_format_features(hud_coverage::FORMAT),
);
let frame_device = (hdr_output.is_some(), wide_ok, linear.2, coverage_ok);
let (frame_format, hud_coverage) = frame_configuration(
wide_frame,
frame_device,
config.format,
linear_pack,
hdr_float,
);
if previous.is_none() {
startup_step(format_args!("creating the pipelines"));
}
@@ -2964,12 +3015,9 @@ impl Renderer {
blend: Option<wgpu::BlendState>,
coverage: bool| {
let mut constants = constants.to_vec();
let plain = constants
.iter()
.any(|&(k, v)| k == "HDR_LIGHTING" && v == 0.0);
constants.push((
"HDR_WRITE_LIGHT",
f64::from(u8::from(frame_format == LINEAR_FRAME && !plain)),
f64::from(u8::from(frame_format == LINEAR_FRAME)),
));
let desc = wgpu::RenderPipelineDescriptor {
label: Some(label),
@@ -5057,7 +5105,7 @@ impl Renderer {
outline,
depth,
sections: resident::Resident::default(),
lods: HashMap::new(),
lods: FxHashMap::default(),
lod_cull_free: Vec::new(),
lod_cull_next: 0,
lod_cutout: Default::default(),
@@ -5106,12 +5154,11 @@ impl Renderer {
entity_atlas: atlas_view.clone(),
hdr: None,
hdr_lighting_setting: true,
hdr_written: false,
emission_stats,
hdr_output,
frame_format,
hud_coverage,
frame_device: (hdr_output.is_some(), wide_ok, linear.2),
frame_device,
frame_kept_logged: false,
hdr_marker: None,
pack_post: None,
@@ -5128,6 +5175,9 @@ impl Renderer {
shared_material_maps: [None, None],
pack_terrain_flat: false,
separate_ao: false,
pack_pages: Vec::new(),
outline_edges_scratch: Vec::new(),
outline_lines_scratch: Vec::new(),
pack_lava_layers: Vec::new(),
hud,
menu_blur: None,
@@ -5169,7 +5219,7 @@ impl Renderer {
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
pass.draw_indexed(0..quads * 6, (first * 4) as i32, 0..1);
if self.hdr.is_some() && !self.hdr_written {
if light_marks(self.frame_format, self.hdr.is_some()) {
// Drawn into the world: the light sources it holds are marked as the world's are.
pass.set_pipeline(&self.hdr_entity_mark_pipeline);
pass.draw_indexed(0..quads * 6, (first * 4) as i32, 0..1);
@@ -5177,7 +5227,9 @@ impl Renderer {
}
/// HDR lighting on the clouds just drawn: the sky's value (`fs_hdr_cloud_mark`).
fn mark_clouds(&self, pass: &mut wgpu::RenderPass<'_>, quads: u32) {
if self.hdr_written || self.hdr.as_ref().is_none_or(|h| !h.lighting) {
if !light_marks(self.frame_format, self.hdr.is_some())
|| self.hdr.as_ref().is_none_or(|h| !h.lighting)
{
return;
}
pass.set_pipeline(&self.hdr_cloud_mark_pipeline);
@@ -5196,7 +5248,9 @@ impl Renderer {
solid_draws: u32,
translucent_draws: u32,
) {
if self.hdr_written || self.hdr.as_ref().is_none_or(|h| !h.lighting) {
if !light_marks(self.frame_format, self.hdr.is_some())
|| self.hdr.as_ref().is_none_or(|h| !h.lighting)
{
return;
}
match flat {
@@ -6862,15 +6916,14 @@ impl Renderer {
/// `Renderer::new` takes them). Under `HORIZON_HDR_FRAME=float` the game makes the renderer
/// again when it isn't (docs/design/hdr-linear-light.md, "When it changes").
pub fn frame_follows(&self, wide_frame: bool, linear_pack: bool, hdr_float: bool) -> bool {
let (hdr_offered, wide_ok, linear_ok) = self.frame_device;
self.frame_format
== frame_format(
wide_frame,
hdr_offered,
wide_ok,
self.config.format,
(linear_pack, hdr_float, linear_ok),
)
frame_follows(
(self.frame_format, self.hud_coverage),
wide_frame,
self.frame_device,
self.config.format,
linear_pack,
hdr_float,
)
}
/// HDR output (Horizon setting): HDR10 or scRGB at `paper_white` nits, the world's
/// highlights expanded up to `peak` nits. Off where the surface can't present it.
@@ -8158,35 +8211,21 @@ impl Renderer {
if let Some(hdr) = &mut self.hdr {
hdr.lighting = hdr_lighting && hdr.look;
}
let hdr_written = written_lane(
let hdr_gain = no_pack_hdr_gain(
self.hdr.as_ref().is_some_and(|h| h.layered()),
self.frame_format,
self.shader_pack_kind().is_some(),
!frame.title,
);
if self.hdr_written != hdr_written {
log::info!(
"Horizon HDR frame: {}",
if hdr_written {
"written light"
} else {
"marked"
}
);
}
self.hdr_written = hdr_written;
let (hdr_settings, hdr_shares) = self
.hdr
.as_ref()
.map_or(([0.0; 4], [0.0; 4]), |h| h.draw_params(!frame.title));
self.world_post
.light_representation(self.hdr_written.then(|| {
(
hdr_settings,
hdr_shares,
self.hdr.as_ref().unwrap().windows(),
)
}));
self.world_post.baked_light(
hdr_gain,
hdr_settings,
hdr_shares,
self.hdr.as_ref().map_or([[0.0; 4]; 2], hdr::Hdr::windows),
);
let jitter = self.world_post.jitter(
[self.config.width, self.config.height],
taa,
@@ -8341,7 +8380,7 @@ impl Renderer {
},
// Only HDR output reads the solid terrain's lighting alpha.
if self.hdr.is_some() { 1.0 } else { 0.0 },
f32::from(u8::from(self.hdr_written)),
f32::from(u8::from(hdr_gain)),
],
hdr_settings,
hdr_shares,
@@ -8617,7 +8656,7 @@ impl Renderer {
let mut boxed = Vec::new();
self.sections
.visible(camera, radius, &light, |_| true, |_, _| false, &mut boxed);
let seen: std::collections::HashSet<_> =
let seen: crate::fast_hash::FxHashSet<_> =
shadow_visible.iter().map(|s| s.key).collect();
shadow_visible.extend(boxed.into_iter().filter(|s| !seen.contains(&s.key)));
});
@@ -8739,27 +8778,28 @@ impl Renderer {
}
// `[displacement]`: the solid quads of the sections it reaches, nearest first.
if let Some(d) = self.native.as_mut().and_then(|n| n.displacement.as_mut()) {
let near: Vec<displace::Near<'_>> = if frame.title {
Vec::new()
} else {
visible
.iter()
.filter(|v| {
let o = glam::Vec3::from(v.origin);
let closest = glam::Vec3::ZERO.clamp(o, o + 16.0);
closest.length() < d.distance
})
.filter_map(|v| {
let s = self.sections.get(v.slot);
(s.solid > 0).then(|| displace::Near {
buffer: self.arena.buffer(s.page),
start: s.start,
quads: s.solid,
origin: v.origin,
let near: smallvec::SmallVec<[displace::Near<'_>; displace::MAX_SECTIONS]> =
if frame.title {
smallvec::SmallVec::new()
} else {
visible
.iter()
.filter(|v| {
let o = glam::Vec3::from(v.origin);
let closest = glam::Vec3::ZERO.clamp(o, o + 16.0);
closest.length() < d.distance
})
})
.collect()
};
.filter_map(|v| {
let s = self.sections.get(v.slot);
(s.solid > 0).then(|| displace::Near {
buffer: self.arena.buffer(s.page),
start: s.start,
quads: s.solid,
origin: v.origin,
})
})
.collect()
};
d.prepare(&self.queue, &mut encoder, &near, view_proj);
}
// Solid sections front to back, then translucent ones back to front,
@@ -9239,28 +9279,34 @@ impl Renderer {
if outline_edges > 0 {
// The same per-line colour and pixel width layout as the gizmo lines. A high
// contrast outline is two strokes: the black one first, the bright one after it.
let edges = outline_vertices(&frame.outline[..outline_edges], camera);
outline_vertices_into(
&frame.outline[..outline_edges],
camera,
&mut self.outline_edges_scratch,
);
let width = outline_width(self.config.width);
let stroke = |color: u32, width: f32| {
edges.iter().map(move |e| {
let edges = &self.outline_edges_scratch;
let lines = &mut self.outline_lines_scratch;
lines.clear();
let stroke = |color: u32, width: f32, lines: &mut Vec<[f32; 8]>| {
for e in edges {
let mut line = [0.0; 8];
line[..6].copy_from_slice(e);
line[6] = f32::from_bits(color);
line[7] = width;
line
})
lines.push(line);
}
};
// A custom outline (PvP & HUD) takes the bright stroke's colour, scales both widths.
let (custom, scale) = frame.pvp.outline.map_or((None, 1.0), |(c, s)| (Some(c), s));
let lines: Vec<[f32; 8]> = if frame.outline_high_contrast {
stroke(0xff00_0000, 7.0 * scale)
.chain(stroke(custom.unwrap_or(0xff57_ffe1), width * scale))
.collect()
if frame.outline_high_contrast {
stroke(0xff00_0000, 7.0 * scale, lines);
stroke(custom.unwrap_or(0xff57_ffe1), width * scale, lines);
} else {
stroke(custom.unwrap_or(0x6600_0000), width * scale).collect()
};
stroke(custom.unwrap_or(0x6600_0000), width * scale, lines);
}
self.uploads
.buffer(&mut encoder, &self.outline, 0, bytemuck::cast_slice(&lines));
.buffer(&mut encoder, &self.outline, 0, bytemuck::cast_slice(lines));
if let Some(core) = &self.core_lines {
let size = [self.config.width, self.config.height];
let block = crate::post_chain::Globals {
@@ -10811,7 +10857,7 @@ impl Renderer {
pass.set_bind_group(0, &self.entity_group, &[]);
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
pass.draw_indexed(0..entity_quads * 6, 0, 0..1);
if self.hdr.is_some() && !self.hdr_written {
if light_marks(self.frame_format, self.hdr.is_some()) {
pass.set_pipeline(&self.hdr_entity_mark_pipeline);
pass.draw_indexed(0..entity_quads * 6, 0, 0..1);
}
@@ -10850,7 +10896,7 @@ impl Renderer {
((base_quads - translucent_item_quads) * 4) as i32,
0..1,
);
if self.hdr.is_some() && !self.hdr_written {
if light_marks(self.frame_format, self.hdr.is_some()) {
// A glowing item in the world (Item sheet quads), as entities are.
pass.set_pipeline(&self.hdr_mark_pipeline);
pass.draw_indexed(
@@ -10906,7 +10952,7 @@ impl Renderer {
pass.set_bind_group(0, &self.group, &[]);
}
// Cracks multiply the existing light; scalar OIT coverage cannot carry that factor.
if crumbling_quads > 0 && (!oit_on || self.hdr_written) {
if crumbling_quads > 0 && (!oit_on || hdr_gain) {
pass.set_pipeline(&self.crumbling_pipeline);
pass.set_bind_group(0, &self.entity_group, &[]);
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
@@ -10935,7 +10981,7 @@ impl Renderer {
pass.set_pipeline(&self.lightning_pipeline);
pass.set_bind_group(0, &self.entity_group, &[]);
self.lightning.draw(&mut pass, &frame.lightning);
if self.hdr.is_some() && !self.hdr_written {
if light_marks(self.frame_format, self.hdr.is_some()) {
pass.set_pipeline(&self.hdr_mark_lightning_pipeline);
self.lightning.draw(&mut pass, &frame.lightning);
}
@@ -10975,7 +11021,7 @@ impl Renderer {
pass.draw_indexed(first * 6..end * 6, (world_quads * 4) as i32, 0..1);
first = end;
}
if self.hdr.is_some() && !self.hdr_written {
if light_marks(self.frame_format, self.hdr.is_some()) {
// The dragon's eyes and death rays, marked as entities are.
pass.set_pipeline(&self.hdr_mark_pipeline);
pass.draw_indexed(0..additive_quads * 6, (world_quads * 4) as i32, 0..1);
@@ -11082,7 +11128,7 @@ impl Renderer {
}
};
quads(pass, &f.text[s], text_quads, entity_quads);
if !self.hdr_written {
if !hdr_gain {
quads(
pass,
&f.crumbling[s],
@@ -11206,7 +11252,7 @@ impl Renderer {
// HDR output's mark on what shines in the stages, as the classic
// pass marks it after drawing it: eyes, the dragon's eyes and rays,
// firework sparks and lightning.
if self.hdr.is_some() && !self.hdr_written {
if light_marks(self.frame_format, self.hdr.is_some()) {
pass.set_pipeline(&self.hdr_mark_pipeline);
pass.set_bind_group(0, &self.entity_group, &[]);
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
@@ -11275,7 +11321,7 @@ impl Renderer {
((base_quads - translucent_item_quads - translucent_particle_quads) * 4) as i32,
0..1,
);
if self.hdr.is_some() && !self.hdr_written {
if light_marks(self.frame_format, self.hdr.is_some()) {
// Fireworks: their sparks are translucent-layer particles.
pass.set_pipeline(&self.hdr_entity_mark_pipeline);
pass.draw_indexed(
@@ -11315,7 +11361,7 @@ impl Renderer {
if outline_edges > 0 && frame.outline_after_terrain && !pack_draws_outline {
self.draw_outline(&mut pass, outline_edges as u32, frame.outline_high_contrast);
}
if translucent_crumbling_quads > 0 && (!oit_on || self.hdr_written) {
if translucent_crumbling_quads > 0 && (!oit_on || hdr_gain) {
pass.set_pipeline(&self.crumbling_pipeline);
pass.set_bind_group(0, &self.entity_group, &[]);
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
@@ -11403,14 +11449,12 @@ impl Renderer {
};
post.set_weather(weather);
let flat = self.flat.as_ref().filter(|_| flat_frame);
let pages: Vec<(u32, wgpu::Buffer)> = match flat {
Some(_) if pack_draws_terrain || pack_draws_shadow => self
.arena
.page_buffers()
.map(|(p, b)| (p, b.clone()))
.collect(),
_ => Vec::new(),
};
self.pack_pages.clear();
if flat.is_some() && (pack_draws_terrain || pack_draws_shadow) {
self.pack_pages
.extend(self.arena.page_buffers().map(|(p, b)| (p, b.clone())));
}
let pages = &self.pack_pages;
let mut terrain =
|rp: &mut wgpu::RenderPass<'_>,
list: pack_post::TerrainList,
@@ -11497,9 +11541,12 @@ impl Renderer {
&frame.pack,
size,
[self.config.width, self.config.height],
&pages,
pages,
&mut terrain,
);
// Keep the capacity, not the buffers: a page the arena frees must not stay alive
// here until the next shader-pack frame (or forever once the pack is off).
self.pack_pages.clear();
self.pack_post = Some((dimension, Some(post)));
} else if let Some((color, depth)) = &scaled
&& native_post
@@ -11659,6 +11706,7 @@ impl Renderer {
}
// A 0.2 pack is shown at paper white: HDR output lifts nothing of its world.
let lifts_world = !frame.title
&& self.frame_format != LINEAR_FRAME
&& self
.native
.as_ref()
@@ -11913,17 +11961,13 @@ impl Renderer {
pass.draw_indexed(0..overlay.hand_quads * 6, overlay.first_hand_vertex, 0..1);
drop(pass);
if let Some(hdr) = &self.hdr
&& !self.hdr_written
&& !no_pack_hdr_gain(layered, self.shader_pack_kind().is_some(), !frame.title)
{
hdr.mark_hand(encoder, timing.as_mut());
// On the layered frame the hand's marks go to the frame, before they become
// light with the world's.
let marks = if layered {
Some(view_target.clone())
} else {
hdr.hand_light_view()
};
if let Some(view) = marks {
// The non-layered 0.2 hand keeps its UI-brightness sentinel, not light marks.
if light_marks(self.frame_format, true)
&& let Some(view) = hdr.hand_light_view()
{
// The light sources in the hand may shine, the rest of it stays at the
// world's brightness: nothing the world kept here shows through it.
let attachments = [Some(wgpu::RenderPassColorAttachment {
@@ -11960,15 +12004,6 @@ impl Renderer {
}
}
}
// The layered frame's marks become light: what draws from here draws on light, and
// nothing is lifted after.
if layered
&& !self.hdr_written
&& !frame.title
&& let Some(hdr) = &self.hdr
{
hdr.resolve(encoder, timing.as_mut());
}
// A native mod's turn after the hand, on the real frame only.
if overlay.real && !frame.title {
native_render::native_hand_drawn(&mut native_render::HandDrawn {
@@ -12678,11 +12713,8 @@ impl Frustum {
/// Whether the camera-relative box `min..max` is at least partly inside.
pub fn intersects(&self, min: Vec3, max: Vec3) -> bool {
!self.planes.iter().any(|p| {
let v = Vec3::new(
if p.x >= 0.0 { max.x } else { min.x },
if p.y >= 0.0 { max.y } else { min.y },
if p.z >= 0.0 { max.z } else { min.z },
);
let sign = p.truncate().cmpge(Vec3::ZERO);
let v = Vec3::select(sign, max, min);
p.truncate().dot(v) + p.w < 0.0
})
}
@@ -12705,11 +12737,8 @@ impl Frustum {
return true;
}
!self.planes[4..].iter().any(|p| {
let v = Vec3::new(
if p.x >= 0.0 { max.x } else { min.x },
if p.y >= 0.0 { max.y } else { min.y },
if p.z >= 0.0 { max.z } else { min.z },
);
let sign = p.truncate().cmpge(Vec3::ZERO);
let v = Vec3::select(sign, max, min);
p.truncate().dot(v) + p.w < 0.0
})
}
@@ -12719,11 +12748,8 @@ impl Frustum {
/// rounding keeps that order.
pub fn contains(&self, min: Vec3, max: Vec3) -> bool {
self.planes.iter().all(|p| {
let v = Vec3::new(
if p.x >= 0.0 { min.x } else { max.x },
if p.y >= 0.0 { min.y } else { max.y },
if p.z >= 0.0 { min.z } else { max.z },
);
let sign = p.truncate().cmpge(Vec3::ZERO);
let v = Vec3::select(sign, min, max);
p.truncate().dot(v) + p.w >= 0.0
})
}
@@ -12930,27 +12956,142 @@ fn frustum(m: Mat4) -> [Vec4; 6] {
#[cfg(test)]
mod tests {
#[test]
fn written_light_requires_the_entire_default_layered_world_lane() {
use super::{LINEAR_FRAME, WIDE_FRAME, written_lane};
assert!(written_lane(true, LINEAR_FRAME, false, true));
for bits in 0..8 {
let layered = bits & 1 != 0;
let pack = bits & 2 != 0;
let world = bits & 4 != 0;
assert_eq!(
written_lane(layered, LINEAR_FRAME, pack, world),
layered && !pack && world
);
assert!(!written_lane(layered, WIDE_FRAME, pack, world));
assert!(!written_lane(
layered,
wgpu::TextureFormat::Bgra8Unorm,
pack,
world,
));
fn float_frames_never_schedule_light_mark_passes() {
use super::{LINEAR_FRAME, WIDE_FRAME, light_marks};
use wgpu::TextureFormat::{Bgra8Unorm, Rgb10a2Unorm, Rgba8Unorm};
for hdr in [false, true] {
assert!(!light_marks(LINEAR_FRAME, hdr));
for format in [WIDE_FRAME, Bgra8Unorm, Rgba8Unorm, Rgb10a2Unorm] {
assert_eq!(light_marks(format, hdr), hdr);
}
}
}
#[test]
fn frame_policy_keeps_compatibility_frames_and_requires_layering_for_float_hdr() {
use super::{LINEAR_FRAME, WIDE_FRAME, frame_configuration};
use wgpu::TextureFormat::{Bgra8Unorm, Bgra8UnormSrgb};
for bits in 0..128 {
let hdr = bits & 1 != 0;
let offered = bits & 2 != 0;
let wide_ok = bits & 4 != 0;
let float_ok = bits & 8 != 0;
let coverage_ok = bits & 16 != 0;
let switch = bits & 32 != 0;
let smooth = bits & 64 != 0;
for pack in ["none", "0.1", "0.2", "shaders"] {
let expected = if switch && float_ok && pack == "0.2" {
(LINEAR_FRAME, false)
} else if switch && float_ok && pack == "none" && hdr && offered && coverage_ok {
(LINEAR_FRAME, true)
} else if hdr && smooth && offered && wide_ok {
(WIDE_FRAME, false)
} else {
(Bgra8Unorm, false)
};
assert_eq!(
frame_configuration(
hdr && smooth,
(offered, wide_ok, float_ok, coverage_ok),
Bgra8UnormSrgb,
switch && pack == "0.2",
switch && hdr && pack == "none",
),
expected,
"bits {bits}, pack {pack}"
);
}
}
}
#[test]
fn float_hdr_falls_back_for_each_missing_hud_layer_requirement() {
use super::{LINEAR_FRAME, WIDE_FRAME, frame_configuration, hud_coverage};
use wgpu::{DownlevelFlags as D, TextureFormat as F, TextureFormatFeatureFlags as Fl};
use wgpu::{TextureFormatFeatures, TextureUsages as U};
let usages = U::RENDER_ATTACHMENT | U::TEXTURE_BINDING;
for (attachments, flags, allowed_usages, format_flags) in [
(1, D::INDEPENDENT_BLEND, usages, Fl::BLENDABLE),
(2, D::empty(), usages, Fl::BLENDABLE),
(2, D::INDEPENDENT_BLEND, U::TEXTURE_BINDING, Fl::BLENDABLE),
(2, D::INDEPENDENT_BLEND, U::RENDER_ATTACHMENT, Fl::BLENDABLE),
(2, D::INDEPENDENT_BLEND, usages, Fl::empty()),
] {
let coverage = hud_coverage::supported(
attachments,
flags,
TextureFormatFeatures {
allowed_usages,
flags: format_flags,
},
);
let device = (true, true, true, coverage);
assert_eq!(
frame_configuration(true, device, F::Bgra8Unorm, false, true),
(WIDE_FRAME, false)
);
assert_eq!(
frame_configuration(false, device, F::Bgra8Unorm, false, true),
(F::Bgra8Unorm, false)
);
assert_eq!(
frame_configuration(true, device, F::Bgra8Unorm, true, false),
(LINEAR_FRAME, false)
);
}
}
#[test]
fn frame_follows_requires_a_rebuild_when_only_layering_changes() {
use super::{LINEAR_FRAME, frame_follows};
use wgpu::TextureFormat::Bgra8Unorm;
let device = (true, true, true, true);
assert!(frame_follows(
(LINEAR_FRAME, true),
true,
device,
Bgra8Unorm,
false,
true
));
assert!(!frame_follows(
(LINEAR_FRAME, true),
true,
device,
Bgra8Unorm,
true,
false
));
assert!(frame_follows(
(LINEAR_FRAME, false),
true,
device,
Bgra8Unorm,
true,
false
));
assert!(!frame_follows(
(LINEAR_FRAME, false),
true,
device,
Bgra8Unorm,
false,
true
));
}
#[test]
fn only_a_no_pack_layered_world_applies_hdr_gain() {
use super::no_pack_hdr_gain;
assert!(no_pack_hdr_gain(true, false, true));
assert!(!no_pack_hdr_gain(false, false, true));
assert!(!no_pack_hdr_gain(true, true, true));
assert!(!no_pack_hdr_gain(true, false, false));
}
#[test]
fn taa_culling_covers_jittered_view_edges_but_still_rejects_behind_the_camera() {
use super::*;
@@ -554,14 +554,22 @@ mod tests {
});
let view = target.create_view(&Default::default());
// Independent scalar expectation: a sky mark of 206/255 gives this gamma gain.
let gain = (1.0f32 + 3.0 * (1.0 - 206.0 / 255.0) / 0.24).powf(1.0 / 2.2);
for (mode_index, mode, debug) in MODES
let hdr_gain = (1.0f32 + 3.0 * (1.0 - 206.0 / 255.0) / 0.24).powf(1.0 / 2.2);
for (mode_index, mode, debug, written) in MODES
.iter()
.copied()
.enumerate()
.map(|(i, m)| (i, m, false))
.chain([(5, Blend::Screen, true)])
.map(|(i, m)| (i, m, false, true))
.chain([(5, Blend::Screen, true, true)])
.chain(
MODES
.iter()
.copied()
.enumerate()
.map(|(i, m)| (i, m, false, false)),
)
{
let gain = if written { hdr_gain } else { 1.0 };
let uniform = Uniform {
clip_from_sky: Mat4::IDENTITY.to_cols_array_2d(),
brightness: 0.7,
@@ -569,7 +577,7 @@ mod tests {
pad: [0; 2],
hdr_settings: [5.0, 4.0, 1.0, f32::from(u8::from(debug))],
hdr_shares: [0.2, 0.5, 0.6, 1.0],
hdr_light: [0.8, 0.8, 1.0, 1.0],
hdr_light: [0.8, 0.8, 1.0, f32::from(u8::from(written))],
};
let buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: None,
@@ -704,6 +712,12 @@ mod tests {
let data = readback.get_mapped_range(..).unwrap();
let got = half::f16::from_le_bytes([data[0], data[1]]).to_f32();
let (d, t, k) = (2.0, 0.8, 128.0 / 255.0 * 0.7);
let alpha = half::f16::from_le_bytes([data[6], data[7]]).to_f32();
let coverage = if mode == Blend::Alpha { k } else { 1.0 };
assert!(
(alpha - coverage).abs() < 0.001,
"{mode:?}: alpha {alpha}, expected {coverage}"
);
let expected = if debug {
let grey = (0.2126f32 * 0.8f32.powf(2.2)
+ 0.7152 * 0.4f32.powf(2.2)
@@ -728,6 +742,8 @@ mod tests {
"{mode:?} debug {debug}: {got} expected {expected}"
);
}
eprintln!("HDR custom sky GPU ran all nine modes and debug Screen over above-white light");
eprintln!(
"HDR custom sky GPU ran all nine modes with and without gain, plus debug Screen, checking RGB and coverage"
);
}
}
@@ -12,7 +12,7 @@ use crate::mesher::TerrainVertex;
/// The solid quads the grid can draw a frame, and the sections they come from.
const MAX_QUADS: u64 = 1 << 16;
const MAX_SECTIONS: usize = 64;
pub(crate) const MAX_SECTIONS: usize = 64;
/// Bytes of one quad's corners.
const QUAD_BYTES: u64 = 4 * std::mem::size_of::<TerrainVertex>() as u64;
/// How far past the flat face the relief may reach, in blocks, for the view test.
@@ -413,6 +413,8 @@ pub struct Displacement {
pub shadow: Option<Picks>,
/// Group 1's layout for the grid's pipelines.
pub draw_layout: wgpu::BindGroupLayout,
/// CPU-side section table reused between displacement preparation passes.
table: Vec<Section>,
/// Sections the pick ran over this frame (0: nothing to draw).
pub drawn: usize,
}
@@ -598,6 +600,7 @@ impl Displacement {
view,
shadow,
draw_layout,
table: Vec::new(),
drawn: 0,
}
}
@@ -610,7 +613,8 @@ impl Displacement {
near: &[Near<'_>],
view_proj: glam::Mat4,
) {
let mut table = Vec::with_capacity(near.len().min(MAX_SECTIONS));
self.table.clear();
self.table.reserve(near.len().min(MAX_SECTIONS));
let mut first = 0u64;
let mut most = 0u32;
for n in near.iter().take(MAX_SECTIONS) {
@@ -626,7 +630,7 @@ impl Displacement {
first * QUAD_BYTES,
quads * QUAD_BYTES,
);
table.push(Section {
self.table.push(Section {
origin: [
n.origin[0],
n.origin[1],
@@ -640,11 +644,11 @@ impl Displacement {
first += quads;
most = most.max(quads as u32);
}
self.drawn = table.len();
if table.is_empty() {
self.drawn = self.table.len();
if self.table.is_empty() {
return;
}
queue.write_buffer(&self.sections, 0, bytemuck::cast_slice(&table));
queue.write_buffer(&self.sections, 0, bytemuck::cast_slice(&self.table));
let lists = std::iter::once((&self.view, 0)).chain(self.shadow.iter().map(|s| (s, 1)));
let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
label: Some("Displacement pick"),
@@ -655,7 +659,7 @@ impl Displacement {
let params = Params {
view_proj: view_proj.to_cols_array_2d(),
distance: self.distance,
sections: table.len() as u32,
sections: self.table.len() as u32,
margin: RELIEF_MARGIN,
capacity: MAX_QUADS as u32,
shadow,
@@ -665,7 +669,7 @@ impl Displacement {
let vertices = grid_vertices(picks.cells, picks.steps);
queue.write_buffer(&picks.draw, 0, bytemuck::cast_slice(&[vertices, 0, 0, 0]));
pass.set_bind_group(0, &picks.pick_group, &[]);
pass.dispatch_workgroups(most.div_ceil(64), table.len() as u32, 1);
pass.dispatch_workgroups(most.div_ceil(64), self.table.len() as u32, 1);
}
}
+153 -56
View File
@@ -72,32 +72,28 @@ fn named(name: Option<&str>) -> Option<Output> {
struct Targets {
size: [u32; 2],
frame: wgpu::Texture,
/// The world as `keep_world` kept it; on the layered frame, the frame as `resolve` (the
/// world and the hand marked it) or `tonemap` found it.
/// The world as `keep_world` kept it; also the tone curve's input scratch on a layered frame.
world: wgpu::Texture,
coverage: Option<wgpu::Texture>,
/// The layered frame's HUD target: what the HUD draws on.
hud: Option<wgpu::Texture>,
group: wgpu::BindGroup,
/// The layered frame's passes' bindings: `resolve` (and `tonemap`), `keep_world` and
/// `composite`.
/// The layered frame's bindings for `tonemap`, `keep_world` and `composite`.
passes: Option<[wgpu::BindGroup; 3]>,
}
/// The float frame with HUD coverage (`hud_coverage::supported`, docs/design/hdr-linear-light.md
/// "The HUD and menus"): the default world draws write light directly; marked fallback frames
/// turn their marks into light after the world and the hand (`resolve`); the tone curve eases it into the peak after the post effects (`tonemap`); the
/// "The HUD and menus"): the default world draws write light directly; the tone curve eases
/// it into the peak after the post effects (`tonemap`); the
/// camera overlays draw on that light; the HUD draws on a target of its own,
/// the world clipped to white (`keep_world`); and the last pass composites the HUD over the
/// world's light by its coverage (`fs_hdr_layered`), as the menu blur does before it blurs
/// (`composite`).
struct Layered {
resolve: (wgpu::BindGroupLayout, wgpu::RenderPipeline),
/// Reads what `resolve` reads.
tonemap: wgpu::RenderPipeline,
tonemap: (wgpu::BindGroupLayout, wgpu::RenderPipeline),
keep: (wgpu::BindGroupLayout, wgpu::RenderPipeline),
composite: (wgpu::BindGroupLayout, wgpu::RenderPipeline),
/// What `resolve` and `composite` read, written before they run (`begin`).
/// What `tonemap` and `composite` read, written before they run (`begin`).
params: wgpu::Buffer,
}
@@ -337,10 +333,8 @@ impl Hdr {
let pipeline = pass(label, &layout, entry, format);
(layout, pipeline)
};
let resolve = with("Horizon HDR marks to light", &[1], true, "fs_resolve");
Layered {
tonemap: pass("Horizon HDR tone curve", &resolve.0, "fs_tonemap", format),
resolve,
tonemap: with("Horizon HDR tone curve", &[1], true, "fs_tonemap"),
keep: with("Horizon HDR HUD backdrop", &[0], false, "fs_keep"),
composite: with("Horizon HDR composite", &[0, 4, 5], true, "fs_composite"),
params: params(),
@@ -492,7 +486,7 @@ impl Hdr {
})
};
[
group(&l.resolve.0, &[entry(1, &world), params.clone()]),
group(&l.tonemap.0, &[entry(1, &world), params.clone()]),
group(&l.keep.0, &[entry(0, &frame)]),
group(
&l.composite.0,
@@ -529,7 +523,7 @@ impl Hdr {
self.targets.as_ref()?.hud.as_ref()
}
/// On the layered frame, before `resolve` and `composite`: what they read, for a frame that
/// On the layered frame, before `tonemap` and `composite`: what they read, for a frame that
/// drew a world (`world`) or not.
pub fn begin(&self, queue: &wgpu::Queue, world: bool) {
if let Some(l) = &self.layered {
@@ -541,34 +535,12 @@ impl Hdr {
}
}
/// On the layered frame, after the world and the hand drew and marked it: the marks
/// become light in the frame (`fs_resolve`).
pub fn resolve(
&self,
encoder: &mut wgpu::CommandEncoder,
timing: Option<&mut super::pack_timing::PassTiming>,
) {
self.redraw(encoder, |l| &l.resolve.1, "HDR marks to light", timing);
}
/// On the layered frame, after the post effects and before the camera overlays: the tone
/// curve eases the light into the peak (`fs_tonemap`).
pub fn tonemap(
&self,
encoder: &mut wgpu::CommandEncoder,
timing: Option<&mut super::pack_timing::PassTiming>,
) {
self.redraw(encoder, |l| &l.tonemap, "HDR tone curve", timing);
}
/// The layered frame drawn again by `pipeline`, which reads it from a copy in the world
/// target.
fn redraw(
&self,
encoder: &mut wgpu::CommandEncoder,
pipeline: impl Fn(&Layered) -> &wgpu::RenderPipeline,
label: &'static str,
timing: Option<&mut super::pack_timing::PassTiming>,
) {
let (Some(l), Some(t)) = (&self.layered, &self.targets) else {
return;
@@ -582,7 +554,14 @@ impl Hdr {
t.frame.size(),
);
let view = t.frame.create_view(&Default::default());
Self::draw(encoder, pipeline(l), group, &view, label, timing);
Self::draw(
encoder,
&l.tonemap.1,
group,
&view,
"HDR tone curve",
timing,
);
}
/// On the layered frame: the picture so far (`fs_composite`), as the frame stores light,
@@ -981,6 +960,10 @@ var<private> params: ReferenceParams;
let actual = hdr_mark_light(color, mark, shows, eased,
vec4(params.peak, params.scene, params.world, params.debug), params.share);
errors[i] = select(0u, 1u, any(abs(expected.light - actual.light) > vec3(1e-6)) || expected.lifted != actual.lifted);
// Gain stays above the tone knee until the separate tone curve runs.
let gain = 1.0 + 3.0 * (1.0 - 194.0 / 255.0) / 0.24;
let white = hdr_written(vec3(1.0), 0.76, vec4(5.0, 4.0, 1.0, 0.0), params.share);
errors[i] |= select(0u, 4u, any(abs(pow(white, vec3(2.2)) - vec3(gain)) > vec3(1e-5)));
let boundary = (f32(i % 255u) + 0.5) / 255.0;
for (var step = -1; step <= 1; step++) {
let stored = unpack2x16float(pack2x16float(vec2(boundary + f32(step) / 65536.0))).x;
@@ -1133,7 +1116,14 @@ var<private> params: ReferenceParams;
pub(super) fn gpu() -> Option<(wgpu::Device, wgpu::Queue)> {
let instance = crate::gfx::test_gpu::instance();
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).ok()?;
pollster::block_on(adapter.request_device(&Default::default())).ok()
eprintln!("HDR GPU {:?}", adapter.get_info());
pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
required_features: adapter.features()
& (wgpu::Features::TEXTURE_FORMAT_16BIT_NORM
| wgpu::Features::TEXTURE_ADAPTER_SPECIFIC_FORMAT_FEATURES),
..Default::default()
}))
.ok()
}
/// Clears all of `texture` to `color`.
@@ -1193,37 +1183,147 @@ var<private> params: ReferenceParams;
std::array::from_fn(|i| half::f16::from_le_bytes([bytes[2 * i], bytes[2 * i + 1]]).to_f32())
}
// Independent pre-refactor light arithmetic applied to a compatibility attachment.
// Float targets in these tests only receive the resulting colour and coverage.
pub(super) fn legacy_light(
device: &wgpu::Device,
encoder: &mut wgpu::CommandEncoder,
hdr: &Hdr,
source: &wgpu::Texture,
target: &wgpu::Texture,
) {
use wgpu::util::DeviceExt;
assert_eq!(source.format(), F::Rgba16Unorm);
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("legacy non-float light oracle"),
source: wgpu::ShaderSource::Wgsl(
(r#"
struct ReferenceParams { peak: f32, scene: f32, world: f32, debug: f32, share: vec4<f32> }
@group(0) @binding(0) var<uniform> params: ReferenceParams;
@group(0) @binding(1) var source: texture_2d<f32>;
"#
.to_owned()
+ include_str!("hdr_light_reference.txt")
+ r#"
@vertex fn vs(@builtin(vertex_index) i: u32) -> @builtin(position) vec4<f32> {
return vec4(vec2(f32((i << 1u) & 2u), f32(i & 2u)) * 2.0 - 1.0, 0.0, 1.0);
}
@fragment fn fs() -> @location(0) vec4<f32> {
let c = textureLoad(source, vec2(0), 0);
let mark = round(saturate(c.a) * 255.0) / 255.0;
let light = reference_lit(c.rgb, mark, params.world > 0.5 && params.peak > 1.0, false).light;
return vec4(pow(light, vec3(1.0 / 2.2)), 1.0);
}
"#)
.into(),
),
});
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: None,
layout: None,
vertex: wgpu::VertexState {
module: &shader,
entry_point: Some("vs"),
compilation_options: Default::default(),
buffers: &[],
},
primitive: Default::default(),
depth_stencil: None,
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: Some("fs"),
compilation_options: Default::default(),
targets: &[Some(F::Rgba16Float.into())],
}),
multiview_mask: None,
cache: None,
});
let (settings, shares) = hdr.draw_params(true);
let params = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: None,
contents: bytemuck::cast_slice(&[settings, shares]),
usage: wgpu::BufferUsages::UNIFORM,
});
let view = source.create_view(&Default::default());
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: None,
layout: &pipeline.get_bind_group_layout(0),
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: params.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::TextureView(&view),
},
],
});
Hdr::draw(
encoder,
&pipeline,
&group,
&target.create_view(&Default::default()),
"legacy oracle",
None,
);
}
#[test]
fn the_layered_frame_shows_the_world_as_the_last_pass_lifted_it() {
fn direct_light_matches_the_legacy_non_float_display_below_the_tone_knee() {
let Some((device, queue)) = gpu() else {
return;
};
// What the last pass shows of a world pixel nothing covers, from today's frame
// (`layered` false) or the layered one, whose marks turn into light before it, then
// pass the tone curve.
if !device.features().contains(
wgpu::Features::TEXTURE_FORMAT_16BIT_NORM
| wgpu::Features::TEXTURE_ADAPTER_SPECIFIC_FORMAT_FEATURES,
) {
eprintln!("legacy display GPU skipped: 16-bit UNORM unavailable");
return;
}
// Compare the compatibility display lift with direct colour under the layered HUD.
// The frozen oracle supplies the writer's light from a non-float attachment.
let shown = |layered: bool, world: [f64; 4]| {
let mut hdr =
Hdr::with_debug(&device, &queue, F::Rgba16Float, Output::Pq, layered, None);
let mut hdr = Hdr::with_debug(
&device,
&queue,
if layered {
F::Rgba16Float
} else {
F::Rgba16Unorm
},
Output::Pq,
layered,
None,
);
let frame = hdr.frame(&device, [1, 1]);
let mut encoder = device.create_command_encoder(&Default::default());
fill(&mut encoder, &frame, world);
if layered {
let mut legacy =
Hdr::with_debug(&device, &queue, F::Rgba16Unorm, Output::Pq, false, None);
let marked = legacy.frame(&device, [1, 1]);
fill(&mut encoder, &marked, world);
if world[3] == 1.0 {
fill(&mut encoder, &frame, world);
} else {
legacy_light(&device, &mut encoder, &hdr, &marked, &frame);
}
hdr.begin(&queue, true);
hdr.resolve(&mut encoder, None);
hdr.tonemap(&mut encoder, None);
hdr.keep_world(&mut encoder, None);
super::super::hud_coverage::clear(&mut encoder, &hdr.coverage_view().unwrap());
} else {
fill(&mut encoder, &frame, world);
hdr.keep_world(&mut encoder, None);
}
let capture = hdr.capture(&device, &queue, &mut encoder, true).unwrap();
texel(&device, &queue, encoder, &capture)
};
// Unmarked, below white and above it; HDR lighting; light sources of each tier: all up to
// Unmarked, HDR lighting and light sources of each tier: all up to
// the tone curve's knee, which the curve leaves as they are.
for world in [
[0.5, 0.4, 0.3, 1.0],
[1.5, 1.2, 0.6, 1.0],
[0.8, 0.7, 0.6, 0.88],
[0.9, 0.8, 0.4, 0.05],
[0.9, 0.8, 0.4, 0.2],
@@ -1240,6 +1340,10 @@ var<private> params: ReferenceParams;
assert!(layered[0] > shown(true, [world[0], world[1], world[2], 1.0])[0]);
}
}
// Above-white unmarked colour belongs to float storage, not the UNORM oracle.
let above = shown(true, [1.5, 1.2, 0.6, 1.0]);
let white = shown(true, [1.0, 1.0, 0.6, 1.0]);
assert!(above[0] > white[0] && above[1] > white[1]);
}
#[test]
@@ -1306,13 +1410,6 @@ var<private> params: ReferenceParams;
hdr.peak = 150.0;
let [r, g, b] = eased(&mut hdr, [1.5, 0.5, 0.2]);
assert!(r == 1.0 && near(g, 0.5) && near(b, 0.2), "{r} {g} {b}");
// HDR lighting's gain stays past the knee until the curve: the marks become light
// without easing.
hdr.peak = 1000.0;
let resolve = |hdr: &Hdr, encoder: &mut wgpu::CommandEncoder| hdr.resolve(encoder, None);
let [r, ..] = after(&mut hdr, [1.0, 1.0, 1.0, 0.76], &resolve);
let gain = 1.0 + 3.0 * (1.0 - 194.0 / 255.0) / 0.24;
assert!(near(r.powf(2.2), gain), "{r}");
}
#[test]
@@ -1,6 +1,6 @@
use super::{
Hdr, Output,
tests::{gpu, texel},
tests::{gpu, legacy_light, texel},
};
use wgpu::TextureFormat as F;
use wgpu::util::DeviceExt;
@@ -46,6 +46,29 @@ override TEST_OVERLAY: u32 = 0u;
}
"#;
const TERRAIN_EXTRA_VERTICES: &str = r#"
@vertex fn test_model(@builtin(vertex_index) i: u32) -> NativeModelOut {
let p = vec2(f32((i << 1u) & 2u), f32(i & 2u));
var o: NativeModelOut;
o.clip = vec4(p * 2.0 - 1.0, 0.0, 1.0);
o.uv = vec2(0.5);
o.color = vec4(0.8, 0.9, 0.7, 1.0);
return o;
}
@vertex fn test_fluid(@builtin(vertex_index) i: u32) -> NativeFluidOut {
let p = vec2(f32((i << 1u) & 2u), f32(i & 2u));
var o: NativeFluidOut;
o.clip = vec4(p * 2.0 - 1.0, 0.0, 1.0);
o.uv = vec2(0.5);
o.color = vec4(0.8, 0.9, 0.7, 1.0);
return o;
}
@vertex fn test_sunrise(@builtin(vertex_index) i: u32) -> SunriseOut {
let p = vec2(f32((i << 1u) & 2u), f32(i & 2u));
return SunriseOut(vec4(p * 2.0 - 1.0, 0.0, 1.0), 0.5);
}
"#;
struct Draw<'a> {
device: &'a wgpu::Device,
queue: &'a wgpu::Queue,
@@ -54,6 +77,9 @@ struct Draw<'a> {
layout: wgpu::PipelineLayout,
bindings: wgpu::BindGroupLayout,
group: wgpu::BindGroup,
raw: bool,
hdr_lighting: bool,
material_frame: Option<super::super::native_post::NativeFrame>,
}
impl<'a> Draw<'a> {
fn render_oit(
@@ -316,7 +342,7 @@ impl<'a> Draw<'a> {
alpha: u8,
) -> Self {
let source = if terrain {
super::super::terrain_shader() + TERRAIN_VERTEX
super::super::terrain_shader() + TERRAIN_VERTEX + TERRAIN_EXTRA_VERTICES
} else {
super::super::entity_shader() + ENTITY_VERTEX
};
@@ -432,8 +458,43 @@ impl<'a> Draw<'a> {
layout,
bindings,
group,
raw: false,
hdr_lighting: true,
material_frame: None,
}
}
fn material(&mut self, api: horizon_shader_api::Api) {
let source = super::super::native_material::coverage_test_shader(api);
self.shader = self
.device
.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("actual assembled material wrappers"),
source: wgpu::ShaderSource::Wgsl(source.into()),
});
let frame = super::super::native_post::NativeFrame::new(
self.device,
None,
Vec::new(),
Default::default(),
None,
api,
);
self.queue.write_buffer(
&frame.buffer,
0,
bytemuck::bytes_of(&horizon_shader_api::Frame::default()),
);
self.layout = self
.device
.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: None,
bind_group_layouts: &[Some(&self.bindings), None, None, Some(&frame.layout)],
..Default::default()
});
self.material_frame = Some(frame);
self.raw = true;
}
fn render(
&self,
hdr: &mut Hdr,
@@ -443,11 +504,50 @@ impl<'a> Draw<'a> {
overlay: u32,
background: [f64; 4],
) -> [f32; 4] {
let constants = [
("HDR_WRITE_LIGHT", f64::from(u8::from(written))),
let mut constants = vec![
(
if self.material_frame.is_some() {
"horizon_terrain_HDR_WRITE_LIGHT"
} else {
"HDR_WRITE_LIGHT"
},
f64::from(u8::from(written)),
),
("TEST_TIER", f64::from(tier)),
("TEST_OVERLAY", f64::from(overlay)),
];
if self.terrain {
constants.push((
if self.material_frame.is_some() {
"horizon_terrain_HDR_LIGHTING"
} else {
"HDR_LIGHTING"
},
f64::from(u8::from(self.hdr_lighting)),
));
}
if self.material_frame.is_some() {
constants.push((
"horizon_displace_distance",
if entry == "horizon_material_fs_displaced" {
10.0
} else {
0.0
},
));
}
let vertex = match entry {
"fs_sky" | "fs_sky_base" => "vs_sky",
"fs_sunrise" => "test_sunrise",
"fs_native_model" => "test_model",
"fs_native_fluid" => "test_fluid",
_ => "test_vertex",
};
let format = if !written && !self.raw {
F::Rgba16Unorm
} else {
F::Rgba16Float
};
let pipeline = |entry, mark| {
self.device
.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
@@ -455,7 +555,7 @@ impl<'a> Draw<'a> {
layout: Some(&self.layout),
vertex: wgpu::VertexState {
module: &self.shader,
entry_point: Some("test_vertex"),
entry_point: Some(vertex),
compilation_options: wgpu::PipelineCompilationOptions {
constants: &constants,
..Default::default()
@@ -473,12 +573,14 @@ impl<'a> Draw<'a> {
..Default::default()
},
targets: &[Some(wgpu::ColorTargetState {
format: F::Rgba16Float,
blend: Some(if mark || (self.terrain && entry != "fs_translucent") {
wgpu::BlendState::REPLACE
} else {
wgpu::BlendState::ALPHA_BLENDING
}),
format,
blend: Some(
if self.raw || mark || (self.terrain && entry != "fs_translucent") {
wgpu::BlendState::REPLACE
} else {
wgpu::BlendState::ALPHA_BLENDING
},
),
write_mask: if mark {
wgpu::ColorWrites::ALPHA
} else {
@@ -491,7 +593,7 @@ impl<'a> Draw<'a> {
})
};
let color = pipeline(entry, false);
let mark = (!self.terrain && !written && entry == "fs_entity").then(|| {
let mark = (!self.raw && !self.terrain && !written && entry == "fs_entity").then(|| {
pipeline(
if overlay == 4 {
"fs_hdr_cloud_mark"
@@ -501,7 +603,22 @@ impl<'a> Draw<'a> {
true,
)
});
let frame = hdr.frame(self.device, [1, 1]);
let frame = self.device.create_texture(&wgpu::TextureDescriptor {
label: None,
size: wgpu::Extent3d {
width: 1,
height: 1,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT
| wgpu::TextureUsages::TEXTURE_BINDING
| wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
let view = frame.create_view(&Default::default());
let mut encoder = self.device.create_command_encoder(&Default::default());
{
@@ -519,6 +636,9 @@ impl<'a> Draw<'a> {
..Default::default()
});
pass.set_bind_group(0, &self.group, &[]);
if let Some(frame) = &self.material_frame {
pass.set_bind_group(3, &frame.group, &[]);
}
pass.set_pipeline(&color);
pass.draw(0..3, 0..1);
if let Some(mark) = &mark {
@@ -526,20 +646,266 @@ impl<'a> Draw<'a> {
pass.draw(0..3, 0..1);
}
}
if !written {
hdr.begin(self.queue, true);
hdr.resolve(&mut encoder, None);
if !written && !self.raw {
let light = hdr.frame(self.device, [1, 1]);
legacy_light(self.device, &mut encoder, hdr, &frame, &light);
return texel(self.device, self.queue, encoder, &light);
}
texel(self.device, self.queue, encoder, &frame)
}
}
#[test]
fn actual_opaque_writers_match_marks_then_resolve() {
fn float_plain_fragments_keep_rgb_and_write_only_coverage() {
let Some((device, queue)) = gpu() else {
eprintln!("plain float GPU unavailable");
return;
};
let mut hdr = Hdr::with_debug(&device, &queue, F::Rgba16Float, Output::Pq, true, None);
let (settings, shares) = hdr.draw_params(true);
let mut cases = 0;
for hdr_on in [false, true] {
let globals = super::super::Globals {
inverse_view: glam::Mat4::IDENTITY.to_cols_array_2d(),
fog_color: [0.2, 0.3, 0.4, 1.0],
sky_color: [0.7, 0.8, 0.9, 1.0],
sunrise: [1.2, 0.7, 0.4, 0.6],
fog: [0.0, 100.0, 0.0, 100.0],
fog_ends: [100.0, 100.0, 0.0, 0.0],
misc: [0.0, 1.0, 0.0, 0.0],
filtering: [0.0, 1.0, 0.0, 0.0],
hdr_windows: hdr.windows(),
hdr_light: [0.8, 0.8, f32::from(u8::from(hdr_on)), 0.0],
hdr_settings: settings,
hdr_shares: shares,
..bytemuck::Zeroable::zeroed()
};
for terrain in [true, false] {
let mut draw = Draw::new(&device, &queue, terrain, &globals, 180);
draw.raw = true;
let coverage = 180.0 / 255.0;
let entries = if terrain {
vec![
("fs_opaque", 1.0),
("fs_solid", 1.0),
("fs_solid_mirror", 1.0),
("fs_translucent", coverage),
("fs_native_model", 1.0),
("fs_native_fluid", coverage),
("fs_sky", 1.0),
("fs_sky_base", 1.0),
("fs_sunrise", 0.3),
]
} else {
vec![
("fs_entity", coverage),
("fs_celestial", coverage),
("fs_sky_celestial", coverage),
]
};
for plain in [false, true] {
draw.hdr_lighting = !plain;
for &(entry, alpha) in &entries {
for tier in [0, 3, 4] {
for overlay in if terrain { vec![0, 1] } else { vec![0, 6] } {
let old = draw.render(&mut hdr, false, entry, tier, overlay, [0.0; 4]);
let float = draw.render(&mut hdr, true, entry, tier, overlay, [0.0; 4]);
assert_eq!(
&float[..3],
&old[..3],
"{hdr_on} {entry} {tier} {overlay} {plain}"
);
assert!(
(float[3] - alpha).abs() < 0.001,
"{entry}: {float:?}, expected alpha {alpha}"
);
assert!(
float[..3].iter().any(|&c| c > 0.0),
"empty fixture: {entry}"
);
cases += 1;
}
}
}
}
}
}
eprintln!("plain float GPU ran {cases} colour/coverage comparisons");
}
#[test]
fn float_stars_keep_the_original_rgb_with_brightness_as_coverage() {
let Some((device, queue)) = gpu() else {
return;
};
let mut hdr = Hdr::with_debug(&device, &queue, F::Rgba16Float, Output::Pq, true, None);
let (settings, shares) = hdr.draw_params(true);
// Frozen f030d9c80 fragment: the original written branch and its marked fallback.
let source = include_str!("../hdr_light.wgsl").to_owned()
+ include_str!("stars.wgsl")
+ r#"
@vertex fn fixture(@builtin(vertex_index) i: u32) -> @builtin(position) vec4<f32> {
return vec4(vec2(f32((i << 1u) & 2u), f32(i & 2u)) * 2.0 - 1.0, 0.0, 1.0);
}
@fragment fn reference() -> @location(0) vec4<f32> {
let b = stars.color.a;
if HDR_WRITE_LIGHT && stars.hdr_light.w != 0.0 {
return vec4(hdr_written(stars.color.rgb * b, 0.175, stars.hdr_settings, stars.hdr_shares), b);
}
return vec4(stars.color.rgb * b, 0.175);
}
"#;
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("actual stars with original RGB reference"),
source: wgpu::ShaderSource::Wgsl(source.into()),
});
for gain in [0.0, 1.0] {
let mut data = [0.0_f32; 32];
data[16..20].copy_from_slice(&[0.7, 0.8, 0.9, 0.35]);
data[20..24].copy_from_slice(&settings);
data[24..28].copy_from_slice(&shares);
data[31] = gain;
let uniform = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: None,
contents: bytemuck::cast_slice(&data),
usage: wgpu::BufferUsages::UNIFORM,
});
let mut draw = |entry| {
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: None,
layout: None,
vertex: wgpu::VertexState {
module: &shader,
entry_point: Some("fixture"),
compilation_options: Default::default(),
buffers: &[],
},
primitive: Default::default(),
depth_stencil: None,
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: Some(entry),
compilation_options: wgpu::PipelineCompilationOptions {
constants: &[("HDR_WRITE_LIGHT", 1.0)],
..Default::default()
},
targets: &[Some(wgpu::ColorTargetState {
format: F::Rgba16Float,
blend: None,
write_mask: wgpu::ColorWrites::ALL,
})],
}),
multiview_mask: None,
cache: None,
});
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: None,
layout: &pipeline.get_bind_group_layout(0),
entries: &[wgpu::BindGroupEntry {
binding: 0,
resource: uniform.as_entire_binding(),
}],
});
let frame = hdr.frame(&device, [1, 1]);
let view = frame.create_view(&Default::default());
let mut encoder = device.create_command_encoder(&Default::default());
{
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &view,
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
store: wgpu::StoreOp::Store,
},
})],
..Default::default()
});
pass.set_pipeline(&pipeline);
pass.set_bind_group(0, &group, &[]);
pass.draw(0..3, 0..1);
}
texel(&device, &queue, encoder, &frame)
};
let old = draw("reference");
let float = draw("fs_stars");
assert_eq!(&float[..3], &old[..3]);
assert!((float[3] - 0.35).abs() < 0.001, "{float:?}");
}
}
#[test]
fn float_material_wrappers_keep_rgb_and_coverage_including_displacement() {
let instance = super::super::test_gpu::instance();
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
return;
};
// The real material layout binds the terrain arrays and the pack's frame together.
if adapter.limits().max_sampled_textures_per_shader_stage < 19 {
return;
}
let Ok((device, queue)) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
required_limits: wgpu::Limits {
max_sampled_textures_per_shader_stage: 19,
..Default::default()
},
..Default::default()
})) else {
return;
};
let mut hdr = Hdr::with_debug(&device, &queue, F::Rgba16Float, Output::Pq, true, None);
let globals = super::super::Globals {
filtering: [0.0, 1.0, 0.0, 0.0],
hdr_windows: hdr.windows(),
..bytemuck::Zeroable::zeroed()
};
for api in [horizon_shader_api::Api::V0_1, horizon_shader_api::Api::V0_2] {
let mut draw = Draw::new(&device, &queue, true, &globals, 255);
draw.material(api);
for entry in [
"horizon_material_fs_solid",
"horizon_material_fs_displaced",
"horizon_material_fs_translucent",
] {
let old = draw.render(&mut hdr, false, entry, 3, 0, [0.0; 4]);
let float = draw.render(&mut hdr, true, entry, 3, 0, [0.0; 4]);
assert_eq!(&float[..3], &old[..3], "{api} {entry}");
let alpha = if entry.ends_with("translucent") {
0.7
} else {
1.0
};
assert!((float[3] - alpha).abs() < 0.001, "{api} {entry}: {float:?}");
assert!(
float[0] > 1.0,
"{api} {entry}: clipping or an empty fixture: {float:?}"
);
if alpha == 1.0 {
assert_ne!(
old[3], float[3],
"the legacy source mark must distinguish the paths"
);
}
}
}
eprintln!("material GPU ran both API lines through solid, displaced and translucent wrappers");
}
#[test]
fn actual_opaque_writers_match_the_legacy_non_float_oracle() {
let Some((device, queue)) = gpu() else {
eprintln!("HDR draw GPU unavailable");
return;
};
if !device.features().contains(
wgpu::Features::TEXTURE_FORMAT_16BIT_NORM
| wgpu::Features::TEXTURE_ADAPTER_SPECIFIC_FORMAT_FEATURES,
) {
eprintln!("opaque oracle GPU skipped: 16-bit UNORM unavailable");
return;
}
let mut hdr = Hdr::with_debug(&device, &queue, F::Rgba16Float, Output::Pq, true, None);
(hdr.paper_white, hdr.peak) = (203.0, 1000.0);
let (settings, shares) = hdr.draw_params(true);
@@ -576,8 +942,8 @@ fn actual_opaque_writers_match_marks_then_resolve() {
draw.render(&mut hdr, false, entry, tier, overlay, [0.0; 4]);
let written =
draw.render(&mut hdr, true, entry, tier, overlay, [0.0; 4]);
// The marked path stores half floats before conversion; the written
// one stores after it. Allow two half-float ULPs in gamma light.
// The oracle stores UNORM before conversion; the direct writer
// stores half floats after it. Allow two half-float ULPs in gamma light.
for i in 0..3 {
assert!(
(marked[i] - written[i]).abs() <= 0.002 * marked[i].max(1.0),
@@ -630,6 +996,10 @@ fn actual_translucent_writers_blend_their_own_light() {
let dst = [2.5, 1.8, 0.9, 1.0];
let got = draw.render(&mut hdr, true, entry, tier, overlay, dst);
let alpha = f32::from(texel_alpha) / 255.0 * tint_alpha;
assert!(
(src[3] - alpha).abs() < 0.001,
"{entry}: {src:?}, coverage {alpha}"
);
if terrain {
// Full block light leaves mark 194/255 whatever the texel's alpha, even when
// vertex coverage is halved.
@@ -657,6 +1027,13 @@ fn unmarked_entity_users_keep_their_source_gain() {
let Some((device, queue)) = gpu() else {
return;
};
if !device.features().contains(
wgpu::Features::TEXTURE_FORMAT_16BIT_NORM
| wgpu::Features::TEXTURE_ADAPTER_SPECIFIC_FORMAT_FEATURES,
) {
eprintln!("legacy oracle GPU skipped: 16-bit UNORM unavailable");
return;
}
let mut hdr = Hdr::with_debug(&device, &queue, F::Rgba16Float, Output::Pq, true, None);
let (settings, shares) = hdr.draw_params(true);
let globals = super::super::Globals {
@@ -694,6 +1071,13 @@ fn additive_texels_below_the_mark_cutoff_keep_their_gain() {
let Some((device, queue)) = gpu() else {
return;
};
if !device.features().contains(
wgpu::Features::TEXTURE_FORMAT_16BIT_NORM
| wgpu::Features::TEXTURE_ADAPTER_SPECIFIC_FORMAT_FEATURES,
) {
eprintln!("legacy oracle GPU skipped: 16-bit UNORM unavailable");
return;
}
let mut hdr = Hdr::with_debug(&device, &queue, F::Rgba16Float, Output::Pq, true, None);
let (settings, shares) = hdr.draw_params(true);
let globals = super::super::Globals {
@@ -3,17 +3,11 @@
pub(crate) const FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::R8Unorm;
pub(crate) fn supported(
lane: (bool, bool, bool),
hdr_offered: bool,
attachments: u32,
flags: wgpu::DownlevelFlags,
format: wgpu::TextureFormatFeatures,
) -> bool {
!lane.0
&& lane.1
&& lane.2
&& hdr_offered
&& attachments >= 2
attachments >= 2
&& flags.contains(wgpu::DownlevelFlags::INDEPENDENT_BLEND)
&& format
.allowed_usages
@@ -345,37 +339,17 @@ mod tests {
use super::*;
#[test]
fn coverage_requires_the_no_pack_float_hdr_lane_and_independent_r8_blending() {
fn coverage_requires_two_attachments_and_independent_r8_blending() {
let flags = wgpu::DownlevelFlags::INDEPENDENT_BLEND;
let format = wgpu::TextureFormatFeatures {
allowed_usages: wgpu::TextureUsages::RENDER_ATTACHMENT
| wgpu::TextureUsages::TEXTURE_BINDING,
flags: wgpu::TextureFormatFeatureFlags::BLENDABLE,
};
let lane = (false, true, true);
assert!(supported(lane, true, 2, flags, format));
// Switch unset (including legacy 16-bit HDR and 0.1 packs), HDR off, a 0.2
// pack's float frame, and unsupported devices never allocate coverage.
for other in [
(false, false, true),
(true, false, true),
(true, true, true),
(false, true, false),
] {
assert!(!supported(other, true, 2, flags, format));
}
assert!(!supported(lane, false, 2, flags, format));
assert!(!supported(lane, true, 1, flags, format));
assert!(supported(2, flags, format));
assert!(!supported(1, flags, format));
assert!(!supported(2, wgpu::DownlevelFlags::empty(), format));
assert!(!supported(
lane,
true,
2,
wgpu::DownlevelFlags::empty(),
format
));
assert!(!supported(
lane,
true,
2,
flags,
wgpu::TextureFormatFeatures {
@@ -384,8 +358,6 @@ mod tests {
}
));
assert!(!supported(
lane,
true,
2,
flags,
wgpu::TextureFormatFeatures {
@@ -393,6 +365,14 @@ mod tests {
..format
}
));
assert!(!supported(
2,
flags,
wgpu::TextureFormatFeatures {
allowed_usages: wgpu::TextureUsages::RENDER_ATTACHMENT,
..format
}
));
}
#[test]
+21 -15
View File
@@ -50,8 +50,10 @@ impl Pending {
}
}
/// Small, its meshes boxed: `draw_frame` walks every tile each frame (thousands at long far
/// distances), and that walk is bound by the memory it reads.
pub(super) struct Tile {
pub mesh: SectionGpu,
pub mesh: Box<SectionGpu>,
bounds: Bounds,
/// When it began to fade in; none while the rest of its job uploads.
shown: Option<Instant>,
@@ -62,7 +64,7 @@ pub(super) struct Tile {
/// Its mesh before near chunks took some of its columns, drawn in its
/// place until they have faded in (from the job's end, the instant), so
/// no hole shows through them.
previous: Option<(SectionGpu, Option<Instant>)>,
previous: Option<Box<(SectionGpu, Option<Instant>)>>,
/// Its slot in occlusion culling's results (`cull::TILE`), kept while
/// meshes of the same tile replace one another.
pub cull_slot: u32,
@@ -155,10 +157,12 @@ pub(super) struct Visible {
impl Tile {
/// The mesh drawn this frame: the previous one while it stands in.
pub fn drawn(&self) -> &SectionGpu {
self.previous.as_ref().map_or(&self.mesh, |(mesh, _)| mesh)
self.previous
.as_deref()
.map_or(&self.mesh, |(mesh, _)| mesh)
}
pub fn drawn_mut(&mut self) -> &mut SectionGpu {
match &mut self.previous {
match self.previous.as_deref_mut() {
Some((mesh, _)) => mesh,
None => &mut self.mesh,
}
@@ -177,7 +181,7 @@ impl Tile {
}
/// Both meshes it holds in the arena: its own and the stand-in, if any.
pub fn meshes_mut(&mut self) -> impl Iterator<Item = &mut SectionGpu> {
std::iter::once(&mut self.mesh).chain(self.previous.as_mut().map(|(mesh, _)| mesh))
std::iter::once(&mut *self.mesh).chain(self.previous.as_deref_mut().map(|(mesh, _)| mesh))
}
pub fn visible(
&self,
@@ -214,12 +218,12 @@ impl Renderer {
let (shown, previous) = match self.lods.remove(&key) {
Some(old) if old.dying.is_none() => match old.previous {
Some(previous) => {
self.release_mesh(old.mesh);
self.release_mesh(*old.mesh);
(old.shown, Some(previous))
}
None if keep => (old.shown, Some((old.mesh, None))),
None if keep => (old.shown, Some(Box::new((*old.mesh, None)))),
None => {
self.release_mesh(old.mesh);
self.release_mesh(*old.mesh);
(old.shown, None)
}
},
@@ -240,7 +244,7 @@ impl Renderer {
self.lods.insert(
key,
Tile {
mesh,
mesh: Box::new(mesh),
bounds,
shown,
dying: None,
@@ -251,7 +255,7 @@ impl Renderer {
);
}
None => {
if let Some((previous, _)) = previous {
if let Some((previous, _)) = previous.map(|p| *p) {
self.release_mesh(previous);
}
self.lod_cull_free.extend(old_slot);
@@ -260,8 +264,8 @@ impl Renderer {
}
fn release_tile(&mut self, tile: Tile) {
self.release_mesh(tile.mesh);
if let Some((previous, _)) = tile.previous {
self.release_mesh(*tile.mesh);
if let Some((previous, _)) = tile.previous.map(|p| *p) {
self.release_mesh(previous);
}
}
@@ -337,8 +341,10 @@ impl Renderer {
let stood_in: Vec<_> = self
.lods
.values_mut()
.filter_map(|tile| match tile.previous {
Some((_, Some(since))) if start - since >= FADE => tile.previous.take(),
.filter_map(|tile| match tile.previous.as_deref() {
Some((_, Some(since))) if start - *since >= FADE => {
tile.previous.take().map(|p| *p)
}
_ => None,
})
.collect();
@@ -440,7 +446,7 @@ impl Renderer {
if tile.shown.is_none() {
tile.shown = Some(if over_replaced(key) { start } else { whole });
}
if let Some((_, since)) = &mut tile.previous {
if let Some((_, since)) = tile.previous.as_deref_mut() {
since.get_or_insert(start);
}
}
@@ -195,6 +195,7 @@ struct HorizonMaterialSolid {
// What `fs_solid` leaves in the frame's alpha for HDR output, from the sprite before the pack's
// material: the pack decides the colour, Horizon still knows which pixels are light sources.
fn horizon_material_mark(in: HorizonMaterialOut, texel: vec4<f32>) -> f32 {
if horizon_terrain_HDR_WRITE_LIGHT { return 1.0; }
let code = in.hdr_code & 15u;
if code > 0u {
let whole = code > 4u;
@@ -432,6 +433,10 @@ impl NativeMaterial {
];
constants.extend(sprite_names.into_iter().zip(shape.sprites));
constants.push(("horizon_material_fog", f64::from(u8::from(fog))));
constants.push((
"horizon_terrain_HDR_WRITE_LIGHT",
f64::from(u8::from(shape.format == super::LINEAR_FRAME)),
));
constants.push((
"horizon_displace_distance",
displaced.map_or(0.0, |d| f64::from(d.distance)),
@@ -580,6 +585,39 @@ impl NativeMaterial {
}
}
#[cfg(test)]
pub(super) fn coverage_test_shader(api: horizon_shader_api::Api) -> String {
use horizon_shader_api::{MANIFEST, Pack, StageKind, check};
let manifest = format!(
"schema = 1\nid = \"coverage\"\nname = \"Coverage\"\nversion = \"1\"\napi = \"{api}\"\n[material]\nfile = \"material.wgsl\"\n[displacement]\n"
);
let material = "fn material(input: MaterialInput) -> Surface { return Surface(vec4<f32>(1.4, 0.3, 0.1, 0.7)); }\nfn displacement(input: DisplacementInput) -> f32 { return 0.0; }\n";
let files = [(MANIFEST, manifest.as_str()), ("material.wgsl", material)]
.map(|(n, text)| (n.to_owned(), text.as_bytes().to_vec()))
.into();
let report = check(&Pack::from_files(files).unwrap()).unwrap();
let stage = report
.stages
.iter()
.find(|s| s.kind == StageKind::Material)
.unwrap();
shader_source(&stage.source, &[], &[], (false, true), api)
+ r#"
override TEST_TIER: u32 = 0u;
override TEST_OVERLAY: u32 = 0u;
@vertex fn test_vertex(@builtin(vertex_index) i: u32) -> HorizonMaterialOut {
var o: HorizonMaterialOut;
o.clip = vec4(vec2(f32((i << 1u) & 2u), f32(i & 2u)) * 2.0 - 1.0, 0.0, 1.0);
o.uv = vec2(0.5);
o.tint = vec4(1.0);
o.lightmap = vec3(1.0);
o.hdr_code = TEST_TIER;
o.base = vec3(1.0);
return o;
}
"#
}
#[cfg(test)]
mod tests {
use super::*;
@@ -2464,6 +2464,12 @@ impl PackPost {
let textures = pack_textures::upload(device, queue, post.textures, &mut notes);
let shadow_config = post.config.shadow.clone();
let noise_size = post.config.noise_resolution.unwrap_or(256);
let half_lives = post.config.half_lives;
for (name, ticks) in horizon_shaders::HALF_LIVES.iter().zip(half_lives) {
if let Some(t) = ticks {
notes.push(format!("`{name}` is {t} ticks"));
}
}
// shadowcolor2 to 7 only for a pack that names `HIGHER_SHADOWCOLOR`.
let shadow_colors = (1u16 << post.shadow_colors) - 1;
let mut custom = post.custom;
@@ -3443,6 +3449,7 @@ impl PackPost {
let mut u = FrameUniforms::default();
u.atlas = world.atlas.map_or([0; 2], |a| a.1);
u.shadow = shadow_config;
u.half_lives = half_lives;
u
},
shadow_cache: Default::default(),
@@ -405,6 +405,8 @@ pub struct FrameUniforms {
center_depth: Option<f32>,
/// The pack's shadow settings (7.1).
pub shadow: Shadow,
/// The pack's smoothing half-lives in ticks (`horizon_shaders::HALF_LIVES`).
pub half_lives: [Option<f32>; 4],
}
impl Default for FrameUniforms {
@@ -422,6 +424,7 @@ impl Default for FrameUniforms {
center_depth_raw: None,
center_depth: None,
shadow: Shadow::default(),
half_lives: [None; 4],
}
}
}
@@ -734,6 +737,7 @@ impl FrameUniforms {
*self = Self {
atlas: self.atlas,
shadow: std::mem::take(&mut self.shadow),
half_lives: self.half_lives,
..Self::default()
};
}
@@ -798,10 +802,13 @@ impl FrameUniforms {
// a tenth of a second a tick: Iris 1.11.7 smooths that slowly (measured frame by frame
// with a probe pack: wetness 60 s rising, 20 s falling; eyeBrightnessSmooth 1 s;
// centerDepthSmooth 0.1 s).
// The pack's `wetnessHalflife`/`drynessHalflife` when it sets them (OptiFine's defaults
// otherwise), as for `eyeBrightnessHalflife` and `centerDepthHalflife` below.
let [wet, dry, eye_half, depth_half] = self.half_lives;
let half_life = if inputs.rain > self.wetness {
600.0
wet.unwrap_or(600.0)
} else {
200.0
dry.unwrap_or(200.0)
};
self.wetness = smooth(self.wetness, inputs.rain, half_life * SECONDS_A_TICK, dt);
let eye = inputs.eye_light.map(|l| (l * 16) as f32);
@@ -810,13 +817,18 @@ impl FrameUniforms {
self.eye_smooth = eye;
}
for (smoothed, target) in self.eye_smooth.iter_mut().zip(eye) {
*smoothed = smooth(*smoothed, target, 10.0 * SECONDS_A_TICK, dt);
*smoothed = smooth(
*smoothed,
target,
eye_half.unwrap_or(10.0) * SECONDS_A_TICK,
dt,
);
}
// `centerDepthHalflife`'s default, 1 tick (OptiFine's shaders.txt); the first value
// read back is taken as it is.
if let Some(raw) = self.center_depth_raw {
self.center_depth = Some(match self.center_depth {
Some(v) => smooth(v, raw, SECONDS_A_TICK, dt),
Some(v) => smooth(v, raw, depth_half.unwrap_or(1.0) * SECONDS_A_TICK, dt),
None => raw,
});
}
@@ -1260,10 +1272,12 @@ mod tests {
format!(r#"{{"attributes": {attributes}}}"#),
)
.unwrap();
let environment =
crate::environment::Environment::load_dimension(&root, name, false).unwrap();
let environment = crate::environment::Environment::load_dimension(&root, name).unwrap();
std::fs::remove_dir_all(&root).unwrap();
environment.sample(6000, None, None)
environment.sample(&crate::environment::Sample {
day_time: 6000,
..Default::default()
})
}
#[test]
@@ -2218,22 +2232,33 @@ pub(super) mod initial_history_tests {
let hud = HudBatch::new([800, 450]);
let f = frame(&hud);
let mut u = FrameUniforms::default();
// The next frame comes `seconds` after the last (and a few microseconds of the test).
let after = |u: &mut FrameUniforms, seconds: f32| {
u.last = Some(std::time::Instant::now() - std::time::Duration::from_secs_f32(seconds));
// A scripted clock: the next frame comes exactly `seconds` after the last, however long
// the test itself takes (a wall-clock gap of a millisecond moved centerDepthSmooth by
// more than the tolerance on a busy CI runner).
let mut counter = 1;
let mut elapsed = 0.0;
let mut after = |input: &mut PackInputs, seconds: f32| {
counter += 1;
elapsed += seconds;
input.frame_timing = Some(FrameTiming {
counter,
elapsed,
delta: seconds,
});
};
let mut input = PackInputs {
eye_light: [0, 15],
frame_timing: Some(FrameTiming::nth(1, 0.0)),
..Default::default()
};
u.center_depth_raw = Some(1.0);
u.update(&f, &input, [800, 450]);
input.eye_light = [0, 0];
u.center_depth_raw = Some(0.0);
after(&mut u, 0.1);
after(&mut input, 0.1);
u.update(&f, &input, [800, 450]);
assert!((float(&u, "centerDepthSmooth")[0] - 0.5).abs() < 1e-3);
after(&mut u, 0.6);
after(&mut input, 0.6);
u.update(&f, &input, [800, 450]);
// 240 falling toward 0 for 0.7 s: 147.8, read as 147.
match u.values[index("eyeBrightnessSmooth").unwrap()] {
@@ -2241,11 +2266,46 @@ pub(super) mod initial_history_tests {
_ => panic!("integer uniform expected"),
}
input.rain = 1.0;
after(&mut u, 60.0);
after(&mut input, 60.0);
u.update(&f, &input, [800, 450]);
assert!((float(&u, "wetness")[0] - 0.5).abs() < 1e-3);
input.rain = 0.0;
after(&mut u, 20.0);
after(&mut input, 20.0);
u.update(&f, &input, [800, 450]);
assert!((float(&u, "wetness")[0] - 0.25).abs() < 1e-3);
}
/// A pack's `const float wetnessHalflife = 30.0;` (and its dryness) replace the defaults:
/// wetness reaches half the rain in 3 s instead of 60 s.
#[test]
fn a_pack_s_half_lives_replace_the_defaults() {
let hud = HudBatch::new([800, 450]);
let f = frame(&hud);
let mut u = FrameUniforms {
half_lives: [Some(30.0), Some(10.0), None, None],
..Default::default()
};
// A scripted clock, as in `smoothed_uniforms_take_iris_half_lives`: the wall clock's gap
// between two updates is not exact on a busy runner.
let mut input = PackInputs {
frame_timing: Some(FrameTiming::nth(1, 0.0)),
..Default::default()
};
u.update(&f, &input, [800, 450]);
input.rain = 1.0;
input.frame_timing = Some(FrameTiming {
counter: 2,
elapsed: 3.0,
delta: 3.0,
});
u.update(&f, &input, [800, 450]);
assert!((float(&u, "wetness")[0] - 0.5).abs() < 1e-3);
input.rain = 0.0;
input.frame_timing = Some(FrameTiming {
counter: 3,
elapsed: 4.0,
delta: 1.0,
});
u.update(&f, &input, [800, 450]);
assert!((float(&u, "wetness")[0] - 0.25).abs() < 1e-3);
}
@@ -274,7 +274,13 @@ impl Panorama {
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: Some("fs_panorama"),
compilation_options: Default::default(),
compilation_options: wgpu::PipelineCompilationOptions {
constants: &[(
"HDR_WRITE_LIGHT",
f64::from(u8::from(format == super::LINEAR_FRAME)),
)],
..Default::default()
},
targets: &[Some(wgpu::ColorTargetState {
format,
blend: None,
@@ -1,4 +1,5 @@
// CubeMap's position-only cube and panorama.vsh/fsh: sample its untransformed direction.
override HDR_WRITE_LIGHT: bool = false;
struct Projection { view_proj: mat4x4<f32> }
@group(0) @binding(0) var<uniform> projection: Projection;
@group(0) @binding(1) var panorama: texture_cube<f32>;
@@ -26,5 +27,6 @@ struct Out {
return out;
}
@fragment fn fs_panorama(in: Out) -> @location(0) vec4<f32> {
return textureSample(panorama, panorama_sampler, in.direction);
let color = textureSample(panorama, panorama_sampler, in.direction);
return vec4(color.rgb, select(color.a, 1.0, HDR_WRITE_LIGHT));
}
@@ -439,17 +439,18 @@ impl<T> Resident<T> {
if list.is_empty() {
continue;
}
let min = origin((column.x, column.low, column.z), self.min_y, camera).as_vec3();
let top = origin((column.x, column.high, column.z), self.min_y, camera).as_vec3();
let max = top + Vec3::splat(16.0);
let inside = frustum.contains(min, max);
if !inside && !frustum.intersects(min, max) {
continue;
}
let ox = f64::from(column.x * 16) - camera.x;
let oz = f64::from(column.z * 16) - camera.z;
let min_x = ox as f32;
let min_z = oz as f32;
let low_oy = (f64::from(self.min_y + column.low * 16) - camera.y) as f32;
let high_oy = (f64::from(self.min_y + column.high * 16) - camera.y) as f32;
let min = Vec3::new(min_x, low_oy, min_z);
let max = Vec3::new(min_x + 16.0, high_oy + 16.0, min_z + 16.0);
let inside = frustum.contains(min, max);
if !inside && !frustum.intersects(min, max) {
continue;
}
let dx8 = ox + 8.0;
let dz8 = oz + 8.0;
let xz_sq = dx8 * dx8 + dz8 * dz8;
@@ -22,5 +22,6 @@ struct Stars {
if HDR_WRITE_LIGHT && stars.hdr_light.w != 0.0 {
return vec4(hdr_written(stars.color.rgb * b, 0.175, stars.hdr_settings, stars.hdr_shares), b);
}
if HDR_WRITE_LIGHT { return vec4(stars.color.rgb * b, b); }
return vec4(stars.color.rgb * b, 0.175);
}
@@ -79,7 +79,7 @@ pub struct Post {
/// The last TAA frame's unjittered `view_proj` and camera, `None` once a frame ran without it.
taa_previous: Option<(DMat4, DVec3)>,
taa_this_frame: TaaUniform,
light_key: Option<([f32; 4], [f32; 4], [[f32; 4]; 2])>,
light_key: Option<(bool, [f32; 4], [f32; 4], [[f32; 4]; 2])>,
}
impl Post {
@@ -263,12 +263,21 @@ impl Post {
}
}
// History stores the baked gain as well as the picture. Reject it before jitter records
// the next frame whenever that gain or the frame's representation changes.
pub(super) fn light_representation(
// History stores baked light, so a change must reject it before jitter records the next frame.
pub(super) fn baked_light(
&mut self,
key: Option<([f32; 4], [f32; 4], [[f32; 4]; 2])>,
gain: bool,
settings: [f32; 4],
shares: [f32; 4],
windows: [[f32; 4]; 2],
) {
// These uniforms do not change baked colour without gain. Keep compatibility
// history stable when only its final display settings change.
let key = Some(if gain {
(true, settings, shares, windows)
} else {
(false, [0.0; 4], [0.0; 4], [[0.0; 4]; 2])
});
if self.light_key != key {
self.taa_previous = None;
self.light_key = key;
@@ -745,13 +754,15 @@ mod tests {
}
#[test]
fn written_light_changes_reject_history_before_the_next_jitter() {
fn baked_light_changes_and_returning_from_a_pack_reject_taa_history() {
let Some((device, _)) = test_device() else {
eprintln!("TAA baked-light GPU skipped: no test device");
return;
};
let mut post = Post::new(&device, wgpu::TextureFormat::Rgba16Float);
post.taa = true;
if !post.supports_taa() {
eprintln!("TAA baked-light GPU skipped: TAA unsupported");
return;
}
let key = (
@@ -759,11 +770,11 @@ mod tests {
[0.2, 0.5, 0.6, 1.0],
[[0.1; 4], [0.4; 4]],
);
for key in [Some(key), None, Some(key)] {
post.light_representation(key);
for gain in [true, false, true] {
post.baked_light(gain, key.0, key.1, key.2);
post.jitter([32, 32], true, Mat4::IDENTITY, DVec3::ZERO);
assert_eq!(post.taa_this_frame.jitter[2], 1.0);
post.light_representation(key);
post.baked_light(gain, key.0, key.1, key.2);
post.jitter([32, 32], true, Mat4::IDENTITY, DVec3::ZERO);
assert_eq!(post.taa_this_frame.jitter[2], 0.0);
}
@@ -774,10 +785,26 @@ mod tests {
4..=7 => changed.1[field - 4] += 0.01,
_ => changed.2[(field - 8) / 4][(field - 8) % 4] += 0.01,
}
post.light_representation(Some(changed));
post.baked_light(true, changed.0, changed.1, changed.2);
post.jitter([32, 32], true, Mat4::IDENTITY, DVec3::ZERO);
assert_eq!(post.taa_this_frame.jitter[2], 1.0);
}
post.baked_light(false, key.0, key.1, key.2);
post.jitter([32, 32], true, Mat4::IDENTITY, DVec3::ZERO);
assert_eq!(post.taa_this_frame.jitter[2], 1.0);
post.baked_light(false, [0.0; 4], [0.0; 4], [[0.0; 4]; 2]);
post.jitter([32, 32], true, Mat4::IDENTITY, DVec3::ZERO);
assert_eq!(post.taa_this_frame.jitter[2], 0.0);
// A pack disables TAA even when the light settings stay the same. Its first
// world frame after the pack is dropped must not reuse the pre-pack history.
post.jitter([32, 32], false, Mat4::IDENTITY, DVec3::ZERO);
post.jitter([32, 32], true, Mat4::IDENTITY, DVec3::ZERO);
assert_eq!(post.taa_this_frame.jitter[2], 1.0);
post.jitter([32, 32], true, Mat4::IDENTITY, DVec3::ZERO);
assert_eq!(post.taa_this_frame.jitter[2], 0.0);
eprintln!(
"TAA baked-light GPU checked gain, all settings/shares/windows and the first frame after a pack"
);
}
fn test_device() -> Option<(wgpu::Device, wgpu::Queue)> {
@@ -2394,6 +2394,19 @@ mod tests {
assert_eq!(s.horizon.entity_culling, EntityCullingMode::Off);
}
#[test]
fn left_and_right_step_a_focused_choice_back_and_on() {
let mut s = Settings::default();
let mut ui = ui();
row(&mut ui, &s, Item::Horizon(H::EntityCulling));
focus_on(&mut ui, Item::Horizon(H::EntityCulling));
assert_eq!(key(&mut ui, &mut s, K::ArrowLeft), [CLICK, CHANGED]);
assert_eq!(s.horizon.entity_culling, EntityCullingMode::Asynchronous);
assert_eq!(key(&mut ui, &mut s, K::ArrowRight), [CLICK, CHANGED]);
assert_eq!(s.horizon.entity_culling, EntityCullingMode::Off);
assert_eq!(ui.focused_row(), Some(Item::Horizon(H::EntityCulling)));
}
#[test]
fn entity_culling_precheck_slider_is_only_live_for_async() {
let item = Item::Horizon(H::EntityCullingPrecheck);
+1 -11
View File
@@ -34,7 +34,7 @@ struct Params {
// Horizon's logo, 1 inside, 0 outside, for calibration's test pictures.
@group(0) @binding(3) var logo: texture_2d<f32>;
// The float frame with HUD coverage (Hdr::layered): the frame keeps the world as light
// (fs_resolve), the HUD draws on a picture of its own (`hud`, fs_keep), and `coverage` says how
// directly from its writers; the HUD draws on a picture of its own (`hud`, fs_keep), and `coverage` says how
// much of the world it hides there (gfx/hud_coverage.rs).
@group(0) @binding(4) var coverage: texture_2d<f32>;
@group(0) @binding(5) var hud: texture_2d<f32>;
@@ -211,16 +211,6 @@ fn display(light: vec3<f32>) -> vec4<f32> {
return display(light);
}
// Hdr::resolve, after the world and the hand: the marks they left (`world` holds the frame as
// they drew it) become light in the frame, in gamma as the frame stores it, alpha 1, past the
// peak if they lift it there (the tone curve eases it in). What draws after it (camera overlays,
// the menu blur) draws on light, and nothing is lifted later.
@fragment fn fs_resolve(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> {
let w = frame_texel(textureLoad(world, vec2<i32>(pos.xy), 0));
let shows = params.world > 0.5 && params.peak > 1.0;
return vec4(pow(lit(w.rgb, w.a, shows, false).light, vec3(1.0 / 2.2)), 1.0);
}
// The tone curve's knee: up to it light stays as it is. Where HDR lighting eased into the peak
// before the curve (`lit`), 60% of it, so bright textures keep their detail; at least paper white.
fn tone_knee(headroom: f32) -> f32 {
@@ -12,8 +12,6 @@ impl Gpu {
let instance = crate::gfx::test_gpu::instance();
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).ok()?;
if !hud_coverage::supported(
(false, true, true),
true,
adapter.limits().max_color_attachments,
adapter.get_downlevel_capabilities().flags,
adapter.get_texture_format_features(hud_coverage::FORMAT),
@@ -0,0 +1,11 @@
//! The HUD kit: small immediate-mode pieces Horizon's own overlays share, so they fit around
//! vanilla's HUD and look alike (`docs/ui-kit.md`). Nothing here reaches gameplay; the game
//! builds the inputs (`Game::hud_room`) and the overlays draw with them.
//!
//! - `room`: what of a view an overlay may use, clear of the boss bars.
//! - `plate`: the box behind a notice's text.
//!
//! Vanilla's own display lists (`hud_layout`, `screen_draw`) never use the kit: they stay
//! exactly vanilla's.
pub mod plate;
pub mod room;
@@ -0,0 +1,64 @@
//! Plates: the boxes Horizon's HUD overlays draw behind their text, one place for each look so
//! overlays that mean the same thing look the same.
//!
//! A plate is drawn first, then the overlay's text and glyphs over it. Only the kinds the
//! overlays use today are here; each keeps the colours its overlay always had. Today that is
//! one, shared by the controller notice and splitscreen's prompts.
//!
//! ```
//! use horizon_client_render::hud_kit::plate::Plate;
//!
//! // A notice's box: a dark fill and a faint 1 px edge inside its area.
//! assert_eq!(Plate::Notice.fill(), 0xD200_0000);
//! assert_eq!(Plate::Notice.edge(), Some(0x5AFF_FFFF));
//! ```
//!
//! In a draw: `plate::draw(batch, atlas, Plate::Notice, [x, y, w, h])`, the area in GUI px.
use crate::hud::{HudAtlas, HudBatch, rgba};
/// A plate's look.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Plate {
/// Something to read now: controller notices and splitscreen's prompts. A dark fill with a
/// faint edge.
Notice,
}
impl Plate {
/// The fill colour as `0xAARRGGBB`.
pub fn fill(self) -> u32 {
match self {
Self::Notice => 0xD200_0000,
}
}
/// The 1 GUI px edge's colour as `0xAARRGGBB`, drawn inside the area; `None` for none.
pub fn edge(self) -> Option<u32> {
match self {
Self::Notice => Some(0x5AFF_FFFF),
}
}
}
/// `[a, r, g, b]` of `0xAARRGGBB` as the batch's colour.
fn colour(argb: u32) -> u32 {
let [a, r, g, b] = argb.to_be_bytes();
rgba(r, g, b, a)
}
/// Draws `plate` over `[x, y, w, h]` (GUI px): its fill, then its edge inside the area.
pub fn draw(batch: &mut HudBatch, atlas: &HudAtlas, plate: Plate, [x, y, w, h]: [f32; 4]) {
batch.fill(atlas, x, y, w, h, colour(plate.fill()));
if let Some(edge) = plate.edge() {
let edge = colour(edge);
for [ex, ey, ew, eh] in [
[x, y, w, 1.0],
[x, y + h - 1.0, w, 1.0],
[x, y, 1.0, h],
[x + w - 1.0, y, 1.0, h],
] {
batch.fill(atlas, ex, ey, ew, eh, edge);
}
}
}
@@ -0,0 +1,137 @@
//! Room: what of a view Horizon's own HUD overlays may use. Vanilla's HUD elements are
//! keep-outs; an overlay asks the room where it fits instead of assuming a fixed spot, so it
//! doesn't cover them. Today the only keep-out is the boss bars at the top centre.
//!
//! The room is built once a frame from what the HUD shows (`Game::hud_room` in gameplay). With
//! nothing showing it is empty, and every overlay stays exactly where it always was.
//!
//! All sizes are GUI px, from the view's top left. An area is `[x, y, w, h]`.
//!
//! ```
//! use horizon_client_render::hud_kit::room::{Keep, Room};
//! use horizon_client_render::hud_overlays::boss_bars_bottom;
//!
//! // A centred notice wants y = 18.
//! let empty = Room::new(320.0, 240.0);
//! assert_eq!(empty.below_top(18.0), 18.0);
//! // One boss bar ends at y 17: no overlap, so the notice stays.
//! let one = Room::new(320.0, 240.0).with(Keep::BossBars, [69.0, 0.0, 182.0, 17.0]);
//! assert_eq!(one.below_top(18.0), 18.0);
//! // Two end at y 36: the notice goes below them, a gap past.
//! let bars = boss_bars_bottom(240, 2) as f32;
//! let two = Room::new(320.0, 240.0).with(Keep::BossBars, [69.0, 0.0, 182.0, bars]);
//! assert_eq!(two.below_top(18.0), bars + 2.0);
//! ```
/// An area in GUI px: `[x, y, w, h]` from the view's top left.
pub type Area = [f32; 4];
/// The gap, in GUI px, an overlay leaves below a keep-out it moved past.
pub const GAP: f32 = 2.0;
/// A HUD element Horizon's overlays keep clear of.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Keep {
/// The boss bars and their names at the top centre (`hud_overlays::boss_bars_bottom`).
BossBars,
}
/// A view and the keep-outs its HUD shows this frame.
///
/// Invariants: the view's size is what the overlays draw in (the `HudBatch`'s size), and an
/// empty keep-out (no width or height) is never stored, so a room built from hidden elements
/// equals `Room::new`.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Room {
width: f32,
height: f32,
keeps: Vec<(Keep, Area)>,
}
impl Room {
/// A `width` x `height` GUI px view with nothing to keep clear of.
pub fn new(width: f32, height: f32) -> Self {
Self {
width,
height,
keeps: Vec::new(),
}
}
/// This room with `area` kept clear of as `keep`; an empty area is ignored.
pub fn with(mut self, keep: Keep, area: Area) -> Self {
self.add(keep, area);
self
}
/// Keeps clear of `area` as `keep`; an empty area is ignored.
pub fn add(&mut self, keep: Keep, area: Area) {
if area[2] > 0.0 && area[3] > 0.0 {
self.keeps.push((keep, area));
}
}
/// The view's width in GUI px.
pub fn width(&self) -> f32 {
self.width
}
/// The view's height in GUI px.
pub fn height(&self) -> f32 {
self.height
}
/// The keep-outs, in the order they were added.
pub fn keeps(&self) -> impl Iterator<Item = (Keep, Area)> + '_ {
self.keeps.iter().copied()
}
/// How far, in GUI px from the view's top, the keep-outs at the top reach down: the boss
/// bars' bottom, or 0 with none.
pub fn top(&self) -> f32 {
self.keeps()
.map(|(Keep::BossBars, [_, y, _, h])| y + h)
.fold(0.0, f32::max)
}
/// Where a centred overlay that wants its top at `wanted` (GUI px) goes: at `wanted` unless
/// a keep-out reaches past it (`top() > wanted`, a real overlap; touching isn't one), else
/// `GAP` below that keep-out.
pub fn below_top(&self, wanted: f32) -> f32 {
let top = self.top();
if top > wanted { top + GAP } else { wanted }
}
/// The view below the top keep-outs, with `GAP` past them: where a centred overlay may go.
/// The whole view when nothing is kept clear of.
pub fn usable(&self) -> Area {
let top = self.top();
let top = if top > 0.0 { top + GAP } else { 0.0 };
[0.0, top, self.width, (self.height - top).max(0.0)]
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn an_empty_room_is_the_whole_view_and_moves_nothing() {
let room = Room::new(320.0, 240.0).with(Keep::BossBars, [69.0, 0.0, 182.0, 0.0]);
assert_eq!(room, Room::new(320.0, 240.0));
assert_eq!(room.usable(), [0.0, 0.0, 320.0, 240.0]);
assert_eq!(room.below_top(18.0), 18.0);
}
#[test]
fn an_overlay_moves_only_when_the_bars_reach_past_it() {
let bars =
|bottom: f32| Room::new(500.0, 300.0).with(Keep::BossBars, [159.0, 0.0, 182.0, bottom]);
// Ending above or at its top: no overlap.
assert_eq!(bars(17.0).below_top(18.0), 18.0);
assert_eq!(bars(18.0).below_top(18.0), 18.0);
// Past it: below, a gap away.
assert_eq!(bars(36.0).below_top(18.0), 38.0);
assert_eq!(bars(36.0).usable(), [0.0, 38.0, 500.0, 262.0]);
}
}
@@ -571,6 +571,41 @@ pub fn boss_overlay(w: i32, h: i32, bars: &[BossBar], font: &Font) -> Vec<GuiDra
out
}
/// `BossHealthOverlay`'s first bar's top edge, in GUI px (its name sits 9 px above).
pub const BOSS_BAR_TOP: i32 = 12;
/// `BossHealthOverlay`'s step from one bar to the next, in GUI px (`10 + 9`).
pub const BOSS_BAR_STEP: i32 = 10 + 9;
/// A boss bar's sprite height, in GUI px.
pub const BOSS_BAR_HEIGHT: i32 = 5;
/// A boss bar's sprite width, in GUI px; it is centred on the view.
pub const BOSS_BAR_WIDTH: i32 = 182;
/// How many of `n` boss bars `BossHealthOverlay.extractRenderState` draws on a view `h` GUI px
/// tall: it stops once the next bar's top reaches a third of the height (integer division,
/// as vanilla's `guiHeight() / 3`), but always draws the first.
pub fn boss_bars_drawn(h: i32, n: usize) -> usize {
let mut drawn = 0;
let mut y = BOSS_BAR_TOP;
while drawn < n {
drawn += 1;
y += BOSS_BAR_STEP;
if y >= h / 3 {
break;
}
}
drawn
}
/// The bottom edge, in GUI px from the view's top, of the last of `n` boss bars a view `h` GUI
/// px tall shows (`boss_overlay`'s layout); 0 when there are none. Names sit above their bar,
/// so nothing of the overlay reaches below this.
pub fn boss_bars_bottom(h: i32, n: usize) -> i32 {
match boss_bars_drawn(h, n) {
0 => 0,
k => BOSS_BAR_TOP + BOSS_BAR_STEP * (k as i32 - 1) + BOSS_BAR_HEIGHT,
}
}
/// `JumpableVehicleBar.extractBackground`: `cooldown` is the mount's jump
/// cooldown and `scale` the player's `getJumpRidingScale()`.
pub fn jump_bar(w: i32, h: i32, cooldown: i32, scale: f32) -> Vec<GuiDraw> {
@@ -1381,3 +1416,64 @@ mod text_position_tests {
assert_eq!(relative, Some(-4));
}
}
#[cfg(test)]
mod boss_bar_room_tests {
use super::*;
/// `BossHealthOverlay.extractRenderState` worked by hand: bars at y 12, 31, 50, 69, 88...,
/// each 5 tall, until the next top reaches `guiHeight() / 3`.
#[test]
fn boss_bars_end_where_vanillas_overlay_stops() {
assert_eq!(boss_bars_bottom(300, 0), 0);
assert_eq!(boss_bars_bottom(300, 1), 17);
assert_eq!(boss_bars_bottom(300, 2), 36);
assert_eq!(boss_bars_bottom(300, 3), 55);
// A third of 300 is 100: the fifth bar is the last (its next top would be 107).
assert_eq!(boss_bars_drawn(300, 9), 5);
assert_eq!(boss_bars_bottom(300, 9), 93);
// A short view still draws the first.
assert_eq!(boss_bars_bottom(60, 3), 17);
// `guiHeight() / 3` is integer division: 152 / 3 = 50 stops at two, 153 / 3 = 51 not.
assert_eq!(boss_bars_drawn(152, 3), 2);
assert_eq!(boss_bars_drawn(153, 3), 3);
}
#[test]
fn boss_bars_bottom_is_below_every_drawn_bar() {
// The missing-glyph metrics: no game assets needed.
let font = Font::load(
std::path::Path::new("/nonexistent-horizon-hud-test-resources"),
&std::collections::HashMap::new(),
&[],
(1, 1),
(0, 0),
crate::hud::Rect {
u0: 0.0,
v0: 0.0,
u1: 1.0,
v1: 1.0,
w: 1,
h: 1,
},
);
let bars: Vec<BossBar> = (0..6)
.map(|_| BossBar {
name: Text::literal(""),
progress: 1.0,
color: 0,
overlay: 0,
})
.collect();
for h in [60, 120, 152, 153, 240, 300, 600] {
let bottom = boss_overlay(400, h, &bars, &font)
.iter()
.filter_map(|d| match d {
GuiDraw::Sprite { y, h, .. } => Some(y + h),
_ => None,
})
.max();
assert_eq!(bottom, Some(boss_bars_bottom(h, bars.len())), "height {h}");
}
}
}
+2
View File
@@ -55,6 +55,7 @@ pub mod graphics_style;
pub mod graphics_ui;
pub mod hdr_calibration;
pub mod hud;
pub mod hud_kit;
pub mod hud_layout;
pub mod hud_overlays;
pub mod inventory_effects;
@@ -109,6 +110,7 @@ pub mod shader_pack_list;
pub mod shader_ui;
pub mod signs;
pub mod skins;
pub mod social;
pub mod sound_engine;
pub mod sound_events;
pub mod sound_preview;
+4 -2
View File
@@ -653,15 +653,17 @@ fn pack_lighting(
sky_light: bool,
) -> Vec<Light> {
let open = if sky_light { 0xF0 } else { 0x00 };
let mut data = [open; 4096];
(0..sections)
.map(|si| {
if si * 16 >= h {
return Light::Uniform(open);
}
let mut data = Box::new([open; 4096]);
for ly in 0..16 {
let y = si * 16 + ly;
if y >= h {
let out_row = ly << 8;
data[out_row..out_row + 256].fill(open);
continue;
}
let y_offset = y * (W * W);
@@ -674,7 +676,7 @@ fn pack_lighting(
}
}
}
Light::packed(data)
Light::from_slice(&data)
})
.collect()
}
+41 -6
View File
@@ -10,6 +10,7 @@ use crate::{
pub mod cache;
mod mesh;
pub mod packed;
mod samples;
use horizon_world::registry::BlockStateId;
use std::{
collections::VecDeque,
@@ -957,7 +958,7 @@ type Reduced = FxHashMap<(i32, i32), Arc<ReducedColumns>>;
/// every tile's inputs from it (gathering on the render thread stalled the
/// frame, tens of milliseconds for a large job on a 4-thread machine).
struct Snapshot {
samples: FxHashMap<TileKey, Arc<Vec<Column>>>,
samples: samples::Samples,
saved: Saved,
reduced: Reduced,
/// The plan's tiles, to tell terrain not drawn yet from terrain out of
@@ -1267,6 +1268,8 @@ pub struct Lod {
/// Stale tiles `refresh`ed again while sampled: that sample may be older.
restale: FxHashSet<TileKey>,
active: FxHashSet<TileKey>,
/// The active tiles' `ready` samples, as mesh jobs read them (`sync_sample`).
mesh_samples: samples::Samples,
/// Tiles needing a mesh, with the revision their job must carry.
dirty: FxHashMap<TileKey, u64>,
/// The newest revision of each active tile's mesh.
@@ -1657,6 +1660,7 @@ impl Lod {
ready: FxHashMap::default(),
complete: FxHashMap::default(),
active: FxHashSet::default(),
mesh_samples: samples::Samples::default(),
dirty: FxHashMap::default(),
mesh_version: FxHashMap::default(),
shown_version: FxHashMap::default(),
@@ -1935,9 +1939,11 @@ impl Lod {
if active != was_active {
if active {
self.active.insert(node);
self.sync_sample(node);
self.removed_pending.remove(&node);
} else {
self.active.remove(&node);
self.sync_sample(node);
self.removed_pending.insert(node);
self.cancel_mesh(node);
self.mesh_version.remove(&node);
@@ -2210,6 +2216,7 @@ impl Lod {
}
}
for key in modified {
self.sync_sample(key);
self.mark_data_changed(key);
}
}
@@ -2271,6 +2278,7 @@ impl Lod {
return;
}
let existed = self.ready.insert(key, Arc::new(samples)).is_some();
self.sync_sample(key);
self.refresh_ready(key, existed);
}
}
@@ -2505,6 +2513,8 @@ impl Lod {
.collect();
self.plan = plan;
self.ready.retain(|key, _| self.plan.nodes.contains(key));
let nodes = &self.plan.nodes;
self.mesh_samples.retain(|key| nodes.contains(key));
// Queued tiles the view left are not sampled (a running one is
// dropped when it finishes, `finish_sample`).
drop_queued_samples(&self.shared, &mut self.pending, |key| {
@@ -2572,6 +2582,7 @@ impl Lod {
.retain(|key| !self.active.contains(key));
let changed: Vec<_> = old.symmetric_difference(&self.active).copied().collect();
for &key in &changed {
self.sync_sample(key);
if old.contains(&key) && !self.active.contains(&key) {
self.cancel_mesh(key);
self.removed_pending.insert(key);
@@ -2857,12 +2868,10 @@ impl Lod {
if keys.is_empty() {
return;
}
#[cfg(debug_assertions)]
self.check_mesh_samples();
let snapshot = Arc::new(Snapshot {
samples: self
.active
.iter()
.filter_map(|key| self.ready.get(key).map(|columns| (*key, columns.clone())))
.collect(),
samples: self.mesh_samples.clone(),
saved: self.saved.clone(),
reduced: self.reduced.clone(),
nodes: self.plan.nodes.clone(),
@@ -2895,6 +2904,31 @@ impl Lod {
self.outstanding_meshes += count;
self.shared.mesh_wake.notify_all();
}
/// Brings `mesh_samples` up to date for `key` after its `active` or `ready` changed.
fn sync_sample(&mut self, key: TileKey) {
let samples = self.ready.get(&key).filter(|_| self.active.contains(&key));
self.mesh_samples.set(key, samples);
}
/// `mesh_samples` holds every active tile's ready samples and nothing else (debug builds).
#[cfg(debug_assertions)]
fn check_mesh_samples(&self) {
let kept = self.mesh_samples.iter().count();
let mut want = 0;
for key in &self.active {
if let Some(columns) = self.ready.get(key) {
want += 1;
let got = self.mesh_samples.get(key);
assert!(
got.is_some_and(|got| Arc::ptr_eq(got, columns)),
"mesh samples of {key:?} out of date"
);
}
}
assert_eq!(
kept, want,
"mesh samples keep tiles no longer active or ready"
);
}
fn finer_of(&self, key: TileKey) -> Vec<TileKey> {
if !self.plan.split.contains(&key) {
return Vec::new();
@@ -4852,6 +4886,7 @@ mod tests {
lod.active.insert(key);
lod.ready
.insert(key, Arc::new(vec![Column::default(); CELLS * CELLS]));
lod.sync_sample(key);
lod.mark_mesh(key);
lod.set_near_loading(true);
lod.dispatch_meshes([0, 0]);
@@ -0,0 +1,105 @@
//! The samples mesh jobs read (`Snapshot::samples`): the active tiles' ready columns, kept
//! as the tiles change rather than gathered again for every batch of jobs. Kept in shards of
//! nearby tiles behind `Arc`s: a batch's copy (`clone`) copies the shards' handles, and a
//! change copies only its shard, and only while a job still reads it.
use super::{Column, TileKey};
use crate::fast_hash::FxHashMap;
use std::sync::Arc;
type Shard = FxHashMap<TileKey, Arc<Vec<Column>>>;
/// 8 by 8 tiles of one level a shard.
fn shard_of(key: TileKey) -> (i32, i32, u8) {
(key.x >> 3, key.z >> 3, key.level)
}
#[derive(Clone, Default)]
pub(super) struct Samples {
shards: FxHashMap<(i32, i32, u8), Arc<Shard>>,
}
impl Samples {
pub fn get(&self, key: &TileKey) -> Option<&Arc<Vec<Column>>> {
self.shards.get(&shard_of(*key))?.get(key)
}
/// Sets `key`'s samples (`None`: not active or not ready), copying its shard only when
/// they change.
pub fn set(&mut self, key: TileKey, samples: Option<&Arc<Vec<Column>>>) {
let at = shard_of(key);
match samples {
Some(samples) => {
let shard = self.shards.entry(at).or_default();
if shard.get(&key).is_none_or(|s| !Arc::ptr_eq(s, samples)) {
Arc::make_mut(shard).insert(key, samples.clone());
}
}
None => {
let Some(shard) = self.shards.get_mut(&at) else {
return;
};
if shard.contains_key(&key) {
Arc::make_mut(shard).remove(&key);
if shard.is_empty() {
self.shards.remove(&at);
}
}
}
}
}
/// Keeps the tiles `keep` accepts.
pub fn retain(&mut self, mut keep: impl FnMut(&TileKey) -> bool) {
self.shards.retain(|_, shard| {
if !shard.keys().all(&mut keep) {
Arc::make_mut(shard).retain(|key, _| keep(key));
}
!shard.is_empty()
});
}
#[cfg(any(test, debug_assertions))]
pub fn iter(&self) -> impl Iterator<Item = (&TileKey, &Arc<Vec<Column>>)> {
self.shards.values().flat_map(|shard| shard.iter())
}
}
impl FromIterator<(TileKey, Arc<Vec<Column>>)> for Samples {
fn from_iter<I: IntoIterator<Item = (TileKey, Arc<Vec<Column>>)>>(iter: I) -> Self {
let mut samples = Self::default();
for (key, columns) in iter {
samples.set(key, Some(&columns));
}
samples
}
}
#[cfg(test)]
mod tests {
use super::*;
fn key(x: i32, z: i32, level: u8) -> TileKey {
TileKey { x, z, level }
}
#[test]
fn a_change_leaves_a_batchs_copy_as_it_was() {
let a = Arc::new(Vec::new());
let b = Arc::new(Vec::new());
let mut live: Samples = [(key(0, 0, 0), a.clone()), (key(40, -3, 1), a.clone())]
.into_iter()
.collect();
let batch = live.clone();
live.set(key(0, 0, 0), Some(&b));
live.set(key(40, -3, 1), None);
live.set(key(-9, 2, 0), Some(&b));
assert!(Arc::ptr_eq(batch.get(&key(0, 0, 0)).unwrap(), &a));
assert!(batch.get(&key(40, -3, 1)).is_some());
assert!(batch.get(&key(-9, 2, 0)).is_none());
assert!(Arc::ptr_eq(live.get(&key(0, 0, 0)).unwrap(), &b));
assert!(live.get(&key(40, -3, 1)).is_none());
live.retain(|k| k.x < 0);
assert_eq!(
live.iter().map(|(k, _)| *k).collect::<Vec<_>>(),
[key(-9, 2, 0)]
);
assert_eq!(batch.iter().count(), 2);
}
}
+1 -1
View File
@@ -450,7 +450,7 @@ impl SectionMesh {
self.packed.clear();
self.packed_far = false;
if packed::packing() && quads > 0 && self.solid_tints.len() == quads {
self.packed = packed::layout(&self.solid, &self.solid_tints);
packed::layout_into(&self.solid, &self.solid_tints, &mut self.packed);
}
self.solid_tints.clear();
}
@@ -340,12 +340,11 @@ pub(super) fn tesselate(
} else {
(tables.lava_still, tables.lava_flow, None)
};
let target = if water {
&mut out.translucent
let (target, sprite_layers) = if water {
(&mut out.translucent, &mut out.sprite_layers)
} else {
&mut out.solid
(&mut out.solid, &mut out.sprite_layers)
};
let mut sprite_layers = Vec::new();
let mut quad = |v: [([f32; 3], [f32; 2]); 4],
layer: u16,
color: u32,
@@ -497,7 +496,6 @@ pub(super) fn tesselate(
d as usize,
);
}
out.sprite_layers.extend(sprite_layers);
}
#[cfg(test)]
@@ -199,16 +199,29 @@ pub fn packed_vertex(words: &[u32; 6], corner: usize) -> TerrainVertex {
}
}
thread_local! {
static RECORDS_SCRATCH: std::cell::RefCell<Vec<Option<[u32; 6]>>> =
const { std::cell::RefCell::new(Vec::new()) };
}
/// The compact layout of sorted solid quads with their tints (`SectionMesh::packed`), or
/// nothing when it would be no smaller than their vertices.
pub fn layout(solid: &[TerrainVertex], tints: &[u32]) -> Vec<[u32; 6]> {
let mut out = Vec::new();
layout_into(solid, tints, &mut out);
out
}
/// Writes the compact layout of sorted solid quads with their tints into `out`,
/// reusing `out`'s allocation and a thread-local record scratch buffer.
pub fn layout_into(solid: &[TerrainVertex], tints: &[u32], out: &mut Vec<[u32; 6]>) {
let quads = solid.as_chunks::<4>().0;
let records: Vec<Option<[u32; 6]>> = quads
.iter()
.zip(tints)
.map(|(q, &t)| pack_quad(q, t))
.collect();
place(&records, quads)
RECORDS_SCRATCH.with(|cell| {
let mut records = cell.borrow_mut();
records.clear();
records.extend(quads.iter().zip(tints).map(|(q, &t)| pack_quad(q, t)));
place_into(&records, quads, out);
});
}
/// A mesh's solid range from a record per quad, `None` where no record fits:
@@ -220,6 +233,17 @@ pub fn layout(solid: &[TerrainVertex], tints: &[u32]) -> Vec<[u32; 6]> {
/// Shared with `lod::packed`, whose records hold a far tile's quads instead but
/// spill and pad the same way.
pub fn place(records: &[Option<[u32; 6]>], quads: &[[TerrainVertex; 4]]) -> Vec<[u32; 6]> {
let mut out = Vec::new();
place_into(records, quads, &mut out);
out
}
pub fn place_into(
records: &[Option<[u32; 6]>],
quads: &[[TerrainVertex; 4]],
out: &mut Vec<[u32; 6]>,
) {
out.clear();
let n = quads.len();
let packed = records.iter().filter(|r| r.is_some()).count();
let full = n - packed;
@@ -229,9 +253,9 @@ pub fn place(records: &[Option<[u32; 6]>], quads: &[[TerrainVertex; 4]]) -> Vec<
0
};
if n + pad + 4 * full >= 4 * n {
return Vec::new();
return;
}
let mut out = Vec::with_capacity(n + pad + 4 * full);
out.reserve(n + pad + 4 * full);
let mut next = n + pad;
for (i, record) in records.iter().enumerate() {
out.push(record.unwrap_or_else(|| {
@@ -249,7 +273,6 @@ pub fn place(records: &[Option<[u32; 6]>], quads: &[[TerrainVertex; 4]]) -> Vec<
);
}
}
out
}
/// Quads `layout` gave a record of their own.
@@ -12,7 +12,7 @@
return oit_transmittance_fragment(entity_color(in, front).a, in.clip);
}
@fragment fn fs_oit_entity_accumulate(in: Out, @builtin(front_facing) front: bool) -> @location(0) vec4<f32> {
return oit_accumulate_fragment(entity_written_color(in, front), in.clip);
return oit_accumulate_fragment(entity_written_color(in, entity_color(in, front)), in.clip);
}
// Text keeps its source gain, as fs_entity_unmarked does in the forward pass.
@fragment fn fs_oit_text_accumulate(in: Out, @builtin(front_facing) front: bool) -> @location(0) vec4<f32> {
+94 -6
View File
@@ -34,8 +34,11 @@ pub struct Pack {
pub description_style: Style,
pub compatible: bool,
pub compatibility: crate::pack_screen::Compat,
/// Overlay directories active for this game version, in `pack.mcmeta` order.
/// Overlay directories active for this game version, in `pack.mcmeta` order: also those a
/// Polytone `polytone_condition` turns on.
pub overlays: Vec<String>,
/// The values of the pack's Polytone config entries.
pub polytone_configs: horizon_polytone::pack::Configs,
/// `filter.block` entries (namespace and path patterns) hiding resources
/// of the packs below this one.
pub filter: Vec<Filter>,
@@ -416,12 +419,12 @@ fn overlay_applies(entry: &serde_json::Value) -> bool {
_ => false,
}
}
fn overlays_of(meta: &serde_json::Value) -> Vec<String> {
fn overlays_of(meta: &serde_json::Value, configs: &horizon_polytone::pack::Configs) -> Vec<String> {
meta["overlays"]["entries"]
.as_array()
.into_iter()
.flatten()
.filter(|e| overlay_applies(e))
.filter(|e| horizon_polytone::pack::overlay_active(e, configs, overlay_applies(e)))
.filter_map(|e| e["directory"].as_str().map(str::to_owned))
// OverlayMetadataSection's DIR_VALIDATOR: one folder of the pack, `[-_a-zA-Z0-9.]+`.
.filter(|d| {
@@ -468,12 +471,66 @@ pub(crate) fn parse_meta(bytes: &[u8]) -> Option<serde_json::Value> {
.next()?
.ok()
}
/// A pack's Polytone config entries (`assets/<namespace>/polytone/config_entries/<name>.json`,
/// in the pack's base, not its overlays) at their default values.
pub fn polytone_configs(source: &Source) -> horizon_polytone::pack::Configs {
let mut configs = horizon_polytone::pack::Configs::default();
let id_of = |name: &str| -> Option<String> {
let rest = name.strip_prefix("assets/")?;
let (namespace, rest) = rest.split_once('/')?;
let entry = rest
.strip_prefix("polytone/config_entries/")?
.strip_suffix(".json")?;
Some(format!("{namespace}:{entry}"))
};
match source {
Source::Dir(d) => {
let Ok(namespaces) = fs::read_dir(d.join("assets")) else {
return configs;
};
for namespace in namespaces.flatten() {
let folder = namespace.path().join("polytone/config_entries");
for file in fs::read_dir(&folder).into_iter().flatten().flatten() {
let name = format!(
"assets/{}/polytone/config_entries/{}",
namespace.file_name().to_string_lossy(),
file.file_name().to_string_lossy()
);
if let (Some(id), Ok(bytes)) = (id_of(&name), fs::read(file.path()))
&& let Ok(json) = serde_json::from_slice(&bytes)
{
configs.add_entry(id, &json);
}
}
}
}
Source::Zip(z) => {
let Ok(zip) = Zip::open(z) else {
return configs;
};
let names: Vec<String> = zip
.names()
.filter(|n| id_of(n).is_some())
.map(str::to_owned)
.collect();
for name in names {
if let (Some(id), Ok(bytes)) = (id_of(&name), zip.read(&name))
&& let Ok(json) = serde_json::from_slice(&bytes)
{
configs.add_entry(id, &json);
}
}
}
}
configs
}
fn pack(id: String, title: Text, source: Source) -> Option<Pack> {
pack_for(id, title, source, RESOURCE_FORMAT)
}
/// A pack whose compatibility is against `format`.
fn pack_for(id: String, title: Text, source: Source, format: (i64, i64)) -> Option<Pack> {
let meta = read_meta(&source)?;
let polytone_configs = polytone_configs(&source);
let (raw_description, description_style) = component_root(&meta["pack"]["description"]);
Some(Pack {
id,
@@ -483,7 +540,8 @@ fn pack_for(id: String, title: Text, source: Source, format: (i64, i64)) -> Opti
description_style,
compatible: compatible(&meta),
compatibility: compatibility_for(&meta, format),
overlays: overlays_of(&meta),
overlays: overlays_of(&meta, &polytone_configs),
polytone_configs,
filter: filter_of(&meta),
source,
})
@@ -1930,6 +1988,36 @@ mod tests {
let _ = fs::remove_dir_all(base);
}
#[test]
fn polytone_conditions_turn_overlays_on_from_the_packs_configs() {
let dir =
std::env::temp_dir().join(format!("horizon-packs-polytone-{}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
let configs = dir.join("assets/mypack/polytone/config_entries");
fs::create_dir_all(&configs).unwrap();
fs::write(configs.join("fog.json"), r#"{"default_value": "ON"}"#).unwrap();
fs::write(
configs.join("birds.json"),
r#"{"default_value": 0, "max": 300}"#,
)
.unwrap();
fs::write(
dir.join("pack.mcmeta"),
r#"{"pack":{"description":"p","pack_format":97},"overlays":{"entries":[
{"directory":"opt_fog","formats":[0,0],"polytone_condition":"config('mypack:fog') == 'ON'"},
{"directory":"opt_birds","formats":[0,0],"polytone_condition":"config('mypack:birds') > 0"},
{"directory":"opt_plain","formats":[0,0]}]}}"#,
)
.unwrap();
let pack = pack(
"file/p".into(),
Text::literal("p"),
Source::Dir(dir.clone()),
)
.unwrap();
assert_eq!(pack.overlays, ["opt_fog"]);
let _ = fs::remove_dir_all(dir);
}
#[test]
fn overlays_and_filters() {
let v = |s: &str| serde_json::from_str::<serde_json::Value>(s).unwrap();
let meta = v(r#"{"overlays":{"entries":[
@@ -1937,7 +2025,7 @@ mod tests {
{"formats":{"min_inclusive":97,"max_inclusive":97},"directory":"new"},
{"min_format":97,"max_format":[97,5],"directory":"also"},
{"formats":50,"directory":"no"}]}}"#);
assert_eq!(overlays_of(&meta), ["new", "also"]);
assert_eq!(overlays_of(&meta, &Default::default()), ["new", "also"]);
// Only one plain folder of the pack, as vanilla's DIR_VALIDATOR has it.
let meta = v(r#"{"overlays":{"entries":[
{"formats":97,"directory":".."},
@@ -1946,7 +2034,7 @@ mod tests {
{"formats":97,"directory":"a/b"},
{"formats":97,"directory":"C:"},
{"formats":97,"directory":"ok_1.21-x"}]}}"#);
assert_eq!(overlays_of(&meta), ["ok_1.21-x"]);
assert_eq!(overlays_of(&meta, &Default::default()), ["ok_1.21-x"]);
let base = std::env::temp_dir().join(format!("horizon-packs-ov-{}", std::process::id()));
let _ = fs::remove_dir_all(&base);
let vanilla = base.join("vanilla");
@@ -28,6 +28,7 @@ use std::{
pub use horizon_client_core::particle_type::ParticleType;
pub use horizon_items::firework::{DYE_FIREWORK_COLORS, FireworkExplosion, FireworkShape};
mod native;
pub mod polytone;
pub use native::{ParticleController, ParticleUpdate};
/// `ParticleOptions` with the parameters of the option classes used here.
@@ -64,6 +65,8 @@ pub struct ParticleOptions {
/// `nextInt(40) + 10` after constructing the particle. Set by the client for an older
/// server only, never read from the wire.
pub legacy_trail_lifetime: bool,
/// What the Polytone emitter that spawned this custom particle sets on it.
pub polytone_seed: Option<std::sync::Arc<polytone::Seed>>,
}
impl ParticleOptions {
pub fn simple(ty: ParticleType) -> Self {
@@ -80,6 +83,7 @@ impl ParticleOptions {
mod_particle: None,
legacy_entity_effect: None,
legacy_trail_lifetime: false,
polytone_seed: None,
}
}
/// A native mod's `SimpleParticleType`: the sprite set of
@@ -522,6 +526,8 @@ pub struct Particle {
/// `Particle.random`: the particle's own generator (`RandomSource.create()`).
random: LegacyRandom,
controller: Option<Box<dyn native::Controller>>,
/// A resource pack's Polytone custom particle type, for its `limit`.
polytone_type: Option<std::sync::Arc<str>>,
}
fn aabb_at(p: [f64; 3], w: f32, h: f32) -> Aabb {
let w = f64::from(w) / 2.0;
@@ -883,6 +889,7 @@ impl Particle {
frame_ticks: 0,
random: LegacyRandom::new(0),
controller: None,
polytone_type: None,
}
}
/// The normal age/removal test and previous pose, without vanilla motion.
@@ -1277,6 +1284,8 @@ pub struct ParticleEngine {
/// A native mod's sprite sets, by namespaced particle name.
mod_sets: HashMap<String, (u32, Option<ModBehaviour>)>,
set_sprites: Vec<Vec<u32>>,
/// Resource packs' Polytone custom particle types (#11486).
polytone: Option<std::sync::Arc<polytone::Registry>>,
/// `TrackingEmitter`s: particle type, entity feet position, width, height, ticks left.
emitters: Vec<(ParticleOptions, [f64; 3], f64, f64, u32)>,
/// Camera position for the 32-block spawn limiter.
@@ -1469,6 +1478,7 @@ impl ParticleEngine {
sets: HashMap::new(),
mod_sets: HashMap::new(),
set_sprites: Vec::new(),
polytone: None,
emitters: Vec::new(),
starters: Vec::new(),
tracked_explosions: Vec::new(),
@@ -1689,6 +1699,10 @@ impl ParticleEngine {
particle.controller = Some(Box::new(controller));
Some(particle)
}
/// Sets (or clears) the resource packs' Polytone custom particle types.
pub fn set_polytone(&mut self, registry: Option<std::sync::Arc<polytone::Registry>>) {
self.polytone = registry.filter(|r| !r.is_empty());
}
/// Choose a frame from this particle's declared sprite set.
pub fn set_sprite_frame(&self, particle: &mut Particle, frame: usize) {
if let Some(sprites) = particle.set.and_then(|s| self.set_sprites.get(s as usize))
@@ -2092,6 +2106,12 @@ impl ParticleEngine {
let ty = o.ty;
// A native mod's SimpleParticleType: vanilla's TextureSheetParticle with
// the sprite set its particles/<name>.json names, no tint, 20..40 ticks.
if let Some(name) = &o.mod_particle
&& let Some(registry) = &self.polytone
&& let Some(def) = registry.get(name)
{
return polytone::make(self, &registry.clone(), name, &def.clone(), o, p, v, seed);
}
if let Some(name) = &o.mod_particle {
let (set, behaviour) = *self.mod_sets.get(name)?;
let mut r = LegacyRandom::new(seed);
File diff suppressed because it is too large. Load diff
+127 -2
View File
@@ -155,6 +155,10 @@ pub struct Context {
pub source: Option<Who>,
/// The projectile's owner, when the source is a projectile.
pub owner: Option<Who>,
/// The saved UUIDs of `source` and `owner` (`VibrationInfo`'s `uuid` and
/// `projectileOwnerUuid`), when the game knows them for that entity.
pub source_uuid: Option<[i32; 4]>,
pub owner_uuid: Option<[i32; 4]>,
/// `affectedState` is in `dampens_vibrations`.
pub dampening_state: bool,
/// The source is a spectator.
@@ -309,14 +313,29 @@ impl Data {
}
}
fn info_to_nbt(info: &Info) -> Tag {
Tag::compound(vec![
let mut fields = vec![
("game_event", Tag::String(info.event.into())),
("distance", Tag::Float(info.distance.to_bits())),
(
"pos",
Tag::List(info.pos.iter().map(|v| Tag::Double(v.to_bits())).collect()),
),
])
];
// `VibrationInfo.CODEC`: each UUID an int array of four, absent for none.
if let Some(u) = info.source_uuid {
fields.push(("source", Tag::IntArray(u.to_vec())));
}
if let Some(u) = info.owner_uuid {
fields.push(("projectile_owner", Tag::IntArray(u.to_vec())));
}
Tag::compound(fields)
}
/// A UUID in its saved int array form; anything else is no UUID.
fn uuid_field(tag: &Tag, key: &str) -> Option<[i32; 4]> {
match tag.get(key) {
Some(Tag::IntArray(v)) => <[i32; 4]>::try_from(v.as_slice()).ok(),
_ => None,
}
}
fn info_from_nbt(tag: &Tag) -> Option<Info> {
let event = intern(&horizon_client_core::nbt_value::text(
@@ -342,8 +361,12 @@ fn info_from_nbt(tag: &Tag) -> Option<Info> {
event,
distance,
pos,
// The live entities are found again by UUID only when needed; a
// loaded vibration names none of them.
source: None,
owner: None,
source_uuid: uuid_field(tag, "source"),
owner_uuid: uuid_field(tag, "projectile_owner"),
})
}
@@ -623,6 +646,8 @@ mod tests {
pos: [0.0; 3],
source: None,
owner: None,
source_uuid: None,
owner_uuid: None,
}
}
@@ -748,6 +773,106 @@ mod tests {
assert_eq!(back.selector.current.map(|(_, t)| t), Some(41));
}
fn identified(event: &'static str, distance: f32) -> Info {
Info {
source: Some(Who::Mob(7)),
owner: Some(Who::Mob(8)),
source_uuid: Some([100, 0, 0, 1]),
owner_uuid: Some([100, 0, 0, 2]),
..info(event, distance)
}
}
#[test]
fn vibration_uuids_save_as_source_and_projectile_owner() {
let tag = info_to_nbt(&identified("minecraft:step", 3.0));
assert_eq!(tag.get("source"), Some(&Tag::IntArray(vec![100, 0, 0, 1])));
assert_eq!(
tag.get("projectile_owner"),
Some(&Tag::IntArray(vec![100, 0, 0, 2]))
);
// Read back, the identities stay; the live entities do not.
let back = info_from_nbt(&tag).unwrap();
assert_eq!(back.source_uuid, Some([100, 0, 0, 1]));
assert_eq!(back.owner_uuid, Some([100, 0, 0, 2]));
assert_eq!((back.source, back.owner), (None, None));
// A live entity with no saved identity writes nothing for it.
let unnamed = Info {
source: Some(Who::Mob(7)),
..info("minecraft:step", 3.0)
};
let tag = info_to_nbt(&unnamed);
assert!(tag.get("source").is_none() && tag.get("projectile_owner").is_none());
}
#[test]
fn a_loaded_vibration_keeps_only_well_formed_uuids() {
let base = |extra: Vec<(&'static str, Tag)>| {
let mut fields = vec![
("game_event", Tag::String("minecraft:step".into())),
("distance", Tag::Float(3.0f32.to_bits())),
("pos", Tag::List(vec![Tag::Double(0f64.to_bits()); 3])),
];
fields.extend(extra);
info_from_nbt(&Tag::compound(fields)).unwrap()
};
let absent = base(vec![]);
assert_eq!((absent.source_uuid, absent.owner_uuid), (None, None));
let malformed = base(vec![
("source", Tag::IntArray(vec![1, 2, 3])),
(
"projectile_owner",
Tag::String("00000064-0000-0000-0000-000000000002".into()),
),
]);
assert_eq!((malformed.source_uuid, malformed.owner_uuid), (None, None));
// Only the codec's fields are kept: an unknown key is not saved again.
let extra = base(vec![("horizon:raw", Tag::Int(1))]);
assert!(info_to_nbt(&extra).get("horizon:raw").is_none());
}
#[test]
fn a_reloaded_listener_drops_stale_uuids() {
let mut d = Data::default();
d.schedule(identified("minecraft:step", 2.0), 40);
d.tick(41, true, true, |_| 5);
assert_eq!(d.current.and_then(|i| i.source_uuid), Some([100, 0, 0, 1]));
// A listener saved with no identity replaces the old one, not merges.
d = Data::from_nbt(
&Data {
current: Some(info("minecraft:step", 2.0)),
..Data::default()
}
.to_nbt(),
);
assert_eq!(
d.current.map(|i| (i.source_uuid, i.owner_uuid)),
Some((None, None))
);
}
#[test]
fn the_chosen_candidate_carries_its_uuids_into_the_travelling_vibration() {
let mut d = Data::default();
d.schedule(identified("minecraft:step", 2.0), 40);
let saved = Data::from_nbt(&d.to_nbt());
assert_eq!(
saved.selector.current.map(|(i, _)| i.source_uuid),
Some(Some([100, 0, 0, 1]))
);
// The candidate read back is the one that starts travelling.
let mut d = saved;
let t = d.tick(41, true, true, |_| 5);
let travelling = t.started.map(|(i, _)| i).unwrap();
assert_eq!(travelling.source_uuid, Some([100, 0, 0, 1]));
assert_eq!(travelling.owner_uuid, Some([100, 0, 0, 2]));
let saved = Data::from_nbt(&d.to_nbt()).current.unwrap();
assert_eq!(
(saved.source_uuid, saved.owner_uuid),
(Some([100, 0, 0, 1]), Some([100, 0, 0, 2]))
);
}
#[test]
fn wool_on_the_line_occludes() {
let origin = [0.5, 0.5, 0.5];
@@ -390,6 +390,10 @@ impl Settings {
// Horizon's own key bindings live in horizon-extras.txt, not in options.txt.
let emote = settings.extras.emote_key.clone();
settings.keys.set("key.emote", &emote);
// `Options.load`: `sharePresence` decides what the friends service posts.
crate::social::set_share_presence(crate::social::SharePresence::of(
settings.file.int("sharePresence").clamp(0, 2) as u8,
));
settings.describe_for_crashes();
// Options.load: the backend asked for at startup, what a restart is measured from.
Self {
@@ -751,7 +751,8 @@ const SCROLL_RATE: f32 = 18.0;
pub(crate) enum Value {
/// A tick box.
Switch(bool),
/// A value stepped between `<` `>`, as it reads now.
/// A value stepped between `<` `>`, as it reads now: a press steps it on, a Shift press back,
/// and focused, Left and Right do the same.
Choice(String),
/// A slider: left and right step it (`steps`: its stops, else a pixel of the track), a press
/// on the track sets it (a controller's focus does the same).
@@ -992,7 +993,7 @@ pub(crate) trait Hosted {
/// The one way a list scrolls: the wheel, a right stick and a D-pad past the view's edge.
fn scroll(&mut self, s: &Settings, size: (f32, f32), amount: f32);
/// Typing in the search field, Tab and the arrows between controls, Left and Right on a
/// slider, Enter or Space to press, Escape to leave.
/// slider or a choice, Enter or Space to press, Escape to leave.
fn key(
&mut self,
s: &mut Settings,
@@ -1415,6 +1416,9 @@ impl<S: Screen> Shell<S> {
key: matches!(row.value, Value::Key(..)).then_some(waits),
};
self::row(batch, atlas, cols, y, &view);
if row.live && matches!(row.value, Value::Choice(_)) {
crate::widgets::steps_value(cols.row(y));
}
crate::widgets::last_narratable(|n| n.hint = Some(row.described().to_owned()));
}
/// The strip under the plate: the hovered or focused row's name and what it does; with none,
@@ -1738,6 +1742,14 @@ impl<S: Screen + Default> Hosted for Shell<S> {
self.did(&[DRAGGED]);
return vec![DRAGGED];
}
// A choice's `<` `>`: Left steps it back and Right on, as a Shift press and a press.
if matches!(key, K::ArrowLeft | K::ArrowRight)
&& let Some(row) = focused
&& row.live
&& matches!(row.value, Value::Choice(_))
{
return self.activate(s, row, key == K::ArrowLeft, None);
}
if matches!(
key,
K::ArrowUp | K::ArrowDown | K::ArrowLeft | K::ArrowRight
@@ -2303,6 +2315,43 @@ mod tests {
assert_eq!(key(&mut shell, K::Escape), vec![Output::Done]);
}
#[test]
fn left_and_right_step_a_focused_choice_and_leave_other_rows() {
let mut s = Settings::default();
let mut shell = Shell::<Probe>::default();
let mut key = |shell: &mut Shell<Probe>, k| shell.key(&mut s, k, None, false, SIZE);
shell.set_focus(Some(Target::Row(CHOICE)));
// Right is a press, Left a Shift press: the screen's own way back, and the focus stays.
assert_eq!(key(&mut shell, K::ArrowRight), vec![CLICK, CHANGED]);
assert_eq!(key(&mut shell, K::ArrowLeft), vec![CLICK, CHANGED]);
assert_eq!(shell.screen.pressed, [(CHOICE, false), (CHOICE, true)]);
assert_eq!(shell.focus, Some(Target::Row(CHOICE)));
assert!(shell.changed);
// A row that isn't a choice: Left goes to the page list, as before.
shell.set_focus(Some(Target::Row(FIRST_FILLER)));
assert!(key(&mut shell, K::ArrowLeft).is_empty());
assert!(matches!(shell.focus, Some(Target::Page(_))));
assert_eq!(shell.screen.pressed.len(), 2);
}
#[test]
fn a_drawn_choice_tells_the_pad_its_arrows_step_it() {
let atlas = atlas();
let s = Settings::default();
let mut shell = Shell::<Probe>::default();
let mut batch = HudBatch::new([1708, 960]);
shell.draw(&s, &mut batch, &atlas, (0.0, 0.0), false, None);
crate::widgets::end_frame(u64::MAX);
let view = shell.view(&s, (batch.width, batch.height));
let rect_of = |id| {
let mut placed = shell.placed(&view);
view.cols.row(placed.find(|(r, _)| r.id == id).unwrap().1)
};
assert!(crate::widgets::last_steps_value(rect_of(CHOICE)));
assert!(!crate::widgets::last_steps_value(rect_of(SLIDER)));
assert!(!crate::widgets::last_steps_value(rect_of(FIRST_FILLER)));
}
/// A controller presses with a click, then moves its own focus: only keys show this one.
#[test]
fn leaving_the_search_field_gives_the_keys_back() {
+66 -8
View File
@@ -24,12 +24,43 @@ pub struct Textures {
pub signed: bool,
}
/// The `getTexture` endpoint's valid URIs from Minecraft services discovery.
/// The `getTexture` endpoint's valid URIs in production's services discovery
/// document (`MinecraftServicesEnvironment.PROD`).
pub const TEXTURE_URIS: [&str; 2] = [
"https://textures.minecraft.net/texture/{textureId}",
"http://textures.minecraft.net/texture/{textureId}",
];
/// `MinecraftServicesDiscoveryService`'s valid texture URIs: the discovery
/// document's `profiles` / `getTexture` `validUris`, which
/// `MinecraftServicesSessionService.isAllowedTextureDomain` checks texture URLs
/// against. Production's document lists [`TEXTURE_URIS`], so they are used
/// without asking it; another environment (`HORIZON_API_DISCOVERY`) is asked
/// once per game, and keeps [`TEXTURE_URIS`] when its document lists none or
/// can't be fetched.
pub fn texture_uris() -> &'static [String] {
static URIS: std::sync::OnceLock<Vec<String>> = std::sync::OnceLock::new();
URIS.get_or_init(|| {
crate::auth::discovery_overridden()
.then(|| crate::auth::discovery().ok())
.flatten()
.and_then(|doc| discovery_texture_uris(&doc))
.unwrap_or_else(|| TEXTURE_URIS.map(str::to_owned).to_vec())
})
}
/// The `profiles` / `getTexture` endpoint's `validUris` in a discovery
/// document, when it lists any.
fn discovery_texture_uris(doc: &serde_json::Value) -> Option<Vec<String>> {
let uris: Vec<String> = doc
.pointer("/discovery/profiles/endpoints/getTexture/validUris")?
.as_array()?
.iter()
.filter_map(|u| u.as_str().map(str::to_owned))
.collect();
(!uris.is_empty()).then_some(uris)
}
/// `java.util.Base64.getDecoder().decode`.
pub fn base64_decode(text: &str) -> Option<Vec<u8>> {
use base64::{
@@ -44,7 +75,7 @@ pub fn base64_decode(text: &str) -> Option<Vec<u8>> {
/// `unpackTextures(property)`: the textures of a `textures` property's
/// base64 JSON payload.
pub fn unpack(value: &str, signature: Signature, valid_uris: &[&str]) -> Textures {
pub fn unpack(value: &str, signature: Signature, valid_uris: &[impl AsRef<str>]) -> Textures {
let empty = Textures::default();
let Some(json) = base64_decode(value)
.map(|b| String::from_utf8_lossy(&b).into_owned())
@@ -68,7 +99,7 @@ pub fn unpack(value: &str, signature: Signature, valid_uris: &[&str]) -> Texture
};
let allowed = valid_uris
.iter()
.any(|u| url.starts_with(&u.replace("{textureId}", "")));
.any(|u| url.starts_with(&u.as_ref().replace("{textureId}", "")));
if !allowed {
return empty;
}
@@ -95,7 +126,7 @@ pub fn unpack(value: &str, signature: Signature, valid_uris: &[&str]) -> Texture
/// those textures be unsigned (`getInsecureSkinInformation`).
pub fn profile_skin_url(profile: &serde_json::Value) -> Option<String> {
let (value, signature) = parse_profile(profile).textures?;
unpack(&value, signature, &TEXTURE_URIS).skin
unpack(&value, signature, texture_uris()).skin
}
/// The textures of a Minecraft services `/minecraft/profile` answer: the
@@ -114,7 +145,7 @@ pub fn services_profile_textures(profile: &serde_json::Value) -> Textures {
.and_then(|t| t.get("url"))
.and_then(|u| u.as_str())
.filter(|url| {
TEXTURE_URIS
texture_uris()
.iter()
.any(|u| url.starts_with(&u.replace("{textureId}", "")))
})
@@ -127,7 +158,7 @@ pub fn services_profile_textures(profile: &serde_json::Value) -> Textures {
.and_then(|t| t.get("url"))
.and_then(|u| u.as_str())
.filter(|url| {
TEXTURE_URIS
texture_uris()
.iter()
.any(|u| url.starts_with(&u.replace("{textureId}", "")))
})
@@ -155,7 +186,7 @@ pub fn profile_skin_url_nbt(profile: &horizon_world::nbt::Tag) -> Option<String>
.then(|| nbt_text(p.get("value")))
.flatten()
})?;
unpack(&value, Signature::Unsigned, &TEXTURE_URIS).skin
unpack(&value, Signature::Unsigned, texture_uris()).skin
}
/// `ResolvableProfile.skinPatch().body()` of a skull's `profile` NBT: the
@@ -478,7 +509,7 @@ struct HeadProfile {
fn prepare_head(profile: &serde_json::Value) -> HeadProfile {
let info = parse_profile(profile);
let skin = info.textures.as_ref().and_then(|(value, signature)| {
let url = unpack(value, *signature, &TEXTURE_URIS).skin?;
let url = unpack(value, *signature, texture_uris()).skin?;
let location = skin_location(&texture_hash(&url)?);
Some((url, location))
});
@@ -1362,6 +1393,33 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir);
}
/// `MinecraftServicesDiscoveryService`: another environment's document
/// names its own texture host; one that lists none names nothing.
#[test]
fn texture_uris_come_from_the_discovery_document() {
use super::{Signature, discovery_texture_uris, unpack};
let doc = serde_json::json!({"discovery": {"profiles": {"endpoints": {"getTexture": {
"validUris": ["https://skins.example.org/t/{textureId}"]
}}}}});
let uris = discovery_texture_uris(&doc).unwrap();
assert_eq!(uris, ["https://skins.example.org/t/{textureId}"]);
// {"textures":{"SKIN":{"url":"https://skins.example.org/t/abc"}}}
let ours =
"eyJ0ZXh0dXJlcyI6eyJTS0lOIjp7InVybCI6Imh0dHBzOi8vc2tpbnMuZXhhbXBsZS5vcmcvdC9hYmMifX19";
assert_eq!(
unpack(ours, Signature::Signed, &uris).skin.as_deref(),
Some("https://skins.example.org/t/abc")
);
assert_eq!(
unpack(ours, Signature::Signed, &super::TEXTURE_URIS).skin,
None
);
let none = serde_json::json!({"discovery": {"profiles": {"endpoints": {}}}});
assert_eq!(discovery_texture_uris(&none), None);
let empty = serde_json::json!({"discovery": {"profiles": {"endpoints": {"getTexture": {"validUris": []}}}}});
assert_eq!(discovery_texture_uris(&empty), None);
}
/// `MinecraftProfileTexture.getMetadata("model")`: `slim` marks a slim
/// skin; no metadata is the wide (classic) model.
#[test]
+860
View File
@@ -0,0 +1,860 @@
//! Vanilla's friends machinery without its screens: authlib's
//! `MinecraftServicesFriendsService` (`getFriends`, `updateFriends`,
//! `updateAttributes`, `updatePresence`), `RemoteFriendListUpdateHandler`'s worker and
//! state, `PlayerSocialManager`'s flags, and `PresenceHandler`'s posting.
//!
//! `title::poll_account` starts this the way `Minecraft`'s constructor does: the
//! account's `friendsPreferences` (`UserFlag.FRIENDS_ENABLED`,
//! `UserFlag.ACCEPT_FRIEND_INVITES`) decide whether the worker reads the lists. No
//! screen shows them yet; the overlay and the Online options are later parts.
//!
//! The requests keep authlib's cache rules: `If-None-Match` with the last `ETag`, a 304
//! or an empty answer keeps the cached lists, `Retry-After` sets the poll interval, and
//! `getFriendData` waits out a ten-second cooldown between reads. The presence statuses
//! are `PresenceHandler.getPresenceStatus`'s, supplied by the client each tick.
use crate::auth::{self, AuthSession};
use serde_json::{Value, json};
use std::{
sync::{
Arc, Mutex, OnceLock,
atomic::{AtomicBool, Ordering},
},
thread,
time::{Duration, Instant},
};
/// authlib's `FriendsService.ResultCode`.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ResultCode {
Success,
Error,
ServiceNotAvailable,
TooManyRequests,
Forbidden,
UpgradeNeeded,
ConnectionIssue,
TemporaryUnavailable,
UnknownProfile,
Unauthorized,
GenericError,
}
/// `FriendDto`: one friend or one pending request.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Friend {
pub id: u128,
pub name: String,
}
/// authlib's `FriendData`: the three lists the service answers.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct FriendData {
pub friends: Vec<Friend>,
pub incoming: Vec<Friend>,
pub outgoing: Vec<Friend>,
}
/// authlib's `PresenceStatus`, as `updatePresence` stores it.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum PresenceStatus {
Online,
PlayingOffline,
PlayingRealms,
PlayingServer,
PlayingHostedServer,
Offline,
}
impl PresenceStatus {
/// `PresenceStatus.name()`: the string `updatePresence` sends and
/// `PresenceStatusDto.status` carries.
fn wire(self) -> &'static str {
match self {
Self::Online => "ONLINE",
Self::PlayingOffline => "PLAYING_OFFLINE",
Self::PlayingRealms => "PLAYING_REALMS",
Self::PlayingServer => "PLAYING_SERVER",
Self::PlayingHostedServer => "PLAYING_HOSTED_SERVER",
Self::Offline => "OFFLINE",
}
}
}
/// `PresenceStatusDto`.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Presence {
pub id: u128,
pub status: PresenceStatus,
}
/// `RemoteFriendListUpdateHandler.State`.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum State {
Loading,
UpgradeNeeded,
ConnectionIssue,
UserMayLackActiveProfile,
Unauthorized,
TemporaryUnavailable,
GenericError,
Success,
}
impl State {
/// `RemoteFriendListUpdateHandler.mapResultCodeToState`.
fn of(code: ResultCode) -> Self {
match code {
ResultCode::Success => Self::Success,
ResultCode::UpgradeNeeded => Self::UpgradeNeeded,
ResultCode::ConnectionIssue => Self::ConnectionIssue,
ResultCode::UnknownProfile => Self::UserMayLackActiveProfile,
ResultCode::Unauthorized => Self::Unauthorized,
ResultCode::TemporaryUnavailable
| ResultCode::Forbidden
| ResultCode::ServiceNotAvailable
| ResultCode::TooManyRequests => Self::TemporaryUnavailable,
ResultCode::Error | ResultCode::GenericError => Self::GenericError,
}
}
}
/// `Options.sharePresence` (`PresenceSharing`): how much of the player the service sees.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum SharePresence {
None,
Limited,
All,
}
impl SharePresence {
/// `options.txt`'s `sharePresence` (0 none, 1 limited, 2 all; `Options` defaults to
/// all).
pub fn of(setting: u8) -> Self {
match setting {
0 => Self::None,
1 => Self::Limited,
_ => Self::All,
}
}
}
/// `MinecraftServicesFriendsService`: the HTTP client and its caches.
#[derive(Default)]
struct Service {
friends_etag: Option<String>,
presence_etag: Option<String>,
friends_cache: FriendData,
presence_cache: Vec<Presence>,
/// `getFriendsPollInterval`/`getPresencePollInterval`: the service's `Retry-After`.
friends_poll: Option<Duration>,
presence_poll: Option<Duration>,
/// `requestCooldown`: ten seconds between `getFriendData` requests.
cooldown: Option<Instant>,
/// `requestPending`: one list request at a time.
pending: bool,
}
impl Service {
/// `canMakeRequest`.
fn can_make_request(&self) -> bool {
self.cooldown.is_none_or(|at| Instant::now() > at)
}
}
/// `RemoteFriendListUpdateHandler`.
struct Handler {
enabled: bool,
state: State,
latest: FriendData,
/// `lastUpdateNanos`: a read at that moment is due; `None` reads at once.
last_update: Option<Instant>,
/// A forced read (`forceUpdate`) is due, whatever the interval.
forced: bool,
/// `getUpdateIntervalNanos`'s foreground: the friends overlay is open.
foreground: bool,
}
impl Default for Handler {
fn default() -> Self {
Self {
enabled: false,
state: State::Loading,
latest: FriendData::default(),
last_update: None,
forced: false,
foreground: false,
}
}
}
impl Handler {
/// `getUpdateIntervalNanos`: the service's interval (a minute when it named none),
/// five times as long in the background.
fn interval(&self, service: &Service) -> Duration {
let foreground = service.friends_poll.unwrap_or(Duration::from_secs(60));
if self.foreground {
foreground
} else {
foreground * 5
}
}
}
/// `PresenceHandler`.
struct PresenceHandler {
latest: Vec<Presence>,
last_post: Instant,
/// `updatePresence`: post at the next tick.
update: bool,
/// `PresenceHandler.getPresenceStatus`'s answer, from the client's state.
playing: PresenceStatus,
}
impl Default for PresenceHandler {
fn default() -> Self {
Self {
latest: Vec::new(),
// `lastPresencePost = Instant.now()`: the first post waits an interval.
last_post: Instant::now(),
// Its constructor sets `updatePresence = true`.
update: true,
playing: PresenceStatus::Online,
}
}
}
/// `PlayerSocialManager`'s flags.
#[derive(Default)]
struct Manager {
friend_list_enabled: bool,
allow_friend_requests: bool,
}
struct Inner {
service: Service,
handler: Handler,
presence: PresenceHandler,
manager: Manager,
share: SharePresence,
started: bool,
}
impl Default for Inner {
fn default() -> Self {
Self {
service: Service::default(),
handler: Handler::default(),
presence: PresenceHandler::default(),
manager: Manager::default(),
share: SharePresence::All,
started: false,
}
}
}
fn inner() -> &'static Arc<Mutex<Inner>> {
static INNER: OnceLock<Arc<Mutex<Inner>>> = OnceLock::new();
INNER.get_or_init(|| Arc::new(Mutex::new(Inner::default())))
}
fn lock() -> std::sync::MutexGuard<'static, Inner> {
inner().lock().unwrap_or_else(|e| e.into_inner())
}
/// `Minecraft`'s constructor: make the social manager, whose `friendListEnabled` comes
/// from the account's `friendsPreferences`, and start the updater when it is on.
/// Called once; later calls leave the running manager alone.
pub fn init(friends_enabled: bool, allow_requests: bool) {
{
let mut inner = lock();
if inner.started {
return;
}
inner.started = true;
inner.manager.friend_list_enabled = friends_enabled;
inner.manager.allow_friend_requests = allow_requests;
inner.handler.enabled = friends_enabled;
}
spawn_updater();
}
/// `PlayerSocialManager.setFriendListEnabled`: the Online options switch.
pub fn set_friend_list_enabled(enabled: bool) {
let mut inner = lock();
inner.manager.friend_list_enabled = enabled;
inner.handler.enabled = enabled;
if enabled {
inner.handler.last_update = None;
}
}
/// `PlayerSocialManager.isFriendListEnabled`.
pub fn is_friend_list_enabled() -> bool {
lock().manager.friend_list_enabled
}
/// `PlayerSocialManager.isAllowFriendRequests`.
pub fn allow_friend_requests() -> bool {
lock().manager.allow_friend_requests
}
/// `PlayerSocialManager.setAllowFriendRequests`.
pub fn set_allow_friend_requests(allow: bool) {
lock().manager.allow_friend_requests = allow;
}
/// `PlayerSocialManager.getFriends`.
pub fn friends() -> Vec<Friend> {
lock().handler.latest.friends.clone()
}
/// `PlayerSocialManager.getIncomingRequests`.
pub fn incoming_requests() -> Vec<Friend> {
lock().handler.latest.incoming.clone()
}
/// `PlayerSocialManager.getOutgoingRequests`.
pub fn outgoing_requests() -> Vec<Friend> {
lock().handler.latest.outgoing.clone()
}
/// `PlayerSocialManager.getFriendListState`.
pub fn friend_list_state() -> State {
lock().handler.state
}
/// `PresenceHandler.getLatestPresence`.
pub fn latest_presence() -> Vec<Presence> {
lock().presence.latest.clone()
}
/// `RemoteFriendListUpdateHandler`'s foreground interval: the friends overlay is open.
pub fn set_foreground(open: bool) {
lock().handler.foreground = open;
}
/// `Options.sharePresence`: what presence the handler posts.
pub fn set_share_presence(share: SharePresence) {
lock().share = share;
}
/// `PresenceHandler.getPresenceStatus`, supplied by the client from its world state.
pub fn set_presence_status(status: PresenceStatus) {
lock().presence.playing = status;
}
/// `Minecraft.runTick`: `PresenceHandler.tick`, once per client tick.
pub fn tick() {
let due = {
let inner = lock();
if !inner.manager.friend_list_enabled || inner.handler.latest.friends.is_empty() {
return;
}
let interval = inner
.service
.presence_poll
.unwrap_or(Duration::from_secs(60));
let since = inner.presence.last_post.elapsed();
(inner.presence.update && since >= interval) || since >= interval * 5
};
if due {
update_presence();
}
}
/// `PresenceHandler.updatePresence`.
fn update_presence() {
let status = {
let mut inner = lock();
inner.presence.update = false;
inner.presence.last_post = Instant::now();
match inner.share {
SharePresence::None => PresenceStatus::Offline,
SharePresence::Limited => PresenceStatus::Online,
SharePresence::All => inner.presence.playing,
}
};
let _ = thread::Builder::new()
.name("Friends presence".into())
.spawn(move || {
if let Some(presence) = post_presence(status) {
lock().presence.latest = presence;
}
});
}
/// `PresenceHandler.tryUpdatePresence`: the world was entered (or the overlay opened).
pub fn try_update_presence() {
lock().presence.update = true;
}
/// `PresenceHandler.sendOfflinePresence`: the game is closing.
pub fn send_offline_presence() {
static SENT: AtomicBool = AtomicBool::new(false);
{
let inner = lock();
if !inner.manager.friend_list_enabled || inner.handler.latest.friends.is_empty() {
return;
}
}
// `Minecraft.close` happens once; a second quit path must not post twice.
if SENT.swap(true, Ordering::Relaxed) {
return;
}
let _ = thread::Builder::new()
.name("Friends offline".into())
.spawn(|| {
let _ = post_presence(PresenceStatus::Offline);
});
}
/// `FriendsService.sendFriendRequest(name)`.
pub fn send_friend_request(name: &str) -> ResultCode {
friend_action(json!({"name": name, "updateType": "ADD"}))
}
/// `FriendsService.removeFriend`/`declineIncomingFriendRequest`/
/// `revokeOutgoingFriendRequest` (`updateType` REMOVE).
pub fn remove_friend(id: u128) -> ResultCode {
friend_action(json!({"profileId": uuid(id), "updateType": "REMOVE"}))
}
/// `FriendsService.acceptIncomingFriendRequest`: ADD by profile id.
pub fn accept_incoming_friend_request(id: u128) -> ResultCode {
friend_action(json!({"profileId": uuid(id), "updateType": "ADD"}))
}
/// `FriendsService.updateFriendSettings`: the account's flags, server-side.
pub fn update_friend_settings(friends_enabled: bool, allow_requests: bool) -> ResultCode {
let Some(session) = session() else {
return ResultCode::Unauthorized;
};
let body = json!({
"friendsPreferences": {
"friends": toggle(friends_enabled),
"acceptInvites": toggle(allow_requests),
}
});
let Some(url) = url("updateAttributes") else {
return ResultCode::ServiceNotAvailable;
};
match send_json(&session, "POST", &url, &body.to_string(), None) {
Ok(_) => ResultCode::Success,
Err(failure) => failure.code,
}
}
fn toggle(on: bool) -> &'static str {
if on { "ENABLED" } else { "DISABLED" }
}
/// `MinecraftServicesFriendsService.putFriendAction`: the answer already is the new
/// lists, as authlib caches it, and the handler reads again.
fn friend_action(body: Value) -> ResultCode {
let Some(session) = session() else {
return ResultCode::Unauthorized;
};
let Some(url) = url("updateFriends") else {
return ResultCode::ServiceNotAvailable;
};
let answer = match send_json(&session, "PUT", &url, &body.to_string(), None) {
Ok(answer) => answer,
Err(failure) => return failure.code,
};
let code = if answer.body.is_empty() {
ResultCode::Success
} else {
match parse_friends(&answer.body) {
Some(data) => {
let mut inner = lock();
inner.service.friends_cache = data;
inner.service.friends_etag = None;
inner.service.cooldown = Some(Instant::now() + Duration::from_secs(10));
ResultCode::Success
}
None => ResultCode::Error,
}
};
if code == ResultCode::Success {
// `PlayerSocialManager.runAction` forces a read after a successful action.
lock().handler.forced = true;
}
code
}
/// `RemoteFriendListUpdateHandler.start`'s worker: one read per interval while the list
/// is enabled, the first one as soon as it starts.
fn spawn_updater() {
static STARTED: AtomicBool = AtomicBool::new(false);
if STARTED.swap(true, Ordering::Relaxed) {
return;
}
let _ = thread::Builder::new()
.name("Friends List".into())
.spawn(|| {
loop {
thread::sleep(Duration::from_secs(1));
let due = {
let inner = lock();
if !inner.handler.enabled {
continue;
}
inner.handler.forced
|| inner
.handler
.last_update
.is_none_or(|at| at.elapsed() >= inner.handler.interval(&inner.service))
};
if due {
update_friend_data();
}
}
});
}
/// `RemoteFriendListUpdateHandler.runUpdateFriendDataInternal`.
fn update_friend_data() {
let etag = {
let mut inner = lock();
if inner.service.pending {
// Another read is in flight; its answer is the cache's.
return;
}
if !inner.service.can_make_request() {
inner.handler.forced = false;
inner.handler.state = State::Success;
inner.handler.latest = inner.service.friends_cache.clone();
inner.handler.last_update = Some(Instant::now());
return;
}
inner.service.pending = true;
inner.service.cooldown = Some(Instant::now() + Duration::from_secs(10));
inner.service.friends_etag.clone()
};
log::debug!("Performing Friends List update");
let result = match session() {
Some(session) => fetch_friends(&session, etag.as_deref()),
None => Err(Failure {
code: ResultCode::Unauthorized,
}),
};
let mut inner = lock();
inner.service.pending = false;
inner.handler.forced = false;
inner.handler.last_update = Some(Instant::now());
match result {
Ok(answer) => {
if let Some(retry) = answer.retry_after {
inner.service.friends_poll = Some(retry);
}
if let Some(etag) = answer.etag {
inner.service.friends_etag = Some(etag);
}
if answer.status != 304 && !answer.body.is_empty() {
match parse_friends(&answer.body) {
Some(data) => inner.service.friends_cache = data,
None => {
// authlib's parse failure is an ERROR result: the handler keeps
// the cached lists and shows the generic error.
inner.handler.latest = inner.service.friends_cache.clone();
inner.handler.state = State::GenericError;
return;
}
}
}
inner.handler.latest = inner.service.friends_cache.clone();
inner.handler.state = State::Success;
}
Err(failure) => {
inner.handler.latest = inner.service.friends_cache.clone();
inner.handler.state = State::of(failure.code);
log::warn!(
"Friends List update failed with result code: {:?}",
failure.code
);
}
}
}
/// `MinecraftServicesFriendsService.presence`: post one status, cache the answer.
/// `None` means the answer said nothing new (304, an empty body or a failure); the
/// handler keeps its last presence then.
fn post_presence(status: PresenceStatus) -> Option<Vec<Presence>> {
let session = session()?;
let url = url("updatePresence")?;
let body = json!({"status": status.wire()}).to_string();
let etag = lock().service.presence_etag.clone();
let answer = send_json(&session, "POST", &url, &body, etag.as_deref()).ok()?;
let mut inner = lock();
if let Some(retry) = answer.retry_after {
inner.service.presence_poll = Some(retry);
}
if let Some(etag) = answer.etag {
inner.service.presence_etag = Some(etag);
}
if answer.status == 304 || answer.body.is_empty() {
return None;
}
// `PresenceResponse`: a missing `presence` field is an empty list, replacing the
// cached one; malformed JSON keeps the cache, as authlib's exception path does.
if let Ok(value) = serde_json::from_str::<Value>(&answer.body) {
let presence = presence_of(&value);
inner.service.presence_cache = presence.clone();
return Some(presence);
}
None
}
/// `MinecraftServicesFriendsService.getFriendData`'s request.
fn fetch_friends(session: &AuthSession, etag: Option<&str>) -> Result<Answer, Failure> {
let url = url("getFriends").ok_or(Failure {
code: ResultCode::ServiceNotAvailable,
})?;
send_json(session, "GET", &url, "", etag)
}
/// One HTTP answer: its status, cache headers and body.
struct Answer {
status: u16,
etag: Option<String>,
retry_after: Option<Duration>,
body: String,
}
struct Failure {
code: ResultCode,
}
/// The discovery document's `player` service endpoint.
fn session() -> Option<Arc<AuthSession>> {
auth::current().filter(|s| !s.expired())
}
fn uuid(id: u128) -> String {
let hex = format!("{id:032x}");
format!(
"{}-{}-{}-{}-{}",
&hex[0..8],
&hex[8..12],
&hex[12..16],
&hex[16..20],
&hex[20..32]
)
}
fn url(name: &str) -> Option<String> {
let doc = auth::discovery().ok()?;
auth::endpoint(&doc, "player", name)
}
/// authlib's `MinecraftClient`: the bearer token, `If-None-Match` when a cache has an
/// `ETag`, and `Retry-After` back to the caller.
fn send_json(
session: &AuthSession,
method: &str,
url: &str,
body: &str,
etag: Option<&str>,
) -> Result<Answer, Failure> {
let bearer = format!("Bearer {}", session.access_token());
let agent = auth::agent();
let mut request = match method {
"GET" => agent.get(url),
"POST" => agent.post(url),
"PUT" => agent.put(url),
_ => {
return Err(Failure {
code: ResultCode::Error,
});
}
};
request = request.set("Authorization", &bearer);
if let Some(etag) = etag {
request = request.set("If-None-Match", etag);
}
let response = if method == "GET" {
request.call()
} else {
request
.set("Content-Type", "application/json; charset=utf-8")
.send_string(body)
}
.map_err(http_error)?;
let status = response.status();
let etag = response.header("ETag").map(str::to_owned);
let retry_after = response.header("Retry-After").and_then(parse_retry_after);
if status == 304 {
return Ok(Answer {
status,
etag,
retry_after,
body: String::new(),
});
}
let body = response.into_string().map_err(|_| Failure {
code: ResultCode::Error,
})?;
Ok(Answer {
status,
etag,
retry_after,
body,
})
}
/// authlib's `MinecraftClient.parseRetryAfter`: whole seconds above zero.
fn parse_retry_after(value: &str) -> Option<Duration> {
value
.trim()
.parse::<u64>()
.ok()
.filter(|seconds| *seconds > 0)
.map(Duration::from_secs)
}
/// `MinecraftServicesFriendsService.handleHttpError`.
fn http_error(error: ureq::Error) -> Failure {
let code = match error {
ureq::Error::Status(status, _) => match status {
429 => ResultCode::TooManyRequests,
500.. => ResultCode::ServiceNotAvailable,
403 => ResultCode::Forbidden,
400 => ResultCode::UnknownProfile,
401 => ResultCode::Unauthorized,
_ => ResultCode::Error,
},
ureq::Error::Transport(_) => ResultCode::ConnectionIssue,
};
Failure { code }
}
/// authlib's `FriendsListResponse` (`getFriends`/`updateFriends` body).
pub fn parse_friends(body: &str) -> Option<FriendData> {
let v: Value = serde_json::from_str(body).ok()?;
Some(FriendData {
friends: parse_friend_list(v.get("friends")),
incoming: parse_friend_list(v.get("incomingRequests")),
outgoing: parse_friend_list(v.get("outgoingRequests")),
})
}
fn parse_friend_list(value: Option<&Value>) -> Vec<Friend> {
value
.and_then(|v| v.as_array())
.into_iter()
.flatten()
.filter_map(|friend| {
Some(Friend {
id: auth::parse_uuid(friend.get("profileId")?.as_str()?)?,
name: friend.get("name")?.as_str()?.to_owned(),
})
})
.collect()
}
/// authlib's `PresenceResponse`.
pub fn parse_presence(body: &str) -> Option<Vec<Presence>> {
let v: Value = serde_json::from_str(body).ok()?;
Some(presence_of(&v))
}
/// `PresenceResponse.presence`: a missing list reads as empty, as Gson's null does.
fn presence_of(v: &Value) -> Vec<Presence> {
v.get("presence")
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter_map(|entry| {
let status = entry.get("status")?.as_str()?;
let status = match status {
"ONLINE" => PresenceStatus::Online,
"PLAYING_OFFLINE" => PresenceStatus::PlayingOffline,
"PLAYING_REALMS" => PresenceStatus::PlayingRealms,
"PLAYING_SERVER" => PresenceStatus::PlayingServer,
"PLAYING_HOSTED_SERVER" => PresenceStatus::PlayingHostedServer,
_ => PresenceStatus::Offline,
};
Some(Presence {
id: auth::parse_uuid(entry.get("profileId")?.as_str()?)?,
status,
})
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_friends_answer_reads_its_three_lists() {
let data = parse_friends(
r#"{"friends":[{"profileId":"0123456789abcdef0123456789abcdef","name":"Alex"}],
"incomingRequests":[],"outgoingRequests":[{"profileId":"fedcba9876543210fedcba9876543210","name":"Steve"}]}"#,
)
.unwrap();
assert_eq!(
data.friends,
vec![Friend {
id: 0x0123_4567_89ab_cdef_0123_4567_89ab_cdef,
name: "Alex".into()
}]
);
assert!(data.incoming.is_empty());
assert_eq!(data.outgoing[0].name, "Steve");
// The service's answer to an account with no friends, as the live check
// captured it: the three lists plus an `empty` flag the parser ignores.
let empty = parse_friends(
r#"{"empty":true,"friends":[],"incomingRequests":[],"outgoingRequests":[]}"#,
)
.unwrap();
assert_eq!(empty, FriendData::default());
}
#[test]
fn a_presence_answer_reads_each_status() {
let presence = parse_presence(
r#"{"presence":[
{"profileId":"0123456789abcdef0123456789abcdef","pmid":"00000000000000000000000000000000","status":"PLAYING_REALMS","lastUpdated":"2026-11-08T12:00:00Z"},
{"profileId":"fedcba9876543210fedcba9876543210","pmid":"00000000000000000000000000000000","status":"OFFLINE","lastUpdated":"2026-11-08T12:00:00Z"}]}"#,
)
.unwrap();
assert_eq!(presence[0].status, PresenceStatus::PlayingRealms);
assert_eq!(presence[1].status, PresenceStatus::Offline);
// `PresenceResponse` without its list is empty, not missing.
assert_eq!(parse_presence("{}"), Some(Vec::new()));
assert_eq!(parse_presence("not json"), None);
}
#[test]
fn result_codes_map_to_the_handlers_states() {
assert_eq!(State::of(ResultCode::Success), State::Success);
assert_eq!(
State::of(ResultCode::TooManyRequests),
State::TemporaryUnavailable
);
assert_eq!(
State::of(ResultCode::UnknownProfile),
State::UserMayLackActiveProfile
);
assert_eq!(State::of(ResultCode::UpgradeNeeded), State::UpgradeNeeded);
assert_eq!(
State::of(ResultCode::ConnectionIssue),
State::ConnectionIssue
);
}
#[test]
fn the_share_presence_option_maps_to_its_levels() {
assert_eq!(SharePresence::of(0), SharePresence::None);
assert_eq!(SharePresence::of(1), SharePresence::Limited);
assert_eq!(SharePresence::of(2), SharePresence::All);
assert_eq!(SharePresence::of(9), SharePresence::All);
}
#[test]
fn retry_after_reads_whole_positive_seconds() {
assert_eq!(parse_retry_after("30"), Some(Duration::from_secs(30)));
assert_eq!(parse_retry_after(" 5 "), Some(Duration::from_secs(5)));
assert_eq!(parse_retry_after("0"), None);
assert_eq!(parse_retry_after("later"), None);
}
}
+1 -1
View File
@@ -721,7 +721,7 @@ impl Sprites {
/// A PNG's width, height and pixels as RGBA. From the prepared data file
/// when it holds the texture: no file opened per texture, nor for its absent
/// LabPBR maps.
fn read_rgba(path: &Path) -> Result<(u32, u32, Vec<u8>)> {
pub(crate) fn read_rgba(path: &Path) -> Result<(u32, u32, Vec<u8>)> {
let bytes = horizon_core::prepared_files::read(path)?;
let mut decoder = png::Decoder::new(std::io::Cursor::new(bytes));
decoder.ignore_checksums(true);
+33 -39
View File
@@ -286,7 +286,7 @@ enum Job {
Decorate(i32, i32, Box<[Proto; 9]>, Vec<(usize, Chunk)>),
Finish(Proto),
Light(i32, i32, Box<Region>, u64, bool),
Mesh(i32, i32, i32, Box<Region>, u64, MeshSettings),
Mesh(i32, i32, i32, Arc<Region>, u64, MeshSettings),
/// Proto chunks checked out to be saved (`ChunkGenerator::save_protos`),
/// off the main thread like vanilla's IOWorker.
Save(Vec<((i32, i32), Proto)>),
@@ -1185,13 +1185,12 @@ impl Streaming {
/// The resident sections `LevelExtractor.allChanged` queues again. An
/// unlit column will queue itself when its light job completes.
fn mark_loaded_sections_dirty(world: &mut World, lit: &FxHashSet<(i32, i32)>) {
let mask = SectionMask::below(world.layout().sections());
for &(x, z) in world.chunks.keys() {
if !lit.contains(&(x, z)) {
continue;
}
for sy in 0..world.layout().sections() as i32 {
world.dirty_sections.insert((x, sy, z));
}
world.dirty_sections.insert_column_mask((x, z), mask);
}
}
/// A column received from a server, with the light it sent
@@ -1204,10 +1203,12 @@ impl Streaming {
world.chunks.insert(key, Arc::new(chunk));
self.lit.insert(key);
self.generated_total += 1;
let mask = SectionMask::below(world.layout().sections());
for dz in -1..=1 {
for dx in -1..=1 {
for sy in 0..world.layout().sections() as i32 {
world.dirty_sections.insert((key.0 + dx, sy, key.1 + dz));
let n = (key.0 + dx, key.1 + dz);
if n == key || world.chunks.contains_key(&n) {
world.dirty_sections.insert_column_mask(n, mask);
}
}
}
@@ -1525,7 +1526,7 @@ impl Streaming {
z,
)
}
fn region(world: &World, x: i32, z: i32) -> Option<Box<Region>> {
fn region(world: &World, x: i32, z: i32) -> Option<Arc<Region>> {
let mut region: Region = Default::default();
for dz in 0..3 {
for dx in 0..3 {
@@ -1537,7 +1538,7 @@ impl Streaming {
);
}
}
Some(Box::new(region))
Some(Arc::new(region))
}
pub fn is_lit(&self, x: i32, z: i32) -> bool {
self.lit.contains(&(x, z))
@@ -1937,8 +1938,8 @@ impl Streaming {
}) {
continue;
}
let keys: Vec<(i32, i32)> =
(0..9).map(|i| (x + (i % 3) - 1, z + (i / 3) - 1)).collect();
let keys: [(i32, i32); 9] =
std::array::from_fn(|i| (x + (i as i32 % 3) - 1, z + (i as i32 / 3) - 1));
// A final neighbour cannot be generated again: a generator that
// can decorates beside it (a chunk of an older world), writing
// into it as vanilla's features do; the others skip decoration
@@ -1947,40 +1948,33 @@ impl Streaming {
if keys.iter().any(|k| self.decorating_finals.contains(k)) {
continue;
}
let has_final = keys.iter().any(|k| world.chunks.contains_key(k));
if has_final && !self.saves_beside_final {
self.decorated.insert((x, z));
waiting.borrow_mut().insert((x, z), false);
continue;
}
if !keys.iter().all(|k| {
world.chunks.contains_key(k) || matches!(self.protos.get(k), Some(Some(_)))
}) {
continue;
}
let finals: Vec<(usize, Chunk)> = keys
.iter()
.enumerate()
.filter_map(|(i, k)| Some((i, (**world.chunks.get(k)?).clone())))
.collect();
if !finals.is_empty() && !self.saves_beside_final {
self.decorated.insert((x, z));
waiting.borrow_mut().insert((x, z), false);
continue;
}
if !keys.iter().enumerate().all(|(i, k)| {
finals.iter().any(|(f, _)| *f == i)
|| matches!(self.protos.get(k), Some(Some(_)))
}) {
continue;
}
let region: Vec<Proto> = keys
.iter()
.enumerate()
.map(|(i, k)| {
if finals.iter().any(|(f, _)| *f == i) {
return Box::new(()) as Proto;
}
self.light_views.remove(k);
self.protos
.insert(*k, None)
.flatten()
.expect("present proto")
})
.collect();
let region: Box<[Proto; 9]> = match region.try_into() {
Ok(r) => Box::new(r),
Err(_) => continue,
};
let region: Box<[Proto; 9]> = Box::new(std::array::from_fn(|i| {
let k = &keys[i];
if finals.iter().any(|(f, _)| *f == i) {
return Box::new(()) as Proto;
}
self.light_views.remove(k);
self.protos
.insert(*k, None)
.flatten()
.expect("present proto")
}));
self.decorating += 1;
self.decorating_finals
.extend(finals.iter().map(|(i, _)| keys[*i]));
+14 -8
View File
@@ -40,7 +40,7 @@ struct Globals {
hdr_windows: array<vec4<f32>, 2>,
// HDR lighting: x how strongly the sky lights the world now (`Sky::sky_light_factor`),
// y what the sky and fog get: the same, but none under water; z 1 with HDR output on.
// w: this entire frame writes light, so alpha is coverage rather than a mark.
// w: apply the no-pack HDR gain; HDR_WRITE_LIGHT independently keeps float alpha as coverage.
hdr_light: vec4<f32>,
hdr_settings: vec4<f32>,
hdr_shares: vec4<f32>,
@@ -762,11 +762,14 @@ fn solid_color(in: TerrainOut) -> vec4<f32> {
fn solid_finish(in: TerrainOut, texel: vec4<f32>, color: vec4<f32>) -> vec4<f32> {
return solid_light(solid_finish_at(in, texel, color, dpdx(in.uv), dpdy(in.uv)));
}
// A finished solid pixel as the frame keeps it: its mark in alpha, or on the written lane its
// light, alpha 1.
// A finished solid pixel as the frame keeps it: on the float frame its colour, alpha 1, lit by
// HDR's gain where it applies (`hdr_light.w`); else its mark in alpha.
fn solid_light(finished: vec4<f32>) -> vec4<f32> {
if HDR_WRITE_LIGHT && globals.hdr_light.w != 0.0 {
return vec4(hdr_written(finished.rgb, finished.a, globals.hdr_settings, globals.hdr_shares), 1.0);
if HDR_WRITE_LIGHT {
if globals.hdr_light.w != 0.0 {
return vec4(hdr_written(finished.rgb, finished.a, globals.hdr_settings, globals.hdr_shares), 1.0);
}
return vec4(finished.rgb, 1.0);
}
return finished;
}
@@ -915,7 +918,7 @@ struct NativeModelOut {
color = vec4(color.rgb * shade, color.a);
}
let finished = apply_fog(color, in.spherical, in.cylindrical);
return vec4(finished.rgb, select(finished.a, 1.0, HDR_WRITE_LIGHT && globals.hdr_light.w != 0.0));
return vec4(finished.rgb, select(finished.a, 1.0, HDR_WRITE_LIGHT));
}
// Sky (SkyRenderer): the 10-vertex sky disc fan at y = 16 (radius 512) in
@@ -1050,8 +1053,11 @@ fn sky_along_ray_base(d: vec3<f32>) -> vec3<f32> {
}
fn sky_finished(color: vec3<f32>) -> vec4<f32> {
let mark = 1.0 - 0.24 * globals.hdr_light.y;
if HDR_WRITE_LIGHT && globals.hdr_light.w != 0.0 {
return vec4(hdr_written(color, mark, globals.hdr_settings, globals.hdr_shares), 1.0);
if HDR_WRITE_LIGHT {
if globals.hdr_light.w != 0.0 {
return vec4(hdr_written(color, mark, globals.hdr_settings, globals.hdr_shares), 1.0);
}
return vec4(color, 1.0);
}
return vec4(color, mark);
}
+23 -16
View File
@@ -1057,9 +1057,9 @@ pub fn tooltip(batch: &mut HudBatch, atlas: &HudAtlas, lines: &[Text], cursor: (
}
tooltip_box(batch, atlas, lines, x, y, w, h);
}
/// `WidgetTooltipHolder.createTooltipPositioner`'s focused branch
/// (`MenuTooltipPositioner`): a widget with the keyboard focus keeps the
/// pointer where it is, and its tooltip stays against the widget at `rect`.
/// `WidgetTooltipHolder.createTooltipPositioner` for a hovered widget
/// (`MenuTooltipPositioner`): the tooltip follows the pointer's x and keeps
/// 3 to 5 px from the widget at `rect`.
pub fn tooltip_menu(
batch: &mut HudBatch,
atlas: &HudAtlas,
@@ -1132,14 +1132,16 @@ pub fn menu_tooltip_position(
}
pos
}
/// `MenuTooltipPositioner.getOffset`: how far the tooltip keeps from the
/// widget's top (or bottom) edge, from `MAX_DISTANCE_TO_WIDGET` 5 to
/// `MAX_OVERLAP_WITH_WIDGET` 3 as the pointer nears the edge.
/// The distance over the height is an int division in vanilla, so the lerp
/// is `height - 3` until the pointer is a whole height away, then 5.
/// `MenuTooltipPositioner.getOffset`: how far the tooltip goes past the
/// widget's top (or bottom) edge, `height - 3` (`MAX_OVERLAP_WITH_WIDGET`)
/// with the pointer on the edge, down to `MAX_DISTANCE_TO_WIDGET` 5 a whole
/// height away. The distance over the height is a float division (`i2f`,
/// `fdiv` in 26.3's bytecode), so the offset moves smoothly with the pointer;
/// then `Mth.lerp` and Java's float rounding.
fn widget_offset(at: i32, edge: i32, height: i32) -> i32 {
let d = ((at - edge).unsigned_abs().min(height as u32) / height as u32) as f32;
((height as f32 - 3.0) + d * (5.0 - (height as f32 - 3.0))).round() as i32
let distance = (at - edge).unsigned_abs().min(height as u32) as f32;
let start = (height - 3) as f32;
horizon_core::jmath::round_f32(start + distance / height as f32 * (5.0 - start))
}
fn tooltip_size(atlas: &HudAtlas, lines: &[Text]) -> (f32, f32) {
let w = lines
@@ -2487,16 +2489,21 @@ mod tests {
let screen = (427, 240);
let widget = [100, 100, 150, 20];
let size = (80, 10);
// Pointer at the widget's top edge: the tooltip drops 3 px, then
// `getOffset`'s int division gives `height - 3` = 17 more.
// Pointer at the widget's top edge: the tooltip drops 3 px to 103,
// then `getOffset` adds round(lerp(3 / 20, 17, 5)) = round(15.2) = 15.
assert_eq!(
menu_tooltip_position(screen, (100, 100), widget, size),
(112, 120)
(112, 118)
);
// Near the right edge its left side mirrors to the pointer's left.
assert_eq!(
menu_tooltip_position(screen, (400, 100), widget, size),
(308, 120)
(308, 118)
);
// Halfway down the offset follows the pointer: 113 + round(9.2).
assert_eq!(
menu_tooltip_position(screen, (100, 110), widget, size),
(112, 122)
);
// A whole widget height from the edge, the offset is 5.
assert_eq!(
@@ -2504,10 +2511,10 @@ mod tests {
(112, 125)
);
// A widget too close to the bottom: the tooltip goes above it,
// 203 - (10 + 6) - 17.
// 203 - (10 + 6) - round(lerp(17 / 20, 17, 5)) = 203 - 16 - 7.
assert_eq!(
menu_tooltip_position(screen, (100, 200), [100, 200, 150, 20], size),
(112, 170)
(112, 180)
);
}
}
@@ -107,6 +107,9 @@ thread_local! {
static SCROLL_AREAS: RefCell<(Vec<ScrollArea>, Vec<ScrollArea>)> = const { RefCell::new((Vec::new(), Vec::new())) };
/// The steps screens declared since `end_frame` ([`focus_link`]), and the last frame's.
static FOCUS_LINKS: RefCell<(Vec<FocusLink>, Vec<FocusLink>)> = const { RefCell::new((Vec::new(), Vec::new())) };
/// The targets whose value left and right step ([`steps_value`]) since `end_frame`, and the
/// last frame's.
static STEPPERS: RefCell<(Vec<[f32; 4]>, Vec<[f32; 4]>)> = const { RefCell::new((Vec::new(), Vec::new())) };
}
/// Splitscreen: one player's buttons and focus targets, kept aside while another player's game
@@ -120,6 +123,7 @@ pub struct Saved(
(u64, u64),
(Vec<ScrollArea>, Vec<ScrollArea>),
(Vec<FocusLink>, Vec<FocusLink>),
(Vec<[f32; 4]>, Vec<[f32; 4]>),
);
/// This thread's buttons and targets, leaving none.
@@ -132,6 +136,7 @@ pub fn take() -> Saved {
ACTION_SCREENS.take(),
SCROLL_AREAS.take(),
FOCUS_LINKS.take(),
STEPPERS.take(),
)
}
@@ -141,6 +146,7 @@ pub fn put(saved: Saved) {
TARGETS.set(saved.1);
SCROLL_AREAS.set(saved.5);
FOCUS_LINKS.set(saved.6);
STEPPERS.set(saved.7);
ACTIONS.set(saved.2);
ACTION_GLYPHS.set(saved.3);
ACTION_SCREENS.set(saved.4);
@@ -288,6 +294,7 @@ pub fn cover_frame() {
ACTIONS.with_borrow_mut(|(current, _)| current.clear());
SCROLL_AREAS.with_borrow_mut(|(current, _)| current.clear());
FOCUS_LINKS.with_borrow_mut(|(current, _)| current.clear());
STEPPERS.with_borrow_mut(|(current, _)| current.clear());
}
/// This frame drew a list with something to scroll, in `rect`, scrolled `amount`
@@ -353,6 +360,18 @@ pub fn linked_step(focus: [f32; 4], down: bool) -> Option<[f32; 4]> {
})
}
/// Left and right on the target at `rect` step its value, back and on, rather than move the focus
/// (the settings shell's choices, whose `<` `>` say so): Right as a press, Left as a Shift press.
pub fn steps_value(rect: [f32; 4]) {
STEPPERS.with_borrow_mut(|(current, _)| current.push(rect));
}
/// Whether the last drawn frame said left and right step the value of the target at `focus`.
pub fn last_steps_value(focus: [f32; 4]) -> bool {
let same = |rect: &[f32; 4]| rect.iter().zip(focus).all(|(a, b)| (a - b).abs() < 0.5);
STEPPERS.with_borrow(|(_, last)| last.iter().any(same))
}
/// The focus targets of the last drawn frame.
pub fn last_targets() -> Vec<Target> {
TARGETS.with_borrow(|(_, last, _)| last.clone())
@@ -433,6 +452,9 @@ pub fn end_frame(frames: u64) {
FOCUS_LINKS.with_borrow_mut(|(current, last)| {
*last = std::mem::take(current);
});
STEPPERS.with_borrow_mut(|(current, last)| {
*last = std::mem::take(current);
});
NARRATION.with_borrow_mut(|(current, last)| {
*last = std::mem::replace(current, Narration::EMPTY);
});
@@ -21,6 +21,10 @@ impl DirtySections {
debug_assert!((0..SectionMask::CAPACITY as i32).contains(&sy));
self.columns.entry((x, z)).or_default().insert(sy as usize);
}
/// Mark all sections in `mask` dirty for column `(x, z)`.
pub fn insert_column_mask(&mut self, (x, z): (i32, i32), mask: SectionMask) {
*self.columns.entry((x, z)).or_default() |= mask;
}
pub fn contains(&self, &(x, sy, z): &(i32, i32, i32)) -> bool {
(0..SectionMask::CAPACITY as i32).contains(&sy)
&& self
+35 -5
View File
@@ -17,6 +17,9 @@ use zip::{CompressionMethod, ZipArchive};
/// The most one entry inflates to: far above any real pack file, but a zip
/// bomb stops here.
const MAX_ENTRY: usize = 1 << 30;
/// The most every entry inflates to together, once per archive: per-entry
/// limits still let a bomb spend its budget one 1 GiB entry at a time.
const MAX_TOTAL: usize = 4 << 30;
/// One archive with its entries indexed by name (`\` read as `/`), files
/// and folders apart.
@@ -27,6 +30,8 @@ pub struct Zip {
folders: BTreeMap<String, usize>,
/// The archive's length: no size an entry claims can be bigger.
len: usize,
/// The bytes left for every entry together, drawn down as they are read.
budget: AtomicU64,
}
fn invalid(e: impl std::fmt::Display) -> io::Error {
io::Error::new(io::ErrorKind::InvalidData, e.to_string())
@@ -147,6 +152,7 @@ impl Zip {
entries,
folders,
len,
budget: AtomicU64::new(MAX_TOTAL as u64),
})
}
/// The central directory on, as read at opening: every entry's name, sizes
@@ -172,9 +178,15 @@ impl Zip {
self.index(name).is_some()
}
pub fn read(&self, name: &str) -> io::Result<Vec<u8>> {
self.read_within(name, &self.budget)
}
/// `read`, drawing `budget` down as entries are returned: a per-entry cap
/// is not a total one, and a bomb spends its budget one 1 GiB entry at a
/// time otherwise.
fn read_within(&self, name: &str, budget: &AtomicU64) -> io::Result<Vec<u8>> {
let (method, raw) = self.raw(name)?;
match method {
CompressionMethod::Stored => Ok(raw),
let bytes = match method {
CompressionMethod::Stored => raw,
CompressionMethod::Deflated => {
let mut decoded = Vec::with_capacity(raw.len().min(MAX_ENTRY));
inflate(&raw)
@@ -184,10 +196,12 @@ impl Zip {
if decoded.len() > MAX_ENTRY {
return Err(invalid("entry inflates past the size limit"));
}
Ok(decoded)
decoded
}
_ => Err(invalid("unsupported zip compression")),
}
_ => return Err(invalid("unsupported zip compression")),
};
take(budget, bytes.len() as u64)?;
Ok(bytes)
}
/// What [`Self::read`] reads, written to `out` a piece at a time as it inflates, each piece
/// taken from `budget`: the bytes left for everything sharing it. Past the budget it stops
@@ -450,6 +464,22 @@ mod tests {
assert!(zip.read("missing").is_err());
}
/// A per-entry cap is not a total one: an archive whose entries together
/// inflate past the budget is refused, whatever each entry alone says.
#[test]
fn entries_share_one_total_inflated_budget() {
let zip = Zip::from_bytes(archive()).unwrap();
let tight = AtomicU64::new(16);
assert!(zip.read_within("a/b.txt", &tight).is_err());
let zip = Zip::from_bytes(archive()).unwrap();
let tight = AtomicU64::new(12);
assert_eq!(
zip.read_within("pack.mcmeta", &tight).unwrap(),
b"{\"pack\":{}}"
);
assert!(zip.read_within("a/b.txt", &tight).is_err());
}
/// The offset of the central-directory header of `name`.
fn central_header(bytes: &[u8], name: &str) -> usize {
let mut at = 0;
@@ -5,7 +5,7 @@
//! usage: environment_fixture RESOURCES INPUT OUTPUT
use horizon_client::{
biome::Biomes,
environment::{Environment, apply_weather},
environment::{Environment, Sample},
};
use std::{collections::HashMap, fmt::Write as _, fs, path::Path};
@@ -22,22 +22,22 @@ pub fn main() -> horizon_client::Result<()> {
for line in input.lines() {
let f: Vec<&str> = line.split(' ').collect();
let dimension = f[0];
let environment =
match environments.entry(dimension.to_owned()) {
std::collections::hash_map::Entry::Occupied(e) => e.into_mut(),
std::collections::hash_map::Entry::Vacant(e) => e.insert(
Environment::load_dimension(root, dimension, dimension == "overworld")?,
),
};
let environment = match environments.entry(dimension.to_owned()) {
std::collections::hash_map::Entry::Occupied(e) => e.into_mut(),
std::collections::hash_map::Entry::Vacant(e) => {
e.insert(Environment::load_dimension(root, dimension)?)
}
};
let info = biomes.get(biomes.id(&format!("minecraft:{}", f[4])));
let mut sky = environment.sample(
f[1].parse()?,
info.sky.map(horizon_client::biome::rgb),
info.fog.map(horizon_client::biome::rgb),
);
if dimension == "overworld" {
apply_weather(&mut sky, f[2].parse()?, f[3].parse()?);
}
// The weather layers only exist where the dimension can have weather.
let sky = environment.sample(&Sample {
day_time: f[1].parse()?,
rain: f[2].parse()?,
thunder: f[3].parse()?,
blend: None,
biome: Some(&info.attributes),
..Sample::default()
});
let v3 = |c: [f32; 3]| format!("[{:?},{:?},{:?}]", c[0], c[1], c[2]);
let v4 = |c: [f32; 4]| format!("[{:?},{:?},{:?},{:?}]", c[0], c[1], c[2], c[3]);
let mut row = format!("{{\"case\":\"{line}\"");
@@ -72,6 +72,7 @@ pub fn main() -> horizon_client::Result<()> {
"visual/cloud_fog_end_distance",
format!("{:?}", sky.cloud_fog_end),
);
put("visual/cloud_height", format!("{:?}", sky.cloud_height));
out.push_str(&row);
out.push_str("}\n");
}
@@ -96,6 +96,8 @@ fn run(
blend: blended.then_some(&blend),
biome: &biome,
clock: &|_| case["ticks"].as_i64().unwrap_or(0),
post_blend: None,
post_biome: None,
};
for attribute in ATTRIBUTES.iter().filter(|a| a.kind != Kind::Other) {
let value = system.value(attribute, &conditions).ok_or("no value")?;
@@ -86,6 +86,7 @@ mod particle_providers_probe;
mod pick_block_data_fixture;
mod place_recipe_fixture;
mod player_sync_fixture;
mod portal_tickets_fixture;
mod raid_fixture;
mod random_tick_sections_probe;
mod recipe_book_fixture;
@@ -100,6 +101,7 @@ mod screen_layout_fixture;
mod section_mesh_fixture;
mod server_files_probe;
mod sheep_dye_fixture;
mod shulker_bullet_probe;
mod situational_music_fixture;
mod skin_image_fixture;
mod sound_engine_fixture;
@@ -202,6 +204,7 @@ probes! {
pick_block_data_fixture,
place_recipe_fixture,
player_sync_fixture,
portal_tickets_fixture,
raid_fixture,
random_tick_sections_probe,
recipe_book_fixture,
@@ -219,6 +222,7 @@ probes! {
skin_image_fixture,
late_entity_pair_fixture,
sheep_dye_fixture,
shulker_bullet_probe,
allay_interaction_fixture,
entity_sound_packet_fixture,
sound_engine_fixture,
@@ -34,10 +34,13 @@ pub fn main() -> Result<(), Box<dyn std::error::Error>> {
let args: Vec<_> = crate::args().collect();
let input = fs::read_to_string(&args[1])?;
let mut lines = input.lines();
let root: Vec<&str> = lines.next().ok_or("empty input")?.split(' ').collect();
let vanilla = PathBuf::from(root[1]);
let ids: Vec<String> = root[3..].iter().map(|s| (*s).to_owned()).collect();
let all = packs::discover(&vanilla, &PathBuf::from(root[2]));
let root: Vec<String> = serde_json::from_str(lines.next().ok_or("empty input")?)?;
if root.len() < 3 || root[0] != "root" {
return Err("invalid resource-pack header".into());
}
let vanilla = PathBuf::from(&root[1]);
let ids = root[3..].to_vec();
let all = packs::discover(&vanilla, &PathBuf::from(&root[2]));
let enabled = packs::enabled(&all, &ids);
let overlay = PathBuf::from(&args[2]).with_extension("overlay");
packs::build_overlay(&vanilla, &enabled, &overlay)?;
@@ -0,0 +1,100 @@
//! A level's saved chunk tickets through `chunk_tickets.dat`, for VanillaPortalTicketsOracle:
//! each case's file is loaded, its `PORTAL` tickets ticked, refreshed and reloaded over a
//! restart, and every step prints what the file then holds and which of the case's chunks a
//! `PORTAL` ticket holds now.
use horizon_client::save::{LevelShape, SaveDir, read_dat, write_dat};
use horizon_client::ticket_levels::ChunkTickets;
use horizon_world::nbt::{Tag, parse_snbt};
use serde_json::Value;
use std::collections::HashSet;
use std::io::Write;
use std::path::Path;
fn chunk(pos: &Value) -> (i32, i32) {
(
pos[0].as_i64().unwrap() as i32,
pos[1].as_i64().unwrap() as i32,
)
}
/// Save the level's tickets, then list each saved one (type, level, chunk and ticks_left,
/// "-" where it is left out) and "held x z" for each of `chunks` a `PORTAL` ticket holds.
fn snapshot(
(save, file): (&SaveDir, &Path),
forced: &HashSet<(i32, i32)>,
tickets: &ChunkTickets,
case: &Value,
) -> Vec<String> {
save.save_chunk_tickets(forced, tickets.portal_tickets())
.unwrap();
let data = read_dat(file).unwrap();
let mut rows = Vec::new();
if let Some(Tag::List(saved)) = data.get("data").and_then(|d| d.get("tickets")) {
for t in saved {
let (Some(Tag::String(kind)), Some(Tag::Int(level)), Some(Tag::IntArray(pos))) =
(t.get("type"), t.get("level"), t.get("chunk_pos"))
else {
panic!("unexpected ticket {t:?}")
};
let left = match t.get("ticks_left") {
Some(Tag::Long(n)) => n.to_string(),
None => "-".into(),
Some(other) => format!("{other:?}"),
};
let kind = kind.to_string_lossy();
rows.push(format!("{kind} {level} {} {} {left}", pos[0], pos[1]));
}
}
for (x, z) in case["chunks"].as_array().unwrap().iter().map(chunk) {
if tickets.portal_tickets().iter().any(|t| t.chunk == (x, z)) {
rows.push(format!("held {x} {z}"));
}
}
rows.sort();
rows
}
pub fn main() {
let args: Vec<_> = super::args().collect();
let input = std::fs::read_to_string(&args[1]).unwrap();
let mut out = std::fs::File::create(&args[2]).unwrap();
let root = std::env::temp_dir().join(format!("horizon-portal-tickets-{}", std::process::id()));
for line in input.lines() {
let _ = std::fs::remove_dir_all(&root);
let case: Value = serde_json::from_str(line).unwrap();
let (data, _) = parse_snbt(case["saved"].as_str().unwrap()).unwrap();
let file = root.join("data/minecraft/chunk_tickets.dat");
write_dat(&file, Tag::compound([("data", data)])).unwrap();
// How the game opens a level (`world_loading::make_streaming`, `open_level`).
let save = SaveDir::new(root.clone(), LevelShape::OVERWORLD);
let mut forced = save.load_forced_chunks();
let mut tickets = ChunkTickets::with_saved_portal(save.load_portal_tickets());
let level = (&save, file.as_path());
let mut trace = vec![snapshot(level, &forced, &tickets, &case)];
for op in case["ops"].as_array().unwrap() {
match op["op"].as_str().unwrap() {
"tick" => {
let loading: HashSet<_> = op["loading"]
.as_array()
.into_iter()
.flatten()
.map(chunk)
.collect();
for _ in 0..op["times"].as_u64().unwrap_or(1) {
tickets.tick_portal(|t| !loading.contains(&t.chunk));
}
}
"portal" => tickets.add_portal(chunk(&op["pos"])),
// The last snapshot saved them: the level opens again from the file.
"restart" => {
forced = save.load_forced_chunks();
tickets = ChunkTickets::with_saved_portal(save.load_portal_tickets());
}
other => panic!("unknown operation {other}"),
}
trace.push(snapshot(level, &forced, &tickets, &case));
}
writeln!(out, "{}", serde_json::to_string(&trace).unwrap()).unwrap();
}
let _ = std::fs::remove_dir_all(&root);
}
@@ -0,0 +1,126 @@
//! Shulker bullets for tools/test_shulker_bullet_parity.py: where a shulker's
//! bullet starts (`shulker::bullet_origin` through `Projectile::shulker_bullet`)
//! and the block test that turns it in flight
//! (`collisions::projectile_can_stand_on`), one line per fixture.
#![forbid(unsafe_code)]
use horizon_client::{
Result, collisions,
mobs::{Mob, MobTypes, Who, shulker},
projectiles::{Projectile, ProjectileWorld},
world::World,
};
use horizon_core::{
geometry::{Aabb, Vec3},
position::BlockPos,
};
use horizon_world::{chunk::BlockState, registry::BlockStateId};
use serde_json::Value;
use std::{
fs::File,
io::{BufRead, BufReader, BufWriter, Write},
path::Path,
sync::Arc,
};
/// The bullet's first move only asks which neighbours are empty: all are.
struct Open(Vec<(Who, Aabb)>);
impl ProjectileWorld for Open {
fn clip(&self, _: Vec3, _: Vec3) -> Option<(Vec3, [i32; 3], [i32; 3])> {
None
}
fn in_water(&self, _: Aabb) -> bool {
false
}
fn targets(&self) -> &[(Who, Aabb)] {
&self.0
}
}
fn vector(v: &Value) -> Vec3 {
let n = |i: usize| v[i].as_f64().unwrap();
Vec3::new(n(0), n(1), n(2))
}
/// `"minecraft:oak_slab[type=top]"` as a registry state.
fn parse_state(text: &str) -> BlockState {
let (name, properties) = text.split_once('[').unwrap_or((text, ""));
BlockState {
name: name.into(),
properties: properties
.trim_end_matches(']')
.split(',')
.filter(|p| !p.is_empty())
.map(|p| {
let (key, value) = p.split_once('=').unwrap();
(key.to_string(), value.to_string())
})
.collect(),
}
}
pub fn main() -> Result<()> {
let args: Vec<_> = crate::args().collect();
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client")
.join(horizon_core::VANILLA_VERSION);
let registry = horizon_world::level::data::load_registry(&root)?;
let binding = collisions::load(&root, &registry)?;
let types = MobTypes::load(&root)?;
let kind = Arc::new(
types
.types
.get("minecraft:shulker")
.ok_or("no shulker")?
.clone(),
);
let mut out = BufWriter::new(File::create(&args[2])?);
for line in BufReader::new(File::open(&args[1])?).lines() {
let f: Value = serde_json::from_str(&line?)?;
if f["mode"] == "origin" {
let mut m = Mob::new(1, kind.clone(), vector(&f["position"]), 0.0);
m.extra.variant3 = f["face"].as_u64().unwrap() as u8;
m.anim.v[shulker::PEEK] = f32::from_bits(f["peek"].as_u64().unwrap() as u32);
m.extra.model_scale = f["scale"].as_f64().unwrap() as f32;
let target = Aabb::new(Vec3::new(-0.5, 0.0, -0.5), Vec3::new(0.5, 1.0, 0.5))
.move_by(vector(&f["target"]));
let axis = match m.extra.variant3 {
0 | 1 => 1,
2 | 3 => 2,
_ => 0,
};
let world = Open(vec![(Who::Mob(2), target)]);
let at = shulker::bullet_origin(&m);
let p = Projectile::shulker_bullet(&world, at, Who::Mob(1), Who::Mob(2), axis, 1);
let b = p.position;
writeln!(
out,
"{:016x} {:016x} {:016x}",
b.x.to_bits(),
b.y.to_bits(),
b.z.to_bits()
)?;
} else {
let pos = &f["pos"];
let pos = BlockPos::new(
pos[0].as_i64().unwrap() as i32,
pos[1].as_i64().unwrap() as i32,
pos[2].as_i64().unwrap() as i32,
);
let mut world = World::new(BlockStateId(0), 0);
let chunk = (pos.x >> 4, pos.z >> 4);
world.chunks.insert(
chunk,
Arc::new(horizon_client::chunk::Chunk::empty(chunk.0, chunk.1)),
);
let state = registry.resolve(&parse_state(f["block"].as_str().unwrap()))?;
world.set(pos.x, pos.y, pos.z, state)?;
let y = f["y"].as_f64().unwrap();
writeln!(
out,
"{}",
collisions::projectile_can_stand_on(&world, &binding, pos, y)
)?;
}
}
Ok(())
}
+182 -2
View File
@@ -628,6 +628,11 @@ pub trait World {
fn block_entity_nbt(&self, _pos: [i32; 3]) -> Option<horizon_world::nbt::Tag> {
None
}
/// Read-only contents of a loaded named predicate, when this context
/// supplies a predicate registry. Other contexts keep references absent.
fn predicate(&self, _id: &str) -> Option<&Value> {
None
}
}
/// An item stack as predicates see it.
#[derive(Clone, Debug, Default)]
@@ -642,9 +647,18 @@ pub struct ItemView {
/// family unprojected and unsupported; an empty compound is actual absence.
pub custom_data: Option<horizon_world::nbt::Tag>,
}
/// Query snapshot of supported known movement, in blocks/tick, and actual fall distance.
#[derive(Clone, Copy, Debug)]
pub struct MovementView {
pub per_tick: [f64; 3],
pub fall_distance: f64,
}
/// An entity as predicates see it.
#[derive(Clone, Debug, Default)]
pub struct EntityView {
/// None is unprojected/unsupported, not a fabricated stationary entity.
pub movement: Option<MovementView>,
pub kind: String,
pub position: [f64; 3],
pub on_fire: bool,
@@ -1010,6 +1024,51 @@ pub fn distance(v: &Value, a: [f64; 3], b: [f64; 3]) -> bool {
(d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt(),
)
}
/// `MinMaxBounds.Doubles.matches` and `matchesSqr`, only for movement predicates.
fn movement_range(v: &Value, value: f64, squared: bool) -> bool {
let (mut min, mut max) = match v {
Value::Null => (None, None),
Value::Number(n) => {
let Some(n) = n.as_f64() else { return false };
(Some(n), Some(n))
}
Value::Object(o) => {
let read = |key| match o.get(key) {
None => Some(None),
Some(n) => n.as_f64().map(Some),
};
let (Some(min), Some(max)) = (read("min"), read("max")) else {
return false;
};
(min, max)
}
_ => return false,
};
if squared {
min = min.map(|n| n * n);
max = max.map(|n| n * n);
}
if min.is_some_and(|n| n > value) {
return false;
}
max.is_none_or(|n| n >= value)
}
/// `MovementPredicate.matches`: known movement is scaled by 20 before the ordered tests.
fn movement(p: &Value, m: MovementView) -> bool {
if !p.is_object() {
return false;
}
let [x, y, z] = m.per_tick.map(|v| v * 20.0);
movement_range(&p["x"], x, false)
&& movement_range(&p["y"], y, false)
&& movement_range(&p["z"], z, false)
&& movement_range(&p["speed"], x * x + y * y + z * z, true)
&& movement_range(&p["horizontal_speed"], x * x + z * z, true)
&& movement_range(&p["vertical_speed"], y.abs(), false)
&& movement_range(&p["fall_distance"], m.fall_distance, false)
}
/// `EntityPredicate` (26.x keyed sub-predicates).
pub fn entity(v: &Value, e: &EntityView, w: &dyn World, origin: [f64; 3]) -> bool {
if v.is_null() {
@@ -1027,6 +1086,7 @@ pub fn entity(v: &Value, e: &EntityView, w: &dyn World, origin: [f64; 3]) -> boo
location(p, [e.position[0], e.position[1] - 0.5, e.position[2]], w)
}
"minecraft:distance" => distance(p, e.position, origin),
"minecraft:movement" => e.movement.is_some_and(|m| movement(p, m)),
"minecraft:flags" => {
let f = |k: &str, have: bool| p[k].as_bool().is_none_or(|want| want == have);
f("is_on_fire", e.on_fire)
@@ -1402,9 +1462,63 @@ pub fn recipe_ingredients(v: &Value, used: &[ItemView], w: &dyn World) -> bool {
}
/// A `ContextAwarePredicate`: a list of loot conditions (or one condition).
/// Existing callers have no random context and keep their leaf behavior.
/// Invalid references are distinct from false: inversion must not accept them.
pub fn conditions(v: &Value, ctx: &Entities, w: &dyn World) -> bool {
conditions_with(v, ctx, &mut |v, ctx| condition_leaf(v, ctx, w))
references_valid(v, w) && conditions_with(v, ctx, &mut |v, ctx| condition_leaf(v, ctx, w))
}
/// Every named predicate `v` reaches is loaded, and none reaches itself.
pub fn references_valid(v: &Value, w: &dyn World) -> bool {
predicate_references(v, w, &mut BTreeSet::new()).is_ok()
}
enum InvalidReference {
Missing,
Cycle,
}
/// `Validatable.validateHolder`'s active-name rule, as a defensive check on
/// this evaluation context. Check every typed reference before boolean
/// short-circuiting; this does not implement registry/startup admission.
fn predicate_references(
v: &Value,
w: &dyn World,
active: &mut BTreeSet<String>,
) -> Result<(), InvalidReference> {
match v {
Value::String(id) => {
let id = full(id).into_owned();
if !active.insert(id.clone()) {
return Err(InvalidReference::Cycle);
}
let result = w
.predicate(&id)
.ok_or(InvalidReference::Missing)
.and_then(|v| predicate_references(v, w, active));
active.remove(&id);
result
}
Value::Array(list) => list
.iter()
.try_for_each(|v| predicate_references(v, w, active)),
Value::Object(o) => {
let kind = o
.get("condition")
.or_else(|| o.get("type"))
.and_then(Value::as_str)
.unwrap_or("");
match &*full(kind) {
"minecraft:inverted" => predicate_references(&v["term"], w, active),
"minecraft:all_of" | "minecraft:any_of" => v["terms"]
.as_array()
.into_iter()
.flatten()
.try_for_each(|v| predicate_references(v, w, active)),
_ => Ok(()),
}
}
_ => Ok(()),
}
}
/// The existing condition traversal with a caller-owned leaf context.
@@ -1534,6 +1648,10 @@ fn condition_leaf(v: &Value, ctx: &Entities, w: &dyn World) -> bool {
}
// An inline entity predicate (old-style).
Value::Object(_) => ctx.this.is_some_and(|e| entity(v, e, w, ctx.origin)),
// A named predicate reference: its loaded definition, walked like any other.
Value::String(id) => w
.predicate(&full(id))
.is_some_and(|v| conditions_with(v, ctx, &mut |v, ctx| condition_leaf(v, ctx, w))),
_ => false,
}
}
@@ -3493,4 +3611,66 @@ mod tests {
}
assert_eq!(draws, 0);
}
#[cfg(test)]
mod movement_model_tests {
use super::*;
use serde_json::json;
#[test]
fn added_model_movement_units_exact_bounds_and_squared_bounds() {
// Source-derived model proof; no moving Java/runtime claim.
let m = MovementView {
per_tick: [3.0 / 20.0, -4.0 / 20.0, 0.0],
fall_distance: 7.25,
};
assert_eq!(m.per_tick.map(|n| n * 20.0), [3.0, -4.0, 0.0]);
for p in [
json!({"x":3.0,"y":-4.0,"z":0.0}),
json!({"x":{"min":3.0,"max":3.0}}),
json!({"speed":5.0,"horizontal_speed":3.0,"vertical_speed":4.0,"fall_distance":7.25}),
json!({"speed":{"min":5.0,"max":5.0}}),
// Doubles boundsSqr maps each bound through Mth.square, including its sign.
json!({"speed":-5.0,"horizontal_speed":{"max":-3.0}}),
json!({}),
] {
assert!(movement(&p, m), "{p}");
}
for p in [
json!({"x":3.0000000001}),
json!({"x":{"min":3.0000000001}}),
json!({"y":{"max":-4.0000000001}}),
json!({"speed":{"max":4.9999999999}}),
json!({"horizontal_speed":{"min":-4.0}}),
json!({"vertical_speed":-4.0}),
json!({"fall_distance":{"max":7.2499999999}}),
] {
assert!(!movement(&p, m), "{p}");
}
assert!(!movement_range(&json!(3.0000000001), 3.0, false));
assert!(
range_f(&json!(3.0000000001), 3.0),
"existing global tolerance is untouched"
);
}
#[test]
fn added_model_unprojected_is_distinct_from_actual_stationary() {
let p = json!({"minecraft:movement":{}});
let view = EntityView {
kind: "minecraft:pig".into(),
..Default::default()
};
// Unprojected is explicitly unsupported, not claimed Java-correct.
assert!(!entity(&p, &view, &super::Flat, [0.0; 3]));
let view = EntityView {
movement: Some(MovementView {
per_tick: [0.0; 3],
fall_distance: 0.0,
}),
..view
};
assert!(entity(&p, &view, &super::Flat, [0.0; 3]));
}
}
}
+9 -1
View File
@@ -8,7 +8,7 @@ use crate::{
blocks::{BlockFacts, Fluid},
command::args::GameMode,
items::ItemStack,
particles::{ParticleEngine, ParticleOptions, ParticleType as T, along_axis},
particles::{ParticleEngine, ParticleLevel, ParticleOptions, ParticleType as T, along_axis},
world::World,
};
use horizon_core::{
@@ -600,6 +600,14 @@ fn do_animate_tick(
}
let state = ctx.state(pos);
block_animate_tick(tables, engine, ctx, state, pos, center);
// Resource packs' Polytone block emitters (#11486): nothing without such a pack.
let level = ParticleLevel {
world: ctx.world,
facts: ctx.facts,
collision: ctx.collision,
player: None,
};
engine.polytone_animate(&level, pos);
let facts = ctx.facts.get(state);
if facts.fluid != Fluid::None {
fluid_animate_tick(
+2 -2
View File
@@ -351,7 +351,7 @@ impl OwnerLookup for () {
}
}
impl ArrowScene<'_, '_> {
fn block(&self, p: BlockPos) -> BlockStateId {
pub fn block(&self, p: BlockPos) -> BlockStateId {
self.world.block(p.x, p.y, p.z)
}
fn is_air(&self, state: BlockStateId) -> bool {
@@ -408,7 +408,7 @@ impl ArrowScene<'_, '_> {
Some((hit.location, hit.block, hit.direction.normal()))
}
/// `CollisionGetter.noCollision(null, box)` over blocks.
fn no_block_collision(&self, b: Aabb) -> bool {
pub fn no_block_collision(&self, b: Aabb) -> bool {
let (lo, hi) = (b.min_position(), b.max_position());
for x in (lo.x.floor() as i32 - 1)..=(hi.x.floor() as i32 + 1) {
for y in (lo.y.floor() as i32 - 1)..=(hi.y.floor() as i32 + 1) {
+15 -1
View File
@@ -61,6 +61,12 @@ fn read_tag(root: &Path, name: &str, depth: u32) -> Vec<Arc<str>> {
impl Banners {
pub fn load(root: &Path) -> Self {
Self::load_with_defaults(root, None)
}
pub(crate) fn load_with_defaults(
root: &Path,
defaults: Option<&horizon_world::item_defaults::Defaults>,
) -> Self {
let mut banners = Self::default();
let mut files: Vec<_> = fs::read_dir(root.join("data/minecraft/banner_pattern"))
.into_iter()
@@ -92,7 +98,15 @@ impl Banners {
});
}
banners.no_item_required = read_tag(root, "no_item_required", 0);
if let Ok(raw) = horizon_world::item_defaults::read(root) {
let owned_defaults;
let defaults = match defaults {
Some(defaults) => Some(defaults),
None => {
owned_defaults = horizon_world::item_defaults::read(root).ok();
owned_defaults.as_ref()
}
};
if let Some(raw) = defaults {
for (item, c) in raw {
let id: Arc<str> = Arc::from(item.as_str());
if let Some(dye) = c["minecraft:dye"].as_str() {
+10 -10
View File
@@ -16,16 +16,16 @@ pub use horizon_client_render::{
cit_armor, cit_assets, cit_glint, client_resources, clouds, collisions, command_block,
containers, controller_ui, crash, custom_sky, datapack, dynamic_light, effects, environment,
extras, fast_hash, fluid_render, fog, font, gfx, graphics, graphics_style, graphics_ui,
hdr_calibration, hud, 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_screen, packs, pad_cursor, pad_glyph,
par, particles, poi_sections, 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, sound_engine, sound_events, sound_preview,
spans, split_layout, split_map, split_profiles, sprites, streaming, structure_block, tab_list,
telemetry, terrain_model, test_block, test_instance_block, testgen, title_edition, trims, ui,
vault, weather, widgets, world, worldborder, zip,
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_screen, packs, pad_cursor,
pad_glyph, par, particles, poi_sections, 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, split_layout, split_map, split_profiles, sprites, streaming,
structure_block, tab_list, telemetry, terrain_model, test_block, test_instance_block, testgen,
title_edition, trims, ui, vault, weather, widgets, world, worldborder, zip,
};
pub mod attack_strength;
pub mod attribute_updates;
+4 -16
View File
@@ -4,6 +4,7 @@
//! reconnects, a solo game's controller coming and going) decides that for itself.
use crate::gamepad::{Button, GlyphStyle};
use crate::hud::{HudAtlas, HudBatch, rgba};
use crate::hud_kit::plate::{self, Plate};
use crate::pad_glyph;
use crate::toasts::Slide;
use std::time::{Duration, Instant};
@@ -117,7 +118,8 @@ impl NoticeSlot {
}
!finished
}
/// The notice under `top` on a view: a strip in the middle, its words wrapped to the view (a
/// The notice under `top` (GUI px; the caller keeps it below the boss bars with the HUD's
/// `Room`) on a view: a strip in the middle, its words wrapped to the view (a
/// toast is as wide as its longest line: off a narrow view's edge), sliding down from the
/// view's edge.
pub fn draw(&mut self, batch: &mut HudBatch, atlas: &HudAtlas, top: f32, shown_for: Duration) {
@@ -142,7 +144,7 @@ impl NoticeSlot {
let (pw, ph) = (inner.min(max) + 16.0, 20.0 + 10.0 * lines.len() as f32);
let px = ((w - pw) / 2.0).floor();
let py = (top - (1.0 - notice.slide.portion) * (top + ph)).floor();
panel(batch, atlas, [px, py, pw, ph]);
plate::draw(batch, atlas, Plate::Notice, [px, py, pw, ph]);
let white = rgba(255, 255, 255, 255);
let tx = (px + (pw - width(&notice.title)) / 2.0).floor();
batch.text(atlas, &notice.title, tx, py + 6.0, white, true);
@@ -157,20 +159,6 @@ impl NoticeSlot {
}
}
/// The prompts' and notices' box: dark, a faint edge.
pub fn panel(batch: &mut HudBatch, atlas: &HudAtlas, [px, py, pw, ph]: [f32; 4]) {
batch.fill(atlas, px, py, pw, ph, rgba(0, 0, 0, 210));
let edge = rgba(255, 255, 255, 90);
for [x, y, fw, fh] in [
[px, py, pw, 1.0],
[px, py + ph - 1.0, pw, 1.0],
[px, py, 1.0, ph],
[px + pw - 1.0, py, 1.0, ph],
] {
batch.fill(atlas, x, y, fw, fh, edge);
}
}
#[cfg(test)]
mod tests {
use super::*;
+292 -42
View File
@@ -14,7 +14,7 @@ use crate::{
use horizon_core::{
geometry::{Aabb, Direction, Vec3, VoxelShape, border::BorderGeometry},
position::BlockPos,
random::RandomSource,
random::{LegacyRandom, RandomSource},
};
use horizon_world::{
block_collision::BlockCollisionRegistry,
@@ -1101,10 +1101,20 @@ fn lava_cauldron_contacts(
pub struct ObstacleGrid {
cells: crate::fast_hash::FxHashMap<(i32, i32, i32), Vec<u32>>,
boxes: Vec<Aabb>,
/// Each box's entity's `getRootVehicle` when it rides something; empty
/// when none was given.
roots: Vec<Option<u32>>,
found: Vec<u32>,
}
impl ObstacleGrid {
const CELL: f64 = 8.0;
pub fn new(boxes: Vec<Aabb>) -> Self {
Self::with_roots(boxes, Vec::new())
}
/// [`Self::new`] with the vehicle at the bottom of what each box's entity
/// rides (`roots[i]` for `boxes[i]`), so [`Self::near_except`] can leave a
/// mover's own riders out.
pub fn with_roots(boxes: Vec<Aabb>, roots: Vec<Option<u32>>) -> Self {
let mut cells: crate::fast_hash::FxHashMap<(i32, i32, i32), Vec<u32>> = Default::default();
for (i, b) in boxes.iter().enumerate() {
let (lo, hi) = Self::span(*b);
@@ -1116,7 +1126,12 @@ impl ObstacleGrid {
}
}
}
Self { cells, boxes }
Self {
cells,
boxes,
roots,
found: Vec::new(),
}
}
fn span(b: Aabb) -> ((i32, i32, i32), (i32, i32, i32)) {
let cell = |v: Vec3| {
@@ -1132,30 +1147,54 @@ impl ObstacleGrid {
self.boxes.is_empty()
}
/// The boxes meeting `area` (`getEntityCollisions`'s query box), in the order they were given.
pub fn near(&self, area: Aabb, out: &mut Vec<Aabb>) {
pub fn near(&mut self, area: Aabb, out: &mut Vec<Aabb>) {
self.near_except(area, None, out);
}
/// [`Self::near`] without the boxes of what rides `vehicle` (given with
/// [`Self::with_roots`]): `Entity.canCollideWith` leaves out
/// `isPassengerOfSameVehicle`, so a stand's riders do not stop it.
pub fn near_except(&mut self, area: Aabb, vehicle: Option<u32>, out: &mut Vec<Aabb>) {
out.clear();
if self.boxes.is_empty() {
return;
}
let kept = |i: usize| vehicle.is_none() || self.roots.get(i).copied().flatten() != vehicle;
let (lo, hi) = Self::span(area);
let mut found: Vec<u32> = Vec::new();
let side = |lo: i32, hi: i32| (i64::from(hi) - i64::from(lo) + 1).max(0);
let cells = side(lo.0, hi.0)
.saturating_mul(side(lo.1, hi.1))
.saturating_mul(side(lo.2, hi.2));
// An area over more cells than there are boxes (a stand loaded with a
// huge Motion asks for one millions of blocks long): test each box
// once rather than walk the cells.
if cells > self.boxes.len() as i64 {
out.extend(
self.boxes
.iter()
.enumerate()
.filter(|&(i, b)| kept(i) && b.intersects(area))
.map(|(_, b)| *b),
);
return;
}
self.found.clear();
for x in lo.0..=hi.0 {
for y in lo.1..=hi.1 {
for z in lo.2..=hi.2 {
if let Some(list) = self.cells.get(&(x, y, z)) {
found.extend(list);
self.found.extend(list);
}
}
}
}
found.sort_unstable();
found.dedup();
out.extend(
found
.into_iter()
.map(|i| self.boxes[i as usize])
.filter(|b| b.intersects(area)),
);
self.found.sort_unstable();
self.found.dedup();
for &i in &self.found {
let b = self.boxes[i as usize];
if kept(i as usize) && b.intersects(area) {
out.push(b);
}
}
}
}
@@ -1315,6 +1354,9 @@ pub struct ExperienceOrb {
pub health: i32,
/// `Entity.noGravity`: `ExperienceOrb.tick`'s `applyGravity` does nothing.
pub no_gravity: bool,
/// `ExperienceOrb.followingPlayer`: the followed player's entity id, kept
/// until they are gone or further than 8 blocks. Never saved.
pub following: Option<u32>,
}
impl ExperienceOrb {
/// `ExperienceOrb.hurtServer`: the damage comes off the health as an
@@ -1466,7 +1508,9 @@ impl ExperienceOrb {
) -> bool {
self.ticks += 1;
self.previous = self.position;
self.update_fluids(world, fluids);
// `Entity.baseTick`'s fluid step, whose `updateFluidOnEyes` answer the
// water branch below reads: the orb's eye, not its feet, decides.
let eye_in_water = self.update_fluids(world, fluids);
let block = world.block(
self.position.x.floor() as i32,
self.position.y.floor() as i32,
@@ -1481,7 +1525,10 @@ impl ExperienceOrb {
// `boolean colliding = !level.noCollision(getBoundingBox())`: an orb
// inside blocks takes no gravity until it is pushed out below.
let colliding = !crate::collisions::no_collision_empty(&mut scene, bounds);
if facts.get(block).fluid == Fluid::Water {
// ExperienceOrb.tick: `isEyeInFluid(FluidTags.WATER)` floats the orb,
// so an orb whose eye dips into the surface floats with its feet
// still in the air block above it.
if eye_in_water {
self.set_underwater_movement();
} else if !colliding {
self.velocity.y -= self.get_gravity();
@@ -1932,6 +1979,7 @@ pub struct ItemFrame {
pub check_interval: u32,
/// `Entity.getCustomName` (`CustomName`), copied onto the dropped frame.
pub custom_name: Option<horizon_world::nbt::Tag>,
pub random: LegacyRandom,
}
impl ItemFrame {
pub fn step(&self) -> [i32; 3] {
@@ -2106,7 +2154,7 @@ impl ArmorStand {
in_water: bool,
border: Option<&BorderGeometry>,
) {
if let Some(step) = self.tick_move(scene, facts, climbing, in_water, border) {
if let Some(step) = self.tick_move(scene, facts, climbing, in_water, border, &[]) {
let medium = if in_water {
StandMedium::Water
} else {
@@ -2374,10 +2422,12 @@ impl ArmorStand {
}
self.health > 0.5
}
/// EntityType.ARMOR_STAND dimensions (0.5 x 1.975, half for small).
/// EntityType.ARMOR_STAND dimensions (0.5 x 1.975F, half for small).
pub fn bounds(&self) -> Aabb {
let s = if self.small { 0.5 } else { 1.0 };
let (w, h) = (0.25 * s, 1.975 * s);
// EntityDimensions.makeBoundingBox adds the float height, widened
// (halving it for the small stand is exact).
let (w, h) = (0.25 * s, f64::from(1.975_f32) * s);
let p = self.position;
Aabb::new(
Vec3::new(p.x - w, p.y, p.z - w),
@@ -2402,7 +2452,10 @@ impl ArmorStand {
/// The game runs the fluid push first (`Entity::push_by_fluid`), then
/// this up to the blocks' push, then the bubble column
/// (`Entity::push_by_bubble_column`), then [`ArmorStand::tick_settle`].
/// None: a marker or a stand with `NoGravity` does not travel.
/// `entities` are the boxes `EntityGetter.getEntityCollisions` gives the
/// stand (a living shulker, a boat, a happy ghast held still), which stop
/// its move as blocks do. None: a marker or a stand with `NoGravity` does
/// not travel.
pub fn tick_move(
&mut self,
scene: &mut crate::collisions::SceneCollisions,
@@ -2410,6 +2463,7 @@ impl ArmorStand {
climbing: &horizon_world::climbing::ClimbingRegistry<'_>,
in_water: bool,
border: Option<&BorderGeometry>,
entities: &[Aabb],
) -> Option<StandStep> {
use crate::entity_rules::EntityRules;
self.previous = self.position;
@@ -2468,18 +2522,20 @@ impl ArmorStand {
}
let mut velocity = wanted;
let mut horizontal_collision = false;
// Entity.collide: the world border's walls stop the stand too.
// Entity.collide: the world border's walls and the entities that
// `canBeCollidedWith` the stand stop it too.
let moved = SceneCollisions {
world: scene.world,
binding: scene.binding,
subject: scene.subject,
}
.resolve_in_border(
.resolve_in_border_with(
self.bounds(),
wanted,
0.0,
self.on_ground,
border.map(|b| (b, [self.position.x, self.position.z])),
entities,
);
if let Ok(moved) = moved {
self.position = Vec3::new(
@@ -2489,24 +2545,7 @@ impl ArmorStand {
);
self.on_ground = wanted.y < 0.0 && moved.y > wanted.y;
// Entity.setOnGroundWithMovement: the block under the feet.
let context = horizon_world::collision_context::CollisionContext::for_entity(
horizon_world::collision_context::CollisionEntity {
identity: horizon_world::entity_obstruction::EntityIdentity(u64::from(self.id)),
kind: horizon_world::collision_context::EntityContextKind::Living,
y: self.position.y,
descending: false,
main_hand: Some(horizon_world::collision_context::CollisionItem::EMPTY),
},
false,
);
let position = self.position;
let _ =
self.support
.check_support(self.on_ground, self.bounds(), Some(wanted), |area| {
horizon_world::ground_support::find_supporting_block(
scene, context, position, area, 1_000_000,
)
});
self.ground_support(scene, wanted);
if (wanted.x - moved.x).abs() >= f64::from(1.0e-5_f32) {
horizontal_collision = true;
velocity.x = 0.0;
@@ -2525,6 +2564,115 @@ impl ArmorStand {
self.velocity = velocity;
Some(StandStep { falling, friction })
}
/// `Entity.move(MoverType.SHULKER, delta)`: an opening shulker lid
/// shoves the stand (one with physics) by `delta`. Blocks, the world
/// border and `entities` (the boxes `getEntityCollisions` gives the stand)
/// cut the move short, a move cut to almost nothing is not made, and an
/// axis that hits something loses its speed
/// (`restituteMovementAfterCollisions`: a stand does not bounce, so the
/// speed becomes `-speed * 0`).
///
/// The stand has already travelled this tick (stands tick before mobs
/// here). `travels_after` says vanilla ticks it after the shulker (it
/// joined the level later, as a stand placed on the shell does), so
/// there the push comes first and its own travel follows:
/// - a push nothing stops leaves `on_ground` and the speed as that
/// travel left them (it lands the stand again on the risen lid);
/// - a push down that something stops: the travel that follows starts
/// with no vertical speed, moves nothing vertically (`on_ground`
/// false) and ends with gravity's speed only, which the stand's own
/// travel, stopped by the same floor, already gave it;
/// - a push up that a ceiling stops loses the vertical speed, so the
/// stand stays pinned on the next tick as vanilla's does (vanilla
/// shows gravity's speed by then, and once the lid stops, the stand
/// falls through it one tick later than vanilla's).
///
/// Otherwise vanilla ticks it before the shulker, as here, and the push
/// sets `on_ground` and the block under the feet itself. Left out: the
/// block speed factor on the horizontal speed, the fall distance a push
/// down through the air adds, and a slime or bed bounce.
pub fn shove(
&mut self,
scene: SceneCollisions,
delta: Vec3,
border: Option<&BorderGeometry>,
entities: &[Aabb],
travels_after: bool,
) {
let mut scene = SceneCollisions {
subject: self.feet_are_leather_boots().then_some(
crate::collisions::PowderSnowSubject {
leather_boots: true,
..Default::default()
},
),
..scene
};
let Ok(moved) = SceneCollisions {
world: scene.world,
binding: scene.binding,
subject: scene.subject,
}
.resolve_in_border_with(
self.bounds(),
delta,
0.0,
self.on_ground,
border.map(|b| (b, [self.position.x, self.position.z])),
entities,
) else {
return;
};
let length = moved.length_squared();
if length > 1.0e-7 || delta.length_squared() - length < 1.0e-7 {
self.position = self.position.add_vector(moved);
}
// Mth.equal on x and z; y only when the push had a vertical part.
if (delta.x - moved.x).abs() >= f64::from(1.0e-5_f32) {
self.velocity.x = -self.velocity.x * 0.0;
}
if (delta.z - moved.z).abs() >= f64::from(1.0e-5_f32) {
self.velocity.z = -self.velocity.z * 0.0;
}
let vertical = moved.y != delta.y;
if travels_after {
if vertical {
self.on_ground = false;
if delta.y > 0.0 {
self.velocity.y = -self.velocity.y * 0.0;
}
self.ground_support(&mut scene, moved);
}
} else {
if vertical && delta.y != 0.0 {
self.velocity.y = -self.velocity.y * 0.0;
}
self.on_ground = vertical && delta.y < 0.0;
self.ground_support(&mut scene, moved);
}
}
/// `Entity.checkSupportingBlock` after a move by `movement`: the block
/// under the feet while `on_ground`, none otherwise.
fn ground_support(&mut self, scene: &mut SceneCollisions, movement: Vec3) {
let context = horizon_world::collision_context::CollisionContext::for_entity(
horizon_world::collision_context::CollisionEntity {
identity: horizon_world::entity_obstruction::EntityIdentity(u64::from(self.id)),
kind: horizon_world::collision_context::EntityContextKind::Living,
y: self.position.y,
descending: false,
main_hand: Some(horizon_world::collision_context::CollisionItem::EMPTY),
},
false,
);
let (position, bounds) = (self.position, self.bounds());
let _ = self
.support
.check_support(self.on_ground, bounds, Some(movement), |area| {
horizon_world::ground_support::find_supporting_block(
scene, context, position, area, 1_000_000,
)
});
}
/// `HoneyBlock.entityInside`: side-slide movement after the stand's travel.
/// `ArmorStand.hasPhysics` / `Entity.isAffectedByBlocks` exclude markers
/// and NoGravity stands. Slide effects are separate from this movement.
@@ -3338,6 +3486,7 @@ mod world_border_tests {
count: 1,
ticks: 0,
health: 5,
following: None,
};
orb.tick(
&world,
@@ -4010,7 +4159,7 @@ mod entity_collision_tests {
Vec3::new(x + 1.375, 64.5625, 1.375),
)
};
let grid = ObstacleGrid::new((0..1000).map(|i| boat(f64::from(i) * 100.0)).collect());
let mut grid = ObstacleGrid::new((0..1000).map(|i| boat(f64::from(i) * 100.0)).collect());
let mut out = Vec::new();
// Around the 7th boat: only it.
let area = Aabb::new(Vec3::new(699.0, 63.0, -1.0), Vec3::new(701.0, 66.0, 1.0));
@@ -4022,12 +4171,46 @@ mod entity_collision_tests {
assert!(out.is_empty());
// A box spanning cells is found from any of them, once.
let wide = Aabb::new(Vec3::new(0.0, 0.0, 0.0), Vec3::new(20.0, 1.0, 1.0));
let grid = ObstacleGrid::new(vec![wide]);
let mut grid = ObstacleGrid::new(vec![wide]);
let area = Aabb::new(Vec3::new(17.0, 0.0, 0.0), Vec3::new(18.0, 1.0, 1.0));
grid.near(area, &mut out);
assert_eq!(out, vec![wide]);
}
#[test]
fn obstacle_grid_leaves_out_a_movers_riders_and_answers_a_huge_area_at_once() {
let unit = |x: f64| Aabb::new(Vec3::new(x, 64.0, 0.0), Vec3::new(x + 1.0, 65.0, 1.0));
// The shulker at x = 2 rides stand 7; the one at x = 0 and the boat
// at x = 4 ride nothing.
let mut grid = ObstacleGrid::with_roots(
vec![unit(0.0), unit(2.0), unit(4.0)],
vec![None, Some(7), None],
);
let mut out = Vec::new();
let area = Aabb::new(Vec3::new(-1.0, 63.0, -1.0), Vec3::new(6.0, 66.0, 2.0));
grid.near_except(area, Some(7), &mut out);
assert_eq!(out, vec![unit(0.0), unit(4.0)], "stand 7 ignores its rider");
grid.near_except(area, Some(8), &mut out);
assert_eq!(
out,
vec![unit(0.0), unit(2.0), unit(4.0)],
"stand 8 does not"
);
// A stand loaded with Motion 1e10 asks for an area 1e10 blocks long
// (about 1.25e9 cells): the boxes in it come back in order, at once.
let huge = Aabb::new(Vec3::new(-1.0, 63.0, -1.0), Vec3::new(1.0e10, 66.0, 2.0));
let start = std::time::Instant::now();
grid.near(huge, &mut out);
assert_eq!(out, vec![unit(0.0), unit(2.0), unit(4.0)]);
let far = Aabb::new(
Vec3::new(-1.0e300, 63.0, -1.0),
Vec3::new(1.0e300, 66.0, 2.0),
);
grid.near(far, &mut out);
assert_eq!(out.len(), 3);
assert!(start.elapsed() < std::time::Duration::from_secs(1));
}
/// A farm: 5000 drops and 1000 separated vehicles. Run with `-- --ignored --nocapture`.
#[test]
#[ignore = "timing"]
@@ -4053,7 +4236,7 @@ mod entity_collision_tests {
hits += boxes.iter().filter(|b| b.intersects(area)).count();
}
let scan = start.elapsed();
let grid = ObstacleGrid::new(boxes);
let mut grid = ObstacleGrid::new(boxes);
let start = std::time::Instant::now();
let (mut out, mut found) = (Vec::new(), 0);
for item in &items {
@@ -4333,6 +4516,70 @@ mod armor_stand_motion_tests {
}
}
/// `Entity.collide` (#11877): the boxes `getEntityCollisions` gives a
/// stand (a shulker's here, one block on the test scene's ground, whose
/// top is at y = 4) stop it as blocks do. Dropped from y = 8 it rests on
/// the box's top at y = 5; sliding east at 2 blocks a tick from x = 18 it
/// stops with its east side on the box's west face, x = 20. With no boxes
/// it falls to the ground and slides on.
#[test]
fn a_stand_lands_on_and_stops_at_the_entity_boxes_it_is_given() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client")
.join(horizon_core::VANILLA_VERSION);
if !root.join("movement-properties.nbt").exists() {
return;
}
let assets = crate::assets::Assets::load(&root).unwrap();
let render = assets.reload(&root).unwrap();
let facts = BlockFacts::load(&root, assets.registry).unwrap();
let collision = crate::collisions::load(&root, assets.registry).unwrap();
let world = World::test_scene(&assets, &render, 3).unwrap();
let empty = Default::default();
let climbing = horizon_world::climbing::ClimbingRegistry::new(
collision.registry(),
&empty,
&empty,
&empty,
)
.unwrap();
let shulker = Aabb::new(Vec3::new(20.0, 4.0, 20.0), Vec3::new(21.0, 5.0, 21.0));
let run = |stand: &mut ArmorStand, boxes: &[Aabb], ticks: usize| {
for _ in 0..ticks {
let mut scene = SceneCollisions {
world: &world,
binding: &collision,
subject: None,
};
if let Some(step) =
stand.tick_move(&mut scene, &facts, &climbing, false, None, boxes)
{
stand.tick_settle(StandMedium::Air, step);
}
}
};
for (boxes, floor) in [(&[shulker][..], 5.0), (&[][..], 4.0)] {
let mut stand = ArmorStand::new(1, Vec3::new(20.5, 8.0, 20.5), 0.0);
run(&mut stand, boxes, 20);
let y = stand.position.y;
assert!((y - floor).abs() < 1.0e-9, "rests at {floor}, got {y}");
assert!(stand.on_ground, "on the ground at {floor}");
}
for (boxes, blocked) in [(&[shulker][..], true), (&[][..], false)] {
let mut stand = ArmorStand::new(1, Vec3::new(18.0, 4.0, 20.5), 0.0);
stand.on_ground = true;
stand.velocity = Vec3::new(2.0, 0.0, 0.0);
run(&mut stand, boxes, 1);
let east = stand.bounds().max_position().x;
if blocked {
assert!((east - 20.0).abs() < 1.0e-7, "stops at the box, got {east}");
assert_eq!(stand.velocity.x, 0.0, "the wall takes its speed");
} else {
assert!((east - 20.25).abs() < 1.0e-7, "goes on, got {east}");
}
}
}
#[test]
fn a_stand_in_lava_halves_its_motion_and_sinks_by_a_quarter_of_gravity() {
// LivingEntity.travelInLava, measured on the real server: a stand at
@@ -4642,6 +4889,7 @@ mod orb_underwater_tests {
count: 1,
ticks: 0,
health: 5,
following: None,
};
orb.set_underwater_movement();
[
@@ -4675,6 +4923,7 @@ mod render_light_tests {
count: 1,
ticks: 0,
health: 5,
following: None,
}
}
@@ -4871,6 +5120,7 @@ mod orb_lava_tests {
count: 1,
ticks: 0,
health,
following: None,
}
}
+73 -5
View File
@@ -57,7 +57,7 @@ pub fn update_entity_fluids(
velocity: &mut Vec3,
world: &World,
fluids: &FluidResources,
) -> (f64, f64) {
) -> (f64, f64, bool) {
let mut interaction = EntityFluidInteraction::new([WATER, LAVA]);
let mut scene = EntityFluidWorld {
bounds,
@@ -68,10 +68,13 @@ pub fn update_entity_fluids(
fluids,
};
if interaction.update(&mut scene, false, 4096).is_err() {
return (0.0, 0.0);
return (0.0, 0.0, false);
}
let water_height = interaction.fluid_height(&scene, WATER);
let lava_height = interaction.fluid_height(&scene, LAVA);
// `updateFluidOnEyes`'s answer, kept by the interaction's trackers:
// whether the entity's eye is inside the water it scanned.
let eye_in_water = interaction.is_eye_in_fluid(&FluidTags, WATER);
let _ = interaction.apply_current(&mut scene, WATER, 0.014);
let _ = interaction.apply_current(
&mut scene,
@@ -82,7 +85,7 @@ pub fn update_entity_fluids(
0.0023333333333333335
},
);
(water_height, lava_height)
(water_height, lava_height, eye_in_water)
}
impl ItemEntity {
/// `Entity.getEyeY` of an item (eye height 0.2125), where
@@ -94,13 +97,17 @@ impl ItemEntity {
#[cfg(test)]
pub(super) fn update_fluids(&mut self, world: &World, fluids: &FluidResources) -> (f64, f64) {
let (bounds, at, eye) = (self.bounds(), self.position, self.fluid_eye_y());
update_entity_fluids(bounds, at, eye, &mut self.velocity, world, fluids)
let (water, lava, _) =
update_entity_fluids(bounds, at, eye, &mut self.velocity, world, fluids);
(water, lava)
}
}
impl ExperienceOrb {
/// `Entity.updateFluidInteraction`: refreshes fluid immersion over the
/// orb's box and pushes its velocity by any water or lava current.
pub fn update_fluids(&mut self, world: &World, fluids: &FluidResources) -> (f64, f64) {
/// Answers `isEyeInFluid(FluidTags.WATER)` for the eye the scan reads,
/// `0.425` above the orb's feet.
pub fn update_fluids(&mut self, world: &World, fluids: &FluidResources) -> bool {
update_entity_fluids(
self.bounds(),
self.position,
@@ -109,6 +116,7 @@ impl ExperienceOrb {
world,
fluids,
)
.2
}
}
impl ArmorStand {
@@ -419,6 +427,7 @@ mod tests {
count: 1,
ticks: 0,
health: 5,
following: None,
};
let mut still = orb.clone();
assert!(still.tick(
@@ -469,6 +478,65 @@ mod tests {
assert!(carried.position.x > at.x + 0.5);
}
/// `ExperienceOrb.tick`'s water branch reads `isEyeInFluid(FluidTags.WATER)`,
/// not the feet's block: an orb whose feet hang in the air under the
/// surface, with its eye in the water, floats (`setUnderwaterMovement`)
/// instead of falling.
#[test]
fn an_orb_whose_eye_dips_into_water_floats_with_its_feet_in_air() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client")
.join(horizon_core::VANILLA_VERSION);
if !root.join("movement-properties.nbt").exists() {
return;
}
let assets = crate::assets::Assets::load(&root).unwrap();
let render = assets.reload(&root).unwrap();
let facts = crate::blocks::BlockFacts::load(&root, assets.registry).unwrap();
let fluids = FluidResources::new(&assets, &facts).unwrap();
let collision = crate::collisions::load(&root, assets.registry).unwrap();
let mut world = World::test_scene(&assets, &render, 3).unwrap();
let water = assets.registry.default_state("minecraft:water").unwrap();
for x in 19..=21 {
for z in 19..=21 {
world.set(x, 5, z, water).unwrap();
}
}
let at = Vec3::new(20.5, 4.6, 20.5);
let mut orb = ExperienceOrb {
rotation: [0.0, 0.0],
join: Default::default(),
no_gravity: false,
id: 1,
position: at,
previous: at,
velocity: Vec3::ZERO,
value: 1,
age: 0,
on_ground: false,
count: 1,
ticks: 0,
health: 5,
following: None,
};
assert!(orb.tick(
&world,
&facts,
&collision,
None,
true,
&mut || 0.0,
None,
&fluids,
&mut |_, _| {},
));
assert!(
orb.velocity.y > 0.0,
"the eye in the water floats the orb, got {}",
orb.velocity.y
);
}
/// `Entity.baseTick` pushes a boat by the current it sits in: a boat box
/// in water[level=1..7] gains velocity along the flow, and the same box in
/// still source water does not. Vanilla's boat tick runs this before
+163 -17
View File
@@ -49,6 +49,13 @@ pub fn uuid(tag: &Tag) -> Option<[i32; 4]> {
pub fn motion(tag: &Tag) -> Vec3 {
read_doubles::<3>(tag.get("Motion")).map_or(Vec3::ZERO, |m| Vec3::new(m[0], m[1], m[2]))
}
/// A saved Motion as `Entity.load` takes it: a component faster than 10 blocks
/// a tick loads as 0.
pub fn loaded_motion(tag: &Tag) -> Vec3 {
let m = motion(tag);
let clamp = |v: f64| if v.abs() > 10.0 { 0.0 } else { v };
Vec3::new(clamp(m.x), clamp(m.y), clamp(m.z))
}
pub fn rotation(tag: &Tag) -> [f32; 2] {
read_doubles::<2>(tag.get("Rotation")).map_or([0.0; 2], |r| [r[0] as f32, r[1] as f32])
}
@@ -223,6 +230,33 @@ pub fn who_uuid(who: Who, uuid_of: &mut impl FnMut(u32) -> Option<[i32; 4]>) ->
Who::Mob(id) => uuid_of(id),
}
}
/// Replace every reference to the player `old` in a saved entity with `new`. Older
/// Horizon worlds named their single player by the offline UUID (`Owner` on tamed
/// animals and horses, a projectile's `Owner` and an item's `Thrower`, a fox's
/// `Trusted`, `angry_at`, a leash's `UUID`); when such a world opens under the
/// account (`lan::playerdata::migrate_owner`), a chunk's entities are rewritten as
/// they load, and their next save keeps the account's UUID. A reference is the
/// player's four ints, wherever the entity keeps it; an entity's own `UUID` never
/// names the player.
pub fn migrate_player_uuid(tag: &mut Tag, old: [i32; 4], new: [i32; 4]) {
if old == new {
return;
}
match tag {
Tag::IntArray(uuid) if uuid.as_slice() == old.as_slice() => uuid.copy_from_slice(&new),
Tag::List(list) => {
for tag in list {
migrate_player_uuid(tag, old, new);
}
}
Tag::Compound(map) => {
for tag in map.values_mut() {
migrate_player_uuid(tag, old, new);
}
}
_ => {}
}
}
/// The entity a stored UUID names, while it is in the level: `player` (the
/// player's own UUID, when one is in the level), or the mob `uuids` gives that
/// UUID to. `uuids` is the game layer's id -> UUID map, so this is the read half
@@ -1449,12 +1483,20 @@ pub fn mob_to_nbt(
// the attack damage.
use crate::mobs::finalize::Attr;
// Every other living entity's first tick makes the movement_speed instance
// (LivingEntity.removeFrost reads it through getAttribute).
let made = if species == crate::mobs::Species::Rabbit {
vec![Attr::MovementSpeed, Attr::AttackDamage]
} else {
vec![Attr::MovementSpeed]
};
// (LivingEntity.removeFrost reads it through getAttribute); before it, the
// mob has only the instances it was loaded with (the kept list above).
let speed_at_load =
crate::mobs::doors::is_zombie(species) || species == crate::mobs::Species::Piglin;
let mut made = Vec::new();
if speed_at_load || m.age > 0 {
made.push(Attr::MovementSpeed);
}
if species == crate::mobs::Species::Rabbit {
made.push(Attr::AttackDamage);
}
// AttributeMap.pack writes the list even when no instance exists.
map.entry("attributes".into())
.or_insert_with(|| Tag::List(Vec::new()));
for attr in made {
let base = crate::mobs::finalize::base_value(m, attr);
let mut list = match map.get(&"attributes".into()) {
@@ -1492,6 +1534,15 @@ pub fn mob_to_nbt(
if matches!(m.species(), S::Evoker | S::Illusioner) {
map.insert("SpellTicks".into(), Tag::Int(m.extra.counter));
}
// Vindicator.addAdditionalSaveData: `Johnny` only while the flag is set
// (`if (this.isJohnny) output.putBoolean("Johnny", true)`).
if m.species() == S::Vindicator {
if m.extra.johnny {
map.insert("Johnny".into(), Tag::Byte(1));
} else {
map.remove(&"Johnny".into());
}
}
crate::mobs::raider::write_nbt(m, map);
crate::mobs::riders::write_nbt(m, map);
// LivingEntity equipment: what the mob holds now.
@@ -2135,6 +2186,13 @@ pub fn mob_from_nbt(
.map(Box::new),
);
}
if m.extra
.effects
.iter()
.any(|e| e.0 == "minecraft:health_boost")
{
crate::mobs::finalize::health_boost_changed(&mut m);
}
}
m.silent = flag(tag, "Silent");
m.invulnerable_flag = flag(tag, "Invulnerable");
@@ -2181,12 +2239,9 @@ pub fn mob_from_nbt(
if m.species() == crate::mobs::Species::Piglin
&& let Some(Tag::List(items)) = tag.get("Inventory")
{
// InventoryCarrier.readInventoryFromTag (eight slots).
m.extra.inventory = items
.iter()
.filter_map(crate::save::item_from_nbt)
.take(8)
.collect();
// InventoryCarrier.readInventoryFromTag -> SimpleContainer
// .fromItemList (eight slots).
m.extra.inventory = crate::mobs::villager::load_container(&m, items, 8);
}
}
if matches!(
@@ -2663,6 +2718,11 @@ pub fn mob_from_nbt(
) {
m.extra.counter = number(tag.get("SpellTicks")).unwrap_or(0) as i32;
}
// Vindicator.readAdditionalSaveData: `Johnny`, false when absent. A name
// that is Johnny latches the flag again on the next AI step.
if m.species() == crate::mobs::Species::Vindicator {
m.extra.johnny = flag(tag, "Johnny");
}
crate::mobs::raider::read_nbt(&mut m, tag);
crate::mobs::riders::read_nbt(&mut m, tag);
// A mannequin is an `Avatar`, not a `Mob`: it reads none of
@@ -2985,6 +3045,9 @@ pub fn orbs_from_nbt(tag: &Tag) -> Vec<ExperienceOrb> {
count,
ticks: 0,
health: number(tag.get("Health")).unwrap_or(5) as i32,
// ExperienceOrb's followingPlayer is transient: never saved, so a
// loaded orb starts without one.
following: None,
};
vec![orb]
}
@@ -3890,6 +3953,7 @@ pub fn item_frame_from_nbt(tag: &Tag, id: u32) -> Option<ItemFrame> {
invisible: flag(tag, "Invisible"),
check_interval: 0,
custom_name: tag.get("CustomName").cloned(),
random: crate::random_source::entity(),
})
}
/// ArmorStand.addAdditionalSaveData with LivingEntity's `equipment`.
@@ -4004,7 +4068,7 @@ pub fn armor_stand_from_nbt(tag: &Tag, id: u32) -> Option<ArmorStand> {
// AttributeMap.load makes an instance for each saved attribute.
a.speed_instance = matches!(tag.get("attributes"), Some(Tag::List(list))
if list.iter().any(|attr| text(attr.get("id")).as_deref() == Some(SPEED_ATTRIBUTE)));
a.velocity = motion(tag);
a.velocity = loaded_motion(tag);
a.health = float(tag.get("Health")).unwrap_or(20.0) as f32;
a.fire_ticks = number(tag.get("Fire")).unwrap_or(0) as i32;
a.custom_name = tag.get("CustomName").map(name_text);
@@ -4037,8 +4101,9 @@ pub fn armor_stand_from_nbt(tag: &Tag, id: u32) -> Option<ArmorStand> {
}
Some(a)
}
/// A boat entity (`minecraft:<wood>_boat`, `minecraft:bamboo_chest_raft`...):
/// chest boats keep their slots in `Items` (`ContainerHelper.saveAllItems`).
/// `AbstractBoat.addAdditionalSaveData` / `ContainerEntity.addChestVehicleSaveData`:
/// only chest boats own container fields; a pending table replaces `Items`.
/// Ordinary slots use `ContainerHelper.saveAllItems` through the existing item encoder.
pub fn boat_to_nbt(
id: &str,
position: Vec3,
@@ -4058,8 +4123,22 @@ pub fn boat_to_nbt(
fall_distance: 0.0,
}
.non_mob_tag(raw);
if let (Some(items), Tag::Compound(map)) = (items, &mut tag) {
map.insert("Items".into(), crate::save::encode_items(items));
if let Tag::Compound(map) = &mut tag {
map.remove(&"Items".into());
if let Some(items) = items {
let table = crate::save::text(map.get(&"LootTable".into()))
.and_then(|id| horizon_client_core::identifier::normalize_id(&id));
if let Some(table) = table {
map.insert("LootTable".into(), Tag::String(table.as_str().into()));
} else {
map.remove(&"LootTable".into());
map.remove(&"LootTableSeed".into());
map.insert("Items".into(), crate::save::encode_items(items));
}
} else {
map.remove(&"LootTable".into());
map.remove(&"LootTableSeed".into());
}
}
tag
}
@@ -4523,6 +4602,50 @@ mod tests {
use super::*;
use crate::items::ItemStack;
/// Older worlds name their single player by the offline UUID; the migration rewrites
/// every reference to it (owner, thrower, trusted list, anger, leash) and nothing else.
#[test]
fn a_players_offline_uuid_becomes_the_accounts_everywhere_in_an_entity() {
let old = [1, 2, 3, 4];
let new = [5, 6, 7, 8];
let mut tag = Tag::compound([
("id", string("minecraft:wolf")),
("UUID", Tag::IntArray(vec![9, 9, 9, 9])),
("Owner", Tag::IntArray(old.to_vec())),
("Thrower", Tag::IntArray(old.to_vec())),
("angry_at", Tag::IntArray(old.to_vec())),
(
"Trusted",
Tag::List(vec![
Tag::IntArray(old.to_vec()),
Tag::IntArray(vec![3, 3, 3, 3]),
]),
),
(
"leash",
Tag::compound([("UUID", Tag::IntArray(old.to_vec()))]),
),
("Health", Tag::Float(20.0f32.to_bits())),
]);
migrate_player_uuid(&mut tag, old, new);
assert_eq!(tag.get("Owner"), Some(&Tag::IntArray(new.to_vec())));
assert_eq!(tag.get("Thrower"), Some(&Tag::IntArray(new.to_vec())));
assert_eq!(tag.get("angry_at"), Some(&Tag::IntArray(new.to_vec())));
assert_eq!(
tag.get("Trusted"),
Some(&Tag::List(vec![
Tag::IntArray(new.to_vec()),
Tag::IntArray(vec![3, 3, 3, 3]),
]))
);
assert_eq!(
tag.get("leash").and_then(|leash| leash.get("UUID")),
Some(&Tag::IntArray(new.to_vec()))
);
// The entity's own UUID is not the player's.
assert_eq!(tag.get("UUID"), Some(&Tag::IntArray(vec![9, 9, 9, 9])));
}
#[test]
fn empty_saved_tags_are_omitted_without_changing_raw_or_nonempty_tags() {
let base = Base {
@@ -5103,6 +5226,27 @@ mod tests {
assert!(!armor_stand_from_nbt(&cleared, 7).unwrap().no_gravity);
}
/// `Entity.load`: a Motion component past 10 blocks a tick loads as 0,
/// the others as saved.
#[test]
fn armor_stand_drops_a_motion_component_past_ten() {
let doubles = |v: [f64; 3]| Tag::List(v.map(|d| Tag::Double(d.to_bits())).to_vec());
let tag = Tag::compound([
("id", string("minecraft:armor_stand")),
("Pos", doubles([0.0, 64.0, 0.0])),
("Motion", doubles([1.0e10, -0.5, -10.5])),
]);
let a = armor_stand_from_nbt(&tag, 7).unwrap();
assert_eq!(a.velocity, Vec3::new(0.0, -0.5, 0.0));
let tag = Tag::compound([
("id", string("minecraft:armor_stand")),
("Pos", doubles([0.0, 64.0, 0.0])),
("Motion", doubles([10.0, 0.0, -10.0])),
]);
let a = armor_stand_from_nbt(&tag, 7).unwrap();
assert_eq!(a.velocity, Vec3::new(10.0, 0.0, -10.0), "10 itself stays");
}
fn falling_decode_registry() -> BlockRegistry {
BlockRegistry::from_nbt(&Tag::compound([
("DataVersion", Tag::Int(horizon_world::DATA_VERSION)),
@@ -5903,6 +6047,7 @@ mod tests {
count: 1,
ticks: 0,
health: 5,
following: None,
};
let tag = orb_to_nbt(&orb, [0; 4]);
assert_eq!(tag.get("Fire"), Some(&Tag::Short(0)));
@@ -5966,6 +6111,7 @@ mod tests {
count: 3,
ticks: 0,
health: 5,
following: None,
};
let tag = orb_to_nbt(&o, [0; 4]);
let back = orbs_from_nbt(&tag);
File diff suppressed because it is too large. Load diff
@@ -136,6 +136,9 @@ impl AdvState {
/// The game's world for predicates.
struct View<'a, 'g>(&'a Game<'g>);
impl adv::World for View<'_, '_> {
fn predicate(&self, id: &str) -> Option<&Value> {
self.0.fns.store.predicates.get(id)
}
fn dimension(&self) -> &str {
self.0.dim.id()
}
@@ -512,6 +515,10 @@ impl Game<'_> {
tool: None,
block_pos: None,
};
// An invalid reference anywhere fails the whole tree, even under an inversion.
if !adv::references_valid(v, &View(self)) {
return false;
}
adv::conditions_with(v, &ctx, &mut |leaf, ctx| {
adv::literal_random_chance(leaf, &mut || self.world.random.next_float())
.unwrap_or_else(|| adv::conditions(leaf, ctx, &View(self)))
@@ -768,7 +775,21 @@ impl Game<'_> {
super::persist::add_passengers(&mut tag, riders);
tag
});
let movement = (m.extra.mount.is_none()
&& m.rider.is_none()
&& self.riding != Some(m.id)
&& self.lan.as_ref().is_none_or(|host| {
host.hub
.guests
.iter()
.all(|guest| guest.vehicle != Some(m.id))
}))
.then_some(adv::MovementView {
per_tick: [m.velocity.x, m.velocity.y, m.velocity.z],
fall_distance: m.fall_distance,
});
EntityView {
movement,
kind: m.kind.id.clone(),
position: [m.position.x, m.position.y, m.position.z],
on_fire: m.fire_ticks > 0,
@@ -3289,3 +3310,114 @@ mod custom_data_equipment_tests {
);
}
}
#[cfg(test)]
mod movement_predicate_tests {
use super::super::native_machines::tests::server_game;
use horizon_core::geometry::Vec3;
use horizon_core::position::BlockPos;
use serde_json::json;
#[test]
#[ignore = "requires prepared resources and scratch cwd/homes"]
fn actual_registered_movement_predicate_uses_admitted_mob() {
let mut game = server_game();
game.build_registries();
game.set_allow_commands(true);
game.block_ticks
.add_empty_chunk(BlockPos::new(0, 0, 0).chunk())
.unwrap();
assert!(game.run_command("forceload add 0 0"));
assert!(game.run_command("fill 0 199 0 15 199 15 stone"));
assert!(game.world.loaded(4, 4));
let root = std::env::current_dir().unwrap().join("movement-world");
let pack = root.join("datapacks/movement");
let folder = pack.join("data/motion/predicate");
std::fs::create_dir_all(&folder).unwrap();
std::fs::write(pack.join("pack.mcmeta"), r#"{"pack":{"description":"Movement fixture","min_format":[121,0],"max_format":[121,0]}}"#).unwrap();
let entity =
|p| json!({"type":"minecraft:entity_properties","entity":"this","predicate":p});
let movement =
|p| entity(json!({"minecraft:entity_type":"minecraft:pig","minecraft:movement":p}));
let high = movement(json!({"x":{"min":100}}));
let zero = movement(json!({"x":{"min":0,"max":0}}));
let probes = [
("high", high.clone(), false),
("zero", zero.clone(), true),
("empty", movement(json!({})), true),
(
"not_high",
json!({"type":"minecraft:inverted","term":high}),
true,
),
(
"not_zero",
json!({"type":"minecraft:inverted","term":zero}),
false,
),
];
for (name, p, _) in &probes {
std::fs::write(folder.join(format!("{name}.json")), p.to_string()).unwrap();
}
std::fs::write(
folder.join("identity.json"),
entity(json!({"minecraft:entity_type":"minecraft:pig"})).to_string(),
)
.unwrap();
std::fs::write(
folder.join("always.json"),
r#"{"type":"minecraft:all_of","terms":[]}"#,
)
.unwrap();
game.world_root = root;
game.reload_datapacks(false);
assert!(game.run_command("scoreboard objectives add motion dummy"));
assert!(game.run_command("execute if predicate motion:always"));
let clock = game.world.game_time;
let actor = "000002a1-0000-0000-0000-000000000001";
assert!(game.run_command("summon pig 4.5 200 4.5 {UUID:[I;673,0,0,1],NoAI:1b,NoGravity:1b,Health:10f,Motion:[0d,0d,0d]}"));
assert!(game.run_command(&format!("execute as {actor} if entity @s[type=pig,nbt={{UUID:[I;673,0,0,1],Health:10f,Pos:[4.5d,200d,4.5d],Motion:[0d,0d,0d],NoAI:1b,NoGravity:1b}}]")));
assert!(game.run_command(&format!("execute as {actor} if predicate motion:identity")));
let id = *game
.persist
.uuids
.iter()
.find(|(_, u)| **u == [673, 0, 0, 1])
.unwrap()
.0;
let mob = game.mobs.iter().find(|m| m.id == id).unwrap();
assert_eq!(mob.velocity, Vec3::ZERO);
assert_eq!(mob.fall_distance, 0.0);
assert!(mob.no_ai && mob.no_gravity);
assert!(mob.extra.mount.is_none());
let view = game.entity_view_of(super::super::commands::Target::Mob(id));
assert_eq!(view.kind, "minecraft:pig");
let mut failures = Vec::new();
for (name, p, want) in probes {
assert!(
game.fns
.store
.predicates
.contains_key(&format!("motion:{name}"))
);
let direct = game.conditions_hold(&p, Some(&view), [4.5, 200.0, 4.5]);
if direct != want {
failures.push(format!("direct {name}: {direct} != {want}"));
}
assert!(game.run_command("scoreboard players set result motion 91"));
assert!(game.run_command("scoreboard players set success motion 17"));
game.run_command(&format!("execute store result score result motion store success score success motion as {actor} if predicate motion:{name}"));
for key in ["result", "success"] {
let value = game.score.board.score(key, "motion").unwrap().value;
if value != i32::from(want) {
failures.push(format!("registered {name} {key}: {value}"));
}
}
}
assert_eq!(game.world.game_time, clock, "zero observation ticks");
assert!(
failures.is_empty(),
"actual movement predicate mismatches: {failures:?}"
);
}
}
+1
View File
@@ -458,6 +458,7 @@ impl Game<'_> {
Screen::DebugOptions => "debug-options",
Screen::Mod => "mod",
Screen::HudEditor => "hud-editor",
Screen::Studio => "studio",
}
}
pub(super) fn inventory_counts(&self) -> BTreeMap<String, i64> {
+504 -1
View File
@@ -193,7 +193,8 @@ impl ArmorStandAssets {
/// `TrimTextureKey`: the trim texture of `stack` on its humanoid
/// layer (`humanoid` or `humanoid_leggings`), if it has a trim.
fn trim_texture(&self, asset: &str, kind: &str, stack: &ItemStack) -> Option<TrimTexture> {
let (material, pattern) = stack.extra.as_ref()?.trim.as_ref()?;
let (material, pattern) =
horizon_client::item_data::trim::named(stack.extra.as_ref()?.trim.as_ref()?)?;
let overrides = self
.trim_overrides
.get(asset)
@@ -857,6 +858,13 @@ impl Game<'_> {
let mut dead = Vec::new();
// ServerLevel.tick: only entities in entity-ticking chunks tick.
let ticking = self.entity_ticking_area();
// EntityGetter.getEntityCollisions: the boxes that stop a stand's move
// (a living shulker, a boat, a happy ghast held still), gathered when
// the first stand travels. A server's stand rides nothing (`vehicle`
// is set on a server's client only), but mobs can ride it (`/ride`):
// `isPassengerOfSameVehicle` leaves those riders out.
let mut obstacles: Option<horizon_client::entities::ObstacleGrid> = None;
let mut nearby = Vec::new();
for i in 0..self.armor_stands.len() {
let at = self.armor_stands[i].position;
if !ticking(at.x.floor() as i32, at.z.floor() as i32) {
@@ -865,6 +873,11 @@ impl Game<'_> {
continue;
}
self.armor_stand_location_effects(i, false);
// ArmorStand.travel: a marker or a NoGravity stand does not move.
let travels = !self.armor_stands[i].marker && !self.armor_stands[i].no_gravity;
if travels && obstacles.is_none() {
obstacles = Some(self.stand_entity_collisions());
}
let a = &mut self.armor_stands[i];
a.previous = a.position;
a.previous_yaw = a.yaw;
@@ -872,11 +885,36 @@ impl Game<'_> {
a.first_tick = false;
// aiStep's freezing step (removeFrost) makes the speed instance.
a.speed_instance = true;
// LivingEntity.aiStep -> EntityEquipment.tick, before movement.
// Inspect in slot order, then mutate only each actual owned compass.
let clear = self.armor_stands[i].equipment.each_ref().map(|stack| {
stack.as_ref().is_some_and(|stack| {
stack.item.as_ref() == "minecraft:compass"
&& super::maps::lodestone_target_gone(self, stack, self.dim.id())
})
});
for (stack, clear) in self.armor_stands[i].equipment.iter_mut().zip(clear) {
if clear && let Some(stack) = stack {
stack.data_mut().set_other(
"minecraft:lodestone_tracker",
Some(horizon_world::nbt::Tag::Compound(Default::default())),
);
}
}
let a = &mut self.armor_stands[i];
let (water_height, lava_height) =
a.push_by_fluid(&self.world, &self.res.motion().fluids);
let in_water = water_height > 0.0;
let medium =
horizon_client::entities::StandMedium::from_heights(water_height, lava_height);
// Entity.collide asks for the entities meeting the box stretched
// by this tick's movement (the fluid push is already in it).
nearby.clear();
if let Some(grid) = obstacles.as_mut().filter(|_| travels) {
let area = a.bounds().expand_towards(a.velocity);
let area = area.inflate(Vec3::new(1.0e-7, 1.0e-7, 1.0e-7));
grid.near_except(area, Some(a.id), &mut nearby);
}
let (world, registry, facts, collision) = (
&self.world,
self.pack.assets.registry,
@@ -895,6 +933,7 @@ impl Game<'_> {
&self.res.motion().climbing,
in_water,
Some(self.border.geometry()),
&nearby,
) {
// LivingEntity.aiStep: `travel` first, then the blocks' push.
a.tick_settle(medium, step);
@@ -953,6 +992,60 @@ impl Game<'_> {
}
Ok(())
}
/// The armor stands' share of `Shulker.onPeekAmountChange`: the opening
/// lid of mob `shulker` moves every stand with physics (vanilla's
/// `!noPhysics`: not a marker, not `NoGravity`) whose box meets `area` by
/// `delta`, through the stand's `shove`. The stand the shulker
/// rides is left out (`isPassengerOfSameVehicle`). Stands tick before mobs
/// here, while vanilla ticks a stand that joined after the shulker (one
/// placed on it) after the shulker: `shove` makes up for the order.
pub(super) fn lid_pushes_armor_stands(&mut self, shulker: u32, area: Aabb, delta: [f64; 3]) {
if self.armor_stands.is_empty() {
return;
}
let delta = Vec3::new(delta[0], delta[1], delta[2]);
let mut obstacles: Option<horizon_client::entities::ObstacleGrid> = None;
// (the stand the shulker rides, when it joined), worked out at the first stand it meets.
let mut shell: Option<(Option<u32>, horizon_client::join_seq::JoinSeq)> = None;
let mut nearby = Vec::new();
for i in 0..self.armor_stands.len() {
let a = &self.armor_stands[i];
if a.marker || a.no_gravity || a.health <= 0.0 || !a.bounds().intersects(area) {
continue;
}
let (ridden, joined) = *shell.get_or_insert_with(|| {
let j = self.mobs.iter().position(|m| m.id == shulker);
(
j.and_then(|j| self.mob_root_vehicle(j)),
j.map_or(horizon_client::join_seq::JoinSeq::UNJOINED, |j| {
self.mobs[j].join
}),
)
});
if Some(a.id) == ridden {
continue;
}
let grid = obstacles.get_or_insert_with(|| self.stand_entity_collisions());
let a = &mut self.armor_stands[i];
// Entity.collide: the entities meeting the box stretched by the push.
let reach = a.bounds().expand_towards(delta);
let reach = reach.inflate(Vec3::new(1.0e-7, 1.0e-7, 1.0e-7));
grid.near_except(reach, Some(a.id), &mut nearby);
let scene = horizon_client::collisions::SceneCollisions {
world: &self.world,
binding: self.collision,
subject: None,
};
let travels_after = a.join > joined;
a.shove(
scene,
delta,
Some(self.border.geometry()),
&nearby,
travels_after,
);
}
}
/// `Ravager.roar` with `mob_griefing` on: every armor stand in `area` (the
/// ravager's box grown by 4) takes `strongKnockback` from the ravager at
/// `from`. Its `mobAttack` leaves the stand whole (`ArmorStand.hurtServer`:
@@ -1074,6 +1167,79 @@ impl Game<'_> {
self.entity_killed_event(at, false);
self.world.modified.insert((pos[0] >> 4, pos[2] >> 4));
}
/// `ArmorStand.hurtServer` for a command's damage of `damage_type` from the
/// causing entity `causing`, up to its sources that act and still return
/// false: a blast breaks the stand (`brokenByAnything`, `kill`), fire that
/// ignites stands lights it (or burns a burning one 0.15), and burning
/// takes 4 health. Every other source is refused here, so this is always
/// false and `/damage` fails as vanilla's does.
pub(super) fn command_damage_stand(
&mut self,
id: u32,
damage_type: &str,
causing: Option<super::commands::Target>,
) -> bool {
use super::commands::Target;
let Some(i) = self.armor_stands.iter().position(|a| a.id == id) else {
return false;
};
let registry = &self.motion.health.damage;
let Some(kind) = registry.id(damage_type) else {
return false;
};
let tagged = |tag: &str| registry.in_tag(kind, tag);
if !self.ui.rules.bool("mob_griefing") && matches!(causing, Some(Target::Mob(_))) {
return false;
}
if tagged("minecraft:bypasses_invulnerability") {
self.kill_target(Target::Stand(id));
return false;
}
// Entity.isInvulnerableToBase: the flag, unless a creative player hits.
let creative = match causing {
Some(Target::Player) => self.creative,
Some(Target::Guest(conn)) => self.command_guest(conn).is_some_and(|g| g.creative()),
_ => false,
};
let invulnerable = self
.persist
.raw
.get(&id)
.is_some_and(|tag| horizon_client::entity_nbt::flag(tag, "Invulnerable"));
let a = &self.armor_stands[i];
if (invulnerable && !creative) || a.invisible || a.marker {
return false;
}
let (explosion, ignites, burns) = (
tagged("minecraft:is_explosion"),
tagged("minecraft:ignites_armor_stands"),
tagged("minecraft:burns_armor_stands"),
);
if explosion {
self.break_armor_stand_by_anything(i);
} else if ignites {
if self.armor_stands[i].fire_ticks > 0 {
self.cause_stand_damage(i, 0.15);
} else {
// igniteForSeconds(5.0f): at least 100 ticks.
let a = &mut self.armor_stands[i];
a.fire_ticks = a.fire_ticks.max(100);
}
} else if burns && self.armor_stands[i].health > 0.5 {
self.cause_stand_damage(i, 4.0);
}
false
}
/// `ArmorStand.causeDamage`: the health less `amount`, broken by anything
/// at 0.5 or less.
fn cause_stand_damage(&mut self, i: usize, amount: f32) {
let health = self.armor_stands[i].health - amount;
if health <= 0.5 {
self.break_armor_stand_by_anything(i);
} else {
self.armor_stands[i].health = health;
}
}
/// ArmorStandRenderer: the stand with its default pose, then armor layers.
pub(super) fn armor_stand_geometry(
&self,
@@ -2117,3 +2283,340 @@ mod pumpkin_equippable_use_tests {
);
}
}
#[cfg(test)]
mod stand_lodestone_inventory_tick_tests {
use horizon_client::motion::PlayerMotion;
use horizon_core::{geometry::Vec3, position::BlockPos};
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES and scratch homes/cwd"]
fn actual_stand_equipment_compasses_tick_after_loaded_lodestone_removal() {
let mut game = crate::game::native_machines::tests::server_game();
game.build_registries();
game.set_allow_commands(true);
game.level.health = 20.0;
game.player = Some(PlayerMotion::new(game.motion, Vec3::new(14.5, 200.0, 14.5)).unwrap());
game.block_ticks
.add_empty_chunk(BlockPos::new(0, 0, 0).chunk())
.unwrap();
assert!(game.run_command("forceload add 0 0"));
assert!(game.run_command("fill 0 199 0 15 199 15 stone"));
assert!(game.run_command("setblock 12 200 12 lodestone"));
assert!(game.world.loaded(12, 12));
let mut ids = Vec::new();
for (n, dim, tracked, target) in [
(1, "minecraft:overworld", true, true),
(2, "minecraft:overworld", false, true),
(3, "minecraft:the_nether", true, true),
(4, "minecraft:overworld", true, false),
] {
let who = format!("0000a551-0000-0000-0000-{n:012x}");
let x = f64::from(n * 2) + 0.5;
assert!(game.run_command(&format!("summon armor_stand {x} 200.0 4.5 {{UUID:[I;42321,0,0,{n}],Health:20f,NoGravity:1b}}")));
let id = game
.armor_stands
.iter()
.find(|a| game.persist.uuid_of(a.id) == Some([42321, 0, 0, n]))
.unwrap()
.id;
let tracker = format!(
"{}tracked:{tracked}",
if target {
format!("target:{{dimension:\"{dim}\",pos:[I;12,200,12]}},")
} else {
String::new()
}
);
for hand in ["mainhand", "offhand"] {
assert!(game.run_command(&format!("item replace entity {who} weapon.{hand} with compass[minecraft:lodestone_tracker={{{tracker}}},minecraft:custom_data={{marker:{n}}}] 1")));
}
let a = game.armor_stands.iter().find(|a| a.id == id).unwrap();
assert_eq!(a.position, Vec3::new(x, 200.0, 4.5));
assert!(a.no_gravity);
assert!(a.first_tick, "fresh actual stand has not ticked yet");
assert_eq!(a.health, 20.0);
for stack in a.equipment[..2].iter().flatten() {
assert_eq!(&*stack.item, "minecraft:compass");
assert_eq!(stack.count, 1);
assert_eq!(
stack
.data()
.unwrap()
.other("minecraft:lodestone_tracker")
.unwrap()
.get("target")
.is_some(),
target
);
}
assert!(game.persist.raw.remove(&id).is_some());
ids.push((id, n, target));
}
for hand in ["mainhand", "offhand"] {
assert!(game.run_command(&format!("item replace entity @s weapon.{hand} with compass[minecraft:lodestone_tracker={{target:{{dimension:\"minecraft:overworld\",pos:[I;12,200,12]}},tracked:true}}] 1")));
}
assert!(game.run_command("setblock 12 200 12 air"));
assert_eq!(
&*game.res.facts().get(game.world.block(12, 200, 12)).name,
"minecraft:air"
);
game.world.game_time += 1;
game.tick_lodestone_compasses();
for slot in [0, crate::game::shield::OFFHAND_SLOT] {
assert!(
game.stack_in(slot)
.unwrap()
.data()
.unwrap()
.other("minecraft:lodestone_tracker")
.unwrap()
.get("target")
.is_none(),
"actual player tick positive control"
);
}
assert!(
game.entity_ticking_area()(2, 4),
"actual entity-ticking chunk admission"
);
game.tick_armor_stands().unwrap();
let mut mismatches = Vec::new();
for (id, n, target) in ids {
let a = game.armor_stands.iter().find(|a| a.id == id).unwrap();
assert_eq!(a.position, Vec3::new(f64::from(n * 2) + 0.5, 200.0, 4.5));
assert_eq!(a.health, 20.0);
assert!(a.no_gravity);
for (slot, stack) in a.equipment[..2].iter().enumerate() {
let stack = stack.as_ref().unwrap();
assert_eq!(stack.count, 1);
let data = stack.data().unwrap();
assert_eq!(
data.other("minecraft:custom_data").unwrap().get("marker"),
Some(&horizon_world::nbt::Tag::Int(n))
);
let present = data
.other("minecraft:lodestone_tracker")
.unwrap()
.get("target")
.is_some();
if present != (target && n != 1) {
mismatches.push((n, slot, present));
}
}
let saved = horizon_client::entity_nbt::armor_stand_to_nbt(a, [42321, 0, 0, n], None);
assert!(
saved.get("equipment").is_some(),
"actual no-raw model writer"
);
for slot in 0..2 {
let key = horizon_client::entities::ArmorStand::SLOTS[slot];
let saved_target = saved
.get("equipment")
.unwrap()
.get(key)
.unwrap()
.get("components")
.unwrap()
.get("minecraft:lodestone_tracker")
.unwrap()
.get("target")
.is_some();
let modeled_target = a.equipment[slot]
.as_ref()
.unwrap()
.data()
.unwrap()
.other("minecraft:lodestone_tracker")
.unwrap()
.get("target")
.is_some();
assert_eq!(
saved_target, modeled_target,
"no-raw writer preserves actual tracker state"
);
}
}
assert!(
mismatches.is_empty(),
"actual stand equipment inventory tick mismatches: {mismatches:?}"
);
}
}
#[cfg(test)]
mod postbaseline_lodestone_controls {
//! Added after baseline: source-backed slots and preservation, not new runtime breadth.
use super::Game;
use horizon_client::{chunk::Chunk, entities::ArmorStand, items::ItemStack};
use horizon_core::position::BlockPos;
use std::sync::Arc;
fn stack<'g>(game: &'g Game<'_>, id: u32, slot: usize) -> &'g ItemStack {
game.armor_stands
.iter()
.find(|a| a.id == id)
.unwrap()
.equipment[slot]
.as_ref()
.unwrap()
}
fn has_target(stack: &ItemStack) -> bool {
stack
.data()
.unwrap()
.other("minecraft:lodestone_tracker")
.unwrap()
.get("target")
.is_some()
}
fn item(id: &str, tracker: &str, marker: usize, count: usize) -> String {
format!(
"{{id:\"{id}\",count:{count},components:{{\"minecraft:lodestone_tracker\":{{{tracker}}},\"minecraft:custom_data\":{{marker:{marker}}}}}}}"
)
}
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES and scratch homes/cwd"]
fn postbaseline_six_slots_noncompass_guard_and_dimension_preservation() {
let mut game = crate::game::native_machines::tests::server_game();
game.build_registries();
game.set_allow_commands(true);
game.block_ticks
.add_empty_chunk(BlockPos::new(0, 0, 0).chunk())
.unwrap();
assert!(game.run_command("forceload add 0 0"));
assert!(game.run_command("fill 0 199 0 15 199 15 stone"));
assert!(game.run_command("setblock 12 200 12 lodestone"));
let tracked = "target:{dimension:\"minecraft:overworld\",pos:[I;12,200,12]},tracked:true";
let slots = ArmorStand::SLOTS
.iter()
.enumerate()
.map(|(slot, key)| format!("{key}:{}", item("minecraft:compass", tracked, slot, 1)))
.collect::<Vec<_>>()
.join(",");
assert!(game.run_command(&format!("summon armor_stand 4.5 200.0 4.5 {{UUID:[I;42321,0,0,100],Health:20f,NoGravity:1b,equipment:{{{slots}}}}}")));
let mixed = format!(
"mainhand:{},offhand:{},head:{},feet:{}",
item("minecraft:stone", tracked, 66, 3),
item(
"minecraft:compass",
"target:{dimension:\"minecraft:the_nether\",pos:[I;12,200,12]},tracked:true",
77,
1
),
item(
"minecraft:compass",
"target:{dimension:\"minecraft:overworld\",pos:[I;12,200,12]},tracked:false",
88,
1
),
item("minecraft:compass", "tracked:true", 99, 1)
);
assert!(game.run_command(&format!("summon armor_stand 8.5 200.0 8.5 {{UUID:[I;42321,0,0,101],Health:20f,NoGravity:1b,equipment:{{{mixed}}}}}")));
game.world
.chunks
.insert((4, 4), Arc::new(Chunk::empty(4, 4)));
game.block_ticks
.add_empty_chunk(BlockPos::new(64, 0, 64).chunk())
.unwrap();
assert!(game.run_command("fill 64 199 64 79 199 79 stone"));
assert!(game.run_command(&format!("summon armor_stand 65.5 200.0 65.5 {{UUID:[I;42321,0,0,102],Health:20f,NoGravity:1b,equipment:{{mainhand:{}}}}}",item("minecraft:compass",tracked,100,1))));
let id = |n| {
game.armor_stands
.iter()
.find(|a| game.persist.uuid_of(a.id) == Some([42321, 0, 0, n]))
.unwrap()
.id
};
let (all, mixed, inactive) = (id(100), id(101), id(102));
// Direct class ticks do not refresh the headless fixture's tickets.
// Use the existing ticket model: only chunk zero is forced to tick.
game.tickets.update(Some(crate::ticket_levels::Sources {
forced: vec![(0, 0)],
..Default::default()
}));
assert!(game.entity_ticking_area()(4, 4));
assert!(!game.entity_ticking_area()(65, 65));
assert!(game.world.loaded(65, 65));
let before: Vec<_> = (0..6)
.map(|slot| {
game.armor_stands
.iter()
.find(|a| a.id == mixed)
.unwrap()
.equipment[slot]
.as_ref()
.map(horizon_items::item_nbt::item_to_nbt)
})
.collect();
assert!(has_target(stack(&game, mixed, 0)));
assert!(has_target(stack(&game, mixed, 1)));
assert!(has_target(stack(&game, mixed, 5)));
assert!(!has_target(stack(&game, mixed, 2)));
assert!(has_target(stack(&game, inactive, 0)));
for slot in 0..6 {
assert!(has_target(stack(&game, all, slot)));
}
let dimension = game.dim;
let chunks: std::collections::HashSet<_> = game.world.chunks.keys().copied().collect();
assert!(game.run_command("setblock 12 200 12 air"));
game.world.game_time += 1;
game.tick_armor_stands().unwrap();
assert!(
!game
.armor_stands
.iter()
.find(|a| a.id == all)
.unwrap()
.first_tick,
"active stand really ticked"
);
assert!(
game.armor_stands
.iter()
.find(|a| a.id == inactive)
.unwrap()
.first_tick,
"non-ticking guard unchanged"
);
for slot in 0..6 {
let s = stack(&game, all, slot);
assert!(!has_target(s), "source-backed equipment slot {slot}");
assert_eq!(s.count, 1);
assert_eq!(
s.data()
.unwrap()
.other("minecraft:custom_data")
.unwrap()
.get("marker"),
Some(&horizon_world::nbt::Tag::Int(slot as i32))
);
}
let after: Vec<_> = (0..6)
.map(|slot| {
game.armor_stands
.iter()
.find(|a| a.id == mixed)
.unwrap()
.equipment[slot]
.as_ref()
.map(horizon_items::item_nbt::item_to_nbt)
})
.collect();
assert_eq!(
before, after,
"actual non-compass/untracked/other-dimension/targetless stacks preserved"
);
assert!(has_target(stack(&game, inactive, 0)));
assert_eq!(game.dim, dimension);
assert_eq!(
game.world
.chunks
.keys()
.copied()
.collect::<std::collections::HashSet<_>>(),
chunks
);
}
}
@@ -913,7 +913,7 @@ impl Game<'_> {
return None;
};
let (value, signature) = profile.textures()?;
let url = skins::unpack(&value, signature, &skins::TEXTURE_URIS).skin?;
let url = skins::unpack(&value, signature, skins::texture_uris()).skin?;
let location = skins::skin_location(&skins::texture_hash(&url)?);
Some((url, location))
});
@@ -38,8 +38,8 @@ impl Game<'_> {
self.forget_gone_hoppers();
}
let ticking = self.block_ticking_area();
let mut spawners = Vec::new();
let mut trial_spawners = Vec::new();
self.spawner_tick_positions.clear();
self.trial_spawner_tick_positions.clear();
self.item_columns = Some(Default::default());
self.pass_mobs = Some(Default::default());
for slot in self.world.block_entity_tickers().iter().copied() {
@@ -62,11 +62,11 @@ impl Game<'_> {
Some(BlockEntity::CreakingHeart(_)) => self.tick_creaking_heart(pos)?,
Some(BlockEntity::Spawner(_)) => {
self.tick_spawner(pos)?;
spawners.push(pos);
self.spawner_tick_positions.push(pos);
}
Some(BlockEntity::TrialSpawner(_)) => {
self.tick_trial_spawner(pos)?;
trial_spawners.push(pos);
self.trial_spawner_tick_positions.push(pos);
}
Some(BlockEntity::Vault(_)) => self.tick_vault(pos)?,
Some(BlockEntity::Beehive(_)) => self.tick_beehive(pos),
@@ -94,10 +94,14 @@ impl Game<'_> {
// The client ticks (particles, sounds, the turning mob) are for a
// window; a dedicated or integrated server has none.
if self.options.dedicated.is_none() {
let spawners = std::mem::take(&mut self.spawner_tick_positions);
self.client_tick_spawners(&spawners);
for key in trial_spawners {
self.spawner_tick_positions = spawners;
let trial_spawners = std::mem::take(&mut self.trial_spawner_tick_positions);
for key in trial_spawners.iter().copied() {
self.client_tick_trial_spawner(key);
}
self.trial_spawner_tick_positions = trial_spawners;
}
// `ShulkerBoxBlockEntity.tick`: the lids of the boxes this level
// animates. Their grown collision shape reaches the collision queries
@@ -70,11 +70,18 @@ impl Game<'_> {
/// acting as it) and the server's other players.
/// Starts or ends a stretch of vehicle ticks (`tick_boats`, `tick_carts`): while one runs,
/// a vehicle's box query tests the mobs' positions against their [`BoxReach`] first.
/// Nothing a vehicle does resizes a mob, and a mob joining or leaving works it out again.
/// A mob joining or leaving works it out again, and so does [`Self::vehicle_picked_up`].
pub(super) fn vehicles_ticking(&self, on: bool) {
self.vehicle_mob_reach
.set(on.then(|| (self.mobs.len(), BoxReach::of(&self.mobs))));
}
/// A vehicle picked a mob up: a shulker turns its lid up (`Shulker.startRiding`), which
/// changes its box, so the next query works the [`BoxReach`] out again.
pub(super) fn vehicle_picked_up(&self) {
if let Some((_, reach)) = self.vehicle_mob_reach.get() {
self.vehicle_mob_reach.set(Some((usize::MAX, reach)));
}
}
/// The mobs' [`BoxReach`] while vehicles tick, none otherwise.
pub(super) fn vehicle_mob_reach(&self) -> Option<BoxReach> {
let (count, reach) = self.vehicle_mob_reach.get()?;
@@ -124,7 +131,7 @@ impl Game<'_> {
}
/// `getRootVehicle` of mob `j`: the mob or boat at the bottom of what it
/// rides, None when it rides nothing.
fn mob_root_vehicle(&self, mut j: usize) -> Option<u32> {
pub(super) fn mob_root_vehicle(&self, mut j: usize) -> Option<u32> {
let mut root = None;
for _ in 0..8 {
let Some(v) = self.mobs[j].extra.mount else {
@@ -305,6 +312,7 @@ impl Game<'_> {
&& self.native_mob_may_ride(mob, id, true)
&& let Some(m) = self.mobs.iter_mut().find(|m| m.id == mob)
{
horizon_client::mobs::shulker::on_start_riding(m);
m.extra.mount = Some(id);
m.extra.passenger = true;
m.ai.nav.stop();
@@ -312,6 +320,7 @@ impl Game<'_> {
// Mob.startRiding drops its lead.
self.break_leash(mob, true, false);
self.note_started_riding(id);
self.vehicle_picked_up();
}
continue;
}
+21 -18
View File
@@ -23,7 +23,7 @@ use horizon_client::{
use horizon_core::{
geometry::{Aabb, ShapeOp, Vec3, VoxelShape},
position::BlockPos,
random::RandomSource,
random::{LegacyRandom, RandomSource},
};
use horizon_world::collision_context::CollisionContext;
use horizon_world::nbt::Tag;
@@ -109,6 +109,7 @@ pub(super) struct Boat {
pub leash_spin: f64,
/// `Entity.tickCount`, for the 100 ticks a saved holder is waited for.
pub age: i32,
pub random: LegacyRandom,
}
/// `ChestBoat.CONTAINER_SIZE`.
pub(super) const CHEST_SLOTS: usize = 27;
@@ -220,6 +221,7 @@ impl Boat {
leash_pending: None,
leash_spin: 0.0,
age: 0,
random: horizon_client::random_source::entity(),
}
}
/// A chest boat or chest raft.
@@ -823,15 +825,11 @@ impl Game<'_> {
None,
);
b.velocity = horizon_world::entity_move::scale_by_block_speed(b.velocity, factor);
Self::tick_boat_paddles(&mut b, &mut self.audio, &mut self.world.random);
Self::tick_boat_paddles(&mut b, &mut self.audio);
b
}
/// `AbstractBoat.tick` advances the oars even when this client does not move the hull.
pub(super) fn tick_boat_paddles(
b: &mut Boat,
audio: &mut super::Audio,
random: &mut horizon_core::random::LegacyRandom,
) {
pub(super) fn tick_boat_paddles(b: &mut Boat, audio: &mut super::Audio) {
// Paddle sounds at the top of each stroke.
for i in 0..2 {
if b.paddle_state(i) {
@@ -847,7 +845,7 @@ impl Game<'_> {
if let Some(sound) = sound {
let v = view_vector(b.yaw, 0.0);
let (dx, dz) = if i == 1 { (-v[2], v[0]) } else { (v[2], -v[0]) };
let pitch = 0.8 + 0.4 * random.next_float();
let pitch = 0.8 + 0.4 * b.random.next_float();
audio.play(
sound,
Some([b.position.x + dx, b.position.y, b.position.z + dz]),
@@ -1687,18 +1685,17 @@ impl Game<'_> {
let id = self.boats[i].id;
self.release_vehicle_container(id);
let b = self.boats.remove(i);
// AbstractBoat.remove: a destroyed boat drops its lead.
self.boat_removed(&b);
// `AbstractChestBoat.remove` spills the chest through
// `Containers.dropContents` for every destroying removal reason;
// that has no `entity_drops` gate of its own.
for stack in b.items.iter().flatten() {
self.spawn_item_own(stack.clone(), at, 10);
}
// `Containers.dropContents` for every destroying removal reason
// (the kill below, and a creative hit's discard); that has no
// `entity_drops` gate of its own. A plain boat has no slots.
self.drop_vehicle_contents(at, &b.items);
// AbstractBoat.remove, after that: a destroyed boat drops its lead.
self.boat_removed(&b);
// `VehicleEntity.destroy(level, dropItem)`: `kill` (the boat's own
// ENTITY_DIE), then the boat item on the death path only, and only
// with `entity_drops` on, by `spawnAtLocation` (an ItemEntity's own
// random motion).
// ENTITY_DIE after that removal), then the boat item on the death
// path only, and only with `entity_drops` on, by `spawnAtLocation`
// (an ItemEntity's own random motion).
if !creative {
self.entity_killed_event(at, false);
}
@@ -1714,6 +1711,12 @@ impl Game<'_> {
at,
10,
);
// AbstractChestBoat.destroy: `chestVehicleDestroyed` drops the
// emptied chest again, which only draws each slot's spot.
if b.chest {
let empty = vec![None; b.items.len()];
self.drop_vehicle_contents(at, &empty);
}
}
}
Some(destroyed)
+3
View File
@@ -252,6 +252,9 @@ pub struct UiState {
pub show_hand_when_hidden: bool,
/// Death message key set by the cause of death (e.g. /kill).
pub death_cause: Option<String>,
/// What this frame's HUD shows that Horizon's overlays keep clear of (`Game::hud_room`),
/// set by `hud_layers` before anything draws.
pub room: horizon_client::hud_kit::room::Room,
/// Path of an F2 screenshot waiting for its frame.
pub screenshot_message: Option<std::path::PathBuf>,
/// The options screens.
@@ -0,0 +1,215 @@
//! `/clone from <sourceDimension>` / `to <targetDimension>`: each name is
//! resolved (`DimensionArgument.getDimension`) as its position is read, so an
//! unknown one fails the command with `argument.dimension.invalid` before
//! anything is cloned (tools/test_clone_dimensions_parity.py measures the
//! same on vanilla 26.3).
use super::*;
use horizon_client::chunk::Chunk;
use horizon_core::position::ChunkPos;
use std::sync::Arc;
fn game() -> Game<'static> {
let root = std::path::PathBuf::from(
std::env::var_os("HORIZON_TEST_RESOURCES")
.expect("set HORIZON_TEST_RESOURCES to prepared resources"),
);
let settings = Settings::default();
let loaded = load_resources(
root.clone(),
None,
&root,
&settings,
std::time::Instant::now(),
false,
true,
&Progress::new(),
&mut |_, _| unreachable!(),
)
.unwrap();
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
options.resources = root.clone();
options.execution_role = execution_role::ExecutionRole::IntegratedServer;
let res: &'static _ = Box::leak(Box::new(loaded.resources));
let mut game = Game::new(
res,
loaded.pack,
loaded.gui.unwrap(),
options,
settings,
Audio::silent(&root),
None,
)
.unwrap();
game.build_registries();
game.set_allow_commands(true);
game.world
.chunks
.insert((0, 0), Arc::new(Chunk::empty(0, 0)));
game.block_ticks
.add_empty_chunk(ChunkPos::new(0, 0))
.unwrap();
assert!(game.run_command("fill 0 99 0 15 99 15 stone"));
assert!(game.run_command("setblock 0 100 0 chest{CustomName:'\"h236\"'}"));
game
}
/// Runs `command`: whether it succeeded and the key of the error it raised.
fn run(game: &mut Game<'_>, command: &str) -> (bool, Option<String>) {
game.chat_capture = Some(Vec::new());
let ok = game.run_command(command);
let said = game.chat_capture.take().unwrap_or_default();
(ok, said.first().map(horizon_client::text::Text::plain))
}
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn an_unknown_clone_dimension_fails_and_clones_nothing() {
let mut game = game();
for (command, x, unknown) in [
(
"clone from h236:none 0 100 0 0 100 0 4 100 0",
4,
"h236:none",
),
("clone 0 100 0 0 100 0 to h236:none 6 100 0", 6, "h236:none"),
(
"clone from h236:first 0 100 0 0 100 0 to h236:second 8 100 0",
8,
"h236:first",
),
] {
assert!(game.run_command(&format!("setblock {x} 100 0 gold_block")));
assert_eq!(
run(&mut game, command),
(false, Some(format!("Unknown dimension '{unknown}'"))),
"{command}"
);
assert!(
run(&mut game, &format!("execute if block {x} 100 0 gold_block")).0,
"{command}: the destination stays"
);
}
// The same level, named or not, still clones.
assert!(run(&mut game, "clone 0 100 0 0 100 0 10 100 0").0);
assert!(
run(
&mut game,
"clone from minecraft:overworld 0 100 0 0 100 0 to minecraft:overworld 12 100 0"
)
.0
);
assert!(run(&mut game, "execute if block 12 100 0 chest").0);
}
/// Opens `dim`'s level through the same adapter the clone uses and gives it
/// the empty chunk at the origin, then returns to `back`.
fn load_chunk_in(game: &mut Game<'_>, dim: Dim, back: Dim) {
game.enter_command_level(dim);
assert_eq!(game.dim, dim, "the level is in place");
game.world
.chunks
.insert((0, 0), Arc::new(Chunk::empty(0, 0)));
let _ = game.block_ticks.add_empty_chunk(ChunkPos::new(0, 0));
game.enter_command_level(back);
assert_eq!(game.dim, back, "back in the first level");
}
fn block_in(game: &mut Game<'_>, dim: &str, at: &str, block: &str) -> bool {
run(game, &format!("execute in {dim} if block {at} {block}")).0
}
/// Added controls (after the baseline): a valid other dimension clones into
/// that level's own world, not into the command's, and the command's level
/// is back in place after every success and every failure.
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn a_clone_to_another_dimension_lands_in_that_world_and_comes_back() {
let mut game = game();
let here = game.dim;
assert_eq!(here, Dim::Overworld);
load_chunk_in(&mut game, Dim::Nether, here);
assert!(game.run_command("execute in minecraft:the_nether run setblock 14 100 0 gold_block"));
assert!(block_in(
&mut game,
"minecraft:the_nether",
"14 100 0",
"gold_block"
));
// Into the nether.
assert_eq!(
run(
&mut game,
"clone from minecraft:overworld 0 100 0 0 100 0 to minecraft:the_nether 14 100 0"
),
(true, Some("Successfully cloned 1 block(s)".to_owned()))
);
assert_eq!(game.dim, here, "the command's level is back");
assert!(block_in(
&mut game,
"minecraft:the_nether",
"14 100 0",
"chest"
));
assert!(
run(
&mut game,
"execute in minecraft:the_nether if data block 14 100 0 {CustomName:'\"h236\"'}"
)
.0,
"the chest's name came along"
);
assert!(
block_in(&mut game, "minecraft:overworld", "14 100 0", "air"),
"not in the overworld"
);
assert!(
block_in(&mut game, "minecraft:overworld", "0 100 0", "chest"),
"the source stays"
);
// The same coordinates in two levels do not overlap.
assert!(
run(
&mut game,
"clone from minecraft:overworld 0 100 0 0 100 0 to minecraft:the_nether 0 100 0"
)
.0
);
assert_eq!(game.dim, here);
assert!(block_in(
&mut game,
"minecraft:the_nether",
"0 100 0",
"chest"
));
// Failures leave both worlds and the level as they were.
assert!(game.run_command("execute in minecraft:the_nether run setblock 2 100 0 gold_block"));
for (command, said) in [
(
"clone 0 100 0 0 100 0 to h236:none 2 100 0",
"Unknown dimension 'h236:none'",
),
(
"clone 0 100 0 0 100 0 to minecraft:the_nether 40 100 40",
"That position is not loaded",
),
] {
assert_eq!(
run(&mut game, command),
(false, Some(said.to_owned())),
"{command}"
);
assert_eq!(game.dim, here, "{command}: the level is back");
assert!(block_in(
&mut game,
"minecraft:the_nether",
"2 100 0",
"gold_block"
));
assert!(block_in(
&mut game,
"minecraft:overworld",
"0 100 0",
"chest"
));
}
}
+286 -45
View File
@@ -96,6 +96,8 @@ pub(in crate::game) struct OtherEntity {
pub yaw: f32,
pub pitch: f32,
pub item_transformation: Option<super::native_entities::ItemDisplayTransformation>,
/// A `minecraft:interaction`'s saved settings; None for every other kind.
pub interaction: Option<super::interactions::InteractionState>,
/// Only explicitly loaded leash knots own this BlockAttachedEntity check counter.
pub knot_check: Option<u8>,
}
@@ -1373,6 +1375,9 @@ impl Game<'_> {
let custom = s.data().and_then(|data| data.custom_name.as_ref());
let hover = if let Some(c) = custom {
horizon_client::component::name_text(c)
} else if let Some(key) = horizon_client::brewing::contents_name_key(s) {
// A potion's or tipped arrow's own name, over its `item_name`.
Text::translate(&key, &[])
} else if let Some(name) = s.data().and_then(|d| d.other("minecraft:item_name")) {
// `ItemStack.getItemName`: the `item_name` component wins over the
// item's own name (`Item.getName`), so a datapack's or a server's
@@ -1385,18 +1390,6 @@ impl Game<'_> {
.filter(|t| !t.trim().is_empty())
{
Text::literal(title)
} else if matches!(
item,
"minecraft:potion"
| "minecraft:splash_potion"
| "minecraft:lingering_potion"
| "minecraft:tipped_arrow"
) && s.potion().is_some()
{
Text::translate(
&horizon_client::brewing::potion_name_key(item, s.potion()),
&[],
)
} else if item == "minecraft:shield"
&& let Some(color) = s.data().and_then(|d| d.base_color.as_deref())
{
@@ -2378,10 +2371,12 @@ impl Game<'_> {
self.ui.death_cause = Some("death.attack.genericKill".into());
}
Target::Mob(id) => {
let mut killed = None;
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) {
// LivingEntity.kill: a generic_kill hit of Float.MAX_VALUE,
// which its death message tells.
if m.alive() {
let alive = m.alive();
if alive {
m.record_damage(
horizon_client::mobs::Damage::of(
horizon_client::mobs::DamageKind::GenericKill,
@@ -2393,6 +2388,16 @@ impl Game<'_> {
if m.death_time == 0 {
m.death_time = 1;
}
killed = alive.then(|| m.clone());
}
// The hit runs LivingEntity.die before the command returns:
// its ENTITY_DIE, loot and experience come now, not in the
// next tick's deaths, which skip it (as a frog's bite does).
if let Some(m) = killed
&& !m.removed
&& self.died.insert(id)
{
self.mob_died(&m);
}
}
Target::Item(id) => {
@@ -2422,7 +2427,9 @@ impl Game<'_> {
if let Some(i) = self.boats.iter().position(|b| b.id == id) {
self.release_vehicle_container(id);
let b = self.boats.remove(i);
self.spill_killed_vehicle_contents(b.position, &b.items);
// AbstractChestBoat.remove, after the live menu writes
// back its slots.
self.drop_vehicle_contents(b.position.components(), &b.items);
self.boat_removed(&b);
}
}
@@ -2430,7 +2437,8 @@ impl Game<'_> {
self.release_vehicle_container(id);
if let Some(i) = self.carts.iter().position(|c| c.id == id) {
let c = self.carts.remove(i);
self.spill_killed_vehicle_contents(c.position, &c.items);
// AbstractMinecartContainer.remove.
self.drop_vehicle_contents(c.position.components(), &c.items);
}
}
Target::Other(id) => self.others.retain(|o| o.id != id),
@@ -2452,30 +2460,6 @@ impl Game<'_> {
}
self.native_leaves_flush();
}
/// `AbstractChestBoat.remove` / `AbstractMinecartContainer.remove` for
/// destroying removal (`Entity.kill`): `Containers.dropContents`, separate
/// from hurt-time hull drops and after the live menu writes back its slots.
fn spill_killed_vehicle_contents(&mut self, pos: Vec3, slots: &[Option<ItemStack>]) {
for stack in slots {
let count = stack.as_ref().map_or(0, |s| s.count);
for piece in horizon_world::level::removal::drop_item_stack_at(
&mut self.world.random,
pos.components(),
count,
) {
let Some(stack) = stack else { continue };
self.spawn_item(
ItemStack {
count: piece.count,
..stack.clone()
},
piece.pos,
piece.motion,
0,
);
}
}
}
fn cmd_kill(&mut self, ctx: &Context) -> R<i32> {
let targets = if ctx.get("targets").is_some() {
self.entities(ctx, "targets", false)?
@@ -2574,6 +2558,7 @@ impl Game<'_> {
count: 1,
ticks: 0,
health: 5,
following: None,
});
// tryAddFreshEntityWithPassengers refused: ERROR_DUPLICATE_UUID.
if !self.native_joins_flush(false, self.next_entity) {
@@ -3204,10 +3189,115 @@ impl Game<'_> {
self.feedback(Text::tr("commands.fill.success", &[&count]));
Ok(count)
}
/// `CloneCommands`: `from <sourceDimension>` is resolved as `begin` is
/// read, `to <targetDimension>` as `destination` is
/// (`DimensionArgument.getDimension`), each position checked loaded in its
/// own level (`BlockPosArgument.getLoadedBlockPos`): an unknown source fails
/// before an unknown target. The blocks are read in the source level and
/// placed in the target level. The command's own level is back in place,
/// whatever happened, before the command reports.
fn cmd_clone(&mut self, ctx: &Context) -> R<i32> {
let a = self.block_pos_arg(ctx, "begin")?;
let b = self.block_pos_arg(ctx, "end")?;
let dest = self.block_pos_arg(ctx, "destination")?;
let here = self.dim;
let result = self.clone_levels(ctx);
if self.dim != here {
self.enter_command_level(here);
}
if self.dim != here {
log::error!("/clone could not return to {}", here.id());
return Err(err("commands.clone.failed", &[]));
}
let count = result?;
self.feedback(Text::tr("commands.clone.success", &[&count]));
Ok(count)
}
fn clone_levels(&mut self, ctx: &Context) -> R<i32> {
let here = self.dim;
let source_level = self.command_source.map_or(self.dim, |s| s.dimension);
// The coordinates, from the command's own source, before any level moves.
let position = |name: &str| -> R<[i32; 3]> {
let c = self
.coords(ctx, name)
.ok_or_else(|| err("argument.pos.missing.int", &[]))?;
Ok(self.resolve_coords(&c).map(|v| v.floor() as i32))
};
let (a, b, dest) = (
position("begin")?,
position("end")?,
position("destination")?,
);
let from_dim = self.clone_dimension(ctx, "sourceDimension")?;
self.clone_loaded(from_dim, here, source_level, a)?;
self.clone_loaded(from_dim, here, source_level, b)?;
let to_dim = self.clone_dimension(ctx, "targetDimension")?;
self.clone_loaded(to_dim, here, source_level, dest)?;
self.clone_between(
ctx,
a,
b,
dest,
from_dim.unwrap_or(here),
to_dim.unwrap_or(here),
)
}
/// `BlockPosArgument.getLoadedBlockPos` in the level a dimension argument
/// names, or, without one, the command's own (as `block_pos_arg` checks it).
fn clone_loaded(
&mut self,
dim: Option<Dim>,
here: Dim,
source_level: Dim,
p: [i32; 3],
) -> R<()> {
match dim {
Some(dim) => self.clone_enter(dim)?,
None => {
self.clone_enter(here)?;
if source_level != self.dim {
return Err(err("argument.pos.unloaded", &[]));
}
}
}
if !self.world.chunks.contains_key(&(p[0] >> 4, p[2] >> 4)) {
return Err(err("argument.pos.unloaded", &[]));
}
if p[1] < self.world.bottom_y() || p[1] >= self.world.top_y() {
return Err(err("argument.pos.outofworld", &[]));
}
Ok(())
}
/// `DimensionArgument.getDimension`: a built-in dimension, or
/// `argument.dimension.invalid`; None when the argument is absent.
fn clone_dimension(&self, ctx: &Context, name: &str) -> R<Option<Dim>> {
let Some(Arg::Resource(id)) = ctx.get(name) else {
return Ok(None);
};
if !matches!(
id.as_str(),
"minecraft:overworld" | "minecraft:the_nether" | "minecraft:the_end"
) {
return Err(err("argument.dimension.invalid", &[id]));
}
Ok(Some(Dim::from_id(id)))
}
/// Put `dim`'s level in place for the clone, or fail it when it cannot be.
fn clone_enter(&mut self, dim: Dim) -> R<()> {
if self.dim != dim {
self.enter_command_level(dim);
}
if self.dim != dim {
return Err(err("commands.clone.failed", &[]));
}
Ok(())
}
fn clone_between(
&mut self,
ctx: &Context,
a: [i32; 3],
b: [i32; 3],
dest: [i32; 3],
from_dim: Dim,
to_dim: Dim,
) -> R<i32> {
let min = [a[0].min(b[0]), a[1].min(b[1]), a[2].min(b[2])];
let max = [a[0].max(b[0]), a[1].max(b[1]), a[2].max(b[2])];
let size = [max[0] - min[0], max[1] - min[1], max[2] - min[2]];
@@ -3215,7 +3305,7 @@ impl Game<'_> {
let overlap = (0..3).all(|i| dest[i] <= max[i] && dmax[i] >= min[i]);
let moving = ctx.has("move");
// Mode.canOverlap: only the normal mode refuses overlapping regions.
if !ctx.has("force") && !moving && overlap {
if !ctx.has("force") && !moving && from_dim == to_dim && overlap {
return Err(err("commands.clone.overlap", &[]));
}
let area = (0..3).map(|i| i64::from(size[i] + 1)).product::<i64>();
@@ -3223,6 +3313,7 @@ impl Game<'_> {
if area > i64::from(limit) {
return Err(err("commands.clone.toobig", &[&limit, &area]));
}
self.clone_enter(to_dim)?;
for p in [dest, dmax] {
if !self.world.chunks.contains_key(&(p[0] >> 4, p[2] >> 4)) {
return Err(err("argument.pos.unloaded", &[]));
@@ -3238,6 +3329,7 @@ impl Game<'_> {
};
let air = self.world.air;
let masked = ctx.has("masked");
self.clone_enter(from_dim)?;
let mut copies = Vec::new();
for y in min[1]..=max[1] {
for z in min[2]..=max[2] {
@@ -3270,6 +3362,7 @@ impl Game<'_> {
self.place_state(*from, air, true, false)?;
}
}
self.clone_enter(to_dim)?;
let strict = ctx.has("strict");
// CloneCommands.clone sets every destination to a barrier first, so
// each copied state but a barrier counts as placed.
@@ -3306,7 +3399,6 @@ impl Game<'_> {
if count == 0 {
return Err(err("commands.clone.failed", &[]));
}
self.feedback(Text::tr("commands.clone.success", &[&count]));
Ok(count)
}
/// Player.giveExperiencePoints including negative amounts.
@@ -3734,9 +3826,16 @@ impl Game<'_> {
Some(Arg::Coords(c)) => c.rotation(self.source_rotation()),
_ => [0.0, 0.0],
};
// SetWorldSpawnCommand uses RespawnData.of before storing or comparing.
let rot = [wrap_degrees(rot[0]), rot[1].clamp(-90.0, 90.0)];
// LevelData.setRespawnData: the world's spawn (level.dat `spawn`).
let previous_point = self.world_spawn_position().map(|v| v.floor() as i32);
let old = self.level.world_spawn.replace(pos);
let old_angle = self.level.world_spawn_angle;
self.level.world_spawn_angle = rot;
if previous_point != pos || old_angle.map(f32::to_bits) != rot.map(f32::to_bits) {
self.lan_broadcast_world_spawn();
}
self.native_spawn_change(pos, rot);
// A player whose fallback is the world spawn (no spawnpoint of its
// own; a bed or an anchor that is gone falls back to it) respawns at
@@ -3892,7 +3991,9 @@ impl Game<'_> {
_ => false,
}
}
_ => present.is_some(),
// Count is a pseudo-component, not a catalog component.
_ if id == "minecraft:count" => present.is_some(),
_ => self.stack_component_present(stack, &id),
};
result != negated
};
@@ -3901,6 +4002,23 @@ impl Game<'_> {
.filter(|t| !t.trim().is_empty())
.all(|term| split_top_level(term, '|').iter().any(|alt| test(alt)))
}
/// `ItemPredicateArgument.createComponentExistencePredicate`: the stack's
/// effective component presence, with its patch ahead of its prototype.
fn stack_component_present(&self, stack: &ItemStack, id: &str) -> bool {
// Read the modeled patch before saved-default elision. A removal wins
// even when the actual item prototype supplies the component.
if let Some(patch) = horizon_client::item_data::components_tag(stack.damage, stack.data()) {
if patch.get(&format!("!{id}")).is_some() {
return false;
}
if patch.get(id).is_some() {
return true;
}
}
self.items()
.get(&stack.item)
.is_some_and(|item| item.components.iter().any(|key| key == id))
}
fn cmd_clear(&mut self, ctx: &Context) -> R<i32> {
let targets = if ctx.get("targets").is_some() {
self.entities(ctx, "targets", true)?
@@ -5279,6 +5397,129 @@ mod forced_respawn_tests {
}
}
#[cfg(test)]
mod effective_presence_tests {
use super::super::native_machines::tests::server_game;
use super::*;
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES and scratch homes/cwd"]
fn registered_presence_uses_actual_catalog_and_stack_patch() {
let mut game = server_game();
game.build_registries();
game.set_allow_commands(true);
let key = "minecraft:jukebox_playable";
assert!(
game.items()
.get("minecraft:music_disc_cat")
.unwrap()
.components
.iter()
.any(|k| k == key)
);
assert!(
!game
.items()
.get("minecraft:stone")
.unwrap()
.components
.iter()
.any(|k| k == key)
);
assert!(game.run_command("forceload add 0 0 15 15"));
assert!(game.world.loaded(4, 4));
assert!(game.run_command("setblock 4 199 4 stone"));
assert!(game.run_command("setblock 4 200 4 chest{Items:[{Slot:0b,id:\"minecraft:music_disc_cat\",count:1},{Slot:1b,id:\"minecraft:stone\",count:1},{Slot:2b,id:\"minecraft:stone\",count:1,components:{\"minecraft:jukebox_playable\":\"minecraft:cat\",\"minecraft:custom_data\":{h193:1}}},{Slot:3b,id:\"minecraft:music_disc_cat\",count:1,components:{\"!minecraft:jukebox_playable\":{},\"minecraft:custom_data\":{h193:1}}}]}"));
assert!(game.run_command("scoreboard objectives add h193 dummy"));
let clock = game.world.game_time;
let horizon_client::containers::BlockEntity::Chest(stacks) =
game.world.block_entities.get(&(4, 200, 4)).unwrap()
else {
panic!("actual chest missing")
};
let stacks = stacks.clone();
assert_eq!(stacks.len(), 27);
assert!(
stacks[0].as_ref().unwrap().extra.is_none(),
"plain modeled stack has no patch"
);
assert!(
stacks[2]
.as_ref()
.unwrap()
.extra
.as_ref()
.unwrap()
.other(key)
.is_some()
);
assert!(
stacks[3]
.as_ref()
.unwrap()
.extra
.as_ref()
.unwrap()
.other("!minecraft:jukebox_playable")
.is_some()
);
let saved = game.block_entity_nbt([4, 200, 4]).unwrap();
let mut failures = Vec::new();
for (slot, present) in [true, false, true, false].into_iter().enumerate() {
let stack = stacks[slot].as_ref().unwrap();
assert_eq!(stack.count, 1);
for neg in [false, true] {
let expected = present != neg;
let term = format!("{}minecraft:jukebox_playable", if neg { "!" } else { "" });
let predicate = ItemInput {
id: String::new(),
tag: false,
any: true,
components: Some(format!("[{term}]")),
};
if game.stack_matches(&predicate, stack) != expected {
failures.push(format!("direct slot{slot} {term}"));
}
for sign in ["if", "unless"] {
for (name, n) in [("r", 91), ("s", 17)] {
assert!(
game.run_command(&format!("scoreboard players set #{name} h193 {n}"))
);
}
let accepted = game.run_command(&format!("execute store result score #r h193 store success score #s h193 {sign} items block 4 200 4 container.{slot} *[{term}]"));
let yes = if sign == "if" { expected } else { !expected };
let score = |name| game.score.board.score(name, "h193").unwrap().value;
if (accepted, score("#r"), score("#s")) != (yes, i32::from(yes), i32::from(yes))
{
failures.push(format!("command slot{slot} {sign} {term}"));
}
}
}
}
let plain = ItemStack::new("minecraft:music_disc_cat", 1);
assert!(plain.extra.is_none());
let p = ItemInput {
id: String::new(),
tag: false,
any: true,
components: Some("[minecraft:jukebox_playable]".into()),
};
if !game.stack_matches(&p, &plain) {
failures.push("no-raw modeled prototype".into());
}
assert_eq!(
game.block_entity_nbt([4, 200, 4]).unwrap(),
saved,
"read-only saved inventory"
);
assert_eq!(game.world.game_time, clock, "zero actor ticks");
assert!(game.world.loaded(4, 4));
assert!(
failures.is_empty(),
"actual effective component presence mismatches: {failures:?}"
);
}
}
#[cfg(test)]
mod fill_note_instrument_tests {
use super::*;
+493 -4
View File
@@ -145,6 +145,8 @@ impl Game<'_> {
let i = self.boats.iter().position(|b| b.id == id)?;
let b = &self.boats[i];
let mut tag = b.to_nbt(uuid, self.persist.raw.get(&id));
// Entity.saveWithoutId: the actual runtime passenger tree.
self.put_mob_passengers(&mut tag, id);
// AbstractBoat.addAdditionalSaveData: the live or pending leash.
self.write_boat_leash(&mut tag, i);
tag
@@ -152,7 +154,9 @@ impl Game<'_> {
Target::Cart(id) => {
let uuid = self.uuid_for(id);
let c = self.carts.iter().find(|c| c.id == id)?;
c.to_nbt(uuid, self.persist.raw.get(&id))
let mut tag = c.to_nbt(uuid, self.persist.raw.get(&id));
self.put_mob_passengers(&mut tag, id);
tag
}
Target::Fangs(id) => {
let uuid = self.uuid_for(id);
@@ -263,6 +267,7 @@ impl Game<'_> {
id,
kind: "minecraft:area_effect_cloud".into(),
item_transformation: None,
interaction: None,
knot_check: None,
position: horizon_core::geometry::Vec3::new(x, y, z),
yaw: 0.0,
@@ -331,6 +336,18 @@ impl Game<'_> {
/// `TypedEntityData.loadInto` is: a spawn egg's data on the mob it spawned.
pub(in crate::game) fn apply_entity_nbt(&mut self, t: Target, mut tag: Tag) -> R<()> {
let stale = || err("commands.data.merge.failed", &[]);
// Entity.load does not read Passengers: the riders stay, and so do the
// passengers the load kept raw (none, when it kept none).
let kept_passengers = match t {
Target::Mob(id) | Target::Boat(id) | Target::Cart(id) | Target::Tnt(id) => Some(
self.persist
.raw
.get(&id)
.and_then(|raw| raw.get("Passengers"))
.cloned(),
),
_ => None,
};
match t {
// EntityDataAccessor.setData: players can't be modified, guests neither.
Target::Player | Target::Guest(_) => {
@@ -364,8 +381,10 @@ impl Game<'_> {
tag.get("Brain").and_then(|brain| brain.get("memories")),
Some(Tag::Compound(_))
);
// Runtime-only state (AI, animation) carries over.
// Runtime-only state (AI, animation) carries over, and the
// entity keeps its place in the level's join order.
let old = std::mem::replace(slot, new);
slot.join = old.join;
if keep_liked_player {
slot.extra.tame = old.extra.tame;
slot.extra.owner = old.extra.owner;
@@ -386,6 +405,9 @@ impl Game<'_> {
horizon_client::mobs::villager::ext(&old)
.and_then(|ext| ext.last_traded_player);
}
if slot.species() == horizon_client::mobs::Species::Shulker {
horizon_client::mobs::shulker::keep_eased_peek(slot, &old);
}
slot.ai = old.ai;
// Mob.readAdditionalSaveData updates persistent home fields,
// while retaining the rest of the live AI/runtime state.
@@ -427,8 +449,10 @@ impl Game<'_> {
Target::Stand(id) => {
let mut new = entity_nbt::armor_stand_from_nbt(&tag, id).ok_or_else(stale)?;
if let Some(slot) = self.armor_stands.iter_mut().find(|a| a.id == id) {
// Entity.load keeps the existing entity's firstTick state.
// Entity.load keeps the existing entity's firstTick state
// and its place in the level's join order.
new.first_tick = slot.first_tick;
new.join = slot.join;
*slot = new;
}
}
@@ -543,6 +567,41 @@ impl Game<'_> {
// Runtime id/join, owner binding and guest attribution stay live.
set_key(&mut tag, "UUID", Tag::IntArray(uuid.to_vec()));
}
Target::Orb(id) => {
// Entity.load and ExperienceOrb.readAdditionalSaveData on the
// resident orb: its runtime id, join and tick count stay.
let new = entity_nbt::orbs_from_nbt(&tag)
.into_iter()
.next()
.ok_or_else(stale)?;
let uuid = self.uuid_for(id);
let orb = self
.orbs
.iter_mut()
.find(|o| o.id == id)
.ok_or_else(stale)?;
orb.rotation = new.rotation;
orb.position = new.position;
orb.previous = new.previous;
orb.velocity = new.velocity;
orb.value = new.value;
orb.age = new.age;
orb.on_ground = new.on_ground;
orb.count = new.count;
orb.health = new.health;
orb.no_gravity = new.no_gravity;
// Entity.load reads portalCooldown and leaves the portal
// process as it is.
if let Some(portal) = self.portals.entities.get_mut(&id) {
portal.cooldown = match tag.get("PortalCooldown") {
Some(Tag::Int(cooldown)) => *cooldown,
_ => 0,
};
} else {
self.restore_portal_cooldown(id, &tag);
}
set_key(&mut tag, "UUID", Tag::IntArray(uuid.to_vec()));
}
Target::Cart(id) => {
let uuid = self.uuid_for(id);
let i = self
@@ -565,7 +624,9 @@ impl Game<'_> {
.others
.iter_mut()
.find(|o| {
o.id == id && matches!(o.kind.as_str(), "minecraft:marker" | "marker")
o.id == id
&& (matches!(o.kind.as_str(), "minecraft:marker" | "marker")
|| crate::game::interactions::is_interaction(&o.kind))
})
.ok_or_else(stale)?;
let position = entity_nbt::position(&tag).ok_or_else(stale)?;
@@ -573,6 +634,11 @@ impl Game<'_> {
marker.position = position;
marker.yaw = yaw;
marker.pitch = pitch;
// Interaction.readAdditionalSaveData on the same entity.
if crate::game::interactions::is_interaction(&marker.kind) {
marker.interaction =
Some(crate::game::interactions::InteractionState::from_tag(&tag));
}
// Keep the existing marker identity even when the input edits UUID or id.
set_key(&mut tag, "UUID", Tag::IntArray(uuid.to_vec()));
set_key(&mut tag, "id", Tag::String(marker.kind.as_str().into()));
@@ -586,10 +652,23 @@ impl Game<'_> {
| Target::Boat(id)
| Target::Cart(id)
| Target::Tnt(id)
| Target::Orb(id)
| Target::Cushion(id)
| Target::Other(id)
| Target::Fangs(id) = t
{
if let Some(kept) = kept_passengers
&& let Tag::Compound(c) = &mut tag
{
match kept {
Some(passengers) => {
c.insert("Passengers".into(), passengers);
}
None => {
c.remove(&"Passengers".into());
}
}
}
self.persist.raw.insert(id, tag);
}
Ok(())
@@ -1348,6 +1427,15 @@ impl Game<'_> {
set_key(&mut tag, "home_pos", Tag::IntArray(home.to_vec()));
}
place_summoned(&mut tag, pos);
// ServerLevel.tryAddFreshEntityWithPassengers: a UUID of the tree that
// an entity in the level holds refuses the whole tree, before any of
// it joins (players are not looked at here).
if self.summon_tree_uuids(&tag).into_iter().any(|u| {
self.target_with_uuid(u)
.is_some_and(|t| t != Target::Player)
}) {
return Err(err("commands.summon.failed.uuid", &[]));
}
let from = self.mobs.len();
let canceled = self.persist.joins_canceled;
let target = self.spawn_entity_from_nbt(tag);
@@ -1541,6 +1629,159 @@ mod wolf_live_anger_tests {
}
}
#[cfg(test)]
mod boat_passenger_tree_tests {
use super::*;
use crate::game::native_machines::tests::server_game;
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES and scratch homes/cwd"]
fn actual_boat_getter_projects_runtime_children_and_existing_bookkeeping() {
let mut game = server_game();
game.build_registries();
game.set_allow_commands(true);
assert!(game.run_command("forceload add 0 0 15 15"));
assert!(game.world.loaded(8, 8));
assert!(game.run_command("setblock 8 199 8 stone"));
assert!(game.run_command("summon oak_boat 8.5 200 8.5 {UUID:[I;7,151,1,1],NoGravity:1b,Silent:1b,CustomName:\"Hull\"}"));
let bi = game.boats.iter().position(|b| b.position.x == 8.5).unwrap();
let bid = game.boats[bi].id;
let child = game
.spawn_mob_reason("minecraft:cow", [8.5, 200.0, 8.5], true, true, false)
.unwrap();
let ci = game.mobs.iter().position(|m| m.id == child).unwrap();
game.mobs[ci].no_ai = true;
game.mobs[ci].no_gravity = true;
assert!(
!game.persist.raw.contains_key(&child),
"fresh actual runtime child has no raw NBT"
);
assert_eq!(
game.mobs[ci].extra.anger_end, 0,
"unprimed constructor state"
);
game.ride_mount(Target::Mob(child), Target::Boat(bid))
.unwrap();
assert_eq!(game.mobs[ci].extra.mount, Some(bid));
let clock = game.world.game_time;
let chunks = game.world.chunks.len();
let raw = game.persist.raw.get(&bid).cloned();
assert!(raw.as_ref().unwrap().get("Passengers").is_none());
let before = (
game.persist.uuids.len(),
game.persist.uuid_of(child),
game.mobs[ci].extra.anger_end,
game.mobs[ci].extra.angry_at,
);
let first = game.entity_nbt(Target::Boat(bid)).unwrap();
let after = (
game.persist.uuids.len(),
game.persist.uuid_of(child),
game.mobs[ci].extra.anger_end,
game.mobs[ci].extra.angry_at,
);
println!("parent-first bookkeeping before={before:?} after={after:?}");
let mut failures = Vec::new();
if after.2 != -1 {
failures.push("first parent must use existing 0->-1 normalization".to_owned());
}
if after.1.is_none() || after.0 != before.0 + usize::from(before.1.is_none()) {
failures.push("first parent must use existing UUID registration once".into());
}
let control = game
.spawn_mob_reason("minecraft:cow", [12.5, 200.0, 8.5], true, true, false)
.unwrap();
let di = game.mobs.iter().position(|m| m.id == control).unwrap();
game.mobs[di].no_ai = true;
game.mobs[di].no_gravity = true;
assert!(!game.persist.raw.contains_key(&control));
assert_eq!(game.mobs[di].extra.anger_end, 0);
let direct_before = (
game.persist.uuids.len(),
game.persist.uuid_of(control),
game.mobs[di].extra.anger_end,
);
let direct_control = game.entity_nbt(Target::Mob(control)).unwrap();
let direct_after = (
game.persist.uuids.len(),
game.persist.uuid_of(control),
game.mobs[di].extra.anger_end,
);
println!(
"existing child-getter bookkeeping before={direct_before:?} after={direct_after:?}"
);
assert_eq!(direct_after.2, -1);
assert_eq!(
direct_after.0,
direct_before.0 + usize::from(direct_before.1.is_none())
);
assert!(direct_after.1.is_some());
assert_eq!(
direct_control.get("UUID"),
Some(&Tag::IntArray(direct_after.1.unwrap().to_vec()))
);
let direct_child = game.entity_nbt(Target::Mob(child)).unwrap();
let saved_child = |tag: &Tag| match tag.get("Passengers") {
Some(Tag::List(v)) if v.len() == 1 => Some(v[0].clone()),
_ => None,
};
if saved_child(&first).as_ref() != Some(&direct_child) {
failures.push("first parent typed child must equal actual child getter".into());
}
let stable = game.persist.uuids.len();
for _ in 0..2 {
let again = game.entity_nbt(Target::Boat(bid)).unwrap();
if saved_child(&again).as_ref() != Some(&direct_child) {
failures.push("repeated parent must keep exactly one actual child".into());
}
assert_eq!(game.persist.uuids.len(), stable);
assert_eq!(game.mobs[ci].extra.anger_end, -1);
assert_eq!(game.mobs[ci].extra.mount, Some(bid));
}
assert!(game.run_command("scoreboard objectives add h151 dummy"));
assert!(game.run_command("scoreboard players set #r h151 91"));
assert!(game.run_command("scoreboard players set #s h151 17"));
let accepted=game.run_command("execute store result score #r h151 store success score #s h151 run data get entity @e[nbt={UUID:[I;7,151,1,1]},limit=1] Passengers[0].UUID");
let score = |name| game.score.board.score(name, "h151").unwrap().value;
if (accepted, score("#r"), score("#s")) != (true, 4, 1) {
failures.push("registered data command must reach live tree".into());
}
game.boats[bi].leash = Some(horizon_client::mobs::leash::Holder::Mob(control));
let leashed = game.entity_nbt(Target::Boat(bid)).unwrap();
assert_eq!(
leashed.get("leash").and_then(|t| t.get("UUID")),
direct_control.get("UUID")
);
assert_eq!(leashed.get("CustomName"), Some(&Tag::String("Hull".into())));
assert_eq!(leashed.get("Silent"), Some(&Tag::Byte(1)));
game.dismount_mob(child);
assert!(
game.entity_nbt(Target::Boat(bid))
.unwrap()
.get("Passengers")
.is_none()
);
game.ride_mount(Target::Mob(child), Target::Boat(bid))
.unwrap();
let remount_child = game.entity_nbt(Target::Mob(child)).unwrap();
if saved_child(&game.entity_nbt(Target::Boat(bid)).unwrap()).as_ref()
!= Some(&remount_child)
{
failures.push("actual remount tree".into());
}
assert_eq!(
game.persist.raw.get(&bid),
raw.as_ref(),
"getter must not replay/mutate raw passenger input"
);
assert_eq!(game.world.chunks.len(), chunks, "no chunk loading");
assert_eq!(game.world.game_time, clock, "no scene ticks");
assert!(
failures.is_empty(),
"actual boat passenger getter mismatches: {failures:?}"
);
}
}
#[cfg(test)]
mod loot_table_tests {
use crate::game::{Audio, Game, Options, Progress, Settings, load_resources};
@@ -2175,3 +2416,251 @@ mod falling_live_adoption_tests {
});
}
}
#[cfg(test)]
mod vehicle_container_tests {
use super::*;
use crate::game::{Audio, Options, Progress, Settings, load_resources};
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES and scratch homes/cwd"]
fn loaded_vehicle_saves_own_only_their_container_fields() {
let root = std::path::PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES").unwrap());
let settings = Settings::default();
let loaded = load_resources(
root.clone(),
None,
&root,
&settings,
std::time::Instant::now(),
false,
true,
&Progress::new(),
&mut |_, _| unreachable!(),
)
.unwrap();
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
options.resources = root.clone();
options.execution_role = crate::game::execution_role::ExecutionRole::IntegratedServer;
let mut game = Game::new(
&loaded.resources,
loaded.pack,
loaded.gui.unwrap(),
options,
settings,
Audio::silent(&root),
None,
)
.unwrap();
game.build_registries();
game.set_allow_commands(true);
let cases = [
(
"boat_plain",
"minecraft:oak_boat",
"Items:[{Slot:0b,id:\"minecraft:diamond\",count:3,components:{\"minecraft:custom_data\":{h052:9}}}],LootTable:\"minecraft:empty\",LootTableSeed:73L",
None,
false,
"",
),
(
"cart_plain",
"minecraft:minecart",
"Items:[{Slot:0b,id:\"minecraft:diamond\",count:3,components:{\"minecraft:custom_data\":{h052:9}}}],LootTable:\"minecraft:empty\",LootTableSeed:73L",
None,
false,
"",
),
(
"boat_items",
"minecraft:oak_chest_boat",
"Items:[{Slot:0b,id:\"minecraft:diamond\",count:3,components:{\"minecraft:custom_data\":{h052:9}}}],LootTableSeed:73L",
Some(1usize),
false,
"",
),
(
"cart_items",
"minecraft:chest_minecart",
"Items:[{Slot:0b,id:\"minecraft:diamond\",count:3,components:{\"minecraft:custom_data\":{h052:9}}}],LootTableSeed:73L",
Some(1usize),
false,
"",
),
(
"boat_table",
"minecraft:oak_chest_boat",
"LootTable:\"minecraft:empty\",LootTableSeed:73L",
None,
true,
"minecraft:empty",
),
(
"cart_table",
"minecraft:chest_minecart",
"LootTable:\"minecraft:empty\",LootTableSeed:73L",
None,
true,
"minecraft:empty",
),
(
"boat_empty_table",
"minecraft:oak_chest_boat",
"LootTable:\"\",LootTableSeed:73L",
None,
true,
"minecraft:",
),
(
"cart_empty_table",
"minecraft:chest_minecart",
"LootTable:\"\",LootTableSeed:73L",
None,
true,
"minecraft:",
),
(
"boat_empty",
"minecraft:oak_chest_boat",
"",
Some(0usize),
false,
"",
),
(
"cart_empty",
"minecraft:chest_minecart",
"",
Some(0usize),
false,
"",
),
];
let clock = game.world.game_time;
let mut differences = Vec::new();
for (i, (name, kind, fields, expected_items, pending, table_text)) in
cases.iter().enumerate()
{
let uuid = [52, 0, 0, i as i32 + 1];
let source = [
r#"{Pos:[4.5d,200d,4.5d],NoGravity:1b,Tags:["h052"]"#,
if fields.is_empty() { "" } else { "," },
fields,
"}",
]
.concat();
let (tag, _) = parse_snbt(&source).unwrap();
let target = if kind.contains("boat") {
game.load_boat(tag, Some(uuid), kind);
let b = game.boats.last().unwrap();
assert_eq!(
b.items
.first()
.and_then(Option::as_ref)
.map_or(0, |s| s.count),
if *expected_items == Some(1) { 3 } else { 0 },
"{name}: actual boat inventory"
);
Target::Boat(b.id)
} else {
game.load_cart(tag, Some(uuid), kind);
let c = game.carts.last().unwrap();
assert_eq!(
c.items
.first()
.and_then(Option::as_ref)
.map_or(0, |s| s.count),
if *expected_items == Some(1) { 3 } else { 0 },
"{name}: actual cart inventory"
);
Target::Cart(c.id)
};
let id = match target {
Target::Boat(id) | Target::Cart(id) => id,
_ => unreachable!(),
};
let saved = game.entity_nbt(target).unwrap();
assert_eq!(saved.get("UUID"), Some(&Tag::IntArray(uuid.to_vec())));
assert_eq!(
saved.get("Tags"),
Some(&Tag::List(vec![Tag::String("h052".into())]))
);
let actual_items = match saved.get("Items") {
Some(Tag::List(items)) => Some(items.len()),
None => None,
other => panic!("{name}: {other:?}"),
};
if actual_items != *expected_items {
differences.push(format!(
"{name}: Items {actual_items:?} != {expected_items:?}"
));
}
let table = pending.then(|| Tag::String((*table_text).into()));
let seed = pending.then_some(Tag::Long(73));
for (key, expected) in [
("LootTable", table.as_ref()),
("LootTableSeed", seed.as_ref()),
] {
if saved.get(key) != expected {
differences.push(format!(
"{name}: {key} {:?} != {expected:?}",
saved.get(key)
));
}
}
if *expected_items == Some(1) {
// The model, not a stale loaded Items list, owns the inventory.
let slots = match target {
Target::Boat(_) => &mut game.boats.last_mut().unwrap().items,
Target::Cart(_) => &mut game.carts.last_mut().unwrap().items,
_ => unreachable!(),
};
slots[0].as_mut().unwrap().count = 4;
}
// A normal model can save without any raw input. Boat pending tables
// already live in the retained source; no replacement pending state is invented.
if !pending {
game.persist.raw.remove(&id);
let saved = game.entity_nbt(target).unwrap();
let expected = if *expected_items == Some(1) { 4 } else { 0 };
let count = match saved.get("Items") {
Some(Tag::List(items)) => items.first().and_then(|t| t.get("count")).cloned(),
_ => None,
};
assert_eq!(
count,
(expected != 0).then_some(Tag::Int(expected)),
"{name}: no-raw model count"
);
assert!(saved.get("LootTable").is_none() && saved.get("LootTableSeed").is_none());
assert_eq!(saved.get("UUID"), Some(&Tag::IntArray(uuid.to_vec())));
}
}
// Retained loader diagnostic, not a writer parity assertion: an input
// containing both a table and Items currently keeps modeled contents.
for kind in ["minecraft:oak_chest_boat", "minecraft:chest_minecart"] {
let source = r#"{Pos:[4.5d,200d,4.5d],LootTable:"minecraft:empty",LootTableSeed:73L,Items:[{Slot:0b,id:"minecraft:diamond",count:3}]}"#;
let (tag, _) = parse_snbt(source).unwrap();
let count = if kind.contains("boat") {
game.load_boat(tag, Some([52, 1, 0, 1]), kind);
game.boats.last().unwrap().items[0]
.as_ref()
.map_or(0, |s| s.count)
} else {
game.load_cart(tag, Some([52, 1, 0, 2]), kind);
game.carts.last().unwrap().items[0]
.as_ref()
.map_or(0, |s| s.count)
};
eprintln!(
"separate loader diagnostic {kind}: supplied table+Items retains modeled count {count}"
);
}
assert_eq!(game.world.game_time, clock);
assert!(
differences.is_empty(),
"class-owned save differences:\n{}",
differences.join("\n")
);
}
}
@@ -12,6 +12,58 @@ use horizon_core::{position::ChunkPos, random::RandomSource};
/// How long `/forceload add` waits for its chunks, as the game tests' loads do.
const FORCED_CHUNK_WAIT: std::time::Duration = std::time::Duration::from_secs(120);
/// `LocationCheck.test` / `BlockPredicate`: the measured loaded block-state subset.
fn loot_location_block(
condition: &serde_json::Value,
origin: [i32; 3],
chunks: &horizon_client::chunk_map::ChunkMap,
registry: &horizon_world::registry::BlockRegistry,
items: &horizon_client::items::Items,
) -> Option<bool> {
let condition = condition.as_object()?;
if condition.keys().any(|k| {
!matches!(
k.as_str(),
"type" | "predicate" | "offsetX" | "offsetY" | "offsetZ"
)
}) {
return None;
}
let predicate = condition.get("predicate")?.as_object()?;
if predicate.len() != 1 || !predicate.contains_key("block") {
return None;
}
let block = predicate.get("block")?.as_object()?;
if block
.keys()
.any(|k| !matches!(k.as_str(), "blocks" | "state"))
|| block.get("blocks")?.as_str().is_none()
{
return None;
}
if let Some(state) = block.get("state")
&& state.as_object()?.values().any(|v| !v.is_string())
{
return None;
}
let mut at = origin;
for (axis, key) in ["offsetX", "offsetY", "offsetZ"].iter().enumerate() {
let offset = match condition.get(*key) {
None => 0,
Some(value) => i32::try_from(value.as_i64()?).ok()?,
};
// floor(integer block origin + .5 + integer offset), also for negatives.
at[axis] = origin[axis].checked_add(offset)?;
}
// Unloaded positions are unsupported: never load or invent a block here.
// `LocationPredicate.matches`: a block check fails where `Level.isLoaded` is false.
let Some(chunk) = chunks.get(&(at[0] >> 4, at[2] >> 4)) else {
return Some(false);
};
let state = registry.state(chunk.block(at[0] & 15, at[1], at[2] & 15))?;
Some(items.matches_block_state(&predicate["block"], &state.name, &state.properties))
}
fn num(n: impl std::fmt::Display) -> Text {
Text::literal(n.to_string())
}
@@ -235,7 +287,7 @@ impl Game<'_> {
continue;
};
let current = chunk.biome(x, y, z);
let current_name = &world_biomes!(self).list[usize::from(current)].name;
let current_name = world_biomes!(self).name(current);
if let Some(f) = &filter
&& !self.biome_matches(f, current_name)
{
@@ -615,11 +667,25 @@ impl Game<'_> {
&[&self.target_name(t).plain()],
));
}
let by_player = matches!(
self.command_source.map(|s| s.entity),
None | Some(Some(Target::Player))
);
let held = self.slot_get(&super::items::Slot::Inv(self.selected));
// LootCommand.dropKillLoot: the source's entity is the killer.
// A connected player is the last damage player, and its own
// stack counts; it is in place only to read that stack.
let guest = match self.command_source.map(|s| s.entity) {
Some(Some(Target::Guest(conn))) => self.for_player(Target::Guest(conn), |g| {
g.slot_get(&super::items::Slot::Inv(g.selected))
}),
_ => None,
};
let (by_player, held) = match guest {
Some(held) => (true, held),
None => (
matches!(
self.command_source.map(|s| s.entity),
None | Some(Some(Target::Player))
),
self.slot_get(&super::items::Slot::Inv(self.selected)),
),
};
// LootCommand.dropKillLoot's THIS_ENTITY is the selected live mob.
let target = match t {
Target::Mob(id) => self
@@ -712,22 +778,30 @@ impl Game<'_> {
} else {
false
};
let chunks = &self.world.chunks;
let registry = self.pack.assets.registry;
let items = world_items!(self);
let location = |condition: &serde_json::Value| {
loot_location_block(condition, p, chunks, registry, items)
};
let mut drops = match world_items!(self).loot_sequence(&table) {
Some(seq) => {
let random = self.rand_sequences.get(&seq, self.level.seed);
world_items!(self).block_drops_for_command(
items.block_drops_with_location(
self.pack.assets.registry,
state,
tool.as_ref(),
&location,
random,
executor_present,
keep_empty,
)
}
None => world_items!(self).block_drops_for_command(
None => items.block_drops_with_location(
self.pack.assets.registry,
state,
tool.as_ref(),
&location,
&mut self.world.random,
executor_present,
keep_empty,
@@ -753,13 +827,12 @@ impl Game<'_> {
for &t in &self.entities(ctx, "players", true)? {
// LootCommand.playerGive gives to every target player: a
// guest gets it with their state in place, as with /give.
let given = self.for_player(t, |g| {
if let Some(rest) = g.give(d.clone()) {
let p = g.source_position();
g.spawn_item(rest, [p[0], p[1] + 1.32, p[2]], [0.0; 3], 0);
}
});
if given.is_some() {
// `Inventory.add` keeps what fits and drops the rest. It is
// true only when all of it went in (its last pass, which
// adds nothing, is what it compares), or always for a
// player with infinite materials (creative).
let added = self.for_player(t, |g| g.give(d.clone()).is_none() || g.creative);
if added == Some(true) {
used.push(d.clone());
}
}
@@ -1474,3 +1547,237 @@ mod loot_kill_target_tests {
);
}
}
#[cfg(test)]
mod loot_mine_location_tests {
use super::*;
use crate::game::{Audio, Options, Progress, Settings, load_resources};
use horizon_client::{chunk::Chunk, containers::BlockEntity};
use std::sync::Arc;
const TABLE: &str = r###"{"type":"minecraft:block","pools":[{"rolls":1,"entries":[{"type":"minecraft:item","name":"minecraft:emerald","modifier":{"type":"minecraft:set_count","count":2}}]},{"rolls":1,"condition":{"type":"minecraft:location_check","offsetX":1,"predicate":{"block":{"blocks":"minecraft:oak_log","state":{"axis":"x"}}}},"entries":[{"type":"minecraft:item","name":"minecraft:diamond"}]}]}"###;
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES and scratch homes/cwd"]
fn loot_mine_location_reads_actual_offset_block_state() {
let root = std::path::PathBuf::from(
std::env::var_os("HORIZON_TEST_RESOURCES").expect("prepared resources required"),
);
let settings = Settings::default();
let loaded = load_resources(
root.clone(),
None,
&root,
&settings,
std::time::Instant::now(),
false,
true,
&Progress::new(),
&mut |_, _| unreachable!(),
)
.unwrap();
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
options.resources = root.clone();
options.game_dir = Some(
std::path::PathBuf::from(std::env::var_os("HORIZON_HOME").unwrap()).join("h079-game"),
);
options.execution_role = crate::game::execution_role::ExecutionRole::IntegratedServer;
let mut game = Game::new(
&loaded.resources,
loaded.pack,
loaded.gui.unwrap(),
options,
settings,
Audio::silent(&root),
None,
)
.unwrap();
game.build_registries();
game.set_allow_commands(true);
game.world
.chunks
.insert((0, 0), Arc::new(Chunk::empty(0, 0)));
game.block_ticks
.add_empty_chunk(ChunkPos::new(0, 0))
.unwrap();
game.world
.chunks
.insert((-1, -1), Arc::new(Chunk::empty(-1, -1)));
game.world
.chunks
.insert((-1, 0), Arc::new(Chunk::empty(-1, 0)));
game.world
.chunks
.insert((0, -1), Arc::new(Chunk::empty(0, -1)));
for (x, z) in [(-1, -1), (-1, 0), (0, -1)] {
game.block_ticks
.add_empty_chunk(ChunkPos::new(x, z))
.unwrap();
}
game.world_root =
std::path::PathBuf::from(std::env::var_os("HORIZON_HOME").unwrap()).join("h079-world");
let pack = game.world_root.join("datapacks/h079");
std::fs::create_dir_all(pack.join("data/minecraft/loot_table/blocks")).unwrap();
std::fs::create_dir_all(pack.join("data/h079/function")).unwrap();
std::fs::write(
pack.join("pack.mcmeta"),
r#"{"pack":{"description":"H079 fixture"}}"#,
)
.unwrap();
std::fs::write(
pack.join("data/minecraft/loot_table/blocks/stone.json"),
TABLE,
)
.unwrap();
std::fs::write(
pack.join("data/h079/function/admit.mcfunction"),
"scoreboard players set pack h079 42\n",
)
.unwrap();
std::fs::write(
game.world_root.join("datapacks.json"),
r#"{"enabled":["vanilla","file/h079"],"disabled":[]}"#,
)
.unwrap();
game.world_tables = game.build_world_tables(false, &[]).unwrap().0;
assert!(game.world_tables.is_some());
game.ensure_datapacks();
assert!(game.run_command("scoreboard objectives add h079 dummy"));
game.run_command("function h079:admit");
assert!(
game.run_command("execute if score pack h079 matches 42"),
"real pack function admission"
);
assert!(game.run_command("forceload add -16 -16 15 15"));
assert!(game.run_command("fill -16 199 -16 15 199 15 stone"));
let clock = game.world.game_time;
let mut differences = Vec::new();
for (label, x, z, counterpart, expected) in [
("correct", 4, 4, Some("oak_log[axis=x]"), 1),
("wrong-state", 8, 4, Some("oak_log[axis=z]"), 0),
("absent", 12, 4, None, 0),
("negative-center", -4, -4, Some("oak_log[axis=x]"), 1),
] {
assert!(game.run_command(&format!("setblock {x} 200 {z} stone strict")));
if let Some(other) = counterpart {
assert!(game.run_command(&format!("setblock {} 200 {z} {other} strict", x + 1)));
}
assert!(game.run_command(&format!(
"execute if block {} 200 {z} {}",
x + 1,
counterpart.unwrap_or("air")
)));
let before = (game.world.block(x, 200, z), game.world.block(x + 1, 200, z));
assert!(game.run_command(&format!("setblock {x} 200 10 chest strict")));
let Some(BlockEntity::Chest(initial)) = game.world.block_entities.get(&(x, 200, 10))
else {
panic!("real output chest")
};
assert!(initial.iter().all(Option::is_none));
let prefix = if x < 0 {
"execute positioned -8.25 200.125 -8.75 run "
} else {
""
};
assert!(game.run_command(&format!(
"{prefix}loot insert {x} 200 10 mine {x} 200 {z} stick"
)));
let Some(BlockEntity::Chest(output)) = game.world.block_entities.get(&(x, 200, 10))
else {
panic!("actual output")
};
assert!(
output
.iter()
.flatten()
.any(|s| s.item.as_ref() == "minecraft:emerald" && s.count == 2),
"pack override and unconditional roll are real"
);
let diamonds: u32 = output
.iter()
.flatten()
.filter(|s| s.item.as_ref() == "minecraft:diamond")
.map(|s| s.count)
.sum();
if diamonds != expected {
differences.push((label, diamonds, expected));
}
assert_eq!(
(game.world.block(x, 200, z), game.world.block(x + 1, 200, z)),
before
);
}
assert_eq!(game.world.game_time, clock);
assert!(
differences.is_empty(),
"actual offset block predicate differences: {differences:?}"
);
}
}
#[cfg(test)]
mod loot_location_shape_tests {
use super::*;
use horizon_client::{chunk::Chunk, chunk_map::ChunkMap};
use horizon_world::registry::BlockRegistry;
use serde_json::json;
use std::sync::Arc;
#[test]
fn location_shape_uses_loaded_facts_and_rejects_whole_unsupported_conjunctions() {
let registry = BlockRegistry::from_facts_json(
&br#"{"defaults":{"minecraft:air":0,"minecraft:oak_log":1},"states":[
["air",true,false,"","minecraft:air"],
["log",false,false,"",{"id":"minecraft:oak_log","properties":{"axis":"x"}}]
]}"#[..],
)
.unwrap();
let mut positive = Chunk::empty(0, 0);
positive.set(5, 200, 4, 1);
let mut negative = Chunk::empty(-1, -1);
negative.set(13, 200, 12, 1);
let mut chunks = ChunkMap::default();
chunks.insert((0, 0), Arc::new(positive));
chunks.insert((-1, -1), Arc::new(negative));
let items = horizon_client::items::Items::for_tests(Default::default());
let valid = json!({"type":"minecraft:location_check","offsetX":1,"predicate":{"block":{"blocks":"minecraft:oak_log","state":{"axis":"x"}}}});
assert_eq!(
loot_location_block(&valid, [4, 200, 4], &chunks, &registry, &items),
Some(true)
);
assert_eq!(
loot_location_block(&valid, [-4, 200, -4], &chunks, &registry, &items),
Some(true)
);
assert_eq!(
loot_location_block(&valid, [8, 200, 4], &chunks, &registry, &items),
Some(false)
);
for (key, value) in [
("biomes", json!("minecraft:plains")),
("light", json!({"light":15})),
("dimension", json!("minecraft:overworld")),
] {
let mut unsupported = valid.clone();
unsupported["predicate"][key] = value;
assert_eq!(
loot_location_block(&unsupported, [4, 200, 4], &chunks, &registry, &items),
None
);
}
for value in [json!(1.5), json!(2147483648_i64), json!("1")] {
let mut unsupported = valid.clone();
unsupported["offsetX"] = value;
assert_eq!(
loot_location_block(&unsupported, [4, 200, 4], &chunks, &registry, &items),
None
);
}
let count = chunks.len();
assert_eq!(
loot_location_block(&valid, [32, 200, 32], &chunks, &registry, &items),
Some(false),
"an unloaded counterpart fails, as Level.isLoaded does"
);
assert_eq!(chunks.len(), count, "no chunk is loaded as a side effect");
}
}
@@ -274,7 +274,7 @@ impl Game<'_> {
&[Text::literal(infos.len().to_string())],
));
let grid = Grid::new(self.test_around(), per_row);
self.track_and_start_runner(infos, false, Some(grid), true);
self.track_and_start_command_runner(infos, grid);
Ok(1)
}
@@ -310,6 +310,7 @@ impl Game<'_> {
Target::Crystal(id) => {
self.command_damage_crystal(id, ty, Self::damage_kind(ty), cause.or(by))
}
Target::Stand(id) => self.command_damage_stand(id, ty, cause.or(by)),
_ => false,
}
}
@@ -1299,7 +1300,12 @@ impl Game<'_> {
)))
}
"attribute.modifier.add" => {
if finalize::has_modifier(m, attr, &modifier_id) {
// AttributeInstance.hasModifier: the modeled transient
// minecraft:baby counts too.
if finalize::has_modifier(m, attr, &modifier_id)
|| (modifier_id == "minecraft:baby"
&& finalize::transient(m, attr).is_some())
{
return Err(text_error(
"commands.attribute.failed.modifier_already_present",
&[Text::literal(modifier_id.clone()), description, name],
@@ -1320,10 +1326,20 @@ impl Game<'_> {
)))
}
"attribute.modifier.remove" => {
if !finalize::has_modifier(m, attr, &modifier_id) {
// AttributeInstance.removeModifier: a permanent one, else
// the modeled transient minecraft:baby (until setBaby).
if finalize::has_modifier(m, attr, &modifier_id) {
finalize::remove_modifier(m, attr, &modifier_id);
} else if modifier_id == "minecraft:baby"
&& finalize::transient(m, attr).is_some()
{
m.extra
.attributes
.get_or_insert_with(Box::default)
.baby_speed_removed = true;
} else {
return Err(no_modifier);
}
finalize::remove_modifier(m, attr, &modifier_id);
Ok(Some((
1,
Text::translate(
@@ -1381,6 +1381,7 @@ impl Game<'_> {
Screen::DebugOptions => "debug-options",
Screen::Mod => "mod",
Screen::HudEditor => "hud-editor",
Screen::Studio => "studio",
}
}
@@ -129,6 +129,9 @@ fn screen_ids_are_distinct_and_stable() {
Screen::DialogWarning,
Screen::Win,
Screen::DebugOptions,
Screen::Mod,
Screen::HudEditor,
Screen::Studio,
];
let ids: Vec<&'static str> = all.iter().map(|screen| screen.id()).collect();
let unique: BTreeSet<&'static str> = ids.iter().copied().collect();
@@ -144,6 +147,30 @@ fn screen_ids_are_distinct_and_stable() {
assert_eq!(Screen::Playing.id(), "playing");
assert_eq!(Screen::Inventory.id(), "inventory");
assert_eq!(Screen::Win.id(), "win");
assert_eq!(Screen::Studio.id(), "studio");
assert_ne!(Screen::Studio.id(), Screen::Chat.id());
}
/// `Screen::Studio` is the Studio script slot: it never draws the chat screen
/// (`Screen::Studio => {}` in `game.rs`'s draw match, unlike
/// `Screen::Chat => draw_chat_screen`), it never blurs the background, and
/// while it is up gameplay does not own the keyboard (WASD cannot walk
/// through it, and movement keys are cleared on open).
#[test]
fn studio_screen_stays_off_the_chat_slot() {
assert!(Screen::Studio != Screen::Chat);
assert_eq!(Screen::Studio.id(), "studio");
assert!(!Screen::Studio.blurs_background(false));
assert!(!Screen::Studio.blurs_background(true));
assert!(!gameplay_has_keyboard(Screen::Studio, false, false));
// The pure gate behind `Game::open_studio_screen`, as
// `native_mod_screen_open`'s `mod_screen_opens` is gated.
use super::studio::studio_screen_opens;
assert_eq!(studio_screen_opens(Screen::Playing, false), None);
assert_eq!(studio_screen_opens(Screen::Playing, true), Some(false));
assert_eq!(studio_screen_opens(Screen::Chat, false), Some(false));
assert_eq!(studio_screen_opens(Screen::Studio, false), Some(true));
assert_eq!(studio_screen_opens(Screen::Studio, true), Some(true));
}
/// The `modifiers` a native mod's input hooks hand over are GLFW's bits
+27 -15
View File
@@ -108,6 +108,8 @@ pub(super) struct Creatures {
/// The snapshot's buffer between ticks, empty: ~400 bytes a mob, so
/// thousands of mobs would allocate and free megabytes every tick.
seen_buf: Vec<Seen>,
/// Per-tick scratch retained between creature ticks.
event_buf: Vec<MobEvent>,
/// The targets the mobs had before their ticks, kept between ticks
/// (empty) as `seen_buf` is: `native_living_change_target` hears a change.
target_buf: Vec<Option<Who>>,
@@ -215,11 +217,13 @@ impl ProjectileWorld for ProjWorld<'_, '_> {
)
.map(|c| (c.drag, c.surface))
}
fn solid(&self, p: [i32; 3]) -> bool {
self.w
.facts
.get(self.w.world.block(p[0], p[1], p[2]))
.full_collision
fn can_stand_on(&self, p: [i32; 3], entity_y: f64) -> bool {
horizon_client::collisions::projectile_can_stand_on(
self.w.world,
self.w.collision,
BlockPos::new(p[0], p[1], p[2]),
entity_y,
)
}
}
@@ -1166,7 +1170,8 @@ impl Game<'_> {
let level_id = self.dim.id();
let mut snapshot = std::mem::take(&mut self.creatures.seen_buf);
snapshot.extend(self.mobs.iter().map(|m| seen(m, uuid)));
let mut events = Vec::new();
let mut events = std::mem::take(&mut self.creatures.event_buf);
events.clear();
let mut mobs = std::mem::take(&mut self.mobs);
// Each mob's target as the mods last heard it, by its place in `mobs`
// (`native_living_change_target`).
@@ -1203,11 +1208,7 @@ impl Game<'_> {
};
let mut guest_events = Vec::new();
// The block writes since the last tick: mobs re-path around them.
let path_writes = self
.world
.path_writes
.get_or_insert_with(Vec::new)
.split_off(0);
let path_writes = std::mem::take(self.world.path_writes.get_or_insert_with(Vec::new));
// The mobs whose bodies go this tick: (id, feet, width, height, split).
// 🤖 Agent: the last field is the grown slime, magma cube or sulfur
// cube the body leaves behind (`AbstractCubeMob.remove`), captured as
@@ -1575,7 +1576,9 @@ impl Game<'_> {
let bounds = m.bounds();
self.effects_on_death(&names, bounds, false, Some(m.id), Some(&mut m.random));
}
self.handle_mob_events(events)?;
let event_result = self.handle_mob_events_reusable(&mut events);
self.creatures.event_buf = events;
event_result?;
self.lan_guest_mob_events(guest_events);
laps.lap("creature_events");
self.ordered_before(super::ordered_tick::Batch::Projectiles)?;
@@ -1653,7 +1656,7 @@ impl Game<'_> {
cancel
}
/// LivingEntity.die / dropAllDeathLoot.
fn mob_died(&mut self, m: &Mob) {
pub(super) fn mob_died(&mut self, m: &Mob) {
if self.mob_death_canceled(m) {
return;
}
@@ -2312,8 +2315,12 @@ impl Game<'_> {
self.projectile_event("minecraft:projectile_shoot", owner, at);
}
}
pub(super) fn handle_mob_events(&mut self, events: Vec<MobEvent>) -> Result<()> {
for e in events {
pub(super) fn handle_mob_events(&mut self, mut events: Vec<MobEvent>) -> Result<()> {
self.handle_mob_events_reusable(&mut events)
}
pub(super) fn handle_mob_events_reusable(&mut self, events: &mut Vec<MobEvent>) -> Result<()> {
for e in events.drain(..) {
match e {
MobEvent::BubbleColumnParticles { pos } => {
self.bubble_column_particles(BlockPos::new(pos[0], pos[1], pos[2]))
@@ -2608,6 +2615,11 @@ impl Game<'_> {
}
}
}
MobEvent::LidPush {
shulker,
area,
delta,
} => self.lid_pushes_armor_stands(shulker, area, delta),
MobEvent::Wololo { mob } => {
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == mob) {
// DyeColor.RED.
@@ -537,7 +537,8 @@ fn debug_options_interrupts(screen: Screen, playing_overlay: bool) -> bool {
| Screen::Dead
| Screen::Win
| Screen::DebugOptions
| Screen::HudEditor => false,
| Screen::HudEditor
| Screen::Studio => false,
}
}
@@ -1166,6 +1166,7 @@ impl Game<'_> {
Vec::new()
},
owner_profile: None,
singleplayer_profile: None,
};
let config = HubConfig {
allow_flight: props.allow_flight,
+67 -5
View File
@@ -433,11 +433,7 @@ impl Game<'_> {
let (w, h) = (batch.width, batch.height);
let cursor = self.cursor;
let atlas = &self.gui.atlas;
if self.player.is_none() {
// In place of the connecting screen, on its background.
batch.fill(atlas, 0.0, 0.0, w, h, rgba(20, 20, 20, 255));
}
ui::menu_background(batch, atlas, 0.0, 0.0, w, h, self.player.is_some());
draw_background(batch, atlas, self.player.is_some());
if self.dialogs.warning {
let (title, message, [disconnect, back]) = warning_texts();
// DialogScreen.WarningScreen, a ConfirmScreen: its title and its
@@ -1197,3 +1193,69 @@ impl Game<'_> {
serde_json::json!({ "open": open, "screen": screen })
}
}
fn draw_background(batch: &mut HudBatch, atlas: &horizon_client::hud::HudAtlas, in_world: bool) {
let (w, h) = (batch.width, batch.height);
// Screen.extractBackground draws the panorama and blur before this menu overlay.
// The frame renderer already supplies them while a server is configuring.
ui::menu_background(batch, atlas, 0.0, 0.0, w, h, in_world);
}
#[cfg(test)]
mod background_tests {
use super::*;
use horizon_client::hud::{HudAtlas, Rect};
#[test]
fn a_configuration_dialog_keeps_the_panorama_visible() {
let mut atlas = HudAtlas::build(std::path::Path::new(
"/nonexistent-horizon-dialog-background-test",
))
.unwrap();
atlas.sprites.remove("gui/menu_background");
let mut batch = HudBatch::new([640, 480]);
draw_background(&mut batch, &atlas, false);
assert_eq!(
batch.vertices.len(),
6,
"only the menu overlay covers the panorama"
);
assert!(batch.vertices.iter().all(|v| v.color >> 24 == 160));
}
#[test]
fn dialogs_keep_the_menu_and_in_world_pack_backgrounds() {
let mut atlas = HudAtlas::build(std::path::Path::new(
"/nonexistent-horizon-dialog-background-test",
))
.unwrap();
for (name, u) in [
("gui/menu_background", 0.1),
("gui/inworld_menu_background", 0.6),
] {
atlas.sprites.insert(
name.into(),
Rect {
u0: u,
v0: 0.2,
u1: u + 0.1,
v1: 0.3,
w: 32,
h: 32,
},
);
}
for (in_world, u) in [(false, 0.1), (true, 0.6)] {
let mut batch = HudBatch::new([640, 480]);
draw_background(&mut batch, &atlas, in_world);
assert!(!batch.vertices.is_empty());
assert!(
batch
.vertices
.iter()
.all(|v| v.uv[0] >= u && v.uv[0] <= u + 0.1)
);
assert!(batch.vertices.iter().all(|v| v.color == u32::MAX));
}
}
}
+11
View File
@@ -297,6 +297,17 @@ impl EyeOfEnder {
}
impl Game<'_> {
/// The environment attributes the frame draws: the packs' Polytone layers over the
/// current dimension's, when a pack has some. Gameplay keeps `dim_environment`.
pub(super) fn view_environment(&self) -> &Environment {
match (&self.pack.environments, self.dim) {
(Some(e), Dim::Overworld) => &e[0],
(Some(e), Dim::Nether) => &e[1],
(Some(e), Dim::End) => &e[2],
(None, _) => self.dim_environment(),
}
}
/// The environment attributes of the current dimension.
pub(super) fn dim_environment(&self) -> &Environment {
match self.dim {
+38 -10
View File
@@ -194,14 +194,15 @@ pub(super) trait Entity: EntityRules {
return (0.0, 0.0);
}
let (bounds, at, eye) = (self.bounds(), self.position(), self.fluid_eye_y());
horizon_client::entities::update_entity_fluids(
let (water, lava, _) = horizon_client::entities::update_entity_fluids(
bounds,
at,
eye,
self.velocity_mut(),
world,
fluids,
)
);
(water, lava)
}
/// `BubbleColumnBlock.entityInside` through `applyEffectsFromBlocks`: a
/// column lifts or drags every kind alike.
@@ -525,6 +526,7 @@ mod render_bounds_tests {
invisible: false,
check_interval: 0,
custom_name: None,
random: horizon_core::random::LegacyRandom::new(0),
};
let bounds = frame.render_bounds(1.0);
assert!(bounds.min_position().x <= -0.71875);
@@ -966,7 +968,33 @@ macro_rules! load_entity_of_any_kind {
};
}
/// The type ids the minecart kind loads.
fn loads_as_cart(kind: &str) -> bool {
super::minecarts::CartKind::from_id(kind).is_some()
}
/// The type id the primed TNT kind loads.
fn loads_as_tnt(kind: &str) -> bool {
kind == "minecraft:tnt"
}
impl Game<'_> {
/// The type ids the mob kind loads.
fn loads_as_mob(&self, kind: &str) -> bool {
self.res.mob_types().contains_key(kind)
}
/// The type ids the boat kind loads.
fn loads_as_boat(&self, kind: &str) -> bool {
super::boats::any_boat_wood(kind)
.is_some_and(|(wood, chest)| self.boat_models.has(wood, chest))
}
/// Whether `load_listed` loads `kind` as a vehicle that takes its mob
/// riders (`take_mob_passengers`): a mob, boat, minecart or primed TNT.
pub(super) fn listed_kind_takes_riders(&self, kind: &str) -> bool {
self.loads_as_mob(kind)
|| self.loads_as_boat(kind)
|| loads_as_cart(kind)
|| loads_as_tnt(kind)
}
/// Loads a saved entity as the first kind in `entity_lists!` that claims
/// its type id: `Err` hands the compound back when none does.
pub(super) fn load_listed(
@@ -1053,7 +1081,7 @@ impl Entity for Mob {
_player_uuid: Option<[i32; 4]>,
tag: Tag,
) -> Result<(), Tag> {
if !(game.res.mob_types().contains_key(kind)) {
if !game.loads_as_mob(kind) {
return Err(tag);
}
game.load_mob(tag, None);
@@ -1293,9 +1321,7 @@ impl Entity for Boat {
_player_uuid: Option<[i32; 4]>,
tag: Tag,
) -> Result<(), Tag> {
if !(super::boats::any_boat_wood(kind)
.is_some_and(|(wood, chest)| game.boat_models.has(wood, chest)))
{
if !game.loads_as_boat(kind) {
return Err(tag);
}
game.load_boat(tag, uuid, kind);
@@ -1364,7 +1390,7 @@ impl Entity for Minecart {
_player_uuid: Option<[i32; 4]>,
tag: Tag,
) -> Result<(), Tag> {
if !(super::minecarts::CartKind::from_id(kind).is_some()) {
if !loads_as_cart(kind) {
return Err(tag);
}
game.load_cart(tag, uuid, kind);
@@ -1552,16 +1578,17 @@ impl Entity for PrimedTnt {
_player_uuid: Option<[i32; 4]>,
tag: Tag,
) -> Result<(), Tag> {
if !(kind == "minecraft:tnt") {
if !loads_as_tnt(kind) {
return Err(tag);
}
game.load_tnt(tag, uuid);
Ok(())
}
stored_motion!();
/// `PrimedTnt` eye height: `0.98 * 0.85`.
/// `EntityTypes.TNT`'s explicit `eyeHeight(0.15F)`. Nothing vanilla reads
/// for TNT depends on it (only `isEyeInFluid` does), but it is the type's.
fn fluid_eye_y(&self) -> f64 {
self.position.y + f64::from(0.98f32 * 0.85f32)
self.position.y + f64::from(0.15_f32)
}
/// `PrimedTnt` is 0.98 across.
fn portal_size(&self) -> Option<(f32, f32)> {
@@ -2142,6 +2169,7 @@ mod set_rotation_tests {
ticks: 0,
health: 5,
rotation: [0.0, 0.0],
following: None,
};
orb.set_rotation([90.0, 10.0]);
assert_eq!(orb.rotation(), [90.0, 10.0]);
+7 -1
View File
@@ -135,7 +135,13 @@ impl Game<'_> {
/// (the launcher's session file is gone after the first read).
pub(super) fn extras_retry(&mut self) -> horizon_client::Result<()> {
if let Some(retry) = self.title.as_mut().and_then(|t| t.due_retry()) {
self.connect_server_with_options(&retry.address, &retry.name, None, retry.lan)?;
self.connect_server_with_options(
&retry.address,
&retry.name,
None,
retry.lan,
self.realm_session,
)?;
}
Ok(())
}
+119 -14
View File
@@ -24,10 +24,19 @@ use std::{collections::HashMap, sync::Arc};
use super::lan_actions::GuestPlay;
#[cfg(test)]
#[path = "fishing_medium_tests.rs"]
mod medium_tests;
/// The table a catch rolls where no mod's fishing medium names one.
const FISHING_TABLE: &str = "minecraft:gameplay/fishing";
/// `FishingHook.retrieve`: the table's named random sequence, or the level
/// random when a datapack table does not name one.
#[allow(clippy::too_many_arguments)]
fn fishing_loot(
loot: &horizon_world::loot::LootData,
table: &str,
sequences: &mut horizon_client::random_sequences::RandomSequences,
level_random: &mut LegacyRandom,
world_seed: i64,
@@ -35,7 +44,6 @@ fn fishing_loot(
biome: Option<&str>,
open_water: bool,
) -> Vec<horizon_world::loot::LootStack> {
let table = "minecraft:gameplay/fishing";
match loot.random_sequence(table) {
Some(sequence) => loot.roll(
table,
@@ -79,10 +87,39 @@ pub(super) struct Hook {
open_water: bool,
luck: i32,
lure: i32,
/// The fishing medium it last floated in outside water (`native_fishing_medium`): the
/// table its catch rolls and the wait it draws. None in water.
medium: Option<HookMedium>,
/// The position a server's update put it heading for.
lerp: HookLerp,
pub(super) random: LegacyRandom,
}
/// What a hook keeps of the mod's fishing medium it floats in.
#[derive(Clone, Debug, Default, PartialEq)]
struct HookMedium {
loot_table: Option<String>,
lure_ticks: Option<(i32, i32)>,
}
/// The medium a mod answered, when the hook is in it: in the block of its
/// surface or below, as a hook is in water anywhere in a water block.
fn inside_medium(
position: Vec3,
medium: Option<horizon_client::mod_api::NativeFishingMedium>,
) -> Option<horizon_client::mod_api::NativeFishingMedium> {
medium.filter(|m| m.surface.is_finite() && position.y < m.surface.floor() + 1.0)
}
/// `catchingFish`'s wait for a fish: vanilla's 100 to 600 ticks, or a medium's
/// own range, less the rod's Lure.
fn lure_wait(random: &mut LegacyRandom, range: Option<(i32, i32)>, lure: i32) -> i32 {
match range {
None => 100 + random.next_int_bound(501).unwrap_or(0) - lure,
Some((min, max)) => {
let min = min.max(1);
let max = max.max(min);
min + random.next_int_bound(max - min + 1).unwrap_or(0) - lure
}
}
}
/// A player a hook can catch (`canHitEntity`): their entity id, which
/// guest they are (none: the host) and their box.
pub(super) struct HookTarget {
@@ -369,7 +406,7 @@ impl Game<'_> {
let mut state = (Vec3::new(x, 301.1, z), motion, State::Flying, false);
let mut random = LegacyRandom::new(0);
for tick in 0..5 {
state = self.client_hook_step(state, &mut random);
state = self.client_hook_step(1, None, state, &mut random);
rows.push(serde_json::json!({
"case": case, "tick": tick,
"position": [state.0.x - f64::from(block[0]), state.0.y, state.0.z - f64::from(block[2])],
@@ -476,6 +513,7 @@ impl Game<'_> {
open_water: true,
luck,
lure,
medium: None,
lerp: HookLerp::default(),
random,
});
@@ -568,8 +606,14 @@ impl Game<'_> {
// explicit random source. LootContext uses the table's sequence,
// or the level random if the table has no sequence.
let luck = hook.luck as f32 + owner_luck;
let table = hook
.medium
.as_ref()
.and_then(|m| m.loot_table.as_deref())
.unwrap_or(FISHING_TABLE);
let items = fishing_loot(
&loot,
table,
&mut self.rand_sequences,
&mut self.world.random,
self.level.seed,
@@ -650,11 +694,7 @@ impl Game<'_> {
pub(super) fn biome_name_at(&self, p: Vec3) -> Option<String> {
let (x, y, z) = (p.x.floor() as i32, p.y.floor() as i32, p.z.floor() as i32);
let c = self.world.chunks.get(&(x >> 4, z >> 4))?;
Some(
world_biomes!(self).list[c.biome(x, y, z) as usize]
.name
.clone(),
)
Some(world_biomes!(self).name(c.biome(x, y, z)).to_owned())
}
fn water_height(&self, x: i32, y: i32, z: i32) -> Option<(f32, bool)> {
let f = self.res.facts().get(self.world.block(x, y, z));
@@ -832,7 +872,8 @@ impl Game<'_> {
h.time_until_hooked = 20 + h.random.next_int_bound(61).unwrap_or(0);
}
} else {
h.time_until_lured = 100 + h.random.next_int_bound(501).unwrap_or(0) - h.lure;
let range = h.medium.as_ref().and_then(|m| m.lure_ticks);
h.time_until_lured = lure_wait(&mut h.random, range, h.lure);
}
}
/// `catchingFish`'s check below a fish: the block under it is water.
@@ -909,7 +950,23 @@ impl Game<'_> {
h.position.z.floor() as i32,
];
let liquid = self.water_height(b[0], b[1], b[2]).map_or(0.0, |w| w.0);
let in_water = liquid > 0.0;
// Out of water, a mod may say the bobber floats here all the same.
let medium = if liquid > 0.0 || matches!(h.state, State::Hooked(_)) {
None
} else {
self.hook_medium(h.id, h.position, Some(owner))
};
match &medium {
Some(m) => {
h.medium = Some(HookMedium {
loot_table: m.loot_table.clone(),
lure_ticks: m.lure_ticks,
})
}
None if liquid > 0.0 => h.medium = None,
None => {}
}
let in_water = liquid > 0.0 || medium.is_some();
// The block a flying bobber meets or rests on this tick (and the face), for
// `native_fishing_hook_landed` once the tick is done.
let rested = h.on_ground;
@@ -1068,7 +1125,10 @@ impl Game<'_> {
}
State::Bobbing => {
let v = h.velocity;
let mut force = h.position.y + v.y - f64::from(b[1]) - f64::from(liquid);
let mut force = match &medium {
Some(m) => h.position.y + v.y - m.surface,
None => h.position.y + v.y - f64::from(b[1]) - f64::from(liquid),
};
if force.abs() < 0.01 {
force += force.signum() * 0.1;
}
@@ -1077,8 +1137,10 @@ impl Game<'_> {
if h.nibble <= 0 && h.time_until_hooked <= 0 {
h.open_water = true;
} else {
h.open_water =
h.open_water && h.out_of_water < 10 && self.calculate_open_water(b);
// A medium has no water around it to be open: it counts as open.
h.open_water = h.open_water
&& h.out_of_water < 10
&& (medium.is_some() || self.calculate_open_water(b));
}
if in_water {
h.out_of_water = (h.out_of_water - 1).max(0);
@@ -1368,6 +1430,7 @@ impl Game<'_> {
"lured_in": h.time_until_lured,
"hooked_in": h.time_until_hooked,
"open_water": h.open_water,
"loot_table": h.medium.as_ref().and_then(|m| m.loot_table.clone()),
}))
}
}
@@ -1480,6 +1543,7 @@ impl Hook {
open_water: true,
luck: 0,
lure: 0,
medium: None,
lerp: HookLerp::default(),
random: random_source::entity(),
}
@@ -1616,7 +1680,10 @@ impl Game<'_> {
h.previous = h.position;
h.position = h.lerp.step(h.position);
let before = h.position;
let owner = self.player.as_ref().map(|p| p.position);
(h.position, h.velocity, h.state, h.on_ground) = self.client_hook_step(
h.id,
owner,
(h.position, h.velocity, h.state, h.on_ground),
&mut h.random,
);
@@ -1628,7 +1695,14 @@ impl Game<'_> {
h.previous = h.position;
h.position = h.lerp.step(h.position);
let before = h.position;
let owner = self
.mobs
.iter()
.find(|m| m.id == h.owner)
.map(|m| m.position);
(h.position, h.velocity, h.state, h.on_ground) = self.client_hook_step(
h.id,
owner,
(h.position, h.velocity, h.state, h.on_ground),
&mut h.random,
);
@@ -1637,6 +1711,25 @@ impl Game<'_> {
self.remote_hooks = hooks;
}
/// The fishing medium a mod says the hook at `position` floats in
/// (`native_fishing_medium`), when it is in it. API @API:fishing_medium@.
fn hook_medium(
&mut self,
id: u32,
position: Vec3,
owner: Option<Vec3>,
) -> Option<horizon_client::mod_api::NativeFishingMedium> {
let mut medium = None;
let dimension = self.dim.id();
self.native_fishing_medium(
dimension,
id,
[position.x, position.y, position.z],
owner.map(|o| [o.x, o.y, o.z]),
&mut medium,
);
inside_medium(position, medium)
}
/// `FishingHook.checkCollision` clips blocks on both sides of the connection.
fn hook_block_hit(&self, from: Vec3, to: Vec3) -> Option<Vec3> {
self.hook_block_hit_at(from, to).map(|hit| hit.location)
@@ -1666,6 +1759,8 @@ impl Game<'_> {
/// server's): position, motion, state and whether it rests on the ground.
fn client_hook_step(
&mut self,
id: u32,
owner: Option<Vec3>,
(mut position, mut velocity, state, mut on_ground): (Vec3, Vec3, State, bool),
random: &mut impl RandomSource,
) -> (Vec3, Vec3, State, bool) {
@@ -1675,7 +1770,13 @@ impl Game<'_> {
position.z.floor() as i32,
];
let liquid = self.water_height(b[0], b[1], b[2]).map_or(0.0, |w| w.0);
let in_water = liquid > 0.0;
// The same fishing medium the server's hook floats in, so it is drawn floating too.
let medium = if liquid > 0.0 || matches!(state, State::Hooked(_)) {
None
} else {
self.hook_medium(id, position, owner)
};
let in_water = liquid > 0.0 || medium.is_some();
match state {
State::Flying if in_water => {
velocity = Vec3::new(velocity.x * 0.3, velocity.y * 0.2, velocity.z * 0.3);
@@ -1730,7 +1831,10 @@ impl Game<'_> {
};
}
State::Bobbing => {
let mut force = position.y + velocity.y - f64::from(b[1]) - f64::from(liquid);
let mut force = match &medium {
Some(m) => position.y + velocity.y - m.surface,
None => position.y + velocity.y - f64::from(b[1]) - f64::from(liquid),
};
if force.abs() < 0.01 {
force += force.signum() * 0.1;
}
@@ -1887,6 +1991,7 @@ mod tests {
let mut level_random = LegacyRandom::new(1);
let items = super::fishing_loot(
&loot,
super::FISHING_TABLE,
&mut sequences,
&mut level_random,
seed,
@@ -0,0 +1,150 @@
//! Run alone with private prepared resources: installs process-wide trait mods.
use super::super::*;
use super::{Hook, State};
use crate::mod_api::{self, FishingMediumEvent, Hooks, Host, NativeFishingMedium};
use horizon_client::chunk::Chunk;
use std::sync::Mutex;
/// Fishes the air east of x = 3, four blocks under its owner's feet, rolling the junk table.
#[derive(Default)]
struct Probe {
asked: Mutex<Vec<(String, Option<[f64; 3]>)>>,
}
const JUNK: &str = "minecraft:gameplay/fishing/junk";
impl Hooks for Arc<Probe> {
fn fishing_medium(
&self,
_: &mut dyn Host,
event: &mut FishingMediumEvent<'_>,
) -> Option<NativeFishingMedium> {
self.asked
.lock()
.unwrap()
.push((event.dimension.to_owned(), event.owner));
(event.position[0] > 3.0).then(|| NativeFishingMedium {
surface: event.owner.map_or(60.0, |o| o[1] - 4.0),
loot_table: Some(JUNK.into()),
lure_ticks: Some((5, 10)),
})
}
}
const FISH: [&str; 4] = [
"minecraft:cod",
"minecraft:salmon",
"minecraft:pufferfish",
"minecraft:tropical_fish",
];
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES and an isolated mod registry"]
fn a_bobber_floats_bites_and_rolls_the_table_of_a_mods_medium() {
let root = PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES").unwrap());
let settings = Settings::default();
let loaded = load_resources(
root.clone(),
None,
&root,
&settings,
Instant::now(),
false,
true,
&Progress::new(),
&mut |_, _| unreachable!(),
)
.unwrap();
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
options.resources = root.clone();
options.execution_role = execution_role::ExecutionRole::IntegratedServer;
let probe = Arc::new(Probe::default());
assert!(
mod_api::MODS
.set(Arc::from(vec![Box::new(probe.clone()) as Box<dyn Hooks>]))
.is_ok()
);
let mut game = Game::new(
&loaded.resources,
loaded.pack,
loaded.gui.unwrap(),
options,
settings,
Audio::silent(&root),
None,
)
.unwrap();
game.world = World::new(game.res.facts().air, 1);
game.world
.chunks
.insert((0, 0), Arc::new(Chunk::empty(0, 0)));
game.player = Some(PlayerMotion::new(game.motion, Vec3::new(0.5, 64.0, 0.5)).unwrap());
game.selected = 0;
game.set_mode_id(0);
let rod = ItemStack::new("minecraft:fishing_rod", 1);
game.inventory[0] = Some(rod.clone());
// Over air the mod doesn't claim, the bobber falls as vanilla's does.
game.hook = Some(Hook::remote(1, Vec3::new(1.5, 66.0, 0.5)));
for _ in 0..40 {
game.tick_fishing();
}
let hook = game.hook.as_ref().expect("still within reach");
assert!(hook.position.y < 58.0, "fell to {}", hook.position.y);
assert_eq!(game.hook_status().unwrap()["state"], "Flying");
assert!(game.hook_status().unwrap()["loot_table"].is_null());
{
let asked = probe.asked.lock().unwrap();
assert!(!asked.is_empty());
assert!(
asked.iter().all(
|(dim, owner)| dim == "minecraft:overworld" && *owner == Some([0.5, 64.0, 0.5])
)
);
}
// In the mod's medium it falls to the surface, floats there and gets a bite.
let mut caught = Vec::new();
for cast in 0..6 {
let id = 10 + cast;
game.hook = Some(Hook::remote(id, Vec3::new(5.5, 66.0, 0.5)));
let mut bit = false;
for tick in 0..400 {
game.tick_fishing();
let status = game.hook_status().expect("the bobber stays out");
let y = game.hook.as_ref().unwrap().position.y;
if tick > 60 {
assert_eq!(status["state"], "Bobbing");
assert!((59.0..61.0).contains(&y), "floats at {y} on tick {tick}");
}
if status["nibble"].as_i64().unwrap() > 0 {
assert_eq!(status["loot_table"], JUNK);
bit = true;
break;
}
}
assert!(bit, "no bite in 20 seconds on cast {cast}");
let before = game.dropped.len();
assert!(game.use_fishing_rod(&rod));
assert!(game.hook.is_none());
let items: Vec<String> = game.dropped[before..]
.iter()
.map(|i| i.stack.item.to_string())
.collect();
assert!(!items.is_empty(), "a bite reeled in nothing");
caught.extend(items);
}
// The junk table has no fish, where vanilla's would have landed some by now.
assert!(
caught.iter().all(|item| !FISH.contains(&item.as_str())),
"{caught:?}"
);
// A client draws its own bobber floating in the same medium.
let mut state = (Vec3::new(5.5, 62.0, 0.5), Vec3::ZERO, State::Flying, false);
let mut random = horizon_core::random::LegacyRandom::new(7);
for tick in 0..100 {
state = game.client_hook_step(1, Some(Vec3::new(0.5, 64.0, 0.5)), state, &mut random);
if tick > 60 {
assert!((59.0..61.0).contains(&state.0.y), "client at {}", state.0.y);
}
}
}
+13 -8
View File
@@ -182,6 +182,7 @@ impl Game<'_> {
check_interval: 0,
// `applyComponentsFromItemStack` sets `CUSTOM_NAME`.
custom_name: stack.data().and_then(|d| d.custom_name.clone()),
random: horizon_client::random_source::entity(),
};
// `EntityType.createDefaultStackConfig`: the item's `custom_data`, then its
// `entity_data` merged over the frame (`TypedEntityData.loadInto`), before
@@ -354,7 +355,7 @@ impl Game<'_> {
.to_owned();
let rotation = self.item_frames[i].rotation;
let at = center(&self.item_frames[i]);
let f = if with_frame {
let mut f = if with_frame {
self.item_frames.remove(i)
} else {
self.item_frames[i].clone()
@@ -372,7 +373,10 @@ impl Game<'_> {
// item only gives the item up (ItemFrame.hurtServer).
self.entity_killed_event(at, true);
}
self.drop_frame_items(&f, with_frame, true);
self.drop_frame_items(&mut f, with_frame, true);
if !with_frame {
self.item_frames[i].random = f.random.clone();
}
// BLOCK_CHANGE by the attacker after the drops: ItemFrame.hurtServer
// for the item, ItemFrame.dropItem(level, causedBy) for the frame.
let ctx = self.who_context(Some(horizon_client::mobs::Who::Player), None);
@@ -415,7 +419,7 @@ impl Game<'_> {
self.with_level(|b, level, ticks| b.update_neighbour_for_output_signal(level, ticks, at));
}
/// `ItemFrame.dropItem`: the frame (when breaking) and the held item.
fn drop_frame_items(&mut self, f: &ItemFrame, with_frame: bool, by_player: bool) {
fn drop_frame_items(&mut self, f: &mut ItemFrame, with_frame: bool, by_player: bool) {
// The framed map forgets the frame, except when the drops are off and
// something caused the break (`removeFramedMap` runs only for a null
// `causedBy` then).
@@ -434,7 +438,7 @@ impl Game<'_> {
drops.push(f.item_stack_with_data());
}
if let Some(item) = f.item.clone()
&& self.world.random.next_float() < f.drop_chance
&& f.random.next_float() < f.drop_chance
{
drops.push(item);
}
@@ -481,11 +485,11 @@ impl Game<'_> {
i += 1;
continue;
}
let f = self.item_frames.remove(i);
let mut f = self.item_frames.remove(i);
self.entity_killed_event(center(&f), false);
self.audio
.play(&sound(&f, "break"), Some(center(&f)), 1.0, 1.0);
self.drop_frame_items(&f, true, false);
self.drop_frame_items(&mut f, true, false);
// ItemFrame.dropItem(level, causedBy): BLOCK_CHANGE after the drops.
let ctx = self.who_context(None, None);
self.post_game_event("minecraft:block_change", center(&f), ctx);
@@ -509,14 +513,14 @@ impl Game<'_> {
self.item_frames[i].check_interval += 1;
if self.item_frames[i].check_interval >= 100 {
self.item_frames[i].check_interval = 0;
let f = self.item_frames[i].clone();
let mut f = self.item_frames[i].clone();
if !self.frame_survives(&f) && !self.hanging_break_cancelled(f.kind(), f.pos, false)
{
self.item_frames.remove(i);
self.entity_killed_event(center(&f), false);
self.audio
.play(&sound(&f, "break"), Some(center(&f)), 1.0, 1.0);
self.drop_frame_items(&f, true, false);
self.drop_frame_items(&mut f, true, false);
// ItemFrame.dropItem(level, null): BLOCK_CHANGE after the drops.
let ctx = self.who_context(None, None);
self.post_game_event("minecraft:block_change", center(&f), ctx);
@@ -780,6 +784,7 @@ mod map_limit_tests {
invisible: false,
check_interval: 0,
custom_name: None,
random: horizon_core::random::LegacyRandom::new(0),
}
}
/// A map whose `count` frame markers all count toward the limit.
+257
View File
@@ -2486,4 +2486,261 @@ mod required_tag_tests {
"required tag partial execution: {failures:#?}"
);
}
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn named_predicates_resolve_loaded_references_for_actual_sources() {
use crate::game::{Audio, Options, Settings, load_resources};
use std::time::Instant;
let root = std::path::PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES").unwrap());
let settings = Settings::default();
let loaded = load_resources(
root.clone(),
None,
&root,
&settings,
Instant::now(),
false,
true,
&crate::game::Progress::new(),
&mut |_, _| unreachable!(),
)
.unwrap();
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
options.resources = root.clone();
options.execution_role = crate::game::execution_role::ExecutionRole::IntegratedServer;
let mut game = Game::new(
&loaded.resources,
loaded.pack,
loaded.gui.unwrap(),
options,
settings,
Audio::silent(&root),
None,
)
.unwrap();
game.build_registries();
game.set_allow_commands(true);
let dir = std::env::temp_dir().join(format!(
"horizon-predicate-reference-{}",
std::process::id()
));
let _ = std::fs::remove_dir_all(&dir);
let pack = dir.join("datapacks/references");
let folder = pack.join("data/h121/predicate");
std::fs::create_dir_all(&folder).unwrap();
std::fs::write(pack.join("pack.mcmeta"), r#"{"pack":{"description":"Predicate references","pack_format":121,"min_format":[121,0],"max_format":[121,0]}}"#).unwrap();
let write = |name: &str, value: serde_json::Value| {
std::fs::write(folder.join(format!("{name}.json")), value.to_string()).unwrap();
};
write(
"pig",
serde_json::json!({"type":"minecraft:entity_properties","entity":"this","predicate":{"minecraft:entity_type":"minecraft:pig"}}),
);
write(
"cow",
serde_json::json!({"type":"minecraft:entity_properties","entity":"this","predicate":{"minecraft:entity_type":"minecraft:cow"}}),
);
write(
"reference",
serde_json::json!({"type":"minecraft:all_of","terms":["h121:pig"]}),
);
write(
"inverted",
serde_json::json!({"type":"minecraft:inverted","term":"h121:pig"}),
);
write(
"conjunction",
serde_json::json!({"type":"minecraft:all_of","terms":["h121:pig",{"type":"minecraft:inverted","term":"h121:cow"}]}),
);
write(
"both",
serde_json::json!({"type":"minecraft:all_of","terms":["h121:pig","h121:cow"]}),
);
write(
"either",
serde_json::json!({"type":"minecraft:any_of","terms":["h121:pig","h121:cow"]}),
);
write(
"repeated",
serde_json::json!({"type":"minecraft:all_of","terms":["h121:pig","h121:pig"]}),
);
for index in 0..33 {
let next = if index == 32 {
"h121:pig".to_owned()
} else {
format!("h121:chain_{}", index + 1)
};
write(
&format!("chain_{index}"),
serde_json::json!({"type":"minecraft:all_of","terms":[next]}),
);
}
game.world_root = dir.clone();
game.reload_datapacks(false);
assert!(game.fns.store.predicates.contains_key("h121:reference"));
assert!(
game.ui
.registries
.predicates
.contains(&"h121:reference".into())
);
let run = |game: &mut Game<'_>, line: &str| {
game.exec = ExecState::default();
let source = game.player_source();
game.execute_line(line, source).unwrap();
};
run(&mut game, "scoreboard objectives add h121 dummy");
let mut actors = Vec::new();
for (index, kind) in ["pig", "cow"].into_iter().enumerate() {
let uuid = [121, 20, 30, index as i32 + 1];
let tag = Tag::compound([
(
"id",
Tag::String(format!("minecraft:{kind}").as_str().into()),
),
("UUID", Tag::IntArray(uuid.to_vec())),
(
"Pos",
Tag::List(vec![
Tag::Double((4.5 + index as f64 * 4.0).to_bits()),
Tag::Double(200.0_f64.to_bits()),
Tag::Double(4.5_f64.to_bits()),
]),
),
("Health", Tag::Float(10.0_f32.to_bits())),
("NoAI", Tag::Byte(1)),
("NoGravity", Tag::Byte(1)),
]);
let id = game.load_mob(tag, None).unwrap();
assert_eq!(game.persist.uuids.get(&id), Some(&uuid));
assert_eq!(
game.entity_view_of(Target::Mob(id)).kind,
format!("minecraft:{kind}")
);
actors.push((id, save::uuid_text(uuid), kind));
}
let clock = game.world.game_time;
for (_, uuid, kind) in &actors {
let pig = *kind == "pig";
for (name, expected) in [
("pig", pig),
("cow", !pig),
("reference", pig),
("inverted", !pig),
("conjunction", pig),
("both", false),
("either", true),
("repeated", pig),
("chain_0", pig),
] {
for sign in ["if", "unless"] {
run(&mut game, "scoreboard players set branch h121 -99");
run(
&mut game,
&format!(
"execute as {uuid} {sign} predicate h121:{name} run scoreboard players set branch h121 1"
),
);
// The intended old failure is the real loaded reference on the pig.
assert_eq!(
game.score.board.score("branch", "h121").unwrap().value,
if expected == (sign == "if") { 1 } else { -99 },
"{kind} {sign} {name}"
);
}
}
}
let previous = game.command_source;
let server = game.server_source();
assert!(server.entity.is_none());
game.command_source = Some(server);
assert!(!game.check_predicate("h121:pig").unwrap());
assert!(!game.check_predicate("h121:reference").unwrap());
assert!(game.check_predicate("h121:inverted").unwrap());
assert!(game.check_predicate("h121:absent").is_err());
game.command_source = previous;
// Defensive evaluator controls only. Java rejects these graphs at
// world load, so this does not establish startup/availability parity.
write(
"missing",
serde_json::json!({"type":"minecraft:all_of","terms":["h121:absent"]}),
);
write(
"nested_missing",
serde_json::json!({"type":"minecraft:all_of","terms":["h121:missing"]}),
);
write(
"not_missing",
serde_json::json!({"type":"minecraft:inverted","term":"h121:absent"}),
);
write(
"cycle_a",
serde_json::json!({"type":"minecraft:all_of","terms":["h121:cycle_b"]}),
);
write(
"cycle_b",
serde_json::json!({"type":"minecraft:all_of","terms":["h121:cycle_a"]}),
);
write(
"not_cycle",
serde_json::json!({"type":"minecraft:inverted","term":"h121:cycle_a"}),
);
for invalid in ["h121:absent", "h121:cycle_a"] {
for (label, kind, first) in [
("any_skip", "minecraft:any_of", "h121:pig"),
("any_reach", "minecraft:any_of", "h121:cow"),
("all_skip", "minecraft:all_of", "h121:cow"),
("all_reach", "minecraft:all_of", "h121:pig"),
] {
write(
&format!("{label}_{}", invalid.split(':').next_back().unwrap()),
serde_json::json!({"type":kind,"terms":[first,invalid]}),
);
}
}
let canonical = pack.join("data/minecraft/predicate/h121_canonical.json");
std::fs::create_dir_all(canonical.parent().unwrap()).unwrap();
std::fs::write(
canonical,
r#"{"type":"minecraft:all_of","terms":["h121_canonical"]}"#,
)
.unwrap();
game.reload_datapacks(false);
let mut source = game.player_source();
source.entity = Some(Target::Mob(actors[0].0));
source.pos = game.target_position(Target::Mob(actors[0].0)).unwrap();
game.command_source = Some(source);
for name in [
"missing",
"nested_missing",
"not_missing",
"cycle_a",
"not_cycle",
"any_skip_absent",
"any_reach_absent",
"all_skip_absent",
"all_reach_absent",
"any_skip_cycle_a",
"any_reach_cycle_a",
"all_skip_cycle_a",
"all_reach_cycle_a",
] {
assert!(
game.fns
.store
.predicates
.contains_key(&format!("h121:{name}"))
);
assert!(
!game.check_predicate(&format!("h121:{name}")).unwrap(),
"{name}"
);
}
assert!(!game.check_predicate("minecraft:h121_canonical").unwrap());
assert!(game.check_predicate("h121:reference").unwrap());
game.command_source = previous;
assert_eq!(game.world.game_time, clock);
std::fs::remove_dir_all(dir).unwrap();
}
}
Loaded 100 of 435 files, more files were not shown because too many files have changed in this diff. Show more