Compare commits

...
106 Commits
Author SHA1 Message Date
Ludovic Marie 8b618d8157 Union union/pt2-e: #3544 7e459e5fb 2026-10-29 18:05:44 +01:00
Ludovic Marie d500607a7d Union union/pt2-e: #3522 2c8b7ee17 2026-10-29 18:05:43 +01:00
Ludovic Marie 394eacab4a Union union/pt2-e: #3507 8d9353093 2026-10-29 18:05:42 +01:00
Ludovic Marie 124dd7c78d Union union/pt2-e: #3405 95c234cc1 2026-10-29 18:05:42 +01:00
Ludovic Marie a6e6613c31 Union union/pt2-e: #3401 1a01f5ef5 2026-10-29 18:05:41 +01:00
Ludovic Marie 0666ea7566 Union union/pt2-e: #3374 a717d6d58 2026-10-29 18:05:40 +01:00
Ludovic Marie 57cf8c462e Union union/pt2-e: #3373 1ffe68635 2026-10-29 18:05:39 +01:00
Ludovic Marie 01d352c76e Union union/pt2-e: #3372 217216e26 2026-10-29 18:05:38 +01:00
Ludovic Marie fe96eb32ef Union union/pt2-e: #3370 825f8f884 2026-10-29 18:05:37 +01:00
Ludovic Marie 06a5980529 Union union/pt2-e: #3369 0771f59bc 2026-10-29 18:05:36 +01:00
Ludovic Marie 48788e2b3b Union union/pt2-e: #3366 52cc53a4f 2026-10-29 18:05:35 +01:00
Ludovic Marie e912d8fdee Union union/pt2-e: #3360 bd59c0bab 2026-10-29 18:05:34 +01:00
Ludovic Marie f6f327e30e Union union/pt2-e: #3163 481251832 2026-10-29 18:05:33 +01:00
Ludovic Marie 6820dda93a Union union/pt2-e: #3148 04cd6ece2 2026-10-29 18:05:32 +01:00
Ludovic Marie 4b000dd767 Union union/pt2-e: #2924 e932a2da6 2026-10-29 18:05:31 +01:00
Ludovic Marie af2e6f86e0 Union union/pt2-e: #2872 b36a63fb3 2026-10-29 18:05:30 +01:00
Ludovic Marie 8ddd57b30e Union union/pt2-e: #2847 c455bb6ec 2026-10-29 18:05:29 +01:00
Ludovic Marie f18fc1c1f4 Union union/pt2-e: #2821 a11e54e7b 2026-10-29 18:05:28 +01:00
Ludovic Marie 40ebcd5c16 Union union/pt2-e: #2689 c566e69c6 2026-10-29 18:05:27 +01:00
Ludovic Marie db2e8a5e67 Union union/pt2-e: #2577 ae72e1f6c 2026-10-29 18:05:26 +01:00
Ludovic Marie c5f1b3771b Union union/pt2-e: #2570 461a8757e 2026-10-29 18:05:25 +01:00
Ludovic MarieandClaude Opus 5.5 2c8b7ee172 Merge main into the branch: keep both lang test blocks
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 18:04:37 +01:00
Ludovic MarieandClaude Opus 5.5 95c234cc1e Merge main into the branch
Keep main's Component::Instrument beside this branch's StewEffects (enum,
test and item_wire_probe).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 18:00:27 +01:00
Ludovic MarieandClaude Opus 5.5 a11e54e7b8 Merge main into the branch
Keep #3543's hurt flash for other players beside the main-hand stack.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 17:01:41 +01:00
ThatCakeIsAPieandClaude Opus 5.5 52cc53a4f9 Surface rules: an empty biome list keeps the name lookup
An empty `biome_is` list on a surface that does not number its biomes
compiled to the id comparison, since collecting no indices never asks
`biome_index`. It then failed with MissingBiomeProvider where vanilla and
main answer false (material_condition's codec-corpus condition 49). It
keeps the name lookup main uses. Review on #3366.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 10:41:38 -05:00
Ludovic MarieandClaude Opus 5.5 1a01f5ef59 Merge main into the branch
Main added block_model_vertices_tinted (one tint for every tinted face)
for mob_blocks.rs; it now calls this branch's tinted_block_model_vertices
with that tint for tint indices >= 0.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 15:10:01 +01:00
ThatCakeIsAPie a717d6d583 Merge feat/upgrade-datapack-3: refuse pack entries that leave the pack 2026-10-29 09:08:50 -05:00
ThatCakeIsAPie 1ffe686350 Merge feat/upgrade-datapack-2b: refuse pack entries that leave the pack 2026-10-29 09:08:50 -05:00
ThatCakeIsAPie 217216e263 Merge feat/upgrade-datapack-2a: refuse pack entries that leave the pack 2026-10-29 09:08:48 -05:00
ThatCakeIsAPie 825f8f8845 Merge feat/upgrade-datapack-1: refuse pack entries that leave the pack 2026-10-29 09:08:47 -05:00
ThatCakeIsAPieandClaude Opus 5.5 0771f59bca upgrade-datapack: refuse pack entries that leave the pack
A zip entry named `../x` or `/x` (or `C:\x` on Windows) was written as it
was, outside `<world>/datapacks/<name>-26.3/`. `read_pack` now takes each
entry's name from `enclosed_name()` and refuses a pack with one that has
anything but plain components, and `upgrade_into` refuses to write such a
path. Review on #3369.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 09:08:46 -05:00
Ludovic MarieandClaude Opus 5.5 c455bb6ec7 Merge main into the branch
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 15:04:30 +01:00
degepieandClaude Opus 5.5 8537e29c92 Merge remote-tracking branch 'origin/main' into fix/issue-3137-suspicious-stew
Conflicts:
- game/stations.rs: keep both tooltip lines, the ominous bottle's before
  the suspicious stew's, in ItemStack.addDetailsToTooltip's component order.
- item_tooltip.rs: keep both tests; the stew test now expects main's
  zero-padded durations ("00:03", "00:07").

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UE3PMKbnTcxfBSeT5uQWCK
2026-10-29 18:06:00 +05:00
Ludovic Marie 4812518327 Merge main into the branch
Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	crates/horizon-client/src/game/creatures.rs
2026-10-29 13:53:24 +01:00
Ludovic MarieandClaude Opus 5.5 7daca92c4a rustfmt: sulfur cubes join the baby-flag group
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 13:53:01 +01:00
JustKarkatandJustKarkat's AI agent (ZCode) 8d93530931 Review: the death forgiveness follows tellNeutralMobsThatIDied
Three corrections from the PR review, checked against 26.3:

- The area is the dying player's block cell grown 32 blocks sideways and
  10 up and down (new AABB(blockPosition()).inflate(32, 10, 32)), not
  the player's box grown 10 on every axis, which never reached the mobs
  11 to 32 blocks out.
- Only a mob whose persistent anger is at the dying player is forgiven
  (NeutralMob.playerDied's getPersistentAngerTarget gate): a wolf angry
  at a skeleton keeps its anger instead of losing the timer behind a
  live target, a state vanilla never has. The clear is then full — the
  last hurt by, the persistent target, the target and the end time —
  with the polar bear ending its flag2 through its own stop.
- The NeutralMob classes are 26.3's six: bee, enderman, iron golem,
  polar bear, wolf, zombified piglin. The panda, spider and trader
  llama are not implementers any more.

The enderman's gate is its player target (its stare aggro), the polar
bear's is flag2 behind the target, the rest anger and a player target,
the bee its own state's angry_at.

Co-Authored-By: JustKarkat's AI agent (ZCode)
2026-10-29 07:47:32 -05:00
Shreyas d27a7cbce3 Sulfur cubes: align babyhood and egg offspring with vanilla 26.3
- Route sulfur cube baby flag through baby_index (entry 16) in lan.rs
- Remove client-side baby state derivation from size in remote_mob_data.rs
- Revert finalize.rs age_boundary_reached and set_baby to vanilla behavior
- Ensure spawn_egg_offspring sets size 1 on new sulfur cube offspring
- Drop lists.rs change from this branch
2026-10-29 18:00:54 +05:30
levzzzandClaude Opus 5.5 c566e69c6c Tick dropped items again, and keep a cooling-down entity out of a gateway
The first commit replaced the tick_items span with the gateway handler, so
dropped items stopped falling, merging and despawning in every mode. The
call is back, next to the gateway's.

An entity that has just come through a portal carries PortalCooldown 300.
EndGatewayBlock.entityInside still triggers the gateway's own cooldown, but
Entity.setAsInsidePortal then only restarts the entity's cooldown, whatever
the portal: the gateway now asks the shared portal state first.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 17:21:13 +05:00
Ludovic MarieandClaude Opus 5.5 461a8757e3 Merge main into the branch
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 13:19:44 +01:00
degepieandClaude Opus 5.5 7e459e5fb1 Frogs long jump: level and unseeded randoms, activity gate TODO
Address the review on #3544:
- goals::Cx carries the level random (the one the brains already get);
  the weighted jump-candidate pick and the three TIME_BETWEEN_LONG_JUMPS
  samples draw from it, as LongJumpToRandomPos/LongJumpMidJump do.
- The angle shuffle in calculateOptimalJumpVector uses a fresh unseeded
  random (Collections.shuffle's global one), not the frog's.
- The preferred-block roll stays on the frog's random (vanilla uses the
  body's random there).
- TODO in LongJump::can_use for FrogAi.initJumpActivity's TEMPTING_PLAYER /
  BREED_TARGET gate and LAY_SPAWN priority: frogs have no tempting,
  breeding or frogspawn here yet.

Other mobs only get the new field threaded through; nothing else reads it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UE3PMKbnTcxfBSeT5uQWCK
2026-10-29 16:15:54 +05:00
Ludovic MarieandClaude Opus 5.5 84185641bc Merge main into perf/worldgen-surface-biomes
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 12:07:11 +01:00
Ludovic Marie e932a2da61 Merge remote-tracking branch 'origin/main' into upd/2924
# Conflicts:
#	crates/horizon-client-render/src/options_ui.rs
2026-10-29 11:47:35 +01:00
Ludovic Marie edd11b6103 Merge remote-tracking branch 'origin/main' into upd/3401 2026-10-29 11:47:06 +01:00
levzzzandOpenAI Codex 6534db4f6d Specify the equipment regression fixture's mob registry type
Co-Authored-By: OpenAI Codex <[email protected]>
2026-10-29 14:24:18 +05:00
levzzzandOpenAI Codex 69689b3fdd Cover named equipment replacement through save and reload
Co-Authored-By: OpenAI Codex <[email protected]>
2026-10-29 14:23:48 +05:00
levzzzandOpenAI Codex c6dfae6dd7 Keep live enchantments when displaying and saving named equipment
Co-Authored-By: OpenAI Codex <[email protected]>
2026-10-29 14:18:51 +05:00
Ludovic Marie 04cd6ece23 Drop the test that only exercised std::mem::replace (the fix is the one line in as_guest) 2026-10-29 10:16:08 +01:00
Ludovic Marie 9218dc3e7d Leave horizon-server's lists test as on main (not part of this fix) 2026-10-29 10:15:47 +01:00
Ludovic Marie 5b66bd0ec0 Merge remote-tracking branch 'origin/main' into upd/3148 2026-10-29 10:15:46 +01:00
levzzzandOpenAI Codex f75896ec45 Retry unloaded gateway arrivals and identify the fixture entities by tag
Co-Authored-By: OpenAI Codex <[email protected]>
2026-10-29 14:13:49 +05:00
levzzzandOpenAI Codex ae72e1f6ca Add a two-path saved vehicle tree and passenger restoration audit
Co-Authored-By: OpenAI Codex <[email protected]>
2026-10-29 14:12:50 +05:00
levzzzandOpenAI Codex 164cfd6b11 Restore server riders after their chunk is sent and refresh entity pairing
Co-Authored-By: OpenAI Codex <[email protected]>
2026-10-29 14:12:19 +05:00
levzzzandOpenAI Codex 7d0e5aa953 Do not save replaced main-hand components from the raw entity
Co-Authored-By: OpenAI Codex <[email protected]>
2026-10-29 14:04:47 +05:00
levzzzandOpenAI Codex 09a9735630 Keep unsupported saved vehicle types pending without respawning them
Co-Authored-By: OpenAI Codex <[email protected]>
2026-10-29 14:04:46 +05:00
levzzzandOpenAI Codex a80973335c Merge current main before rerunning End gateway cargo proof
Co-Authored-By: OpenAI Codex <[email protected]>
2026-10-29 14:03:47 +05:00
levzzzandOpenAI Codex ace27c11a9 Keep named held stacks current and use full death-message display names
Co-Authored-By: OpenAI Codex <[email protected]>
2026-10-29 14:01:23 +05:00
levzzzandOpenAI Codex c91ca07115 Merge current main and use its preset-aware terrain interface
Co-Authored-By: OpenAI Codex <[email protected]>
2026-10-29 13:58:50 +05:00
levzzzandOpenAI Codex c001f56d0f Merge current main before death-message review fixes
Co-Authored-By: OpenAI Codex <[email protected]>
2026-10-29 13:58:45 +05:00
levzzzandOpenAI Codex b36a63fb39 Merge current main and retain vanilla features save status
Co-Authored-By: OpenAI Codex <[email protected]>
2026-10-29 13:58:03 +05:00
levzzzandOpenAI Codex f86c946a3c Merge current main and preserve saved vanilla vehicle trees
Co-Authored-By: OpenAI Codex <[email protected]>
2026-10-29 13:57:42 +05:00
levzzzandOpenAI Codex bd59c0bab2 Pick up validated standard macros for body hooks
Co-Authored-By: OpenAI Codex <[email protected]>
2026-10-29 13:47:44 +05:00
levzzzandOpenAI Codex d3cb2f5c29 Pick up HEAD macro preservation in the launcher mixins pin
Co-Authored-By: OpenAI Codex <[email protected]>
2026-10-29 13:47:44 +05:00
Alexander KrylovandHermes Agent 5485e189f8 Options: Horizon's own captions read the bundled language
The options pages, the mod and shader pack lists, the agent button and the
multiplayer friend screens drew their own labels as literals, so a client with
ru_ru stayed English there. They read `horizon.*` keys now, and the bundled
namespace carries both languages for every one of them.

Co-Authored-By: Hermes Agent (Nous Research) <[email protected]>
2026-10-29 08:35:09 +03:00
Ludovic MarieandClaude Opus 5.5 c48c2f2167 Merge main into this branch
Main now has the render crate split (#3186); Git followed the moved files,
and nothing in this branch needed changing.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 06:13:14 +01:00
JustKarkatandJustKarkat's AI agent (ZCode) bdac04d6ca A dying player's forgiveness reaches the neutral mobs
ServerPlayer.die asks the level for every NeutralMob within 10 blocks of
the dying player and stops its anger when the forgive_dead_players
gamerule (on by default) allows. Nothing here ran the forgiveness, so a
wolf, an iron golem, a zombified piglin or an enderman stayed angry
through a death and attacked again at once on respawn.

The anger and a player target go; a target that is not the player (an
iron golem defending its village) is not the anger. Bees keep their
anger in their own state and end it there.

Fixes #3155.

Co-Authored-By: JustKarkat's AI agent (ZCode)
2026-10-28 23:57:18 -05:00
degepieandClaude Opus 5.5 5f269772d9 Frogs long jump: FrogAi's long jump activity (fixes #2841)
Frogs had no long jump on the server, so they only ever walked. Port the
LONG_JUMP activity as one goal: LongJumpToPreferredBlock searches the
9x5x9 box (weighted by squared distance, lily pads and big dripleaf first
half the time) for a spot FrogAi.isAcceptableLandingSpot accepts and an
8-block path cannot reach, faces it for 40 ticks and leaps along the
LongJumpUtil arc (now with its collision check); LongJumpMidJump holds the
LONG_JUMPING pose without friction until the frog lands. The
LONG_JUMP_COOLDOWN_TICKS memory counts down and is reset as vanilla does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UE3PMKbnTcxfBSeT5uQWCK
2026-10-29 05:23:58 +05:00
JacobRWebbandClaude Opus 5.5 ee90b3b34b Moving blocks take their light from the cells their faces look at
A finished piston's block is drawn over the chunk mesh until that mesh
shows it, but it was lit with the light stored at the moving piston's own
cell. Once the placed block stands there and the edit is relit, that cell
is opaque and holds 0: the pushed or pulled block and the piston base
flashed black for the frames until the mesh came up (#3002).

Vanilla's PistonHeadRenderer.createMovingBlock gives each block a
MovingBlockRenderState (the lone block at the cell it moves from, the
level's light engine), and MovingBlockFeatureRenderer tesselates it with
ModelBlockRenderer.tesselateBlock: in tesselateFlat each quad reads
BlockModelLighter.getLightCoords at its cull-face neighbour, or
prepareQuadFlat's position (the neighbour when cubic, else the block).
Moving blocks now follow that through MeshTables::flat_light, the chunk
mesher's own flat-light rules; a finished one is lit as the block placed
at its cell, which it stands in for.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 17:46:24 -05:00
JacobRWebbandClaude Opus 5.5 a72f06cfab Test the moving piston's saved fields against vanilla's
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 17:28:37 -05:00
JacobRWebbandClaude Opus 5.5 fab9c37413 Moving pistons save facing as a byte, as Direction.LEGACY_ID_CODEC does
PistonMovingBlockEntity.saveAdditional stores facing through
Direction.LEGACY_ID_CODEC, Codec.BYTE of get3DDataValue. The block-entity
canonicaliser rewrote it as an int too.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 17:27:45 -05:00
degepie ac7e491f3b Merge remote-tracking branch 'origin/main' into fix/issue-3137-suspicious-stew
# Conflicts:
#	crates/horizon-protocol/src/item.rs
2026-10-29 03:04:30 +05:00
degepieandClaude Opus 5.5 cb1c3499bf Keep suspicious stew effects across the wire and list them in creative
A stew taken from the creative inventory gave no effect when eaten and
its tooltip listed none (#3137). The protocol read the
`suspicious_stew_effects` component past without decoding it, so the
creative client's `SetCreativeModeSlot` sent the server a bare stew and
the server's copy had nothing for `SuspiciousStewEffects.onConsume` to
apply. Decode and encode it (`Entry.STREAM_CODEC`: mob effect id,
duration) both ways, and add `SuspiciousStewEffects.addToTooltip`'s
creative-only effect lines (`PotionContents.addPotionTooltip`).

Fixes #3137.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UE3PMKbnTcxfBSeT5uQWCK
2026-10-29 02:29:33 +05:00
JacobRWebbandClaude Opus 5.5 4a094bee90 Pistons: moving blocks keep their tint, survive saves and leave no black ghost
- Moving blocks are tinted per face with the chunk mesher's block colours
  (MeshTables::block_tints), not the terrain particle tint, which is none
  for grass blocks: a pushed grass block was drawn gray.
- PistonMovingBlockEntity is saved with its chunk as vanilla's
  minecraft:piston block entity and loaded back into the level's moving
  pistons, so blocks caught mid-move finish it after a rejoin instead of
  leaving an invisible, solid moving_piston (#3310).
- A server client drops its moving piston when the server changes that
  block (LevelChunk.setBlockState removes the block entity), instead of
  animating it for five more death ticks over the placed block, black and
  z-fighting (#3002).
- A finished piston stays drawn until the chunk mesh shows its block, and
  no longer, so no frame shows neither.

Fixes #3310
Fixes #3002

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 16:29:10 -05:00
ThatCakeIsAPieandClaude Opus 5.5 85bfbc7d46 upgrade-datapack: docs, and checks against vanilla 26.2 and 26.3
- docs/datapack-upgrade.md: how to run `horizon-port-pack
  upgrade-datapack`, every rewrite and how it is checked, and what is not
  rewritten; linked from docs/README.md and the command's help.
- tools/datapack_upgrade_rosetta.py writes vanilla 26.2's own worldgen out
  as a pack, upgrades it and compares it with vanilla 26.3's file by file:
  registries whose format changed and whose content did not must come out
  identical.
- tools/datapack_upgrade_fidelity.py starts vanilla's 26.2 and 26.3
  dedicated servers on the same seed, the first with a pack and the second
  with its upgrade, force loads the same chunks and compares the saved
  blocks; without packs it measures what 26.3 itself changed.

Both need the 26.2 server jar the player downloaded; nothing is fetched.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 14:57:24 -05:00
ThatCakeIsAPieandClaude Opus 5.5 f983223354 port-pack: upgrade-datapack rewrites features and placements
The fourth part of the 26.2 to 26.3 data pack upgrade.

- Configured features move to `feature/` with `config` unwrapped, block
  state providers take their 26.3 names (a `simple_state_provider` is its
  state), and nested and inline placed features are upgraded with them.
  Feature types 26.3 replaced by compositions of other features are listed
  in the report and left as they are.
- Placement `random_offset` becomes `offset` with one provider per axis
  (x and z both from `xz_spread`).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 14:57:23 -05:00
ThatCakeIsAPieandClaude Opus 5.5 3d39bbdaf1 port-pack: upgrade-datapack rewrites noise settings, dimensions and presets
The third part of the 26.2 to 26.3 data pack upgrade: noise settings in
26.3's form, with the parts 26.2 hard-coded written out as data.

- The beardifier, which 26.2's NoiseChunk added, is added to the final
  density.
- The aquifer router fields move to `aquifers`, whose `exclusion` spells
  out 26.2's `isDeepDarkRegion` (erosion < -0.225 and depth > 0.9) from
  the pack's own erosion and depth; `surface_level` is the preliminary
  surface, and `chunk_surface_level` its per-chunk 16 x 1 interpolation.
- 26.2's ore veinifier becomes two `ore_vein` material rules (copper,
  iron) with OreVeinifier's heights, thresholds, edge roundoff, solidness,
  richness and gap, ahead of the surface rule: 26.2 placed veins while
  filling the noise and its surface pass only replaced the default block.
  The rule is written as a named `material_rule` under the settings' id.
- The climate functions are named, and the spawn target refers to them by
  id (`depth` and `offset` dropped, as vanilla did).
- `size_horizontal`/`size_vertical` are dropped: each `interpolated` holds
  its cell.
- Inline noise settings in dimensions and world presets get the same.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 14:57:23 -05:00
ThatCakeIsAPieandClaude Opus 5.5 2aca6fe06c port-pack: upgrade-datapack rewrites density functions
The second part of the 26.2 to 26.3 data pack upgrade: named density
functions in 26.3's form.

- Inputs are named `input`/`left`/`right`; `invert` is `reciprocal`,
  `y_clamped_gradient` a `gradient` on y, `shifted_noise` a `noise` with
  shifts, `end_islands` `end_outer_islands`, and `constant` its number.
- 26.2's `flat_cache` held each column's value at y = 0, so it becomes a
  `cache` around a `slice` at y = 0 when its input depends on y (through
  the pack's own functions); 26.2's other caches only memoise in 26.3.
- `interpolated` takes its cell from the noise settings whose router
  reaches it (`size_horizontal * 4`, `size_vertical * 4`), which 26.2 read
  from the settings, or vanilla's overworld cell when none does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 14:57:23 -05:00
ThatCakeIsAPieandClaude Opus 5.5 81b3803017 port-pack: upgrade-datapack, a 26.2 data pack's registries in 26.3's formats
Vanilla 26.3 refuses a world generation data pack written for 26.2: the
registries changed format. `horizon-port-pack upgrade-datapack <pack>
(<out> | --world <world>)` reads a pack (zip or folder) and writes it in
26.3's formats, with a report of what it rewrote, what has no faithful 26.3
form and which types it does not know. Each rewrite is the one vanilla's own
data went through between 26.2 and 26.3.

This first part handles the registries that need no context:
- noise parameters: `firstOctave`/`amplitudes` become `base_octave`,
  `octave_count`, `amplitude_modifiers` and the base amplitude of
  `NormalNoise.createParity`, which keeps 26.2's normalisation;
- block states: `{Name, Properties}` become `{id, properties}`, or the id;
- configured carvers move to `carver/` with `config` unwrapped and the cave
  carvers' hard-coded count, thickness and room scale written out;
- biome spawners and spawn probability become 26.3's gameplay attributes;
- structure spawn overrides and the dimension type's bed rule.

Density functions, noise settings and features follow in their own
changes. serde_json gains `float_roundtrip` so the numbers the upgrade
writes back are the ones it read.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 14:57:19 -05:00
Lyle ColeandClaude Opus 5.5 c8f484e040 Surface biomes: keep each quart cell's corner jitter and biomes
The surface stage's per-chunk cell cache only proved cells whose eight
quart corners hold one biome; a block in any other cell ran the full
eight-corner zoom mix and a stored lookup again. Now a mixed cell keeps
its corners' jitter (BiomeZoom::corner_jitter, one mix per cell) and
their eight biomes, so each block of the cell only redoes the distance
comparison (BiomeZoom::select_corner, same arithmetic and tie order); the
cell of the previous lookup is kept too, as columns walk down in y. A
test checks the split zoom against quart_at_block.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 14:57:16 -05:00
ThatCakeIsAPieandClaude Opus 5.5 d4a1ebaa33 Surface rules: compare biome ids, not names
Every solid block of every column asks the rule tree's biome conditions
for its biome. Each one cloned and dropped the biome's Arc<str> (an atomic
count all generator threads share) and looked its name up in a string set.
Queries that number their biomes (the generator's) now answer with the
index, and biome conditions keep a bit per index; others compare names as
before. Same answers: the generator's name -> id map is exact.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 14:57:16 -05:00
Shreyas 2ac85c50eb lan: register default size 1 for AbstractCubeMob metadata 2026-10-29 00:11:11 +05:30
Shreyas 43f2497f1f Sulfur cubes: synchronize size metadata and adult egg spawns
Include sulfur cubes in AbstractCubeMob size metadata serialization and
remote data handling so adult cubes retain their size across the network.
Adult sulfur cubes spawned from spawn eggs now correctly render and act
as adults rather than defaulting to size 1 babies.

Vanilla reference: AbstractCubeMob.ID_SIZE, SulfurCube.setSpawnSize.

Fixes #3151
2026-10-28 17:13:45 +05:30
Shreyas 380d71d8a8 lists: fall back to offline resolution when services do not answer in tests 2026-10-28 16:31:23 +05:30
Shreyas 0543cf0345 Select the picked hotbar slot in singleplayer and LAN
Preserve the guest's updated hotbar selection when restoring the host
player state after an action. The previous restore sequence discarded
the guest's new selection while keeping the inventory changes.

The held-slot response now uses the selected slot produced by the pick,
so integrated-server and LAN players select the picked block or spawn
egg. Keep the host's selection independent of the guest's selection.

Vanilla reference: ServerGamePacketListenerImpl.tryPickItem,
Minecraft.pickBlockOrEntity, and ClientPacketListener.handleSetHeldSlot.

Fixes #3031
2026-10-28 16:00:22 +05:30
Ludovic MarieandClaude Opus 5.5 2ee238e5f1 Merge #2931 into #2924: both add vanilla 26.3 options to options_ui.rs
The controls list and the mouse list sit side by side; #2931 lands
first.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 22:28:53 +01:00
Ludovic MarieandClaude Opus 5.5 6bb648db1e Controls: the three switches nothing reads yet show as unavailable
Operator Items Tab, Quit Shortcuts and Control-click are stored and saved
like vanilla's, but no code reads them yet: grey them out like the other
placeholders instead of offering a switch that does nothing.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 22:26:24 +01:00
Ludovic MarieandClaude Opus 5.5 e1d825312f Read the flat world's horizon from WorldGen's preset
main's dark disc and void fog read WorldGen.flat, which is now the preset
(seen by sirrayi's review).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 22:25:52 +01:00
Ludovic MarieandClaude Opus 5.5 5ea61ed3d8 Merge main into world-types
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 22:25:45 +01:00
Ludovic MarieandClaude Opus 5.5 231b309634 Name the forced terrain ring where the protos are kept
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 22:24:48 +01:00
ElyTheCat 80df20837b Options: add Java 26.3 controls 2026-10-27 22:09:31 +03:30
levzzz b901415c80 Merge remote-tracking branch 'upstream/main' into fix/forceload-rings 2026-10-27 20:32:05 +05:00
levzzzandminimax-m3.1-flash da877fe1db Give a forced chunk vanilla's rings
A `/forceload`ed chunk holds a FORCED ticket at level 31, and vanilla's
level rises one per ring, so the chunk itself and the two rings around it
are level chunks with their light, the third ring has its features and the
fourth only its terrain. Horizon took the forced chunk's reach from the
caller's margin instead, so only the chunk itself ever finished and its
neighbours stayed proto chunks.

The reach now follows the ticket levels: the full ring is two chunks out,
the features ring three and the terrain ring four, and the protos of the
terrain ring are kept so the features ring can be decorated from them. A
decorated proto chunk is saved as `minecraft:initialize_light`, the status
vanilla gives a chunk that has its features but no light.

Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>
2026-10-27 20:18:42 +05:00
sirrayiandDevin 6143e258ab Release notes: a Contributors heading and a rule before the disclaimer
The "Thanks to ..." line read as part of the section before it (often Known issues), on GitHub and on playhorizon.dev/releases. It now has its own heading, and a horizontal rule sets the disclaimer apart. Fixes #2314.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-27 14:55:35 +00:00
levzzzandminimax-m3.1-flash f9ca0a76cf The death message names the cause's item when it has a custom name
`DamageSource.getLocalizedDeathMessage` uses the `<id>.item` message when
the cause holds an item with a custom name, with the item's name as a
third argument: "Steve was shot by Zombie using My Sword". Horizon always
used the two-argument message, so the item was never named.

The mob's main hand item now keeps its custom name, and the death message
picks the message and its arguments from it.

Fixes #2802

Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>
2026-10-27 18:38:37 +05:00
levzzzandminimax-m3.1-flash 1232745064 Keep a traveller that waits for an End gateway's exit
The traveller was dropped from the queue before the exit was resolved, so a
failure to resolve it lost the entity for good. The traveller now waits and
the failure is retried, with the reason in the log.

Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>
2026-10-27 18:34:07 +05:00
levzzzandminimax-m3.1-flash 3382b666ce Add the audit that drives a cow and a stack through an End gateway
The script plays the End in a scratch home: it places a gateway beside the
spawn platform, puts a cow and a seven-diamond stack in it, and checks that
both leave it and land at the gateway's exit with their data, in the
server-thread and the in-frame singleplayer.

Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>
2026-10-27 18:27:43 +05:00
levzzzandminimax-m3.1-flash 92c073de61 Merge upstream/main into fix/end-gateway-entities
Upstream now keeps a gateway's exit in the gateway's own block entity
(TheEndGatewayBlockEntity.setExitPosition), so the player travels to the
exit the gateway was given (#1517). The entity travel stays: the mobs and
the dropped items go through the same gateway, and the exit the first
traveller found is reused, so the player and the entities land in the same
place and only one island is built for a gateway.

Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>
2026-10-27 17:27:46 +05:00
levzzzandminimax-m3.1-flash 139de824aa Re-run the checks after the audit script fix
The pull request's last push did not start a run; this empty commit
starts one on the same tree.

Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>
2026-10-27 17:10:44 +05:00
Ludovic MarieandClaude Opus 5.5 ed01a93df6 Merge main into fix/world-types
main's Superflat Customize screen (#2214) still set the removed WorldOptions.flat: it now picks WorldPreset::Flat,
and the agent's customize-flat state reads the preset. structure_mobs.rs's test passes the new preset argument
(minecraft:normal) to VanillaTerrain::for_dimension.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 12:39:11 +01:00
levzzzandminimax-m3.1-flash 1a56e74324 Fix the f-string in the end cargo audit script
The braces were one too many, so the file did not parse and the Python
tests failed on it. The command it builds is the same now: the item
stack {Item:{id:"minecraft:diamond",count:3}}.

Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>
2026-10-27 16:35:44 +05:00
Claude Codeandminimax-m3.1-flash 25b84dfc2e Format the world preset test
rustfmt keeps the registry read on one line.

Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>
2026-10-27 16:11:14 +05:00
levzzzandminimax-m3.1-flash a2098c2ffa Let mobs and dropped items through End gateways
Only the player was checked for End gateways, so a cow or a dropped
stack standing in one stayed there for good. The entities now go
through a gateway the way the player does: the block their box swept
through takes them in (unless the gateway is cooling down, as
EndGatewayBlock.entityInside does), they travel on the next tick
(Entity.handlePortal, with the End gateway's transition time of 0) and
land where the gateway leads once its chunks have loaded, keeping their
data and counts.

Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>
2026-10-27 16:11:14 +05:00
Claude Codeandminimax-m3.1-flash d98dc8edc9 Read the test registry without the explicit deref
clippy's explicit_auto_deref asks for the LazyLock itself, which
derefs to the registry the generator data keeps.

Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>
2026-10-27 16:11:14 +05:00
Claude Codeandminimax-m3.1-flash bf05176692 Add the amplified, large-biome and single-biome world types
The create screen only offered Default and Superflat, so the amplified
world type of #1873 could not be chosen at all, and the generator
always built the overworld's noise settings.

The world-type button now cycles through the presets vanilla's
WorldPresetTags.NORMAL tag holds (normal, flat, large_biomes, amplified,
single_biome_surface), the chosen preset is written into the new world's
world_gen_settings.dat with the generator
worldgen/world_preset/<preset>.json has, and the generator is built from
that preset, so an amplified world gets the amplified noise settings.
A world opened again reads its preset back from its generator.

Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>
2026-10-27 16:11:14 +05:00
levzzz a3489d33b4 Merge remote-tracking branch 'upstream/main' into fix/root-vehicle-restore 2026-10-27 10:53:42 +05:00
levzzz 9c16d40db1 Merge remote-tracking branch 'upstream/main' into fix/root-vehicle-restore 2026-10-27 04:54:43 +05:00
Claude Code 730184c64f Riding a horse survives a save and a reopen
The player's file now names the vehicle it rides, the way vanilla's
ServerPlayer.saveParentVehicle does, and the ride comes back on the next
login in both single player's modes.

- The player compound holds a "RootVehicle" compound whose "Attach" is the
  UUID of the vehicle at the root of the rider chain (Entity.getRootVehicle).
  Horizon keeps the vehicle in the world, so the file names it instead of
  holding a second copy of it: the horse and its equipment stay in the
  chunk's entity file, and nothing is duplicated.
- The single player's own player writes the vehicle it rides, and a server's
  guests write theirs; on the next login the vehicle is looked up by its
  UUID among the entities in the world, and the player rides it again.
- A guest whose file names a vehicle gets back on it once the vehicle is
  loaded, as ServerPlayer.loadAndSpawnParentVehicle does.

Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>
2026-10-27 04:40:43 +05:00
102 changed files with 5619 additions and 404 deletions

No files matched your search

@@ -494,7 +494,11 @@ pub fn canonical(tag: &Tag) -> Option<Tag> {
_ => string("minecraft:air"),
},
);
f.put("facing", Tag::Int(int(tag.get("facing")).unwrap_or(0)));
// Direction.LEGACY_ID_CODEC: the 3D data value as a byte.
f.put(
"facing",
Tag::Byte(int(tag.get("facing")).unwrap_or(0) as i8),
);
f.float("progress", 0.0);
f.flag("extending", false);
f.flag("source", false);
@@ -873,6 +877,23 @@ mod tests {
);
}
/// `PistonMovingBlockEntity.saveAdditional`: `facing` through
/// `Direction.LEGACY_ID_CODEC` (a byte), `progress` a float.
#[test]
fn moving_piston_fields_match_vanilla() {
let out = canon(
"{id:\"minecraft:piston\",x:1,y:2,z:3,blockState:{Name:\"minecraft:stone\"},\
facing:5,progress:0.5f,extending:1b,source:0b}",
);
assert_eq!(out.get("facing"), Some(&Tag::Byte(5)));
assert_eq!(out.get("progress"), Some(&f32_tag(0.5)));
assert_eq!(out.get("extending"), Some(&Tag::Byte(1)));
assert_eq!(out.get("source"), Some(&Tag::Byte(0)));
let fresh = canon("{id:\"minecraft:piston\",x:1,y:2,z:3}");
assert_eq!(fresh.get("facing"), Some(&Tag::Byte(0)));
assert_eq!(fresh.get("blockState"), Some(&string("minecraft:air")));
}
#[test]
fn loot_table_replaces_items_and_zero_seed_is_omitted() {
let out = canon(
+7
View File
@@ -269,6 +269,13 @@ mod tests {
assert_eq!(en.get("horizon.options.controller"), Some("Controller"));
let ru = Language::load(&[&empty], "ru_ru");
assert_eq!(ru.get("horizon.options.controller"), Some("Геймпад"));
// Every bundled string carries both languages: a key added to one file
// and forgotten in the other shows as a key name on screen.
for key in en.map().keys() {
assert!(ru.get(key).is_some(), "{key} has no ru_ru value");
}
assert_eq!(en.get("horizon.pages.mods"), Some("Mods"));
assert_eq!(ru.get("horizon.pages.mods"), Some("Моды"));
let uk = Language::load(&[&empty], "uk_ua");
assert_eq!(
uk.get("horizon.options.on_screen_keyboard"),
@@ -1563,3 +1563,49 @@ mod tests {
);
}
}
/// `DamageSource.getLocalizedDeathMessage`: the cause's item with a custom
/// name gets the `<id>.item` message, with the item's name as a third
/// argument.
pub fn death_message_args(
key: &str,
victim: &Text,
cause: &Text,
item: Option<&Text>,
) -> (String, Vec<Text>) {
match item {
Some(item) => (
format!("{key}.item"),
vec![victim.clone(), cause.clone(), item.clone()],
),
None => (key.to_owned(), vec![victim.clone(), cause.clone()]),
}
}
#[cfg(test)]
mod death_message_tests {
use super::*;
#[test]
fn the_death_message_names_the_causes_item_when_it_has_a_custom_name() {
let victim = Text::literal("Steve");
let cause = Text::literal("Zombie");
// DamageSource.getLocalizedDeathMessage: the item variant, with the
// item's name as the third argument.
let (key, args) = death_message_args(
"death.attack.arrow",
&victim,
&cause,
Some(&Text::literal("My Sword")),
);
assert_eq!(key, "death.attack.arrow.item");
assert_eq!(args.len(), 3);
assert_eq!(args[0].plain(), "Steve");
assert_eq!(args[1].plain(), "Zombie");
assert_eq!(args[2].plain(), "My Sword");
// Without a custom name the message keeps its two arguments.
let (key, args) = death_message_args("death.attack.arrow", &victim, &cause, None);
assert_eq!(key, "death.attack.arrow");
assert_eq!(args.len(), 2);
}
}
+16 -6
View File
@@ -50,20 +50,30 @@ pub fn tooltip(
Category::Neutral,
)];
}
def.effects
.iter()
effect_lines(def.effects.iter().copied(), scale, translate)
}
/// `PotionContents.addPotionTooltip`'s line per effect: its name, potency and
/// scaled duration, for (effect id, duration, amplifier) entries.
pub fn effect_lines<'a>(
effects: impl IntoIterator<Item = (&'a str, i32, i32)>,
scale: f32,
translate: &dyn Fn(&str) -> Option<String>,
) -> Vec<(String, Category)> {
effects
.into_iter()
.map(|(e, duration, amp)| {
let e = e.trim_start_matches("minecraft:");
let mut line =
translate(&format!("effect.minecraft.{e}")).unwrap_or_else(|| (*e).to_owned());
if *amp > 0 {
translate(&format!("effect.minecraft.{e}")).unwrap_or_else(|| e.to_owned());
if amp > 0 {
line.push(' ');
line.push_str(
&translate(&format!("potion.potency.{amp}"))
.unwrap_or_else(|| crate::enchant::level_name(*amp as u32 + 1)),
.unwrap_or_else(|| crate::enchant::level_name(amp as u32 + 1)),
);
}
let instant = effect(e).is_some_and(|d| d.instant);
let scaled = (*duration as f32 * scale) as i32;
let scaled = (duration as f32 * scale) as i32;
if !instant && scaled > 20 {
line = format!("{line} ({})", format_duration(scaled));
}
@@ -513,6 +513,56 @@ impl MeshTables {
let mut tints = Tints::new(region, biomes, zoom, blend_radius, origin);
Mesher::tint(info, 0, &mut tints, [x & 15, y & 15, z & 15])
}
/// `BlockColors` for each of the state's tint indices at `pos`, as the
/// chunk mesher tints them (grass blocks included): `None` for an
/// untinted (-1) layer. For block models drawn outside the chunk mesh.
pub fn block_tints(
&self,
state: BlockStateId,
pos: [i32; 3],
region: &[[Option<&Chunk>; 3]; 3],
biomes: &Biomes,
zoom: BiomeZoom,
blend_radius: i32,
) -> impl Fn(i32) -> Option<u32> + use<> {
let sources = self.info.get(state.0 as usize).map(|info| {
let [x, y, z] = pos;
let origin = [x >> 4, z >> 4, y & !15];
let mut tints = Tints::new(region, biomes, zoom, blend_radius, origin);
let local = [x & 15, y & 15, z & 15];
let colors = [0, 1].map(|i| Mesher::tint(info, i, &mut tints, local));
(colors, i32::from(info.tint_count))
});
move |index| {
let (colors, count) = sources?;
if index < 0 || index >= count {
return None;
}
colors[(index as usize).min(1)]
}
}
/// `ModelBlockRenderer.tesselateFlat`'s light coords (`LightCoordsUtil`
/// packing, the quad's own emission included) for `quad` of `state`
/// drawn as the only block at `pos`, as `MovingBlockRenderState` gives
/// it: `light` is the world's stored light (sky << 4 | block) at a
/// position. For block models drawn outside the chunk mesh.
pub fn flat_light(
&self,
state: BlockStateId,
pos: [i32; 3],
quad: &horizon_assets::bake::ModelQuad,
light: impl Fn([i32; 3]) -> u8,
) -> u32 {
let own = self.info.get(state.0 as usize);
let full = own.is_some_and(|i| i.full);
let at = lighting::flat_position(pos, &quad.positions, quad.facing, quad.cull, full);
let packed = light(at);
let coords = match own {
Some(own) => lighting::packed_coords(own, packed),
None => u32::from(packed & 15) << 4 | u32::from(packed >> 4) << 20,
};
lighting::with_emission(coords, quad.light_emission)
}
/// `BlockState.getOffset` for blocks with a random model offset.
pub fn offset(&self, state: BlockStateId, x: i32, z: i32) -> Option<[f64; 3]> {
offset(self.info.get(state.0 as usize)?, x, z)
@@ -39,10 +39,13 @@ fn state<'a>(pad: &Padded, info: &'a [Info], p: [i32; 3]) -> &'a Info {
/// `LightCoordsUtil.getLightCoords`: light at `p` as seen through `state`
/// (block level << 4 | sky level << 20).
pub(super) fn coords(pad: &Padded, state: &Info, p: [i32; 3]) -> u32 {
packed_coords(state, pad.light[pi(p[0], p[1], p[2])])
}
/// `coords` of a stored light byte (sky level << 4 | block level).
pub(super) fn packed_coords(state: &Info, packed: u8) -> u32 {
if state.emissive {
return FULL_BRIGHT;
}
let packed = pad.light[pi(p[0], p[1], p[2])];
let block = u32::from((packed & 15).max(state.emission));
block << 4 | u32::from(packed >> 4) << 20
}
@@ -274,8 +277,28 @@ pub(super) fn flat(
positions: &[[f32; 3]; 4],
face: D,
) -> u32 {
let cubic = shape(positions, face, own.full).cubic;
coords(pad, own, if cubic { step(pos, face) } else { pos })
coords(
pad,
own,
flat_position(pos, positions, face, None, own.full),
)
}
/// Where `tesselateFlat` reads the light of a quad of a block at `pos`: the
/// neighbour on its cull face, else `prepareQuadFlat`'s choice (the neighbour
/// when the quad is cubic, else the block itself). `full` is the block's
/// `isCollisionShapeFullBlock`.
pub(super) fn flat_position(
pos: [i32; 3],
positions: &[[f32; 3]; 4],
face: D,
cull: Option<D>,
full: bool,
) -> [i32; 3] {
match cull {
Some(d) => step(pos, d),
None if shape(positions, face, full).cubic => step(pos, face),
None => pos,
}
}
/// `prepareQuadAmbientOcclusion`: per-vertex AO brightness (before `ARGB.gray`)
/// and smooth light coords, by vertex index.
@@ -398,6 +421,39 @@ mod tests {
assert_eq!(max_coords(packed(3, 9), packed(7, 2)), packed(7, 9));
}
#[test]
fn flat_light_reads_the_neighbour_a_face_looks_at() {
let pos = [4, 64, -2];
let full_top = [
[0.0, 1.0, 0.0],
[0.0, 1.0, 1.0],
[1.0, 1.0, 1.0],
[1.0, 1.0, 0.0],
];
// A cull face samples its neighbour, whatever the quad's shape.
let inset = full_top.map(|[x, _, z]| [x, 0.75, z]);
assert_eq!(
flat_position(pos, &inset, D::Up, Some(D::Up), false),
[4, 65, -2]
);
// A flush quad without a cull face samples its neighbour too: a block
// drawn at a cell that is now opaque (a moving block, #3002) still
// takes the light around it.
assert_eq!(
flat_position(pos, &full_top, D::Up, None, false),
[4, 65, -2]
);
let west = [
[0.0, 0.0, 0.0],
[0.0, 0.0, 1.0],
[0.0, 1.0, 1.0],
[0.0, 1.0, 0.0],
];
assert_eq!(flat_position(pos, &west, D::West, None, false), [3, 64, -2]);
// An inset quad (a piston head's arm) takes the block's own light.
assert_eq!(flat_position(pos, &inset, D::Up, None, false), pos);
assert_eq!(flat_position(pos, &inset, D::Up, None, true), [4, 65, -2]);
}
#[test]
fn quad_shape_classifies_partial_and_cubic_faces() {
let full_top = [
[0.0, 1.0, 0.0],
@@ -54,3 +54,40 @@ fn terrain_particles_use_the_particle_sprite_and_vanilla_tint_sources() {
assert_eq!(tint("minecraft:pink_petals"), None);
assert_eq!(tint("minecraft:stone"), None);
}
/// Blocks drawn outside the chunk mesh (moved by a piston) take the chunk
/// mesher's per-layer tints: a grass block's top and side overlay are tinted
/// as in the world, not left gray like its particles.
#[test]
fn block_tints_match_the_chunk_mesh_for_grass_blocks() {
let root =
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../.local/horizon-client/26.3");
if !root.join("horizon-resources.json").exists() {
return;
}
let assets = Assets::load(&root).unwrap();
let render = assets.reload(&root).unwrap();
let facts = BlockFacts::load(&root, assets.registry).unwrap();
let visibility = crate::models::load_visibility(&root, assets.registry).unwrap();
let tables = MeshTables::new(&assets, &render, &facts, visibility).unwrap();
let biomes = Biomes::load(&root).unwrap();
let state = |name| assets.registry.default_state(name).unwrap();
let center = Chunk::empty(0, 0);
let mut region = [[None; 3]; 3];
region[1][1] = Some(&center);
let tints = |name| {
tables.block_tints(
state(name),
[5, 70, 9],
&region,
&biomes,
BiomeZoom::new(0),
2,
)
};
let grass = tints("minecraft:grass_block");
assert!(grass(0).is_some());
assert_eq!(grass(-1), None);
assert_eq!(tints("minecraft:spruce_leaves")(0), Some(0x61_9961));
assert_eq!(tints("minecraft:stone")(0), None);
}
+179 -91
View File
@@ -84,6 +84,27 @@ pub(crate) const ACCESSIBILITY_OPTIONS: &[Opt] = &[
Opt::HighContrastBlockOutlines,
];
/// `ControlsScreen.options`: the controls added by Java Edition 26.3, in
/// their vanilla order. The macOS-only Control-click switch is omitted on
/// other platforms just as `InputQuirks.EMULATE_RIGHT_CLICK_WITH_CTRL_KEY`
/// does in Java.
pub(crate) fn vanilla_control_options() -> Vec<Opt> {
let mut options = vec![
Opt::ToggleCrouch,
Opt::ToggleSprint,
Opt::ToggleAttack,
Opt::ToggleUse,
Opt::AutoJump,
Opt::SprintWindow,
Opt::QuitShortcuts,
];
if cfg!(target_os = "macos") {
options.push(Opt::CtrlClickEmulatesRightClick);
}
options.push(Opt::OperatorItemsTab);
options
}
/// `MouseSettingsScreen.options` in Java Edition 26.3, in vanilla order.
pub(crate) const MOUSE_OPTIONS: &[Opt] = &[
Opt::Sensitivity,
@@ -120,6 +141,11 @@ impl Opt {
| Opt::ChunkFade
| Opt::ImprovedTransparency
| Opt::ChunkBuilder
// Saved, but the creative tabs, the quit keys and the mouse
// don't read them yet.
| Opt::OperatorItemsTab
| Opt::QuitShortcuts
| Opt::CtrlClickEmulatesRightClick
)
}
fn file_key(self) -> Option<&'static str> {
@@ -144,6 +170,9 @@ impl Opt {
Opt::RotateWithMinecarts => "rotateWithMinecart",
Opt::HighContrastBlockOutlines => "highContrastBlockOutline",
Opt::MusicFrequency => "musicFrequency",
Opt::OperatorItemsTab => "operatorItemsTab",
Opt::QuitShortcuts => "quitShortcuts",
Opt::CtrlClickEmulatesRightClick => "ctrlClickEmulatesRightClick",
Opt::ModelPart(i) => MODEL_PART_KEYS[i],
Opt::AllowServerListing => "allowServerListing",
Opt::RealmsNotifications => "realmsNotifications",
@@ -194,7 +223,13 @@ impl Opt {
Opt::MenuBlur => "options.accessibility.menu_background_blurriness",
Opt::ToggleCrouch => "key.sneak",
Opt::ToggleSprint => "key.sprint",
Opt::ToggleAttack => "key.attack",
Opt::ToggleUse => "key.use",
Opt::AutoJump => "options.autoJump",
Opt::SprintWindow => "options.sprintWindow",
Opt::QuitShortcuts => "options.quitShortcuts",
Opt::CtrlClickEmulatesRightClick => "options.ctrlClickEmulatesRightClick",
Opt::OperatorItemsTab => "options.operatorItemsTab",
Opt::ChatVisibility => "options.chat.visibility",
Opt::ChatColors => "options.chat.color",
Opt::ChatLinks => "options.chat.links",
@@ -217,7 +252,7 @@ impl Opt {
Opt::NotificationDisplayTime => "options.notifications.display_time",
Opt::Narrator => "options.narrator",
Opt::TextBackground => "options.accessibility.text_background",
Opt::ChatDelay => "Chat Delay",
Opt::ChatDelay => "horizon.options.chat_delay",
Opt::DistortionEffects => "options.screenEffectScale",
Opt::FovEffects => "options.fovEffectScale",
Opt::DarknessPulsing => "options.darknessEffectScale",
@@ -308,6 +343,7 @@ impl Opt {
Opt::PadLookSensitivity => Kind::Slider(s.controller.look_sensitivity),
Opt::PadMoveDeadzone => unit(s.controller.move_deadzone, 0.0, 0.5),
Opt::PadLookDeadzone => unit(s.controller.look_deadzone, 0.0, 0.5),
Opt::SprintWindow => unit(s.sprint_window as f32, 0.0, 10.0),
_ => Kind::Cycle,
}
}
@@ -356,6 +392,14 @@ impl Opt {
}
}
Opt::PanoramaSpeed => percent(c, s.file.double("panoramaScrollSpeed") as f32),
Opt::SprintWindow => {
let value = if s.sprint_window == 0 {
tr("options.off")
} else {
Text::tr("options.value", &[&s.sprint_window])
};
generic(c, value)
}
Opt::Fov => match s.fov as i32 {
70 => generic(c, tr("options.fov.min")),
110 => generic(c, tr("options.fov.max")),
@@ -573,7 +617,20 @@ impl Opt {
} else {
"options.key.hold"
}),
Opt::ToggleAttack => tr(if s.toggle_attack {
"options.key.toggle"
} else {
"options.key.hold"
}),
Opt::ToggleUse => tr(if s.toggle_use {
"options.key.toggle"
} else {
"options.key.hold"
}),
Opt::AutoJump => on_off(s.auto_jump),
Opt::QuitShortcuts => on_off(s.quit_shortcuts),
Opt::CtrlClickEmulatesRightClick => on_off(s.ctrl_click_emulates_right_click),
Opt::OperatorItemsTab => on_off(s.operator_items_tab),
Opt::ChatVisibility => tr([
"options.chat.visibility.full",
"options.chat.visibility.system",
@@ -603,25 +660,25 @@ impl Opt {
} else {
"options.key.hold"
}),
Opt::PadLightbar => Text::literal(match s.controller.lightbar {
gamepad::Lightbar::Health => "Health",
gamepad::Lightbar::Dimension => "Dimension",
gamepad::Lightbar::Off => "Off",
Opt::PadLightbar => tr(match s.controller.lightbar {
gamepad::Lightbar::Health => "horizon.options.lightbar.health",
gamepad::Lightbar::Dimension => "horizon.options.lightbar.dimension",
gamepad::Lightbar::Off => "options.off",
}),
Opt::PadGlyphs => Text::literal(match s.controller.glyphs {
GlyphStyle::Auto => "Auto",
GlyphStyle::Xbox => "Xbox",
GlyphStyle::PlayStation => "PlayStation",
GlyphStyle::Nintendo => "Nintendo",
Opt::PadGlyphs => tr(match s.controller.glyphs {
GlyphStyle::Auto => "horizon.options.glyphs.auto",
GlyphStyle::Xbox => "horizon.options.glyphs.xbox",
GlyphStyle::PlayStation => "horizon.options.glyphs.playstation",
GlyphStyle::Nintendo => "horizon.options.glyphs.nintendo",
}),
Opt::PadMenuNav => Text::literal(match s.controller.menu_nav {
gamepad::MenuNav::Focus => "Focus",
gamepad::MenuNav::Pointer => "Pointer",
Opt::PadMenuNav => tr(match s.controller.menu_nav {
gamepad::MenuNav::Focus => "horizon.options.menu_focus",
gamepad::MenuNav::Pointer => "horizon.options.menu_pointer",
}),
Opt::PadKeyboard => Text::literal(match s.controller.keyboard {
gamepad::TextKeyboard::Auto => "Auto",
gamepad::TextKeyboard::Horizon => "Horizon",
gamepad::TextKeyboard::Steam => "Steam",
Opt::PadKeyboard => tr(match s.controller.keyboard {
gamepad::TextKeyboard::Auto => "horizon.options.keyboard.auto",
gamepad::TextKeyboard::Horizon => "horizon.options.keyboard.horizon",
gamepad::TextKeyboard::Steam => "horizon.options.keyboard.steam",
}),
_ => Text::empty(),
}
@@ -674,6 +731,7 @@ impl Opt {
Opt::PadLookSensitivity => s.controller.look_sensitivity = v,
Opt::PadMoveDeadzone => s.controller.move_deadzone = step(0.0, 50.0) / 100.0,
Opt::PadLookDeadzone => s.controller.look_deadzone = step(0.0, 50.0) / 100.0,
Opt::SprintWindow => s.sprint_window = step(0.0, 10.0) as i32,
_ => {}
}
if self.affects_preset() {
@@ -770,7 +828,14 @@ impl Opt {
Opt::ChunkBuilder => s.prioritize_chunk_updates = next(s.prioritize_chunk_updates, 3),
Opt::ToggleCrouch => s.toggle_crouch = !s.toggle_crouch,
Opt::ToggleSprint => s.toggle_sprint = !s.toggle_sprint,
Opt::ToggleAttack => s.toggle_attack = !s.toggle_attack,
Opt::ToggleUse => s.toggle_use = !s.toggle_use,
Opt::AutoJump => s.auto_jump = !s.auto_jump,
Opt::QuitShortcuts => s.quit_shortcuts = !s.quit_shortcuts,
Opt::CtrlClickEmulatesRightClick => {
s.ctrl_click_emulates_right_click = !s.ctrl_click_emulates_right_click
}
Opt::OperatorItemsTab => s.operator_items_tab = !s.operator_items_tab,
Opt::ChatVisibility => s.chat_visibility = next(s.chat_visibility, 3),
Opt::ChatColors => s.chat_colors = !s.chat_colors,
Opt::ChatLinks => s.chat_links = !s.chat_links,
@@ -1178,22 +1243,14 @@ fn option_widget(s: &Settings, opt: Opt, x: f32, y: f32, w: f32) -> Widget {
/// What a button opening one of Horizon's pages says on hover.
fn horizon_page_tooltip(page: Page) -> Option<&'static str> {
Some(match page {
Page::Horizon => "Horizon's own settings: graphics, controllers, extras and mods.",
Page::Graphics => "Graphics, simply: each setting says what it costs.",
Page::GraphicsAdvanced => {
"Every graphics setting, Horizon's included, with what each costs."
}
Page::Controller => "Controllers: their buttons, sticks, vibration and menu navigation.",
Page::Extras => {
"Optional additions, all off by default: the game stays vanilla until you turn one on."
}
Page::Agent => "Let your own AI agent play: horizon-ctl and MCP clients on this computer.",
Page::ShaderPacks => {
"Shader packs from the shaderpacks folder: Horizon's own and those in the shaders/ format. One runs at a time."
}
Page::Mods => {
"Mods made for Horizon, from the horizon-mods folder. Each runs in a sandbox and stays off until you turn it on."
}
Page::Horizon => "horizon.pages.settings.tooltip",
Page::Graphics => "horizon.pages.graphics.tooltip",
Page::GraphicsAdvanced => "horizon.pages.graphics_advanced.tooltip",
Page::Controller => "horizon.pages.controller.tooltip",
Page::Extras => "horizon.pages.extras.tooltip",
Page::Agent => "horizon.pages.agent.tooltip",
Page::ShaderPacks => "horizon.pages.shader_packs.tooltip",
Page::Mods => "horizon.pages.mods.tooltip",
_ => return None,
})
}
@@ -1322,16 +1379,16 @@ impl OptionsUi {
Page::Language => "options.language.title",
Page::Font => "options.font.title",
Page::Packs => "resourcePack.title",
Page::Mods => "Mods",
Page::Mods => "horizon.pages.mods",
Page::NativeMod(_) => "Mod Settings",
Page::ShaderPacks => "Shader Packs",
Page::ShaderPacks => "horizon.pages.shader_packs",
Page::Graphics | Page::GraphicsAdvanced => "options.videoTitle",
Page::Controller => "Controller Settings",
Page::PadBinds => "Controller Binds",
Page::Extras => "Horizon Extras",
Page::Controller => "horizon.pages.controller",
Page::PadBinds => "horizon.pages.controller_binds",
Page::Extras => "horizon.pages.extras",
Page::Credits => "credits_and_attribution.screen.title",
Page::Horizon => "Horizon Settings",
Page::Agent => "AI Agent",
Page::Horizon => "horizon.pages.settings",
Page::Agent => "horizon.pages.agent",
}
}
fn scrollbar_x(&self, width: f32) -> f32 {
@@ -1469,21 +1526,23 @@ impl OptionsUi {
Page::Horizon => {
// Two to a row, as vanilla's OptionsList.addSmall lays out
// buttons; each opens a page built from vanilla's widgets.
let open = |label: &str, page: Page| {
button(0.0, 0.0, 150.0, Text::literal(label), Act::Push(page))
};
let open =
|key: &str, page: Page| button(0.0, 0.0, 150.0, tr(key), Act::Push(page));
vec![
Row::Buttons(
open("Graphics...", Page::Graphics),
Some(open("Advanced Graphics...", Page::GraphicsAdvanced)),
open("horizon.pages.open.graphics", Page::Graphics),
Some(open(
"horizon.pages.open.graphics_advanced",
Page::GraphicsAdvanced,
)),
),
Row::Buttons(
open("Shader Packs...", Page::ShaderPacks),
Some(open("Controller Settings...", Page::Controller)),
open("horizon.pages.open.shader_packs", Page::ShaderPacks),
Some(open("horizon.pages.open.controller", Page::Controller)),
),
Row::Buttons(
open("Extras...", Page::Extras),
Some(open("Mods...", Page::Mods)),
open("horizon.pages.open.extras", Page::Extras),
Some(open("horizon.pages.open.mods", Page::Mods)),
),
// No AI hides the AI Agent page and everything AI.
Row::Buttons(
@@ -1491,7 +1550,7 @@ impl OptionsUi {
Some(if s.extras.no_ai {
self.extra_button(s, crate::extras::Item::NoAi)
} else {
open("AI Agent...", Page::Agent)
open("horizon.pages.open.agent", Page::Agent)
}),
),
]
@@ -1510,7 +1569,7 @@ impl OptionsUi {
0.0,
0.0,
150.0,
Text::literal("Copy Connection Details"),
tr("horizon.agent.copy_details"),
Act::CopyAgentDetails,
)),
)];
@@ -1524,14 +1583,14 @@ impl OptionsUi {
0.0,
0.0,
150.0,
Text::literal("Horizon Graphics..."),
tr("horizon.pages.open.horizon_graphics"),
Act::Push(Page::Graphics),
),
Some(button(
0.0,
0.0,
150.0,
Text::literal("Advanced Graphics..."),
tr("horizon.pages.open.graphics_advanced"),
Act::Push(Page::GraphicsAdvanced),
)),
)];
@@ -1615,18 +1674,15 @@ impl OptionsUi {
Act::Push(Page::KeyBinds),
)),
)];
rows.extend(pairs(&[
Opt::ToggleCrouch,
Opt::ToggleSprint,
Opt::AutoJump,
]));
let controls = vanilla_control_options();
rows.extend(pairs(&controls));
// Horizon's controller page, after vanilla's rows.
rows.push(Row::Buttons(
button(
0.0,
0.0,
310.0,
Text::literal("Controller Settings..."),
tr("horizon.pages.open.controller"),
Act::Push(Page::Controller),
),
None,
@@ -1639,7 +1695,7 @@ impl OptionsUi {
0.0,
0.0,
310.0,
Text::literal("Controller Binds..."),
tr("horizon.pages.open.controller_binds"),
Act::Push(Page::PadBinds),
),
None,
@@ -1697,9 +1753,7 @@ impl OptionsUi {
Page::Font => pairs(&[Opt::ForceUnicode, Opt::JapaneseGlyphs]),
Page::Mods => {
if s.mods.installed.is_empty() {
return vec![Row::Header(
"No mods: put .hmod files in the horizon-mods folder",
)];
return vec![Row::Header("horizon.mods.empty")];
}
s.mods
.installed
@@ -1709,9 +1763,9 @@ impl OptionsUi {
let mut label = Text::literal(format!("{}: ", installed.title))
.append(on_off(s.mods.is_on(i)));
if installed.problem.is_some() {
label = label.append(" (Stopped)");
label = label.append(tr("horizon.mods.stopped"));
} else if installed.dev {
label = label.append(" (Dev)");
label = label.append(tr("horizon.mods.dev"));
}
let dead = installed.dev || installed.id.is_empty();
// A running mod with a settings page (capability
@@ -1722,7 +1776,7 @@ impl OptionsUi {
0.0,
0.0,
150.0,
Text::literal("Settings"),
tr("horizon.mods.settings"),
Act::ModSettings(i),
);
Row::Buttons(
@@ -1739,17 +1793,17 @@ impl OptionsUi {
Page::NativeMod(i) => self.native_rows(s, i),
Page::ShaderPacks => {
if s.shader_packs.is_empty() {
return vec![Row::Header(
"No shader packs: put them in the shaderpacks folder",
)];
return vec![Row::Header("horizon.shader_packs.empty")];
}
s.shader_packs
.iter()
.enumerate()
.map(|(i, pack)| {
let kind = match pack.kind {
crate::shader_pack_list::Kind::Horizon => " (Horizon)",
crate::shader_pack_list::Kind::Classic => "",
crate::shader_pack_list::Kind::Horizon => {
lang::get("horizon.shader_packs.kind_horizon")
}
crate::shader_pack_list::Kind::Classic => String::new(),
};
let label = Text::literal(format!("{}{kind}: ", pack.title))
.append(on_off(s.horizon.shader_pack == pack.file));
@@ -1809,8 +1863,8 @@ impl OptionsUi {
// Vanilla's Telemetry Data and Credits slots: Horizon's
// settings and its mods, as a mod adds its button here.
// Credits & Attribution (WinScreen's credits) follows them.
("Horizon Settings...", Some(Page::Horizon)),
("Mods...", Some(Page::Mods)),
("horizon.pages.open.horizon_settings", Some(Page::Horizon)),
("horizon.pages.open.mods", Some(Page::Mods)),
("options.credits_and_attribution", Some(Page::Credits)),
];
let rows = entries.len().div_ceil(2) as f32;
@@ -2243,18 +2297,12 @@ impl OptionsUi {
content = y + self.scroll - top + 2.0;
out[list_start..].iter_mut().for_each(|w| w.list = true);
let folder = match page {
Page::Mods => Some(("Open Mod Folder", Act::OpenModFolder)),
Page::ShaderPacks => Some(("Open Pack Folder", Act::OpenShaderPackFolder)),
Page::Mods => Some(("horizon.mods.open_folder", Act::OpenModFolder)),
Page::ShaderPacks => Some(("pack.openFolder", Act::OpenShaderPackFolder)),
_ => None,
};
if let Some((label, act)) = folder {
out.push(button(
cx - 154.0,
h - 27.0,
150.0,
Text::literal(label),
act,
));
out.push(button(cx - 154.0, h - 27.0, 150.0, tr(label), act));
out.push(button(cx + 4.0, h - 27.0, 150.0, tr("gui.done"), Act::Done));
} else {
out.push(done(h - 27.0));
@@ -2367,7 +2415,7 @@ impl OptionsUi {
} = &wd.kind
&& let Some(text) = horizon_page_tooltip(*page)
{
tooltip = Some(atlas.font.split(&Text::literal(text), 170.0));
tooltip = Some(atlas.font.split(&tr(text), 170.0));
}
let key = match &wd.kind {
WKind::Button {
@@ -2394,10 +2442,7 @@ impl OptionsUi {
} = &wd.kind
&& !opt.available()
{
tooltip = Some(atlas.font.split(
&Text::literal("This feature is not yet available in Horizon."),
170.0,
));
tooltip = Some(atlas.font.split(&tr("horizon.options.unavailable"), 170.0));
}
if let WKind::Button {
act: Act::ToggleMod(i),
@@ -2538,9 +2583,7 @@ impl OptionsUi {
if search.value.is_empty() && !search.focused {
batch.text_styled(
atlas,
&Text::literal("Search settings...")
.with_color(text::GRAY)
.italic(),
&tr("horizon.options.search").with_color(text::GRAY).italic(),
search.text_x(),
search.text_y(),
u32::MAX,
@@ -3223,6 +3266,44 @@ mod tests {
)));
}
#[test]
fn controls_match_java_26_3_option_order() {
let mut ui = OptionsUi::default();
ui.show(Page::Controls);
let options =
ui.list_rows(&Settings::default())
.into_iter()
.fold(Vec::new(), |mut options, row| {
match row {
Row::Big(opt) => options.push(opt),
Row::Pair(a, b) => {
options.push(a);
options.extend(b);
}
_ => {}
}
options
});
assert_eq!(options, vanilla_control_options());
}
#[test]
fn sprint_window_and_new_control_switches_round_trip() {
let mut settings = Settings::default();
set_slider(Opt::SprintWindow, &mut settings, 0.5);
settings.operator_items_tab = true;
settings.quit_shortcuts = false;
settings.ctrl_click_emulates_right_click = true;
settings.toggle_attack = true;
settings.toggle_use = true;
let loaded = Settings::parse(&settings.serialize());
assert_eq!(loaded.sprint_window, 5);
assert!(loaded.operator_items_tab);
assert!(!loaded.quit_shortcuts);
assert!(loaded.ctrl_click_emulates_right_click);
assert!(loaded.toggle_attack && loaded.toggle_use);
}
#[test]
fn render_distance_slider_round_trips_through_extended_options_range() {
let mut s = Settings::default();
@@ -3314,8 +3395,15 @@ mod tests {
ui.show(Page::Mods);
let empty = ui.layout(&s, size.0, size.1).0;
assert!(empty.iter().any(|w| {
matches!(&w.kind, WKind::Text { text, .. } if text.plain().contains(".hmod"))
matches!(&w.kind, WKind::Text { text, .. } if text.plain() == "horizon.mods.empty")
}));
// The empty page points at the bundled key; the English line behind it
// is what a player reads, so both halves are checked here.
let en = crate::lang::Language::load(&[], "en_us");
assert!(
en.get("horizon.mods.empty")
.is_some_and(|line| line.contains(".hmod"))
);
let open_folder = empty
.iter()
.find(|w| {
@@ -174,6 +174,12 @@ pub struct Settings {
pub toggle_use: bool,
/// Taps of the sprint key counted as double taps (sprintWindow).
pub sprint_window: i32,
/// Show the operator items tab in a creative inventory.
pub operator_items_tab: bool,
/// Allow platform quit shortcuts to close the game window.
pub quit_shortcuts: bool,
/// On macOS, turn Control-click into a right click.
pub ctrl_click_emulates_right_click: bool,
/// Horizon's own graphics settings (`horizon-graphics.txt`, not options.txt).
pub horizon: crate::graphics::Graphics,
/// Controller settings (`horizon-controller.txt`, Horizon's own).
@@ -256,6 +262,9 @@ impl Default for Settings {
toggle_attack: false,
toggle_use: false,
sprint_window: 7,
operator_items_tab: false,
quit_shortcuts: true,
ctrl_click_emulates_right_click: false,
horizon: Default::default(),
controller: Default::default(),
extras: Default::default(),
@@ -418,6 +427,9 @@ impl Settings {
toggle_attack: f.bool("toggleAttack"),
toggle_use: f.bool("toggleUse"),
sprint_window: f.int("sprintWindow"),
operator_items_tab: f.bool("operatorItemsTab"),
quit_shortcuts: f.bool("quitShortcuts"),
ctrl_click_emulates_right_click: f.bool("ctrlClickEmulatesRightClick"),
horizon: Default::default(),
controller: Default::default(),
extras: Default::default(),
@@ -518,6 +530,13 @@ impl Settings {
put_b(&mut f, "toggleAttack", self.toggle_attack);
put_b(&mut f, "toggleUse", self.toggle_use);
put_i(&mut f, "sprintWindow", self.sprint_window);
put_b(&mut f, "operatorItemsTab", self.operator_items_tab);
put_b(&mut f, "quitShortcuts", self.quit_shortcuts);
put_b(
&mut f,
"ctrlClickEmulatesRightClick",
self.ctrl_click_emulates_right_click,
);
put_b(&mut f, "autoJump", self.auto_jump);
put_s(&mut f, "lang", &self.language);
put_b(&mut f, "forceUnicodeFont", self.force_unicode);
@@ -664,7 +683,13 @@ pub enum Opt {
MenuBlur,
ToggleCrouch,
ToggleSprint,
ToggleAttack,
ToggleUse,
AutoJump,
SprintWindow,
QuitShortcuts,
CtrlClickEmulatesRightClick,
OperatorItemsTab,
ChatVisibility,
ChatColors,
ChatLinks,
+88 -9
View File
@@ -404,8 +404,14 @@ impl GenerationScan {
wanted.push((input.ranges.priority(x, z, input.loading), x, z));
}
}
// Forced chunks and their generation neighbourhood.
let reach = margin - 1;
// Forced chunks and their generation neighbourhood: the ring that
// is decorated (or finished, without decoration) and the one its
// 3x3 needs.
let reach = if input.decorates {
FORCED_TERRAIN_RING
} else {
FORCED_FEATURES_RING
};
for &(fx, fz) in input.ranges.forced {
for dz in -reach..=reach {
for dx in -reach..=reach {
@@ -1047,10 +1053,10 @@ impl Streaming {
&& i64::from(x - center.0).pow(2) + i64::from(z - center.1).pow(2) > r4 * r4
&& !ranges.in_extra(x, z, 4)
&& !ranges.in_band(x, z, 4)
&& !ranges
.forced
.iter()
.any(|f| (x - f.0).abs() <= 3 && (z - f.1).abs() <= 3)
&& !ranges.forced.iter().any(|f| {
(x - f.0).abs() <= FORCED_TERRAIN_RING
&& (z - f.1).abs() <= FORCED_TERRAIN_RING
})
})
.map(|(k, _)| *k)
.collect();
@@ -1570,6 +1576,14 @@ pub fn in_view_distance(center: (i32, i32), radius: i32, x: i32, z: i32) -> bool
let (dx, dz) = (gap(x - center.0), gap(z - center.1));
dx * dx + dz * dz < i64::from(radius) * i64::from(radius)
}
/// The rings around a `/forceload`ed chunk, in chunks. Vanilla's FORCED
/// ticket is level 31 (`ChunkMap.FORCED_TICKET_LEVEL`), the level rises one
/// per ring (`ChunkTracker.computeLevelFromNeighbor`), and the level picks
/// the column's status (`ChunkLevel.generationStatus`): full (33) two rings
/// out, features (34) at three, terrain (35) at four.
const FORCED_FULL_RING: i32 = 2;
const FORCED_FEATURES_RING: i32 = 3;
const FORCED_TERRAIN_RING: i32 = 4;
/// The areas kept loaded: circles around the player and `extra`, and forced chunks.
struct Ranges<'a> {
center: (i32, i32),
@@ -1825,7 +1839,16 @@ impl Ranges<'_> {
if dx * dx + dz * dz <= r * r {
return true;
}
let reach = (margin - 1).max(0);
// A forced chunk holds a FORCED ticket at level 31
// (`ChunkMap.FORCED_TICKET_LEVEL`), and the level rises one per ring
// (`ChunkTracker.computeLevelFromNeighbor`): the margin is the
// vanilla status ring the caller asks for.
let reach = match margin {
1 => FORCED_FULL_RING,
2 => FORCED_FEATURES_RING,
3 => FORCED_TERRAIN_RING,
_ => margin + 1,
};
self.forced
.iter()
.any(|f| (x - f.0).abs() <= reach && (z - f.1).abs() <= reach)
@@ -2063,9 +2086,14 @@ mod tests {
}
}
}
let forced_reach = if input.decorates {
FORCED_TERRAIN_RING
} else {
FORCED_FEATURES_RING
};
for &(fx, fz) in input.ranges.forced {
for dz in -(margin - 1)..=(margin - 1) {
for dx in -(margin - 1)..=(margin - 1) {
for dz in -forced_reach..=forced_reach {
for dx in -forced_reach..=forced_reach {
let (x, z) = (fx + dx, fz + dz);
if missing(x, z) {
wanted.push((input.ranges.distance(x, z), x, z));
@@ -2467,6 +2495,57 @@ mod tests {
}
}
}
/// A `/forceload`ed chunk holds a FORCED ticket at level 31
/// (`ChunkMap.FORCED_TICKET_LEVEL`): the level rises one per ring
/// (`ChunkTracker.computeLevelFromNeighbor`) and picks the column's
/// status (`ChunkLevel.generationStatus`). So vanilla finishes the two
/// rings around it (33), gives the third its features (34) and the
/// fourth its terrain (35), and the rings past that are left alone.
#[test]
fn forced_chunks_reach_vanillas_rings() {
let forced: HashSet<(i32, i32)> = [(40, -40)].into();
let r = Ranges {
// The player's own area, far away.
center: (-400, 300),
radius: 2,
extra: &[],
forced: &forced,
cover: None,
band: None,
};
let level = |x: i32, z: i32| 31 + (x - 40).abs().max((z + 40).abs());
for x in 40 - 6..=40 + 6 {
for z in -40 - 6..=-40 + 6 {
// The full ring: the column becomes a level chunk.
assert_eq!(r.in_range(x, z, 1), level(x, z) <= 33, "({x}, {z})");
// The features ring, and the terrain ring.
assert_eq!(r.in_range(x, z, 2), level(x, z) <= 34, "({x}, {z})");
assert_eq!(r.in_range(x, z, 3), level(x, z) <= 35, "({x}, {z})");
assert_eq!(r.requested(x, z, true), level(x, z) <= 35, "({x}, {z})");
assert_eq!(r.chunk_kept(x, z, true), level(x, z) <= 35, "({x}, {z})");
assert_eq!(r.unloaded(x, z, true), level(x, z) > 35, "({x}, {z})");
}
}
// Generation reaches the terrain ring: the features ring is
// decorated from it.
let mut fixture = GenerationFixture::new(2, true);
fixture.center = (-400, 300);
fixture.forced = forced;
let candidates = GenerationScan::default()
.candidates(fixture.input(), usize::MAX)
.expect("a scan");
for (dx, dz) in [(-4, 0), (4, 0), (0, -4), (0, 4), (3, 3)] {
assert!(
candidates
.iter()
.any(|&(_, x, z)| (x, z) == (40 + dx, -40 + dz)),
"the terrain ring at ({}, {}) is not generated",
40 + dx,
-40 + dz
);
}
}
}
#[cfg(test)]
@@ -11,5 +11,57 @@
"horizon.options.look_deadzone": "Look Deadzone",
"horizon.options.button_glyphs": "Button Glyphs",
"horizon.options.menu_navigation": "Menu Navigation",
"horizon.options.on_screen_keyboard": "On-Screen Keyboard"
"horizon.options.on_screen_keyboard": "On-Screen Keyboard",
"horizon.options.chat_delay": "Chat Delay",
"horizon.options.lightbar.health": "Health",
"horizon.options.lightbar.dimension": "Dimension",
"horizon.options.glyphs.auto": "Auto",
"horizon.options.glyphs.xbox": "Xbox",
"horizon.options.glyphs.playstation": "PlayStation",
"horizon.options.glyphs.nintendo": "Nintendo",
"horizon.options.menu_focus": "Focus",
"horizon.options.menu_pointer": "Pointer",
"horizon.options.keyboard.auto": "Auto",
"horizon.options.keyboard.horizon": "Horizon",
"horizon.options.keyboard.steam": "Steam",
"horizon.options.unavailable": "This feature is not yet available in Horizon.",
"horizon.options.search": "Search settings...",
"horizon.pages.mods": "Mods",
"horizon.pages.shader_packs": "Shader Packs",
"horizon.pages.controller": "Controller Settings",
"horizon.pages.controller_binds": "Controller Binds",
"horizon.pages.extras": "Horizon Extras",
"horizon.pages.settings": "Horizon Settings",
"horizon.pages.agent": "AI Agent",
"horizon.pages.settings.tooltip": "Horizon's own settings: graphics, controllers, extras and mods.",
"horizon.pages.graphics.tooltip": "Graphics, simply: each setting says what it costs.",
"horizon.pages.graphics_advanced.tooltip": "Every graphics setting, Horizon's included, with what each costs.",
"horizon.pages.controller.tooltip": "Controllers: their buttons, sticks, vibration and menu navigation.",
"horizon.pages.extras.tooltip": "Optional additions, all off by default: the game stays vanilla until you turn one on.",
"horizon.pages.agent.tooltip": "Let your own AI agent play: horizon-ctl and MCP clients on this computer.",
"horizon.pages.shader_packs.tooltip": "Shader packs from the shaderpacks folder: Horizon's own and those in the shaders/ format. One runs at a time.",
"horizon.pages.mods.tooltip": "Mods made for Horizon, from the horizon-mods folder. Each runs in a sandbox and stays off until you turn it on.",
"horizon.pages.open.graphics": "Graphics...",
"horizon.pages.open.graphics_advanced": "Advanced Graphics...",
"horizon.pages.open.shader_packs": "Shader Packs...",
"horizon.pages.open.controller": "Controller Settings...",
"horizon.pages.open.extras": "Extras...",
"horizon.pages.open.mods": "Mods...",
"horizon.pages.open.agent": "AI Agent...",
"horizon.pages.open.horizon_graphics": "Horizon Graphics...",
"horizon.pages.open.controller_binds": "Controller Binds...",
"horizon.pages.open.horizon_settings": "Horizon Settings...",
"horizon.mods.empty": "No mods: put .hmod files in the horizon-mods folder",
"horizon.mods.stopped": " (Stopped)",
"horizon.mods.dev": " (Dev)",
"horizon.mods.settings": "Settings",
"horizon.mods.open_folder": "Open Mod Folder",
"horizon.shader_packs.empty": "No shader packs: put them in the shaderpacks folder",
"horizon.shader_packs.kind_horizon": " (Horizon)",
"horizon.agent.copy_details": "Copy Connection Details",
"horizon.multiplayer.join": "Join",
"horizon.multiplayer.cancel_reconnecting": "Cancel Reconnecting",
"horizon.multiplayer.join_friend": "Join a Friend",
"horizon.multiplayer.invite_code": "Invite Code",
"horizon.multiplayer.relay": "Relay (optional)"
}
@@ -11,5 +11,57 @@
"horizon.options.look_deadzone": "Мёртвая зона обзора",
"horizon.options.button_glyphs": "Символы кнопок",
"horizon.options.menu_navigation": "Навигация по меню",
"horizon.options.on_screen_keyboard": "Экранная клавиатура"
"horizon.options.on_screen_keyboard": "Экранная клавиатура",
"horizon.options.chat_delay": "Задержка чата",
"horizon.options.lightbar.health": "Здоровье",
"horizon.options.lightbar.dimension": "Измерение",
"horizon.options.glyphs.auto": "Авто",
"horizon.options.glyphs.xbox": "Xbox",
"horizon.options.glyphs.playstation": "PlayStation",
"horizon.options.glyphs.nintendo": "Nintendo",
"horizon.options.menu_focus": "Фокус",
"horizon.options.menu_pointer": "Указатель",
"horizon.options.keyboard.auto": "Авто",
"horizon.options.keyboard.horizon": "Horizon",
"horizon.options.keyboard.steam": "Steam",
"horizon.options.unavailable": "Эта возможность в Horizon пока недоступна.",
"horizon.options.search": "Поиск настроек...",
"horizon.pages.mods": "Моды",
"horizon.pages.shader_packs": "Шейдерпаки",
"horizon.pages.controller": "Настройки контроллера",
"horizon.pages.controller_binds": "Назначения кнопок контроллера",
"horizon.pages.extras": "Дополнения Horizon",
"horizon.pages.settings": "Настройки Horizon",
"horizon.pages.agent": "ИИ-агент",
"horizon.pages.settings.tooltip": "Собственные настройки Horizon: графика, контроллеры, дополнения и моды.",
"horizon.pages.graphics.tooltip": "Графика без лишнего: у каждой настройки написано, чего она стоит.",
"horizon.pages.graphics_advanced.tooltip": "Все графические настройки, включая собственные настройки Horizon, и их цена.",
"horizon.pages.controller.tooltip": "Контроллеры: кнопки, стики, вибрация и навигация по меню.",
"horizon.pages.extras.tooltip": "Дополнительные возможности, все выключены по умолчанию: игра остаётся ванильной, пока вы не включите что-то.",
"horizon.pages.agent.tooltip": "Пусть играет ваш ИИ-агент: horizon-ctl и MCP-клиенты на этом компьютере.",
"horizon.pages.shader_packs.tooltip": "Шейдерпаки из папки shaderpacks: собственные паки Horizon и паки в формате shaders/. Одновременно работает один.",
"horizon.pages.mods.tooltip": "Моды для Horizon из папки horizon-mods. Каждый работает в песочнице и выключен, пока вы его не включите.",
"horizon.pages.open.graphics": "Графика...",
"horizon.pages.open.graphics_advanced": "Продвинутая графика...",
"horizon.pages.open.shader_packs": "Шейдерпаки...",
"horizon.pages.open.controller": "Настройки контроллера...",
"horizon.pages.open.extras": "Дополнения...",
"horizon.pages.open.mods": "Моды...",
"horizon.pages.open.agent": "ИИ-агент...",
"horizon.pages.open.horizon_graphics": "Графика Horizon...",
"horizon.pages.open.controller_binds": "Назначения кнопок...",
"horizon.pages.open.horizon_settings": "Настройки Horizon...",
"horizon.mods.empty": "Модов нет: положите файлы .hmod в папку horizon-mods",
"horizon.mods.stopped": " (остановлен)",
"horizon.mods.dev": " (разработка)",
"horizon.mods.settings": "Настройки",
"horizon.mods.open_folder": "Открыть папку модов",
"horizon.shader_packs.empty": "Шейдерпаков нет: положите их в папку shaderpacks",
"horizon.shader_packs.kind_horizon": " (Horizon)",
"horizon.agent.copy_details": "Скопировать данные подключения",
"horizon.multiplayer.join": "Подключиться",
"horizon.multiplayer.cancel_reconnecting": "Отменить переподключение",
"horizon.multiplayer.join_friend": "Играть с другом",
"horizon.multiplayer.invite_code": "Код приглашения",
"horizon.multiplayer.relay": "Ретранслятор (необязательно)"
}
@@ -150,7 +150,7 @@ fn entity_state(mob: &mut Mob, species: Species, case: &Value) {
mob.extra.crouching = flag("isCrouching");
mob.extra.passenger = flag("isPassenger");
if flag("isHoldingBow") {
mob.extra.main_hand = Some("minecraft:bow".into());
mob.extra.set_main_hand(Some("minecraft:bow".into()));
}
}
Species::IronGolem => {
@@ -298,7 +298,7 @@ fn entity_state(mob: &mut Mob, species: Species, case: &Value) {
}
}
Species::Witch if flag("isHoldingItem") => {
mob.extra.main_hand = Some("minecraft:potion".into());
mob.extra.set_main_hand(Some("minecraft:potion".into()));
}
_ => {}
}
@@ -386,7 +386,7 @@ pub fn main() -> horizon_client::Result<()> {
.cloned()
.unwrap_or_else(|| kind.model.clone());
let mut mob = Mob::new(7, kind, Vec3::new(0.0, 64.0, 0.0), 0.0);
mob.extra.main_hand = None;
mob.extra.set_main_hand(None);
let case = &v["case"];
let f = |k: &str| case[k].as_f64().unwrap_or(0.0) as f32;
if baby {
@@ -134,7 +134,7 @@ pub fn main() -> horizon_client::Result<()> {
let item: Option<Arc<str>> = Some(Arc::from(s.item.as_str()));
let index = match *slot {
"mainhand" => {
m.extra.main_hand = item;
m.extra.set_main_hand(item);
Some(gear::MAIN)
}
"offhand" => {
+159
View File
@@ -0,0 +1,159 @@
//! End gateway travel for the entities that step into one:
//! `EndGatewayBlock.entityInside` (the gateway takes the entity in and starts
//! its 40 tick cooldown), `Entity.setAsInsidePortal` and `Entity.handlePortal`
//! (the entity goes through on the next tick), and
//! `TheEndGatewayBlockEntity.getPortalPosition` (where it lands).
use crate::nether_portal::{ENTITY_DELAY, PortalState};
use horizon_core::{geometry::Vec3, position::BlockPos};
/// The End gateway travel of one entity.
#[derive(Clone, Copy, Debug, Default)]
pub struct Traveller {
/// The entity's portal process and cooldown (`Entity.portalProcess`).
state: PortalState,
}
impl Traveller {
/// `Entity.setAsInsidePortal`: the entity is in the gateway and goes
/// through it on the next tick.
pub fn enter(&mut self, gateway: BlockPos) {
self.state.set_inside(gateway, ENTITY_DELAY);
}
/// `Entity.handlePortal`: the entity goes through the gateway it entered
/// (the End gateway has no transition time), or stays where it is.
pub fn travel(&mut self, allowed: bool) -> Option<BlockPos> {
self.state.handle(allowed, 0, ENTITY_DELAY)
}
/// Whether the entity's travel is over: it has no portal process left and
/// its portal cooldown has run out.
pub fn is_done(&self) -> bool {
self.state.cooldown == 0 && self.state.process.is_none()
}
/// The entity's portal cooldown, the ticks it stays out of a portal.
pub fn cooldown(&self) -> i32 {
self.state.cooldown
}
}
/// The End gateway block an entity's box swept through
/// (`Entity.checkInsideBlocks`, which calls `EndGatewayBlock.entityInside`).
pub fn swept_gateway(
from: Vec3,
to: Vec3,
width: f32,
height: f32,
mut is_gateway: impl FnMut(BlockPos) -> bool,
) -> Option<BlockPos> {
let half = f64::from(width) / 2.0;
let bounds = horizon_core::geometry::Aabb::new(
Vec3::new(to.x - half, to.y, to.z - half),
Vec3::new(to.x + half, to.y + f64::from(height), to.z + half),
)
.deflate(Vec3::new(1.0e-5, 1.0e-5, 1.0e-5));
let mut found = None;
let _ =
horizon_core::geometry::sweep_intersected_blocks(from, to, bounds, 1_000_000, |p, _| {
if is_gateway(p) {
found = Some(p);
return false;
}
true
});
found
}
/// The bottom centre of a block (`Vec3.atBottomCenterOf`): where a traveller
/// lands at a gateway's exit.
pub fn bottom_center(p: [i32; 3]) -> Vec3 {
Vec3::new(
f64::from(p[0]) + 0.5,
f64::from(p[1]),
f64::from(p[2]) + 0.5,
)
}
#[cfg(test)]
mod tests {
use super::*;
/// The gateway of the issue: a block at [702, 100, 0] in the End.
const GATEWAY: [i32; 3] = [702, 100, 0];
fn at(x: f64, y: f64, z: f64) -> Vec3 {
Vec3::new(x, y, z)
}
fn is_gateway(p: BlockPos) -> bool {
[p.x, p.y, p.z] == GATEWAY
}
#[test]
fn an_item_and_a_mob_in_a_gateway_are_found_in_it() {
// A dropped item, 0.25 wide, standing in the gateway block.
let item = at(702.5, 100.5, 0.5);
assert_eq!(
swept_gateway(item, item, 0.25, 0.25, is_gateway),
Some(BlockPos::new(702, 100, 0))
);
// A cow, 0.9 wide and 1.4 tall, in the same block.
let cow = at(702.5, 100.0, 0.5);
assert_eq!(
swept_gateway(cow, cow, 0.9, 1.4, is_gateway),
Some(BlockPos::new(702, 100, 0))
);
}
#[test]
fn an_entity_out_of_the_gateway_is_not_in_it() {
// Two blocks above the gateway: nothing to travel through.
let above = at(702.5, 102.5, 0.5);
assert_eq!(swept_gateway(above, above, 0.25, 0.25, is_gateway), None);
// Beside the gateway, outside its block.
let beside = at(704.5, 100.5, 0.5);
assert_eq!(swept_gateway(beside, beside, 0.9, 1.4, is_gateway), None);
}
#[test]
fn an_entity_falling_through_the_gateway_goes_through_it() {
// A cow falling past the gateway block passes through it
// (Entity.checkInsideBlocks visits the blocks along the way).
let from = at(702.5, 103.0, 0.5);
let to = at(702.5, 99.0, 0.5);
assert_eq!(
swept_gateway(from, to, 0.9, 1.4, is_gateway),
Some(BlockPos::new(702, 100, 0))
);
}
#[test]
fn an_entity_goes_through_the_gateway_on_the_tick_after_it_entered() {
let gateway = BlockPos::new(702, 100, 0);
let mut traveller = Traveller::default();
// Nothing happens while the entity is outside the gateway.
assert_eq!(traveller.travel(true), None);
// It enters the gateway, and goes through on the next tick.
traveller.enter(gateway);
assert_eq!(traveller.travel(true), Some(gateway));
// Its portal cooldown keeps it from going back at once.
assert_eq!(traveller.cooldown(), ENTITY_DELAY);
assert!(!traveller.is_done());
}
#[test]
fn an_entity_that_may_not_use_portals_stays_in_the_gateway() {
let gateway = BlockPos::new(702, 100, 0);
let mut traveller = Traveller::default();
traveller.enter(gateway);
// A mob that cannot use a portal does not go through.
assert_eq!(traveller.travel(false), None);
}
#[test]
fn a_traveller_lands_at_the_bottom_centre_of_the_exit() {
// TheEndGatewayBlockEntity.getPortalPosition: the exit's bottom
// centre, the same spot the player arrives at.
assert_eq!(bottom_center([8, 100, 0]), at(8.5, 100.0, 0.5));
}
}
+67 -9
View File
@@ -759,6 +759,17 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
.into_iter()
.enumerate()
{
if slot == "mainhand"
&& let Some(stack) = m.extra.main_hand_stack()
{
let mut stack = stack.clone();
if let Some(g) = crate::mobs::gear::gear(m) {
stack.data_mut().enchantments = g.slots[index].clone();
stack.data_mut().trim = g.trims[index].clone();
}
equipment.insert(slot.into(), crate::save::item_to_nbt(&stack));
continue;
}
// Enchanted or trimmed gear is saved with its components.
if let (Some(id), Some(g)) = (item, crate::mobs::gear::gear(m))
&& (!g.slots[index].is_empty() || g.trims[index].is_some())
@@ -782,10 +793,11 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
}
match item {
Some(id) => {
let keep = matches!(
equipment.get(&slot.into()).and_then(|e| e.get("id")),
Some(Tag::String(s)) if s.to_string_lossy() == **id
);
let keep = slot != "mainhand"
&& matches!(
equipment.get(&slot.into()).and_then(|e| e.get("id")),
Some(Tag::String(s)) if s.to_string_lossy() == **id
);
if slot == "head"
&& &**id == crate::mobs::raider::BANNER
&& crate::mobs::raider::is_raider(m.species())
@@ -989,10 +1001,8 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
m.hurt_time = number(tag.get("HurtTime")).unwrap_or(0) as i32;
// What it holds (equipment.mainhand / offhand), when saved.
if let Some(eq) = tag.get("equipment") {
m.extra.main_hand = eq
.get("mainhand")
.and_then(|i| text(i.get("id")))
.map(Into::into);
m.extra
.set_main_hand_stack(eq.get("mainhand").and_then(crate::save::item_from_nbt));
m.extra.off_hand = eq
.get("offhand")
.and_then(|i| text(i.get("id")))
@@ -1019,7 +1029,7 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
} else {
// Registration hands out default weapons (finalizeSpawn does in
// vanilla); a loaded mob holds only what its file lists.
m.extra.main_hand = None;
m.extra.set_main_hand(None);
}
m.death_time = number(tag.get("DeathTime")).unwrap_or(0) as i32;
// Mob.readAdditionalSaveData overrides what the constructor set (copper
@@ -3133,3 +3143,51 @@ mod neutral_anger_tests {
assert_eq!(crate::mobs::anger_left(3000, 4000), 0);
}
}
#[cfg(test)]
mod held_item_name_tests {
use super::*;
#[test]
fn replacing_a_named_item_does_not_restore_its_old_name_on_save() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client")
.join(horizon_core::VANILLA_VERSION);
if !root.join("items.json").exists() {
return;
}
let data = crate::mobs::MobTypes::load(&root).unwrap();
let types: HashMap<_, _> = data
.types
.into_iter()
.map(|(id, kind)| (id, Arc::new(kind)))
.collect();
let mut mob = Mob::new(1, Arc::clone(&types["minecraft:zombie"]), Vec3::ZERO, 0.0);
let mut sword = crate::items::ItemStack::new("minecraft:diamond_sword", 1);
sword.data_mut().custom_name = Some("Old Sword".into());
mob.extra.set_main_hand_stack(Some(sword));
let raw = mob_to_nbt(&mob, [1, 2, 3, 4], None);
let mut loaded = mob_from_nbt(&raw, &types, 2).unwrap();
assert_eq!(
loaded.extra.main_hand_stack().unwrap().custom_name(),
Some("Old Sword")
);
loaded
.extra
.set_main_hand(Some("minecraft:diamond_sword".into()));
let saved = mob_to_nbt(&loaded, [1, 2, 3, 4], Some(&raw));
let restored = mob_from_nbt(&saved, &types, 3).unwrap();
assert_eq!(
restored.extra.main_hand.as_deref(),
Some("minecraft:diamond_sword")
);
assert!(
restored
.extra
.main_hand_stack()
.unwrap()
.custom_name()
.is_none()
);
}
}
+58 -4
View File
@@ -1817,6 +1817,9 @@ struct Game<'a> {
cart_models: minecarts::CartModels,
/// The boat the player rides.
riding: Option<u32>,
/// The vehicle the player's file names, waiting for it to be in the world
/// (`ServerPlayer.loadAndSpawnParentVehicle`).
pending_vehicle: Option<[i32; 4]>,
/// The ender inventory (27 slots) and the ender chest showing it.
ender_items: Vec<Option<ItemStack>>,
ender_open: Option<(i32, i32, i32)>,
@@ -3078,6 +3081,10 @@ impl Game<'_> {
self.portals.sections.remove(&key);
self.block_ticks
.remove_chunk(horizon_core::position::ChunkPos::new(key.0, key.1));
// Its moving pistons were saved with it and load back with it.
self.block_ticks
.moving_pistons
.retain(|(p, _)| (p.x >> 4, p.z >> 4) != key);
}
self.tick_chunks.recycle_added(added);
}
@@ -3289,10 +3296,20 @@ impl Game<'_> {
.iter()
.map(|(p, piston)| ([p.x, p.y, p.z], piston.shape_state()))
.collect();
self.world.moving_piston_fallback = previous
let mut fallback: Vec<_> = previous
.into_iter()
.filter(|(pos, _)| !current.iter().any(|(next, _)| next == pos))
.collect();
// A finished piston stays drawn until the chunk mesh shows the block
// it placed, however many ticks that takes.
let older = std::mem::take(&mut self.world.moving_piston_fallback);
for (pos, piston) in older {
let replaced = current.iter().chain(&fallback).any(|(p, _)| *p == pos);
if !replaced && self.section_mesh_pending(pos) {
fallback.push((pos, piston));
}
}
self.world.moving_piston_fallback = fallback;
self.world.moving_pistons = current;
}
/// `ServerLevel.tick`'s scheduled block then fluid ticks, one at a time:
@@ -5309,6 +5326,7 @@ impl Game<'_> {
let riding = self.riding.is_some();
let entity_collisions = self.player_entity_collisions();
let spin_targets = self.spin_targets();
let sprint_window = self.settings.sprint_window;
self.sync_player_body();
let Some(player) = self.player.as_mut() else {
// A dedicated server has no local player: the world still runs.
@@ -5361,7 +5379,7 @@ impl Game<'_> {
collision_binding: self.collision,
resources: self.motion,
}
.controlled_simulation_tick(keys)?;
.controlled_simulation_tick_with_sprint_window(keys, sprint_window)?;
// Player.jumpFromGround: the jump statistic.
if grounded && keys.jump && !player.flying && player.velocity.y > 0.2 {
self.stats.custom("jump", 1);
@@ -5579,7 +5597,26 @@ impl Game<'_> {
// Inventory.dropAll: every stack thrown in a random direction.
let feet = [player.position.x, player.position.y, player.position.z];
let at = [feet[0], feet[1] + 1.32, feet[2]];
// ServerPlayer.die: tellNeutralMobsThatIDied — every NeutralMob
// in the dying player's block cell grown 32 sideways and 10 up
// and down stops being angry at the player, when the gamerule
// lets it and the mob's anger is at this player.
let forgiving = self
.ui
.rules
.bool("forgive_dead_players")
.then(|| horizon_client::mobs::ai::forgiveness_area(feet));
let killer = self.combat_killer_kind();
if let Some(area) = forgiving {
for m in &mut self.mobs {
if horizon_client::mobs::ai::is_neutral(m.species())
&& horizon_client::mobs::ai::angry_at_player(m)
&& area.intersects(m.bounds())
{
horizon_client::mobs::ai::stop_being_angry(m);
}
}
}
// Player.die: the dimension and block of the death.
self.level.last_death =
Some((self.dim.id().to_owned(), feet.map(|v| v.floor() as i32)));
@@ -5674,11 +5711,16 @@ impl Game<'_> {
});
}
if running {
horizon_client::spans::time("entity_portals", || self.handle_entity_portals());
horizon_client::spans::time("entity_portals", || {
self.handle_entity_portals();
self.handle_end_gateway_portals();
});
horizon_client::spans::time("tick_items", || self.tick_items());
horizon_client::spans::time("touch_arrows", || self.touch_arrows());
}
horizon_client::spans::time("spawn_loaded_entities", || self.spawn_loaded_entities());
// The ride the player's file names, once the vehicle is in the world.
self.restore_root_vehicle();
if running {
horizon_client::spans::time("tick_mobs", || self.tick_mobs())?;
horizon_client::spans::time("recheck_golem_openers", || self.recheck_golem_openers());
@@ -5697,6 +5739,11 @@ impl Game<'_> {
horizon_client::spans::time("tick_portal", || self.tick_nether_portal())?;
if running {
horizon_client::spans::time("tick_end", || self.tick_end())?;
horizon_client::spans::time("end_gateways", || {
self.find_end_gateway_portals();
self.resolve_end_gateway_arrivals()
})?;
self.keep_gateway_destinations();
}
if running {
horizon_client::spans::time("tick_falling", || self.tick_falling())?;
@@ -9348,6 +9395,8 @@ impl Game<'_> {
self.world = world;
self.inventory = inventory;
self.save = save;
// The ride the player's file names, once the vehicle is in the world.
self.pending_vehicle = level.root_vehicle;
self.level = level;
// ServerRecipeBook.loadUntrusted: recipes the manager does not have are dropped.
let index = &self.res.stations().recipe_index;
@@ -9356,7 +9405,8 @@ impl Game<'_> {
book.to_be_displayed.retain(|r| index.contains(r));
self.dim = dim;
self.world_root = save_root.clone();
self.world_flat = horizon_client::save::WorldGen::read(&save_root).is_some_and(|w| w.flat);
self.world_flat = horizon_client::save::WorldGen::read(&save_root)
.is_some_and(|w| w.preset == horizon_client::save::WorldPreset::Flat);
self.block_ticks = Ticks::new(self.level.seed as i32);
self.tick_chunks.clear();
self.clear_entities();
@@ -11718,6 +11768,8 @@ impl Game<'_> {
self.level.game_rules = self.ui.rules.by_name();
self.level.selected_slot = self.selected as i32;
let mode = self.mode_id();
// The vehicle the player rides (`ServerPlayer.saveParentVehicle`).
let vehicle = self.root_vehicle_uuid(self.riding);
let level = &mut self.level;
level.creative = self.creative;
level.game_mode = mode;
@@ -11734,6 +11786,7 @@ impl Game<'_> {
level.xp_level = self.experience.level;
level.xp_progress = self.experience.progress;
level.xp_total = self.experience.total;
level.root_vehicle = vehicle.or(self.pending_vehicle);
if let Some(p) = &self.player {
level.position = Some([p.position.x, p.position.y, p.position.z]);
level.health = p.health.health();
@@ -14393,6 +14446,7 @@ impl<'a> Game<'a> {
trim_slots: Vec::new(),
cart_models: prepared.cart_models,
riding: None,
pending_vehicle: None,
ender_items: vec![None; 27],
ender_open: None,
pending: Default::default(),
+2 -2
View File
@@ -566,7 +566,7 @@ impl Game<'_> {
"hardcore": options.hardcore,
"difficulty": horizon_client::title::DIFFICULTIES[options.difficulty()],
"allow_commands": options.commands(*creative),
"superflat": options.flat,
"world_type": options.world_type.id(),
"generate_structures": options.structures,
"bonus_chest": options.bonus_chest(),
}),
@@ -579,7 +579,7 @@ impl Game<'_> {
} => json!({
"mode": "customize-flat",
"fields": { "name": name, "seed": seed },
"superflat": options.flat,
"superflat": options.world_type == horizon_client::save::WorldPreset::Flat,
"biome": flat.settings().biome,
"layers": flat.settings().layers.iter().map(|layer| json!({
"block": layer.block,
+1 -1
View File
@@ -219,7 +219,7 @@ impl Game<'_> {
let holds = m.extra.main_hand.is_some();
if !holds && !item.is_empty() {
let m = &mut self.mobs[index];
m.extra.main_hand = Some(item.into());
m.extra.set_main_hand(Some(item.into()));
m.extra.tame = true;
// `Allay.mobInteract` remembers LIKED_PLAYER by UUID.
m.extra.owner = Some(acting);
+20 -2
View File
@@ -304,6 +304,16 @@ impl Game<'_> {
None
}
}
/// The display component of a custom-named held stack.
fn mob_main_hand_name(&self, id: u32) -> Option<Text> {
let m = self.mobs.iter().find(|m| m.id == id)?;
let mut stack = m.extra.main_hand_stack()?.clone();
stack.custom_name()?;
if let Some(g) = horizon_client::mobs::gear::gear(m) {
stack.data_mut().enchantments = g.slots[horizon_client::mobs::gear::MAIN].clone();
}
Some(self.stack_display_name(&stack))
}
/// `CombatTracker.getDeathMessage`.
pub(super) fn combat_death_message(&mut self) -> Text {
self.combat_death_message_for(Text::literal(PLAYER_NAME))
@@ -357,8 +367,16 @@ impl Game<'_> {
}
// `getLocalizedDeathMessage`: the causing entity's name, or the
// direct entity's when nothing caused the damage.
if let Some((_, name)) = killer {
return Text::translate(&key, &[me, name]);
if let Some((id, name)) = killer {
// DamageSource.getLocalizedDeathMessage: the cause's item with a
// custom name gets the `<id>.item` message.
let (key, args) = horizon_client::scoreboard::death_message_args(
&key,
&me,
&name,
self.mob_main_hand_name(id).as_ref(),
);
return Text::translate(&key, &args);
}
if let Some(name) = direct {
return Text::translate(&key, &[me, name]);
@@ -235,9 +235,9 @@ impl Game<'_> {
let Some(m) = self.mobs.iter_mut().find(|m| m.id == *id) else {
return false;
};
let item = stack.map(|s| s.item);
let item = stack.as_ref().map(|s| s.item.clone());
match *key {
"mainhand" => m.extra.main_hand = item,
"mainhand" => m.extra.set_main_hand_stack(stack),
"offhand" => m.extra.off_hand = item,
"head" | "body" => m.extra.head = item,
"chest" => m.extra.chest = item,
+26 -26
View File
@@ -391,15 +391,14 @@ impl Lists {
}
}
/// The world presets of 26.3's `worldgen/world_preset`.
const WORLD_PRESETS: [&str; 7] = [
"minecraft:normal",
"minecraft:flat",
"minecraft:large_biomes",
"minecraft:amplified",
"minecraft:single_biome_surface",
"minecraft:debug_all_block_states",
"minecraft:flat_all_dimensions",
/// DedicatedServerProperties.LEGACY_PRESET_NAMES: the old `level-type`
/// names, before the presets had their own ids.
const LEGACY_PRESET_NAMES: [(&str, horizon_client::save::WorldPreset); 2] = [
("default", horizon_client::save::WorldPreset::Normal),
(
"largebiomes",
horizon_client::save::WorldPreset::LargeBiomes,
),
];
/// Identifier.tryParse: `namespace:path`, or a path in `minecraft`.
@@ -416,11 +415,18 @@ impl Game<'_> {
/// DedicatedServerProperties.createDimensions for a world that doesn't
/// exist yet: `level-type`'s preset, a flat world's `generator-settings`
/// and `generate-structures`, written before the world opens so that
/// streaming reads them. Horizon generates the normal and flat presets.
/// streaming reads them.
fn write_world_gen(&self, dir: &std::path::Path, props: &ServerProperties) -> Result<()> {
let level_type = &props.level_type;
let preset = parse_identifier(level_type)
.filter(|id| WORLD_PRESETS.contains(&id.as_str()))
.as_deref()
.and_then(horizon_client::save::WorldPreset::of_id)
.or_else(|| {
LEGACY_PRESET_NAMES
.iter()
.find(|(name, _)| *name == level_type)
.map(|(_, preset)| *preset)
})
.unwrap_or_else(|| {
log_from(
"Worker-Main-1",
@@ -429,18 +435,9 @@ impl Game<'_> {
"Failed to parse level-type {level_type}, defaulting to minecraft:normal"
),
);
"minecraft:normal".to_owned()
horizon_client::save::WorldPreset::Normal
});
let flat = preset == "minecraft:flat";
if !flat && preset != "minecraft:normal" {
log_from(
"Worker-Main-1",
"WARN",
&format!(
"Horizon doesn't generate the {preset} world preset yet: using minecraft:normal"
),
);
}
let flat = preset == horizon_client::save::WorldPreset::Flat;
let settings = if flat {
let biomes = self.res.biomes();
let (settings, errors) = horizon_client::flatgen::settings_from_json(
@@ -456,7 +453,7 @@ impl Game<'_> {
None
};
let options = horizon_client::save::WorldGen {
flat,
preset,
structures: props.generate_structures,
bonus_chest: false,
};
@@ -1457,10 +1454,13 @@ impl Game<'_> {
fn stop_dedicated(&mut self) -> Result<()> {
log("INFO", "Stopping server");
log("INFO", "Saving players");
if let Some(h) = self.lan.as_mut() {
for g in &h.hub.guests {
horizon_client::lan::playerdata::save(g, &self.world_root);
if let Some(host) = self.lan.take() {
for g in &host.hub.guests {
// The vehicle the guest rides, for the file that names it.
let vehicle = self.root_vehicle_uuid(g.vehicle);
horizon_client::lan::playerdata::save(g, &self.world_root, vehicle);
}
self.lan = Some(host);
}
if let Some(h) = self.lan.as_ref() {
for g in &h.hub.guests {
+1 -1
View File
@@ -296,7 +296,7 @@ pub(super) fn mob_set_slot(m: &mut Mob, slot: Slot, stack: ItemStack) {
let item: Arc<str> = stack.item.clone();
let x = &mut m.extra;
match slot {
Slot::MainHand => x.main_hand = Some(item),
Slot::MainHand => x.set_main_hand(Some(item)),
Slot::OffHand => x.off_hand = Some(item),
Slot::Feet => x.feet = Some(item),
Slot::Legs => x.legs = Some(item),
+234 -7
View File
@@ -3,6 +3,7 @@
//! sky. The dragon fight itself lives in game/dragon.rs: the podium is
//! inactive until the dragon dies.
use super::{Dim, Game};
use horizon_client::nether_portal::ENTITY_DELAY;
use horizon_client::{
Result,
containers::{BlockEntity, EndGateway},
@@ -16,10 +17,18 @@ use horizon_client::{
};
use horizon_core::{
geometry::Vec3,
position::BlockPos,
random::{LegacyRandom, RandomSource},
};
use horizon_world::nbt::Tag;
use std::{collections::HashMap, sync::Arc};
/// A list of doubles, as an NBT list of them.
fn doubles(values: [f64; 3]) -> Tag {
Tag::List(values.iter().map(|v| Tag::Double(v.to_bits())).collect())
}
/// ServerLevel.END_SPAWN_POINT.
pub(super) const END_SPAWN: [i32; 3] = [100, 50, 0];
/// EnderDragonFight gateway ring radius and height.
@@ -38,6 +47,18 @@ pub(super) enum GatewayJob {
Known { exit: [i32; 3], exact: bool },
}
/// An entity travelling through an End gateway: its saved compound and the
/// job that finds where it lands (`TheEndGatewayBlockEntity.getPortalPosition`).
#[derive(Clone, Debug)]
struct GatewayArrival {
/// The entity's compound, as it waits for the exit's chunks.
tag: Tag,
/// The gateway's exit, or the search for one.
job: GatewayJob,
/// The spot to load before the job is resolved.
target: Vec3,
}
impl GatewayJob {
/// TheEndGatewayBlockEntity.getPortalPosition, with legacy save fallbacks.
fn for_gateway(
@@ -94,6 +115,11 @@ pub(super) struct EndState {
job: Option<GatewayJob>,
/// Ticks until the player can use a gateway again (TheEndGatewayBlockEntity cooldown).
gateway_cooldown: u32,
gateways: HashMap<u32, horizon_client::end_gateway::Traveller>,
/// The entities that went through a gateway, waiting for its exit.
arrivals: Vec<GatewayArrival>,
/// The chunks kept loaded for those arrivals.
areas: Vec<(i32, i32)>,
/// The Overworld generator, for stronghold positions (ServerLevel.findNearestMapStructure).
locator: std::sync::Mutex<Option<Arc<VanillaTerrain>>>,
/// Thrown eyes of ender in flight.
@@ -947,6 +973,17 @@ impl Game<'_> {
self.save_gateway_exits()
}
/// The exit a gateway already leads to: its block entity, or the side file
/// that kept it for a gateway whose chunk is not loaded.
fn known_gateway_exit(&mut self, gateway: [i32; 3]) -> Option<([i32; 3], bool)> {
let key = (gateway[0], gateway[1], gateway[2]);
let saved = match self.world.block_entities.get(&key) {
Some(BlockEntity::EndGateway(g)) => g.exit_portal.map(|exit| (exit, g.exact_teleport)),
_ => None,
};
saved.or_else(|| self.gateway_exits().get(&gateway).copied())
}
/// EndGatewayBlock.entityInside and TheEndGatewayBlockEntity.getPortalPosition:
/// travel within the End, waiting for the destination chunks to load.
fn enter_gateway(&mut self, gateway: [i32; 3]) -> Result<()> {
@@ -1043,13 +1080,7 @@ impl Game<'_> {
/// The teleport position of a gateway job, creating the exit gateway (and
/// an island when the chunk has no landing spot) for new destinations.
pub(super) fn resolve_gateway(&mut self, job: GatewayJob, target: Vec3) -> Result<Vec3> {
let bottom_center = |p: [i32; 3]| {
Vec3::new(
f64::from(p[0]) + 0.5,
f64::from(p[1]),
f64::from(p[2]) + 0.5,
)
};
let bottom_center = horizon_client::end_gateway::bottom_center;
let find_exit = |game: &Self, exit: [i32; 3]| {
let p = game.tallest_block([exit[0], exit[1] + 2, exit[2]], 5, false);
[p[0], p[1] + 1, p[2]]
@@ -1059,6 +1090,14 @@ impl Game<'_> {
find_exit(self, e)
}))),
GatewayJob::Find { gateway, exact } => {
// The player and the entities may resolve the same gateway: the
// first one to arrive finds the exit, the others use it.
let known = self.known_gateway_exit(gateway);
if let Some((exit, exact)) = known {
return Ok(bottom_center(exit_target(exit, exact, |e| {
find_exit(self, e)
})));
}
// findExitPortalXZPosTentative over the loaded chunks.
let dir = glam::DVec2::new(f64::from(gateway[0]), f64::from(gateway[2]))
.normalize_or_zero();
@@ -1420,6 +1459,194 @@ impl Game<'_> {
}
}
}
// ---- End gateway travel for the entities ----
/// `EndGatewayBlock.entityInside` for the items and mobs in the End: the
/// gateway blocks their box swept through. A gateway that is cooling down
/// (`TheEndGatewayBlockEntity.triggerCooldown`) takes nobody in.
pub(super) fn find_end_gateway_portals(&mut self) {
if self.remote.is_some() || self.dim != Dim::End {
return;
}
let items: Vec<(u32, Vec3, f32, f32)> = self
.dropped
.iter()
.map(|e| (e.id, e.position, 0.25, 0.25))
.collect();
let mobs: Vec<(u32, Vec3, f32, f32)> = self
.mobs
.iter()
.filter(|m| self.mob_can_use_portal(m))
.map(|m| (m.id, m.position, m.width(), m.height()))
.collect();
let mut inside: Vec<(u32, BlockPos)> = Vec::new();
for (id, to, width, height) in items.into_iter().chain(mobs) {
let from = self.portals.before.get(&id).copied().unwrap_or(to);
let found = horizon_client::end_gateway::swept_gateway(from, to, width, height, |p| {
&*self.res.facts().get(self.world.block(p.x, p.y, p.z)).name
== "minecraft:end_gateway"
});
if let Some(pos) = found {
inside.push((id, pos));
}
}
for (id, pos) in inside {
// One entity at a time: the gateway cools down for 40 ticks.
if !self.gateway_enter([pos.x, pos.y, pos.z]) {
continue;
}
// An entity that just came through any portal stays out of this one.
if self.restart_portal_cooldown(id) {
continue;
}
self.end.gateways.entry(id).or_default().enter(pos);
}
}
/// `Entity.handlePortal` for the entities in an End gateway: they go
/// through to the gateway's exit, where they wait for its chunks.
pub(super) fn handle_end_gateway_portals(&mut self) {
if self.remote.is_some() || self.dim != Dim::End {
return;
}
let ids: Vec<(u32, bool)> = self
.dropped
.iter()
.map(|e| (e.id, true))
.chain(self.mobs.iter().map(|m| (m.id, self.mob_can_use_portal(m))))
.collect();
let present: std::collections::HashSet<u32> = ids.iter().map(|(id, _)| *id).collect();
self.end.gateways.retain(|id, _| present.contains(id));
let mut leaving: Vec<(u32, BlockPos)> = Vec::new();
for (id, allowed) in ids {
let Some(state) = self.end.gateways.get_mut(&id) else {
continue;
};
// The End gateway has no transition time (Portal.getPortalTransitionTime).
if let Some(entry) = state.travel(allowed) {
leaving.push((id, entry));
}
if state.is_done() {
self.end.gateways.remove(&id);
}
}
for (id, entry) in leaving {
self.send_through_gateway(id, entry);
}
}
/// `Entity.teleportToPortalDestination` for an entity that goes through an
/// End gateway: it is saved and waits for the exit's chunks.
fn send_through_gateway(&mut self, id: u32, gateway: BlockPos) {
let tag = if let Some(i) = self.dropped.iter().position(|e| e.id == id) {
let uuid = self.uuid_for(id);
let tag = horizon_client::entity_nbt::item_entity_to_nbt(
&self.dropped[i],
uuid,
self.persist_raw(id),
);
self.dropped.remove(i);
self.forget_entity(id);
tag
} else if let Some(i) = self.mobs.iter().position(|m| m.id == id) {
let mut tag = self.mob_tree_nbt(i);
// Entity.removeAfterChangingDimensions drops the leash.
if let Tag::Compound(c) = &mut tag {
c.remove(&"leash".into());
}
// The mobs riding it travel with it (their tags are in its compound).
let mut gone = vec![id];
let mut k = 0;
while k < gone.len() {
let v = gone[k];
gone.extend(
self.mobs
.iter()
.filter(|m| m.extra.mount == Some(v))
.map(|m| m.id),
);
k += 1;
}
self.mobs.retain(|m| !gone.contains(&m.id));
for g in gone {
self.forget_entity(g);
}
tag
} else {
return;
};
let (job, target) = self.gateway_job([gateway.x, gateway.y, gateway.z]);
self.end.arrivals.push(GatewayArrival { tag, job, target });
}
/// The entities that reached a gateway's exit: they land where the
/// gateway leads, once its chunks have loaded.
pub(super) fn resolve_end_gateway_arrivals(&mut self) -> Result<()> {
if self.remote.is_some() || self.dim != Dim::End {
return Ok(());
}
let mut i = 0;
while i < self.end.arrivals.len() {
let (cx, cz) = (
(self.end.arrivals[i].target.x.floor() as i32) >> 4,
(self.end.arrivals[i].target.z.floor() as i32) >> 4,
);
if !self.world.chunks.contains_key(&(cx, cz)) {
i += 1;
continue;
}
let (job, target) = (self.end.arrivals[i].job, self.end.arrivals[i].target);
let at = match self.resolve_gateway(job, target) {
Ok(at) => at,
Err(e) => {
// The traveller waits for the exit: the failure is retried.
log::warn!("End gateway: {e}");
i += 1;
continue;
}
};
let mut tag = self.end.arrivals.remove(i).tag;
if let Tag::Compound(c) = &mut tag {
c.insert("Pos".into(), doubles([at.x, at.y, at.z]));
c.insert("Motion".into(), doubles([0.0, 0.0, 0.0]));
// setPortalCooldown before the teleport.
c.insert("PortalCooldown".into(), Tag::Int(ENTITY_DELAY));
}
let key = ((at.x.floor() as i32) >> 4, (at.z.floor() as i32) >> 4);
match self
.world
.loaded_entities
.iter_mut()
.find(|(k, _)| *k == key)
{
Some((_, tags)) => tags.push(tag),
None => self.world.loaded_entities.push((key, vec![tag])),
}
}
Ok(())
}
/// The chunks the entities waiting to arrive need, kept loaded like the
/// areas a server keeps around its players.
pub(super) fn keep_gateway_destinations(&mut self) {
let areas = &self.end.areas;
self.streaming
.extra
.retain(|&(c, r)| !(r == 1 && areas.contains(&c)));
let mut areas = Vec::new();
for a in &self.end.arrivals {
let c = (
(a.target.x.floor() as i32) >> 4,
(a.target.z.floor() as i32) >> 4,
);
if !areas.contains(&c) {
areas.push(c);
self.streaming.extra.push((c, 1));
}
}
self.end.areas = areas;
}
}
#[cfg(test)]
+66 -13
View File
@@ -178,7 +178,8 @@ pub(super) fn baby_index(kind: &str) -> Option<u8> {
| "minecraft:axolotl"
| "minecraft:dolphin"
| "minecraft:squid"
| "minecraft:glow_squid" => Some(16),
| "minecraft:glow_squid"
| "minecraft:sulfur_cube" => Some(16),
_ => None,
}
}
@@ -603,10 +604,11 @@ fn mob_meta(
}
}
// AbstractCubeMob.ID_SIZE.
"minecraft:slime" | "minecraft:magma_cube" => {
"minecraft:slime" | "minecraft:magma_cube" | "minecraft:sulfur_cube" => {
if e.size > 1 {
meta.push((18, serializer::INT, Value::Int(e.size)));
}
defaults.push((18, serializer::INT, Value::Int(1)));
}
// Creeper.DATA_SWELL_DIR, DATA_IS_POWERED and DATA_IS_IGNITED: the
// guest swells the creeper from them. The direction always goes in,
@@ -1245,15 +1247,53 @@ impl Game<'_> {
/// Save a guest when they leave or the world closes: an integrated
/// server's owner with the world (`save_game`), anyone else in their file.
pub(super) fn save_guest(&mut self, host: &LanHost, guest: &horizon_client::lan::guest::Guest) {
// The vehicle the guest rides, for the file that names it.
let vehicle = self.root_vehicle_uuid(guest.vehicle);
if host.owner.as_ref().is_some_and(|o| o.uuid == guest.uuid) {
if guest.placed {
playerdata::store(guest, &mut self.level);
playerdata::store(guest, &mut self.level, vehicle);
}
} else {
playerdata::save(guest, &self.world_root);
playerdata::save(guest, &self.world_root, vehicle);
}
self.save_guest_records(host, guest);
}
/// `ServerPlayer.loadAndSpawnParentVehicle`: a guest whose file names the
/// vehicle it rode rides it again, once the vehicle is in the world.
fn restore_guest_vehicles(&mut self, host: &mut LanHost) {
let waiting: Vec<(usize, [i32; 4])> = host
.hub
.guests
.iter()
.enumerate()
.filter_map(|(i, g)| Some((i, g.saved_vehicle?)))
.filter(|(i, _)| {
let g = &host.hub.guests[*i];
let chunk = (
(g.position[0].floor() as i32).div_euclid(16),
(g.position[2].floor() as i32).div_euclid(16),
);
g.placed
&& g.vehicle.is_none()
&& g.dimension == self.dim.id()
&& g.sent_chunks.contains(&chunk)
})
.collect();
for (i, uuid) in waiting {
let raw = host.hub.guests[i].saved.clone();
let Some(id) = self.restore_saved_vehicle(uuid, raw.as_ref()) else {
continue;
};
let g = &mut host.hub.guests[i];
if let Some(horizon_world::nbt::Tag::Compound(p)) = &mut g.saved {
p.remove(&"RootVehicle".into());
}
g.vehicle = Some(id);
g.saved_vehicle = None;
// Pair the stationary restored entities before their passenger packet.
g.moved = true;
}
}
/// The file name of a player's advancements and statistics: an
/// integrated server's owner keeps single player's.
fn records_uuid(&self, host: &LanHost, uuid: u128) -> Option<[i32; 4]> {
@@ -1324,14 +1364,28 @@ impl Game<'_> {
}
/// The integrated server's owner into the world's player, for `Level::write`.
pub(super) fn store_owner(&mut self) {
let Some(uuid) = self
.lan
.as_ref()
.and_then(|h| h.owner.as_ref())
.map(|o| o.uuid)
else {
return;
};
let riding = self.lan.as_ref().and_then(|h| {
h.hub
.guests
.iter()
.find(|g| g.uuid == uuid && g.placed)
.and_then(|g| g.vehicle)
});
// The vehicle the owner rides, by the UUID it is saved with.
let vehicle = self.root_vehicle_uuid(riding);
let Some(host) = &self.lan else {
return;
};
let Some(uuid) = host.owner.as_ref().map(|o| o.uuid) else {
return;
};
if let Some(g) = host.hub.guests.iter().find(|g| g.uuid == uuid && g.placed) {
playerdata::store(g, &mut self.level);
playerdata::store(g, &mut self.level, vehicle);
self.save_guest_records(host, g);
}
}
@@ -1514,6 +1568,8 @@ impl Game<'_> {
m.rider = None;
}
}
// A guest whose file names the vehicle it rode rides it again.
self.restore_guest_vehicles(&mut host);
// A dedicated server runs the hub without a host player.
if state.is_some() || host.hub.config.dedicated {
let has_guests = !host.hub.guests.is_empty();
@@ -2162,11 +2218,8 @@ impl Game<'_> {
.motion
.as_ref()
.map_or(0, |p| p.health.hurt_animation_time);
m.extra.main_hand = g
.inventory
.get(g.selected)
.and_then(|s| s.as_ref())
.map(|s| s.item.clone());
m.extra
.set_main_hand_stack(g.inventory.get(g.selected).cloned().flatten());
m.extra.off_hand = g.offhand.as_ref().map(|s| s.item.clone());
[m.extra.head, m.extra.chest, m.extra.legs, m.extra.feet] = g
.armor
@@ -675,9 +675,7 @@ impl Game<'_> {
let new_mode = self.mode_id();
self.mode = mode;
self.creative = creative;
self.selected = std::mem::replace(&mut g.selected, selected);
// `g.selected` now holds the host's value: swap the inventories back.
std::mem::swap(&mut self.selected, &mut g.selected);
g.selected = std::mem::replace(&mut self.selected, selected);
std::mem::swap(&mut self.inventory, &mut g.inventory);
std::mem::swap(&mut self.armor, &mut g.armor);
std::mem::swap(&mut self.offhand, &mut g.offhand);
+26 -8
View File
@@ -1240,7 +1240,7 @@ impl Game<'_> {
diffuse: bool,
out: &mut Vec<Vertex>,
) {
self.block_model_vertices_tinted(state, seed, m, light, diffuse, 0xFF_FFFF, out);
self.tinted_block_model_vertices(state, seed, m, |_| light, diffuse, |_| None, out);
}
/// `block_model_vertices` with `tint` (RGB) on the faces that have a tint index.
#[allow(clippy::too_many_arguments)]
@@ -1253,6 +1253,23 @@ impl Game<'_> {
diffuse: bool,
tint: u32,
out: &mut Vec<Vertex>,
) {
let tint = |index: i32| (index >= 0).then_some(tint);
self.tinted_block_model_vertices(state, seed, m, |_| light, diffuse, tint, out);
}
/// `block_model_vertices` with each face multiplied by `tint` of its
/// tint index (RGB, `None` for untinted faces) and lit by `light` of
/// each quad.
#[allow(clippy::too_many_arguments)]
pub(super) fn tinted_block_model_vertices(
&self,
state: BlockStateId,
seed: i64,
m: glam::Mat4,
light: impl Fn(&horizon_assets::bake::ModelQuad) -> u32,
diffuse: bool,
tint: impl Fn(i32) -> Option<u32>,
out: &mut Vec<Vertex>,
) {
let Ok(models) = self.pack.render.model_catalog.collect_seeded(state, seed) else {
return;
@@ -1274,17 +1291,17 @@ impl Game<'_> {
D::North | D::South => 204,
D::West | D::East => 153,
};
let color = if diffuse {
let mut color = if diffuse {
u32::MAX
} else {
0xFF00_0000 | shade << 16 | shade << 8 | shade
};
let color = if face.quad.tint_index >= 0 {
let tint = horizon_client::mobs::render::abgr(tint);
horizon_client::hud_layout::multiply(color, tint)
} else {
color
};
if let Some(t) = tint(face.quad.tint_index) {
color = horizon_client::hud_layout::multiply(
color,
horizon_client::mobs::render::abgr(t),
);
}
let normal = if diffuse {
use horizon_client::item_render::{direction_vector, transform_normal};
let n = transform_normal(m, direction_vector(face.quad.facing));
@@ -1293,6 +1310,7 @@ impl Game<'_> {
} else {
[0; 4]
};
let light = light(&face.quad);
let light = if diffuse {
let emission = u32::from(face.quad.light_emission) * 16;
(light & 0xFF) | (light >> 8 & 0xFF).max(emission) << 8
+2 -2
View File
@@ -182,13 +182,13 @@ impl Game<'_> {
0
};
if level > 0 {
m.extra.main_hand = Some("minecraft:crossbow".into());
m.extra.set_main_hand(Some("minecraft:crossbow".into()));
gear::set(m, MAIN, Vec::new());
gear::upgrade(m, MAIN, "minecraft:quick_charge", level);
}
}
RaiderType::Vindicator => {
m.extra.main_hand = Some("minecraft:iron_axe".into());
m.extra.set_main_hand(Some("minecraft:iron_axe".into()));
gear::set(m, MAIN, Vec::new());
if enchant {
let level = if wave > raid::num_groups(2) { 2 } else { 1 };
@@ -14,30 +14,58 @@ impl Game<'_> {
out: &mut Vec<Vertex>,
) {
let partial = alpha as f32;
let mut states = self.world.moving_pistons.clone();
states.extend(self.world.moving_piston_fallback.iter().copied());
for (p, piston) in states {
let live = self
.world
.moving_pistons
.iter()
.map(|&(p, s)| (p, s, false));
// A piston that just finished bridges the frames until the chunk mesh
// shows the block it placed; drawn over that mesh, it z-fights (#3002).
let finished = self
.world
.moving_piston_fallback
.iter()
.filter(|(p, _)| self.section_mesh_pending(*p))
.map(|&(p, s)| (p, s, true));
let states: Vec<_> = live.chain(finished).collect();
for (p, piston, placed) in states {
let Some((block, base)) = self.moving_piston_blocks(piston, partial) else {
continue;
};
let step = piston.movement_direction().normal();
let from = horizon_core::position::BlockPos::new(
p[0] - step[0],
p[1] - step[1],
p[2] - step[2],
);
let at = horizon_core::position::BlockPos::new(p[0], p[1], p[2]);
// `createMovingBlock`'s block position (seed, tint and light): the
// cell the block moves from. A finished one stands in for the
// chunk mesh's block at `p`, so it is drawn as that block.
let from = if placed {
at
} else {
horizon_core::position::BlockPos::new(
p[0] - step[0],
p[1] - step[1],
p[2] - step[2],
)
};
let offset = piston.interpolated_offset(partial);
let light = self.world.light(p[0], p[1], p[2]);
let packed = (u32::from(light >> 4) * 16) | ((u32::from(light & 15) * 16) << 8);
let corner = glam::DVec3::new(f64::from(p[0]), f64::from(p[1]), f64::from(p[2]));
let moved = corner + glam::Vec3::from_array(offset).as_dvec3();
self.moving_block_vertices(block, from, moved - camera, packed, out);
self.moving_block_vertices(block, from, moved - camera, out);
if let Some(base) = base {
let at = horizon_core::position::BlockPos::new(p[0], p[1], p[2]);
self.moving_block_vertices(base, at, corner - camera, packed, out);
self.moving_block_vertices(base, at, corner - camera, out);
}
}
}
/// Whether the chunk mesh doesn't show the blocks at `p` yet (edited,
/// meshing, or waiting for its upload).
pub(super) fn section_mesh_pending(&self, p: [i32; 3]) -> bool {
let Some(y) = p[1].checked_sub(horizon_client::chunk::MIN_Y) else {
return false;
};
let section = (p[0] >> 4, y.div_euclid(16), p[2] >> 4);
self.world.dirty_sections.contains(&section)
|| self.streaming.is_meshing(section)
|| self.pending_uploads.iter().any(|(key, _)| *key == section)
}
/// The moved block and, for a retracting piston, its extended base.
fn moving_piston_blocks(
&self,
@@ -81,32 +109,51 @@ impl Game<'_> {
Some((piston.moved_state, None))
}
/// One `MovingBlockRenderState`: the baked model at `at` (camera-relative),
/// seeded by its block position and tinted like a falling block.
/// seeded by its block position `seed`, tinted per face with the block
/// colours there and lit as `ModelBlockRenderer.tesselateFlat` lights a
/// lone block at `seed`: each face from the cell it looks at. Not from
/// the moving piston's own cell, which is dark once the block it placed
/// stands there (#3002).
fn moving_block_vertices(
&self,
state: BlockStateId,
seed: horizon_core::position::BlockPos,
at: glam::DVec3,
light: u32,
out: &mut Vec<Vertex>,
) {
let tint = self
.particle_sprite(state, [seed.x, seed.y, seed.z])
.map(|(_, tint)| tint);
let start = out.len();
self.block_model_vertices(
let chunks = &self.world.chunks;
let (cx, cz) = (seed.x >> 4, seed.z >> 4);
let region: [[Option<&horizon_client::chunk::Chunk>; 3]; 3] = std::array::from_fn(|dz| {
std::array::from_fn(|dx| {
chunks
.get(&(cx + dx as i32 - 1, cz + dz as i32 - 1))
.map(|c| &**c)
})
});
let tint = self.pack.context.tables.block_tints(
state,
[seed.x, seed.y, seed.z],
&region,
self.res.biomes(),
self.streaming.biome_zoom,
self.streaming.biome_blend,
);
let tables = &self.pack.context.tables;
let light = |quad: &horizon_assets::bake::ModelQuad| {
let coords = tables.flat_light(state, [seed.x, seed.y, seed.z], quad, |p| {
self.world.light(p[0], p[1], p[2])
});
// Light coords (block << 4 | sky << 20) as the vertex packs them.
(coords >> 16 & 0xF0) | ((coords & 0xF0) << 8)
};
self.tinted_block_model_vertices(
state,
horizon_core::random::position_seed(seed),
glam::Mat4::from_translation(at.as_vec3()),
light,
false,
tint,
out,
);
if let Some(tint) = tint {
let tint = horizon_client::mobs::render::abgr(tint);
for vertex in &mut out[start..] {
vertex.color = horizon_client::hud_layout::multiply(vertex.color, tint);
}
}
}
}
@@ -12,7 +12,7 @@
//! portal) until the player is in that dimension and its exit is loaded.
use super::{Dim, Game, Screen};
use horizon_client::Result;
use horizon_client::nether_portal::PortalState;
use horizon_client::nether_portal::{ENTITY_DELAY, PortalState};
use horizon_core::{
geometry::{Aabb, Axis, Vec3},
position::BlockPos,
@@ -29,8 +29,6 @@ use std::collections::HashMap;
/// `Player.getDimensionChangingDelay`.
pub(super) const PLAYER_DELAY: i32 = 10;
/// `Entity.getDimensionChangingDelay`.
const ENTITY_DELAY: i32 = 300;
/// Where in its portal a traveller stood, and where its exit is to be found.
#[derive(Clone, Copy, Debug)]
@@ -89,7 +87,7 @@ pub(super) struct Portals {
/// Items and mobs, by entity id.
entities: HashMap<u32, PortalState>,
/// Positions before this tick's moves, by entity id.
before: HashMap<u32, Vec3>,
pub(super) before: HashMap<u32, Vec3>,
arrivals: Vec<Arrival>,
/// The vehicles server players ride that went through a portal this
/// tick, in their level, with their entry: they carry their riders
@@ -673,6 +671,17 @@ impl Game<'_> {
);
}
}
/// `Entity.setAsInsidePortal` on an entity that is still on its portal
/// cooldown, whatever the portal: the cooldown restarts and it stays.
pub(super) fn restart_portal_cooldown(&mut self, id: u32) -> bool {
match self.portals.entities.get_mut(&id) {
Some(state) if state.cooldown > 0 => {
state.cooldown = ENTITY_DELAY;
true
}
_ => false,
}
}
/// `Entity.setPortalCooldown` on an entity that has just spawned in a
/// portal: it stands in the portal for 300 ticks without going through.
pub(super) fn set_portal_cooldown(&mut self, id: u32) {
@@ -686,7 +695,7 @@ impl Game<'_> {
}
/// `canUsePortal(false)` of a mob: alive, riding nothing, not asleep,
/// not carrying the player, and not a boss that never travels.
fn mob_can_use_portal(&self, m: &horizon_client::mobs::Mob) -> bool {
pub(super) fn mob_can_use_portal(&self, m: &horizon_client::mobs::Mob) -> bool {
use horizon_client::mobs::Species;
m.alive()
&& m.extra.mount.is_none()
+4 -3
View File
@@ -159,7 +159,7 @@ impl Game<'_> {
{
if let Some(zombie) = self.spawn_mob("minecraft:zombie", at, false) {
if let Some(z) = self.mobs.iter_mut().find(|m| m.id == zombie) {
z.extra.main_hand = Some("minecraft:iron_spear".into());
z.extra.set_main_hand(Some("minecraft:iron_spear".into()));
}
self.mount_mob(zombie, id);
}
@@ -189,7 +189,7 @@ impl Game<'_> {
return;
}
if let Some(h) = self.mobs.iter_mut().find(|m| m.id == id) {
h.extra.main_hand = Some("minecraft:iron_spear".into());
h.extra.set_main_hand(Some("minecraft:iron_spear".into()));
}
let Some(camel) = self.spawn_mob("minecraft:camel_husk", at, false) else {
return;
@@ -257,7 +257,8 @@ impl Game<'_> {
if let Some(piglin) = self.spawn_mob("minecraft:zombified_piglin", at, false) {
let baby = self.world.random.next_float() < CHICKEN_JOCKEY_ODDS;
if let Some(p) = self.mobs.iter_mut().find(|m| m.id == piglin) {
p.extra.main_hand = Some("minecraft:warped_fungus_on_a_stick".into());
p.extra
.set_main_hand(Some("minecraft:warped_fungus_on_a_stick".into()));
p.extra.age = if baby { i32::MIN / 2 } else { 0 };
}
if let Some(s) = self.mobs.iter_mut().find(|m| m.id == id) {
+177 -3
View File
@@ -9,8 +9,13 @@ use horizon_client::{
mobs::Who,
save::{ChunkExtras, random_uuid},
};
use horizon_core::{geometry::Vec3, position::ChunkPos, random::RandomSource, ticks::ChunkTicks};
use horizon_world::{chunk::BlockState, nbt::Tag, registry::BlockStateId};
use horizon_core::{
geometry::{Direction, Vec3},
position::{BlockPos, ChunkPos},
random::RandomSource,
ticks::ChunkTicks,
};
use horizon_world::{chunk::BlockState, level::MovingPiston, nbt::Tag, registry::BlockStateId};
use std::{
collections::{HashMap, HashSet},
sync::Arc,
@@ -66,6 +71,10 @@ fn painting_center(p: &horizon_client::entities::Painting) -> Vec3 {
(lo[2] + hi[2]) / 2.0,
)
}
fn block_entity_pos(tag: &Tag) -> [i32; 3] {
[tag.get("x"), tag.get("y"), tag.get("z")]
.map(|v| horizon_client::save::number(v).unwrap_or(0) as i32)
}
fn chunk_of(p: Vec3) -> (i32, i32) {
((p.x.floor() as i32) >> 4, (p.z.floor() as i32) >> 4)
}
@@ -86,6 +95,19 @@ impl Game<'_> {
extras.fluid_ticks = t.pack(time);
}
extras.last_update = time;
// PistonMovingBlockEntity.saveAdditional: a block caught mid-move is
// saved with its chunk (#3310).
for (p, piston) in &self.block_ticks.moving_pistons {
if (p.x >> 4, p.z >> 4) != (chunk.x, chunk.z) {
continue;
}
extras
.block_entities
.retain(|tag| block_entity_pos(tag) != [p.x, p.y, p.z]);
extras
.block_entities
.push(self.moving_piston_to_nbt(*p, piston));
}
// TheEndGatewayBlockEntity saves its Age (the server copy's).
for tag in &mut extras.block_entities {
let pos = [tag.get("x"), tag.get("y"), tag.get("z")]
@@ -142,6 +164,82 @@ impl Game<'_> {
s.fluid_ticks.clear();
}
}
// PistonMovingBlockEntity.loadAdditional: blocks saved mid-move carry
// on moving; later saves take them from the level (#3310).
let pistons: Vec<_> = saved.as_deref().map_or_else(Vec::new, |s| {
s.block_entities
.iter()
.filter_map(|tag| self.moving_piston_from_nbt(tag))
.collect()
});
if pistons.is_empty() {
return;
}
for &(pos, piston) in &pistons {
self.block_ticks.load_moving_piston(pos, piston);
}
if let Some(c) = self.world.chunks.get_mut(&key) {
let c = Arc::make_mut(c);
if let Some(s) = c.saved.as_mut() {
Arc::make_mut(s).block_entities.retain(|tag| {
let at = block_entity_pos(tag);
!pistons.iter().any(|(p, _)| [p.x, p.y, p.z] == at)
});
}
}
}
/// `PistonMovingBlockEntity.saveAdditional`.
fn moving_piston_to_nbt(&self, pos: BlockPos, piston: &MovingPiston) -> Tag {
let flag = |b: bool| Tag::Byte(i8::from(b));
Tag::compound([
("id", Tag::String("minecraft:piston".into())),
("x", Tag::Int(pos.x)),
("y", Tag::Int(pos.y)),
("z", Tag::Int(pos.z)),
("blockState", self.state_to_nbt(piston.moved_state)),
// Direction.LEGACY_ID_CODEC: the 3D data value as a byte.
("facing", Tag::Byte(piston.direction as i8)),
("progress", Tag::Float(piston.progress_o.to_bits())),
("extending", flag(piston.extending)),
("source", flag(piston.source)),
])
}
/// `PistonMovingBlockEntity.loadAdditional`, for a `minecraft:piston`
/// entity whose block is still a moving piston.
fn moving_piston_from_nbt(&self, tag: &Tag) -> Option<(BlockPos, MovingPiston)> {
use horizon_client::save::{float, number, text};
if text(tag.get("id")).as_deref() != Some("minecraft:piston") {
return None;
}
let [x, y, z] = block_entity_pos(tag);
let here = self.world.block(x, y, z);
if &*self.res.facts().get(here).name != "minecraft:moving_piston" {
return None;
}
let moved_state = tag
.get("blockState")
.and_then(|t| self.state_from_nbt(t))
.or_else(|| self.pack.assets.registry.default_state("minecraft:air"))?;
let direction = usize::try_from(number(tag.get("facing")).unwrap_or(0))
.ok()
.and_then(|i| Direction::ALL.get(i).copied())
.unwrap_or(Direction::Down);
let progress = float(tag.get("progress")).unwrap_or(0.0) as f32;
let flag = |k: &str| number(tag.get(k)).unwrap_or(0) != 0;
Some((
BlockPos::new(x, y, z),
MovingPiston {
moved_state,
direction,
extending: flag("extending"),
source: flag("source"),
progress,
progress_o: progress,
last_ticked: 0,
death_ticks: 0,
id: 0,
},
))
}
/// Spawn the entities of chunks that finished loading.
pub(super) fn spawn_loaded_entities(&mut self) {
@@ -151,6 +249,9 @@ impl Game<'_> {
for tag in tags {
let kind = entity_nbt::kind(&tag).unwrap_or_default();
let uuid = entity_nbt::uuid(&tag);
if uuid.is_some_and(|u| self.vehicle_by_uuid(u).is_some()) {
continue;
}
if matches!(
kind.as_str(),
"minecraft:experience_orb"
@@ -368,8 +469,11 @@ impl Game<'_> {
}
/// A saved mob and, under `Passengers`, the mobs riding it.
pub(super) fn load_mob(&mut self, tag: Tag, mount: Option<u32>) -> Option<u32> {
self.next_entity += 1;
let uuid = entity_nbt::uuid(&tag);
if let Some(id) = uuid.and_then(|u| self.vehicle_by_uuid(u)) {
return Some(id);
}
self.next_entity += 1;
// Structure mobs that generation left to finish: saves from before
// they were finalized at placement, or a placement that could not.
let mut tag = tag;
@@ -538,6 +642,76 @@ impl Game<'_> {
}
i
}
/// `RootVehicle.Attach` names the immediate mount, not the root of its tree.
pub(super) fn root_vehicle_uuid(&mut self, id: Option<u32>) -> Option<[i32; 4]> {
Some(self.uuid_for(id?))
}
/// The vehicle with this UUID, among the ones in the world.
pub(super) fn vehicle_by_uuid(&self, uuid: [i32; 4]) -> Option<u32> {
let id = *self
.persist
.uuids
.iter()
.find(|(_, u)| **u == uuid)
.map(|(id, _)| id)?;
let there = self.mobs.iter().any(|m| m.id == id && m.alive())
|| self.boats.iter().any(|b| b.id == id)
|| self.carts.iter().any(|c| c.id == id);
there.then_some(id)
}
/// Vanilla stores the root entity in the player's file, outside chunk entities.
/// Keep the raw wrapper until attachment succeeds, including unsupported trees.
pub(super) fn restore_saved_vehicle(
&mut self,
uuid: [i32; 4],
raw: Option<&Tag>,
) -> Option<u32> {
if let Some(id) = self.vehicle_by_uuid(uuid) {
return Some(id);
}
let tag = raw?.get("RootVehicle")?.get("Entity")?.clone();
let root_uuid = entity_nbt::uuid(&tag)?;
let kind = entity_nbt::kind(&tag)?;
if !self.res.mob_types().contains_key(&kind)
&& super::boats::any_boat_wood(&kind).is_none()
&& super::minecarts::CartKind::from_id(&kind).is_none()
{
return None;
}
if self.vehicle_by_uuid(root_uuid).is_none() {
let at = entity_nbt::position(&tag)?;
let key = (
(at.x.floor() as i32).div_euclid(16),
(at.z.floor() as i32).div_euclid(16),
);
self.world.loaded_entities.push((key, vec![tag]));
self.spawn_loaded_entities();
}
self.vehicle_by_uuid(uuid)
}
/// `ServerPlayer.loadAndSpawnParentVehicle`: the ride the player's file
/// names, once the vehicle is in the world.
pub(super) fn restore_root_vehicle(&mut self) {
if self.serving.is_some()
|| self.options.dedicated.is_some()
|| self.riding.is_some()
|| self.player.is_none()
{
return;
}
let Some(uuid) = self.pending_vehicle else {
return;
};
let raw = self.level.player_extra.clone();
let Some(id) = self.restore_saved_vehicle(uuid, raw.as_ref()) else {
return;
};
if let Some(Tag::Compound(p)) = &mut self.level.player_extra {
p.remove(&"RootVehicle".into());
}
self.pending_vehicle = None;
self.riding = Some(id);
}
/// A mob's entity tag with the mobs riding it under `Passengers`.
pub(super) fn mob_tree_nbt(&mut self, i: usize) -> Tag {
let id = self.mobs[i].id;
@@ -361,9 +361,7 @@ impl Game<'_> {
p.pose == Pose::Swimming || (!p.fall_flying && p.pose == Pose::FallFlying);
let in_water = p.fluids.was_touching_water;
let (yaw, pitch) = (self.yaw, self.pitch);
let main = self.inventory[self.selected]
.as_ref()
.map(|s| s.item.clone());
let main = self.inventory[self.selected].clone();
let off = self.offhand.as_ref().map(|s| s.item.clone());
let riding = self.riding.is_some();
let hurt = p.health.hurt_animation_time;
@@ -427,7 +425,7 @@ impl Game<'_> {
m.age = m.age.wrapping_add(1);
m.anim.attack_anim_o = attack.0;
m.anim.attack_anim = attack.1;
m.extra.main_hand = main;
m.extra.set_main_hand_stack(main);
m.extra.off_hand = off;
m.extra.crouching = crouching;
m.extra.passenger = riding;
+14 -2
View File
@@ -1370,10 +1370,21 @@ impl Game<'_> {
}
fn set_block(&mut self, pos: [i32; 3], state: i32) {
let state = BlockStateId(state.max(0) as u32);
let previous = self.world.block(pos[0], pos[1], pos[2]);
if self.world.loaded(pos[0], pos[2])
&& self.world.set(pos[0], pos[1], pos[2], state).is_ok()
{
self.sync_block_entity((pos[0], pos[1], pos[2]), state);
// LevelChunk.setBlockState removes the block entity of a block
// that changes: the moving piston the client's moveBlocks made
// stops when the server places what it moved (#3002).
let facts = self.res.facts();
let moving = |s: BlockStateId| &*facts.get(s).name == "minecraft:moving_piston";
if moving(previous) && !moving(state) {
let at = horizon_core::position::BlockPos::new(pos[0], pos[1], pos[2]);
self.block_ticks.moving_pistons.retain(|(p, _)| *p != at);
self.sync_moving_pistons();
}
}
// A block we just placed: play its place sound (the server skips the placer).
if let Some((target, _)) = self.remote.as_ref().and_then(|r| r.placing)
@@ -2336,7 +2347,7 @@ impl Game<'_> {
for (slot, item, stack) in items {
let e = &mut m.extra;
match slot {
0 => e.main_hand = item,
0 => e.set_main_hand(item),
1 => e.off_hand = item,
2 => {
e.feet = item;
@@ -3820,6 +3831,7 @@ impl Game<'_> {
self.tick_subtitles();
let entity_collisions = self.player_entity_collisions();
let pushed = self.remote_pushes();
let sprint_window = self.settings.sprint_window;
let Some(player) = self.player.as_mut() else {
return Ok(());
};
@@ -3918,7 +3930,7 @@ impl Game<'_> {
collision_binding: self.collision,
resources: self.motion,
}
.controlled_simulation_tick(keys)?;
.controlled_simulation_tick_with_sprint_window(keys, sprint_window)?;
} else {
// LivingEntity.baseTick counts hurtTime down for a dead or riding
// player too (controlled_simulation_tick does it otherwise): left
@@ -138,7 +138,7 @@ fn apply(m: &mut Mob, values: &[(u8, i32, Value)], builtin: &Builtin, local_enti
m.extra.color = (*c & 15) as u8;
}
// AbstractCubeMob.ID_SIZE: its size and box (refreshDimensions).
(Species::Slime | Species::MagmaCube, 18, Value::Int(size)) => {
(Species::Slime | Species::MagmaCube | Species::SulfurCube, 18, Value::Int(size)) => {
m.extra.size = (*size).clamp(1, 127);
}
// Enderman.DATA_CREEPY (after DATA_CARRY_STATE): the open mouth
@@ -539,6 +539,21 @@ mod tests {
assert!(!calf.is_baby());
}
#[test]
fn cube_mobs_receive_size_from_metadata() {
for kind in ["slime", "magma_cube", "sulfur_cube"] {
let Some(mut m) = mob(&format!("minecraft:{kind}")) else {
return;
};
assert_eq!(m.extra.size, 1);
apply(&mut m, &[(18, s::INT, Value::Int(2))]);
assert_eq!(m.extra.size, 2);
if kind == "sulfur_cube" {
assert!(!m.is_baby());
}
}
}
#[test]
fn bat_flags_select_continuous_client_clocks_without_changing_motion() {
use horizon_client::mobs::{MobTypes, anim, keyframes::Animation};
@@ -2000,6 +2000,16 @@ impl Game<'_> {
{
push(&mut lines, text, c);
}
// SuspiciousStewEffects.addToTooltip: the creative flag, which vanilla
// sets for the creative screen's own slots, as `name_visible` above.
for (text, c) in horizon_client::item_tooltip::stew_lines(
stack,
!nested && self.screen == Screen::Creative,
&display,
&translate,
) {
push(&mut lines, text, c);
}
// BlockItemStateProperties.addToTooltip.
if let Some(text) = horizon_client::item_tooltip::honey_line(stack, &display, &translate) {
push(&mut lines, text, gray);
@@ -218,7 +218,10 @@ fn carry_over(from: &Mob, to: &mut Mob) {
to.name_visible = from.name_visible;
to.invisible = from.invisible;
to.extra.pick_up_loot = from.extra.pick_up_loot;
to.extra.main_hand = from.extra.main_hand.clone();
to.extra.set_main_hand(from.extra.main_hand.clone());
if let Some(stack) = from.extra.main_hand_stack() {
to.extra.set_main_hand_stack(Some(stack.clone()));
}
to.extra.off_hand = from.extra.off_hand.clone();
to.extra.head = from.extra.head.clone();
to.extra.chest = from.extra.chest.clone();
+40 -1
View File
@@ -183,6 +183,18 @@ fn components_tag(s: &Stack, builtin: &Builtin, synced: &Dynamic) -> Option<Tag>
("mob_factor", Tag::Float(v[5].to_bits())),
]),
Component::Profile(p) => profile_tag(p),
// SuspiciousStewEffects' NBT: each effect by name and its duration.
Component::StewEffects(list) => Tag::List(
list.iter()
.filter_map(|(id, duration)| {
let name = builtin.get("minecraft:mob_effect")?.name(*id)?;
Some(Tag::compound([
("id", Tag::String(name.into())),
("duration", Tag::Int(*duration)),
]))
})
.collect(),
),
// PotionContents' NBT: the potion by name and the custom colour.
Component::Potion { potion, color, .. } => {
let potion = potion.and_then(|id| builtin.get("minecraft:potion")?.name(id));
@@ -523,7 +535,8 @@ mod tests {
}
/// What a creative client sends (`stack_to_network`) comes back whole on
/// the server: damage, both enchantment lists, repair cost, name, trim.
/// the server: damage, both enchantment lists, repair cost, name, trim,
/// potion and stew effects.
#[test]
fn a_creative_stack_keeps_its_components_across_the_wire() {
let mut components = vec![String::new(); 60];
@@ -539,6 +552,10 @@ mod tests {
"minecraft:stored_enchantments",
),
(component::TRIM, "minecraft:trim"),
(
component::SUSPICIOUS_STEW_EFFECTS,
"minecraft:suspicious_stew_effects",
),
] {
components[id as usize] = name.to_owned();
}
@@ -551,6 +568,7 @@ mod tests {
"minecraft:air",
"minecraft:diamond_chestplate",
"minecraft:splash_potion",
"minecraft:suspicious_stew",
])),
),
(
@@ -561,6 +579,10 @@ mod tests {
"minecraft:potion".to_owned(),
Ids::from_names(names(&["minecraft:water", "minecraft:swiftness"])),
),
(
"minecraft:mob_effect".to_owned(),
Ids::from_names(names(&["minecraft:speed", "minecraft:fire_resistance"])),
),
]
.into(),
};
@@ -642,5 +664,22 @@ mod tests {
from_network(&wire, &builtin, &dynamic).potion(),
Some("minecraft:swiftness")
);
// SuspiciousStewEffects, as a creative tab's stew has them: without
// them the server's stew gives no effect when eaten (#3137).
let mut stew = ItemStack::new("minecraft:suspicious_stew", 1);
stew.data_mut()
.stew_effects
.push((Arc::from("minecraft:fire_resistance"), 60));
let wire = crate::lan::guest::stack_to_network(
|name| id("minecraft:item", name).unwrap_or(0),
id,
&Some(stew),
)
.unwrap();
let back = from_network(&wire, &builtin, &dynamic);
assert_eq!(
back.data().map(|d| d.stew_effects.clone()),
Some(vec![(Arc::from("minecraft:fire_resistance"), 60)])
);
}
}
+112 -4
View File
@@ -483,14 +483,26 @@ pub fn potion_modifier_lines(
if !is_potion_item(&stack.item) {
return Vec::new();
}
let applied: Vec<(String, u32)> = stack
let effects = stack
.potion()
.and_then(crate::brewing::potion)
.into_iter()
.flat_map(|p| p.effects.iter())
.filter_map(|(effect, _, amplifier)| {
let (attribute, amount, operation) = effect_modifier(effect)?;
let amount = amount * (*amplifier + 1) as f64;
.map(|(effect, _, amplifier)| (*effect, *amplifier));
effect_modifier_lines(effects, translate)
}
/// `addPotionTooltip`'s attribute block for (effect id, amplifier) entries.
fn effect_modifier_lines<'a>(
effects: impl IntoIterator<Item = (&'a str, i32)>,
translate: &dyn Fn(&str) -> Option<String>,
) -> Vec<(String, u32)> {
let applied: Vec<(String, u32)> = effects
.into_iter()
.filter_map(|(effect, amplifier)| {
let (attribute, amount, operation) =
effect_modifier(effect.trim_start_matches("minecraft:"))?;
let amount = amount * (amplifier + 1) as f64;
let line = modifier_line(translate, attribute, amount, operation, false)?;
let color = if amount > 0.0 {
rgba(85, 85, 255, 255)
@@ -514,6 +526,48 @@ pub fn potion_modifier_lines(
lines
}
/// `SuspiciousStewEffects.addToTooltip`: only with the creative tooltip flag,
/// the stew's effects as `PotionContents.addPotionTooltip` lists them (each in
/// its `MobEffectCategory` colour, "No Effects" for none), then their
/// attribute modifiers.
pub fn stew_lines(
stack: &ItemStack,
creative: bool,
display: &TooltipDisplay,
translate: &dyn Fn(&str) -> Option<String>,
) -> Vec<(String, u32)> {
let effects = stack.data().map_or(&[][..], |d| &d.stew_effects[..]);
// The component is the item's default (empty) on a suspicious stew.
if !creative
|| (effects.is_empty() && &*stack.item != "minecraft:suspicious_stew")
|| !display.shows("minecraft:suspicious_stew_effects")
{
return Vec::new();
}
if effects.is_empty() {
let none = translate("effect.none").unwrap_or_else(|| "No Effects".into());
return vec![(none, rgba(170, 170, 170, 255))];
}
// `Entry.createEffectInstance`: amplifier 0.
let entries = effects.iter().map(|(id, duration)| (&**id, *duration, 0));
let mut lines: Vec<(String, u32)> = crate::brewing::effect_lines(entries, 1.0, translate)
.into_iter()
.map(|(line, category)| {
// MobEffectCategory.getTooltipFormatting: harmful red, else blue.
let color = match category {
crate::brewing::Category::Harmful => rgba(255, 85, 85, 255),
_ => rgba(85, 85, 255, 255),
};
(line, color)
})
.collect();
lines.extend(effect_modifier_lines(
effects.iter().map(|(id, _)| (&**id, 0)),
translate,
));
lines
}
/// `ItemStack.components.size()`: the item's default components with the
/// stack's own changes applied.
pub fn component_count(stack: &ItemStack, info: Option<&ItemInfo>) -> usize {
@@ -804,6 +858,7 @@ pub fn lore_texts(stack: &ItemStack) -> Vec<crate::text::Text> {
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Arc;
#[test]
fn lore_keeps_component_colours_under_dark_purple_italic() {
@@ -870,6 +925,59 @@ mod tests {
assert!(!display.shows("minecraft:attribute_modifiers"));
}
#[test]
fn stew_effects_show_only_with_the_creative_flag() {
let translate = |key: &str| {
Some(
match key {
"effect.minecraft.fire_resistance" => "Fire Resistance",
"effect.minecraft.weakness" => "Weakness",
"effect.minecraft.saturation" => "Saturation",
"effect.none" => "No Effects",
"potion.whenDrank" => "When Applied:",
"attribute.modifier.take.0" => "-%s %s",
"attribute.name.attack_damage" => "Attack Damage",
_ => return None,
}
.to_owned(),
)
};
let (blue, red) = (rgba(85, 85, 255, 255), rgba(255, 85, 85, 255));
let lines = |stack: &ItemStack, creative: bool| {
stew_lines(
stack,
creative,
&TooltipDisplay::for_stack(stack),
&translate,
)
};
let mut stew = ItemStack::new("minecraft:suspicious_stew", 1);
// The item's default, an empty list.
assert_eq!(
lines(&stew, true),
[("No Effects".to_owned(), rgba(170, 170, 170, 255))]
);
stew.data_mut().stew_effects = vec![
(Arc::from("minecraft:fire_resistance"), 60),
(Arc::from("minecraft:weakness"), 140),
(Arc::from("minecraft:saturation"), 7),
];
assert!(lines(&stew, false).is_empty());
assert_eq!(
lines(&stew, true),
[
("Fire Resistance (00:03)".to_owned(), blue),
("Weakness (00:07)".to_owned(), red),
// 20 ticks or less: no duration.
("Saturation".to_owned(), blue),
(String::new(), u32::MAX),
("When Applied:".to_owned(), rgba(170, 0, 170, 255)),
("-4 Attack Damage".to_owned(), red),
]
);
assert!(lines(&ItemStack::new("minecraft:bowl", 1), true).is_empty());
}
#[test]
fn ominous_bottle_lists_bad_omen_at_its_amplifier_for_100_minutes() {
let lang = |key: &str| {
+19
View File
@@ -165,6 +165,9 @@ pub struct Guest {
/// What the player's file says of the body (`Entity.load`: the fire
/// and air ticks and the fall distance), for the first body it gets.
pub loaded_body: Option<SavedBody>,
/// The vehicle the player's file names (`ServerPlayer.saveParentVehicle`'s
/// "RootVehicle"), for the ride to come back on the next login.
pub saved_vehicle: Option<[i32; 4]>,
/// The hurt and attack logs of the body it had before it respawned,
/// until the new body takes them over (the control socket's `hurtlog`
/// reads across a death).
@@ -324,6 +327,7 @@ impl Guest {
tracker: Tracker::default(),
motion: None,
loaded_body: None,
saved_vehicle: None,
logs_kept: None,
inventory: vec![None; 36],
armor: [None, None, None, None],
@@ -737,6 +741,21 @@ fn network_stack(
},
));
}
// SuspiciousStewEffects: each effect by its `minecraft:mob_effect` id.
if !d.stew_effects.is_empty() {
let effects = d
.stew_effects
.iter()
.filter_map(|(effect, duration)| {
let name = horizon_world::datafix::dynamic::namespaced(effect);
Some((synced_id("minecraft:mob_effect", &name)?, *duration))
})
.collect();
stack.added.push((
horizon_protocol::item::component::SUSPICIOUS_STEW_EFFECTS,
horizon_protocol::item::Component::StewEffects(effects),
));
}
// A loaded crossbow's ammunition.
if !d.charged.is_empty() {
let charged = d
+68 -4
View File
@@ -98,6 +98,7 @@ pub fn apply(guest: &mut Guest, level: &Level) {
air: short("Air", 300),
fall_distance: level.fall_distance,
});
guest.saved_vehicle = level.root_vehicle;
guest.recipe_book = level.recipe_book.clone();
guest.dimension = match level.dimension.as_str() {
d @ ("minecraft:the_nether" | "minecraft:the_end") => d.to_owned(),
@@ -129,12 +130,12 @@ pub fn apply(guest: &mut Guest, level: &Level) {
}
/// Save a guest (PlayerDataStorage.save).
pub fn save(guest: &Guest, world: &Path) {
pub fn save(guest: &Guest, world: &Path, vehicle: Option<[i32; 4]>) {
let mut level = Level {
player_uuid: Some(uuid_ints(guest.uuid)),
..Level::default()
};
store(guest, &mut level);
store(guest, &mut level, vehicle);
let file = path(world, guest.uuid);
if let Some(dir) = file.parent() {
let _ = std::fs::create_dir_all(dir);
@@ -143,7 +144,7 @@ pub fn save(guest: &Guest, world: &Path) {
}
/// Put a guest's state in `level`'s player fields.
pub fn store(guest: &Guest, level: &mut Level) {
pub fn store(guest: &Guest, level: &mut Level, vehicle: Option<[i32; 4]>) {
level.position = Some(guest.position);
level.dimension = guest.dimension.clone();
level.rotation = [guest.yaw, guest.pitch];
@@ -171,6 +172,8 @@ pub fn store(guest: &Guest, level: &mut Level) {
level.seen_credits = guest.seen_credits;
level.last_death = guest.last_death.clone();
level.player_extra = guest.saved.clone();
// The vehicle the player rides (`ServerPlayer.saveParentVehicle`).
level.root_vehicle = vehicle.or(guest.saved_vehicle);
// The live body's fire, air and fall distance (Entity.saveWithoutId),
// or what the file had while the player has no body yet.
let body = guest
@@ -251,7 +254,7 @@ mod tests {
fall_distance: 9.5,
});
let mut level = Level::default();
store(&g, &mut level);
store(&g, &mut level, None);
let tag = level.player_tag();
let mut read = Level::default();
read.read_player(&tag);
@@ -260,4 +263,65 @@ mod tests {
apply(&mut back, &read);
assert_eq!(back.loaded_body, g.loaded_body);
}
/// The resources the prepared game data holds, for a body to breathe with.
fn resources() -> Option<(crate::assets::Assets, crate::motion::MotionResources)> {
let root = std::env::var_os("HORIZON_TEST_RESOURCES")
.map(std::path::PathBuf::from)
.unwrap_or_else(|| {
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client")
.join(horizon_core::VANILLA_VERSION)
});
if !root.join("movement-properties.nbt").exists() {
return None;
}
let assets = crate::assets::Assets::load(&root).ok()?;
let resources = crate::motion::MotionResources::load(&root, &assets).ok()?;
Some((assets, resources))
}
#[test]
fn a_saved_air_comes_back() {
// A player saved underwater (#1321): the file keeps the remaining air
// (Entity.saveWithoutId's Air), and the body that comes back breathes
// on it instead of starting with a full supply.
let Some((_assets, resources)) = resources() else {
eprintln!("skipped the air round trip: prepared resources unavailable");
return;
};
let mut g = guest();
let mut motion =
crate::motion::PlayerMotion::new(&resources, horizon_core::geometry::Vec3::ZERO)
.expect("a body");
motion.air = 99;
g.motion = Some(Box::new(motion));
let mut level = Level::default();
store(&g, &mut level, None);
let tag = level.player_tag();
let mut read = Level::default();
read.read_player(&tag);
read.player_extra = Some(tag);
let mut back = guest();
apply(&mut back, &read);
assert_eq!(back.loaded_body.expect("a body").air, 99);
}
#[test]
fn a_saved_root_vehicle_comes_back() {
// A player saved riding a horse (#1474): the file names the vehicle
// (ServerPlayer.saveParentVehicle's RootVehicle), and the next login
// finds it and rides it again.
let vehicle = [1, 2, 3, 4];
let g = guest();
let mut level = Level::default();
store(&g, &mut level, Some(vehicle));
let tag = level.player_tag();
let mut read = Level::default();
read.read_player(&tag);
read.player_extra = Some(tag);
let mut back = guest();
apply(&mut back, &read);
assert_eq!(back.saved_vehicle, Some(vehicle));
}
}
+1
View File
@@ -43,6 +43,7 @@ pub mod dialog;
pub mod dialog_screen;
pub mod discord_presence;
pub mod end_crystal;
pub mod end_gateway;
pub mod entities;
pub mod entity_culling;
pub mod entity_nbt;
+46
View File
@@ -325,6 +325,7 @@ pub struct Flags {
pub water_sensitive: bool,
pub animal: bool,
}
#[derive(Clone, Debug)]
pub struct MobType {
pub id: String,
@@ -808,6 +809,8 @@ pub struct Extra {
pub charged: bool,
/// Held item ids (main hand, off hand) and head item.
pub main_hand: Option<Arc<str>>,
/// Components of the current held stack; setters clear these on replacement.
main_hand_stack: Option<crate::items::ItemStack>,
pub off_hand: Option<Arc<str>>,
pub head: Option<Arc<str>>,
/// Worn armour below the head (humanoid mobs).
@@ -939,6 +942,26 @@ pub struct Extra {
/// A panda's flags, counters and amounts.
pub panda: Option<Box<panda::State>>,
}
impl Extra {
pub fn set_main_hand(&mut self, item: Option<Arc<str>>) {
self.main_hand = item;
self.main_hand_stack = None;
}
pub fn set_main_hand_stack(&mut self, stack: Option<crate::items::ItemStack>) {
self.main_hand = stack.as_ref().map(|s| s.item.clone());
self.main_hand_stack = stack;
}
pub fn main_hand_stack(&self) -> Option<&crate::items::ItemStack> {
self.main_hand_stack
.as_ref()
.filter(|s| self.main_hand.as_ref() == Some(&s.item))
}
pub fn take_main_hand(&mut self) -> Option<Arc<str>> {
self.main_hand_stack = None;
self.main_hand.take()
}
}
#[derive(Clone, Debug)]
pub struct Mob {
pub id: u32,
@@ -3327,3 +3350,26 @@ mod push_tests {
}
}
}
#[cfg(test)]
mod held_stack_tests {
use super::*;
#[test]
fn replacing_even_the_same_item_clears_its_old_components() {
let mut extra = Extra::default();
let mut named = crate::items::ItemStack::new("minecraft:diamond_sword", 1);
named.data_mut().custom_name = Some("Old Sword".into());
extra.set_main_hand_stack(Some(named.clone()));
assert_eq!(
extra.main_hand_stack().unwrap().custom_name(),
Some("Old Sword")
);
extra.set_main_hand(Some(named.item.clone()));
assert!(extra.main_hand_stack().is_none());
extra.set_main_hand_stack(Some(named));
extra.take_main_hand();
assert!(extra.main_hand.is_none());
assert!(extra.main_hand_stack().is_none());
}
}
+169 -3
View File
@@ -13,7 +13,7 @@ use super::{
};
use crate::blocks::Fluid;
use horizon_core::{
geometry::Vec3,
geometry::{Aabb, Vec3},
random::{LegacyRandom, RandomSource},
};
@@ -1205,7 +1205,11 @@ fn server_ai_step(
let mut targets = std::mem::take(&mut m.ai.targets);
let mut goals = std::mem::take(&mut m.ai.goals);
{
let mut cx = Cx { w, events };
let mut cx = Cx {
w,
lr: level_random,
events,
};
if id_based % 2 != 0 && tick_count > 1 {
targets.tick_running(m, &mut cx, false);
goals.tick_running(m, &mut cx, false);
@@ -1246,7 +1250,11 @@ fn server_ai_step(
super::animal_brain::tick(m, w, level_random, events);
}
{
let mut cx = Cx { w, events };
let mut cx = Cx {
w,
lr: level_random,
events,
};
custom_server_ai_step(m, &mut cx);
}
if frozen {
@@ -1827,6 +1835,54 @@ pub fn after_hurt(m: &mut Mob, source: &Damage) {
_ => {}
}
}
/// The `NeutralMob` classes of 26.3: they stop being angry when the player
/// they are angry at dies nearby (`forgive_dead_players`).
pub fn is_neutral(s: Species) -> bool {
matches!(
s,
Species::Bee
| Species::Enderman
| Species::IronGolem
| Species::PolarBear
| Species::Wolf
| Species::ZombifiedPiglin
)
}
/// Whether the mob's persistent anger is at the player
/// (`NeutralMob.getPersistentAngerTarget`), `playerDied`'s gate.
pub fn angry_at_player(m: &Mob) -> bool {
match m.species() {
Species::Bee => super::bee::get(m).is_some_and(|b| b.angry_at == Some(Who::Player)),
// The polar bear's and enderman's anger is their player target: the
// bear marks it with flag2, the enderman holds it while stared at.
Species::PolarBear => m.extra.flag2 && m.ai.target == Some(Who::Player),
Species::Enderman => m.ai.target == Some(Who::Player),
_ => m.extra.anger > 0 && m.ai.target == Some(Who::Player),
}
}
/// `NeutralMob.stopBeingAngry`, for a mob `angry_at_player` let through: the
/// last hurt by, the persistent target and the target all go, the end time
/// is -1. Bees keep their anger in their own state, and the polar bear ends
/// its own flags with it.
pub fn stop_being_angry(m: &mut Mob) {
match m.species() {
Species::Bee => return super::bee::stop_being_angry(m),
Species::PolarBear => return super::polar_bear::stop_being_angry(m),
_ => {}
}
m.ai.last_hurt_by = None;
m.extra.anger = 0;
m.extra.anger_end = -1;
m.extra.angry_at = None;
m.ai.target = None;
}
/// `ServerPlayer.tellNeutralMobsThatIDied`'s area: the dying player's block
/// cell grown 32 blocks sideways and 10 up and down.
pub fn forgiveness_area(feet: [f64; 3]) -> Aabb {
let (x, y, z) = (feet[0].floor(), feet[1].floor(), feet[2].floor());
Aabb::new(Vec3::new(x, y, z), Vec3::new(x + 1.0, y + 1.0, z + 1.0))
.inflate(Vec3::new(32.0, 10.0, 32.0))
}
/// Mob.doHurtTarget: attack damage (+ species extras) to the target.
pub fn do_hurt_target(m: &mut Mob, who: Who, cx: &mut Cx) {
if m.species() == Species::IronGolem {
@@ -2287,3 +2343,113 @@ mod frog_tests {
);
}
}
#[cfg(test)]
mod forgiveness_tests {
use super::*;
use std::sync::Arc;
fn mob(species: Species) -> Mob {
let mut t = (*crate::mobs::sensor_scan_tests::kind()).clone();
t.species = species;
Mob::new(1, Arc::new(t), Vec3::ZERO, 0.0)
}
/// The dying player's forgiveness covers the six `NeutralMob` classes of
/// 26.3 and no others: a panda, a spider and a trader llama are not one
/// any more, piglins' anger is Brain-based and zombies are always
/// hostile.
#[test]
fn the_neutral_classes_match_vanilla() {
for s in [
Species::Bee,
Species::Enderman,
Species::IronGolem,
Species::PolarBear,
Species::Wolf,
Species::ZombifiedPiglin,
] {
assert!(is_neutral(s), "{s:?}");
}
for s in [
Species::Panda,
Species::Spider,
Species::TraderLlama,
Species::Piglin,
Species::Zombie,
Species::Cow,
] {
assert!(!is_neutral(s), "{s:?}");
}
}
/// `NeutralMob.playerDied` only forgives a mob whose persistent anger is
/// at the dying player; `stopBeingAngry` then clears the hurt by, the
/// persistent target, the target and the timer fully.
#[test]
fn only_the_mobs_angry_at_the_player_are_forgiven() {
let mut wolf = mob(Species::Wolf);
wolf.extra.anger = 500;
// A wolf angry at a skeleton keeps its anger.
wolf.ai.target = Some(Who::Mob(7));
assert!(!angry_at_player(&wolf));
wolf.ai.target = Some(Who::Player);
wolf.ai.last_hurt_by = Some(Who::Player);
assert!(angry_at_player(&wolf));
stop_being_angry(&mut wolf);
assert_eq!(wolf.extra.anger, 0);
assert_eq!(wolf.extra.anger_end, -1);
assert_eq!(wolf.extra.angry_at, None);
assert_eq!(wolf.ai.target, None);
assert_eq!(wolf.ai.last_hurt_by, None);
}
/// The polar bear's anger is its player target behind flag2, and its own
/// stop ends the flag with the rest.
#[test]
fn a_polar_bears_flag_ends_with_the_rest() {
let mut bear = mob(Species::PolarBear);
bear.extra.anger = 500;
bear.extra.flag2 = true;
bear.ai.target = Some(Who::Player);
bear.ai.last_hurt_by = Some(Who::Player);
assert!(angry_at_player(&bear));
stop_being_angry(&mut bear);
assert!(!bear.extra.flag2);
assert_eq!(bear.extra.anger, 0);
assert_eq!(bear.ai.target, None);
assert_eq!(bear.ai.last_hurt_by, None);
}
/// A bee's anger is its own state's target: the same call ends it there.
#[test]
fn a_bees_anger_ends_with_the_same_call() {
let mut bee = mob(Species::Bee);
bee.ai.last_hurt_by = Some(Who::Player);
bee.ai.target = Some(Who::Player);
stop_being_angry(&mut bee);
assert_eq!(bee.ai.target, None);
assert_eq!(bee.ai.last_hurt_by, None);
let b = crate::mobs::bee::get(&bee).unwrap();
assert_eq!(b.angry_at, None);
assert_eq!(b.anger_end, -1);
}
/// `tellNeutralMobsThatIDied`'s area: the block cell grown 32 sideways
/// and 10 up and down, so a wolf 20 blocks out is in it, one 34 out or
/// 12 up is not.
#[test]
fn the_area_reaches_twenty_blocks_out_but_not_twelve_up() {
let area = forgiveness_area([0.0, 64.0, 0.0]);
let mut wolf = mob(Species::Wolf);
wolf.position = Vec3::new(20.0, 64.0, 0.0);
assert!(area.intersects(wolf.bounds()));
wolf.position = Vec3::new(34.0, 64.0, 0.0);
assert!(!area.intersects(wolf.bounds()));
wolf.position = Vec3::new(0.0, 76.0, 0.0);
assert!(!area.intersects(wolf.bounds()));
wolf.position = Vec3::new(0.0, 69.0, 0.0);
assert!(area.intersects(wolf.bounds()));
}
}
@@ -2396,6 +2396,7 @@ fn axolotl_action(m: &mut Mob, cx: &mut Bx, action: AxolotlAction, until: &mut i
target,
&mut super::goals::Cx {
w: cx.w,
lr: cx.lr,
events: cx.events,
},
);
+43 -3
View File
@@ -103,8 +103,15 @@ pub(super) fn dangerous_block(name: &str) -> bool {
| "minecraft:powder_snow"
)
}
/// `LongJumpUtil.calculateJumpVectorForAngle` without the collision check.
fn jump_vector(m: &Mob, target: Vec3, max_velocity: f32, angle: i32) -> Option<Vec3> {
/// `LongJumpUtil.calculateJumpVectorForAngle`; with a world, the arc is
/// checked for collisions (`checkCollision`).
pub(super) fn jump_vector(
m: &Mob,
target: Vec3,
max_velocity: f32,
angle: i32,
collide: Option<&MobWorld>,
) -> Option<Vec3> {
let pos = m.position;
let plane = Vec3::new(target.x - pos.x, 0.0, target.z - pos.z)
.normalize()
@@ -130,8 +137,41 @@ fn jump_vector(m: &Mob, target: Vec3, max_velocity: f32, angle: i32) -> Option<V
return None;
}
let (v0r, v0y) = (v0 * cos, v0 * sin);
if let Some(w) = collide {
// `ceil(r / v0r) * 2` points along the arc, each step clear of blocks.
let samples = (r / v0r).ceil() as i32 * 2;
let (sin_xz, cos_xz) = (jmath::sin(xz), jmath::cos(xz));
let mut ri = 0.0;
let mut previous: Option<Vec3> = None;
for _ in 0..samples - 1 {
ri += r / f64::from(samples);
let yi = sin / cos * ri - jmath::pow(ri, 2.0) * g / (2.0 * v0sq * jmath::pow(cos, 2.0));
let p = Vec3::new(pos.x + ri * cos_xz, pos.y + yi, pos.z + ri * sin_xz);
if previous.is_some_and(|q| !clear_transition(m, w, q, p)) {
return None;
}
previous = Some(p);
}
}
Some(Vec3::new(v0r * jmath::cos(xz), v0y, v0r * jmath::sin(xz)).scale(f64::from(0.95_f32)))
}
/// `LongJumpUtil.isClearTransition`: the box fits at points no further apart
/// than nine tenths of its smaller side.
fn clear_transition(m: &Mob, w: &MobWorld, from: Vec3, to: Vec3) -> bool {
let d = to.subtract(from);
let min = f64::from(m.width().min(m.height()));
let checks = (d.length() / min).ceil() as i32;
let step = d.normalize().scale(min * f64::from(0.9_f32));
let mut p = from;
(0..checks).all(|i| {
p = if i == checks - 1 {
to
} else {
p.add_vector(step)
};
w.no_collision(m.bounds_at(p))
})
}
/// `LongJump.calculateOptimalJumpVector`: the allowed angles shuffled, the
/// first that reaches the target.
pub(super) fn optimal_jump_vector(m: &mut Mob, target: Vec3) -> Option<Vec3> {
@@ -143,7 +183,7 @@ pub(super) fn optimal_jump_vector(m: &mut Mob, target: Vec3) -> Option<Vec3> {
let max = 0.058_333_334_f32 * m.attrs.follow_range as f32;
let v = angles
.iter()
.find_map(|&a| jump_vector(m, target, max, a))?;
.find_map(|&a| jump_vector(m, target, max, a, None))?;
if m.has_effect("minecraft:jump_boost") {
let amp = m
.extra
+1 -1
View File
@@ -270,7 +270,7 @@ pub fn clear_slot(m: &mut Mob, slot: &str) {
let body = head_is_body(m.species());
let index = match slot {
"mainhand" => {
m.extra.main_hand = None;
m.extra.set_main_hand(None);
gear::MAIN
}
"offhand" => {
+5 -1
View File
@@ -836,6 +836,10 @@ pub fn spawn_egg_offspring(child: &mut Mob, species: Species, variant: u8, color
if species == Species::Sheep {
child.extra.color = color;
}
// `SulfurCube.getBreedOffspring`: `setSize(1, true)`.
if species == Species::SulfurCube {
sulfur_size(child, 1);
}
}
/// `setBaby`: AgeableMob through its age, zombies, piglins and zoglins.
pub fn set_baby(m: &mut Mob, baby: bool) {
@@ -896,7 +900,7 @@ pub fn item(m: &Mob, slot: usize) -> Option<Arc<str>> {
pub fn set_item(m: &mut Mob, slot: usize, id: &str) {
let v = Some(Arc::from(id));
match slot {
MAINHAND => m.extra.main_hand = v,
MAINHAND => m.extra.set_main_hand(v),
OFFHAND => m.extra.off_hand = v,
FEET => m.extra.feet = v,
LEGS => m.extra.legs = v,
+8 -6
View File
@@ -168,11 +168,12 @@ pub fn ai_step(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
let eaten = m.extra.ints[TICKS_SINCE_EATEN];
if eaten > 600 {
// ItemStack.finishUsingItem: the food is gone, a use remainder stays.
m.extra.main_hand = w
.items
.and_then(|i| i.items.get(&item))
.and_then(|i| i.use_remainder.as_deref())
.map(Into::into);
m.extra.set_main_hand(
w.items
.and_then(|i| i.items.get(&item))
.and_then(|i| i.use_remainder.as_deref())
.map(Into::into),
);
m.extra.ints[TICKS_SINCE_EATEN] = 0;
} else if eaten > 560 && m.random.next_float() < 0.1 {
let at = [m.position.x, m.position.y, m.position.z];
@@ -742,7 +743,8 @@ impl EatBerries {
.unwrap_or(0);
let mut count = 1 + m.random.next_int_bound(2).unwrap_or(0) + i32::from(age == 3);
if m.extra.main_hand.is_none() {
m.extra.main_hand = Some("minecraft:sweet_berries".into());
m.extra
.set_main_hand(Some("minecraft:sweet_berries".into()));
count -= 1;
}
if count > 0 {
+398 -3
View File
@@ -1,5 +1,5 @@
//! Frogs (`Frog`, `FrogAi`): the `Croak` and `ShootTongue` behaviours as
//! goals, the poses they set and the client's animation states.
//! Frogs (`Frog`, `FrogAi`): the `Croak`, `ShootTongue` and long jump
//! behaviours as goals, the poses they set and the client's animation states.
//! AnimState states: jump, croak, tongue, swim idle.
//!
//! The brain's idle `RunOne` picks a croak three times as often as a stroll
@@ -13,7 +13,10 @@ use super::{
},
sync,
};
use horizon_core::{geometry::Vec3, random::RandomSource};
use horizon_core::{
geometry::Vec3,
random::{LegacyRandom, RandomSource},
};
const JUMP: usize = 0;
const CROAK: usize = 1;
@@ -30,6 +33,25 @@ const EATING_DISTANCE: f64 = 1.75;
const DETECTION_DISTANCE: f64 = 10.0;
const SCAN_RATE: i32 = 20;
/// `FrogAi.TIME_BETWEEN_LONG_JUMPS` and the brain memory
/// `LONG_JUMP_COOLDOWN_TICKS` (saved with the others, as `finalize` sets it).
const TIME_BETWEEN_LONG_JUMPS: (i32, i32) = (100, 140);
const LONG_JUMP_COOLDOWN: &str = "long_jump_cooldown";
/// `FrogAi`'s `LongJumpToPreferredBlock`: `MAX_LONG_JUMP_WIDTH`,
/// `MAX_LONG_JUMP_HEIGHT`, `MAX_JUMP_VELOCITY_MULTIPLIER` and the chance to
/// look for a lily pad or big dripleaf first.
const JUMP_WIDTH: i32 = 4;
const JUMP_HEIGHT: i32 = 2;
const JUMP_VELOCITY_MULTIPLIER: f32 = 3.571_428_8;
const PREFERRED_BLOCKS_CHANCE: f32 = 0.5;
/// `LongJumpToRandomPos.ALLOWED_ANGLES`, `FIND_JUMP_TRIES` and
/// `PREPARE_JUMP_DURATION`.
const ALLOWED_ANGLES: [i32; 4] = [65, 70, 75, 80];
const FIND_JUMP_TRIES: i32 = 20;
const PREPARE_JUMP_DURATION: i32 = 40;
/// `LongJumpMidJump`'s duration (`TIME_OUT_DURATION`).
const MID_JUMP_DURATION: i32 = 100;
/// `ShootTongue.State`.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
enum Phase {
@@ -56,6 +78,8 @@ pub struct State {
eat_timer: i32,
path_counter: i32,
time: i32,
/// `LivingEntity.discardFriction`, set through the long jump.
pub discard_friction: bool,
}
pub fn register(m: &mut Mob) {
@@ -74,6 +98,7 @@ pub fn register(m: &mut Mob) {
g.add(0, Float);
g.add(1, Panic::new(1.25));
g.add(2, ShootTongue);
g.add(3, LongJump::default());
g.add(4, Croak);
g.add(5, Stroll::avoiding_water(1.0).interval(1));
g.add(6, LookAtPlayer::new(6.0));
@@ -119,6 +144,13 @@ fn can_eat(species: Species, size: i32) -> bool {
/// `FrogAttackablesSensor` (every 20 ticks) and the memory timers.
pub fn server_ai_step(m: &mut Mob, w: &MobWorld) {
// `CountDownCooldownTicks(LONG_JUMP_COOLDOWN_TICKS)`: one less each tick,
// erased the tick after it reaches 0.
match jump_cooldown(m) {
Some(t) if t > 0 => set_jump_cooldown(m, Some(t - 1)),
Some(_) => set_jump_cooldown(m, None),
None => {}
}
let f = &mut m.extra.frog;
f.unreachable.retain_mut(|(_, ttl)| {
*ttl -= 1;
@@ -310,3 +342,366 @@ impl Goal for ShootTongue {
}
}
}
/// `LONG_JUMP_COOLDOWN_TICKS`.
fn jump_cooldown(m: &Mob) -> Option<i64> {
m.extra
.memories
.iter()
.find(|x| x.0 == LONG_JUMP_COOLDOWN)
.map(|x| x.1)
}
fn set_jump_cooldown(m: &mut Mob, ticks: Option<i64>) {
let memories = &mut m.extra.memories;
let at = memories.iter().position(|x| x.0 == LONG_JUMP_COOLDOWN);
match (at, ticks) {
(Some(i), Some(t)) => memories[i].1 = t,
(Some(i), None) => {
memories.remove(i);
}
(None, Some(t)) => memories.push((LONG_JUMP_COOLDOWN, t, i64::MAX)),
(None, None) => {}
}
}
/// `TIME_BETWEEN_LONG_JUMPS.sample` from the level's random, divided as the
/// caller asks.
fn sample_cooldown(m: &mut Mob, lr: &mut LegacyRandom, div: i32) {
let (lo, hi) = TIME_BETWEEN_LONG_JUMPS;
let t = lr.next_int_between_inclusive(lo, hi).unwrap_or(lo) / div;
set_jump_cooldown(m, Some(i64::from(t)));
}
/// `FrogAi.isAcceptableLandingSpot`: no fluid in the target, below or above
/// it; a lily pad or big dripleaf (`frog_prefer_jump_to`) or a trapdoor; else
/// `LongJumpToRandomPos.defaultAcceptableLandingSpot`: a solid block below
/// and a path type the frog walks without a malus.
fn acceptable_landing(m: &Mob, w: &MobWorld, [x, y, z]: [i32; 3]) -> bool {
use super::path::PathType;
if (y - 1..=y + 1).any(|y| w.fluid(x, y, z) != crate::blocks::Fluid::None) {
return false;
}
let preferred = |y| w.has_tag("minecraft:frog_prefer_jump_to", w.block_name(x, y, z));
if preferred(y) || preferred(y - 1) {
return true;
}
let here = w.path_type_static(x, y, z);
if here == PathType::Trapdoor
|| w.facts.is_air(w.world.block(x, y, z))
&& w.path_type_static(x, y - 1, z) == PathType::Trapdoor
{
return true;
}
w.facts.get(w.world.block(x, y - 1, z)).solid_render && m.malus(here) == 0.0
}
/// `LongJumpToRandomPos.PossibleJump`: a block and its weight.
type Candidate = ([i32; 3], i32);
/// `FrogAi`'s long jump activity: `LongJumpToPreferredBlock` looks for a spot
/// it cannot walk to, faces it for 40 ticks and leaps; `LongJumpMidJump`
/// holds the pose until it lands. One goal here, like the brain activity
/// that keeps the idle croak and stroll from starting meanwhile.
#[derive(Clone, Debug, Default)]
struct LongJump {
candidates: Vec<Candidate>,
not_preferred: Vec<Candidate>,
wanting_preferred: bool,
initial: Option<Vec3>,
chosen: Option<Vec3>,
/// The chosen spot, looked at while preparing.
look: [f64; 3],
tries: i32,
/// Ticks since the jump was chosen.
prepare: i32,
/// `LONG_JUMP_MID_JUMP`, and the ticks `LongJumpMidJump` has run.
mid_jump: bool,
in_air: Option<i32>,
}
impl LongJump {
/// `WeightedRandom.getRandomItem` with the level's random over the
/// candidates, removed once drawn.
fn draw(&mut self, lr: &mut LegacyRandom) -> Option<Candidate> {
let total: i32 = self.candidates.iter().map(|c| c.1).sum();
if total == 0 {
return None;
}
let mut pick = lr.next_int_bound(total).unwrap_or(0);
let i = self.candidates.iter().position(|c| {
pick -= c.1;
pick < 0
})?;
Some(self.candidates.remove(i))
}
/// `LongJumpToPreferredBlock.getJumpCandidate`: while it wants them, the
/// first drawn spot over a preferred block, else the first one passed over.
fn candidate(&mut self, w: &MobWorld, lr: &mut LegacyRandom) -> Option<Candidate> {
if !self.wanting_preferred {
return self.draw(lr);
}
while !self.candidates.is_empty() {
if let Some(c) = self.draw(lr) {
let [x, y, z] = c.0;
if w.has_tag("minecraft:frog_prefer_jump_to", w.block_name(x, y - 1, z)) {
return Some(c);
}
self.not_preferred.push(c);
}
}
(!self.not_preferred.is_empty()).then(|| self.not_preferred.remove(0))
}
/// `LongJumpToRandomPos.pickCandidate`: the first acceptable spot it can
/// reach in an arc but not by an 8 block path.
fn pick(&mut self, m: &mut Mob, w: &MobWorld, lr: &mut LegacyRandom) {
while !self.candidates.is_empty() {
let Some((target, _)) = self.candidate(w, lr) else {
continue;
};
let here = [m.position.x.floor() as i32, m.position.z.floor() as i32];
if here == [target[0], target[2]] || !acceptable_landing(m, w, target) {
continue;
}
let centre = Vec3::new(
f64::from(target[0]) + 0.5,
f64::from(target[1]) + 0.5,
f64::from(target[2]) + 0.5,
);
let Some(v) = optimal_jump_vector(m, w, centre) else {
continue;
};
self.look = [centre.x, centre.y, centre.z];
let walkable = m
.create_path_raw(w, &[target], 0, 8.0)
.is_some_and(|p| p.reached);
if !walkable {
self.chosen = Some(v);
self.prepare = 0;
return;
}
}
}
/// `LongJumpMidJump.stop`, or the end of a search.
fn end(&mut self, m: &mut Mob, cx: &mut Cx) {
if self.mid_jump {
if m.on_ground {
m.velocity = m.velocity.multiply(Vec3::new(0.1, 1.0, 0.1));
let at = [m.position.x, m.position.y, m.position.z];
cx.events
.push(MobEvent::Sound("entity.frog.step", at, 2.0, 1.0));
}
set_pose(m, sync::STANDING);
sample_cooldown(m, cx.lr, 1);
}
m.extra.frog.discard_friction = false;
*self = Self::default();
}
}
/// `LongJumpToRandomPos.start`'s candidates: every block of the box but the
/// frog's own, weighted by the squared distance, in `betweenClosed` order.
fn candidates([x, y, z]: [i32; 3]) -> Vec<Candidate> {
let mut out = Vec::new();
for dz in -JUMP_WIDTH..=JUMP_WIDTH {
for dy in -JUMP_HEIGHT..=JUMP_HEIGHT {
for dx in -JUMP_WIDTH..=JUMP_WIDTH {
if (dx, dy, dz) != (0, 0, 0) {
out.push(([x + dx, y + dy, z + dz], dx * dx + dy * dy + dz * dz));
}
}
}
}
out
}
/// `LongJumpToRandomPos.calculateOptimalJumpVector`: the angles shuffled, the
/// first arc that reaches the target clear of blocks. `Collections.shuffle`
/// without a random draws from an unseeded one, not the mob's or the level's.
fn optimal_jump_vector(m: &Mob, w: &MobWorld, target: Vec3) -> Option<Vec3> {
let mut random = crate::sound_engine::SoundEngine::unseeded();
let mut angles = ALLOWED_ANGLES;
for i in (2..=angles.len()).rev() {
let j = random.next_int_bound(i as i32).unwrap_or(0) as usize;
angles.swap(i - 1, j);
}
let max = (m.attrs.jump_strength * f64::from(JUMP_VELOCITY_MULTIPLIER)) as f32;
angles
.iter()
.find_map(|&a| super::breeze::jump_vector(m, target, max, a, Some(w)))
}
impl Goal for LongJump {
goal_clone!();
fn flags(&self) -> u8 {
MOVE | LOOK
}
fn interruptable(&self) -> bool {
!self.mid_jump
}
fn every_tick(&self) -> bool {
true
}
fn can_use(&mut self, m: &mut Mob, cx: &mut Cx) -> bool {
// The activity: no cooldown and out of water. FrogAi.initJumpActivity
// also wants TEMPTING_PLAYER and BREED_TARGET absent, and the
// LAY_SPAWN activity (IS_PREGNANT) goes before LONG_JUMP.
// TODO: gate on them once frogs follow frog_food, breed and lay
// frogspawn here; none of the three exists for frogs yet.
if jump_cooldown(m).is_some() || m.fluids.in_water {
return false;
}
// checkExtraStartConditions: on the ground, not in lava or on honey.
let (x, y, z) = (
m.position.x.floor() as i32,
m.position.y.floor() as i32,
m.position.z.floor() as i32,
);
let can =
m.on_ground && !m.fluids.in_lava && cx.w.block_name(x, y, z) != "minecraft:honey_block";
if !can {
sample_cooldown(m, cx.lr, 2);
}
can
}
fn start(&mut self, m: &mut Mob, _cx: &mut Cx) {
// LongJumpToRandomPos.start.
let (x, y, z) = (
m.position.x.floor() as i32,
m.position.y.floor() as i32,
m.position.z.floor() as i32,
);
*self = Self {
candidates: candidates([x, y, z]),
tries: FIND_JUMP_TRIES,
initial: Some(m.position),
..Self::default()
};
// LongJumpToPreferredBlock.start.
self.wanting_preferred = m.random.next_float() < PREFERRED_BLOCKS_CHANCE;
}
fn can_continue(&mut self, m: &mut Mob, cx: &mut Cx) -> bool {
if self.mid_jump {
// LongJumpMidJump starts the tick after the leap and runs until
// the frog is on the ground again, for 100 ticks at most; water
// switches the brain to the swim activity, which stops it too.
match self.in_air {
None => {
self.in_air = Some(0);
m.extra.frog.discard_friction = true;
set_pose(m, sync::LONG_JUMPING);
}
Some(t) => self.in_air = Some(t + 1),
}
return !m.on_ground
&& !m.fluids.in_water
&& self.in_air.is_some_and(|t| t <= MID_JUMP_DURATION);
}
let valid = self.initial == Some(m.position)
&& self.tries > 0
&& !m.fluids.in_water
&& !(self.chosen.is_none() && self.candidates.is_empty());
if !valid {
sample_cooldown(m, cx.lr, 2);
}
valid
}
fn stop(&mut self, m: &mut Mob, cx: &mut Cx) {
self.end(m, cx);
}
fn tick(&mut self, m: &mut Mob, cx: &mut Cx) {
if self.mid_jump {
return;
}
let Some(jump) = self.chosen else {
self.tries -= 1;
self.pick(m, cx.w, cx.lr);
return;
};
m.look_at(self.look);
self.prepare += 1;
if self.prepare < PREPARE_JUMP_DURATION {
return;
}
// The leap: the body's facing, no friction, the arc's velocity
// lengthened by jump boost.
m.yaw = m.body_yaw;
m.extra.frog.discard_friction = true;
let len = jump.length();
let boost = m
.extra
.effects
.iter()
.find(|e| e.0 == "minecraft:jump_boost")
.map_or(0.0, |e| f64::from(0.1_f32 * (e.2 + 1) as f32));
m.velocity = jump.scale((len + boost) / len);
self.mid_jump = true;
let at = [m.position.x, m.position.y, m.position.z];
cx.events
.push(MobEvent::Sound("entity.frog.long_jump", at, 1.0, 1.0));
}
}
#[cfg(test)]
mod tests {
use super::*;
use horizon_core::random::LegacyRandom;
fn frog() -> Mob {
let mut kind = crate::mobs::sensor_scan_tests::kind();
let k = std::sync::Arc::make_mut(&mut kind);
k.id = "minecraft:frog".into();
k.species = Species::Frog;
Mob::new(1, kind, Vec3::new(0.5, 0.0, 0.5), 0.0)
}
/// `LongJumpToRandomPos.start`: the 9 x 5 x 9 box without the frog's own
/// block, x fastest, then y, then z, each weighted by its squared distance.
#[test]
fn the_candidates_cover_the_jump_box_in_vanilla_order() {
let c = candidates([10, 64, -3]);
assert_eq!(c.len(), 9 * 5 * 9 - 1);
assert_eq!(c[0], ([6, 62, -7], 16 + 4 + 16));
assert_eq!(c[1], ([7, 62, -7], 9 + 4 + 16));
assert_eq!(c[9], ([6, 63, -7], 16 + 1 + 16));
assert_eq!(c[45], ([6, 62, -6], 16 + 4 + 9));
assert!(!c.iter().any(|(p, _)| *p == [10, 64, -3]));
assert_eq!(c.last().unwrap().0, [14, 66, 1]);
}
/// `WeightedRandom.getRandomItem`: `nextInt(total)` walked down the
/// list, and the drawn candidate is removed.
#[test]
fn a_draw_takes_the_weighted_candidate_out() {
let mut j = LongJump {
candidates: vec![([0, 0, 0], 1), ([1, 0, 0], 4), ([2, 0, 0], 9)],
..LongJump::default()
};
let mut r = LegacyRandom::new(7);
let mut lr = r.clone();
let pick = r.next_int_bound(14).unwrap();
let want = if pick < 1 {
0
} else if pick < 5 {
1
} else {
2
};
assert_eq!(j.draw(&mut lr).unwrap().0, [want, 0, 0]);
assert_eq!(j.candidates.len(), 2);
assert!(!j.candidates.iter().any(|c| c.0 == [want, 0, 0]));
}
/// `CountDownCooldownTicks`: the memory falls by one a tick and is gone
/// the tick after it reached 0; the sample is 100..=140, halved after a
/// failed search, drawn from the level's random and not the frog's.
#[test]
fn the_long_jump_cooldown_counts_down_and_is_erased() {
let mut m = frog();
let own = m.random.state();
sample_cooldown(&mut m, &mut LegacyRandom::new(3), 2);
let t = jump_cooldown(&m).unwrap();
assert!((50..=70).contains(&t));
assert_eq!(m.random.state(), own);
set_jump_cooldown(&mut m, Some(1));
set_jump_cooldown(&mut m, Some(0));
assert_eq!(jump_cooldown(&m), Some(0));
set_jump_cooldown(&mut m, None);
assert_eq!(jump_cooldown(&m), None);
assert!(!m.extra.memories.iter().any(|x| x.0 == LONG_JUMP_COOLDOWN));
}
}
+4 -1
View File
@@ -4,7 +4,7 @@ use super::{Mob, MobEvent, MobWorld, Seen, Species, Who, world::TargetInfo};
use horizon_core::{
geometry::{Aabb, Vec3},
jmath,
random::RandomSource,
random::{LegacyRandom, RandomSource},
};
use std::sync::Arc;
@@ -16,6 +16,9 @@ pub const TARGET: u8 = 8;
/// What goals may read and emit during a tick.
pub struct Cx<'a, 'w> {
pub w: &'a MobWorld<'w>,
/// The level's random (`level.getRandom()`), for the draws vanilla's
/// behaviours make from it rather than from the mob's own.
pub lr: &'a mut LegacyRandom,
pub events: &'a mut Vec<MobEvent>,
}
pub trait Goal: std::fmt::Debug {
+1 -1
View File
@@ -62,7 +62,7 @@ pub fn register(m: &mut Mob) {
/// `Illusioner.finalizeSpawn`: a bow in the main hand.
pub fn finalize(m: &mut Mob) {
m.extra.main_hand = Some("minecraft:bow".into());
m.extra.set_main_hand(Some("minecraft:bow".into()));
}
/// `IllusionerMirrorSpellGoal` and `IllusionerBlindnessSpellGoal`.
+2 -2
View File
@@ -1045,8 +1045,8 @@ impl Mob {
} else {
y -= self.gravity();
}
// LivingEntity.shouldDiscardFriction (a breeze's long jump).
if super::breeze::discards_friction(self) {
// LivingEntity.shouldDiscardFriction (a breeze's or a frog's long jump).
if super::breeze::discards_friction(self) || self.extra.frog.discard_friction {
self.velocity = Vec3::new(movement.x, y, movement.z);
return;
}
@@ -783,6 +783,7 @@ impl B {
m.swing();
let mut gcx = Cx {
w,
lr: cx.lr,
events: cx.events,
};
super::ai::do_hurt_target(m, target, &mut gcx);
+1 -1
View File
@@ -232,7 +232,7 @@ impl Mob {
}
q
}
fn create_path_raw(
pub(super) fn create_path_raw(
&mut self,
w: &MobWorld,
targets: &[[i32; 3]],
+1 -1
View File
@@ -183,7 +183,7 @@ pub fn ai_step(m: &mut Mob, w: &MobWorld) {
}
}
/// `NeutralMob.stopBeingAngry`.
fn stop_being_angry(m: &mut Mob) {
pub(super) fn stop_being_angry(m: &mut Mob) {
m.ai.last_hurt_by = None;
m.ai.target = None;
m.extra.anger = 0;
+13 -13
View File
@@ -207,24 +207,24 @@ pub fn finalize(m: &mut Mob, biome: &BiomeFacts, difficulty: &LocalDifficulty) {
let weapon = if difficulty.hard { 0.05 } else { 0.01 };
if m.species() != S::Drowned && m.random.next_float() < weapon {
let r = m.random.next_int_bound(6).unwrap_or(0);
m.extra.main_hand = Some(
m.extra.set_main_hand(Some(
match r {
0 => "minecraft:iron_sword",
1 => "minecraft:iron_spear",
_ => "minecraft:iron_shovel",
}
.into(),
);
));
}
if m.species() == S::Drowned && m.random.next_float() > 0.9 {
m.extra.main_hand = Some(
m.extra.set_main_hand(Some(
if m.random.next_int_bound(16).unwrap_or(1) < 10 {
"minecraft:trident"
} else {
"minecraft:fishing_rod"
}
.into(),
);
));
}
if m.species() == S::ZombieVillager {
m.extra.variant = villager_type(biome);
@@ -238,28 +238,28 @@ pub fn finalize(m: &mut Mob, biome: &BiomeFacts, difficulty: &LocalDifficulty) {
let can_break_doors = m.random.next_float() < difficulty.special() * 0.1;
super::doors::set_can_break_doors(m, can_break_doors);
let spear = m.random.next_int_bound(20).unwrap_or(1) == 0;
m.extra.main_hand = Some(
m.extra.set_main_hand(Some(
if spear {
"minecraft:golden_spear"
} else {
"minecraft:golden_sword"
}
.into(),
);
));
}
// AbstractSkeleton.populateDefaultEquipmentSlots: armour, then a bow.
S::Skeleton | S::Stray | S::Bogged | S::Parched => {
populate_armor(m, difficulty);
m.extra.main_hand = Some("minecraft:bow".into());
m.extra.set_main_hand(Some("minecraft:bow".into()));
m.extra.pick_up_loot = m.random.next_float() < 0.55 * difficulty.special();
}
// WitherSkeleton.populateDefaultEquipmentSlots: a stone sword only.
S::WitherSkeleton => {
m.extra.main_hand = Some("minecraft:stone_sword".into());
m.extra.set_main_hand(Some("minecraft:stone_sword".into()));
m.extra.pick_up_loot = m.random.next_float() < 0.55 * difficulty.special();
}
S::Pillager => {
m.extra.main_hand = Some("minecraft:crossbow".into());
m.extra.set_main_hand(Some("minecraft:crossbow".into()));
m.extra.pick_up_loot = true;
}
S::Horse | S::Donkey | S::Mule | S::SkeletonHorse | S::ZombieHorse => {
@@ -286,21 +286,21 @@ pub fn finalize(m: &mut Mob, biome: &BiomeFacts, difficulty: &LocalDifficulty) {
}
}
let crossbow = m.random.next_float() < 0.5;
m.extra.main_hand = Some(
m.extra.set_main_hand(Some(
if crossbow {
"minecraft:crossbow"
} else {
"minecraft:golden_sword"
}
.into(),
);
));
}
S::PiglinBrute => {
m.extra.main_hand = Some("minecraft:golden_axe".into());
m.extra.set_main_hand(Some("minecraft:golden_axe".into()));
m.extra.pick_up_loot = true;
}
S::Illusioner => super::illusioner::finalize(m),
S::Vindicator => m.extra.main_hand = Some("minecraft:iron_axe".into()),
S::Vindicator => m.extra.set_main_hand(Some("minecraft:iron_axe".into())),
S::Villager => {
m.extra.variant = villager_type(biome);
m.extra.pick_up_loot = true;
+2 -2
View File
@@ -114,11 +114,11 @@ impl Goal for DrinkItem {
m.extra.using_item > 0
}
fn start(&mut self, m: &mut Mob, _: &mut Cx) {
m.extra.main_hand = Some(self.item.into());
m.extra.set_main_hand(Some(self.item.into()));
m.extra.using_item = 1;
}
fn stop(&mut self, m: &mut Mob, cx: &mut Cx) {
m.extra.main_hand = None;
m.extra.set_main_hand(None);
m.extra.using_item = 0;
let pitch = m.random.next_float() * 0.2 + 0.9;
cx.events
@@ -75,7 +75,7 @@ pub fn held(m: &Mob) -> Option<&ItemStack> {
/// `setItemSlot(MAINHAND, ..)`, keeping the rendered copy in step.
pub fn set_held(m: &mut Mob, stack: Option<ItemStack>) {
let stack = stack.filter(|s| s.count > 0);
m.extra.main_hand = stack.as_ref().map(|s| s.item.clone());
m.extra.set_main_hand_stack(stack.clone());
m.extra.ints[1] = stack.as_ref().map_or(0, |s| s.count as i32);
if m.extra.items.is_empty() {
m.extra.items.push(None);
+1 -1
View File
@@ -19,7 +19,7 @@ use std::f32::consts::PI;
pub fn register(m: &mut Mob) {
m.no_gravity = true;
m.ai.mov.kind = super::control::MoveKind::Custom;
m.extra.main_hand = Some("minecraft:iron_sword".into());
m.extra.set_main_hand(Some("minecraft:iron_sword".into()));
if m.extra.egg_time == 0 {
m.extra.egg_time = -1;
}
@@ -1721,7 +1721,7 @@ impl B {
}
t.begin(cx.lr, cx.now);
bonemeal_target(m, *crop);
m.extra.main_hand = Some("minecraft:bone_meal".into());
m.extra.set_main_hand(Some("minecraft:bone_meal".into()));
*next_work = cx.now;
*worked = 0;
*repick = false;
@@ -3200,7 +3200,7 @@ impl B {
t, last_session, ..
} => {
t.running = false;
m.extra.main_hand = None;
m.extra.set_main_hand(None);
*last_session = i64::from(m.age);
}
B::WorkAtPoi { t, .. } => t.running = false,
+8 -2
View File
@@ -454,7 +454,13 @@ pub fn do_push(m: &mut Mob, w: &MobWorld, who: Who, events: &mut Vec<MobEvent>)
}
/// `Warden.doHurtTarget`: the attack keyframes (entity event 4), the impact
/// sound, a sonic boom cooldown, then `Mob.doHurtTarget`.
pub(super) fn do_hurt_target(m: &mut Mob, target: Who, w: &MobWorld, events: &mut Vec<MobEvent>) {
pub(super) fn do_hurt_target(
m: &mut Mob,
target: Who,
w: &MobWorld,
lr: &mut LegacyRandom,
events: &mut Vec<MobEvent>,
) {
m.anim.states[ROAR].stop();
m.anim.states[ATTACK].start(m.age as f32);
events.push(MobEvent::EntityEvent {
@@ -465,7 +471,7 @@ pub(super) fn do_hurt_target(m: &mut Mob, target: Who, w: &MobWorld, events: &mu
let pitch = voice_pitch(m);
sound(m, "entity.warden.attack_impact", 10.0, pitch, events);
warden_brain::mem(m).sonic_boom_cooldown.set_ttl((), 40);
let mut cx = warden_brain::cx(w, events);
let mut cx = warden_brain::cx(w, lr, events);
super::ai::do_hurt_target(m, target, &mut cx);
}
@@ -962,7 +962,7 @@ impl B {
}
mem(m).look_target.set(Tracker::Entity(target, true));
m.swing();
warden::do_hurt_target(m, target, w, cx.events);
warden::do_hurt_target(m, target, w, cx.lr, cx.events);
mem(m)
.attack_cooling_down
.set_ttl(true, MELEE_ATTACK_COOLDOWN);
@@ -1188,8 +1188,12 @@ fn swim_condition(m: &Mob) -> bool {
m.fluids.water > m.fluid_jump_threshold() || m.fluids.in_lava
}
/// The `Cx` the mob library's attacks take.
pub(super) fn cx<'a, 'w>(w: &'a MobWorld<'w>, events: &'a mut Vec<MobEvent>) -> Cx<'a, 'w> {
Cx { w, events }
pub(super) fn cx<'a, 'w>(
w: &'a MobWorld<'w>,
lr: &'a mut LegacyRandom,
events: &'a mut Vec<MobEvent>,
) -> Cx<'a, 'w> {
Cx { w, lr, events }
}
// ---- Printing ---------------------------------------------------------------
+2 -2
View File
@@ -268,7 +268,7 @@ fn drink(m: &mut Mob, cx_events: &mut Vec<MobEvent>, target_far: bool) {
m.extra.using_item -= 1;
if m.extra.using_item < 0 {
m.extra.using_item = 0;
if let Some(potion) = m.extra.main_hand.take() {
if let Some(potion) = m.extra.take_main_hand() {
match potion.as_ref() {
"minecraft:water_breathing" => {
m.add_effect("minecraft:water_breathing", 3600, 0)
@@ -307,7 +307,7 @@ fn drink(m: &mut Mob, cx_events: &mut Vec<MobEvent>, target_far: bool) {
None
};
if let Some(p) = potion {
m.extra.main_hand = Some(p.into());
m.extra.set_main_hand(Some(p.into()));
// PotionItem use duration 32 ticks.
m.extra.using_item = 32;
m.extra.flag = true;
+21 -2
View File
@@ -705,6 +705,14 @@ impl PlayerMotion {
/// Vehicle, item-use and effect bindings remain separate; hunger, flight,
/// water and collision conditions use this actor's actual state.
pub fn sprint_input_tick(&mut self, keys: crate::sprint::Keys) -> Result<()> {
self.sprint_input_tick_with_window(keys, 7)
}
/// The same phase with Java's `Options.sprintWindow` value.
pub fn sprint_input_tick_with_window(
&mut self,
keys: crate::sprint::Keys,
sprint_window: i32,
) -> Result<()> {
let conditions = crate::sprint::Conditions {
sprinting: self.sprinting,
enough_food: self.health.food.has_enough_food(),
@@ -725,7 +733,9 @@ impl PlayerMotion {
// The sprint setter edits attributes and flags, independent of local
// keyboard history. Retain the input prefix even if that setter fails.
let mut state = self.sprint_input;
let result = state.tick(keys, conditions, 7, |value| self.set_sprinting(value));
let result = state.tick(keys, conditions, sprint_window.clamp(0, 10), |value| {
self.set_sprinting(value)
});
self.sprint_input = state;
result
}
@@ -992,6 +1002,14 @@ impl MotionWorld<'_, '_, '_> {
.food_tick(&self.resources.health, self.scene.health_rules)
}
pub fn controlled_simulation_tick(&mut self, keys: crate::sprint::Keys) -> Result<()> {
self.controlled_simulation_tick_with_sprint_window(keys, 7)
}
/// Local simulation with the configured double-tap sprint window.
pub fn controlled_simulation_tick_with_sprint_window(
&mut self,
keys: crate::sprint::Keys,
sprint_window: i32,
) -> Result<()> {
// The base tick (fluids, swimming) precedes LocalPlayer.aiStep.
self.player.health.advance_clock();
self.fluid_base_tick()?;
@@ -1004,7 +1022,8 @@ impl MotionWorld<'_, '_, '_> {
self.player.auto_jump_time -= 1;
keys.jump = true;
}
self.player.sprint_input_tick(keys)?;
self.player
.sprint_input_tick_with_window(keys, sprint_window)?;
self.player.shift_key_down = keys.shift;
let direction = keys.direction();
self.player.move_vector = direction;
+13 -5
View File
@@ -660,7 +660,7 @@ impl Screen {
half(w) - 100.0,
quarter(h) + 96.0 + 18.0,
200.0,
"Join",
&crate::lang::get_or("horizon.multiplayer.join", "Join"),
horizon_friends::invite::parse(code).is_some(),
56,
),
@@ -687,7 +687,10 @@ impl Screen {
half(w) - 100.0,
half(h) + 54.0,
200.0,
"Cancel Reconnecting",
&crate::lang::get_or(
"horizon.multiplayer.cancel_reconnecting",
"Cancel Reconnecting",
),
true,
55,
));
@@ -1055,13 +1058,18 @@ impl Screen {
}
}
Self::Friend { code, relay, focus } => {
centered(batch, "Join a Friend", 20.0, u32::MAX);
centered(
batch,
&crate::lang::get_or("horizon.multiplayer.join_friend", "Join a Friend"),
20.0,
u32::MAX,
);
Self::field(
atlas,
batch,
caret,
focused_at,
"Invite Code",
&crate::lang::get_or("horizon.multiplayer.invite_code", "Invite Code"),
code,
FRIEND_CODE,
max,
@@ -1073,7 +1081,7 @@ impl Screen {
batch,
caret,
focused_at,
"Relay (optional)",
&crate::lang::get_or("horizon.multiplayer.relay", "Relay (optional)"),
relay,
FRIEND_RELAY,
max,
@@ -2,6 +2,10 @@
//! (`Entity.setAsInsidePortal` / `handlePortal`) for nether portals.
use horizon_core::position::BlockPos;
/// `Entity.getDimensionChangingDelay`: the portal cooldown of an entity that
/// went through a portal.
pub const ENTITY_DELAY: i32 = 300;
/// `PortalProcessor` for a nether portal.
#[derive(Clone, Copy, Debug)]
pub struct Process {
+148 -17
View File
@@ -2562,14 +2562,83 @@ const INT_RULES: [&str; 11] = [
"respawn_radius",
];
/// The world presets of 26.3's `worldgen/world_preset` that the create
/// screen offers, in the order of `WorldPresetTags.NORMAL`.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum WorldPreset {
Normal,
Flat,
LargeBiomes,
Amplified,
SingleBiomeSurface,
}
impl WorldPreset {
/// The presets, in the order the world-type button cycles through them.
pub const ALL: [Self; 5] = [
Self::Normal,
Self::Flat,
Self::LargeBiomes,
Self::Amplified,
Self::SingleBiomeSurface,
];
/// The preset's registry id (`minecraft:normal`).
pub const fn id(self) -> &'static str {
match self {
Self::Normal => "minecraft:normal",
Self::Flat => "minecraft:flat",
Self::LargeBiomes => "minecraft:large_biomes",
Self::Amplified => "minecraft:amplified",
Self::SingleBiomeSurface => "minecraft:single_biome_surface",
}
}
/// The preset an id names, if it is one of them.
pub fn of_id(id: &str) -> Option<Self> {
Self::ALL.into_iter().find(|preset| preset.id() == id)
}
/// The language key the world-type button shows
/// (`Identifier.toLanguageKey("generator")`).
pub const fn label(self) -> &'static str {
match self {
Self::Normal => "generator.minecraft.normal",
Self::Flat => "generator.minecraft.flat",
Self::LargeBiomes => "generator.minecraft.large_biomes",
Self::Amplified => "generator.minecraft.amplified",
Self::SingleBiomeSurface => "generator.minecraft.single_biome_surface",
}
}
/// The preset the world-type button cycles to next.
pub fn next(self) -> Self {
let index = Self::ALL
.iter()
.position(|preset| *preset == self)
.unwrap_or(0);
Self::ALL[(index + 1) % Self::ALL.len()]
}
/// The preset a saved overworld generator is: the noise settings name
/// the preset, and the single-biome preset shares the overworld's noise
/// settings, so its fixed biome source tells the two apart.
pub fn of_generator(generator: &Tag) -> Self {
if text(generator.get("type")).as_deref() == Some("minecraft:flat") {
return Self::Flat;
}
let source = generator.get("biome_source");
let fixed = text(source.and_then(|s| s.get("type"))).as_deref() == Some("minecraft:fixed");
match text(generator.get("settings")).as_deref() {
Some("minecraft:large_biomes") => Self::LargeBiomes,
Some("minecraft:amplified") => Self::Amplified,
Some("minecraft:overworld") if fixed => Self::SingleBiomeSurface,
_ => Self::Normal,
}
}
}
/// World and player state stored in `level.dat`, the saved-data files and
/// the player file.
/// The world options CreateWorldScreen sets (`WorldOptions` and the world
/// preset) that `world_gen_settings` keeps.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct WorldGen {
/// The `minecraft:flat` preset (Superflat) instead of `normal`.
pub flat: bool,
/// The world preset the world is created with.
pub preset: WorldPreset,
pub structures: bool,
pub bonus_chest: bool,
}
@@ -2583,8 +2652,7 @@ impl WorldGen {
.and_then(|d| d.get("minecraft:overworld"))
.and_then(|o| o.get("generator"));
Some(Self {
flat: text(generator.and_then(|g| g.get("type")))
.is_some_and(|t| t.trim_start_matches("minecraft:") == "flat"),
preset: generator.map_or(WorldPreset::Normal, WorldPreset::of_generator),
structures: flag("generate_structures", true),
bonus_chest: flag("bonus_chest", false),
})
@@ -2617,7 +2685,7 @@ impl WorldGen {
impl Default for WorldGen {
fn default() -> Self {
Self {
flat: false,
preset: WorldPreset::Normal,
structures: true,
bonus_chest: false,
}
@@ -2692,6 +2760,34 @@ pub fn write_world_gen_settings_with(
("preset", string(p)),
])
};
// The overworld generator of worldgen/world_preset/<preset>.json: the
// noise settings and the biome source each preset uses.
let overworld = match options.preset {
WorldPreset::Flat => flat,
WorldPreset::LargeBiomes => dimension(
"minecraft:overworld",
"minecraft:large_biomes",
preset("minecraft:overworld"),
),
WorldPreset::Amplified => dimension(
"minecraft:overworld",
"minecraft:amplified",
preset("minecraft:overworld"),
),
WorldPreset::SingleBiomeSurface => dimension(
"minecraft:overworld",
"minecraft:overworld",
Tag::compound([
("type", string("minecraft:fixed")),
("biome", string("minecraft:plains")),
]),
),
WorldPreset::Normal => dimension(
"minecraft:overworld",
"minecraft:overworld",
preset("minecraft:overworld"),
),
};
write_saved_data(
dir,
"world_gen_settings",
@@ -2705,18 +2801,7 @@ pub fn write_world_gen_settings_with(
(
"dimensions",
Tag::compound([
(
"minecraft:overworld",
if options.flat {
flat
} else {
dimension(
"minecraft:overworld",
"minecraft:overworld",
preset("minecraft:overworld"),
)
},
),
("minecraft:overworld", overworld),
(
"minecraft:the_nether",
dimension(
@@ -2809,6 +2894,9 @@ pub struct Level {
pub food_tick_timer: i32,
/// Abilities.flying: whether creative/spectator flight is active.
pub flying: bool,
/// The vehicle the player rides, by UUID (`ServerPlayer.saveParentVehicle`'s
/// "RootVehicle"), so the ride comes back on the next login.
pub root_vehicle: Option<[i32; 4]>,
/// The player compound as loaded, for fields Horizon does not model
/// (ender chest, effects, abilities, recipe book, attributes, ...).
pub player_extra: Option<Tag>,
@@ -3091,6 +3179,12 @@ impl Level {
.collect(),
_ => Vec::new(),
};
// ServerPlayer.saveParentVehicle: the vehicle the player rides, by
// UUID. Horizon keeps the vehicle in the world, so the file names it
// instead of holding a second copy of it.
self.root_vehicle = p
.get("RootVehicle")
.and_then(|r| uuid_from_tag(r.get("Attach")));
}
/// The player compound: the loaded one with Horizon's state written over it.
pub fn player_tag(&self) -> Tag {
@@ -3299,6 +3393,18 @@ impl Level {
map.entry(key.into()).or_insert(value);
}
}
// ServerPlayer.saveParentVehicle: the vehicle the player rides, by
// UUID. Horizon keeps the vehicle in the world, so the file names it
// instead of holding a second copy of it.
if let Some(vehicle) = self.root_vehicle
&& p.get("RootVehicle").is_none()
{
put(
&mut p,
"RootVehicle",
Tag::compound([("Attach", uuid_tag(vehicle))]),
);
}
p
}
/// Write every file; a world without a player UUID gets a random one.
@@ -5376,6 +5482,31 @@ mod tests {
assert_eq!(flying_survival, 0);
}
#[test]
fn unconsumed_root_vehicle_keeps_the_complete_vanilla_tree() {
let entity = Tag::compound([
("id", Tag::String("minecraft:horse".into())),
("UUID", uuid_tag([5, 6, 7, 8])),
(
"Passengers",
Tag::List(vec![Tag::compound([
("id", Tag::String("minecraft:pig".into())),
("UUID", uuid_tag([1, 2, 3, 4])),
])]),
),
]);
let vehicle = Tag::compound([("Attach", uuid_tag([1, 2, 3, 4])), ("Entity", entity)]);
let raw = Tag::compound([("RootVehicle", vehicle.clone())]);
let mut level = Level::default();
level.read_player(&raw);
level.player_extra = Some(raw);
assert_eq!(level.root_vehicle, Some([1, 2, 3, 4]));
assert_eq!(level.player_tag().get("RootVehicle"), Some(&vehicle));
// Even saving before the vehicle is restored must not destroy it.
level.root_vehicle = None;
assert_eq!(level.player_tag().get("RootVehicle"), Some(&vehicle));
}
/// `RecipesUsed` is what a furnace owes in experience, so a save that drops it pays the
/// player nothing after a reload.
#[test]
+1 -1
View File
@@ -447,7 +447,7 @@ impl Title {
} = mode
{
if apply {
options.flat = true;
options.world_type = crate::save::WorldPreset::Flat;
options.flat_settings = Some(flat.settings);
}
self.mode = Mode::Create {
+10 -18
View File
@@ -30,8 +30,8 @@ pub struct WorldOptions {
pub difficulty: usize,
/// Allow Cheats as toggled; unset follows the game mode.
pub commands: Option<bool>,
/// The Superflat world preset instead of Default.
pub flat: bool,
/// The world preset the world is created with.
pub world_type: crate::save::WorldPreset,
/// CreateFlatWorldScreen's layers and biome, kept with the world type.
pub flat_settings: Option<super::flat::FlatSettings>,
pub structures: bool,
@@ -51,7 +51,7 @@ impl Default for WorldOptions {
hardcore: false,
difficulty: 2,
commands: None,
flat: false,
world_type: crate::save::WorldPreset::Normal,
flat_settings: None,
structures: true,
bonus_chest: false,
@@ -76,9 +76,8 @@ impl WorldOptions {
.position(|d| *d == level.difficulty)
.unwrap_or(2),
commands: Some(commands),
flat: generation.flat,
flat_settings: generation
.flat
world_type: generation.preset,
flat_settings: (generation.preset == crate::save::WorldPreset::Flat)
.then(|| super::flat::FlatSettings::from_world(dir))
.flatten(),
structures: generation.structures,
@@ -101,7 +100,7 @@ impl WorldOptions {
}
pub fn world_gen(&self) -> crate::save::WorldGen {
crate::save::WorldGen {
flat: self.flat,
preset: self.world_type,
structures: self.structures,
bonus_chest: self.bonus_chest(),
}
@@ -126,8 +125,8 @@ impl WorldOptions {
23 => self.difficulty = (self.difficulty + 1) % DIFFICULTIES.len(),
24 => self.commands = Some(!self.commands(*creative)),
25 => {
self.flat = !self.flat;
if self.flat {
self.world_type = self.world_type.next();
if self.world_type == crate::save::WorldPreset::Flat {
// setWorldType starts the generator from the flat preset.
self.flat_settings = Some(super::flat::FlatSettings::default());
}
@@ -262,14 +261,7 @@ pub(super) fn buttons(w: f32, h: f32, creative: bool, o: &WorldOptions) -> Vec<B
gx,
gy,
150.0,
labelled(
"selectWorld.mapType",
tr(if o.flat {
"generator.minecraft.flat"
} else {
"generator.minecraft.normal"
}),
),
labelled("selectWorld.mapType", tr(o.world_type.label())),
true,
25,
),
@@ -279,7 +271,7 @@ pub(super) fn buttons(w: f32, h: f32, creative: bool, o: &WorldOptions) -> Vec<B
gy,
150.0,
tr("selectWorld.customizeType"),
o.flat,
o.world_type == crate::save::WorldPreset::Flat,
26,
),
// SwitchGrid: 44-wide value-only buttons at the grid's right.
+95 -13
View File
@@ -851,27 +851,49 @@ fn create_world_tabs_follow_vanilla_and_their_choices_reach_the_action() {
};
assert!(*creative && !options.hardcore && options.commands(true));
// The World tab (second of three 124-wide tabs from x 54): its seed box
// takes the focus, Superflat and the switches toggle.
// takes the focus, the world type cycles and the switches toggle.
let (action, sound) = f.title.click(54.0 + 124.0 + 10.0, 10.0, size);
assert!(sound && action == Action::None);
let world = labels(&f);
assert_eq!((world[0].1, world[0].2), (85.0, 38.0));
assert_eq!((world[2].1, world[2].2), (85.0 + 266.0, 38.0 + 73.0));
f.title.click(world[0].1 + 10.0, world[0].2 + 10.0, size);
// The world-type button cycles through vanilla's world presets.
let world_type = |f: &Fixture| -> crate::save::WorldPreset {
let Mode::Create { options, .. } = &f.title.mode else {
panic!()
};
options.world_type
};
assert_eq!(world_type(&f), crate::save::WorldPreset::Normal);
for expected in [
crate::save::WorldPreset::Flat,
crate::save::WorldPreset::LargeBiomes,
crate::save::WorldPreset::Amplified,
crate::save::WorldPreset::SingleBiomeSurface,
crate::save::WorldPreset::Normal,
] {
f.title.click(world[0].1 + 10.0, world[0].2 + 10.0, size);
assert_eq!(world_type(&f), expected);
}
// The amplified preset is the third one.
for _ in 0..3 {
f.title.click(world[0].1 + 10.0, world[0].2 + 10.0, size);
}
f.title.click(world[3].1 + 10.0, world[3].2 + 10.0, size);
f.title.click(100.0, 38.0 + 41.0 + 10.0, size);
let Mode::Create { focus, options, .. } = &f.title.mode else {
panic!()
};
assert_eq!(*focus, 1);
assert!(options.flat && options.bonus_chest());
assert_eq!(options.world_type, crate::save::WorldPreset::Amplified);
assert!(options.bonus_chest());
let Action::Create { options, .. } = f.title.create_action() else {
panic!()
};
assert_eq!(
options.world_gen(),
crate::save::WorldGen {
flat: true,
preset: crate::save::WorldPreset::Amplified,
structures: true,
bonus_chest: true
}
@@ -906,26 +928,86 @@ fn new_world_generation_settings_round_trip() {
let f = Fixture::new();
let dir = f.root.join("flat");
let options = crate::save::WorldGen {
flat: true,
preset: crate::save::WorldPreset::Flat,
structures: false,
bonus_chest: true,
};
crate::save::write_world_gen_settings(&dir, 42, options).unwrap();
assert_eq!(crate::save::WorldGen::read(&dir), Some(options));
let settings = crate::save::world_gen_settings(&dir).unwrap();
let generator = settings
.get("dimensions")
.and_then(|d| d.get("minecraft:overworld"))
.and_then(|o| o.get("generator"));
assert_eq!(
crate::save::text(
settings
.get("dimensions")
.and_then(|d| d.get("minecraft:overworld"))
.and_then(|o| o.get("generator"))
.and_then(|g| g.get("type"))
)
.as_deref(),
crate::save::text(generator.and_then(|g| g.get("type"))).as_deref(),
Some("minecraft:flat")
);
}
#[test]
fn each_world_preset_writes_its_own_generator() {
let f = Fixture::new();
let generator = |preset: crate::save::WorldPreset| {
let dir = f.root.join(preset.id());
let options = crate::save::WorldGen {
preset,
structures: true,
bonus_chest: false,
};
crate::save::write_world_gen_settings(&dir, 42, options).unwrap();
assert_eq!(crate::save::WorldGen::read(&dir), Some(options));
let settings = crate::save::world_gen_settings(&dir).unwrap();
let generator = settings
.get("dimensions")
.and_then(|d| d.get("minecraft:overworld"))
.and_then(|o| o.get("generator"))
.cloned()
.unwrap();
(
crate::save::text(generator.get("type")),
crate::save::text(generator.get("settings")),
crate::save::text(generator.get("biome_source").and_then(|s| s.get("type"))),
)
};
assert_eq!(
generator(crate::save::WorldPreset::Normal),
(
Some("minecraft:noise".to_owned()),
Some("minecraft:overworld".to_owned()),
Some("minecraft:multi_noise".to_owned()),
)
);
assert_eq!(
generator(crate::save::WorldPreset::Amplified),
(
Some("minecraft:noise".to_owned()),
Some("minecraft:amplified".to_owned()),
Some("minecraft:multi_noise".to_owned()),
)
);
assert_eq!(
generator(crate::save::WorldPreset::LargeBiomes),
(
Some("minecraft:noise".to_owned()),
Some("minecraft:large_biomes".to_owned()),
Some("minecraft:multi_noise".to_owned()),
)
);
assert_eq!(
generator(crate::save::WorldPreset::SingleBiomeSurface),
(
Some("minecraft:noise".to_owned()),
Some("minecraft:overworld".to_owned()),
Some("minecraft:fixed".to_owned()),
)
);
assert_eq!(
generator(crate::save::WorldPreset::Flat).0,
Some("minecraft:flat".to_owned())
);
}
#[test]
fn world_fields_edit_at_their_cursor() {
use winit::keyboard::KeyCode;
@@ -0,0 +1,130 @@
//! A `/forceload`ed chunk reaches vanilla's rings (#2785): vanilla holds a
//! FORCED ticket at level 31 (`ChunkMap.FORCED_TICKET_LEVEL`), the level
//! rises one per ring (`ChunkTracker.computeLevelFromNeighbor`) and picks the
//! column's status (`ChunkLevel.generationStatus`). The chunk itself and the
//! two rings around it are level chunks with their light, the third ring has
//! its features and the fourth only its terrain.
use horizon_client::{
assets::Assets,
biome::Biomes,
blocks::BlockFacts,
chunk::Chunk,
level_loading::Status,
light::LightTables,
mesher::MeshTables,
models,
streaming::{ChunkGenerator, Proto, Streaming, WorkerContext},
world::World,
};
use horizon_world::registry::BlockStateId;
use std::{
path::Path,
sync::Arc,
thread,
time::{Duration, Instant},
};
fn resources() -> Option<std::path::PathBuf> {
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client")
.join(horizon_core::VANILLA_VERSION);
root.join("items.json").exists().then_some(root)
}
/// A column under generation, and whether it has its features.
struct Column {
x: i32,
z: i32,
decorated: bool,
}
/// A generator that makes every column: a proto, decorated on request, and
/// finished into a level chunk.
struct Plain;
impl ChunkGenerator for Plain {
type Worker = ();
fn worker(&self) {}
fn proto(&self, _: &mut (), x: i32, z: i32) -> Proto {
Box::new(Column {
x,
z,
decorated: false,
})
}
fn decorates(&self) -> bool {
true
}
/// The centre gets its features, as vanilla's FEATURES step does; the
/// neighbours are only written to.
fn decorate(&self, _: &mut (), region: &mut [Proto; 9]) {
region[4].downcast_mut::<Column>().unwrap().decorated = true;
}
fn is_decorated(&self, proto: &Proto) -> bool {
proto.downcast_ref::<Column>().is_some_and(|c| c.decorated)
}
fn finish(&self, proto: Proto) -> Chunk {
let column = *proto.downcast::<Column>().unwrap();
Chunk::empty(column.x, column.z)
}
fn light_view(&self, proto: &Proto) -> Option<Chunk> {
let column = proto.downcast_ref::<Column>()?;
Some(Chunk::empty(column.x, column.z))
}
}
#[test]
fn forced_chunks_reach_vanillas_rings() {
let Some(root) = resources() else {
return;
};
let assets = Arc::new(Assets::load(&root).unwrap());
let render = Arc::new(assets.reload(&root).unwrap());
let facts = BlockFacts::load(&root, assets.registry).unwrap();
let visibility = models::load_visibility(&root, assets.registry).unwrap();
let context = Arc::new(WorkerContext {
tables: Arc::new(MeshTables::new(&assets, &render, &facts, visibility).unwrap()),
light: Arc::new(LightTables::load(&root, assets.registry, &facts).unwrap()),
biomes: Arc::new(Biomes::load(&root).unwrap()),
assets,
render: render.clone(),
});
let mut streaming = Streaming::new(Arc::new(Plain), context, 2);
// The player stands far away: only the forced chunk's own rings are
// generated.
streaming.center = (0, 0);
streaming.radius = 2;
streaming.forced.insert((40, -40));
let mut world = World::new(BlockStateId(0), 0);
let deadline = Instant::now() + Duration::from_secs(120);
let mut quiet = 0;
while quiet < 20 {
assert!(Instant::now() < deadline, "streaming never settled");
let before = world.chunks.len();
let _ = streaming.update(&mut world);
thread::sleep(Duration::from_millis(5));
let idle = !streaming.busy() && before == world.chunks.len();
quiet = if idle { quiet + 1 } else { 0 };
}
// The chunk itself and the two rings around it are level chunks with
// their light.
for (dx, dz) in (-2..=2).flat_map(|dx| (-2..=2).map(move |dz| (dx, dz))) {
let (x, z) = (40 + dx, -40 + dz);
assert!(
world.chunks.contains_key(&(x, z)),
"({x}, {z}) is not a level chunk"
);
assert!(streaming.is_lit(x, z), "({x}, {z}) has no light");
}
// The third ring has its features, the fourth only its terrain.
for (dx, dz) in (-4i32..=4).flat_map(|dx| (-4i32..=4).map(move |dz| (dx, dz))) {
let (x, z) = (40 + dx, -40 + dz);
let ring = dx.abs().max(dz.abs());
let status = streaming.latest_status(&world, x, z);
if ring == 3 {
assert_eq!(status, Some(Status::Features), "({x}, {z})");
} else if ring == 4 {
assert_eq!(status, Some(Status::Terrain), "({x}, {z})");
}
}
}
+1 -1
View File
@@ -326,7 +326,7 @@ fn recipe_settings_packets_update_only_the_players_book_and_survive_save() {
);
}
let mut saved = horizon_client::save::Level::default();
horizon_client::lan::playerdata::store(&s.hub.guests[0], &mut saved);
horizon_client::lan::playerdata::store(&s.hub.guests[0], &mut saved, None);
let mut reopened = horizon_client::save::Level::default();
reopened.read_player(&saved.player_tag());
assert_eq!(reopened.recipe_book.open, settings.open);
+74
View File
@@ -106,6 +106,50 @@ impl BiomeZoom {
}
selected
}
/// The jitter `quart_at_block` adds to each of the eight corners of the
/// quart cell `base` (the block position minus 2, shifted right by 2), as
/// `[x, y, z]` per corner in its corner order (x, then y, then z offset).
/// With `select_corner` this gives `quart_at_block`'s choice for every
/// block of the cell from one mix.
pub fn corner_jitter(self, base: [i32; 3]) -> [[f64; 3]; 8] {
let coordinate = base.map(i64::from);
let seed = self.seed;
const A: i64 = 6364136223846793005;
const B: i64 = 1442695040888963407;
let g = |m: i64| m.wrapping_mul(m.wrapping_mul(A).wrapping_add(B));
let root = g(seed);
std::array::from_fn(|corner| {
let [x, y, z] = [(corner >> 2) & 1, (corner >> 1) & 1, corner & 1].map(|v| v as i64);
let m0 = root.wrapping_add(coordinate[0].wrapping_add(x));
let m1 = g(m0).wrapping_add(coordinate[1].wrapping_add(y));
let m2 = g(m1).wrapping_add(coordinate[2].wrapping_add(z));
let m3 = g(m2).wrapping_add(coordinate[0].wrapping_add(x));
let m4 = g(m3).wrapping_add(coordinate[1].wrapping_add(y));
let m5 = g(m4).wrapping_add(coordinate[2].wrapping_add(z));
let m6 = g(m5).wrapping_add(seed);
let m7 = g(m6).wrapping_add(seed);
[fiddle(m5), fiddle(m6), fiddle(m7)]
})
}
/// The corner (index into `corner_jitter`) `quart_at_block` picks for
/// `block` inside the cell whose jitter is `jitter`.
#[inline]
pub fn select_corner(block: BlockPos, jitter: &[[f64; 3]; 8]) -> usize {
let fractions = [block.x, block.y, block.z].map(|v| f64::from(v.wrapping_sub(2) & 3) / 4.);
let mut selected = 0;
let mut best = f64::INFINITY;
for (corner, jitter) in jitter.iter().enumerate() {
let dx = fractions[0] - ((corner >> 2) & 1) as f64 + jitter[0];
let dy = fractions[1] - ((corner >> 1) & 1) as f64 + jitter[1];
let dz = fractions[2] - (corner & 1) as f64 + jitter[2];
let distance = dz * dz + dy * dy + dx * dx;
if best > distance {
best = distance;
selected = corner;
}
}
selected
}
}
/// Direct noise-biome lookup bypasses the eight-corner zoom.
@@ -224,4 +268,34 @@ mod tests {
}
}
}
#[test]
fn corner_jitter_matches_quart_at_block() {
for seed in [0i64, -1, i64::MIN, 0x0123_4567_89ab_cdef] {
let zoom = BiomeZoom::new(seed);
let check = |block: BlockPos| {
let base = [block.x, block.y, block.z].map(|v| v.wrapping_sub(2) >> 2);
let corner = BiomeZoom::select_corner(block, &zoom.corner_jitter(base));
let offsets = [(corner >> 2) & 1, (corner >> 1) & 1, corner & 1];
let quart: [i32; 3] =
std::array::from_fn(|i| base[i].wrapping_add(offsets[i] as i32));
assert_eq!(
quart,
zoom.quart_at_block(block),
"seed {seed} at {block:?}"
);
};
for x in -20..20 {
for y in -20..20 {
for z in -20..20 {
check(BlockPos::new(x, y, z));
}
}
}
for v in [i32::MIN, i32::MIN + 1, -1, 0, i32::MAX - 1, i32::MAX] {
check(BlockPos::new(v, v, v));
check(BlockPos::new(v, v.wrapping_neg(), 3));
}
}
}
}
+3 -1
View File
@@ -10,7 +10,9 @@ description = "Port a Java modpack to Horizon: import, inventory, classification
# Content path rules only (no runtime): what the game's content loader accepts.
horizon-mods = { path = "../horizon-mods", default-features = false }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
# Exact float parsing: the upgrade writes back the numbers it read (data pack
# upgrade), and Java reads them exactly.
serde_json = { version = "1", features = ["float_roundtrip"] }
sha1 = "0.10"
toml = "1.1.6"
ureq = { version = "2", default-features = false, features = ["tls"] }
+1
View File
@@ -21,5 +21,6 @@ pub mod oracle;
pub mod pipeline;
pub mod processors;
pub mod report;
pub mod upgrade;
pub mod worldgen;
mod yung;
+23 -2
View File
@@ -25,22 +25,27 @@
//! `<out>/check.json`.
//! - `port <pack> <work> [--from <folder>] [--resources <prepared>]`: every
//! step for the portable mods, as the launcher's "Import modpack" runs it.
//! - `upgrade-datapack <pack> <out> | --world <world>`: a 26.2 data pack
//! (zip or folder) with its world generation rewritten in 26.3's formats
//! (`docs/datapack-upgrade.md`), into the folder `<out>`, or into
//! `<world>/datapacks/<pack name>-26.3`.
//!
//! The work folder belongs to the user: jars and carried-over files stay
//! there and are never uploaded or committed.
use horizon_port_pack::{fetch, inventory::Record, pipeline};
use horizon_port_pack::{fetch, inventory::Record, pipeline, upgrade};
use std::{
path::{Path, PathBuf},
process::ExitCode,
};
const USAGE: &str = "usage: horizon-port-pack import <pack> <work> | inventory <work> [--offline] | fetch <work> [--from <folder>] [<mod id>...] | carry <work> [--resources <prepared>] [<mod id>...] | report <work> | check <jar> <out> | port <pack> <work> [--from <folder>] [--resources <prepared>]";
const USAGE: &str = "usage: horizon-port-pack import <pack> <work> | inventory <work> [--offline] | fetch <work> [--from <folder>] [<mod id>...] | carry <work> [--resources <prepared>] [<mod id>...] | report <work> | check <jar> <out> | port <pack> <work> [--from <folder>] [--resources <prepared>] | upgrade-datapack <pack> (<out> | --world <world>)";
fn main() -> ExitCode {
let mut args: Vec<String> = std::env::args().skip(1).collect();
let result = (|| {
let from = option(&mut args, "--from")?;
let resources = option(&mut args, "--resources")?;
let world = option(&mut args, "--world")?;
let offline = take_flag(&mut args, "--offline");
let path = |i: usize| args.get(i).map(Path::new).ok_or(USAGE.to_owned());
let rest = || args.get(2..).unwrap_or_default().to_vec();
@@ -89,6 +94,22 @@ fn main() -> ExitCode {
println!("{}", work.join("coverage.md").display());
Ok(())
}
Some("upgrade-datapack") => {
let pack = path(1)?;
let out = match &world {
Some(world) => {
let name = pack.file_stem().unwrap_or_default().to_string_lossy();
world.join("datapacks").join(format!("{name}-26.3"))
}
None => path(2)?.to_path_buf(),
};
let report = upgrade::upgrade_into(pack, &out)?;
for line in report.lines() {
println!("{line}");
}
println!("{}", out.display());
Ok(())
}
_ => Err(USAGE.to_owned()),
}
})();
@@ -0,0 +1,323 @@
//! Density functions.
use super::{PackFile, Report, contains_type, full_id, kind, noise::noise_holder, vanilla};
use serde_json::{Map, Value, json};
use std::collections::{BTreeMap, BTreeSet};
/// Vanilla 26.2 density functions whose value does not depend on y, which
/// a pack can name without defining them.
const VANILLA_FLAT: [&str; 13] = [
"minecraft:zero",
"minecraft:shift_x",
"minecraft:shift_z",
"minecraft:overworld/continents",
"minecraft:overworld/erosion",
"minecraft:overworld/ridges",
"minecraft:overworld/ridges_folded",
"minecraft:overworld/offset",
"minecraft:overworld/factor",
"minecraft:overworld/jaggedness",
"minecraft:overworld_large_biomes/continents",
"minecraft:overworld_large_biomes/erosion",
"minecraft:end/sloped_cheese",
];
pub(super) struct Context<'a> {
/// The pack's own density functions by id, in 26.2's form.
functions: BTreeMap<String, &'a Value>,
/// The interpolation cell (`size_horizontal * 4`, `size_vertical * 4`)
/// of the noise settings being upgraded, which 26.3 writes on each
/// `interpolated`.
pub(super) cell: (i64, i64),
/// The cells of the pack's noise settings that reach each of its named
/// density functions through their routers.
cells: BTreeMap<String, BTreeSet<(i64, i64)>>,
/// The cell of every noise settings in the pack.
all_cells: BTreeSet<(i64, i64)>,
/// Named density functions the upgrade adds, by id.
pub(super) generated: BTreeMap<String, Value>,
/// Material rules the upgrade adds (one per noise settings), by id.
pub(super) rules: BTreeMap<String, Value>,
}
impl<'a> Context<'a> {
pub(super) fn new(files: &'a BTreeMap<String, Value>) -> Self {
let mut functions = BTreeMap::new();
let mut settings = Vec::new();
for (path, v) in files {
let Some(f) = PackFile::parse(path) else {
continue;
};
match f.registry.as_str() {
"worldgen/density_function" => {
functions.insert(f.id(), v);
}
"worldgen/noise_settings" => settings.push(v),
_ => {}
}
}
let mut ctx = Self {
functions,
cell: (4, 8),
cells: BTreeMap::new(),
all_cells: BTreeSet::new(),
generated: BTreeMap::new(),
rules: BTreeMap::new(),
};
for s in settings {
let cell = cell_of(s.get("noise").unwrap_or(&Value::Null));
ctx.all_cells.insert(cell);
let mut reached = BTreeSet::new();
ctx.reach(s.get("noise_router").unwrap_or(&Value::Null), &mut reached);
for id in reached {
ctx.cells.entry(id).or_default().insert(cell);
}
}
ctx
}
/// Every pack density function `v` names, directly or through others.
fn reach(&self, v: &Value, out: &mut BTreeSet<String>) {
match v {
Value::String(id) => {
let id = full_id(id);
if let Some(f) = self.functions.get(&id)
&& out.insert(id)
{
self.reach(f, out);
}
}
Value::Object(o) => o.values().for_each(|x| self.reach(x, out)),
Value::Array(a) => a.iter().for_each(|x| self.reach(x, out)),
_ => {}
}
}
/// A named density function file in 26.3's form, with the cell of the
/// noise settings that use it: vanilla's overworld cell when none of the
/// pack's do, the pack's own when it has one.
pub(super) fn named_function(&mut self, id: &str, v: &Value, report: &mut Report) -> Value {
let cells = self.cells.get(id).cloned().unwrap_or_default();
self.cell = match (cells.len(), self.all_cells.len()) {
(1, _) => *cells.iter().next().unwrap_or(&(4, 8)),
(0, 1) => *self.all_cells.iter().next().unwrap_or(&(4, 8)),
(0, _) => (4, 8),
_ => {
if contains_type(v, "interpolated") {
report.lossy("density: an interpolated function shared by noise settings with different cells");
}
*cells.iter().next().unwrap_or(&(4, 8))
}
};
self.density(v, report)
}
/// One density function (a number, an id, or an object) in 26.3's form.
pub(super) fn density(&mut self, v: &Value, report: &mut Report) -> Value {
let Value::Object(o) = v else {
return v.clone();
};
let Some(t) = kind(v) else {
return v.clone();
};
let arg = |name: &str| o.get(name).cloned().unwrap_or(Value::Null);
let unary = |this: &mut Self, report: &mut Report, to: &str| {
let input = this.density(&arg("argument"), report);
json!({"type": format!("minecraft:{to}"), "input": input})
};
match t {
"abs" | "square" | "cube" | "half_negative" | "quarter_negative" | "squeeze"
| "blend_density" => unary(self, report, t),
"invert" => unary(self, report, "reciprocal"),
"add" | "mul" | "min" | "max" => {
let left = self.density(&arg("argument1"), report);
let right = self.density(&arg("argument2"), report);
json!({"type": format!("minecraft:{t}"), "left": left, "right": right})
}
"interpolated" => {
let input = self.density(&arg("argument"), report);
json!({"type": "minecraft:interpolated", "cell_size_xz": self.cell.0,
"cell_size_y": self.cell.1, "input": input})
}
// 26.2's flat cache holds the value at y = 0 of each column
// inside the chunk; 26.3's cache only memoises.
"flat_cache" => {
let raw = arg("argument");
let input = self.density(&raw, report);
let input = if self.depends_on_y(&raw, &mut BTreeSet::new()) {
json!({"type": "minecraft:slice", "axis": "y", "coordinate": 0, "input": input})
} else {
input
};
json!({"type": "minecraft:cache", "input": input})
}
"cache_2d" | "cache_once" | "cache_all_in_cell" => {
let raw = arg("argument");
if t == "cache_2d" && self.depends_on_y(&raw, &mut BTreeSet::new()) {
report.lossy("density: cache_2d around a function of y");
}
let input = self.density(&raw, report);
json!({"type": "minecraft:cache", "input": input})
}
"y_clamped_gradient" => {
let (from, to) = (arg("from_y"), arg("to_y"));
if from == to {
// A step at that y: clampedMap's NaN at the step itself
// has no 26.3 form.
report.lossy("density: y_clamped_gradient with from_y == to_y");
let y = from.as_f64().unwrap_or_default();
return json!({"type": "minecraft:range_choice", "input": "minecraft:y",
"min_inclusive": -1_000_000.0, "max_exclusive": y,
"when_in_range": arg("from_value"), "when_out_of_range": arg("to_value")});
}
json!({"type": "minecraft:gradient", "axis": "y", "from_coordinate": from,
"to_coordinate": to, "from_value": arg("from_value"),
"to_value": arg("to_value")})
}
"noise" => {
let mut n = o.clone();
n.insert("noise".into(), noise_holder(&arg("noise"), report));
Value::Object(n)
}
"shifted_noise" => {
let mut n = Map::new();
n.insert("type".into(), vanilla("noise"));
n.insert("noise".into(), noise_holder(&arg("noise"), report));
n.insert("xz_scale".into(), arg("xz_scale"));
n.insert("y_scale".into(), arg("y_scale"));
n.insert("shift_x".into(), self.density(&arg("shift_x"), report));
let shift_y = arg("shift_y");
if !shift_y.as_f64().is_some_and(|y| y == 0.0) {
n.insert("shift_y".into(), self.density(&shift_y, report));
}
n.insert("shift_z".into(), self.density(&arg("shift_z"), report));
Value::Object(n)
}
"shift_a" | "shift_b" | "shift" => {
json!({"type": format!("minecraft:{t}"), "noise": noise_holder(&arg("argument"), report)})
}
"end_islands" => json!({"type": "minecraft:end_outer_islands"}),
"constant" => arg("argument"),
"clamp" | "range_choice" | "find_top_surface" | "interval_select" => {
let mut n = o.clone();
for key in [
"input",
"when_in_range",
"when_out_of_range",
"density",
"upper_bound",
] {
if let Some(x) = o.get(key) {
n.insert(key.into(), self.density(x, report));
}
}
if let Some(Value::Array(fs)) = o.get("functions") {
let fs = fs.iter().map(|f| self.density(f, report)).collect();
n.insert("functions".into(), Value::Array(fs));
}
Value::Object(n)
}
"spline" => {
let spline = self.spline(&arg("spline"), report);
json!({"type": "minecraft:spline", "spline": spline})
}
"old_blended_noise" | "blend_alpha" | "blend_offset" | "beardifier" => v.clone(),
other => {
report.unknown(format!("density function {other}"));
v.clone()
}
}
}
/// A cubic spline: a number, or a coordinate and points whose values are
/// splines themselves.
fn spline(&mut self, v: &Value, report: &mut Report) -> Value {
let Value::Object(o) = v else {
return v.clone();
};
let mut n = o.clone();
if let Some(c) = o.get("coordinate") {
n.insert("coordinate".into(), self.density(c, report));
}
if let Some(Value::Array(points)) = o.get("points") {
let points = points
.iter()
.map(|p| {
let mut p = p.clone();
if let Some(value) = p.get("value").cloned() {
p["value"] = self.spline(&value, report);
}
p
})
.collect();
n.insert("points".into(), Value::Array(points));
}
Value::Object(n)
}
/// Whether a 26.2 density function's value can change with y, through
/// the pack's own functions; unknown ids count as depending on y.
fn depends_on_y(&self, v: &Value, visiting: &mut BTreeSet<String>) -> bool {
match v {
Value::Number(_) => false,
Value::String(id) => {
let id = if id.contains(':') {
id.clone()
} else {
format!("minecraft:{id}")
};
if id == "minecraft:y" {
return true;
}
if let Some(f) = self.functions.get(&id) {
return visiting.insert(id.clone()) && {
let r = self.depends_on_y(f, visiting);
visiting.remove(&id);
r
};
}
!VANILLA_FLAT.contains(&id.as_str())
}
Value::Object(o) => match kind(v) {
Some("y_clamped_gradient" | "old_blended_noise" | "beardifier" | "shift") => true,
Some("flat_cache" | "end_islands" | "find_top_surface" | "constant") => false,
Some("blend_alpha" | "blend_offset" | "shift_a" | "shift_b") => false,
Some("noise") => o.get("y_scale").and_then(Value::as_f64) != Some(0.0),
Some("shifted_noise") => {
o.get("y_scale").and_then(Value::as_f64) != Some(0.0)
|| ["shift_x", "shift_y", "shift_z"]
.iter()
.any(|k| o.get(*k).is_some_and(|x| self.depends_on_y(x, visiting)))
}
Some("spline") => self.spline_depends_on_y(&o["spline"], visiting),
_ => o
.iter()
.filter(|(k, _)| *k != "type")
.any(|(_, x)| match x {
Value::Array(a) => a.iter().any(|x| self.depends_on_y(x, visiting)),
x => self.depends_on_y(x, visiting),
}),
},
_ => false,
}
}
fn spline_depends_on_y(&self, v: &Value, visiting: &mut BTreeSet<String>) -> bool {
let Value::Object(o) = v else {
return false;
};
o.get("coordinate")
.is_some_and(|c| self.depends_on_y(c, visiting))
|| o.get("points").and_then(Value::as_array).is_some_and(|ps| {
ps.iter().any(|p| {
p.get("value")
.is_some_and(|x| self.spline_depends_on_y(x, visiting))
})
})
}
}
/// The interpolation cell of 26.2 `noise` settings.
pub(super) fn cell_of(noise: &Value) -> (i64, i64) {
let size = |k: &str, d: i64| noise.get(k).and_then(Value::as_i64).unwrap_or(d) * 4;
(size("size_horizontal", 1), size("size_vertical", 2))
}
@@ -0,0 +1,122 @@
//! Configured and placed features.
use super::{Report, kind, states, vanilla};
use serde_json::{Map, Value, json};
/// Feature types 26.3 dropped for compositions of other features; a pack
/// using them needs vanilla's composition, which the upgrade does not
/// write.
const REPLACED_FEATURES: [&str; 12] = [
"basalt_columns",
"basalt_pillar",
"coral_mushroom",
"desert_well",
"glowstone_blob",
"kelp",
"nether_forest_vegetation",
"sea_pickle",
"seagrass",
"twisting_vines",
"weeping_vines",
"random_patch",
];
/// 26.2 block state provider types and their 26.3 names.
fn provider(t: &str) -> Option<&'static str> {
Some(match t {
"simple_state_provider" => "simple_state_provider",
"weighted_state_provider" => "weighted",
"rotated_block_provider" => "rotated",
"randomized_int_state_provider" => "randomized_int",
"rule_based_state_provider" => "rule_based",
"noise_provider" => "noise",
"dual_noise_provider" => "dual_noise",
"noise_threshold_provider" => "noise_threshold",
_ => return None,
})
}
/// One placement modifier: `random_offset` became `offset` with one
/// provider per axis (x and z both from `xz_spread`, drawn in that order).
fn placement(m: &mut Value) {
if kind(m) == Some("random_offset") {
let xz = m.get("xz_spread").cloned().unwrap_or(json!(0));
let y = m.get("y_spread").cloned().unwrap_or(json!(0));
*m = json!({"type": "minecraft:offset", "x": xz.clone(), "y": y, "z": xz});
}
}
/// A configured feature in 26.3's form: `config` unwrapped, block state
/// providers renamed, nested features and placements upgraded.
pub(super) fn feature(v: &Value, report: &mut Report) -> Value {
let Value::Object(o) = v else {
return v.clone();
};
let Some(t) = kind(v) else {
return v.clone();
};
if REPLACED_FEATURES.contains(&t) {
report.unknown(format!("feature {t} (written as data in 26.3)"));
}
let mut n = Map::new();
n.insert("type".into(), o["type"].clone());
if let Some(Value::Object(config)) = o.get("config") {
for (k, x) in config {
n.insert(k.clone(), x.clone());
}
}
let mut v = Value::Object(n);
states(&mut v);
nested(&mut v, report);
v
}
/// Inside a feature: block state providers, placed features and
/// configured features held inline.
fn nested(v: &mut Value, report: &mut Report) {
match v {
Value::Object(o) => {
if let Some(t) = kind(&Value::Object(o.clone())) {
if let Some(to) = provider(t) {
if t == "simple_state_provider" {
*v = o.get("state").cloned().unwrap_or_default();
return;
}
o.insert("type".into(), vanilla(to));
}
}
// An inline placed feature: {feature, placement}.
if o.contains_key("placement") && o.contains_key("feature") {
let placed = placed(&Value::Object(o.clone()), report);
*v = placed;
return;
}
for x in o.values_mut() {
if x.get("config").is_some() && x.get("type").is_some() {
*x = feature(x, report);
} else {
nested(x, report);
}
}
}
Value::Array(a) => a.iter_mut().for_each(|x| nested(x, report)),
_ => {}
}
}
/// A placed feature: its feature (an id or inline) and its placement
/// modifiers.
pub(super) fn placed(v: &Value, report: &mut Report) -> Value {
let mut v = v.clone();
states(&mut v);
if let Some(f) = v.get("feature").cloned()
&& f.is_object()
{
v["feature"] = feature(&f, report);
}
if let Some(Value::Array(ms)) = v.get_mut("placement") {
for m in ms.iter_mut() {
placement(m);
}
}
v
}
+630
View File
@@ -0,0 +1,630 @@
//! A 26.2 data pack's world generation, rewritten in 26.3's formats.
//!
//! 26.3 reworked the worldgen registries: configured carvers and features
//! became `carver` and `feature` with their `config` unwrapped, noise
//! parameters gained a base amplitude, density functions name their inputs
//! `input`/`left`/`right`, the noise settings moved aquifers, ore veins and
//! the surface rule out of the router into an `aquifers` object and a
//! `material_rule`, and biomes carry their mob spawns as attributes. Each
//! conversion here is the one vanilla's own data went through between 26.2
//! and 26.3, written so that the 26.3 pack generates what the 26.2 pack did:
//! the hard-coded parts of 26.2 (the beardifier added to the final density,
//! the deep dark aquifer exclusion, the ore veinifier, the interpolation
//! cell from `size_horizontal`/`size_vertical`) are spelled out as data.
//! Whatever has no faithful 26.3 form is kept as it is and listed in the
//! report.
mod density;
mod features;
mod noise;
mod registries;
mod settings;
use density::Context;
use noise::noise;
pub use noise::parity_base_amplitude;
use registries::{biome, carver, dimension_type, structure};
use serde_json::{Value, json};
use std::{collections::BTreeMap, fs, io::Read, path::Path};
/// 26.3's data pack format (`version.json`'s `pack_version.data_major`).
pub const PACK_FORMAT: u64 = 121;
/// What the upgrade did and what it could not do faithfully.
#[derive(Default, Debug)]
pub struct Report {
/// Files rewritten, per registry.
pub upgraded: BTreeMap<String, usize>,
/// Files written that the 26.2 pack did not have (named density
/// functions the noise settings share).
pub added: Vec<String>,
/// Parts that changed meaning or were dropped, with how often.
pub lossy: BTreeMap<String, usize>,
/// Types the upgrade does not know, left as they are.
pub unknown: BTreeMap<String, usize>,
}
impl Report {
fn lossy(&mut self, what: impl Into<String>) {
*self.lossy.entry(what.into()).or_default() += 1;
}
fn unknown(&mut self, what: impl Into<String>) {
*self.unknown.entry(what.into()).or_default() += 1;
}
}
/// Every file of a pack, by its path inside the pack (`data/...`,
/// `pack.mcmeta`), upgraded. Files outside world generation are kept as
/// they are.
pub fn upgrade_pack(files: &BTreeMap<String, Vec<u8>>) -> (BTreeMap<String, Vec<u8>>, Report) {
let mut report = Report::default();
let mut json: BTreeMap<String, Value> = BTreeMap::new();
let mut out: BTreeMap<String, Vec<u8>> = BTreeMap::new();
for (path, bytes) in files {
let parsed = path
.ends_with(".json")
.then(|| serde_json::from_slice::<Value>(bytes).ok())
.flatten();
match parsed {
Some(v) if path.starts_with("data/") => {
json.insert(path.clone(), v);
}
_ => {
out.insert(path.clone(), bytes.clone());
}
}
}
let mut ctx = Context::new(&json);
let mut written: BTreeMap<String, Value> = BTreeMap::new();
for (path, v) in &json {
let Some(file) = PackFile::parse(path) else {
written.insert(path.clone(), v.clone());
continue;
};
let upgraded = match file.registry.as_str() {
"worldgen/noise" => Some(noise(v, &mut report)),
"worldgen/density_function" => Some(ctx.named_function(&file.id(), v, &mut report)),
"worldgen/noise_settings" => Some(ctx.settings(&file.id(), v, &mut report)),
"worldgen/configured_carver" => Some(carver(v, &mut report)),
"worldgen/configured_feature" => Some(features::feature(v, &mut report)),
"worldgen/placed_feature" => Some(features::placed(v, &mut report)),
"worldgen/biome" => Some(biome(v, &mut report)),
"worldgen/structure" => Some(structure(v)),
"worldgen/world_preset" => Some(ctx.world_preset(v, &mut report)),
"dimension" => Some(ctx.dimension(v, &mut report)),
"dimension_type" => Some(dimension_type(v)),
"worldgen/template_pool" | "worldgen/processor_list" => {
let mut v = v.clone();
states(&mut v);
Some(v)
}
_ => None,
};
let path = match file.registry.as_str() {
"worldgen/configured_carver" => file.with_registry("worldgen/carver"),
"worldgen/configured_feature" => file.with_registry("worldgen/feature"),
_ => path.clone(),
};
match upgraded {
Some(u) => {
*report.upgraded.entry(file.registry.clone()).or_default() += 1;
written.insert(path, u);
}
None => {
written.insert(path, v.clone());
}
}
}
for (id, v) in std::mem::take(&mut ctx.generated) {
let path = registry_path("worldgen/density_function", &id);
report.added.push(path.clone());
written.insert(path, v);
}
for (id, v) in std::mem::take(&mut ctx.rules) {
let path = registry_path("worldgen/material_rule", &id);
report.added.push(path.clone());
written.insert(path, v);
}
for (path, v) in written {
out.insert(path, pretty(&v));
}
if let Some(meta) = out
.get("pack.mcmeta")
.and_then(|b| serde_json::from_slice(b).ok())
{
out.insert("pack.mcmeta".into(), pretty(&mcmeta(meta)));
}
(out, report)
}
fn pretty(v: &Value) -> Vec<u8> {
let mut bytes = serde_json::to_vec_pretty(v).unwrap_or_default();
bytes.push(b'\n');
bytes
}
/// `pack.mcmeta` for 26.3: the pack format range is 26.3's alone.
fn mcmeta(mut meta: Value) -> Value {
if let Some(pack) = meta.get_mut("pack").and_then(Value::as_object_mut) {
pack.remove("supported_formats");
pack.insert("pack_format".into(), json!(PACK_FORMAT));
pack.insert("min_format".into(), json!(PACK_FORMAT));
pack.insert("max_format".into(), json!(PACK_FORMAT));
if let Some(Value::String(d)) = pack.get_mut("description") {
d.push_str(" (upgraded to 26.3)");
}
}
meta
}
/// `data/<ns>/<registry...>/<path>.json`, split by the registries the
/// upgrade knows (a registry name has one or two segments).
struct PackFile {
namespace: String,
registry: String,
path: String,
}
impl PackFile {
fn parse(path: &str) -> Option<Self> {
let rest = path.strip_prefix("data/")?.strip_suffix(".json")?;
let (namespace, rest) = rest.split_once('/')?;
let (registry, path) = if let Some(r) = rest.strip_prefix("worldgen/") {
let (kind, path) = r.split_once('/')?;
(format!("worldgen/{kind}"), path)
} else {
rest.split_once('/').map(|(k, p)| (k.to_owned(), p))?
};
Some(Self {
namespace: namespace.into(),
registry,
path: path.into(),
})
}
fn id(&self) -> String {
format!("{}:{}", self.namespace, self.path)
}
fn with_registry(&self, registry: &str) -> String {
format!("data/{}/{registry}/{}.json", self.namespace, self.path)
}
}
fn registry_path(registry: &str, id: &str) -> String {
let (ns, path) = id.split_once(':').unwrap_or(("minecraft", id));
format!("data/{ns}/{registry}/{path}.json")
}
/// A type name without its `minecraft:` prefix, as the codecs read both.
fn kind(v: &Value) -> Option<&str> {
let t = v.get("type")?.as_str()?;
Some(t.strip_prefix("minecraft:").unwrap_or(t))
}
/// An id with its namespace (`minecraft:` when it has none).
fn full_id(id: &str) -> String {
if id.contains(':') {
id.to_owned()
} else {
format!("minecraft:{id}")
}
}
/// Whether an object of type `t` appears anywhere in `v`.
fn contains_type(v: &Value, t: &str) -> bool {
match v {
Value::Object(o) => kind(v) == Some(t) || o.values().any(|x| contains_type(x, t)),
Value::Array(a) => a.iter().any(|x| contains_type(x, t)),
_ => false,
}
}
fn vanilla(name: &str) -> Value {
Value::String(format!("minecraft:{name}"))
}
// ---------------------------------------------------------------------------
// Block states
/// 26.2 writes a block or fluid state as `{"Name": ..., "Properties":
/// {...}}`; 26.3 reads `{"id": ..., "properties": {...}}` (or the id alone
/// for the default state).
fn states(v: &mut Value) {
match v {
Value::Object(o) => {
let state = o.get("Name").is_some_and(Value::is_string)
&& o.keys().all(|k| k == "Name" || k == "Properties");
if state {
let name = o.remove("Name").unwrap_or_default();
match o.remove("Properties") {
Some(Value::Object(p)) if !p.is_empty() => {
o.insert("id".into(), name);
o.insert("properties".into(), Value::Object(p));
}
_ => *v = name,
}
return;
}
o.values_mut().for_each(states);
}
Value::Array(a) => a.iter_mut().for_each(states),
_ => {}
}
}
// ---------------------------------------------------------------------------
// Files
/// A pack's files by their path inside it, from a zip or a folder. A zip
/// whose `pack.mcmeta` sits in one top folder is read from that folder.
pub fn read_pack(path: &Path) -> Result<BTreeMap<String, Vec<u8>>, String> {
let at = |e: &dyn std::fmt::Display| format!("{}: {e}", path.display());
let mut files = BTreeMap::new();
if path.is_dir() {
let mut stack = vec![path.to_path_buf()];
while let Some(dir) = stack.pop() {
for entry in fs::read_dir(&dir).map_err(|e| at(&e))? {
let p = entry.map_err(|e| at(&e))?.path();
if p.is_dir() {
stack.push(p);
} else if let Ok(rel) = p.strip_prefix(path) {
let rel = rel.to_string_lossy().replace('\\', "/");
files.insert(rel, fs::read(&p).map_err(|e| at(&e))?);
}
}
}
} else {
let file = fs::File::open(path).map_err(|e| at(&e))?;
let mut zip = zip::ZipArchive::new(file).map_err(|e| at(&e))?;
for i in 0..zip.len() {
let mut entry = zip.by_index(i).map_err(|e| at(&e))?;
if entry.is_dir() {
continue;
}
// A pack from the internet may name entries `../x` or `/x`.
let name = entry
.enclosed_name()
.filter(|name| is_inside(name))
.ok_or_else(|| at(&format!("entry {:?} leaves the pack", entry.name())))?;
let mut bytes = Vec::new();
entry.read_to_end(&mut bytes).map_err(|e| at(&e))?;
files.insert(name.to_string_lossy().replace('\\', "/"), bytes);
}
}
if !files.contains_key("pack.mcmeta") {
let top = files
.keys()
.find_map(|k| k.strip_suffix("/pack.mcmeta").filter(|t| !t.contains('/')))
.map(|t| format!("{t}/"));
if let Some(top) = top {
files = files
.into_iter()
.filter_map(|(k, v)| k.strip_prefix(&top).map(|k| (k.to_owned(), v)))
.collect();
}
}
if !files.contains_key("pack.mcmeta") {
return Err(format!("{}: no pack.mcmeta", path.display()));
}
Ok(files)
}
/// Upgrades the pack at `input` into the folder `out`, which must not
/// exist yet (a world's `datapacks/<name>`).
pub fn upgrade_into(input: &Path, out: &Path) -> Result<Report, String> {
if out.exists() {
return Err(format!("{} already exists", out.display()));
}
let files = read_pack(input)?;
let (upgraded, report) = upgrade_pack(&files);
for (path, bytes) in upgraded {
if !is_inside(Path::new(&path)) {
return Err(format!("{path}: leaves {}", out.display()));
}
let target = out.join(&path);
if let Some(parent) = target.parent() {
fs::create_dir_all(parent).map_err(|e| format!("{}: {e}", parent.display()))?;
}
fs::write(&target, bytes).map_err(|e| format!("{}: {e}", target.display()))?;
}
Ok(report)
}
/// Whether `path` is a plain relative path: no root, drive, `.` or `..`.
fn is_inside(path: &Path) -> bool {
path.components().next().is_some()
&& path
.components()
.all(|c| matches!(c, std::path::Component::Normal(_)))
}
impl Report {
/// The report as lines for the terminal.
pub fn lines(&self) -> Vec<String> {
let mut lines: Vec<String> = self
.upgraded
.iter()
.map(|(r, n)| format!("upgraded {n} {r}"))
.collect();
if !self.added.is_empty() {
lines.push(format!(
"added {} shared density functions",
self.added.len()
));
}
lines.extend(
self.lossy
.iter()
.map(|(w, n)| format!("not faithful ({n}x): {w}")),
);
lines.extend(
self.unknown
.iter()
.map(|(w, n)| format!("left as it is ({n}x): {w}")),
);
lines
}
}
#[cfg(test)]
mod tests {
use super::*;
fn up(path: &str, v: Value) -> (Value, Report) {
let files = BTreeMap::from([(path.to_owned(), serde_json::to_vec(&v).unwrap())]);
let (out, report) = upgrade_pack(&files);
let (_, bytes) = out
.into_iter()
.find(|(p, _)| !p.contains("/upgraded/"))
.unwrap();
(serde_json::from_slice(&bytes).unwrap(), report)
}
#[test]
fn noise_keeps_the_octaves_and_finds_the_parity_amplitude() {
let (v, _) = up(
"data/x/worldgen/noise/n.json",
json!({"firstOctave": -7, "amplitudes": [1.0, 2.0, 1.0, 0.0, 0.0, 0.0]}),
);
// Vanilla 26.3's minecraft:ridge, which 26.2 wrote as above.
assert_eq!(
v,
json!({"base_amplitude": 0.9147152149950137, "base_octave": -7, "octave_count": 6,
"amplitude_modifiers": [1.0, 2.0, 1.0, 0.0, 0.0, 0.0]})
);
let (v, _) = up(
"data/x/worldgen/noise/n.json",
json!({"firstOctave": -3, "amplitudes": [1.0]}),
);
// Vanilla 26.3's minecraft:aquifer_barrier.
assert_eq!(
v,
json!({"base_amplitude": 0.955388882960065, "base_octave": -3})
);
}
#[test]
fn density_arguments_and_removed_types() {
let (v, _) = up(
"data/x/worldgen/density_function/f.json",
json!({"type": "minecraft:add", "argument1": {"type": "minecraft:constant", "argument": 2.0},
"argument2": {"type": "minecraft:abs", "argument": {"type": "minecraft:y_clamped_gradient",
"from_y": -64, "to_y": 320, "from_value": 1.5, "to_value": -1.5}}}),
);
assert_eq!(
v,
json!({"type": "minecraft:add", "left": 2.0, "right": {"type": "minecraft:abs",
"input": {"type": "minecraft:gradient", "axis": "y", "from_coordinate": -64,
"to_coordinate": 320, "from_value": 1.5, "to_value": -1.5}}})
);
let (v, _) = up(
"data/x/worldgen/density_function/f.json",
json!({"type": "minecraft:shifted_noise", "noise": "minecraft:erosion",
"shift_x": "minecraft:shift_x", "shift_y": 0.0, "shift_z": "minecraft:shift_z",
"xz_scale": 0.25, "y_scale": 0.0}),
);
assert_eq!(
v,
json!({"type": "minecraft:noise", "noise": "minecraft:erosion", "shift_x": "minecraft:shift_x",
"shift_z": "minecraft:shift_z", "xz_scale": 0.25, "y_scale": 0.0})
);
}
#[test]
fn flat_cache_slices_at_y_zero_only_what_depends_on_y() {
let flat = |argument: Value| {
up(
"data/x/worldgen/density_function/f.json",
json!({"type": "minecraft:flat_cache", "argument": argument}),
)
.0
};
let n = json!({"type": "minecraft:noise", "noise": "x:n", "xz_scale": 1.0, "y_scale": 0.0});
assert_eq!(
flat(n.clone()),
json!({"type": "minecraft:cache", "input": n})
);
let y = json!({"type": "minecraft:mul", "argument1": "minecraft:y", "argument2": 2.0});
assert_eq!(
flat(y),
json!({"type": "minecraft:cache", "input": {"type": "minecraft:slice", "axis": "y",
"coordinate": 0, "input": {"type": "minecraft:mul", "left": "minecraft:y", "right": 2.0}}})
);
}
#[test]
fn interpolation_cell_comes_from_the_settings() {
let settings = json!({"noise": {"min_y": 0, "height": 128, "size_horizontal": 2, "size_vertical": 1},
"noise_router": {}, "surface_rule": {"type": "minecraft:sequence", "sequence": []},
"default_block": {"Name": "minecraft:stone"}, "default_fluid": {"Name": "minecraft:water",
"Properties": {"level": "0"}}, "aquifers_enabled": false, "ore_veins_enabled": false,
"sea_level": 32, "disable_mob_generation": false, "legacy_random_source": false,
"spawn_target": []});
let files = BTreeMap::from([
(
"data/x/worldgen/noise_settings/s.json".to_owned(),
serde_json::to_vec(&settings).unwrap(),
),
(
"data/x/worldgen/density_function/f.json".to_owned(),
serde_json::to_vec(&json!({"type": "minecraft:interpolated", "argument": 1.0}))
.unwrap(),
),
]);
let (out, _) = upgrade_pack(&files);
let f: Value =
serde_json::from_slice(&out["data/x/worldgen/density_function/f.json"]).unwrap();
assert_eq!(
f,
json!({"type": "minecraft:interpolated", "cell_size_xz": 8, "cell_size_y": 4, "input": 1.0})
);
let s: Value =
serde_json::from_slice(&out["data/x/worldgen/noise_settings/s.json"]).unwrap();
assert_eq!(s["noise"], json!({"min_y": 0, "height": 128}));
assert_eq!(
s["default_fluid"],
json!({"id": "minecraft:water", "properties": {"level": "0"}})
);
assert_eq!(s["default_block"], json!("minecraft:stone"));
assert!(s.get("aquifers").is_none());
assert_eq!(
s["noise_router"]["final_density"]["right"],
json!({"type": "minecraft:beardifier"})
);
}
#[test]
fn settings_move_aquifers_veins_and_spawn_target() {
let settings = json!({"noise": {"min_y": -64, "height": 384, "size_horizontal": 1, "size_vertical": 2},
"noise_router": {"barrier": 1.0, "fluid_level_floodedness": 2.0, "fluid_level_spread": 3.0,
"lava": 4.0, "temperature": "x:t", "vegetation": "x:v", "continents": "x:c",
"erosion": "x:e", "depth": "x:d", "ridges": "x:r", "final_density": "x:f",
"preliminary_surface_level": {"type": "minecraft:constant", "argument": 64.0},
"vein_toggle": 0.5, "vein_ridged": -1.0, "vein_gap": 0.0},
"surface_rule": {"type": "minecraft:block", "result_state": {"Name": "minecraft:dirt"}},
"default_block": {"Name": "minecraft:stone"}, "default_fluid": {"Name": "minecraft:water"},
"aquifers_enabled": true, "ore_veins_enabled": true, "sea_level": 63,
"disable_mob_generation": false, "legacy_random_source": false,
"spawn_target": [{"temperature": [-1, 1], "humidity": [-1, 1], "continentalness": [-0.11, 1],
"erosion": [-1, 1], "weirdness": [-1, -0.16], "depth": 0, "offset": 0}]});
let files = BTreeMap::from([(
"data/x/worldgen/noise_settings/s.json".to_owned(),
serde_json::to_vec(&settings).unwrap(),
)]);
let (out, _) = upgrade_pack(&files);
let read = |p: &str| -> Value { serde_json::from_slice(&out[p]).unwrap() };
let s = read("data/x/worldgen/noise_settings/s.json");
assert_eq!(s["material_rule"], json!("x:s/upgraded/material_rule"));
let rule = read("data/x/worldgen/material_rule/s/upgraded/material_rule.json");
let aquifers = &s["aquifers"];
assert_eq!(aquifers["barrier"], json!(1.0));
assert_eq!(
aquifers["surface_level"],
json!("x:s/upgraded/preliminary_surface_level")
);
assert_eq!(aquifers["exclusion"]["left"]["right"], json!("x:e"));
let rules = rule["sequence"].as_array().unwrap();
assert_eq!(rules.len(), 3);
assert_eq!(rules[0]["type"], json!("minecraft:ore_vein"));
assert_eq!(
rules[2],
json!({"type": "minecraft:block", "result_state": "minecraft:dirt"})
);
assert_eq!(
s["spawn_target"],
json!([{"x:t": [-1, 1], "x:v": [-1, 1], "x:c": [-0.11, 1], "x:e": [-1, 1], "x:r": [-1, -0.16]}])
);
assert_eq!(
s["noise_router"]["chunk_surface_level"],
json!({"type": "minecraft:interpolated", "cell_size_xz": 16, "cell_size_y": 1,
"input": "x:s/upgraded/preliminary_surface_level"})
);
}
#[test]
fn features_lose_their_config_and_placements_their_spread() {
let (v, _) = up(
"data/x/worldgen/configured_feature/f.json",
json!({"type": "minecraft:disk", "config": {"state_provider": {"type":
"minecraft:simple_state_provider", "state": {"Name": "minecraft:clay"}}, "radius": 2}}),
);
assert_eq!(
v,
json!({"type": "minecraft:disk", "state_provider": "minecraft:clay", "radius": 2})
);
let (v, _) = up(
"data/x/worldgen/placed_feature/p.json",
json!({"feature": "x:f", "placement": [{"type": "minecraft:random_offset",
"xz_spread": 0, "y_spread": -1}]}),
);
assert_eq!(
v,
json!({"feature": "x:f", "placement": [{"type": "minecraft:offset", "x": 0, "y": -1, "z": 0}]})
);
}
#[test]
fn biome_spawns_become_attributes() {
let (v, _) = up(
"data/x/worldgen/biome/b.json",
json!({"spawners": {"monster": [{"type": "minecraft:zombie", "minCount": 4, "maxCount": 4,
"weight": 95}], "ambient": []}, "spawn_costs": {}, "creature_spawn_probability": 0.07,
"attributes": {"minecraft:visual/sky_color": "#78a7ff"}}),
);
assert_eq!(
v["attributes"]["minecraft:gameplay/natural_mob_spawns"],
json!({"modifier": "overlay", "argument": {"spawn_costs": {}, "spawns_by_category": {
"monster": [{"type": "minecraft:zombie", "count": 4, "weight": 95}], "ambient": []}}})
);
assert_eq!(
v["attributes"]["minecraft:gameplay/creature_world_gen_spawn_probability"],
json!(0.07)
);
assert!(v.get("spawners").is_none());
}
#[test]
fn entries_outside_the_pack_are_refused() {
use std::io::Write;
let dir = std::env::temp_dir().join(format!("upgrade-escape-{}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
for (i, name) in ["../escape.txt", "data/../../escape.txt", "/escape.txt"]
.into_iter()
.enumerate()
{
let zip_path = dir.join(format!("p{i}.zip"));
let mut zip = zip::ZipWriter::new(fs::File::create(&zip_path).unwrap());
for (file, bytes) in [("pack.mcmeta", &b"{}"[..]), (name, b"x")] {
zip.start_file(file, zip::write::SimpleFileOptions::default())
.unwrap();
zip.write_all(bytes).unwrap();
}
zip.finish().unwrap();
let out = dir.join(format!("out{i}/datapacks/p-26.3"));
let err = upgrade_into(&zip_path, &out).unwrap_err();
assert!(err.contains("leaves the pack"), "{name}: {err}");
assert!(!out.exists(), "{name}");
}
assert!(!dir.join("escape.txt").exists());
assert!(is_inside(Path::new("data/x/worldgen/biome/b.json")));
assert!(!is_inside(Path::new("data/../x.json")));
assert!(!is_inside(Path::new("")));
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn carvers_move_and_spell_out_their_hard_coded_shape() {
let files = BTreeMap::from([(
"data/x/worldgen/configured_carver/c.json".to_owned(),
serde_json::to_vec(&json!({"type": "minecraft:canyon", "config": {"probability": 0.01,
"yScale": 3.0, "lava_level": {"above_bottom": 8}, "shape": {"width_smoothness": 3}}}))
.unwrap(),
)]);
let (out, _) = upgrade_pack(&files);
let v: Value = serde_json::from_slice(&out["data/x/worldgen/carver/c.json"]).unwrap();
assert_eq!(
v,
json!({"type": "minecraft:canyon", "probability": 0.01,
"shape": {"width_smoothness": 3, "y_scale": 3.0}})
);
}
}
@@ -0,0 +1,112 @@
//! Noise parameters: 26.2's `{firstOctave, amplitudes}` as 26.3's `NormalNoise` parameters.
use super::Report;
use serde_json::{Map, Value, json};
/// `{firstOctave, amplitudes}` to 26.3's `NormalNoise` parameters, as
/// vanilla's `NormalNoise.createParity` builds them: the same octaves and
/// the base amplitude that keeps 26.2's normalisation.
pub(super) fn noise(v: &Value, report: &mut Report) -> Value {
let (Some(first), Some(amps)) = (
v.get("firstOctave").and_then(Value::as_i64),
v.get("amplitudes").and_then(Value::as_array),
) else {
return v.clone();
};
let amps: Vec<f64> = amps.iter().filter_map(Value::as_f64).collect();
if amps.is_empty() || amps.len() > 32 || amps.iter().any(|a| *a < 0.0) {
report.lossy("noise: more than 32 octaves or negative amplitudes (left as they are)");
return v.clone();
}
let mut o = Map::new();
let base = parity_base_amplitude(first as i32, &amps);
if base != 1.0 {
o.insert("base_amplitude".into(), json!(base));
}
o.insert("base_octave".into(), json!(first));
if amps.len() != 1 {
o.insert("octave_count".into(), json!(amps.len()));
}
if amps.iter().any(|a| *a != 1.0) {
o.insert("amplitude_modifiers".into(), json!(amps));
}
Value::Object(o)
}
/// `NormalNoise.computeParityBaseAmplitude`.
pub fn parity_base_amplitude(base_octave: i32, amps: &[f64]) -> f64 {
let octaves = build_octaves(base_octave, 1.0, amps.len(), amps);
let target: f64 = java_sum(octaves.iter().map(|a| a.abs()));
let new = normalization_factor(target, &octaves);
if new == 0.0 {
return 1.0;
}
parity_normalization_factor(1.0, amps) / new
}
/// `NormalNoise.buildOctaves` with normalisation on: the octaves' signed
/// amplitudes (zero-modifier octaves left out).
fn build_octaves(_base_octave: i32, base_amplitude: f64, count: usize, mods: &[f64]) -> Vec<f64> {
let n = count as i32;
let mut amplitude = base_amplitude * (0.5f64.powi(-(n - 1)) / (0.5f64.powi(-n) - 1.0));
let mut out = Vec::new();
for i in 0..count {
let m = mods.get(i).copied().unwrap_or(1.0);
if m != 0.0 {
out.push(amplitude * m);
}
amplitude *= 0.5;
}
out
}
fn normalization_factor(target: f64, octaves: &[f64]) -> f64 {
let mut variance = 0.0;
for a in octaves {
let layer = 0.2702247831245211 * a.abs();
variance += layer * layer;
}
let deviation = variance.sqrt();
if deviation == 0.0 {
return 0.0;
}
target * 0.3333333333333333 / (deviation * 2f64.sqrt())
}
fn parity_normalization_factor(base_amplitude: f64, mods: &[f64]) -> f64 {
let (mut min, mut max) = (i32::MAX, i32::MIN);
for (i, m) in mods.iter().enumerate() {
if *m != 0.0 {
min = min.min(i as i32);
max = max.max(i as i32);
}
}
let span = max.wrapping_sub(min);
base_amplitude * 0.5 * 0.3333333333333333 / (0.1 * (1.0 + 1.0 / f64::from(span + 1)))
}
/// `DoubleStream.sum()`: Kahan summation as `Collectors.sumWithCompensation`
/// and `computeFinalSum` do it.
fn java_sum(values: impl Iterator<Item = f64>) -> f64 {
let (mut sum, mut c, mut simple) = (0.0f64, 0.0f64, 0.0f64);
for v in values {
simple += v;
let tmp = v - c;
let velvel = sum + tmp;
c = (velvel - sum) - tmp;
sum = velvel;
}
let tmp = sum - c;
if tmp.is_nan() && simple.is_infinite() {
simple
} else {
tmp
}
}
/// A noise holder: an id, or inline parameters to upgrade.
pub(super) fn noise_holder(v: &Value, report: &mut Report) -> Value {
match v {
Value::Object(_) => noise(v, report),
other => other.clone(),
}
}
@@ -0,0 +1,168 @@
//! Carvers, biomes, structures and dimension types.
use super::{Report, kind, states, vanilla};
use serde_json::{Map, Value, json};
/// A configured carver in 26.3's form. 26.2's cave carvers hard-coded their
/// count, thickness and the room's vertical scale per type; 26.3 reads them.
pub(super) fn carver(v: &Value, report: &mut Report) -> Value {
let Some(t) = kind(v) else {
return v.clone();
};
let mut c = v.get("config").cloned().unwrap_or_default();
let Some(o) = c.as_object_mut() else {
return v.clone();
};
o.remove("debug_settings");
let lava = o.remove("lava_level");
let replaceable = o.remove("replaceable");
if lava.is_some_and(|l| l != json!({"above_bottom": 8}) && l != json!({"above_bottom": 10})) {
report.lossy("carver: lava_level (26.3 takes it from the aquifer)");
}
if replaceable.is_some_and(|r| {
r != json!("#minecraft:overworld_carver_replaceables")
&& r != json!("#minecraft:nether_carver_replaceables")
}) {
report.lossy("carver: replaceable (26.3 carves any stone)");
}
let y_scale = o.remove("yScale");
let mut n = Map::new();
match t {
"cave" | "nether_cave" => {
let nether = t == "nether_cave";
n.insert("type".into(), vanilla("cave"));
n.insert(
"count".into(),
json!({"type": "minecraft:very_biased_to_bottom", "min_inclusive": 0,
"max_inclusive": if nether { 9 } else { 14 }}),
);
n.insert(
"thickness".into(),
if nether {
json!({"type": "minecraft:trapezoid", "min": 0.0, "max": 6.0, "plateau": 2.0})
} else {
json!({"type": "minecraft:trapezoid", "min": 0.0, "max": 3.0, "plateau": 1.0})
},
);
if !nether {
n.insert("weird_thickness_bias".into(), json!(true));
} else {
n.insert("start_vertical_radius_multiplier".into(), json!(5.0));
}
if let Some(s) = y_scale {
n.insert("room_vertical_radius_multiplier".into(), s);
}
}
"canyon" => {
n.insert("type".into(), vanilla("canyon"));
if let (Some(s), Some(shape)) =
(y_scale, o.get_mut("shape").and_then(Value::as_object_mut))
{
shape.insert("y_scale".into(), s);
}
}
other => {
report.unknown(format!("carver {other}"));
n.insert("type".into(), v["type"].clone());
}
}
for (k, x) in o.iter() {
n.insert(k.clone(), x.clone());
}
let mut v = Value::Object(n);
states(&mut v);
v
}
/// `{type, minCount, maxCount, weight}` to `{type, count, weight}`.
fn spawner(s: &Value) -> Value {
let mut s = s.clone();
if let Some(o) = s.as_object_mut() {
let min = o.remove("minCount");
let max = o.remove("maxCount");
if let (Some(min), Some(max)) = (min, max) {
let count = if min == max {
min
} else {
json!({"type": "minecraft:uniform", "min_inclusive": min, "max_inclusive": max})
};
o.insert("count".into(), count);
}
}
s
}
/// A biome in 26.3's form: mob spawns become the
/// `minecraft:gameplay/natural_mob_spawns` attribute (every category kept,
/// empty ones too, so nothing comes from the dimension's defaults), and the
/// creature spawn probability its own attribute.
pub(super) fn biome(v: &Value, report: &mut Report) -> Value {
let mut v = v.clone();
states(&mut v);
let Some(o) = v.as_object_mut() else {
return v;
};
let spawners = o.remove("spawners");
let costs = o.remove("spawn_costs");
let probability = o.remove("creature_spawn_probability");
let attributes = o
.entry("attributes")
.or_insert_with(|| json!({}))
.as_object_mut();
let Some(attributes) = attributes else {
report.lossy("biome: attributes is not an object");
return v;
};
if let Some(Value::Object(categories)) = spawners {
let by_category: Map<String, Value> = categories
.iter()
.map(|(c, list)| {
let list = list.as_array().into_iter().flatten().map(spawner).collect();
(c.clone(), Value::Array(list))
})
.collect();
attributes.insert(
"minecraft:gameplay/natural_mob_spawns".into(),
json!({"modifier": "overlay", "argument": {
"spawn_costs": costs.unwrap_or(json!({})),
"spawns_by_category": by_category}}),
);
}
if let Some(p) = probability
&& p.as_f64().is_some_and(|p| (p as f32) != 0.1)
{
attributes.insert(
"minecraft:gameplay/creature_world_gen_spawn_probability".into(),
p,
);
}
v
}
/// A dimension type: the bed rule's `explodes` is 26.3's `destroy_on_use`.
pub(super) fn dimension_type(v: &Value) -> Value {
let mut v = v.clone();
if let Some(rule) = v
.pointer_mut("/attributes/minecraft:gameplay~1bed_rule")
.and_then(Value::as_object_mut)
&& let Some(explodes) = rule.remove("explodes")
{
rule.insert("destroy_on_use".into(), explodes);
}
v
}
/// A structure: its spawn overrides' counts in 26.3's form.
pub(super) fn structure(v: &Value) -> Value {
let mut v = v.clone();
states(&mut v);
if let Some(Value::Object(overrides)) = v.get_mut("spawn_overrides") {
for o in overrides.values_mut() {
if let Some(Value::Array(spawns)) = o.get_mut("spawns") {
for s in spawns.iter_mut() {
*s = spawner(s);
}
}
}
}
v
}
@@ -0,0 +1,252 @@
//! Noise settings, dimensions and world presets.
use super::{
Report,
density::{Context, cell_of},
kind, states, vanilla,
};
use serde_json::{Map, Value, json};
impl Context<'_> {
/// A density function the settings use in several places, as an id: its
/// own id when it is one, otherwise a new named function under
/// `<settings>/upgraded/<name>`.
pub(super) fn named(&mut self, settings: &str, name: &str, v: Value) -> Value {
if v.is_string() {
return v;
}
let (ns, path) = settings.split_once(':').unwrap_or(("minecraft", settings));
let id = format!("{ns}:{path}/upgraded/{name}");
self.generated.insert(id.clone(), v);
Value::String(id)
}
/// 26.2 noise settings in 26.3's form.
pub(super) fn settings(&mut self, id: &str, v: &Value, report: &mut Report) -> Value {
let Value::Object(o) = v else {
return v.clone();
};
if o.contains_key("material_rule") {
return v.clone();
}
let router = o.get("noise_router").cloned().unwrap_or_default();
self.cell = cell_of(o.get("noise").unwrap_or(&Value::Null));
let conv = |this: &mut Self, report: &mut Report, name: &str| {
this.density(&router.get(name).cloned().unwrap_or(json!(0.0)), report)
};
// The climate functions are named: the spawn target and the aquifer
// exclusion refer to them by id.
let climate = |this: &mut Self, report: &mut Report, name: &str| {
let f = conv(this, report, name);
this.named(id, name, f)
};
let temperature = climate(self, report, "temperature");
let vegetation = climate(self, report, "vegetation");
let continents = climate(self, report, "continents");
let erosion = climate(self, report, "erosion");
let depth = climate(self, report, "depth");
let ridges = climate(self, report, "ridges");
let final_density = conv(self, report, "final_density");
let surface = conv(self, report, "preliminary_surface_level");
let surface = self.named(id, "preliminary_surface_level", surface);
let mut n = Map::new();
let mut noise = o.get("noise").cloned().unwrap_or_default();
if let Some(noise) = noise.as_object_mut() {
noise.remove("size_horizontal");
noise.remove("size_vertical");
}
n.insert("noise".into(), noise);
for key in ["default_block", "default_fluid"] {
let mut s = o.get(key).cloned().unwrap_or_default();
states(&mut s);
n.insert(key.into(), s);
}
let aquifers = o
.get("aquifers_enabled")
.and_then(Value::as_bool)
.unwrap_or(true);
if aquifers {
let barrier = conv(self, report, "barrier");
let floodedness = conv(self, report, "fluid_level_floodedness");
let spread = conv(self, report, "fluid_level_spread");
let lava = conv(self, report, "lava");
// 26.2's OverworldBiomeBuilder.isDeepDarkRegion: no aquifer
// where erosion < -0.225 and depth > 0.9 (positive excludes).
let exclusion = json!({"type": "minecraft:min",
"left": {"type": "minecraft:sub", "left": -0.225, "right": erosion.clone()},
"right": {"type": "minecraft:max",
"left": {"type": "minecraft:sub", "left": depth.clone(), "right": 0.9},
"right": 0.0}});
n.insert(
"aquifers".into(),
json!({"barrier": barrier, "exclusion": exclusion,
"fluid_level_floodedness": floodedness, "fluid_level_spread": spread,
"lava": lava, "surface_level": surface.clone()}),
);
}
let mut rule = o.get("surface_rule").cloned().unwrap_or(json!({
"type": "minecraft:sequence", "sequence": []}));
states(&mut rule);
let veins = o
.get("ore_veins_enabled")
.and_then(Value::as_bool)
.unwrap_or(false);
let rule = if veins {
let toggle = conv(self, report, "vein_toggle");
let toggle = self.named(
id,
"vein_toggle",
json!({"type": "minecraft:cache", "input": toggle}),
);
let ridged = conv(self, report, "vein_ridged");
let ridged = self.named(id, "vein_ridged", ridged);
let gap = conv(self, report, "vein_gap");
let gap = self.named(id, "vein_gap", gap);
// Ore veins come first: 26.2 placed them while filling the
// noise, and its surface pass only replaced the default block.
let mut sequence = ore_veins(&toggle, &ridged, &gap);
sequence.push(rule);
json!({"type": "minecraft:sequence", "sequence": sequence})
} else {
rule
};
n.insert(
"noise_router".into(),
json!({"temperature": temperature, "vegetation": vegetation,
"continents": continents, "erosion": erosion,
"depth": depth, "ridges": ridges,
"chunk_surface_level": {"type": "minecraft:interpolated", "cell_size_xz": 16,
"cell_size_y": 1, "input": surface},
"final_density": {"type": "minecraft:add", "left": final_density,
"right": {"type": "minecraft:beardifier"}}}),
);
// Named, as vanilla names its own (and as Horizon reads them), under
// the settings' id so other vanilla settings keep their rule.
let (ns, path) = id.split_once(':').unwrap_or(("minecraft", id));
let rule_id = format!("{ns}:{path}/upgraded/material_rule");
self.rules.insert(rule_id.clone(), rule);
n.insert("material_rule".into(), Value::String(rule_id));
let climate = [
("temperature", n["noise_router"]["temperature"].clone()),
("humidity", n["noise_router"]["vegetation"].clone()),
("continentalness", n["noise_router"]["continents"].clone()),
("erosion", n["noise_router"]["erosion"].clone()),
("weirdness", n["noise_router"]["ridges"].clone()),
];
let spawn: Vec<Value> = o
.get("spawn_target")
.and_then(Value::as_array)
.into_iter()
.flatten()
.map(|p| {
for key in ["depth", "offset"] {
if p.get(key).is_some_and(|d| d.as_f64() != Some(0.0)) {
report.lossy(format!("noise settings: spawn target {key} other than 0"));
}
}
let mut point = Map::new();
for (param, f) in &climate {
if let (Some(range), Some(f)) = (p.get(*param), f.as_str()) {
point.insert(f.into(), range.clone());
}
}
Value::Object(point)
})
.collect();
n.insert("spawn_target".into(), Value::Array(spawn));
for key in [
"sea_level",
"disable_mob_generation",
"legacy_random_source",
] {
if let Some(x) = o.get(key) {
n.insert(key.into(), x.clone());
}
}
Value::Object(n)
}
/// A dimension (`dimension/<id>.json` or a world preset's entry): an
/// inline noise settings object in its generator is upgraded.
pub(super) fn dimension(&mut self, v: &Value, report: &mut Report) -> Value {
let mut v = v.clone();
let noise = v.get("generator").and_then(kind) == Some("noise");
if let Some(settings) = v.pointer("/generator/settings").cloned()
&& settings.is_object()
&& noise
{
v["generator"]["settings"] = self.settings("minecraft:inline", &settings, report);
}
v
}
pub(super) fn world_preset(&mut self, v: &Value, report: &mut Report) -> Value {
let mut v = v.clone();
if let Some(dims) = v.get_mut("dimensions").and_then(Value::as_object_mut) {
for d in dims.values_mut() {
*d = self.dimension(d, report);
}
}
v
}
}
/// 26.2's `OreVeinifier` as 26.3 material rules, one per vein type, from
/// the pack's `vein_toggle`, `vein_ridged` and `vein_gap`: a block between
/// the type's heights where the toggle (copper above zero, iron below)
/// clears 0.4 less the edge roundoff, and the ridged noise is negative, is
/// the filler with chance 0.7, and ore where the richness draw and the gap
/// noise allow.
fn ore_veins(toggle: &Value, ridged: &Value, gap: &Value) -> Vec<Value> {
let types = [
("copper_ore", "raw_copper_block", "granite", 0, 50, false),
(
"deepslate_iron_ore",
"raw_iron_block",
"tuff",
-60,
-8,
true,
),
];
types
.iter()
.map(|(ore, raw, filler, min_y, max_y, negate)| {
let veininess = if *negate {
json!({"type": "minecraft:negate", "input": toggle})
} else {
toggle.clone()
};
// clampedMap(min(maxY - y, y - minY), 0, 20, -0.2, 0)
let edge = json!({"type": "minecraft:add",
"left": {"type": "minecraft:mul",
"left": {"type": "minecraft:clamp",
"input": {"type": "minecraft:min",
"left": {"type": "minecraft:sub", "left": f64::from(*max_y), "right": "minecraft:y"},
"right": {"type": "minecraft:sub", "left": "minecraft:y", "right": f64::from(*min_y)}},
"min": 0.0, "max": 20.0},
"right": 0.01},
"right": -0.2});
let density = json!({"type": "minecraft:range_choice", "input": "minecraft:y",
"min_inclusive": f64::from(*min_y), "max_exclusive": f64::from(max_y + 1),
"when_in_range": {"type": "minecraft:range_choice", "input": ridged,
"min_inclusive": -1_000_000.0, "max_exclusive": 0.0,
"when_in_range": {"type": "minecraft:range_choice",
"input": {"type": "minecraft:add",
"left": {"type": "minecraft:sub", "left": veininess, "right": 0.4},
"right": edge},
"min_inclusive": 0.0, "max_exclusive": 1_000_000.0,
"when_in_range": 0.7, "when_out_of_range": -1.0},
"when_out_of_range": -1.0},
"when_out_of_range": -1.0});
// clampedMap(|toggle|, 0.4, 0.6, 0.1, 0.3)
let richness = json!({"type": "minecraft:add",
"left": {"type": "minecraft:clamp", "input": {"type": "minecraft:abs", "input": toggle},
"min": 0.4, "max": 0.6},
"right": -0.3});
json!({"type": "minecraft:ore_vein", "ore_block": vanilla(ore),
"raw_ore_block": vanilla(raw), "filler_block": vanilla(filler),
"raw_ore_chance": 0.02, "density": density, "richness": richness,
"filler_gap": {"type": "minecraft:sub", "left": -0.3, "right": gap}})
})
.collect()
}
@@ -3,8 +3,9 @@
//! `item::Slot` and prints, per stack, whether every byte was consumed, the
//! patch's component ids (added, then removed), which of them Horizon
//! decoded into its own lossy types (the rest it reads past keeping their
//! bytes, or decodes into ids it writes back unchanged, like trims and instruments) and
//! whether encoding the decoded stack gives the same bytes back.
//! bytes, or decodes into ids it writes back unchanged, like trims, instruments
//! and stew effects) and whether encoding the decoded stack gives the same
//! bytes back.
//!
//! cargo run -p horizon-protocol --example item_wire_probe -- vanilla.jsonl rust.jsonl
use horizon_protocol::{
@@ -49,7 +50,10 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
.filter(|(_, c)| {
!matches!(
c,
Component::Raw(_) | Component::Trim { .. } | Component::Instrument(_)
Component::Raw(_)
| Component::Trim { .. }
| Component::Instrument(_)
| Component::StewEffects(_)
)
})
.map(|(k, _)| *k)
+17 -1
View File
@@ -40,6 +40,7 @@ pub mod component {
pub const CHARGED_PROJECTILES: i32 = 51;
pub const BUNDLE_CONTENTS: i32 = 52;
pub const POTION_CONTENTS: i32 = 53;
pub const SUSPICIOUS_STEW_EFFECTS: i32 = 55;
pub const WRITABLE_BOOK_CONTENT: i32 = 56;
pub const WRITTEN_BOOK_CONTENT: i32 = 57;
pub const TRIM: i32 = 58;
@@ -119,6 +120,9 @@ pub enum Component {
material: i32,
pattern: i32,
},
/// `SuspiciousStewEffects`: each entry's `minecraft:mob_effect` id and
/// duration in ticks.
StewEffects(Vec<(i32, i32)>),
/// `InstrumentComponent` naming its instrument by `minecraft:instrument`
/// registry id; a direct instrument stays [`Component::Raw`].
Instrument(i32),
@@ -234,6 +238,16 @@ impl Component {
let n = r.count(256)?;
Self::Lore((0..n).map(|_| r.nbt()).collect::<Result<_>>()?)
}
// SuspiciousStewEffects.Entry.STREAM_CODEC: MobEffect.STREAM_CODEC
// (the registry id), then the duration, both varints.
SUSPICIOUS_STEW_EFFECTS => {
let n = r.count(r.remaining())?;
Self::StewEffects(
(0..n)
.map(|_| Ok((r.varint()?, r.varint()?)))
.collect::<Result<_>>()?,
)
}
ENCHANTMENTS | STORED_ENCHANTMENTS => {
let n = r.count(r.remaining())?;
Self::Enchantments(
@@ -442,7 +456,7 @@ impl Component {
w.varint(lines.len() as i32);
lines.iter().for_each(|l| w.nbt(l));
}
Self::Enchantments(list) => {
Self::Enchantments(list) | Self::StewEffects(list) => {
w.varint(list.len() as i32);
for (e, level) in list {
w.varint(*e);
@@ -1077,6 +1091,8 @@ mod tests {
pattern: 1,
}
}
// night_vision for 100 ticks, poison for the default 160.
55 => *c == Component::StewEffects(vec![(15, 100), (18, 160)]),
63 => *c == Component::Instrument(1),
_ => matches!(c, Component::Raw(_)),
};
+7
View File
@@ -87,6 +87,13 @@ impl Ticks {
self.blocks.add_chunk(pos, blocks)?;
self.fluids.add_chunk(pos, fluids)
}
/// A moving-piston block entity loaded with its chunk, as a new ticker.
pub fn load_moving_piston(&mut self, pos: BlockPos, mut piston: super::pistons::MovingPiston) {
self.moving_pistons.retain(|(p, _)| *p != pos);
self.next_block_entity_id += 1;
piston.id = self.next_block_entity_id;
self.moving_pistons.push((pos, piston));
}
/// Register a chunk without saved ticks.
pub fn add_empty_chunk(&mut self, pos: ChunkPos) -> Result<(), TickError> {
self.add_chunk(pos, ChunkTicks::default(), ChunkTicks::default())
@@ -188,6 +188,9 @@ impl<T: Clone> BiomeRegion<T> {
pub fn quart_at_block(&self, block: BlockPos) -> [i32; 3] {
self.zoom.quart_at_block(block)
}
pub fn zoom(&self) -> BiomeZoom {
self.zoom
}
/// Distinct values currently stored anywhere in the chunk. The result is
/// sorted by biome identity; callers must not use this order as registry order.
pub fn present_in_chunk(&self, p: ChunkPos) -> Result<Vec<T>, BiomeRegionError>
+126 -30
View File
@@ -1738,12 +1738,28 @@ fn memo_biome(
memo.biomes.insert(key, id);
Ok(id)
}
// Lazy equality proofs for the eight quart corners of a block-biome cell.
// Adjacent blocks share these candidates even though their zoom distances differ.
// Per quart cell of a block-biome lookup: a lazy equality proof for its
// eight corners, or (when they differ) their jitter and biomes, so the blocks
// of the cell only redo the distance comparison.
#[derive(Clone, Copy, PartialEq)]
enum SurfaceCell {
Unknown,
/// A corner lookup failed: only the original lookup may report errors.
Lookup,
Uniform(BiomeId),
Mixed(u32),
}
struct MixedCell {
jitter: [[f64; 3]; 8],
biomes: [BiomeId; 8],
}
struct UniformSurfaceBiomes {
origin: [i32; 3],
height: i32,
cells: Vec<Option<Option<BiomeId>>>,
cells: Vec<SurfaceCell>,
mixed: Vec<MixedCell>,
/// The cell of the previous lookup (columns are walked down in y).
last: ([i32; 3], SurfaceCell),
}
impl UniformSurfaceBiomes {
fn new(pos: ChunkPos, min_y: i32, height: i32) -> Self {
@@ -1755,43 +1771,63 @@ impl UniformSurfaceBiomes {
pos.z.wrapping_shl(4).wrapping_sub(2) >> 2,
],
height,
cells: vec![None; 25 * height as usize],
cells: vec![SurfaceCell::Unknown; 25 * height as usize],
mixed: Vec::new(),
last: ([0; 3], SurfaceCell::Unknown),
}
}
#[inline]
fn get(&mut self, region: &BiomeRegion<BiomeId>, p: BlockPos) -> Option<BiomeId> {
let base = [p.x, p.y, p.z].map(|v| v.wrapping_sub(2) >> 2);
let cell = if self.last.0 == base && self.last.1 != SurfaceCell::Unknown {
self.last.1
} else {
let cell = self.cell(region, base);
self.last = (base, cell);
cell
};
match cell {
SurfaceCell::Uniform(id) => Some(id),
SurfaceCell::Mixed(index) => {
let mixed = &self.mixed[index as usize];
Some(mixed.biomes[BiomeZoom::select_corner(p, &mixed.jitter)])
}
SurfaceCell::Unknown | SurfaceCell::Lookup => None,
}
}
fn cell(&mut self, region: &BiomeRegion<BiomeId>, base: [i32; 3]) -> SurfaceCell {
let d: [i32; 3] = std::array::from_fn(|i| base[i].wrapping_sub(self.origin[i]));
if !(0..5).contains(&d[0]) || !(0..self.height).contains(&d[1]) || !(0..5).contains(&d[2]) {
return None;
return SurfaceCell::Lookup;
}
let slot = &mut self.cells[((d[0] * 5 + d[2]) * self.height + d[1]) as usize];
if let Some(value) = slot {
return *value;
if *slot != SurfaceCell::Unknown {
return *slot;
}
let value = (|| {
// These are immutable stored lookups. A missing candidate prevents
// the shortcut; only the original selected lookup may report errors.
let first = *region.noise_biome(base).ok()?;
for x in 0..2 {
for y in 0..2 {
for z in 0..2 {
if *region
.noise_biome([
base[0].wrapping_add(x),
base[1].wrapping_add(y),
base[2].wrapping_add(z),
])
.ok()?
!= first
{
return None;
}
}
// These are immutable stored lookups, in quart_at_block's corner order.
let mut biomes = [BiomeId(0); 8];
for (corner, biome) in biomes.iter_mut().enumerate() {
let offsets = [(corner >> 2) & 1, (corner >> 1) & 1, corner & 1];
match region.noise_biome(std::array::from_fn(|i| {
base[i].wrapping_add(offsets[i] as i32)
})) {
Ok(id) => *biome = *id,
Err(_) => {
*slot = SurfaceCell::Lookup;
return SurfaceCell::Lookup;
}
}
Some(first)
})();
*slot = Some(value);
}
let value = if biomes.iter().all(|id| *id == biomes[0]) {
SurfaceCell::Uniform(biomes[0])
} else {
self.mixed.push(MixedCell {
jitter: region.zoom().corner_jitter(base),
biomes,
});
SurfaceCell::Mixed(self.mixed.len() as u32 - 1)
};
*slot = value;
value
}
}
@@ -1838,6 +1874,14 @@ impl<R: RandomSource> MaterialSurfaceQueries for SurfaceQueries<'_, '_, R> {
self.possible.clone()
}
fn biome(&mut self, p: BlockPos) -> Result<Option<Arc<str>>, MaterialRuleError> {
Ok(self
.biome_id(p)?
.map(|id| self.pipeline.data.biome_names[id as usize].clone()))
}
fn biome_index(&self, name: &str) -> Option<Option<u32>> {
Some(self.pipeline.data.biome_id(name).map(|id| id.0))
}
fn biome_id(&mut self, p: BlockPos) -> Result<Option<u32>, MaterialRuleError> {
let data = &self.pipeline.data;
let id = match &mut self.biome {
Biomes::Region(region) => match self
@@ -1864,7 +1908,7 @@ impl<R: RandomSource> MaterialSurfaceQueries for SurfaceQueries<'_, '_, R> {
self.pipeline.zoom.quart_at_block(p),
)?,
};
Ok(Some(data.biome_names[id.0 as usize].clone()))
Ok(Some(id.0))
}
fn cold_enough_to_snow(
&mut self,
@@ -3751,4 +3795,56 @@ mod tests {
assert!(Arc::ptr_eq(first, hit));
}
}
/// The resources the worldgen tests read.
fn test_root() -> std::path::PathBuf {
std::env::var_os("HORIZON_TEST_RESOURCES")
.or_else(|| std::env::var_os("HORIZON_RESOURCES"))
.map(std::path::PathBuf::from)
.unwrap_or_else(|| {
Path::new(env!("CARGO_MANIFEST_DIR")).join("../../.local/horizon-client/26.3")
})
}
/// The block registry the worldgen bundle needs. `GeneratorData` keeps it
/// for as long as the generator lives, so it is shared, not freed.
static TEST_REGISTRY: std::sync::LazyLock<BlockRegistry> = std::sync::LazyLock::new(|| {
crate::level::data::load_registry(&test_root()).expect("registry")
});
/// The world presets with their own noise settings generate the terrain
/// those settings describe: the amplified and large-biome presets are not
/// the overworld's terrain under another name.
#[test]
fn world_presets_use_their_own_noise_settings() {
let root = test_root();
if !root.join("worldgen/world_presets.json").exists() {
eprintln!("skipping: no worldgen bundle at {}", root.display());
return;
}
let data =
GeneratorData::load(&root.join("worldgen"), &TEST_REGISTRY).expect("worldgen data");
let surface = |preset: &str| {
let generator = OverworldGenerator::for_world(
data.clone(),
1234,
preset,
"minecraft:overworld",
false,
)
.expect("generator");
let mut worker = generator.worker();
(0..32)
.map(|i| {
worker
.surface_sample(i * 137, i * 91)
.expect("surface")
.height
})
.collect::<Vec<_>>()
};
let normal = surface("minecraft:normal");
assert!(normal.iter().all(|height| (0..384).contains(height)));
assert_ne!(normal, surface("minecraft:amplified"));
assert_ne!(normal, surface("minecraft:large_biomes"));
}
}
@@ -53,6 +53,16 @@ pub trait MaterialSurfaceQueries {
fn biome(&mut self, _position: BlockPos) -> Result<Option<Arc<str>>, MaterialRuleError> {
Err(MaterialRuleError::MissingBiomeProvider)
}
/// `biome` as the index `biome_index` gives its name, for queries that
/// number their biomes: biome conditions then compare numbers.
fn biome_id(&mut self, _position: BlockPos) -> Result<Option<u32>, MaterialRuleError> {
Err(MaterialRuleError::MissingBiomeProvider)
}
/// `Some` when `biome_id` answers: the index of the biome `name` (`None`
/// inside when no biome has that name).
fn biome_index(&self, _name: &str) -> Option<Option<u32>> {
None
}
fn melts_frozen_ocean(
&mut self,
_biome: &str,
@@ -98,6 +108,12 @@ impl<T: MaterialSurfaceQueries + ?Sized> MaterialSurfaceQueries for &mut T {
fn biome(&mut self, position: BlockPos) -> Result<Option<Arc<str>>, MaterialRuleError> {
(**self).biome(position)
}
fn biome_id(&mut self, position: BlockPos) -> Result<Option<u32>, MaterialRuleError> {
(**self).biome_id(position)
}
fn biome_index(&self, name: &str) -> Option<Option<u32>> {
(**self).biome_index(name)
}
fn cold_enough_to_snow(
&mut self,
biome: &str,
@@ -198,6 +214,7 @@ struct Context<S> {
noise_indices: BTreeMap<(String, bool), usize>,
possible_biomes: Option<BTreeSet<String>>,
biome: Option<Arc<str>>,
biome_id: Option<u32>,
}
struct CachedNoise {
sampler: Arc<dyn Noise>,
@@ -227,6 +244,12 @@ impl<S: MaterialSurfaceQueries> Context<S> {
}
Ok(self.biome.clone())
}
fn biome_id(&mut self) -> Result<Option<u32>, MaterialRuleError> {
if self.biome_id.is_none() {
self.biome_id = self.surface.biome_id(self.position)?;
}
Ok(self.biome_id)
}
fn bind_noise(&mut self, name: &str, is_3d: bool) -> Result<usize, DecodeError> {
let key = (name.to_owned(), is_3d);
if let Some(index) = self.noise_indices.get(&key) {
@@ -287,6 +310,8 @@ struct Compiled<R: RandomSource> {
enum Kind<R: RandomSource> {
Simple(MaterialCondition),
Biome(BTreeSet<String>),
/// `Biome` for queries that number their biomes: a bit per index.
BiomeIds(Vec<u64>),
Constant(bool),
Noise {
sampler: usize,
@@ -317,6 +342,7 @@ where
match self {
Self::Simple(condition) => Self::Simple(condition.clone()),
Self::Biome(names) => Self::Biome(names.clone()),
Self::BiomeIds(bits) => Self::BiomeIds(bits.clone()),
Self::Constant(value) => Self::Constant(*value),
Self::Noise { sampler, min, max } => Self::Noise {
sampler: *sampler,
@@ -416,7 +442,28 @@ impl<R: RandomSource> Compiled<R> {
match &context.possible_biomes {
Some(possible) if names.is_disjoint(possible) => Kind::Constant(false),
Some(possible) if possible.is_subset(&names) => Kind::Constant(true),
_ => Kind::Biome(names),
// An empty list would not ask whether the surface numbers
// its biomes.
_ if names.is_empty() => Kind::Biome(names),
_ => match names
.iter()
.map(|name| context.surface.biome_index(name))
.collect::<Option<Vec<_>>>()
{
// Names no biome has never match the block's biome.
Some(ids) => {
let mut bits = Vec::new();
for id in ids.into_iter().flatten() {
let word = id as usize / 64;
if bits.len() <= word {
bits.resize(word + 1, 0);
}
bits[word] |= 1 << (id % 64);
}
Kind::BiomeIds(bits)
}
None => Kind::Biome(names),
},
}
}
MaterialCondition::NoiseThreshold {
@@ -510,6 +557,11 @@ impl<R: RandomSource> Compiled<R> {
.ok_or(MaterialRuleError::MissingBiome)?
.as_ref(),
),
Kind::BiomeIds(bits) => {
let id = context.biome_id()?.ok_or(MaterialRuleError::MissingBiome)?;
bits.get(id as usize / 64)
.is_some_and(|word| word >> (id % 64) & 1 == 1)
}
Kind::Simple(MaterialCondition::Hole) => context.depth <= 0,
Kind::Simple(MaterialCondition::Steep) => {
context.gradient[0] <= -4 || context.gradient[1] >= 4
@@ -627,6 +679,7 @@ impl<R: RandomSource, S: MaterialSurfaceQueries> MaterialConditions<R, S> {
noise_indices: BTreeMap::new(),
possible_biomes,
biome: None,
biome_id: None,
},
conditions: Vec::new(),
rules: Vec::new(),
@@ -654,6 +707,7 @@ impl<R: RandomSource, S: MaterialSurfaceQueries> MaterialConditions<R, S> {
pub fn update_y(&mut self, stone_above: i32, stone_below: i32, water_height: i32, y: i32) {
self.context.block_revision = self.context.block_revision.wrapping_add(1);
self.context.biome = None;
self.context.biome_id = None;
self.context.position.y = y;
self.context.stone_above = stone_above;
self.context.stone_below = stone_below;
@@ -159,6 +159,12 @@ impl<S: MaterialSurfaceQueries> MaterialSurfaceQueries for MaterialDensitySource
fn biome(&mut self, position: BlockPos) -> Result<Option<Arc<str>>, MaterialRuleError> {
self.surface.biome(position)
}
fn biome_id(&mut self, position: BlockPos) -> Result<Option<u32>, MaterialRuleError> {
self.surface.biome_id(position)
}
fn biome_index(&self, name: &str) -> Option<Option<u32>> {
self.surface.biome_index(name)
}
fn melts_frozen_ocean(
&mut self,
biome: &str,
@@ -70,6 +70,12 @@ impl<P: PositionalRandom, S: MaterialSurfaceQueries> MaterialSurfaceQueries
fn biome(&mut self, position: BlockPos) -> Result<Option<Arc<str>>, MaterialRuleError> {
self.surface.biome(position)
}
fn biome_id(&mut self, position: BlockPos) -> Result<Option<u32>, MaterialRuleError> {
self.surface.biome_id(position)
}
fn biome_index(&self, name: &str) -> Option<Option<u32>> {
self.surface.biome_index(name)
}
fn melts_frozen_ocean(
&mut self,
biome: &str,
@@ -23,10 +23,11 @@ struct Queries {
biomes: usize,
fail_binding: bool,
fail_temperature: bool,
any_biome: bool,
}
impl MaterialSurfaceQueries for Queries {
fn possible_biomes(&self) -> Option<BTreeSet<String>> {
Some(BTreeSet::from(["minecraft:plains".into()]))
(!self.any_biome).then(|| BTreeSet::from(["minecraft:plains".into()]))
}
fn surface_depth(&mut self, _: i32, _: i32) -> i32 {
1
@@ -117,6 +118,22 @@ fn constant_branches_keep_binding_order_and_short_circuit_queries() {
assert_eq!(q.temperatures, 0);
}
#[test]
fn an_empty_biome_list_matches_nothing_on_a_surface_without_biome_ids() {
let mut w = worker(Queries {
any_biome: true,
..Queries::default()
});
let rule = w
.add_rule(condition(
MaterialCondition::Biome(Vec::new()),
MaterialRule::Block(BlockStateId(9)),
))
.unwrap();
w.update_xz(4, 5, 0, 0);
w.update_y(1, 1, i32::MIN, 64);
assert_eq!(w.try_apply(rule, BlockPos::new(4, 64, 5)), Ok(None));
}
#[test]
fn unreachable_children_still_fail_compilation_without_adding_a_rule() {
for preceding_block in [false, true] {
let mut w = worker(Queries {
+1
View File
@@ -15,6 +15,7 @@ To play, use [the launcher](../launcher/README.md); the project site is [playhor
- [Extras](extras.md): optional zoom, minimap, FPS and keystroke overlays and auto-reconnect, all off by default.
- [Shader packs](shaders.md): Horizon's own WGSL shader packs, how to make and check one (`horizon-shader`).
- [Mods](mods.md): Horizon's own mod system (Rust, sandboxed WebAssembly), how to make and install mods.
- [Upgrading a 26.2 data pack](datapack-upgrade.md): `horizon-port-pack upgrade-datapack` rewrites a 26.2 worldgen pack in 26.3's formats, and how the rewrite is checked against vanilla.
- [Modpacks](modpacks.md): porting whole packs (ATM10 and smaller): import, inventory, carry-over, an agent per mod with a harness against the Java original, coverage, and how "10x" is measured.
- [Launcher](../launcher/README.md): installs, updates and starts builds; its CLI and MCP server; releasing it.
- [Versioning](versioning.md): how releases are numbered from 0.2 (`v0.2.x`, `[email protected]`), how installs from before 0.2 get there, and the steps for the day 0.2 ships.
+86
View File
@@ -0,0 +1,86 @@
# Upgrading a 26.2 data pack to 26.3
26.3 reworked the world generation registries, and vanilla 26.3 refuses a
worldgen data pack written for 26.2 (registry errors on load). Horizon
targets 26.3, so such a pack has to be rewritten first:
```sh
cargo run --release -p horizon-port-pack -- upgrade-datapack PACK.zip --world <world folder>
# or into any folder:
cargo run --release -p horizon-port-pack -- upgrade-datapack PACK.zip OUT/
```
`--world` writes the upgraded pack to `<world>/datapacks/<pack name>-26.3/`.
The input is a zip or a folder; nothing is downloaded, and the pack stays on
your machine (packs are often All Rights Reserved: do not commit or share an
upgraded copy).
Horizon does not apply a data pack's world generation yet (see
[the mod browser](mod-browser.md)); the upgraded pack loads in vanilla 26.3,
and the checks below run it there.
The command prints what it rewrote, what it could not rewrite faithfully
(`not faithful`) and the types it does not know (`left as it is`).
## What changes
Each rewrite is the one vanilla's own data went through between 26.2 and
26.3 (checked by `tools/datapack_upgrade_rosetta.py`, below), and the parts
26.2 hard-coded are spelled out as data so the 26.3 pack generates what the
26.2 pack did.
| 26.2 | 26.3 | How it is checked |
|---|---|---|
| noise `firstOctave`, `amplitudes` | `base_octave`, `octave_count`, `amplitude_modifiers` and the `base_amplitude` of vanilla's `NormalNoise.createParity` (keeps 26.2's normalisation) | all 63 vanilla noises identical to 26.3's, bit for bit |
| density `argument`, `argument1`/`argument2` | `input`, `left`/`right` | Rosetta, and generated terrain |
| `invert` | `reciprocal` | codec |
| `y_clamped_gradient` (`from_y`, `to_y`) | `gradient` on axis `y` | Rosetta |
| `shifted_noise` | `noise` with `shift_x`/`shift_y`/`shift_z` (same sampling) | Rosetta |
| `flat_cache` | `cache`, around `slice` at y = 0 when the input depends on y (26.2's flat cache held each column's value at y = 0) | unit test |
| `cache_2d`, `cache_once`, `cache_all_in_cell` | `cache` (26.3's caches only memoise) | Rosetta |
| `end_islands` | `end_outer_islands` | Rosetta |
| `constant` `{argument}` | the number | codec |
| `interpolated` (cell from the noise settings' `size_horizontal * 4`, `size_vertical * 4`) | `interpolated` with `cell_size_xz`/`cell_size_y`, from the settings whose router reaches the function | Rosetta: vanilla's 4 x 8 |
| noise settings `noise.size_*` | dropped (on each `interpolated`) | Rosetta |
| router `final_density` (26.2 added the beardifier in `NoiseChunk`) | `add(final_density, beardifier)` | as vanilla's `overworld/final_density` |
| router `barrier`, `fluid_level_*`, `lava`, `aquifers_enabled` | `aquifers` object; `exclusion` spells out 26.2's `isDeepDarkRegion` (erosion < -0.225 and depth > 0.9) from the pack's own erosion and depth; `surface_level` is the preliminary surface | as vanilla's |
| router `preliminary_surface_level` | named function, `aquifers.surface_level`, and `chunk_surface_level` = `interpolated` 16 x 1 of it (26.2's per-chunk surface interpolation) | as vanilla's |
| router `vein_toggle`, `vein_ridged`, `vein_gap`, `ore_veins_enabled` | two `ore_vein` material rules (copper, iron) that reproduce 26.2's `OreVeinifier`: same heights, thresholds, edge roundoff, solidness, richness and gap | written from `OreVeinifier`; terrain |
| `surface_rule` | `material_rule` (same node types), named `<settings>/upgraded/material_rule`, after the ore veins: 26.2 placed veins while filling the noise and its surface pass only replaced the default block | terrain |
| `spawn_target` climate names | the router's density functions by id (`depth` and `offset` dropped as vanilla did) | as vanilla's |
| block states `{Name, Properties}` | `{id, properties}`, or the id alone | Rosetta |
| `configured_carver/` | `carver/`, `config` unwrapped; the cave carvers' hard-coded count, thickness and room scale written out (`nether_cave` is a `cave`); canyon `yScale` into `shape.y_scale`; `lava_level`, `replaceable`, `debug_settings` dropped | all 4 vanilla carvers identical to 26.3's |
| `configured_feature/` | `feature/`, `config` unwrapped, block state providers renamed (`simple_state_provider` is its state) | Rosetta |
| placement `random_offset` (`xz_spread`, `y_spread`) | `offset` with `x`, `y`, `z` | Rosetta |
| biome `spawners`, `spawn_costs` | attribute `minecraft:gameplay/natural_mob_spawns` (overlay), counts as int providers; empty categories kept so none comes from the dimension's defaults | Rosetta (vanilla leaves empty ones out) |
| biome `creature_spawn_probability` | attribute `minecraft:gameplay/creature_world_gen_spawn_probability` (left out at the default 0.1) | Rosetta |
| structure `spawn_overrides` `minCount`/`maxCount` | `count` | Rosetta |
| dimension type `bed_rule.explodes` | `bed_rule.destroy_on_use` | Rosetta |
| `pack.mcmeta` | pack format 121 | vanilla loads it |
Not rewritten: the feature types 26.3 replaced by compositions of other
features (`seagrass`, `kelp`, `basalt_columns`, `nether_forest_vegetation`
and others; listed by the command), a `cache_2d` around a function of y, a
`y_clamped_gradient` with `from_y == to_y` (a step instead of 26.2's NaN at
that y), carvers' custom `lava_level`/`replaceable`, and noises with more
than 32 octaves or negative amplitudes.
## Checking an upgrade
```sh
python3 tools/datapack_upgrade_rosetta.py
python3 tools/datapack_upgrade_fidelity.py # calibration: vanilla 26.2 against 26.3
python3 tools/datapack_upgrade_fidelity.py --old-pack PACK.zip --new-pack OUT/
```
The Rosetta check writes vanilla 26.2's own worldgen out as a pack, upgrades
it and compares it with 26.3's file by file. Files whose format changed and
whose content did not must come out the same. It needs the 26.2 server jar
(`.local/vanilla-26.2/server-game.jar`, from the launcher's 26.2 version
manifest).
The fidelity check starts vanilla's 26.2 and 26.3 dedicated servers on the
same seed, the first with the original pack and the second with the upgrade,
force loads the same chunks and compares the saved blocks. Without packs it
measures what 26.3 itself changed, which the upgrade cannot be blamed for.
The server logs must hold no data pack or registry errors.
+1 -1
View File
@@ -28,7 +28,7 @@ const NAME: &str = "Native mods";
const PREFIX: &str = "native-";
/// horizon-mixins, for bundles that don't name the revision they were tested with.
const MIXINS_GIT: &str = "https://github.com/horizon-rust/horizon-mixins";
pub const MIXINS_REV: &str = "0d6b103c70633d8fd9443d8d0acd3aadaacedb06";
pub const MIXINS_REV: &str = "3f99a5c60dfa311cc365524c44ae2fd799bfa1de";
/// The profile and features of the release's own game (game-build.yml).
const PROFILE: &str = "release";
const FEATURES: &str = "horizon-client/mods";
+2 -1
View File
@@ -198,7 +198,8 @@ class ReleaseTrainTests(unittest.TestCase):
self.assertIn("### Known issues\n- Sound cuts out after an hour.", body)
self.assertNotIn("#7", body)
self.assertNotIn("#8", body)
self.assertIn("Thanks to @cosmic.", body)
self.assertIn("### Contributors\nThanks to @cosmic.", body)
self.assertTrue(body.endswith("---\n" + train.DISCLAIMER + "\n"), body)
self.assertLess(body.index("### New"), body.index("### Fixed"))
title, _ = train.notes("game", "v2026.10.15.2", [pr(9, "Horizon's mark", labels=["feature"]),
pr(10, "Game menus", labels=["feature"])], [])
+196
View File
@@ -0,0 +1,196 @@
#!/usr/bin/env python3
"""Does an upgraded data pack generate on 26.3 what the original did on 26.2?
Starts vanilla's dedicated server of each version in a new folder, with the
pack in its world's `datapacks/` (26.2 gets `--old-pack`, 26.3 gets
`--new-pack`, the output of `horizon-port-pack upgrade-datapack`; without
packs both run vanilla, which calibrates what 26.3 itself changed), force
loads the same chunks, saves, and compares the saved chunks block by block.
Prints how many blocks differ, the most common (26.2 -> 26.3) pairs and
which heights they are at. The server logs are checked for data pack and
registry errors: a pack 26.3 refuses fails the run.
Jars: `.local/vanilla-26.2/server.jar` and `.local/minecraft/26.3/jars/server.jar`
(the bundler jars the player downloaded). Needs Java 25 on PATH or JAVA_HOME.
usage: datapack_upgrade_fidelity.py [--old-pack P --new-pack Q] [--seed N]
[--site X,Z]... [--radius R] [--status full|carvers] [--keep DIR]
"""
import argparse
import collections
import os
import pathlib
import re
import shutil
import sys
import tempfile
import time
ROOT = pathlib.Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT / "tools"))
import anvil_dump # noqa: E402
from test_dedicated_server_parity import Server, free_port # noqa: E402
JARS = {"26.2": ROOT / ".local/vanilla-26.2/server.jar", "26.3": ROOT / ".local/minecraft/26.3/jars/server.jar"}
ERRORS = re.compile(r"Failed to (load|parse|get element)|Errors in registry|Unknown registry key|"
r"No key|Missing|Couldn't load|Failed to load datapacks|Exception", re.I)
def java():
home = os.environ.get("JAVA_HOME")
return str(pathlib.Path(home) / "bin" / "java") if home else "java"
def run_server(version, folder, seed, pack, chunks):
folder.mkdir(parents=True)
(folder / "eula.txt").write_text("eula=true\n")
(folder / "server.properties").write_text(
f"level-seed={seed}\nonline-mode=false\nserver-port={free_port()}\nspawn-protection=0\n"
"max-tick-time=-1\nsync-chunk-writes=true\nview-distance=2\nsimulation-distance=2\n")
if pack:
target = folder / "world/datapacks" / pack.name
target.parent.mkdir(parents=True)
(shutil.copytree if pack.is_dir() else shutil.copy)(pack, target)
server = Server([java(), "-Xmx2G", "-jar", JARS[version], "nogui"], folder, f"server-{version}")
try:
server.wait_for(r"Done \(", timeout=1800)
problems = [l for l in server.lines if ERRORS.search(l)]
if pack and not any(pack.name in l for l in server.lines):
problems.append(f"{version}: the pack {pack.name} was not mentioned in the log (not loaded?)")
# One forceload per chunk: a rectangle over several sites would pass
# the command's 256-chunk limit and load nothing.
for x, z in chunks:
server.console(f"forceload add {x * 16} {z * 16}")
deadline = time.time() + 3600
for poll in range(100000):
if time.time() > deadline:
raise AssertionError(f"{version}: chunks still not loaded after an hour")
start = len(server.lines)
for x, z in chunks:
server.console(f"execute unless loaded {x * 16} 0 {z * 16} run say unloaded")
server.console(f"say poll {poll}")
server.wait_for(rf"\[Server\] poll {poll}$", timeout=1800)
if not any(l.endswith("[Server] unloaded") for l in server.lines[start:]):
break
# Polls are cheap, but each adds lines to the kept log.
del server.lines[start:]
time.sleep(5)
server.console("save-all flush")
server.wait_for(r"Saved the game", timeout=600)
finally:
server.stop()
return problems
def blocks(world, chunks):
"""{(cx, cz): {y: [state per x,z]}} of the saved world."""
regions = {}
out = {}
for cx, cz in chunks:
rx, rz = cx >> 5, cz >> 5
if (rx, rz) not in regions:
# 26.x keeps each dimension under dimensions/<ns>/<name>/; older worlds used region/.
path = world / "dimensions" / "minecraft" / "overworld" / "region" / f"r.{rx}.{rz}.mca"
if not path.exists():
path = world / "region" / f"r.{rx}.{rz}.mca"
regions[(rx, rz)] = anvil_dump.region_chunks(path) if path.exists() else {}
chunk = regions[(rx, rz)].get((cx & 31, cz & 31))
if chunk is None:
continue
status = chunk.get("Status", "")
column = {}
for section in chunk.get("sections", []):
sy = section["Y"][1] if isinstance(section["Y"], tuple) else section["Y"]
states = section.get("block_states")
if not states:
continue
palette = [state_name(p) for p in states["palette"]]
data = states.get("data", ("L", []))[1]
bits = max(4, (len(palette) - 1).bit_length())
per = 64 // bits
mask = (1 << bits) - 1
for i in range(4096):
if data:
word = data[i // per] & ((1 << 64) - 1)
index = (word >> ((i % per) * bits)) & mask
else:
index = 0
y = sy * 16 + (i >> 8)
column.setdefault(y, [None] * 256)[i & 255] = palette[index]
out[(cx, cz)] = (status, column)
return out
def state_name(entry):
# 26.2 saves {Name, Properties}; 26.3 saves the bare id, or {id, properties}. In a list
# mixing both, NBT wraps each bare id in a compound with an empty key.
if isinstance(entry, str):
return entry
if "" in entry:
return entry[""]
name = entry.get("Name") or entry["id"]
props = entry.get("Properties") or entry.get("properties")
if props:
name += "[" + ",".join(f"{k}={v}" for k, v in sorted(props.items())) + "]"
return name
def main():
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
parser.add_argument("--old-pack", type=pathlib.Path)
parser.add_argument("--new-pack", type=pathlib.Path)
parser.add_argument("--seed", default="12345")
parser.add_argument("--site", action="append", help="chunk X,Z of a site's centre (default 0,0 and 40,-25)")
parser.add_argument("--radius", type=int, default=1, help="chunks around each site")
parser.add_argument("--keep", type=pathlib.Path, help="keep the server folders here")
parser.add_argument("--top", type=int, default=20)
args = parser.parse_args()
if bool(args.old_pack) != bool(args.new_pack):
sys.exit("pass both packs or neither")
sites = [tuple(map(int, s.split(","))) for s in (args.site or ["0,0", "40,-25"])]
chunks = sorted({(x + dx, z + dz) for x, z in sites
for dx in range(-args.radius, args.radius + 1)
for dz in range(-args.radius, args.radius + 1)})
tmp = pathlib.Path(tempfile.mkdtemp(prefix="upgrade-fidelity-"))
try:
worlds, problems = {}, []
for version, pack in (("26.2", args.old_pack), ("26.3", args.new_pack)):
folder = tmp / version
print(f"{version}: generating {len(chunks)} chunks" + (f" with {pack.name}" if pack else ""), flush=True)
problems += run_server(version, folder, args.seed, pack, chunks)
worlds[version] = blocks(folder / "world", chunks)
for p in problems:
print("LOG:", p)
old, new = worlds["26.2"], worlds["26.3"]
total = differ = 0
pairs = collections.Counter()
heights = collections.Counter()
missing = 0
for c in chunks:
if c not in old or c not in new:
missing += 1
continue
(s1, a), (s2, b) = old[c], new[c]
for y in sorted(set(a) | set(b)):
ra, rb = a.get(y, ["minecraft:air"] * 256), b.get(y, ["minecraft:air"] * 256)
for i in range(256):
total += 1
if ra[i] != rb[i]:
differ += 1
pairs[(ra[i], rb[i])] += 1
heights[(y // 64) * 64] += 1
print(f"compared {total} blocks in {len(chunks) - missing} chunks ({missing} missing): "
f"{differ} differ ({100 * differ / max(total, 1):.3f}%)")
for (x, y), n in pairs.most_common(args.top):
print(f" {n:8} {x} -> {y}")
print(" by height:", ", ".join(f"y{h}..{h + 63}: {n}" for h, n in sorted(heights.items())))
if problems or missing:
sys.exit(1)
finally:
if args.keep:
shutil.copytree(tmp, args.keep, dirs_exist_ok=True)
shutil.rmtree(tmp, ignore_errors=True)
if __name__ == "__main__":
main()
+149
View File
@@ -0,0 +1,149 @@
#!/usr/bin/env python3
"""Checks `horizon-port-pack upgrade-datapack` on vanilla's own world generation.
Vanilla 26.2's worldgen (from the 26.2 server jar the player downloaded
themselves, `--jar-26-2`, default `.local/vanilla-26.2/server-game.jar`) is
written out as a data pack, upgraded, and compared with vanilla 26.3's
worldgen (`.local/minecraft/26.3/jars/server-game.jar`) file by file: the
registries whose format changed and whose content did not must come out
identical. Per registry it prints how many files match and the paths where
the rest differ, most common first. Density functions and noise settings
differ where Mojang rewrote expressions (`lerp` for the blend formulas) or
changed content; those are judged by generating terrain, not here.
Nothing is written outside a temporary folder; the jars' files stay there.
"""
import argparse
import collections
import json
import pathlib
import subprocess
import sys
import tempfile
import zipfile
ROOT = pathlib.Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT / "tools"))
import lab # noqa: E402
PREFIXES = ("data/minecraft/worldgen/", "data/minecraft/dimension_type/")
RENAMES = {"configured_carver": "carver", "configured_feature": "feature"}
def extract(jar, out):
with zipfile.ZipFile(jar) as z:
for name in z.namelist():
if name.startswith(PREFIXES) and name.endswith(".json"):
target = out / name
target.parent.mkdir(parents=True, exist_ok=True)
target.write_bytes(z.read(name))
def files(root):
return {p.relative_to(root).as_posix(): p for p in root.rglob("*.json")}
def diff(x, y, path, out):
if isinstance(x, dict) and isinstance(y, dict):
for k in set(x) | set(y):
if k not in y:
out.add(f"-{path}.{k}")
elif k not in x:
out.add(f"+{path}.{k}")
else:
diff(x[k], y[k], f"{path}.{k}", out)
elif isinstance(x, list) and isinstance(y, list) and len(x) == len(y):
for a, b in zip(x, y):
diff(a, b, f"{path}[]", out)
elif x != y:
out.add(f"~{path}")
def normalise(v, providers):
"""26.3 names some block state providers (`worldgen/block_state_provider`)
where 26.2 wrote them inline, and writes a default state as its bare id:
both forms are read the same."""
if isinstance(v, dict):
if set(v) == {"id"}:
return v["id"]
if "spawns_by_category" in v:
# The upgrade keeps empty categories so that none comes from
# the dimension's defaults; vanilla leaves them out.
v = dict(v, spawns_by_category={c: s for c, s in v["spawns_by_category"].items() if s})
out = {}
for k, x in v.items():
if k.endswith("provider") and isinstance(x, str) and x in providers:
x = providers[x]
out[k] = normalise(x, providers)
return out
if isinstance(v, list):
return [normalise(x, providers) for x in v]
return v
def main():
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
parser.add_argument("--jar-26-2", type=pathlib.Path,
default=ROOT / ".local/vanilla-26.2/server-game.jar")
parser.add_argument("--registry", action="append", help="only these registries (e.g. worldgen/noise)")
parser.add_argument("--show", type=int, default=6, help="diff paths shown per registry")
parser.add_argument("--keep", type=pathlib.Path, help="copy both trees here to inspect them")
args = parser.parse_args()
new_jar = ROOT / ".local/minecraft/26.3/jars/server-game.jar"
for jar in (args.jar_26_2, new_jar):
if not jar.exists():
sys.exit(f"missing {jar}")
target = lab.cargo_target()
subprocess.run(["cargo", "build", "-q", "-p", "horizon-port-pack", "--target-dir", str(target)],
cwd=ROOT, check=True)
exe = target / "debug" / "horizon-port-pack"
with tempfile.TemporaryDirectory(prefix="upgrade-rosetta-") as tmp:
tmp = pathlib.Path(tmp)
old, new, out = tmp / "26.2", tmp / "26.3", tmp / "upgraded"
extract(args.jar_26_2, old)
extract(new_jar, new)
(old / "pack.mcmeta").write_text(json.dumps({"pack": {"description": "vanilla 26.2", "pack_format": 107}}))
run = subprocess.run([str(exe), "upgrade-datapack", str(old), str(out)], capture_output=True, text=True)
print(run.stdout.strip())
if run.returncode:
sys.exit(run.stderr)
got, want = files(out), files(new)
providers = {}
for path, p in want.items():
prefix = "data/minecraft/worldgen/block_state_provider/"
if path.startswith(prefix):
providers["minecraft:" + path[len(prefix):-5]] = json.loads(p.read_text())
if args.keep:
import shutil
shutil.copytree(out, args.keep / "upgraded", dirs_exist_ok=True)
shutil.copytree(new, args.keep / "26.3", dirs_exist_ok=True)
per =collections.defaultdict(lambda: [0, 0, 0, collections.Counter()])
for path, p in sorted(got.items()):
parts = path.split("/")
if len(parts) < 4 or path.startswith("data/minecraft/worldgen/density_function/") and "/upgraded/" in path:
continue
registry = "/".join(parts[2:4]) if parts[2] == "worldgen" else parts[2]
if args.registry and registry not in args.registry:
continue
row = per[registry]
if path not in want:
row[2] += 1
continue
a = normalise(json.loads(p.read_text()), providers)
b = normalise(json.loads(want[path].read_text()), providers)
if a == b:
row[0] += 1
else:
row[1] += 1
paths = set()
diff(a, b, "", paths)
row[3].update(paths)
print(f"{'registry':40} {'same':>5} {'differ':>6} {'not in 26.3':>11}")
for registry, (same, differ, gone, paths) in sorted(per.items()):
print(f"{registry:40} {same:5} {differ:6} {gone:11}")
for p, n in paths.most_common(args.show):
print(f" {n:4} {p}")
if __name__ == "__main__":
main()
+2 -2
View File
@@ -407,10 +407,10 @@ def notes(kind, version, prs, issues, previous=None):
parts.append("### Known issues\n" + "\n".join(issues))
contributors = sorted({pr["author"] for pr in shown if pr["author"] and pr["author"] != MAINTAINER})
if contributors:
parts.append("Thanks to " + ", ".join(f"@{name}" for name in contributors) + ".")
parts.append("### Contributors\nThanks to " + ", ".join(f"@{name}" for name in contributors) + ".")
if kind == "launcher":
parts.append(LAUNCHER_FOOTER)
parts.append(DISCLAIMER)
parts.append("---\n" + DISCLAIMER)
return title, "\n\n".join(parts) + "\n"
+149
View File
@@ -0,0 +1,149 @@
#!/usr/bin/env python3
"""Check #1518: mobs and dropped items go through an End gateway.
Build horizon-client and horizon-ctl with the fast profile first, then run:
python3 tools/singleplayer_end_cargo_audit.py --case gateway
Runs the in-frame singleplayer (and the server-thread one with --mode server):
a cow and a seven-diamond stack stand in a gateway in the End, and both are
expected to leave it and land at the gateway's exit, the stack with its seven
diamonds.
"""
import argparse
import math
import os
from pathlib import Path
import re
import subprocess
import tempfile
import time
from agent_control_e2e import Cli
import lab
# The gateway beside the End's spawn platform, where the player waits.
GATEWAY = (104, 50, 0)
# The diamonds the dropped stack carries.
DIAMONDS = 7
def data_get(cli, selector, path):
"""The chat line of a `/data get entity`."""
since = cli('events')['last']
cli('command', command=f'data get entity {selector} {path}')
deadline = time.monotonic() + 30
while time.monotonic() < deadline:
reply = cli('events', since=since)
for event in reply['events']:
text = event.get('text', '')
if event.get('event') == 'chat' and ('entity data' in text or 'No entity was found' in text):
return text
time.sleep(0.1)
raise AssertionError(f'no answer for /data get entity {selector} {path}')
def numbers(text):
"""The numbers of a `/data get` answer: "Pos: [104.5, 50.0, 0.5]"."""
return [float(v) for v in re.findall(r'-?\d+(?:\.\d+)?', text.split(':', 1)[-1])]
def run(bin_dir, resources, in_frame):
with tempfile.TemporaryDirectory(prefix='hz-end-gateway-') as scratch:
work = Path(scratch)
env = lab.game_env({k: v for k, v in os.environ.items() if k not in (
'HORIZON_HOME', 'HORIZON_LAUNCHER_HOME', 'XDG_DATA_HOME', 'XDG_CONFIG_HOME')})
home = Path(env['HORIZON_HOME'])
socket = work / 'game.sock'
cli = Cli(bin_dir / 'horizon-ctl', socket, home, env)
args = [str(bin_dir / 'horizon-client'), '--resources', str(resources),
'--world', 'cargo', '--creative', '--seed', '4242', '--offscreen',
'--render-distance', '4', '--control', '--control-socket', str(socket)]
if in_frame:
args.append('--in-frame')
with (work / 'game.log').open('w') as log:
process = subprocess.Popen(args, cwd=home, env=env, stdout=log, stderr=log)
try:
deadline = time.monotonic() + 120
while not socket.exists():
if process.poll() is not None or time.monotonic() > deadline:
raise RuntimeError('game did not open its control socket')
time.sleep(0.1)
cli('wait_ready', seconds=240)
# The End, on its spawn platform.
cli('command', command='execute in minecraft:the_end run tp @s 100 50 0')
for _ in range(60):
cli('wait', ticks=20)
if cli('state').get('dimension') == 'minecraft:the_end':
break
else:
raise AssertionError('did not enter the End')
cli('wait_ready', seconds=240)
# A gateway beside the player, with a cow and a stack in it.
x, y, z = GATEWAY
cli('command', command=(
f'execute in minecraft:the_end run setblock {x} {y} {z} minecraft:end_gateway'))
cli('command', command=(
f'execute in minecraft:the_end run summon minecraft:cow {x}.5 {y}.5 {z}.5 {{NoAI:1b,Tags:["horizon_gateway_cow"]}}'))
nbt = '{Tags:["horizon_gateway_item"],Item:{id:"minecraft:diamond",count:' + str(DIAMONDS) + '}}'
cli('command', command=(
'execute in minecraft:the_end run summon minecraft:item '
+ f'{x}.5 {y}.5 {z}.5 ' + nbt))
# The gateway takes the stack, and the cow 40 ticks later
# (TheEndGatewayBlockEntity.triggerCooldown).
cli('wait', ticks=120)
# The exit is 1024 blocks away (findOrCreateValidTeleportPos): the
# player waits there, so the travellers are near enough to see.
length = math.hypot(x, z)
ex, ez = 1024 * x / length, 1024 * z / length
cli('command', command=f'execute in minecraft:the_end run tp @s {ex:.1f} 80 {ez:.1f}')
for _ in range(60):
cli('wait', ticks=20)
cow = data_get(cli, '@e[type=cow,tag=horizon_gateway_cow,limit=1]', 'Pos')
if 'entity data' in cow:
break
else:
raise AssertionError('the cow never arrived')
item = data_get(cli, '@e[type=item,tag=horizon_gateway_item,limit=1]', 'Pos')
count = numbers(data_get(cli, '@e[type=item,tag=horizon_gateway_item,limit=1]', 'Item.count'))
cow_at, item_at = numbers(cow), numbers(item)
print(f'cow at {cow_at}, item at {item_at}, count {count}')
assert len(cow_at) == 3, f'the cow has no position: {cow_at}'
assert len(item_at) == 3, f'the stack has no position: {item_at}'
for name, at in (('cow', cow_at), ('item', item_at)):
assert abs(at[0] - x) > 64 or abs(at[2] - z) > 64, \
f'{name} stayed in the gateway at {at}'
assert count and int(count[0]) == DIAMONDS, f'the stack lost diamonds: {count}'
cli('quit')
assert process.wait(timeout=120) == 0
except Exception:
log.flush()
text = (work / 'game.log').read_text(errors='replace')
Path('/tmp/end-gateway-game.log').write_text(text)
for log in (home / 'logs').glob('*'):
if log.is_file():
Path('/tmp/end-gateway-' + log.name).write_text(
log.read_text(errors='replace'))
print(text[-4000:])
raise
finally:
if process.poll() is None:
process.terminate()
process.wait(timeout=30)
print(f'PASS end gateway: {"in-frame" if in_frame else "server-thread"}, '
f'the cow and the stack left the gateway with their data')
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--case', choices=['gateway'], default='gateway')
parser.add_argument('--bin-dir', type=Path, default=lab.cargo_target() / 'fast')
parser.add_argument('--resources', type=Path, default=lab.RESOURCES)
parser.add_argument('--mode', choices=['server', 'in-frame', 'both'], default='in-frame')
args = parser.parse_args()
modes = {'server': (False,), 'in-frame': (True,), 'both': (False, True)}
for in_frame in modes[args.mode]:
run(args.bin_dir.resolve(), args.resources.resolve(), in_frame)
if __name__ == '__main__':
main()
Loaded 100 of 102 files, more files were not shown because too many files have changed in this diff. Show more