Compare commits

...
530 Commits
Author SHA1 Message Date
Ludovic Marie 0395b7220e Merge pull request #12731 from horizon-rust/fix/8513-dxc-igpu
🤖 Agent: discrete GPUs never see the DXC DLL: FXC is now set explicitly, because wgpu's default Auto would load the dxcompiler.dll the zip puts beside the exe for every GPU. Only an IntegratedGpu on DX12 reopens with DXC, once at device creation, and keeps FXC if the DLL fails or the DXC instance picks another GPU.
2026-11-08 18:58:22 +01:00
Ludovic Marie 43f69216d3 Merge pull request #12721 from horizon-rust/docs/java-sort-comments
🤖 Agent: comments only; java_sort's body is byte-for-byte unchanged, the comments now cite listsort.txt and the Arrays.sort Javadoc instead of OpenJDK's private method names.
2026-11-08 18:52:21 +01:00
Ludovic Marie 6651840801 Merge pull request #12720 from horizon-rust/fix/no-iris-identity-macros
🤖 Agent: removes IS_IRIS/IRIS_VERSION/IRIS_FEATURE_* as OQ-2 decided; real_packs over BSL, Complementary (both) and Sildur's: no pack loses a program, Complementary now loads terrain and water (9 skipped → 3).
2026-11-08 18:49:51 +01:00
Ludovic Marie dd2aa4c808 Merge pull request #12684 from horizon-rust/docs/cleanroom-shader-spec
🤖 Agent: docs only; every line of the three published files was read for private material, and only local paths, the machine name, a private rules file name and the unpublished plan link were replaced (each file says so at the top).
2026-11-08 18:25:37 +01:00
Ludovic Marie 5919b2ee37 Merge pull request #12675 from horizon-rust/fix/shop-render-skin
🤖 Agent: the hero's Buy and the prices now share one ownership rule (shop::owns), and a Shop picture's cache key changes when its item files arrive, so the bare-player bake no longer sticks.
2026-11-08 18:18:24 +01:00
Horizon agentandClaude Opus 5.5 a3ea52c7ae HUD: describe java_sort from listsort.txt, not OpenJDK's method names
The comments named OpenJDK's private TimSort methods. They now describe the
algorithm (one run, then binary insertion) and cite Tim Peters's listsort.txt and
the Arrays.sort Javadoc. No code change (#6741).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 18:17:55 +01:00
Horizon agentandClaude Opus 5.5 f606cb820c DX12: DXC on integrated GPUs, FXC elsewhere, with Microsoft's dxcompiler.dll in the Windows x64 zip (#8513)
The adapter is first requested on an FXC instance; an integrated GPU on
DirectX 12 is requested again on an instance that loads dxcompiler.dll
from beside the game, once at device creation. A missing DLL, one that
fails to load, or an instance that offers another GPU keeps FXC with one
log line. WGPU_DX12_COMPILER still overrides the choice.

The Windows x64 game build downloads DXC v1.9.2609 from Microsoft's
release (zip and DLL SHA-256 checked) and ships its licence files and a
notice beside it. Measurements by Mayonesu0 and Cottleta on #8513.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 18:15:52 +01:00
Horizon agentandClaude Opus 5.5 76ef5f41ee Shader packs: define no other loader's identity macros (OQ-2)
Horizon no longer defines IS_IRIS, IRIS_VERSION, IRIS_HAS_TRANSLUCENCY_SORTING or
any IRIS_FEATURE_<name>, as the clean-room decision OQ-2 says: Horizon is not that
loader and does not claim its identity. iris.features.required still refuses a
pack that needs a feature Horizon lacks (Macros::feature_notes); features Horizon
has stay available, and packs take the path they take without those macros.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 18:15:20 +01:00
Horizon agentandClaude Opus 5.5 b5fd86ea95 Docs: publish the shader-pack clean-room spec and provenance log
The specification the shader-pack rewrite was built from, its provenance log and
the two-team rules, as written in October, with local paths, machine names and
private file names replaced. Linked from the docs index; CONTRIBUTING.md and the
pull request template ask shader-pack PRs to name their sources (#6739, #1555).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 18:12:02 +01:00
Horizon agentandClaude Opus 5.5 bdc89c7247 Launcher Shop: draw the items on its pictures, no Buy on an owned pack
- A Shop picture's key now counts only the item files already loaded. The catalogue is read
  from disk at once and the files come a moment later, so the first picture (the bare player)
  stayed for good: the cosmetics never showed on the Shop's looks and pack.
- The pack's hero shows "Owned" alone when the account owns every item of it, instead of
  "Owned" beside a Buy button (`shop::owns`, also used by the open offer and the prices).
- Without its own skin in the cache, the account wears the default skin its UUID has in the
  game (Steve, Alex…), from the player's prepared game files, and its own skin once the
  profile heads have fetched it, instead of a plain figure.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 18:09:10 +01:00
Kacper c98a51a664 Merge pull request #12581 from MSedgeMC/fix/no-ghast-retaliation
A mob does not retaliate against a ghast
2026-11-08 16:32:41 +01:00
Kacper 9d55121379 Merge pull request #12580 from TheMrZZ/fix/sulfur-prime-phase
A sulfur cube loaded with a TNT body primes from its second tick
2026-11-08 16:32:34 +01:00
Kacper 3a90aecb12 Merge pull request #12579 from ilguerro/fix/thorns-mob-attacks
Thorns answers a mob's attacks on a mob, and a player's reaches the dragon
2026-11-08 16:32:27 +01:00
Kacper 524543dfa8 Merge pull request #12578 from MSedgeMC/port_map/rename-realm-world
port map: renameAndDropPlayer names the Realms download helper
2026-11-08 16:32:20 +01:00
Kacper 53a993c972 Merge pull request #12577 from MSedgeMC/port_map/set-damage-value
port map: ItemStack.setDamageValue is set_damage_value
2026-11-08 16:32:13 +01:00
Kacper 36c8983d68 Merge pull request #12576 from MSedgeMC/port_map/portal-ticket
port map: Entity.placePortalTicket is the portal ticket
2026-11-08 16:32:06 +01:00
Kacper 39c43b82bd Merge pull request #12575 from MSedgeMC/port_map/atlas-dump
port map: TextureAtlas.dumpContents is the F3 atlas dump
2026-11-08 16:32:00 +01:00
Kacper 0b0aded17c Merge pull request #12574 from MSedgeMC/port_map/minecart-redstone
port map: AbstractMinecart.getRedstoneDirection is wired under the feature
2026-11-08 16:31:53 +01:00
Kacper d978845087 Merge pull request #12566 from vnsmoreira/fix/particle-sprite-size
Resource packs: particle sprites of any size reach the atlas
2026-11-08 16:31:46 +01:00
Kacper 957eb14a5e Merge pull request #12565 from vnsmoreira/fix/level-change-shared-flags
Server: a level change keeps the player's shared flags in sync
2026-11-08 16:31:39 +01:00
Kacper 601772b4fa Merge pull request #12553 from TheMrZZ/fix/mob-landing-support
A landing mob falls on the block that supports it
2026-11-08 16:31:32 +01:00
Kacper 6a61fdbe28 Merge pull request #12550 from MrCloudy2/fix/mob-water-climb
A mob in waterlogged scaffolding or on an underwater ladder climbs when it hits a wall
2026-11-08 16:31:25 +01:00
Kacper be8b04774f Merge pull request #12549 from MrCloudy2/fix/mob-climbable-push
A mob on a ladder or scaffolding is not pushed before its own tick
2026-11-08 16:31:18 +01:00
Kacper 0698e8d46d Merge pull request #12545 from NikitaMGrimm/parity-fuzz-scenarios
parity_fuzz: persist and spawn scenarios, with field names from vanilla's own saves
2026-11-08 16:31:11 +01:00
Kacper 9afeda6be3 Merge pull request #12544 from NikitaMGrimm/parity-fuzz-runner
parity_fuzz: run generated scenarios in vanilla and Horizon and report each divergence once, shrunk
2026-11-08 16:31:04 +01:00
Kacper 89bb9243ab Merge pull request #12541 from glitchsnake/fix/12518-boat-underwater-buoyancy
game: use 0.01f32 buoyancy for boat under water (fixes #12518)
2026-11-08 16:30:58 +01:00
Kacper 981b36ef36 Merge pull request #12540 from NikitaMGrimm/fix/portal-ticket-outer-ring
The outer ring of a portal ticket runs scheduled ticks and block events
2026-11-08 16:30:51 +01:00
Kacper c3f2a9928e Merge pull request #12538 from vnsmoreira/packs/polytone-particle-colors
Polytone: colormaps on particles, blocks and fluids, and sound emitters
2026-11-08 16:30:38 +01:00
Kacper 5a8e4ea92c Merge pull request #12537 from vnsmoreira/packs/polytone-particle-rendering
Polytone: draw GPU particles, rotation modes and additive particles
2026-11-08 16:30:31 +01:00
Kacper 3427559f65 Merge pull request #12535 from umbx-dot/launcher/version-followups
Launcher: native mod version follow-ups (kept versions, build fallback, changelogs)
2026-11-08 16:30:24 +01:00
Kacper ecf510cfc2 Merge pull request #12529 from TheMrZZ/fix/stand-break-loot
A broken armor stand rolls its entity loot table
2026-11-08 16:30:12 +01:00
Kacper 8201422dc0 Merge pull request #12528 from apeirs/perf/adreno-dx12-flat-draws
Adreno under DirectX 12: draw vanilla terrain flat (2-13% more FPS)
2026-11-08 16:30:05 +01:00
Kacper dec609bb7c Merge pull request #12496 from Akirabane/fix/11705-recipe-book-paper
Recipe book: take the entries a server's packet carries
2026-11-08 16:29:58 +01:00
Kacper d67da2cf27 Merge pull request #12434 from Evan-lhm/fix/mod-content-below-packs
Packs: lay the resource packs over the mods' content root
2026-11-08 16:29:51 +01:00
Kacper 4aa63389e5 Merge pull request #11955 from Stink-O/native/world-warp
Native mods: a world warp, WGSL every terrain and entity vertex goes through
2026-11-08 16:29:43 +01:00
Kacper 92be6a71c2 Merge pull request #11703 from ilguerro/perf/eager-chunk-saves
Save changed chunks during play, as vanilla's eager chunk saves do
2026-11-08 16:29:36 +01:00
Kacper f1d2c12e29 Merge pull request #11570 from TheMrZZ/fix/h112-guest-container-close-event
Emit the final ordinary guest container close game event
2026-11-08 16:29:29 +01:00
Kacper 4e5296d180 Merge pull request #11068 from Delete228/content-block-state-light-upstream
Content mods: choose light emission per block state
2026-11-08 16:29:23 +01:00
MSedgeMC 07c9753b76 A mob does not retaliate against a ghast
`Mob.canAttack` refuses a ghast for every mob, and
`HurtByTargetGoal.canUse` ends with `canAttack(lastHurtByMob)`. Horizon's
`spares_target` only knew the iron golem's creeper rule, so a ghast's
fireball made a mob take the ghast as its target.
2026-11-08 14:48:53 +00:00
Florian ErnstandClaude Opus 5.5 b79fd522cb A sulfur cube loaded with a TNT body primes from its second tick
SulfurCube.tick checks for power (primeWhenOnPoweredPosition, which
needs the body's explosionData) before super.tick adopts the body
(detectEquipmentUpdates → collectEquipmentChanges). A cube loaded with
a TNT body on power therefore primes on its second tick. Horizon's
priming pass ran after the mobs' ticks and read the live body, so such
a cube primed on its first tick and blew up a tick early. The pass now
considers only the cubes whose adopted body explodes as the tick starts.

Part of #12572

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 15:38:55 +01:00
MSedgeMC de5617664b port_map: renameAndDropPlayer names the Realms download helper
The row skipped the method for having no caller, but Horizon calls
rename_and_drop_player when a downloaded Realms world has no links.
2026-11-08 14:34:55 +00:00
MSedgeMC e9d179e183 port_map: ItemStack.setDamageValue is set_damage_value
The row dismissed the method as a field write, but set_damage_value is the
documented clamped write and the anvil, grindstone and mob-slot paths call
it.
2026-11-08 14:34:31 +00:00
MSedgeMC e790fdeead port_map: Entity.placePortalTicket is the portal ticket
The row said arriving entities only wait in portals.arrivals. The ticket is
in the tree: Game::place_portal_ticket adds a PORTAL ticket of radius 3 for
300 ticks, server side, on the nether and end arrival paths.
2026-11-08 14:34:04 +00:00
MSedgeMC 7e78e51558 port_map: TextureAtlas.dumpContents is the F3 atlas dump
The row skipped the dump as debug only, but HudAtlas::dump_contents is
documented as the method and F3's dump-dynamic-textures shortcut writes
the atlas PNG through it.
2026-11-08 14:33:35 +00:00
MSedgeMC ee2e77db35 port_map: AbstractMinecart.getRedstoneDirection is wired under the feature
The row said Horizon only runs the old track behaviour. It runs both:
redstone_direction is called from move_along_track_new when
minecart_improvements is on, so point the row at it and name the gate.
2026-11-08 14:33:11 +00:00
Florian ErnstandClaude Opus 5.5 c0e2fb0c99 Measure a loaded sulfur cube's priming phase (H249 test checkpoint)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 15:31:07 +01:00
Vinícius MoreiraandClaude Opus 5.5 2e237b9aa1 Resource packs: particle sprites of any size reach the atlas
The GUI atlas, which the particles draw from, left out every texture over 1024 wide or 512 tall
without a word: a cap meant to keep GUI textures small. Vanilla's particle atlas stitches any
size, and packs draw light shafts, fog banks and flares from 1024- and 1500-wide particle
textures, which drew nothing. Particle sprites are no longer capped; GUI textures still are.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 11:19:47 -03:00
Stink-OandClaude Opus 5.5 2bac359308 Native mods: a world warp, WGSL every terrain and entity vertex goes through
A render hook, native_world_warp, lets a client mod give the frame a world warp: its
world_warp(pos, section) -> Warped moves (and tints) every terrain vertex, far terrain's tiles
included, and every entity vertex; after a line // [fragments] its world_fragment(f) -> Painted
paints, retextures or cuts every terrain pixel by where the point really is. It reads sixteen
floats, the camera split for precision and the frame's partial tick at group 2.

The stock shaders hold only comment markers where it goes in; gfx/world_warp.rs fills them in
copies, validates them with naga and builds the warp's own pipelines (no back-face culling) the
first time a source is seen. While it draws, nothing is culled by where it was (frustum, caves,
CPU and GPU occlusion, section faces) and the frame keeps to the per-section draws. A frame
without a warp costs nothing more.

Tests: the warped shaders validate with and without a pixel stage and stay inside GL's 15
inter-stage variables, the stock shaders hold none of it, a broken source is refused with
naga's message, the hook keeps the last mod's warp. Example world_warp_check draws it through
the real renderer offscreen (and --bench times a frame with and without one).
docs/native-mods.md "World warps".

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 14:19:30 +00:00
Vinícius MoreiraandClaude Opus 5.5 ef65ffc544 Server: a level change keeps the player's shared flags in sync
A level change sends ClientboundRespawnPacket with KEEP_ALL_DATA, so the client's new player
keeps the old one's synched shared flags (on fire, invisible, crouching...). The hub forgot what
it had sent, so a flag that cleared after the change never reached the client: a creative player
who left lava through a dimension change kept the fire on its screen until the next time the
flags changed. The flags sent now survive the change, and ServerEntity.sendDirtyEntityData's
update reaches the player as in vanilla.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 11:09:56 -03:00
Nikita Maximilian GrimmandClaude Opus 5.5 a33d9e37c8 parity_fuzz: persist and spawn scenario families, with field names from vanilla's own saves
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 14:55:03 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 f496fff3af parity_fuzz: run scenarios in vanilla and Horizon, compare reads by field, shrink and group
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 14:55:03 +01:00
MrCloudy2andClaude Opus 5.5 6c68f82884 A mob in waterlogged scaffolding or on a ladder under water climbs when it hits a wall
LivingEntity.travelInWater sets the vertical movement to 0.2 when
horizontalCollision && onClimbable(). Horizon checked the spider flag
instead, so a mob in waterlogged scaffolding or on a waterlogged ladder
never climbed, and a spider got the climb even when it was not climbing
(Spider.onClimbable is its climbing flag, which on_climbable returns).

Harness: test_mob_water_climb_parity (dedicated servers, frozen and
stepped: a mob in waterlogged scaffolding moving into a wall climbs).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 14:53:08 +01:00
MrCloudy2andClaude Opus 5.5 def33e3892 A mob on a ladder or scaffolding is not pushed before its own tick
LivingEntity.isPushable reads onClimbable live from the block the mob
stands in. Horizon's cached flag was only refreshed by the mob's own push
step, so on the first tick after a load or a spawn (or after it moved)
other mobs pushed it out of a ladder or scaffolding. It is refreshed from
where each mob stands before the tick's snapshot.

Harness: test_mob_climbable_push_parity (dedicated servers, frozen and
stepped: a pair on plain ground pushes apart, a pair in scaffolding stays).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 14:53:08 +01:00
Florian ErnstandClaude Opus 5.5 01ecc1c7b5 A landing mob falls on the block that supports it
Entity.move refreshes the supporting block (setOnGroundWithMovement's
checkSupportingBlock) before checkFallDamage, whose landing block is
getOnPosLegacy, the supporting block when there is one. Horizon's mob
move refreshed it after the fall, so on the landing tick the fall used
the column under the mob's centre: a pig resting on stone with its
centre over honey took honey's fifth of the fall. The support is now
refreshed first.

Fixes #12536

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 14:50:50 +01:00
Loki facc7f17ea Merge remote-tracking branch 'origin/main' into fix/12518-boat-underwater-buoyancy 2026-11-08 22:46:31 +09:00
LokiandAntigravity 0aebe90bbc game: use 0.01f32 buoyancy for boat under water (fixes #12518)
Co-Authored-By: Antigravity <[email protected]>
2026-11-08 22:45:36 +09:00
Nikita Maximilian Grimm c73b892e42 Merge remote-tracking branch 'origin/main' into fix/portal-ticket-outer-ring 2026-11-08 14:43:29 +01:00
Florian ErnstandClaude Opus 5.5 4273e513c0 Measure a mob's landing support (test checkpoint)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 14:37:10 +01:00
umbx-dotandClaude Opus 5.5 c288428168 Launcher: native mod version follow-ups
- The installed list shows a version picked on the Versions tab, with
  a button to go back to the recommended one.
- A build that leaves a registry native mod out installs that mod's
  next older version that says it builds here, and builds once more.
- The details say when an older version fits and the newest doesn't.
- Updates are looked for again when the instance's Horizon changes.
- The Versions tab shows each version's changelog.

Closes #12534

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 14:34:24 +01:00
Vinícius MoreiraandClaude Opus 5.5 fa99fc98e6 Polytone: colormaps on particles, blocks and fluids, and sound emitters
Custom particles take their `colormap` (the vanilla grass, foliage, dry
foliage and water colours, or a pack's colormap) multiplied into their
colour, sampled every tick, per block or once as its `cache` says. Block and
fluid modifiers' colormaps tint the blocks and water they target in the
chunk mesh, keeping the block's own tint where the colormap's pixel is
transparent. Colormaps read the named samplers (`biome_id` with its mapper,
in pixel columns, `temperature`, `downfall`, `y_level`, `day_time`), so
packs' sky and fog colormaps that pick a column per biome load too.
Particle and block sound emitters play their sound events.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 10:31:09 -03:00
Vinícius MoreiraandClaude Opus 5.5 5cc05d62f4 Polytone: draw GPU particles, rotation modes and additive particles
Resource packs' GPU particles (`"type": "gpu"`) are spawn records whose
position, size, colour and frame are a function of the time: gravity and
friction as drag, fade, sway, spin, colour and size toward their ends,
texture strips, `area` spreading one spawn over many quads, and quads under
the open sky only with `kill_below_heightmap`. Custom and GPU particles turn
as their `rotation_mode` says (facing the camera, around Y, flat, along their
motion), and `additive_translucent` ones go to the frame's additive pass.

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

Part of #12514

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 14:15:14 +01:00
RINNECODERandClaude Opus 5.5 bdc8a23f21 Port pack: without the oracle, armour the vanilla armour tags name is worn in its slot
A carry without the oracle's facts declared every item that has no block as
a stick, so a mod's helmets and chestplates could not be worn. Items the
carried minecraft:{head,chest,leg,foot}_armor tags name (directly or through
the mod's own tags) now get minecraft:equippable with that slot and a stack
size of 1.

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 14:03:16 +01:00
MSedgeMC 61b7fa5b26 port_map: the three stable-dimension helpers are arms of dimension_is_vanilla_like 2026-11-08 13:01:34 +00:00
MSedgeMC 1df93f9739 port_map: the rest of the debug profile editor rows are ports 2026-11-08 13:00:39 +00:00
MSedgeMC 17e2982614 port_map: the debug profile load and save rows are ports
Both methods are implemented in debug_entries.rs and used by the F3+F6
DebugOptionsScreen; the rows' "no editor" note is stale.
2026-11-08 12:59:18 +00:00
Horizon agentandClaude Opus 5.5 eb74ad2a63 Merge origin/main into packs/polytone-particle-runtime
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 13:59:14 +01:00
MSedgeMC 34aa927de4 port_map: Minecraft.allowsMultiplayer is allows_multiplayer
The row skipped the account check as a service. AccountStatus::allows_multiplayer
is documented as the method and the title screen enables its Multiplayer and
Realms buttons from it.
2026-11-08 12:58:48 +00:00
MSedgeMC 491403897f port_map: WorldDimensions.isVanillaLike is dimension_is_vanilla_like
The row skipped the predicate, but save::dimension_is_vanilla_like is
documented as the method and its caller world_gen_unstable drives the
experimental-settings warning the note mentions.
2026-11-08 12:58:14 +00:00
MSedgeMC 3f7e6970ae port_map: EquipmentSlot.isArmor is Slot::is_armor
The row skipped the predicate as a plain constant, but Slot::is_armor is
documented as EquipmentSlot.isArmor and read by slot_for and mob loot
replacement.
2026-11-08 12:57:49 +00:00
Horizon agentandClaude Opus 5.5 d609f56d42 golem_defense_tests: drop a doc paragraph and a constant copied from another test
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 13:57:20 +01:00
MSedgeMC 37ed06402d port_map: the weather layer rows name environment_system
apply_weather is gone; the weather layers live in horizon-world's
environment_system: Builder::weather_layers adds one per attribute rain or
thunder changes, and Builder::weather_layer registers the entries weather
runs.
2026-11-08 12:57:19 +00:00
sluisr e5043bb090 fix(security): bound item predicate recursion, validate registry paths, and fix threads UB
- Guard recursive `Component::read` calls in `skip::component_matchers`,
  `gameplay::block_predicate`, and `v1_20_3::from_tag` with `Nested::enter()`
  to prevent stack overflow DoS via deeply nested `CAN_PLACE_ON` / `CAN_BREAK`
  exact component matchers, and tighten `banner_layers` pre-allocation cap.
- Validate registry and entry resource location segments in
  `net::fill_known_entries` before joining paths and reading local files on
  `Clientbound::RegistryData`.
- Fix data race UB in `horizon_threads::heap_census` (`slot` / `slot_name`)
  by publishing `NAME_PTR` with `Release` and loading with `Acquire`, and
  guard `CensusAllocator::alloc` / `realloc` against size addition overflow.
- Fix provenance UB in `horizon_threads::boot_paint` where an immutable
  reference `&count` was passed as Win32 `AddFontMemResourceEx`'s mutable
  `pNumFonts` out-pointer, and require `'static` font byte slices.
2026-11-08 13:55:29 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 1c1ad8ec32 The outer ring of a portal ticket runs scheduled ticks and block events
A portal ticket's streaming area had radius ENTITY_TICKING - level (1 for
the level-30 ticket), so chunks finished only out to ring 2 and a ring-2
chunk never had all its neighbours: its scheduled block and fluid ticks and
block events never ran. Vanilla keeps ring 3 FULL (level 33), so ring 2
(BLOCK_TICKING) runs them. The area's radius is now BLOCK_TICKING - level.

tools/test_portal_ticket_ticking_parity.py sends an item through a Nether
portal on real vanilla and Horizon dedicated servers and compares a
repeater, a piston and water in rings 1, 2 and 3 of the exit's ticket.

Fixes #12288

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 13:51:55 +01:00
Horizon agentandClaude Opus 5.5 d035060e66 Grindstone: a merged max damage equal to the item's own writes no component
GrindstoneMenu.mergeItems sets MAX_DAMAGE to the larger of the two, and
PatchedDataComponentMap.set drops a value equal to the prototype's. Since
#11434 Horizon always wrote it, so merging two plain diamond pickaxes gave
one carrying max_damage:1561 (menu_click 7998/8000 on main).

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

Co-Authored-By: Codex <[email protected]>
2026-11-08 13:41:10 +01:00
MSedgeMC 678dfde14b A non-tame predator stops hunting prey its selector no longer matches
`NonTameRandomTargetGoal.canContinueToUse` re-tests the goal's
`targetConditions` while hunting, so the `Turtle.BABY_ON_LAND_SELECTOR`
(a baby turtle out of water) drops out as soon as the turtle enters
water. Horizon's `NearestTarget` only ran the base continue rules, and its
turtle filter never checked the water, so a wolf or cat kept hunting the
turtle.
2026-11-08 12:37:25 +00:00
AkirabaneandClaude Sonnet 5.5 32715a15da Recipe book: take the entries a server's packet carries
The client kept only the display id of each entry of a
ClientboundRecipeBookAddPacket and looked it up in the numbering of its
own recipes. A server that numbers its recipes differently (a plugin's
recipes, a datapack, another load order) had its recipes dropped, or
shown as another recipe, such as TNT missing on a Paper server.

Vanilla's ClientRecipeBook stores the RecipeDisplayEntry it is sent. The
book now takes each entry as it came: its display, result, category,
group and the ingredients the craftable filter checks. The book's own
entry stays when it is the same recipe (same result, category, layout
and ingredients), so a server of the client's own recipes shows what it
did before, and the entries taken join the groups of the ones kept. An
entry this book cannot show removes the recipe that had its number; an
entry of a category the client does not know changes nothing.

Fixes #11705

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-08 13:36:50 +01:00
Ludovic Marie 1597853de7 Merge pull request #12486 from echo-ilovech3ss/fix/12287-windows-save-roundtrip-paths
🤖 Agent: Tooling only: save_roundtrip compares relative .dat paths as POSIX on every OS, so Windows' backslashes no longer make every file look renamed.
2026-11-08 13:36:22 +01:00
Ludovic Marie 7d0f866a47 Merge pull request #12481 from RINNECODER/up4/upgrade-surface-rule-biomes
🤖 Agent: A surface rule's biome_is keeps vanilla's biomes and the pack's own; others (a "comment" entry, another mod's biome) are dropped and reported as lossy, since 26.3 refuses the pack over them.
2026-11-08 13:36:18 +01:00
Vinícius MoreiraandClaude Opus 5.5 600fd5f04a Polytone: run custom particles and block and entity particle emitters
Resource packs' custom particle types now run in the particle engine: the
initializer sets the particle up, the ticker moves, recolours and removes it,
its emitters spawn more, emitter values seed the particles they spawn, and
`limit` caps how many live at once. Block modifiers' emitters run from the
block animate tick, the way torches spawn flames; entity modifiers' emitters
run on the player each client tick (fog, dust and weather around them).
Expressions see the particle, the blocks and biome around it, the player,
the camera, the time and weather.

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

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

Part of #11486

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

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

Part of #11486

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

Part of #11486

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

Fixes #12487

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

Part of #11486

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

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

Part of #11486

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

Part of #11486

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 09:16:20 -03:00
sluisr fec26a0044 perf: reuse server States tracking pool and reduce meshing/lighting allocations
- Reuse the pooled EntityState entries in States (`out.add`) across all 21
  object and projectile loops in `lan_entities.rs`, and push vehicle, armor
  stand pose, and arrow metadata directly into `e.meta`.
- Look up vehicle driver mobs via `self.mob_index` in `lan_riding` instead
  of a linear scan over `self.mobs`.
- Compare `play.equipped` by reference before cloning 5 `ItemStack`s on
  every `enter_guest` turn, and reuse `play.craft_grid` via `std::mem::swap`.
- Eliminate per-fluid-cell `Vec::new()` allocations in `mesher/fluid.rs` and
  deduplicate animated fluid layers directly into `out.sprite_layers`.
- Reuse `SectionMesh::packed` capacity and a thread-local scratch buffer in
  `mesher/packed.rs` during `sort_solid`.
- Pack uniform/single-channel lighting from a stack buffer in `chunk.rs`
  (`Light::from_slice`) instead of allocating and dropping a 4 KiB `Box`.
- Check `!self.saves_beside_final` and neighbor readiness before cloning
  `Chunk` in `Streaming::update`.
2026-11-08 13:15:43 +01:00
Delete228andCodex e9aa68c3ed Merge Horizon main into the state-dependent light PR
Keep entity-context geometry and state-dependent light in the same content compiler. Exercise both selectors in the light propagation fixture.

Co-authored-by: Codex <[email protected]>
2026-11-08 15:11:38 +03:00
LokiandAntigravity 9fd2c8c339 items: require '=' for non-removal command components
Java 26.3 `ItemParser.State.readComponents` expects '=' after a component
type unless it begins with '!'. Return an error for bare components rather
than silently ignoring them.

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

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 12:58:30 +01:00
Florian Ernst 8b4a95b099 Merge origin/main into fix/h112-guest-container-close-event
Co-Authored-By: Claude Opus 5.5 <[email protected]>

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

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

Part of #11486

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 12:48:19 +01:00
Ludovic Marie 4b2cba530a Merge pull request #12451 from apeirs/fix/fxc-entity-written-color
🤖 Agent: FXC (DX12) failed on discards one call below the entry point; fs_entity now computes entity_color itself and hands it on, same colour on every backend.
2026-11-08 12:48:16 +01:00
Ludovic Marie 4b99e9a701 Merge pull request #12441 from RINNECODER/up3/far-tile-walk
🤖 Agent: Boxing the far tile meshes shrinks Tile for the per-frame walk; release paths unbox each mesh once, nothing double-released.
2026-11-08 12:48:12 +01:00
Horizon agentandClaude Opus 5.5 4497f18b31 Merge origin/main into ui/hud-room
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 12:47:26 +01:00
Riccardo GuerrieroandClaude Opus 5.5 a978363647 Merge origin/main into perf/eager-chunk-saves
dedicated.rs: main's `stop_now` loop condition, keeping the per-tick
`server_tick_deadline` the eager saves read for `haveTime`.
ModifiedChunks gains `clear()` (dirty set only, like `drain`) for main's
new mod_block_entity_tests.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Fekd42N6Dh4LhpXkVwZmHs
2026-11-08 12:45:37 +01:00
Ludovic Marie e761e6394a Merge pull request #12463 from MSedgeMC/port_map/edge-walk
🤖 Agent: VoxelShape::edges exists in horizon-core's shape.rs, so forAllEdges is a port now rather than skip:data.
2026-11-08 12:45:18 +01:00
Ludovic Marie bced9e4ecd Merge pull request #12462 from MSedgeMC/port_map/watcher-home
🤖 Agent: PackSelectionScreen.Watcher spans datapack.rs::folder_stamp and options_ui.rs's watch, so a home row fits better than a single port target.
2026-11-08 12:43:01 +01:00
Evan-lhmandClaude Opus 5.5 c853168fb7 Mod content: mark a reused root beside it, not in it (#12403)
With the packs now laid over the content root, rewriting the root's stamp
on every reuse changed a file the pack overlay depends on, so a second
launch with packs on rebuilt the overlay (53 s instead of 18 s). Mark the
reuse in `<root>.used` beside the root; pruning takes the newer of the two
times and removes the marker with its root.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 12:39:35 +01:00
Ludovic Marie 4dd22ecd6a Merge pull request #12459 from MSedgeMC/port_map/infested-silverfish
🤖 Agent: spawn_infested, player_infested and mobs.rs::infested_silverfish all exist on main; the rows now name where the roll and the spawn happen.
2026-11-08 12:38:53 +01:00
Ludovic Marie ba041dc3bb Merge pull request #12439 from RINNECODER/up3/pack-halflives
🤖 Agent: Pack half-lives replace OptiFine's defaults in the same units (600/200/10/1). I pushed a scripted-clock version of its test: backdating u.last was the wall-clock flake #12354 removed.
2026-11-08 12:34:49 +01:00
861ac2b28d Thorns answers a mob's attacks on a mob, and a player's reaches the dragon
A mob wearing Thorns now hurts the mob that attacks it, as
EnchantmentHelper.doPostAttackEffects runs after each vanilla attack
caller: melee, stings, cube contacts, golem hits, charges, spear stabs,
direct arrows, tridents, wind charges, fireballs, spit, shulker bullets,
wither skulls and owned evoker fangs. Ravager roars, guardian beams and
retaliation, hot-cube contact and other non-attacks get their own
DamageMob event that skips it. The damage rolls from the attacker's own
random, the chance from the level's.

The dragon's head and wing contacts run it for mobs (part by part, as
knockBack and hurt walk each list) and for players, whose Thorns now
hurts the dragon through its body part.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-Authored-By: Codex (gpt-6.1-sol) <[email protected]>
2026-11-08 12:33:49 +01:00
Ludovic Marie 706aad56c0 Merge pull request #12455 from Evan-lhm/fix/release-sums-windows-test
🤖 Agent: Git Bash's sha256sum writes `*name` (binary mode); the test strips the star, nothing else changes.
2026-11-08 12:29:15 +01:00
Ludovic Marie 564f031294 Merge pull request #12445 from RINNECODER/up3/lod-dispatch-incremental
🤖 Agent: Mesh jobs' samples are kept per 8x8 shard and synced at every active/ready change; debug builds assert the copy matches active ∩ ready.
2026-11-08 12:29:12 +01:00
Ludovic Marie f18c761edc Merge pull request #12443 from RINNECODER/up3/mod-content-copy
🤖 Agent: Plain files are copied with the same size and path checks read() made; tags, atlases, declarations, biome modifiers, worldgen and structures are still read and parsed.
2026-11-08 12:29:08 +01:00
Ludovic Marie c2661a333d Merge pull request #12410 from masa-masa-0314/feat/friends-service
🤖 Agent: The friends worker never holds the lock across HTTP (reads the etag, unlocks, fetches, relocks), and nothing starts unless the account's FRIENDS_ENABLED flag is set.
2026-11-08 12:29:05 +01:00
Ludovic Marie c09321478d Merge pull request #12408 from m-uvex/pluto/add-flake
🤖 Agent: The flake builds the launcher from the repo itself, so it follows main, unlike #11200's pinned tarball; both outputs evaluate on NixOS (horizon-launcher-0.2.0.drv, nix-shell.drv); CI does not build it.
2026-11-08 12:29:01 +01:00
Horizon agentandClaude Opus 5.5 519d9b6d97 persist: ignore the second summon UUID test without prepared resources
It unwraps HORIZON_TEST_RESOURCES like its sibling, which is already #[ignore]d.

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

Fixes #12395

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

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

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

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

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

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

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

Fixes #12454

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 11:59:54 +01:00
Evan-lhmandClaude Opus 5.5 dcc73b1491 Packs: lay the resource packs over the mods' content root (#12403)
The content root was built over the pack overlay, so every pack change
(and a rebuilt overlay at launch) gave it a new stamp and a full rebuild:
84k files and 2-3 minutes on Windows with a 23-mod modpack. Build it over
vanilla instead and lay the packs over it: vanilla, then mods, then packs,
which is also vanilla's order (a pack that retextures a mod block wins).

Fixes #12403

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

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

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

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

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

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

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

Part of #12380

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Fixes #12186

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

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

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

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

# Conflicts:
#	crates/horizon-client/src/game.rs
2026-11-08 09:56:11 +01:00
Florian Ernst 518adb688e Merge origin/main into fix/h112-guest-container-close-event
Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	tools/command_exec_corpus.py
#	tools/test_command_exec_parity.py
2026-11-08 09:56:02 +01:00
Florian Ernst 1699b82741 Merge origin/main into fix/named-predicate-context
Co-Authored-By: Claude Opus 5.5 <[email protected]>

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

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

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

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

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

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

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

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

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

New command corpus group: potion contents names.

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

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

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

Part of #11486

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

Part of #11486

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

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

Part of #11486

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

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

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

New command corpus group: loot give full inventory.

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

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

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

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

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

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

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

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

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

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

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

Fixes #12121

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

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

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

Fixes #11974
Fixes #11970

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

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

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

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

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

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

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

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

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

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

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

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

Fixes #12148

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

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

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

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

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

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

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

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

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

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

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

Fixes #9335.

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

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

Fixes #12020

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

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

Fixes #12131.

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

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

Fixes #12122.

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

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

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

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

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

Fixes #12090.

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

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

Fixes #12037.

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

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

Fixes #11997

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 18:33:30 +01:00
Delete228andCodex 753477f5b4 Merge Horizon main into the state-dependent light PR
Co-authored-by: Codex <[email protected]>
2026-11-07 20:00:52 +03:00
Florian ErnstandClaude Opus 5.5 a191dcd2b2 Merge origin/main into fix/h052-vehicle-container-save
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 17:14:59 +01:00
Florian ErnstandClaude Opus 5.5 1bf526ca7f Merge origin/main into fix/h193-effective-component-presence
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 17:14:48 +01:00
Florian ErnstandClaude Opus 5.5 635f14ea0e Merge origin/main into fix/h151-boat-passenger-command
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 17:14:47 +01:00
Horizon agentandClaude Opus 5.5 3dcb5cba92 Merge origin/main into #11570
command_exec_corpus.py: keeps this branch's isolated 'guest container
close event' group beside main's spectral duration and item Health groups.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 17:13:29 +01:00
Florian ErnstandClaude Opus 5.5 4adf2afffb Drop passenger entries that load nothing, keep riders through /data merge
EntityType.loadPassengersRecursive mounts only the Passengers entries that
load, and Entity.saveWithoutId saves the riders the entity has.
take_mob_passengers kept every non-mob entry raw, so an unknown or missing
id, or a non-compound value, stayed in the saved Passengers. It now drops
an entry that is not a compound or has no registered type (entity-types,
EntityType.by), and keeps valid non-mob entries raw as before.

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

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

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

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

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

# Conflicts:
#	crates/horizon-client/src/game/commands/terrain_cmds.rs
#	crates/horizon-items/src/items.rs
2026-11-07 14:19:54 +01:00
Horizon agent 37db8a337e Merge origin/main into PR #11570
Co-Authored-By: Claude Opus 5.5 <[email protected]>

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 11:14:10 +01:00
Horizon agentandClaude Opus 5.5 79fe52def4 Merge origin/main into fix/h112-guest-container-close-event
command_exec corpus: keep main's craft-map group in ISOLATED_GROUPS and its import beside guest_close_event. Oracle: keep main's merchant_saved_stats op; close_menu goes through handleContainerClose. The test's Joined literal gains main's new configured field.

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

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

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

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

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

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

The item test hands set_loot_component a banner_patterns list and reads
`banner_patterns` back as two BannerLayers (pattern and dye), and a null
component empties them again. Without the arm the list is empty - the banner
wears nothing.
2026-11-07 06:49:34 +00:00
MSedgeMC 6ec1865ac1 fix: a looted banner_patterns component dresses the banner
set_loot_component only knew the trim, so a rolled banner_patterns list landed
in `other` as a raw tag: the renderer and the tooltip read
`StackData::banner_patterns`, which stayed empty, and the banner from a
datapack table (or a set_components function) came out undyed. The arm parses
the component with the same `layers_from_nbt` the item's own component uses, so
the layers - pattern and dye - reach the fields the game reads.
2026-11-07 06:49:12 +00:00
MSedgeMC 18cb5eb6d0 fix: set_banner_pattern writes a banner's layers
SetBannerPatternFunction sets the stack's banner_patterns to the function's
layers, or with append adds them to the end of the component's own list
(BannerPatternLayers.Builder.addAll twice). A layer is a pattern id and a dye
name, so the list the table wrote is the component's list as it stands. The arm
was missing, so a datapack's banner pattern changed nothing: the banner came out
bare, or with only the layers an earlier function wrote.
2026-11-07 06:49:12 +00:00
Riccardo GuerrieroandClaude Opus 5.5 bb9e6d6ebf Save a column again when an edit's light changes it
A block edit relit in the frame changes light up to 15 blocks into the
columns around it, but only the edited column was marked unsaved. With
eager saves a neighbour saved a moment earlier kept its old light on disk.
Vanilla's ChunkHolder.sectionLightChanged marks every chunk whose light
section changed unsaved; mark_light_changes now does the same.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 07:48:55 +01:00
MSedgeMC 66931c6958 port_map: the two set_custom_model_data rows are ported
apply names the ListOperation helper its four lists go through, and run names
the arm that draws the floats and colours and keeps the component the stack
already had. The rows said "no vanilla loot table uses it", which was true but
not a reason for a table that does to change nothing.
2026-11-07 06:46:00 +00:00
MSedgeMC 0ba45ef93e test: the four model-data lists each follow their own operation
A table writes custom_model_data through set_components (floats [1.0], strings
["old"]) and then set_custom_model_data: floats appended from a constant
provider, flags and strings replaced, a colour inserted first. The rolled stack
carries 1.0 and 2.0, [true], ["new"] and [16711680] - without the arm the
component keeps the first function's lists and has no colours at all.

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

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

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

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

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

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

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

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

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

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

`extra.johnny` is the latch, taken from the live name in
`vindicator_server_ai_step` (the AI step is where a name set since the last step
is seen, and it never clears), read by the goal, written as `Johnny` and read
back from it. A save with no `Johnny` loads false, whatever the name in the
compound, as `getBooleanOr` does.
2026-11-06 23:04:14 +00:00
MSedgeMC 6a5fc04295 test: a re-saved section writes only the states a cell holds
Both cases read the emitted tag, so they fail on the live palette: 342 longs
of `data` where vanilla's `SimpleBitStorage(4, 4096)` is 256, a palette of
`[0, 1, … 16]` where first use is `[1, … 15, 0]`, and `(2, Some(256))` where a
section edited back to one state is `(1, None)`.
2026-11-06 22:49:30 +00:00
MSedgeMC 5dc59396d6 fix: save a chunk section's palette as vanilla packs it
`PalettedContainer.pack` re-encodes the cells into a fresh palette before a
section is written: a state no cell holds any more is dropped, the entries are
in first-use order, and the bit count is `getConfigurationForPaletteSize` of
the states that remain. `PalettedContainer::to_nbt` wrote the live palette
instead, which keeps the slot of every state a block edit replaced (the `free`
list needs it) and packs at that wider count: a section whose 17th state was
edited away saved 342 longs where vanilla writes 256, and a section edited back
to a single state saved a 2-entry palette with a 256-long `data` where vanilla
writes one entry and no `data`.
2026-11-06 22:47:00 +00:00
Delete228andCodex 5c591b13a4 Merge Horizon main into the PR branch
Co-authored-by: Codex <[email protected]>
2026-11-07 00:45:54 +03:00
137486c83a Save changed chunks while playing to reduce work when leaving a world
Follow vanilla's eager chunk queue, cooldown and per-tick/time/write limits.
Keep save state with its level and respect disabled saves and server pauses.
Save only lit (full) chunks eagerly and only once a world is open; an unlit
one keeps its place in the queue.
Move proto NBT encoding to the region writer without changing its encoder.
Cover selection regressions and repeated edits reaching real region files.

Co-Authored-By: pi coding agent (DeepSeek-V4.1-Flash) <[email protected]>
Co-Authored-By: Codex (gpt-6.1-sol) <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 21:46:12 +01:00
TheMrZZandCodex gpt-6.1-sol fec7a5f9e6 Respect class-owned container fields in vehicle saves
Keep plain vehicles free of container fields and save pending boat tables
instead of Items. Normalize recognized pending keys while preserving the
existing cart loot restoration path.

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

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

Co-Authored-By: Pluto <[email protected]>
2026-11-06 18:21:05 +03:00
Delete228andCodex d2cb5a272b Compile state-dependent light emission from content declarations
Resolve typed state_light selectors once into ordinary gameplay emission rows while preserving scalar light and template defaults. Reject invalid and uncovered selector values. Verify native facts readback, cache invalidation, and real cross-chunk lighting transitions.

Validation: 37 declaration tests and 48 content compiler tests pass; strict client/mods clippy and workspace formatting pass.

Co-Authored-By: Codex <[email protected]>
2026-11-06 14:50:59 +03:00
517 changed files with 38950 additions and 2894 deletions

No files matched your search

+2 -1
View File
@@ -25,7 +25,8 @@
<!-- "none", or for each project you read or copied from: its link at the commit, its license (only MIT,
BSD-2-Clause, BSD-3-Clause, Apache-2.0, ISC, zlib, 0BSD, MIT-0, Unlicense or CC0-1.0), and what you took.
CONTRIBUTING.md "Only original code". -->
CONTRIBUTING.md "Only original code". Shader packs: also the sections of docs/cleanroom/shader-packs-spec.md,
the documentation pages and the packs you used. -->
## Self-check
+5
View File
@@ -291,6 +291,11 @@ jobs:
run: |
set -euo pipefail
cargo run --locked -p xtask -- package "$BIN/horizon-client${{ matrix.exe }}" "dist/${{ matrix.dir }}"
# Windows x64: Microsoft's DXC, pinned and checksummed, which DirectX 12 uses on integrated GPUs
# (gfx/dx12_compiler.rs), with its licence. ARM64 keeps FXC (THIRD_PARTY_NOTICES.md says why).
if [[ "${{ matrix.name }}" == windows ]]; then
python tools/ci/fetch_dxc.py "dist/${{ matrix.dir }}"
fi
cd dist
rm -f "${{ matrix.asset }}"
case "${{ matrix.asset }}" in
+1 -1
View File
@@ -162,7 +162,7 @@ state stays separate from the library tests. This compiles gameplay twice for ex
| Player actions | game.rs (`fn use_hand`, `fn tick_mining`, `fn break_block`); G: `item_actions.rs` (`fn use_item`), `melee.rs` (`fn attack`), `hand.rs`, `combat.rs`, `consume.rs`, `using_item.rs` (an item in use, tick by tick), `potions.rs`, `shield.rs`, `crossbow.rs`, `trident.rs`, `fishing.rs`; L: `motion.rs` + `motion/` (movement), `collisions.rs`; crate `horizon-items`: `items.rs`, `item_data.rs`, `enchant.rs`, `blocks.rs` |
| Inventory and containers | L: `menu.rs` (`AbstractContainerMenu.clicked`), `container_screen.rs`, `containers.rs`, `creative.rs`, `crafting.rs`, `recipe_book*.rs`, `stations.rs`, `merchant.rs`; game.rs `fn menu_ops`, `fn click_inventory`; G: `stations.rs`, `trading.rs`, `recipe_book.rs`, `machines.rs`, `inventory_input.rs` (keys and clicks over a container), `inventory_slots.rs` (slot layout and drawing) |
| Commands | parse: crate `horizon-command` (`args.rs`, `selector.rs`, `tree_*.rs`, `vanilla.rs`); run: G: `commands.rs` (dispatch) + `commands/*.rs` (`admin`, `execute`, `items`, `players`, `world_cmds`...); L: `scoreboard_cmd.rs`, `data_cmd.rs`; `horizon-client-core`: `nbt_path.rs`, `scoreboard.rs`; G: `functions.rs`, `command_blocks.rs` |
| UI screens | `Screen` enum in game.rs; L: `title.rs` + `title/`, `options_ui.rs`, `multiplayer.rs`, `pack_screen.rs`, `graphics_ui.rs`, `screen_draw.rs`, `ui.rs` (widgets), `hud.rs`, `hud_layout.rs`, `hud_overlays.rs`, `chat.rs`, `tab_list.rs`; G: `startup_menu.rs`, `loading_screen.rs` (the loading screen, the pre-game `App`), `screens.rs` (pause, death), `options.rs`, `lan_screen.rs`, `chat.rs`, `sign_edit.rs`, `books.rs`, `creative_screen.rs` |
| UI screens | `Screen` enum in game.rs; L: `title.rs` + `title/`, `options_ui.rs`, `multiplayer.rs`, `pack_screen.rs`, `graphics_ui.rs`, `screen_draw.rs`, `ui.rs` (widgets), `hud.rs`, `hud_layout.rs`, `hud_overlays.rs`, `hud_kit/` (Horizon overlays' `Room` and `Plate`, [docs/ui-kit.md](docs/ui-kit.md)), `chat.rs`, `tab_list.rs`; G: `startup_menu.rs`, `loading_screen.rs` (the loading screen, the pre-game `App`), `screens.rs` (pause, death), `options.rs`, `lan_screen.rs`, `chat.rs`, `sign_edit.rs`, `books.rs`, `creative_screen.rs` |
| Multiplayer client | L: `net.rs` (network thread); G: `remote.rs` (`fn handle_packet`, `fn tick_remote`), `remote_*.rs` (mob data, objects, projectiles, vehicles, stations) |
| LAN host / server side | L: `lan/` (`conn.rs` handshake + login threads, `hub.rs` + `hub/` play state, `chunks.rs`, `entity.rs`, `tracking.rs`, `guest.rs`, `playerdata.rs`); G: `lan.rs` (`LanHost`), `lan_actions.rs` (`fn as_guest`, `fn tick_guests`), `lan_*.rs` per feature, `lan_entities.rs` (tracked entities) |
| Integrated server | G: `integrated.rs` (the server thread), `levels.rs` (one level per dimension, `fn tick_server`), `server_travel.rs` |
+3 -1
View File
@@ -76,7 +76,9 @@ another project's source may also be read and used, **only when that project's l
User documentation and file formats are fine to read, and packs and mods are fine to run and observe, whatever their
license. Shader packs are a clean-room rewrite of the pack format, produced from a written description of it (see
#1561); they carry no foreign source, and the rules above still apply to anything you read while working on them.
#1561): the [specification and its provenance log](docs/cleanroom/README.md) are public. They carry no foreign
source, and the rules above still apply to anything you read while working on them. A shader-pack pull request
names its sources: the sections of that specification, the documentation pages and the packs it ran.
## Before asking for review
Generated
+2
View File
@@ -1588,6 +1588,7 @@ dependencies = [
"horizon-mod-api-gen",
"horizon-narrator",
"horizon-optifine",
"horizon-polytone",
"horizon-protocol",
"horizon-shader-api",
"horizon-shaders",
@@ -1775,6 +1776,7 @@ dependencies = [
name = "horizon-polytone"
version = "0.1.0"
dependencies = [
"log",
"regex",
"serde_json",
]
+26
View File
@@ -63,6 +63,32 @@ variable's array specifier does not change later variables.
It also reads a struct member outside a constant expression through a pointer to the member,
not out of a loaded copy of the whole struct, so a member an atomic function uses can be read.
## DirectX Shader Compiler
The Windows x64 game folder holds `dxcompiler.dll`, Microsoft's DirectX Shader Compiler
[v1.9.2609](https://github.com/microsoft/DirectXShaderCompiler/releases/tag/v1.9.2609), unmodified: the
`bin/x64/dxcompiler.dll` of `dxc_2026_09_29.zip` (zip SHA-256
`ad31b1fc8443175d204f77a611fdb3ef2ec42759bdc2f1167368de24a4a7e7f1`, DLL SHA-256
`0689e2fee0a1c9df8662a59680469f24e2a3ad7ce6bbbcc9284f7a83172bfc59`, 22,274,872 bytes). The release build downloads
it and checks both checksums (`tools/ci/fetch_dxc.py`); it is not in this repository. DirectX 12 loads it only on
integrated GPUs (`crates/horizon-client-render/src/gfx/dx12_compiler.rs`,
[#8513](https://github.com/horizon-rust/horizon/issues/8513)); every other GPU compiles with FXC, part of Windows.
Copyright (c) Microsoft Corporation. It is not covered by Horizon's MIT license. It is distributed under the
"Microsoft Software License Terms, Microsoft DirectX Shader Compiler" (`LICENSE-MS.txt` of that release). Its
Distributable Code terms are the Windows SDK's: end users get terms that protect the code and Microsoft at least as
much, and the distributor indemnifies Microsoft. The project accepted them on 2026-11-08. Its open-source part is also under the University of Illinois/NCSA license (`LICENSE-LLVM.txt`). Both
files ship beside the DLL as `DXC-LICENSE-MS.txt` and `DXC-LICENSE-LLVM.txt`, with `DXC-NOTICE.txt`
(`packaging/windows/`), which tells players those terms apply and that deleting the DLL brings back FXC.
- Only x64: the Windows SDK's list of distributable files
([REDIST.TXT lists](https://learn.microsoft.com/en-us/legal/windows-sdk/redist), updated 2024-10-21) names
`Redist\D3D\x64\dxcompiler.dll` and `dxil.dll` and their x86 copies, for classic Windows applications (Horizon is
one), and no ARM64 copy. The release zip's own `LICENSE-MS.txt` refers to a list of distributable files that the zip
doesn't contain, so the SDK's list is the one followed. The Windows ARM64 build ships no DXC and compiles with FXC.
- No `dxil.dll`: since DXC 1.8.2505 the compiler signs shaders with its own validator and no longer loads it, and
wgpu never does.
## libmimalloc-sys and mimalloc
`third_party/libmimalloc-sys` is [libmimalloc-sys](https://github.com/purpleprotocol/mimalloc_rust)
+2 -2
View File
@@ -97,8 +97,8 @@ rustix = { version = "1", default-features = false, features = ["std", "time", "
# Use the registered URI handler without routing links through Explorer/cmd.
open = { version = "5.4.5", features = ["shellexecute-on-windows"] }
# DirectX 12, where Vulkan has no adapter or device, or by choice (`graphicsApi`, graphics.rs).
# Shaders compile with FXC (d3dcompiler_47.dll, part of Windows), or DXC when dxcompiler.dll is
# on the PATH.
# Shaders compile with FXC (d3dcompiler_47.dll, part of Windows), or on integrated GPUs with DXC
# (dxcompiler.dll beside the game, gfx/dx12_compiler.rs).
wgpu = { version = "30.0.1", default-features = false, features = ["dx12"] }
[target.'cfg(target_os = "macos")'.dependencies]
+1
View File
@@ -23,6 +23,7 @@ horizon-narrator = { path = "../horizon-narrator" }
# OptiFine-format pack files: random entity textures (hud.rs), connected textures
# (connected.rs) and custom item texture rules (cit.rs).
horizon-optifine = { path = "../horizon-optifine" }
horizon-polytone = { path = "../horizon-polytone" }
horizon-protocol = { path = "../horizon-protocol" }
horizon-shader-api = { path = "../horizon-shader-api" }
horizon-shaders = { path = "../horizon-shaders" }
@@ -0,0 +1,480 @@
//! A world warp (`native_world_warp`, docs/native-mods.md "World warps") through the real
//! `Renderer`, offscreen: a wall of terrain and an entity quad drawn without a warp, with one,
//! with one that is off, with one that cuts the wall, with a pixel stage of its own and with
//! one that doesn't compile. Run with throwaway HORIZON_HOME, HORIZON_LAUNCHER_HOME,
//! XDG_DATA_HOME and XDG_CONFIG_HOME (AGENTS.md):
//! `cargo run -p horizon-client-render --example world_warp_check -- <output-dir>`
//! `--release ... -- <output-dir> --bench`: instead, the time of a frame of rolling land 49 by 49
//! chunks around the camera without a warp, and with one (nothing culled), at 1920 by 1080.
use client::{
gfx::{Frame, Renderer, ScreenshotRequest, native_render},
graphics::GraphicsApi,
hud::HudBatch,
mesher::{SectionMesh, TerrainVertex, Vertex},
};
use horizon_client_render as client;
use std::{
path::Path,
sync::{Arc, Mutex},
};
use winit::{
application::ApplicationHandler,
event::WindowEvent,
event_loop::{ActiveEventLoop, EventLoop},
window::{Window, WindowId},
};
/// What the mod below gives the renderer each frame.
static WARP: Mutex<Option<native_render::WorldWarp>> = Mutex::new(None);
struct Mod;
impl native_render::RenderHooks for Mod {
fn world_warp(&self, event: &mut native_render::WorldWarpEvent<'_>) {
if let Some(warp) = WARP.lock().unwrap().as_ref() {
*event.warp = warp.clone();
}
}
}
/// Lifts what is left of x = 8 by `params[0].x` and tints it red.
const LIFT: &str = "fn world_warp(pos: vec3<f32>, section: vec3<f32>) -> Warped {
let here = pos.x + warp.camera.x + warp.camera_rest.x;
if here < 8.0 { return Warped(pos + vec3(0.0, warp.params[0].x, 0.0), vec3(1.0, 0.0, 0.0)); }
return Warped(pos, vec3(1.0));
}
";
/// Cuts every face.
const CUT: &str = "fn world_warp(pos: vec3<f32>, section: vec3<f32>) -> Warped {
return Warped(pos, vec3(-1.0));
}
";
/// Paints the terrain green by its pixels and moves nothing.
const PAINT: &str = "fn world_warp(pos: vec3<f32>, section: vec3<f32>) -> Warped {
return Warped(pos, vec3(1.0));
}
// [fragments]
fn world_fragment(f: Fragment) -> Painted {
if !f.front { return Painted(vec4(0.0, 0.0, 0.0, -1.0), 1.0); }
return Painted(vec4(0.0, 1.0, 0.0, 1.0), 0.0);
}
";
/// Moves entities ten blocks right: off the screen's middle.
const SHIFT: &str = "fn world_warp(pos: vec3<f32>, section: vec3<f32>) -> Warped {
return Warped(pos + vec3(-10.0, 0.0, 0.0), vec3(1.0));
}
";
fn read(path: &Path) -> Vec<[u8; 4]> {
let decoder = png::Decoder::new(std::io::BufReader::new(std::fs::File::open(path).unwrap()));
let mut reader = decoder.read_info().unwrap();
let mut bytes = vec![0; reader.output_buffer_size().unwrap()];
let info = reader.next_frame(&mut bytes).unwrap();
assert_eq!(info.color_type, png::ColorType::Rgba);
bytes.truncate(info.buffer_size());
bytes.as_chunks::<4>().0.to_vec()
}
/// Pixels like `want` (within 40 a channel), of an image.
fn count(pixels: &[[u8; 4]], want: [u8; 3]) -> usize {
pixels
.iter()
.filter(|p| (0..3).all(|i| p[i].abs_diff(want[i]) < 40))
.count()
}
/// A wall across x 0..16, y 0..16 at z = 12 of the section at y 96, both windings.
fn wall() -> SectionMesh {
let mut mesh = SectionMesh::default();
let corners = [
[0., 0., 12.],
[0., 16., 12.],
[16., 16., 12.],
[16., 0., 12.],
];
let uvs = [[0., 1.], [0., 0.], [1., 0.], [1., 1.]];
for order in [[0, 1, 2, 3], [3, 2, 1, 0]] {
for i in order {
mesh.solid
.push(TerrainVertex::new(corners[i], uvs[i], 0, u32::MAX, 0xf0f0));
}
}
mesh.sort_solid();
mesh
}
fn fixture(window: Arc<Window>) -> Result<(), Box<dyn std::error::Error>> {
let out = std::env::args().nth(1).ok_or("output directory required")?;
let out = Path::new(&out);
std::fs::create_dir_all(out)?;
native_render::install(Box::new([&Mod])).map_err(|_| "mod already installed")?;
let white = [255; 16 * 16 * 4];
let zero = [0; 4];
let mut renderer = pollster::block_on(Renderer::new(
window,
true,
&white,
16,
16,
1,
&[],
&[],
(&white, 16, 16),
(&zero, 1, 1),
(&zero, 1, 1),
(&zero, 1),
Arc::new(client::gfx::panorama::Faces::colour([0, 0, 0, 255])),
None,
None,
GraphicsApi::Vulkan,
false,
false,
true,
false,
false,
))?;
renderer.resize(320, 180);
renderer.fullbright = true;
// (keys count sections from the bottom of the world, y -64: 10 is y 96 to 112)
renderer.upload((0, 10, 0), &wall());
// an entity quad in front of the wall, middle of the screen
let entity: Vec<Vertex> = [
[7.5, 103.5, 6.0],
[7.5, 104.5, 6.0],
[8.5, 104.5, 6.0],
[8.5, 103.5, 6.0],
]
.into_iter()
.zip([[0., 1.], [0., 0.], [1., 0.], [1., 1.]])
.map(|(p, uv)| Vertex {
pos: [p[0] - 8.0, p[1] - 104.0, p[2] - 0.5],
uv,
layer: 0,
color: 0xff40_40ff,
light: 0xf0,
normal: [0, 0, 0, 0],
glint_uv: [0.0; 2],
})
.collect();
if std::env::args().any(|a| a == "--bench") {
return bench(&mut renderer, out);
}
let hud = HudBatch::new([320, 180]);
let mut frame = fixture_frame(&hud);
frame.entities = &entity;
let mut shot = |name: &str, warp: Option<native_render::WorldWarp>| {
*WARP.lock().unwrap() = warp;
let path = out.join(format!("{name}.png"));
renderer.screenshot = Some(ScreenshotRequest {
path: path.clone(),
size: None,
scaled: false,
});
// twice: the first frame of a new warp builds it
renderer.render(&frame).unwrap();
renderer.render(&frame).unwrap();
read(&path)
};
let warp = |source: &str, on: bool, lift: f32| {
let mut w = native_render::WorldWarp::default();
w.source = Some(source.into());
w.on = on;
w.params[0] = lift;
Some(w)
};
let plain = shot("plain", None);
let wall_px = count(&plain, [255, 255, 255]);
let entity_px = count(&plain, [255, 64, 64]);
assert!(wall_px > 1000, "the wall shows without a warp: {wall_px}");
assert!(
entity_px > 50,
"the entity shows without a warp: {entity_px}"
);
let off = shot("off", warp(LIFT, false, 4.0));
assert!(off == plain, "a warp that is off draws as no warp");
let lifted = shot("lift", warp(LIFT, true, 4.0));
let red = count(&lifted, [255, 0, 0]);
assert!(red > 500, "the warp tints the wall's left half red: {red}");
assert!(lifted != plain);
let cut = shot("cut", warp(CUT, true, 0.0));
let left = count(&cut, [255, 255, 255]);
assert!(
left < wall_px / 20,
"a cut leaves the wall out: {left} of {wall_px}"
);
let painted = shot("paint", warp(PAINT, true, 0.0));
let green = count(&painted, [0, 255, 0]);
assert!(
green > wall_px / 2,
"the warp's own pixels paint the wall: {green}"
);
let shifted = shot("shift", warp(SHIFT, true, 0.0));
let entity_left = count(&shifted[..], [255, 64, 64]);
let mid = |p: &[[u8; 4]]| -> Vec<[u8; 4]> {
(60..120)
.flat_map(|y| p[y * 320 + 130..y * 320 + 190].to_vec())
.collect()
};
assert!(
count(&mid(&shifted), [255, 64, 64]) < count(&mid(&plain), [255, 64, 64]) / 4,
"the warp moves the entity out of the middle ({entity_left} red-ish pixels left)"
);
let broken = shot(
"broken",
warp("fn world_warp() -> f32 { return 1; }", true, 0.0),
);
assert!(broken == plain, "a warp that doesn't compile is left out");
let back = shot("after", None);
assert!(back == plain, "with the warp gone the frame is as before");
std::fs::write(
out.join("result.json"),
serde_json::to_vec_pretty(&serde_json::json!({
"wall_pixels": wall_px, "entity_pixels": entity_px, "lifted_red_pixels": red,
"cut_wall_pixels_left": left, "painted_green_pixels": green,
"off_equals_plain": true, "broken_equals_plain": true, "after_equals_plain": true,
"graphics_adapter": client::crash::system_details("fixture").into_iter()
.find(|(name, _)| name == "Graphics Adapter").map(|(_, detail)| detail),
}))?,
)?;
println!(
"world warp check passed: {}",
out.join("result.json").display()
);
Ok(())
}
/// Rolling land: a column of a section's top faces and the sides they show, at y 64 to 80.
fn hills(cx: i32, cz: i32) -> SectionMesh {
let height = |x: i32, z: i32| {
let (x, z) = (f64::from(cx * 16 + x), f64::from(cz * 16 + z));
(8.0 + 4.0 * (x * 0.09).sin() + 3.0 * (z * 0.07 + x * 0.02).cos()).floor() as i32
};
let mut mesh = SectionMesh::default();
let mut quad = |c: [[f32; 3]; 4]| {
for (p, uv) in c.into_iter().zip([[0., 1.], [0., 0.], [1., 0.], [1., 1.]]) {
mesh.solid
.push(TerrainVertex::new(p, uv, 0, 0xff60_a060, 0xf0));
}
};
for x in 0..16 {
for z in 0..16 {
let h = height(x, z);
let (fx, fz, fh) = (x as f32, z as f32, h as f32);
quad([
[fx, fh, fz],
[fx, fh, fz + 1.],
[fx + 1., fh, fz + 1.],
[fx + 1., fh, fz],
]);
// the step down to a lower neighbour, toward +x and +z
for (dx, dz) in [(1, 0), (0, 1)] {
let n = height(x + dx, z + dz);
if n < h {
let (lo, hi) = (n as f32, fh);
if dx == 1 {
quad([
[fx + 1., lo, fz],
[fx + 1., hi, fz],
[fx + 1., hi, fz + 1.],
[fx + 1., lo, fz + 1.],
]);
} else {
quad([
[fx, lo, fz + 1.],
[fx + 1., lo, fz + 1.],
[fx + 1., hi, fz + 1.],
[fx, hi, fz + 1.],
]);
}
}
}
}
}
mesh.sort_solid();
mesh
}
/// `--bench`: milliseconds a frame without a warp and with one.
fn bench(renderer: &mut Renderer, out: &Path) -> Result<(), Box<dyn std::error::Error>> {
const R: i32 = 24;
renderer.resize(1920, 1080);
let mut sections = 0;
for cx in -R..=R {
for cz in -R..=R {
// (keys count sections from y -64: 8 is y 64 to 80)
renderer.upload((cx, 8, cz), &hills(cx, cz));
sections += 1;
}
}
let hud = HudBatch::new([1920, 1080]);
let mut frame = fixture_frame(&hud);
frame.camera = glam::DVec3::new(8.0, 90.0, 8.0);
frame.eye = frame.camera;
frame.pitch = 15.0;
frame.eye_rotation = [0.0, 15.0];
frame.distance = 16.0 * R as f32;
frame.detail_distance = frame.distance;
frame.depth_far = 2000.0;
frame.cave_culling = true;
let mut time = |warp: Option<native_render::WorldWarp>| {
*WARP.lock().unwrap() = warp;
for _ in 0..30 {
renderer.render(&frame).unwrap();
}
let started = std::time::Instant::now();
const FRAMES: u32 = 240;
for _ in 0..FRAMES {
renderer.render(&frame).unwrap();
}
// the last frame's picture, so the GPU has caught up
renderer.screenshot = Some(ScreenshotRequest {
path: out.join("bench.png"),
size: None,
scaled: false,
});
renderer.render(&frame).unwrap();
renderer.render(&frame).unwrap();
started.elapsed().as_secs_f64() * 1000.0 / f64::from(FRAMES + 2)
};
let mut wave = native_render::WorldWarp::default();
wave.source = Some(
"fn world_warp(pos: vec3<f32>, section: vec3<f32>) -> Warped {
let x = pos.x + warp.camera.x + warp.camera_rest.x;
return Warped(pos + vec3(0.0, sin(x * 0.05) * 3.0, 0.0), vec3(1.0));
}\n"
.into(),
);
wave.on = true;
let plain = time(None);
let warped = time(Some(wave));
let again = time(None);
let result = serde_json::json!({
"sections": sections, "frame_ms_without_warp": plain, "frame_ms_with_warp": warped,
"frame_ms_without_warp_after": again,
"graphics_adapter": client::crash::system_details("bench").into_iter()
.find(|(name, _)| name == "Graphics Adapter").map(|(_, detail)| detail),
});
println!("{result}");
std::fs::write(out.join("bench.json"), serde_json::to_vec_pretty(&result)?)?;
Ok(())
}
struct App;
impl ApplicationHandler for App {
fn resumed(&mut self, event_loop: &ActiveEventLoop) {
let window = Arc::new(
event_loop
.create_window(Window::default_attributes().with_visible(false))
.unwrap(),
);
if let Err(error) = fixture(window) {
eprintln!("world warp check failed: {error}");
std::process::exit(1);
}
event_loop.exit();
}
fn window_event(&mut self, _: &ActiveEventLoop, _: WindowId, _: WindowEvent) {}
}
fn main() {
for key in [
"HORIZON_HOME",
"HORIZON_LAUNCHER_HOME",
"XDG_DATA_HOME",
"XDG_CONFIG_HOME",
] {
assert!(
std::env::var_os(key).is_some(),
"set throwaway {key} (AGENTS.md)"
);
}
EventLoop::new().unwrap().run_app(&mut App).unwrap();
}
fn fixture_frame(hud: &HudBatch) -> Frame<'_> {
Frame {
weather: &[],
weather_time: client::weather::Time::new(0, 0.0),
camera: glam::DVec3::new(8.0, 104.0, 0.5),
yaw: 0.0,
pitch: 0.0,
fov_degrees: 85.0,
eye: glam::DVec3::new(8.0, 104.0, 0.5),
eye_rotation: [0.0, 0.0],
first_person: true,
bob: glam::Mat4::IDENTITY,
spin: glam::Mat4::IDENTITY,
sky: client::environment::Sky::default(),
shadow_scene_revision: (0, 0),
shadow_cache: false,
distance: 128.0,
detail_distance: 128.0,
depth_far: 1000.0,
underwater: false,
water_plane: None,
cave_culling: false,
title: false,
sky_blocked: false,
skybox: false,
end_sky: false,
end_sky_sprite: None,
boss_world_fog: false,
environment_fog: None,
water_fog: [0.0; 3],
fog: None,
dark_disc: false,
outline: Vec::new(),
outline_after_terrain: false,
outline_high_contrast: false,
pvp: Default::default(),
debug_lines: &[],
debug_lines_on_top: &[],
debug_crosshair: &[],
hud,
glint_alpha: 0.75,
glint_ticks: 0.0,
nether_lighting: false,
entities: &[],
static_block_entities: &[],
visible_static_block_entity_sections: &[],
static_block_entity_stats: Default::default(),
entity_motion: &[],
entity_motion_posed: &[],
entity_spans: &[],
skin: None,
glowing: &[],
dynamic_lights: &[],
colored_lights: &[],
emissive_layers: &[],
clouds: None,
lightning: Default::default(),
additive: &[],
see_through: &[],
world_primitives: Default::default(),
shadow_only: &[],
celestial: &[],
stars: None,
hand: &[],
hand_items: &[],
hand_fov_degrees: 70.0,
screen: &[],
pack: Default::default(),
border: None,
game_time: 0.0,
shader_game_time: 0.0,
end_portal_sprites: [[0.0; 4]; 2],
crumbling_sprite: [0.0; 4],
ticks: 0,
darkness: (0.0, 0.0),
night_vision: 0.0,
block_flicker: 0.0,
boss_darken: 0.0,
chunk_fade: 0.0,
post_effects: client::post_chain::Request::none(),
menu_blur: None,
}
}
+6 -1
View File
@@ -191,13 +191,18 @@ pub fn discovery_url() -> String {
.unwrap_or_else(|| DEFAULT_DISCOVERY.to_owned())
}
fn agent() -> ureq::Agent {
pub(crate) fn agent() -> ureq::Agent {
ureq::AgentBuilder::new()
.timeout(Duration::from_secs(15))
.user_agent(&format!("Horizon/{}", horizon_core::release::VERSION))
.build()
}
/// Whether `HORIZON_API_DISCOVERY` names another services environment.
pub fn discovery_overridden() -> bool {
discovery_url() != DEFAULT_DISCOVERY
}
/// The discovery document, fetched once per game (a failure is retried next time).
pub(crate) fn discovery() -> Result<Arc<serde_json::Value>, JoinError> {
static CACHE: Mutex<Option<Arc<serde_json::Value>>> = Mutex::new(None);
+36 -2
View File
@@ -17,11 +17,15 @@ pub struct BanDetails {
pub reason_message: Option<String>,
}
/// authlib's `UserProperties`, as far as the title screen reads it.
/// authlib's `UserProperties`, as far as the title screen and the friends service read it.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct UserProperties {
/// `UserFlag.SERVERS_ALLOWED` (`privileges.multiplayerServer.enabled`).
pub servers_allowed: bool,
/// `UserFlag.FRIENDS_ENABLED` (`friendsPreferences.friends`).
pub friends_enabled: bool,
/// `UserFlag.ACCEPT_FRIEND_INVITES` (`friendsPreferences.acceptInvites`).
pub accept_friend_invites: bool,
/// `bannedScopes.get("MULTIPLAYER")`.
pub multiplayer_ban: Option<BanDetails>,
}
@@ -30,6 +34,8 @@ impl Default for UserProperties {
fn default() -> Self {
Self {
servers_allowed: true,
friends_enabled: false,
accept_friend_invites: false,
multiplayer_ban: None,
}
}
@@ -209,6 +215,13 @@ pub fn parse_properties(body: &str) -> Option<UserProperties> {
.pointer("/privileges/multiplayerServer/enabled")
.and_then(|e| e.as_bool())
.unwrap_or(false);
let toggle = |name: &str| {
v.pointer(&format!("/friendsPreferences/{name}"))
.and_then(|f| f.as_str())
.is_some_and(|f| f.eq_ignore_ascii_case("ENABLED"))
};
let friends_enabled = toggle("friends");
let accept_friend_invites = toggle("acceptInvites");
let multiplayer_ban = v
.pointer("/banStatus/bannedScopes/MULTIPLAYER")
.filter(|b| b.is_object())
@@ -222,6 +235,8 @@ pub fn parse_properties(body: &str) -> Option<UserProperties> {
});
Some(UserProperties {
servers_allowed,
friends_enabled,
accept_friend_invites,
multiplayer_ban,
})
}
@@ -275,6 +290,24 @@ mod tests {
assert!(clean.servers_allowed && clean.multiplayer_ban.is_none());
}
#[test]
fn friends_preferences_read_their_two_flags() {
let body = r#"{"privileges":{"multiplayerServer":{"enabled":true}},
"friendsPreferences":{"friends":"ENABLED","acceptInvites":"DISABLED"}}"#;
let p = parse_properties(body).unwrap();
assert!(p.friends_enabled);
assert!(!p.accept_friend_invites);
let other = parse_properties(
r#"{"friendsPreferences":{"friends":"DISABLED","acceptInvites":"ENABLED"}}"#,
)
.unwrap();
assert!(!other.friends_enabled);
assert!(other.accept_friend_invites);
// The offline fallback has neither flag, as `OFFLINE_PROPERTIES` does.
assert!(!UserProperties::default().friends_enabled);
assert!(!UserProperties::default().accept_friend_invites);
}
#[test]
fn parses_profile_actions() {
let a = parse_profile_actions(
@@ -297,6 +330,7 @@ mod tests {
properties: UserProperties {
servers_allowed: true,
multiplayer_ban: Some(ban.clone()),
..Default::default()
},
actions: ProfileActions {
forced_name_change: true,
@@ -317,7 +351,7 @@ mod tests {
let no_servers = AccountStatus {
properties: UserProperties {
servers_allowed: false,
multiplayer_ban: None,
..Default::default()
},
..Default::default()
};
+62
View File
@@ -112,6 +112,9 @@ pub struct BiomeInfo {
/// `BiomeGenerationSettings.features`: the placed feature ids of each
/// `GenerationStep.Decoration` step, in order.
pub features: Vec<Vec<String>>,
/// The biome's environment attributes (`Biome.getAttributes`): its layer in the
/// environment attribute stack.
pub attributes: horizon_world::environment_attributes::AttributeMap,
}
impl BiomeInfo {
fn ambient_particles(v: &Value) -> Vec<(ParticleType, f32)> {
@@ -277,6 +280,11 @@ impl BiomeInfo {
.collect()
})
.collect(),
attributes: horizon_world::environment_attributes::AttributeMap::parse(attributes)
.unwrap_or_else(|e| {
log::warn!("biome attributes: {e}");
Default::default()
}),
name,
}
}
@@ -393,6 +401,17 @@ impl Biomes {
pub fn get(&self, id: u16) -> &BiomeInfo {
&self.list[usize::from(id).min(self.list.len() - 1)]
}
/// The name of biome `id`, plains (as for an unknown name) when the id is
/// past this table: a column a server sent under its earlier, longer
/// registry (a proxy's previous server) keeps its ids until the next
/// login replaces it. `get` clamps such an id to the last entry instead,
/// so for that stale column the colours and this name can come from
/// different biomes until the login.
pub fn name(&self, id: u16) -> &str {
self.list
.get(usize::from(id))
.map_or("minecraft:plains", |b| b.name.as_str())
}
pub fn grass(&self, id: u16, x: f64, z: f64) -> u32 {
let b = self.get(id);
match b.modifier {
@@ -624,6 +643,49 @@ mod pack_tests {
assert_eq!(other.get(2).water, 0x3F76E4);
assert!(!other.has_packs());
}
/// A column keeps the ids of the table it was decoded with. When a
/// proxy's next server lacks a biome the last one added, that id is past
/// the next table, and it names plains instead of indexing past the end
/// (#12148: "the len is 67 but the index is 67").
#[test]
fn an_id_past_the_table_names_plains() {
let next = builtin(&["minecraft:meadow", "minecraft:plains", "minecraft:river"]);
let last = next.with_packs([("limbo:void", &json!({}))]);
let stale = last.id("limbo:void");
assert_eq!((stale, last.name(stale)), (3, "limbo:void"));
assert_eq!(next.name(stale), "minecraft:plains");
assert_eq!(next.name(u16::MAX), "minecraft:plains");
assert_eq!(next.name(2), "minecraft:river");
}
}
#[cfg(test)]
impl Biomes {
/// Biomes from their files' JSON, by name, in id order: for tests elsewhere.
pub(crate) fn from_jsons(entries: &[(&str, Value)]) -> Self {
let maps = Colormaps {
grass: Vec::new(),
foliage: Vec::new(),
dry: Vec::new(),
};
let list: Vec<_> = entries
.iter()
.map(|(name, v)| BiomeInfo::from_json((*name).to_owned(), v, &maps))
.collect();
Self {
builtin: Arc::new(list.clone()),
index: list
.iter()
.enumerate()
.map(|(i, b)| (b.name.clone(), i as u16))
.collect(),
list,
info_noise: SimplexNoise::with_skipped_offsets(&mut LegacyRandom::new(2345), true),
colormaps: Arc::new(maps),
packs: BTreeMap::new(),
}
}
}
#[cfg(test)]
@@ -37,6 +37,37 @@ pub fn potion_name_key(item: &str, potion: Option<&str>) -> String {
let suffix = potion.and_then(self::potion).map_or("empty", |p| p.name);
format!("item.minecraft.{prefix}.effect.{suffix}")
}
/// `PotionItem.getName` and `TippedArrowItem.getName`: a potion or a tipped
/// arrow is named by its contents (`PotionContents.getName`: their own
/// `custom_name`, else the potion's name, else `empty`), whatever its
/// `item_name`. None for other items, and for a stack whose contents were
/// taken off (`!minecraft:potion_contents`), which takes the item's name.
pub fn contents_name_key(stack: &ItemStack) -> Option<String> {
use horizon_world::nbt::Tag;
let item = &*stack.item;
let named = matches!(
item,
"minecraft:potion"
| "minecraft:splash_potion"
| "minecraft:lingering_potion"
| "minecraft:tipped_arrow"
);
let data = stack.data();
if !named || data.is_some_and(|d| d.other("!minecraft:potion_contents").is_some()) {
return None;
}
let prefix = item.trim_start_matches("minecraft:");
let own = data
.and_then(|d| d.potion_extra.iter().find(|(k, _)| k == "custom_name"))
.and_then(|(_, name)| match name {
Tag::String(s) => Some(s.to_string_lossy()),
_ => None,
});
Some(match own {
Some(suffix) => format!("item.minecraft.{prefix}.effect.{suffix}"),
None => potion_name_key(item, stack.potion()),
})
}
/// `PotionContents.getAllEffects`: the potion's effects, then the stack's
/// `custom_effects` (id, duration, amplifier). Unknown effect ids are dropped.
pub fn contents_effects(stack: &ItemStack) -> Vec<(&'static str, i32, i32)> {
@@ -469,6 +500,40 @@ mod tests {
potion_name_key("minecraft:splash_potion", Some("minecraft:long_swiftness")),
"item.minecraft.splash_potion.effect.swiftness"
);
// A potion or a tipped arrow is named by its contents, over its
// item_name; one whose contents were taken off is not.
use horizon_world::nbt::Tag;
let mut swift =
ItemStack::new("minecraft:potion", 1).with_potion("minecraft:long_swiftness");
swift
.data_mut()
.set_other("minecraft:item_name", Some(Tag::String("Bottle".into())));
assert_eq!(
contents_name_key(&swift).as_deref(),
Some("item.minecraft.potion.effect.swiftness")
);
assert_eq!(
contents_name_key(&ItemStack::new("minecraft:splash_potion", 1)).as_deref(),
Some("item.minecraft.splash_potion.effect.empty")
);
let mut own = ItemStack::new("minecraft:tipped_arrow", 1).with_potion("minecraft:poison");
own.data_mut()
.potion_extra
.push(("custom_name".into(), Tag::String("luck".into())));
assert_eq!(
contents_name_key(&own).as_deref(),
Some("item.minecraft.tipped_arrow.effect.luck")
);
let mut taken = ItemStack::new("minecraft:potion", 1);
taken.data_mut().set_other(
"!minecraft:potion_contents",
Some(Tag::compound(Vec::<(&str, Tag)>::new())),
);
assert_eq!(contents_name_key(&taken), None);
assert_eq!(
contents_name_key(&ItemStack::new("minecraft:stone", 1)),
None
);
assert_eq!(format_duration(9600), "08:00");
assert_eq!(format_duration(450), "00:22");
assert_eq!(format_duration(140), "00:07");
+25 -12
View File
@@ -357,6 +357,24 @@ impl Light {
}
/// The smallest form of packed light per block.
pub fn packed(light: Box<[u8; 4096]>) -> Self {
match Self::classify(&light) {
(false, false, first) => Self::Uniform(first),
(true, false, first) => Self::Sky(Self::pack_layer(&light, 4), first & 15),
(false, true, first) => Self::Block(Self::pack_layer(&light, 0), first >> 4),
(true, true, _) => Self::Full(light),
}
}
/// The smallest form of packed light per block, allocating a 4 KiB box only
/// when both sky and block light vary within the section.
pub fn from_slice(light: &[u8; 4096]) -> Self {
match Self::classify(light) {
(false, false, first) => Self::Uniform(first),
(true, false, first) => Self::Sky(Self::pack_layer(light, 4), first & 15),
(false, true, first) => Self::Block(Self::pack_layer(light, 0), first >> 4),
(true, true, _) => Self::Full(Box::new(*light)),
}
}
fn classify(light: &[u8; 4096]) -> (bool, bool, u8) {
let first = light[0];
let (mut sky_varies, mut block_varies) = (false, false);
for &v in light.iter() {
@@ -364,19 +382,14 @@ impl Light {
sky_varies |= d & 0xF0 != 0;
block_varies |= d & 15 != 0;
}
let layer = |shift: u32| {
let mut layer = Box::new([0u8; 2048]);
for (out, pair) in layer.iter_mut().zip(light.as_chunks::<2>().0) {
*out = (pair[0] >> shift & 15) | (pair[1] >> shift & 15) << 4;
}
layer
};
match (sky_varies, block_varies) {
(false, false) => Self::Uniform(first),
(true, false) => Self::Sky(layer(4), first & 15),
(false, true) => Self::Block(layer(0), first >> 4),
(true, true) => Self::Full(light),
(sky_varies, block_varies, first)
}
fn pack_layer(light: &[u8; 4096], shift: u32) -> Box<[u8; 2048]> {
let mut layer = Box::new([0u8; 2048]);
for (out, pair) in layer.iter_mut().zip(light.as_chunks::<2>().0) {
*out = (pair[0] >> shift & 15) | (pair[1] >> shift & 15) << 4;
}
layer
}
/// The packed light of blocks `at..at + out.len()`, into `out`.
#[inline]
@@ -515,6 +515,11 @@ pub struct Info {
pub pos: [f64; 3],
pub source: Option<Who>,
pub owner: Option<Who>,
/// `uuid`: the source's saved identity (`source`), taken when the event
/// was posted. `source` is the live entity and comes back `None` on load.
pub source_uuid: Option<[i32; 4]>,
/// `projectileOwnerUuid` (`projectile_owner`), likewise beside `owner`.
pub owner_uuid: Option<[i32; 4]>,
}
/// `VibrationSelector`: the closest, then highest-frequency candidate of a tick.
#[derive(Clone, Debug, Default, PartialEq)]
@@ -356,6 +356,38 @@ where
)
.unwrap_or(false)
}
/// `Level.loadedAndEntityCanStandOn(pos, projectile)` with the projectile's
/// feet at `entity_y` (`loadedAndEntityCanStandOnFace` with `UP`):
/// `Block.isFaceFull(state.getCollisionShape(level, pos,
/// CollisionContext.of(projectile)), UP)`. A projectile is not living and never
/// falls (it moves by `setPos`), so powder snow and liquids hold nothing up
/// and only scaffolding reads the height. An unloaded or out-of-world block
/// reads as air.
pub fn projectile_can_stand_on(
world: &World,
binding: &BlockCollisionRegistry<'_>,
pos: BlockPos,
entity_y: f64,
) -> bool {
use horizon_core::geometry::{Direction, ShapeOp};
let mut scene = SceneCollisions {
world,
binding,
subject: None,
};
let state = world.block(pos.x, pos.y, pos.z);
let context = CollisionContext::position(entity_y);
binding
.collision_shape_with_context(&mut scene, state, pos, &context)
.is_ok_and(|shape| {
shape.is_block()
|| shape.face_shape(Direction::Up).is_ok_and(|face| {
VoxelShape::block()
.join_is_not_empty(&face, ShapeOp::XOR)
.is_ok_and(|differs| !differs)
})
})
}
fn no_collision_in<W, E>(world: &mut W, bounds: Aabb, fluids: bool) -> bool
where
W: BlockCollisionWorld<Error = E> + EntityMovementWorld<Error = E>,
+11 -5
View File
@@ -33,7 +33,7 @@ struct Globals {
hdr_windows: array<vec4<f32>, 2>,
// HDR lighting: x how strongly the sky lights the world now (`Sky::sky_light_factor`),
// y what the sky and fog get: the same, but none under water.
// w: this entire frame writes light, so alpha is coverage rather than a mark.
// w: apply the no-pack HDR gain; HDR_WRITE_LIGHT independently keeps float alpha as coverage.
hdr_light: vec4<f32>,
hdr_settings: vec4<f32>,
hdr_shares: vec4<f32>,
@@ -175,7 +175,11 @@ fn entity_vertex(position: vec3<f32>, uv: vec2<f32>,
@vertex fn vs_entity(@location(0) position: vec3<f32>, @location(1) uv: vec2<f32>,
@location(2) overlay: u32, @location(3) color: vec4<f32>, @location(4) light: vec4<u32>,
@location(5) normal: vec4<f32>, @location(6) glint_uv: vec2<f32>) -> Out {
var out = entity_vertex(position, uv, overlay & 0xffffu, color, light, normal);
// [world warp entity]
// (a world warp's pipelines put the vertex through the mod's `world_warp`: gfx/world_warp.rs)
let at = position;
// [end world warp entity]
var out = entity_vertex(at, uv, overlay & 0xffffu, color, light, normal);
out.glint_uv = glint_uv;
out.tier = (overlay >> 16u) & 0xffu;
return out;
@@ -279,10 +283,11 @@ fn crumbling_uv(uv: vec2<f32>, packed_origin: vec2<f32>, sprite_size: vec2<f32>,
}
// CRUMBLING_UV_END
@fragment fn fs_entity(in: Out, @builtin(front_facing) front: bool) -> @location(0) vec4<f32> {
return entity_written_color(in, front);
return entity_written_color(in, entity_color(in, front));
}
fn entity_written_color(in: Out, front: bool) -> vec4<f32> {
let color = entity_color(in, front);
// Takes entity_color's result rather than calling it: FXC (DX12) fails with "argument pulled into
// unrelated predicate" when entity_color's discards sit a function call below the entry point.
fn entity_written_color(in: Out, color: vec4<f32>) -> vec4<f32> {
if HDR_WRITE_LIGHT && globals.hdr_light.w != 0.0 && in.overlay != 11u && in.overlay != 12u {
var mark = hdr_entity_mark(in);
if in.overlay == 4u { mark = hdr_scene_alpha(globals.hdr_light.y); }
@@ -455,6 +460,7 @@ fn celestial_color(in: Out) -> vec4<f32> {
if HDR_WRITE_LIGHT && globals.hdr_light.w != 0.0 {
return vec4(hdr_written(added, select(1.0, hdr_mark(tier, y), full), globals.hdr_settings, globals.hdr_shares), color.a);
}
if HDR_WRITE_LIGHT { return vec4(added, color.a); }
if !full { return vec4(added, 0.0); }
return vec4(added, 1.0 - hdr_mark(tier, y));
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -554,14 +554,22 @@ mod tests {
});
let view = target.create_view(&Default::default());
// Independent scalar expectation: a sky mark of 206/255 gives this gamma gain.
let gain = (1.0f32 + 3.0 * (1.0 - 206.0 / 255.0) / 0.24).powf(1.0 / 2.2);
for (mode_index, mode, debug) in MODES
let hdr_gain = (1.0f32 + 3.0 * (1.0 - 206.0 / 255.0) / 0.24).powf(1.0 / 2.2);
for (mode_index, mode, debug, written) in MODES
.iter()
.copied()
.enumerate()
.map(|(i, m)| (i, m, false))
.chain([(5, Blend::Screen, true)])
.map(|(i, m)| (i, m, false, true))
.chain([(5, Blend::Screen, true, true)])
.chain(
MODES
.iter()
.copied()
.enumerate()
.map(|(i, m)| (i, m, false, false)),
)
{
let gain = if written { hdr_gain } else { 1.0 };
let uniform = Uniform {
clip_from_sky: Mat4::IDENTITY.to_cols_array_2d(),
brightness: 0.7,
@@ -569,7 +577,7 @@ mod tests {
pad: [0; 2],
hdr_settings: [5.0, 4.0, 1.0, f32::from(u8::from(debug))],
hdr_shares: [0.2, 0.5, 0.6, 1.0],
hdr_light: [0.8, 0.8, 1.0, 1.0],
hdr_light: [0.8, 0.8, 1.0, f32::from(u8::from(written))],
};
let buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: None,
@@ -704,6 +712,12 @@ mod tests {
let data = readback.get_mapped_range(..).unwrap();
let got = half::f16::from_le_bytes([data[0], data[1]]).to_f32();
let (d, t, k) = (2.0, 0.8, 128.0 / 255.0 * 0.7);
let alpha = half::f16::from_le_bytes([data[6], data[7]]).to_f32();
let coverage = if mode == Blend::Alpha { k } else { 1.0 };
assert!(
(alpha - coverage).abs() < 0.001,
"{mode:?}: alpha {alpha}, expected {coverage}"
);
let expected = if debug {
let grey = (0.2126f32 * 0.8f32.powf(2.2)
+ 0.7152 * 0.4f32.powf(2.2)
@@ -728,6 +742,8 @@ mod tests {
"{mode:?} debug {debug}: {got} expected {expected}"
);
}
eprintln!("HDR custom sky GPU ran all nine modes and debug Screen over above-white light");
eprintln!(
"HDR custom sky GPU ran all nine modes with and without gain, plus debug Screen, checking RGB and coverage"
);
}
}
@@ -12,7 +12,7 @@ use crate::mesher::TerrainVertex;
/// The solid quads the grid can draw a frame, and the sections they come from.
const MAX_QUADS: u64 = 1 << 16;
const MAX_SECTIONS: usize = 64;
pub(crate) const MAX_SECTIONS: usize = 64;
/// Bytes of one quad's corners.
const QUAD_BYTES: u64 = 4 * std::mem::size_of::<TerrainVertex>() as u64;
/// How far past the flat face the relief may reach, in blocks, for the view test.
@@ -413,6 +413,8 @@ pub struct Displacement {
pub shadow: Option<Picks>,
/// Group 1's layout for the grid's pipelines.
pub draw_layout: wgpu::BindGroupLayout,
/// CPU-side section table reused between displacement preparation passes.
table: Vec<Section>,
/// Sections the pick ran over this frame (0: nothing to draw).
pub drawn: usize,
}
@@ -598,6 +600,7 @@ impl Displacement {
view,
shadow,
draw_layout,
table: Vec::new(),
drawn: 0,
}
}
@@ -610,7 +613,8 @@ impl Displacement {
near: &[Near<'_>],
view_proj: glam::Mat4,
) {
let mut table = Vec::with_capacity(near.len().min(MAX_SECTIONS));
self.table.clear();
self.table.reserve(near.len().min(MAX_SECTIONS));
let mut first = 0u64;
let mut most = 0u32;
for n in near.iter().take(MAX_SECTIONS) {
@@ -626,7 +630,7 @@ impl Displacement {
first * QUAD_BYTES,
quads * QUAD_BYTES,
);
table.push(Section {
self.table.push(Section {
origin: [
n.origin[0],
n.origin[1],
@@ -640,11 +644,11 @@ impl Displacement {
first += quads;
most = most.max(quads as u32);
}
self.drawn = table.len();
if table.is_empty() {
self.drawn = self.table.len();
if self.table.is_empty() {
return;
}
queue.write_buffer(&self.sections, 0, bytemuck::cast_slice(&table));
queue.write_buffer(&self.sections, 0, bytemuck::cast_slice(&self.table));
let lists = std::iter::once((&self.view, 0)).chain(self.shadow.iter().map(|s| (s, 1)));
let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
label: Some("Displacement pick"),
@@ -655,7 +659,7 @@ impl Displacement {
let params = Params {
view_proj: view_proj.to_cols_array_2d(),
distance: self.distance,
sections: table.len() as u32,
sections: self.table.len() as u32,
margin: RELIEF_MARGIN,
capacity: MAX_QUADS as u32,
shadow,
@@ -665,7 +669,7 @@ impl Displacement {
let vertices = grid_vertices(picks.cells, picks.steps);
queue.write_buffer(&picks.draw, 0, bytemuck::cast_slice(&[vertices, 0, 0, 0]));
pass.set_bind_group(0, &picks.pick_group, &[]);
pass.dispatch_workgroups(most.div_ceil(64), table.len() as u32, 1);
pass.dispatch_workgroups(most.div_ceil(64), self.table.len() as u32, 1);
}
}
@@ -0,0 +1,132 @@
//! Which compiler turns DirectX 12's shaders into bytecode: DXC on integrated
//! GPUs, FXC on every other adapter (#8513).
//!
//! Measured on one laptop at render distance 32 (2560x1440, the same world and
//! view): DXC took Intel Iris Xe from 44 to 65 FPS (22.1 to 14.7 ms of GPU
//! time) and an NVIDIA RTX 3050 Ti from 159 down to 150 FPS. An RX 9070 gained
//! about 9 % of GPU time at render distance 32 and lost 0.03 ms at 8. So
//! discrete GPUs keep FXC (`d3dcompiler_47.dll`, part of Windows), and only
//! integrated ones load DXC (`dxcompiler.dll`, which the Windows x64 zip puts
//! beside the game: `tools/ci/fetch_dxc.py`).
//!
//! wgpu chooses the compiler for a whole instance, before any adapter is
//! known. `open_backend` therefore asks for the adapter on an FXC instance and,
//! when that adapter is an integrated GPU on DirectX 12, asks again on an
//! instance with DXC. That happens once, when the device is opened; nothing
//! here runs per frame. Without the DLL, or when it does not load, the game
//! keeps the FXC instance and logs one line. `WGPU_DX12_COMPILER` (`fxc`,
//! `dxc`, `auto`) overrides the choice: wgpu applies it to every instance.
use std::path::PathBuf;
/// The DLL's name, as the Windows zip ships it beside `horizon-client.exe`.
pub(super) const DLL: &str = "dxcompiler.dll";
/// Whether `info`, opened on an FXC instance, should be opened again with DXC:
/// an integrated GPU on DirectX 12, unless `forced` (`WGPU_DX12_COMPILER` is
/// set, so its compiler is already the one every instance uses).
pub(super) fn wants_dxc(info: &wgpu::AdapterInfo, forced: bool) -> bool {
!forced
&& info.backend == wgpu::Backend::Dx12
&& info.device_type == wgpu::DeviceType::IntegratedGpu
}
/// Whether `WGPU_DX12_COMPILER` names the compiler.
pub(super) fn forced() -> bool {
std::env::var_os("WGPU_DX12_COMPILER").is_some()
}
/// `dxcompiler.dll` beside the running executable, if it is there. An absolute
/// path, so a copy elsewhere on `PATH` (an older Windows SDK's) is never the
/// one loaded.
pub(super) fn bundled_dll() -> Option<PathBuf> {
let dll = std::env::current_exe().ok()?.parent()?.join(DLL);
dll.is_file().then_some(dll)
}
/// The compiler setting that loads `dll`, and fails the instance (rather
/// than falling back to FXC on its own) when it doesn't load: `open_backend`
/// then keeps the FXC instance it already has.
pub(super) fn dxc(dll: &std::path::Path) -> wgpu::Dx12Compiler {
wgpu::Dx12Compiler::DynamicDxc {
dxc_path: dll.to_string_lossy().into_owned(),
}
}
/// Whether the DXC instance offered the adapter the FXC instance had: on a
/// laptop with two GPUs, the same request should pick the same one, and the
/// game must not switch GPUs because the compiler changed.
pub(super) fn same_adapter(fxc: &wgpu::AdapterInfo, dxc: &wgpu::AdapterInfo) -> bool {
fxc.backend == dxc.backend
&& fxc.vendor == dxc.vendor
&& fxc.device == dxc.device
&& fxc.device_type == dxc.device_type
&& fxc.device_pci_bus_id == dxc.device_pci_bus_id
}
#[cfg(test)]
mod tests {
use super::*;
fn adapter(backend: wgpu::Backend, device_type: wgpu::DeviceType) -> wgpu::AdapterInfo {
wgpu::AdapterInfo {
name: "Intel(R) Iris(R) Xe Graphics".into(),
vendor: 0x8086,
device: 0x9a49,
device_type,
device_pci_bus_id: "0000:00:02.0".into(),
driver: String::new(),
driver_info: String::new(),
backend,
subgroup_min_size: 0,
subgroup_max_size: 0,
transient_saves_memory: None,
limit_bucket: None,
}
}
#[test]
fn only_integrated_gpus_on_directx_12_get_dxc() {
use wgpu::{Backend, DeviceType};
assert!(wants_dxc(
&adapter(Backend::Dx12, DeviceType::IntegratedGpu),
false
));
for kind in [
DeviceType::DiscreteGpu,
DeviceType::VirtualGpu,
DeviceType::Cpu,
DeviceType::Other,
] {
assert!(!wants_dxc(&adapter(Backend::Dx12, kind), false), "{kind:?}");
}
// Other backends have no shader compiler to choose.
for backend in [Backend::Vulkan, Backend::Gl, Backend::Metal] {
assert!(
!wants_dxc(&adapter(backend, DeviceType::IntegratedGpu), false),
"{backend:?}"
);
}
}
#[test]
fn wgpu_dx12_compiler_overrides_the_choice() {
let igpu = adapter(wgpu::Backend::Dx12, wgpu::DeviceType::IntegratedGpu);
assert!(!wants_dxc(&igpu, true));
}
#[test]
fn the_dxc_instance_must_offer_the_same_gpu() {
let igpu = adapter(wgpu::Backend::Dx12, wgpu::DeviceType::IntegratedGpu);
assert!(same_adapter(&igpu, &igpu.clone()));
let dgpu = wgpu::AdapterInfo {
name: "NVIDIA GeForce RTX 3050 Ti Laptop GPU".into(),
vendor: 0x10de,
device: 0x25a0,
device_type: wgpu::DeviceType::DiscreteGpu,
device_pci_bus_id: "0000:01:00.0".into(),
..igpu.clone()
};
assert!(!same_adapter(&igpu, &dgpu));
}
}
+36 -1
View File
@@ -174,11 +174,22 @@ pub fn wanted(adapter: &wgpu::Adapter, device: &wgpu::Device) -> bool {
/// Whether frames without a shader pack's terrain draw flat: where the backend
/// emulates multi-draw (Metal, past `MIN_SECTIONS`), on Intel Gen11/Gen12's Vulkan
/// driver, or with `HORIZON_FLAT_DRAWS=1`. Elsewhere per-section draws stay.
/// driver, on Qualcomm's Adreno under DirectX 12, or with `HORIZON_FLAT_DRAWS=1`.
/// Elsewhere per-section draws stay.
pub fn for_vanilla(info: &wgpu::AdapterInfo) -> bool {
std::env::var("HORIZON_FLAT_DRAWS").as_deref() == Ok("1")
|| info.backend == wgpu::Backend::Metal
|| is_broken_intel_vulkan(info)
|| is_adreno_dx12(info)
}
/// Qualcomm's Adreno under DirectX 12 (Windows on Snapdragon, where Auto picks DirectX 12),
/// where wgpu's indirect multi-draw is `ExecuteIndirect`: drawn flat, an X1-45 took 3-11%
/// less GPU time a frame at render distance 12 and 3-13% less at 32 (perf_bench's four
/// scenes, held still), from 389 sections in view up, the same picture. DXGI names
/// Qualcomm `QCOM` (0x4D4F4351); Vulkan, 0x5143.
pub fn is_adreno_dx12(info: &wgpu::AdapterInfo) -> bool {
info.backend == wgpu::Backend::Dx12 && matches!(info.vendor, 0x4d4f_4351 | 0x5143)
}
/// Intel Gen11 (Ice Lake) and Gen12 (Tiger Lake, Rocket Lake, Alder Lake,
@@ -1044,6 +1055,30 @@ impl Flat {
mod tests {
use super::*;
#[test]
fn adreno_draws_flat_under_dx12_only() {
let mut info = wgpu::AdapterInfo {
name: "Qualcomm(R) Adreno(TM) X1-45 GPU".into(),
vendor: 0x4d4f_4351,
device: 0,
device_type: wgpu::DeviceType::IntegratedGpu,
device_pci_bus_id: String::new(),
driver: "31.0.128.0".into(),
driver_info: String::new(),
backend: wgpu::Backend::Dx12,
subgroup_min_size: 0,
subgroup_max_size: 0,
transient_saves_memory: None,
limit_bucket: None,
};
assert!(is_adreno_dx12(&info));
info.backend = wgpu::Backend::Vulkan;
assert!(!is_adreno_dx12(&info));
info.backend = wgpu::Backend::Dx12;
info.vendor = 0x10de;
assert!(!is_adreno_dx12(&info));
}
/// Draws as `render` writes them: solid (page, quads at a base vertex)
/// first, then translucent (page, sorted page, count, first sorted index,
/// base vertex).
@@ -2735,8 +2735,9 @@ mod tests {
/// `test` on each device here that draws counts with first instances:
/// Vulkan, and DirectX 12 with each of its compilers. Players' DirectX 12
/// compiles with FXC, part of Windows: wgpu takes DXC only where
/// `dxcompiler.dll` loads, and the game ships none.
/// compiles with FXC, part of Windows, and with DXC (`dxcompiler.dll`,
/// beside the game in the Windows x64 zip) on integrated GPUs
/// (`gfx/dx12_compiler.rs`).
fn on_each_device(test: impl Fn(&wgpu::Device, &Queue)) {
let mut tested = false;
for (backends, compiler, name) in [
+153 -56
View File
@@ -72,32 +72,28 @@ fn named(name: Option<&str>) -> Option<Output> {
struct Targets {
size: [u32; 2],
frame: wgpu::Texture,
/// The world as `keep_world` kept it; on the layered frame, the frame as `resolve` (the
/// world and the hand marked it) or `tonemap` found it.
/// The world as `keep_world` kept it; also the tone curve's input scratch on a layered frame.
world: wgpu::Texture,
coverage: Option<wgpu::Texture>,
/// The layered frame's HUD target: what the HUD draws on.
hud: Option<wgpu::Texture>,
group: wgpu::BindGroup,
/// The layered frame's passes' bindings: `resolve` (and `tonemap`), `keep_world` and
/// `composite`.
/// The layered frame's bindings for `tonemap`, `keep_world` and `composite`.
passes: Option<[wgpu::BindGroup; 3]>,
}
/// The float frame with HUD coverage (`hud_coverage::supported`, docs/design/hdr-linear-light.md
/// "The HUD and menus"): the default world draws write light directly; marked fallback frames
/// turn their marks into light after the world and the hand (`resolve`); the tone curve eases it into the peak after the post effects (`tonemap`); the
/// "The HUD and menus"): the default world draws write light directly; the tone curve eases
/// it into the peak after the post effects (`tonemap`); the
/// camera overlays draw on that light; the HUD draws on a target of its own,
/// the world clipped to white (`keep_world`); and the last pass composites the HUD over the
/// world's light by its coverage (`fs_hdr_layered`), as the menu blur does before it blurs
/// (`composite`).
struct Layered {
resolve: (wgpu::BindGroupLayout, wgpu::RenderPipeline),
/// Reads what `resolve` reads.
tonemap: wgpu::RenderPipeline,
tonemap: (wgpu::BindGroupLayout, wgpu::RenderPipeline),
keep: (wgpu::BindGroupLayout, wgpu::RenderPipeline),
composite: (wgpu::BindGroupLayout, wgpu::RenderPipeline),
/// What `resolve` and `composite` read, written before they run (`begin`).
/// What `tonemap` and `composite` read, written before they run (`begin`).
params: wgpu::Buffer,
}
@@ -337,10 +333,8 @@ impl Hdr {
let pipeline = pass(label, &layout, entry, format);
(layout, pipeline)
};
let resolve = with("Horizon HDR marks to light", &[1], true, "fs_resolve");
Layered {
tonemap: pass("Horizon HDR tone curve", &resolve.0, "fs_tonemap", format),
resolve,
tonemap: with("Horizon HDR tone curve", &[1], true, "fs_tonemap"),
keep: with("Horizon HDR HUD backdrop", &[0], false, "fs_keep"),
composite: with("Horizon HDR composite", &[0, 4, 5], true, "fs_composite"),
params: params(),
@@ -492,7 +486,7 @@ impl Hdr {
})
};
[
group(&l.resolve.0, &[entry(1, &world), params.clone()]),
group(&l.tonemap.0, &[entry(1, &world), params.clone()]),
group(&l.keep.0, &[entry(0, &frame)]),
group(
&l.composite.0,
@@ -529,7 +523,7 @@ impl Hdr {
self.targets.as_ref()?.hud.as_ref()
}
/// On the layered frame, before `resolve` and `composite`: what they read, for a frame that
/// On the layered frame, before `tonemap` and `composite`: what they read, for a frame that
/// drew a world (`world`) or not.
pub fn begin(&self, queue: &wgpu::Queue, world: bool) {
if let Some(l) = &self.layered {
@@ -541,34 +535,12 @@ impl Hdr {
}
}
/// On the layered frame, after the world and the hand drew and marked it: the marks
/// become light in the frame (`fs_resolve`).
pub fn resolve(
&self,
encoder: &mut wgpu::CommandEncoder,
timing: Option<&mut super::pack_timing::PassTiming>,
) {
self.redraw(encoder, |l| &l.resolve.1, "HDR marks to light", timing);
}
/// On the layered frame, after the post effects and before the camera overlays: the tone
/// curve eases the light into the peak (`fs_tonemap`).
pub fn tonemap(
&self,
encoder: &mut wgpu::CommandEncoder,
timing: Option<&mut super::pack_timing::PassTiming>,
) {
self.redraw(encoder, |l| &l.tonemap, "HDR tone curve", timing);
}
/// The layered frame drawn again by `pipeline`, which reads it from a copy in the world
/// target.
fn redraw(
&self,
encoder: &mut wgpu::CommandEncoder,
pipeline: impl Fn(&Layered) -> &wgpu::RenderPipeline,
label: &'static str,
timing: Option<&mut super::pack_timing::PassTiming>,
) {
let (Some(l), Some(t)) = (&self.layered, &self.targets) else {
return;
@@ -582,7 +554,14 @@ impl Hdr {
t.frame.size(),
);
let view = t.frame.create_view(&Default::default());
Self::draw(encoder, pipeline(l), group, &view, label, timing);
Self::draw(
encoder,
&l.tonemap.1,
group,
&view,
"HDR tone curve",
timing,
);
}
/// On the layered frame: the picture so far (`fs_composite`), as the frame stores light,
@@ -981,6 +960,10 @@ var<private> params: ReferenceParams;
let actual = hdr_mark_light(color, mark, shows, eased,
vec4(params.peak, params.scene, params.world, params.debug), params.share);
errors[i] = select(0u, 1u, any(abs(expected.light - actual.light) > vec3(1e-6)) || expected.lifted != actual.lifted);
// Gain stays above the tone knee until the separate tone curve runs.
let gain = 1.0 + 3.0 * (1.0 - 194.0 / 255.0) / 0.24;
let white = hdr_written(vec3(1.0), 0.76, vec4(5.0, 4.0, 1.0, 0.0), params.share);
errors[i] |= select(0u, 4u, any(abs(pow(white, vec3(2.2)) - vec3(gain)) > vec3(1e-5)));
let boundary = (f32(i % 255u) + 0.5) / 255.0;
for (var step = -1; step <= 1; step++) {
let stored = unpack2x16float(pack2x16float(vec2(boundary + f32(step) / 65536.0))).x;
@@ -1133,7 +1116,14 @@ var<private> params: ReferenceParams;
pub(super) fn gpu() -> Option<(wgpu::Device, wgpu::Queue)> {
let instance = crate::gfx::test_gpu::instance();
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).ok()?;
pollster::block_on(adapter.request_device(&Default::default())).ok()
eprintln!("HDR GPU {:?}", adapter.get_info());
pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
required_features: adapter.features()
& (wgpu::Features::TEXTURE_FORMAT_16BIT_NORM
| wgpu::Features::TEXTURE_ADAPTER_SPECIFIC_FORMAT_FEATURES),
..Default::default()
}))
.ok()
}
/// Clears all of `texture` to `color`.
@@ -1193,37 +1183,147 @@ var<private> params: ReferenceParams;
std::array::from_fn(|i| half::f16::from_le_bytes([bytes[2 * i], bytes[2 * i + 1]]).to_f32())
}
// Independent pre-refactor light arithmetic applied to a compatibility attachment.
// Float targets in these tests only receive the resulting colour and coverage.
pub(super) fn legacy_light(
device: &wgpu::Device,
encoder: &mut wgpu::CommandEncoder,
hdr: &Hdr,
source: &wgpu::Texture,
target: &wgpu::Texture,
) {
use wgpu::util::DeviceExt;
assert_eq!(source.format(), F::Rgba16Unorm);
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("legacy non-float light oracle"),
source: wgpu::ShaderSource::Wgsl(
(r#"
struct ReferenceParams { peak: f32, scene: f32, world: f32, debug: f32, share: vec4<f32> }
@group(0) @binding(0) var<uniform> params: ReferenceParams;
@group(0) @binding(1) var source: texture_2d<f32>;
"#
.to_owned()
+ include_str!("hdr_light_reference.txt")
+ r#"
@vertex fn vs(@builtin(vertex_index) i: u32) -> @builtin(position) vec4<f32> {
return vec4(vec2(f32((i << 1u) & 2u), f32(i & 2u)) * 2.0 - 1.0, 0.0, 1.0);
}
@fragment fn fs() -> @location(0) vec4<f32> {
let c = textureLoad(source, vec2(0), 0);
let mark = round(saturate(c.a) * 255.0) / 255.0;
let light = reference_lit(c.rgb, mark, params.world > 0.5 && params.peak > 1.0, false).light;
return vec4(pow(light, vec3(1.0 / 2.2)), 1.0);
}
"#)
.into(),
),
});
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: None,
layout: None,
vertex: wgpu::VertexState {
module: &shader,
entry_point: Some("vs"),
compilation_options: Default::default(),
buffers: &[],
},
primitive: Default::default(),
depth_stencil: None,
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: Some("fs"),
compilation_options: Default::default(),
targets: &[Some(F::Rgba16Float.into())],
}),
multiview_mask: None,
cache: None,
});
let (settings, shares) = hdr.draw_params(true);
let params = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: None,
contents: bytemuck::cast_slice(&[settings, shares]),
usage: wgpu::BufferUsages::UNIFORM,
});
let view = source.create_view(&Default::default());
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: None,
layout: &pipeline.get_bind_group_layout(0),
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: params.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::TextureView(&view),
},
],
});
Hdr::draw(
encoder,
&pipeline,
&group,
&target.create_view(&Default::default()),
"legacy oracle",
None,
);
}
#[test]
fn the_layered_frame_shows_the_world_as_the_last_pass_lifted_it() {
fn direct_light_matches_the_legacy_non_float_display_below_the_tone_knee() {
let Some((device, queue)) = gpu() else {
return;
};
// What the last pass shows of a world pixel nothing covers, from today's frame
// (`layered` false) or the layered one, whose marks turn into light before it, then
// pass the tone curve.
if !device.features().contains(
wgpu::Features::TEXTURE_FORMAT_16BIT_NORM
| wgpu::Features::TEXTURE_ADAPTER_SPECIFIC_FORMAT_FEATURES,
) {
eprintln!("legacy display GPU skipped: 16-bit UNORM unavailable");
return;
}
// Compare the compatibility display lift with direct colour under the layered HUD.
// The frozen oracle supplies the writer's light from a non-float attachment.
let shown = |layered: bool, world: [f64; 4]| {
let mut hdr =
Hdr::with_debug(&device, &queue, F::Rgba16Float, Output::Pq, layered, None);
let mut hdr = Hdr::with_debug(
&device,
&queue,
if layered {
F::Rgba16Float
} else {
F::Rgba16Unorm
},
Output::Pq,
layered,
None,
);
let frame = hdr.frame(&device, [1, 1]);
let mut encoder = device.create_command_encoder(&Default::default());
fill(&mut encoder, &frame, world);
if layered {
let mut legacy =
Hdr::with_debug(&device, &queue, F::Rgba16Unorm, Output::Pq, false, None);
let marked = legacy.frame(&device, [1, 1]);
fill(&mut encoder, &marked, world);
if world[3] == 1.0 {
fill(&mut encoder, &frame, world);
} else {
legacy_light(&device, &mut encoder, &hdr, &marked, &frame);
}
hdr.begin(&queue, true);
hdr.resolve(&mut encoder, None);
hdr.tonemap(&mut encoder, None);
hdr.keep_world(&mut encoder, None);
super::super::hud_coverage::clear(&mut encoder, &hdr.coverage_view().unwrap());
} else {
fill(&mut encoder, &frame, world);
hdr.keep_world(&mut encoder, None);
}
let capture = hdr.capture(&device, &queue, &mut encoder, true).unwrap();
texel(&device, &queue, encoder, &capture)
};
// Unmarked, below white and above it; HDR lighting; light sources of each tier: all up to
// Unmarked, HDR lighting and light sources of each tier: all up to
// the tone curve's knee, which the curve leaves as they are.
for world in [
[0.5, 0.4, 0.3, 1.0],
[1.5, 1.2, 0.6, 1.0],
[0.8, 0.7, 0.6, 0.88],
[0.9, 0.8, 0.4, 0.05],
[0.9, 0.8, 0.4, 0.2],
@@ -1240,6 +1340,10 @@ var<private> params: ReferenceParams;
assert!(layered[0] > shown(true, [world[0], world[1], world[2], 1.0])[0]);
}
}
// Above-white unmarked colour belongs to float storage, not the UNORM oracle.
let above = shown(true, [1.5, 1.2, 0.6, 1.0]);
let white = shown(true, [1.0, 1.0, 0.6, 1.0]);
assert!(above[0] > white[0] && above[1] > white[1]);
}
#[test]
@@ -1306,13 +1410,6 @@ var<private> params: ReferenceParams;
hdr.peak = 150.0;
let [r, g, b] = eased(&mut hdr, [1.5, 0.5, 0.2]);
assert!(r == 1.0 && near(g, 0.5) && near(b, 0.2), "{r} {g} {b}");
// HDR lighting's gain stays past the knee until the curve: the marks become light
// without easing.
hdr.peak = 1000.0;
let resolve = |hdr: &Hdr, encoder: &mut wgpu::CommandEncoder| hdr.resolve(encoder, None);
let [r, ..] = after(&mut hdr, [1.0, 1.0, 1.0, 0.76], &resolve);
let gain = 1.0 + 3.0 * (1.0 - 194.0 / 255.0) / 0.24;
assert!(near(r.powf(2.2), gain), "{r}");
}
#[test]
@@ -1,6 +1,6 @@
use super::{
Hdr, Output,
tests::{gpu, texel},
tests::{gpu, legacy_light, texel},
};
use wgpu::TextureFormat as F;
use wgpu::util::DeviceExt;
@@ -46,6 +46,29 @@ override TEST_OVERLAY: u32 = 0u;
}
"#;
const TERRAIN_EXTRA_VERTICES: &str = r#"
@vertex fn test_model(@builtin(vertex_index) i: u32) -> NativeModelOut {
let p = vec2(f32((i << 1u) & 2u), f32(i & 2u));
var o: NativeModelOut;
o.clip = vec4(p * 2.0 - 1.0, 0.0, 1.0);
o.uv = vec2(0.5);
o.color = vec4(0.8, 0.9, 0.7, 1.0);
return o;
}
@vertex fn test_fluid(@builtin(vertex_index) i: u32) -> NativeFluidOut {
let p = vec2(f32((i << 1u) & 2u), f32(i & 2u));
var o: NativeFluidOut;
o.clip = vec4(p * 2.0 - 1.0, 0.0, 1.0);
o.uv = vec2(0.5);
o.color = vec4(0.8, 0.9, 0.7, 1.0);
return o;
}
@vertex fn test_sunrise(@builtin(vertex_index) i: u32) -> SunriseOut {
let p = vec2(f32((i << 1u) & 2u), f32(i & 2u));
return SunriseOut(vec4(p * 2.0 - 1.0, 0.0, 1.0), 0.5);
}
"#;
struct Draw<'a> {
device: &'a wgpu::Device,
queue: &'a wgpu::Queue,
@@ -54,6 +77,9 @@ struct Draw<'a> {
layout: wgpu::PipelineLayout,
bindings: wgpu::BindGroupLayout,
group: wgpu::BindGroup,
raw: bool,
hdr_lighting: bool,
material_frame: Option<super::super::native_post::NativeFrame>,
}
impl<'a> Draw<'a> {
fn render_oit(
@@ -316,7 +342,7 @@ impl<'a> Draw<'a> {
alpha: u8,
) -> Self {
let source = if terrain {
super::super::terrain_shader() + TERRAIN_VERTEX
super::super::terrain_shader() + TERRAIN_VERTEX + TERRAIN_EXTRA_VERTICES
} else {
super::super::entity_shader() + ENTITY_VERTEX
};
@@ -432,8 +458,43 @@ impl<'a> Draw<'a> {
layout,
bindings,
group,
raw: false,
hdr_lighting: true,
material_frame: None,
}
}
fn material(&mut self, api: horizon_shader_api::Api) {
let source = super::super::native_material::coverage_test_shader(api);
self.shader = self
.device
.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("actual assembled material wrappers"),
source: wgpu::ShaderSource::Wgsl(source.into()),
});
let frame = super::super::native_post::NativeFrame::new(
self.device,
None,
Vec::new(),
Default::default(),
None,
api,
);
self.queue.write_buffer(
&frame.buffer,
0,
bytemuck::bytes_of(&horizon_shader_api::Frame::default()),
);
self.layout = self
.device
.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: None,
bind_group_layouts: &[Some(&self.bindings), None, None, Some(&frame.layout)],
..Default::default()
});
self.material_frame = Some(frame);
self.raw = true;
}
fn render(
&self,
hdr: &mut Hdr,
@@ -443,11 +504,50 @@ impl<'a> Draw<'a> {
overlay: u32,
background: [f64; 4],
) -> [f32; 4] {
let constants = [
("HDR_WRITE_LIGHT", f64::from(u8::from(written))),
let mut constants = vec![
(
if self.material_frame.is_some() {
"horizon_terrain_HDR_WRITE_LIGHT"
} else {
"HDR_WRITE_LIGHT"
},
f64::from(u8::from(written)),
),
("TEST_TIER", f64::from(tier)),
("TEST_OVERLAY", f64::from(overlay)),
];
if self.terrain {
constants.push((
if self.material_frame.is_some() {
"horizon_terrain_HDR_LIGHTING"
} else {
"HDR_LIGHTING"
},
f64::from(u8::from(self.hdr_lighting)),
));
}
if self.material_frame.is_some() {
constants.push((
"horizon_displace_distance",
if entry == "horizon_material_fs_displaced" {
10.0
} else {
0.0
},
));
}
let vertex = match entry {
"fs_sky" | "fs_sky_base" => "vs_sky",
"fs_sunrise" => "test_sunrise",
"fs_native_model" => "test_model",
"fs_native_fluid" => "test_fluid",
_ => "test_vertex",
};
let format = if !written && !self.raw {
F::Rgba16Unorm
} else {
F::Rgba16Float
};
let pipeline = |entry, mark| {
self.device
.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
@@ -455,7 +555,7 @@ impl<'a> Draw<'a> {
layout: Some(&self.layout),
vertex: wgpu::VertexState {
module: &self.shader,
entry_point: Some("test_vertex"),
entry_point: Some(vertex),
compilation_options: wgpu::PipelineCompilationOptions {
constants: &constants,
..Default::default()
@@ -473,12 +573,14 @@ impl<'a> Draw<'a> {
..Default::default()
},
targets: &[Some(wgpu::ColorTargetState {
format: F::Rgba16Float,
blend: Some(if mark || (self.terrain && entry != "fs_translucent") {
wgpu::BlendState::REPLACE
} else {
wgpu::BlendState::ALPHA_BLENDING
}),
format,
blend: Some(
if self.raw || mark || (self.terrain && entry != "fs_translucent") {
wgpu::BlendState::REPLACE
} else {
wgpu::BlendState::ALPHA_BLENDING
},
),
write_mask: if mark {
wgpu::ColorWrites::ALPHA
} else {
@@ -491,7 +593,7 @@ impl<'a> Draw<'a> {
})
};
let color = pipeline(entry, false);
let mark = (!self.terrain && !written && entry == "fs_entity").then(|| {
let mark = (!self.raw && !self.terrain && !written && entry == "fs_entity").then(|| {
pipeline(
if overlay == 4 {
"fs_hdr_cloud_mark"
@@ -501,7 +603,22 @@ impl<'a> Draw<'a> {
true,
)
});
let frame = hdr.frame(self.device, [1, 1]);
let frame = self.device.create_texture(&wgpu::TextureDescriptor {
label: None,
size: wgpu::Extent3d {
width: 1,
height: 1,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT
| wgpu::TextureUsages::TEXTURE_BINDING
| wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
let view = frame.create_view(&Default::default());
let mut encoder = self.device.create_command_encoder(&Default::default());
{
@@ -519,6 +636,9 @@ impl<'a> Draw<'a> {
..Default::default()
});
pass.set_bind_group(0, &self.group, &[]);
if let Some(frame) = &self.material_frame {
pass.set_bind_group(3, &frame.group, &[]);
}
pass.set_pipeline(&color);
pass.draw(0..3, 0..1);
if let Some(mark) = &mark {
@@ -526,20 +646,266 @@ impl<'a> Draw<'a> {
pass.draw(0..3, 0..1);
}
}
if !written {
hdr.begin(self.queue, true);
hdr.resolve(&mut encoder, None);
if !written && !self.raw {
let light = hdr.frame(self.device, [1, 1]);
legacy_light(self.device, &mut encoder, hdr, &frame, &light);
return texel(self.device, self.queue, encoder, &light);
}
texel(self.device, self.queue, encoder, &frame)
}
}
#[test]
fn actual_opaque_writers_match_marks_then_resolve() {
fn float_plain_fragments_keep_rgb_and_write_only_coverage() {
let Some((device, queue)) = gpu() else {
eprintln!("plain float GPU unavailable");
return;
};
let mut hdr = Hdr::with_debug(&device, &queue, F::Rgba16Float, Output::Pq, true, None);
let (settings, shares) = hdr.draw_params(true);
let mut cases = 0;
for hdr_on in [false, true] {
let globals = super::super::Globals {
inverse_view: glam::Mat4::IDENTITY.to_cols_array_2d(),
fog_color: [0.2, 0.3, 0.4, 1.0],
sky_color: [0.7, 0.8, 0.9, 1.0],
sunrise: [1.2, 0.7, 0.4, 0.6],
fog: [0.0, 100.0, 0.0, 100.0],
fog_ends: [100.0, 100.0, 0.0, 0.0],
misc: [0.0, 1.0, 0.0, 0.0],
filtering: [0.0, 1.0, 0.0, 0.0],
hdr_windows: hdr.windows(),
hdr_light: [0.8, 0.8, f32::from(u8::from(hdr_on)), 0.0],
hdr_settings: settings,
hdr_shares: shares,
..bytemuck::Zeroable::zeroed()
};
for terrain in [true, false] {
let mut draw = Draw::new(&device, &queue, terrain, &globals, 180);
draw.raw = true;
let coverage = 180.0 / 255.0;
let entries = if terrain {
vec![
("fs_opaque", 1.0),
("fs_solid", 1.0),
("fs_solid_mirror", 1.0),
("fs_translucent", coverage),
("fs_native_model", 1.0),
("fs_native_fluid", coverage),
("fs_sky", 1.0),
("fs_sky_base", 1.0),
("fs_sunrise", 0.3),
]
} else {
vec![
("fs_entity", coverage),
("fs_celestial", coverage),
("fs_sky_celestial", coverage),
]
};
for plain in [false, true] {
draw.hdr_lighting = !plain;
for &(entry, alpha) in &entries {
for tier in [0, 3, 4] {
for overlay in if terrain { vec![0, 1] } else { vec![0, 6] } {
let old = draw.render(&mut hdr, false, entry, tier, overlay, [0.0; 4]);
let float = draw.render(&mut hdr, true, entry, tier, overlay, [0.0; 4]);
assert_eq!(
&float[..3],
&old[..3],
"{hdr_on} {entry} {tier} {overlay} {plain}"
);
assert!(
(float[3] - alpha).abs() < 0.001,
"{entry}: {float:?}, expected alpha {alpha}"
);
assert!(
float[..3].iter().any(|&c| c > 0.0),
"empty fixture: {entry}"
);
cases += 1;
}
}
}
}
}
}
eprintln!("plain float GPU ran {cases} colour/coverage comparisons");
}
#[test]
fn float_stars_keep_the_original_rgb_with_brightness_as_coverage() {
let Some((device, queue)) = gpu() else {
return;
};
let mut hdr = Hdr::with_debug(&device, &queue, F::Rgba16Float, Output::Pq, true, None);
let (settings, shares) = hdr.draw_params(true);
// Frozen f030d9c80 fragment: the original written branch and its marked fallback.
let source = include_str!("../hdr_light.wgsl").to_owned()
+ include_str!("stars.wgsl")
+ r#"
@vertex fn fixture(@builtin(vertex_index) i: u32) -> @builtin(position) vec4<f32> {
return vec4(vec2(f32((i << 1u) & 2u), f32(i & 2u)) * 2.0 - 1.0, 0.0, 1.0);
}
@fragment fn reference() -> @location(0) vec4<f32> {
let b = stars.color.a;
if HDR_WRITE_LIGHT && stars.hdr_light.w != 0.0 {
return vec4(hdr_written(stars.color.rgb * b, 0.175, stars.hdr_settings, stars.hdr_shares), b);
}
return vec4(stars.color.rgb * b, 0.175);
}
"#;
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("actual stars with original RGB reference"),
source: wgpu::ShaderSource::Wgsl(source.into()),
});
for gain in [0.0, 1.0] {
let mut data = [0.0_f32; 32];
data[16..20].copy_from_slice(&[0.7, 0.8, 0.9, 0.35]);
data[20..24].copy_from_slice(&settings);
data[24..28].copy_from_slice(&shares);
data[31] = gain;
let uniform = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: None,
contents: bytemuck::cast_slice(&data),
usage: wgpu::BufferUsages::UNIFORM,
});
let mut draw = |entry| {
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: None,
layout: None,
vertex: wgpu::VertexState {
module: &shader,
entry_point: Some("fixture"),
compilation_options: Default::default(),
buffers: &[],
},
primitive: Default::default(),
depth_stencil: None,
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: Some(entry),
compilation_options: wgpu::PipelineCompilationOptions {
constants: &[("HDR_WRITE_LIGHT", 1.0)],
..Default::default()
},
targets: &[Some(wgpu::ColorTargetState {
format: F::Rgba16Float,
blend: None,
write_mask: wgpu::ColorWrites::ALL,
})],
}),
multiview_mask: None,
cache: None,
});
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: None,
layout: &pipeline.get_bind_group_layout(0),
entries: &[wgpu::BindGroupEntry {
binding: 0,
resource: uniform.as_entire_binding(),
}],
});
let frame = hdr.frame(&device, [1, 1]);
let view = frame.create_view(&Default::default());
let mut encoder = device.create_command_encoder(&Default::default());
{
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &view,
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
store: wgpu::StoreOp::Store,
},
})],
..Default::default()
});
pass.set_pipeline(&pipeline);
pass.set_bind_group(0, &group, &[]);
pass.draw(0..3, 0..1);
}
texel(&device, &queue, encoder, &frame)
};
let old = draw("reference");
let float = draw("fs_stars");
assert_eq!(&float[..3], &old[..3]);
assert!((float[3] - 0.35).abs() < 0.001, "{float:?}");
}
}
#[test]
fn float_material_wrappers_keep_rgb_and_coverage_including_displacement() {
let instance = super::super::test_gpu::instance();
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
return;
};
// The real material layout binds the terrain arrays and the pack's frame together.
if adapter.limits().max_sampled_textures_per_shader_stage < 19 {
return;
}
let Ok((device, queue)) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
required_limits: wgpu::Limits {
max_sampled_textures_per_shader_stage: 19,
..Default::default()
},
..Default::default()
})) else {
return;
};
let mut hdr = Hdr::with_debug(&device, &queue, F::Rgba16Float, Output::Pq, true, None);
let globals = super::super::Globals {
filtering: [0.0, 1.0, 0.0, 0.0],
hdr_windows: hdr.windows(),
..bytemuck::Zeroable::zeroed()
};
for api in [horizon_shader_api::Api::V0_1, horizon_shader_api::Api::V0_2] {
let mut draw = Draw::new(&device, &queue, true, &globals, 255);
draw.material(api);
for entry in [
"horizon_material_fs_solid",
"horizon_material_fs_displaced",
"horizon_material_fs_translucent",
] {
let old = draw.render(&mut hdr, false, entry, 3, 0, [0.0; 4]);
let float = draw.render(&mut hdr, true, entry, 3, 0, [0.0; 4]);
assert_eq!(&float[..3], &old[..3], "{api} {entry}");
let alpha = if entry.ends_with("translucent") {
0.7
} else {
1.0
};
assert!((float[3] - alpha).abs() < 0.001, "{api} {entry}: {float:?}");
assert!(
float[0] > 1.0,
"{api} {entry}: clipping or an empty fixture: {float:?}"
);
if alpha == 1.0 {
assert_ne!(
old[3], float[3],
"the legacy source mark must distinguish the paths"
);
}
}
}
eprintln!("material GPU ran both API lines through solid, displaced and translucent wrappers");
}
#[test]
fn actual_opaque_writers_match_the_legacy_non_float_oracle() {
let Some((device, queue)) = gpu() else {
eprintln!("HDR draw GPU unavailable");
return;
};
if !device.features().contains(
wgpu::Features::TEXTURE_FORMAT_16BIT_NORM
| wgpu::Features::TEXTURE_ADAPTER_SPECIFIC_FORMAT_FEATURES,
) {
eprintln!("opaque oracle GPU skipped: 16-bit UNORM unavailable");
return;
}
let mut hdr = Hdr::with_debug(&device, &queue, F::Rgba16Float, Output::Pq, true, None);
(hdr.paper_white, hdr.peak) = (203.0, 1000.0);
let (settings, shares) = hdr.draw_params(true);
@@ -576,8 +942,8 @@ fn actual_opaque_writers_match_marks_then_resolve() {
draw.render(&mut hdr, false, entry, tier, overlay, [0.0; 4]);
let written =
draw.render(&mut hdr, true, entry, tier, overlay, [0.0; 4]);
// The marked path stores half floats before conversion; the written
// one stores after it. Allow two half-float ULPs in gamma light.
// The oracle stores UNORM before conversion; the direct writer
// stores half floats after it. Allow two half-float ULPs in gamma light.
for i in 0..3 {
assert!(
(marked[i] - written[i]).abs() <= 0.002 * marked[i].max(1.0),
@@ -630,6 +996,10 @@ fn actual_translucent_writers_blend_their_own_light() {
let dst = [2.5, 1.8, 0.9, 1.0];
let got = draw.render(&mut hdr, true, entry, tier, overlay, dst);
let alpha = f32::from(texel_alpha) / 255.0 * tint_alpha;
assert!(
(src[3] - alpha).abs() < 0.001,
"{entry}: {src:?}, coverage {alpha}"
);
if terrain {
// Full block light leaves mark 194/255 whatever the texel's alpha, even when
// vertex coverage is halved.
@@ -657,6 +1027,13 @@ fn unmarked_entity_users_keep_their_source_gain() {
let Some((device, queue)) = gpu() else {
return;
};
if !device.features().contains(
wgpu::Features::TEXTURE_FORMAT_16BIT_NORM
| wgpu::Features::TEXTURE_ADAPTER_SPECIFIC_FORMAT_FEATURES,
) {
eprintln!("legacy oracle GPU skipped: 16-bit UNORM unavailable");
return;
}
let mut hdr = Hdr::with_debug(&device, &queue, F::Rgba16Float, Output::Pq, true, None);
let (settings, shares) = hdr.draw_params(true);
let globals = super::super::Globals {
@@ -694,6 +1071,13 @@ fn additive_texels_below_the_mark_cutoff_keep_their_gain() {
let Some((device, queue)) = gpu() else {
return;
};
if !device.features().contains(
wgpu::Features::TEXTURE_FORMAT_16BIT_NORM
| wgpu::Features::TEXTURE_ADAPTER_SPECIFIC_FORMAT_FEATURES,
) {
eprintln!("legacy oracle GPU skipped: 16-bit UNORM unavailable");
return;
}
let mut hdr = Hdr::with_debug(&device, &queue, F::Rgba16Float, Output::Pq, true, None);
let (settings, shares) = hdr.draw_params(true);
let globals = super::super::Globals {
@@ -3,17 +3,11 @@
pub(crate) const FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::R8Unorm;
pub(crate) fn supported(
lane: (bool, bool, bool),
hdr_offered: bool,
attachments: u32,
flags: wgpu::DownlevelFlags,
format: wgpu::TextureFormatFeatures,
) -> bool {
!lane.0
&& lane.1
&& lane.2
&& hdr_offered
&& attachments >= 2
attachments >= 2
&& flags.contains(wgpu::DownlevelFlags::INDEPENDENT_BLEND)
&& format
.allowed_usages
@@ -345,37 +339,17 @@ mod tests {
use super::*;
#[test]
fn coverage_requires_the_no_pack_float_hdr_lane_and_independent_r8_blending() {
fn coverage_requires_two_attachments_and_independent_r8_blending() {
let flags = wgpu::DownlevelFlags::INDEPENDENT_BLEND;
let format = wgpu::TextureFormatFeatures {
allowed_usages: wgpu::TextureUsages::RENDER_ATTACHMENT
| wgpu::TextureUsages::TEXTURE_BINDING,
flags: wgpu::TextureFormatFeatureFlags::BLENDABLE,
};
let lane = (false, true, true);
assert!(supported(lane, true, 2, flags, format));
// Switch unset (including legacy 16-bit HDR and 0.1 packs), HDR off, a 0.2
// pack's float frame, and unsupported devices never allocate coverage.
for other in [
(false, false, true),
(true, false, true),
(true, true, true),
(false, true, false),
] {
assert!(!supported(other, true, 2, flags, format));
}
assert!(!supported(lane, false, 2, flags, format));
assert!(!supported(lane, true, 1, flags, format));
assert!(supported(2, flags, format));
assert!(!supported(1, flags, format));
assert!(!supported(2, wgpu::DownlevelFlags::empty(), format));
assert!(!supported(
lane,
true,
2,
wgpu::DownlevelFlags::empty(),
format
));
assert!(!supported(
lane,
true,
2,
flags,
wgpu::TextureFormatFeatures {
@@ -384,8 +358,6 @@ mod tests {
}
));
assert!(!supported(
lane,
true,
2,
flags,
wgpu::TextureFormatFeatures {
@@ -393,6 +365,14 @@ mod tests {
..format
}
));
assert!(!supported(
2,
flags,
wgpu::TextureFormatFeatures {
allowed_usages: wgpu::TextureUsages::RENDER_ATTACHMENT,
..format
}
));
}
#[test]
@@ -149,6 +149,21 @@ fn composed() -> Vec<(&'static str, Vec<&'static str>, String)> {
vec!["terrain.wgsl", "visbuffer.wgsl"],
super::terrain_shader() + include_str!("../visbuffer.wgsl"),
),
// A native mod's world warp (gfx/world_warp.rs): its copies with the plainest warp.
(
"world warp terrain",
vec![
"terrain.wgsl",
"world_warp.wgsl",
"world_warp_fragment.wgsl",
],
super::world_warp::terrain_shader(super::world_warp::IDENTITY).unwrap(),
),
(
"world warp entities",
vec!["entity.wgsl", "static_entity.wgsl", "world_warp.wgsl"],
super::world_warp::entity_shader(super::world_warp::IDENTITY).unwrap(),
),
(
"improved transparency world border",
vec!["border.wgsl", "oit.wgsl", "oit_border.wgsl"],
+21 -15
View File
@@ -50,8 +50,10 @@ impl Pending {
}
}
/// Small, its meshes boxed: `draw_frame` walks every tile each frame (thousands at long far
/// distances), and that walk is bound by the memory it reads.
pub(super) struct Tile {
pub mesh: SectionGpu,
pub mesh: Box<SectionGpu>,
bounds: Bounds,
/// When it began to fade in; none while the rest of its job uploads.
shown: Option<Instant>,
@@ -62,7 +64,7 @@ pub(super) struct Tile {
/// Its mesh before near chunks took some of its columns, drawn in its
/// place until they have faded in (from the job's end, the instant), so
/// no hole shows through them.
previous: Option<(SectionGpu, Option<Instant>)>,
previous: Option<Box<(SectionGpu, Option<Instant>)>>,
/// Its slot in occlusion culling's results (`cull::TILE`), kept while
/// meshes of the same tile replace one another.
pub cull_slot: u32,
@@ -155,10 +157,12 @@ pub(super) struct Visible {
impl Tile {
/// The mesh drawn this frame: the previous one while it stands in.
pub fn drawn(&self) -> &SectionGpu {
self.previous.as_ref().map_or(&self.mesh, |(mesh, _)| mesh)
self.previous
.as_deref()
.map_or(&self.mesh, |(mesh, _)| mesh)
}
pub fn drawn_mut(&mut self) -> &mut SectionGpu {
match &mut self.previous {
match self.previous.as_deref_mut() {
Some((mesh, _)) => mesh,
None => &mut self.mesh,
}
@@ -177,7 +181,7 @@ impl Tile {
}
/// Both meshes it holds in the arena: its own and the stand-in, if any.
pub fn meshes_mut(&mut self) -> impl Iterator<Item = &mut SectionGpu> {
std::iter::once(&mut self.mesh).chain(self.previous.as_mut().map(|(mesh, _)| mesh))
std::iter::once(&mut *self.mesh).chain(self.previous.as_deref_mut().map(|(mesh, _)| mesh))
}
pub fn visible(
&self,
@@ -214,12 +218,12 @@ impl Renderer {
let (shown, previous) = match self.lods.remove(&key) {
Some(old) if old.dying.is_none() => match old.previous {
Some(previous) => {
self.release_mesh(old.mesh);
self.release_mesh(*old.mesh);
(old.shown, Some(previous))
}
None if keep => (old.shown, Some((old.mesh, None))),
None if keep => (old.shown, Some(Box::new((*old.mesh, None)))),
None => {
self.release_mesh(old.mesh);
self.release_mesh(*old.mesh);
(old.shown, None)
}
},
@@ -240,7 +244,7 @@ impl Renderer {
self.lods.insert(
key,
Tile {
mesh,
mesh: Box::new(mesh),
bounds,
shown,
dying: None,
@@ -251,7 +255,7 @@ impl Renderer {
);
}
None => {
if let Some((previous, _)) = previous {
if let Some((previous, _)) = previous.map(|p| *p) {
self.release_mesh(previous);
}
self.lod_cull_free.extend(old_slot);
@@ -260,8 +264,8 @@ impl Renderer {
}
fn release_tile(&mut self, tile: Tile) {
self.release_mesh(tile.mesh);
if let Some((previous, _)) = tile.previous {
self.release_mesh(*tile.mesh);
if let Some((previous, _)) = tile.previous.map(|p| *p) {
self.release_mesh(previous);
}
}
@@ -337,8 +341,10 @@ impl Renderer {
let stood_in: Vec<_> = self
.lods
.values_mut()
.filter_map(|tile| match tile.previous {
Some((_, Some(since))) if start - since >= FADE => tile.previous.take(),
.filter_map(|tile| match tile.previous.as_deref() {
Some((_, Some(since))) if start - *since >= FADE => {
tile.previous.take().map(|p| *p)
}
_ => None,
})
.collect();
@@ -440,7 +446,7 @@ impl Renderer {
if tile.shown.is_none() {
tile.shown = Some(if over_replaced(key) { start } else { whole });
}
if let Some((_, since)) = &mut tile.previous {
if let Some((_, since)) = tile.previous.as_deref_mut() {
since.get_or_insert(start);
}
}
@@ -195,6 +195,7 @@ struct HorizonMaterialSolid {
// What `fs_solid` leaves in the frame's alpha for HDR output, from the sprite before the pack's
// material: the pack decides the colour, Horizon still knows which pixels are light sources.
fn horizon_material_mark(in: HorizonMaterialOut, texel: vec4<f32>) -> f32 {
if horizon_terrain_HDR_WRITE_LIGHT { return 1.0; }
let code = in.hdr_code & 15u;
if code > 0u {
let whole = code > 4u;
@@ -432,6 +433,10 @@ impl NativeMaterial {
];
constants.extend(sprite_names.into_iter().zip(shape.sprites));
constants.push(("horizon_material_fog", f64::from(u8::from(fog))));
constants.push((
"horizon_terrain_HDR_WRITE_LIGHT",
f64::from(u8::from(shape.format == super::LINEAR_FRAME)),
));
constants.push((
"horizon_displace_distance",
displaced.map_or(0.0, |d| f64::from(d.distance)),
@@ -580,6 +585,39 @@ impl NativeMaterial {
}
}
#[cfg(test)]
pub(super) fn coverage_test_shader(api: horizon_shader_api::Api) -> String {
use horizon_shader_api::{MANIFEST, Pack, StageKind, check};
let manifest = format!(
"schema = 1\nid = \"coverage\"\nname = \"Coverage\"\nversion = \"1\"\napi = \"{api}\"\n[material]\nfile = \"material.wgsl\"\n[displacement]\n"
);
let material = "fn material(input: MaterialInput) -> Surface { return Surface(vec4<f32>(1.4, 0.3, 0.1, 0.7)); }\nfn displacement(input: DisplacementInput) -> f32 { return 0.0; }\n";
let files = [(MANIFEST, manifest.as_str()), ("material.wgsl", material)]
.map(|(n, text)| (n.to_owned(), text.as_bytes().to_vec()))
.into();
let report = check(&Pack::from_files(files).unwrap()).unwrap();
let stage = report
.stages
.iter()
.find(|s| s.kind == StageKind::Material)
.unwrap();
shader_source(&stage.source, &[], &[], (false, true), api)
+ r#"
override TEST_TIER: u32 = 0u;
override TEST_OVERLAY: u32 = 0u;
@vertex fn test_vertex(@builtin(vertex_index) i: u32) -> HorizonMaterialOut {
var o: HorizonMaterialOut;
o.clip = vec4(vec2(f32((i << 1u) & 2u), f32(i & 2u)) * 2.0 - 1.0, 0.0, 1.0);
o.uv = vec2(0.5);
o.tint = vec4(1.0);
o.lightmap = vec3(1.0);
o.hdr_code = TEST_TIER;
o.base = vec3(1.0);
return o;
}
"#
}
#[cfg(test)]
mod tests {
use super::*;
@@ -7,14 +7,16 @@ use super::Vertex;
use super::pack_draw::EntitySpan;
use glam::{DVec3, Mat4};
use std::sync::{
OnceLock,
Arc, OnceLock,
atomic::{AtomicBool, Ordering},
};
mod generated {
include!(concat!(env!("OUT_DIR"), "/native_render_generated.rs"));
}
pub use generated::{HandDrawnEvent, RenderHooks, RenderNeedsEvent, WorldDrawnEvent};
pub use generated::{
HandDrawnEvent, RenderHooks, RenderNeedsEvent, WorldDrawnEvent, WorldWarpEvent,
};
pub use wgpu;
static RENDERERS: OnceLock<Box<[&'static dyn RenderHooks]>> = OnceLock::new();
@@ -178,6 +180,38 @@ pub fn render_needs() -> RenderNeeds {
needs
}
/// A world warp ([`native_world_warp`]): WGSL that every terrain vertex (far terrain's tiles too)
/// and entity vertex goes through, so a mod can bend the drawn world itself: its real geometry,
/// light and depth, seen from any side. docs/native-mods.md, "World warps", has the WGSL contract.
#[derive(Clone, Debug, Default, PartialEq)]
#[non_exhaustive]
pub struct WorldWarp {
/// WGSL with `fn world_warp(pos: vec3<f32>, section: vec3<f32>) -> Warped`, and optionally,
/// after a line `// [fragments]`, `fn world_fragment(f: Fragment) -> Painted` for every
/// pixel of the terrain. `None`: no warp.
pub source: Option<Arc<str>>,
/// Whether this frame draws with the warp. A source given while this is false is still
/// built, so a mod can pay for its pipelines before the frame that needs them.
pub on: bool,
/// What the warp reads as `warp.params`: four `vec4<f32>`.
pub params: [f32; 16],
}
/// Asked once a frame, before the world is drawn (not on the title screen's panorama): set
/// [`WorldWarp`] to have the frame drawn through your warp. Mods are asked in order and one warp
/// draws: the last one set. Keep it cheap: compare and reuse your `Arc<str>`.
#[inline(never)]
pub fn native_world_warp(warp: &mut WorldWarp) {
generated::dispatch_world_warp(RENDERERS.get().map_or(&[], |renderers| renderers), warp);
}
/// This frame's world warp ([`native_world_warp`]).
pub fn world_warp() -> WorldWarp {
let mut warp = WorldWarp::default();
native_world_warp(&mut warp);
warp
}
#[cfg(test)]
mod tests {
use super::*;
@@ -378,6 +412,33 @@ mod tests {
assert_eq!(texels, expected);
}
struct Bend(&'static str);
impl RenderHooks for Bend {
fn world_warp(&self, event: &mut WorldWarpEvent<'_>) {
event.warp.source = Some(self.0.into());
event.warp.on = true;
event.warp.params[0] += 1.0;
}
}
#[test]
fn the_last_mod_to_set_a_warp_draws() {
let mut warp = WorldWarp::default();
generated::dispatch_world_warp(&[&Depth, &Bend("a"), &Bend("b")], &mut warp);
assert_eq!(warp.source.as_deref(), Some("b"));
assert!(warp.on);
// each saw what the one before left
assert_eq!(warp.params[0], 2.0);
}
#[test]
fn without_mods_there_is_no_warp() {
let mut warp = WorldWarp::default();
generated::dispatch_world_warp(&[], &mut warp);
assert_eq!(warp, WorldWarp::default());
assert!(warp.source.is_none() && !warp.on);
}
#[test]
fn needs_accumulate_in_mod_order() {
let mut needs = RenderNeeds::default();
@@ -49,12 +49,12 @@ fn terrain_fade_reaches_pack_pixels_through_real_instances() {
std::env::temp_dir().join(format!("horizon-terrain-fade-{}", std::process::id())),
);
std::fs::create_dir_all(folder.0.join("shaders")).unwrap();
// The guard, initializer, varying and integer output are independent probe code.
// The initializer, varying and integer output are independent probe code. The program reads
// `mc_chunkFade` unguarded: Horizon defines no `IRIS_FEATURE_*` (spec OQ-2).
let vertex = "#version 330\nout vec4 observation;\n\
#ifdef IRIS_FEATURE_FADE_VARIABLE\nfloat first = mc_chunkFade;\n#endif\n\
void main() {\n#ifdef IRIS_FEATURE_FADE_VARIABLE\n\
float first = mc_chunkFade;\n\
void main() {\n\
observation = vec4(mc_chunkFade, first, gl_Color.a, gl_MultiTexCoord0.x);\n\
#else\nobservation = vec4(1.0, 1.0, gl_Color.a, gl_MultiTexCoord0.x);\n#endif\n\
gl_Position = vec4(0.0, 0.0, 0.0, 1.0);\n}";
let fragment = "#version 330\nin vec4 observation;\nlayout(location = 0) out uvec4 pixel;\n\
void main() { pixel = floatBitsToUint(observation); }";
@@ -159,7 +159,8 @@ fn terrain_fade_reaches_pack_pixels_through_real_instances() {
origin,
0x80ffffff,
);
let fade = if requested { expected } else { 1.0 };
// The fade reaches the program whether the pack requested it or not.
let fade = expected;
let expected = [
fade,
if geometry { 0.8125 } else { fade },
@@ -2464,6 +2464,12 @@ impl PackPost {
let textures = pack_textures::upload(device, queue, post.textures, &mut notes);
let shadow_config = post.config.shadow.clone();
let noise_size = post.config.noise_resolution.unwrap_or(256);
let half_lives = post.config.half_lives;
for (name, ticks) in horizon_shaders::HALF_LIVES.iter().zip(half_lives) {
if let Some(t) = ticks {
notes.push(format!("`{name}` is {t} ticks"));
}
}
// shadowcolor2 to 7 only for a pack that names `HIGHER_SHADOWCOLOR`.
let shadow_colors = (1u16 << post.shadow_colors) - 1;
let mut custom = post.custom;
@@ -3443,6 +3449,7 @@ impl PackPost {
let mut u = FrameUniforms::default();
u.atlas = world.atlas.map_or([0; 2], |a| a.1);
u.shadow = shadow_config;
u.half_lives = half_lives;
u
},
shadow_cache: Default::default(),
@@ -405,6 +405,8 @@ pub struct FrameUniforms {
center_depth: Option<f32>,
/// The pack's shadow settings (7.1).
pub shadow: Shadow,
/// The pack's smoothing half-lives in ticks (`horizon_shaders::HALF_LIVES`).
pub half_lives: [Option<f32>; 4],
}
impl Default for FrameUniforms {
@@ -422,6 +424,7 @@ impl Default for FrameUniforms {
center_depth_raw: None,
center_depth: None,
shadow: Shadow::default(),
half_lives: [None; 4],
}
}
}
@@ -734,6 +737,7 @@ impl FrameUniforms {
*self = Self {
atlas: self.atlas,
shadow: std::mem::take(&mut self.shadow),
half_lives: self.half_lives,
..Self::default()
};
}
@@ -798,10 +802,13 @@ impl FrameUniforms {
// a tenth of a second a tick: Iris 1.11.7 smooths that slowly (measured frame by frame
// with a probe pack: wetness 60 s rising, 20 s falling; eyeBrightnessSmooth 1 s;
// centerDepthSmooth 0.1 s).
// The pack's `wetnessHalflife`/`drynessHalflife` when it sets them (OptiFine's defaults
// otherwise), as for `eyeBrightnessHalflife` and `centerDepthHalflife` below.
let [wet, dry, eye_half, depth_half] = self.half_lives;
let half_life = if inputs.rain > self.wetness {
600.0
wet.unwrap_or(600.0)
} else {
200.0
dry.unwrap_or(200.0)
};
self.wetness = smooth(self.wetness, inputs.rain, half_life * SECONDS_A_TICK, dt);
let eye = inputs.eye_light.map(|l| (l * 16) as f32);
@@ -810,13 +817,18 @@ impl FrameUniforms {
self.eye_smooth = eye;
}
for (smoothed, target) in self.eye_smooth.iter_mut().zip(eye) {
*smoothed = smooth(*smoothed, target, 10.0 * SECONDS_A_TICK, dt);
*smoothed = smooth(
*smoothed,
target,
eye_half.unwrap_or(10.0) * SECONDS_A_TICK,
dt,
);
}
// `centerDepthHalflife`'s default, 1 tick (OptiFine's shaders.txt); the first value
// read back is taken as it is.
if let Some(raw) = self.center_depth_raw {
self.center_depth = Some(match self.center_depth {
Some(v) => smooth(v, raw, SECONDS_A_TICK, dt),
Some(v) => smooth(v, raw, depth_half.unwrap_or(1.0) * SECONDS_A_TICK, dt),
None => raw,
});
}
@@ -1260,10 +1272,12 @@ mod tests {
format!(r#"{{"attributes": {attributes}}}"#),
)
.unwrap();
let environment =
crate::environment::Environment::load_dimension(&root, name, false).unwrap();
let environment = crate::environment::Environment::load_dimension(&root, name).unwrap();
std::fs::remove_dir_all(&root).unwrap();
environment.sample(6000, None, None)
environment.sample(&crate::environment::Sample {
day_time: 6000,
..Default::default()
})
}
#[test]
@@ -2218,22 +2232,33 @@ pub(super) mod initial_history_tests {
let hud = HudBatch::new([800, 450]);
let f = frame(&hud);
let mut u = FrameUniforms::default();
// The next frame comes `seconds` after the last (and a few microseconds of the test).
let after = |u: &mut FrameUniforms, seconds: f32| {
u.last = Some(std::time::Instant::now() - std::time::Duration::from_secs_f32(seconds));
// A scripted clock: the next frame comes exactly `seconds` after the last, however long
// the test itself takes (a wall-clock gap of a millisecond moved centerDepthSmooth by
// more than the tolerance on a busy CI runner).
let mut counter = 1;
let mut elapsed = 0.0;
let mut after = |input: &mut PackInputs, seconds: f32| {
counter += 1;
elapsed += seconds;
input.frame_timing = Some(FrameTiming {
counter,
elapsed,
delta: seconds,
});
};
let mut input = PackInputs {
eye_light: [0, 15],
frame_timing: Some(FrameTiming::nth(1, 0.0)),
..Default::default()
};
u.center_depth_raw = Some(1.0);
u.update(&f, &input, [800, 450]);
input.eye_light = [0, 0];
u.center_depth_raw = Some(0.0);
after(&mut u, 0.1);
after(&mut input, 0.1);
u.update(&f, &input, [800, 450]);
assert!((float(&u, "centerDepthSmooth")[0] - 0.5).abs() < 1e-3);
after(&mut u, 0.6);
after(&mut input, 0.6);
u.update(&f, &input, [800, 450]);
// 240 falling toward 0 for 0.7 s: 147.8, read as 147.
match u.values[index("eyeBrightnessSmooth").unwrap()] {
@@ -2241,11 +2266,46 @@ pub(super) mod initial_history_tests {
_ => panic!("integer uniform expected"),
}
input.rain = 1.0;
after(&mut u, 60.0);
after(&mut input, 60.0);
u.update(&f, &input, [800, 450]);
assert!((float(&u, "wetness")[0] - 0.5).abs() < 1e-3);
input.rain = 0.0;
after(&mut u, 20.0);
after(&mut input, 20.0);
u.update(&f, &input, [800, 450]);
assert!((float(&u, "wetness")[0] - 0.25).abs() < 1e-3);
}
/// A pack's `const float wetnessHalflife = 30.0;` (and its dryness) replace the defaults:
/// wetness reaches half the rain in 3 s instead of 60 s.
#[test]
fn a_pack_s_half_lives_replace_the_defaults() {
let hud = HudBatch::new([800, 450]);
let f = frame(&hud);
let mut u = FrameUniforms {
half_lives: [Some(30.0), Some(10.0), None, None],
..Default::default()
};
// A scripted clock, as in `smoothed_uniforms_take_iris_half_lives`: the wall clock's gap
// between two updates is not exact on a busy runner.
let mut input = PackInputs {
frame_timing: Some(FrameTiming::nth(1, 0.0)),
..Default::default()
};
u.update(&f, &input, [800, 450]);
input.rain = 1.0;
input.frame_timing = Some(FrameTiming {
counter: 2,
elapsed: 3.0,
delta: 3.0,
});
u.update(&f, &input, [800, 450]);
assert!((float(&u, "wetness")[0] - 0.5).abs() < 1e-3);
input.rain = 0.0;
input.frame_timing = Some(FrameTiming {
counter: 3,
elapsed: 4.0,
delta: 1.0,
});
u.update(&f, &input, [800, 450]);
assert!((float(&u, "wetness")[0] - 0.25).abs() < 1e-3);
}
@@ -274,7 +274,13 @@ impl Panorama {
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: Some("fs_panorama"),
compilation_options: Default::default(),
compilation_options: wgpu::PipelineCompilationOptions {
constants: &[(
"HDR_WRITE_LIGHT",
f64::from(u8::from(format == super::LINEAR_FRAME)),
)],
..Default::default()
},
targets: &[Some(wgpu::ColorTargetState {
format,
blend: None,
@@ -1,4 +1,5 @@
// CubeMap's position-only cube and panorama.vsh/fsh: sample its untransformed direction.
override HDR_WRITE_LIGHT: bool = false;
struct Projection { view_proj: mat4x4<f32> }
@group(0) @binding(0) var<uniform> projection: Projection;
@group(0) @binding(1) var panorama: texture_cube<f32>;
@@ -26,5 +27,6 @@ struct Out {
return out;
}
@fragment fn fs_panorama(in: Out) -> @location(0) vec4<f32> {
return textureSample(panorama, panorama_sampler, in.direction);
let color = textureSample(panorama, panorama_sampler, in.direction);
return vec4(color.rgb, select(color.a, 1.0, HDR_WRITE_LIGHT));
}
@@ -439,17 +439,18 @@ impl<T> Resident<T> {
if list.is_empty() {
continue;
}
let min = origin((column.x, column.low, column.z), self.min_y, camera).as_vec3();
let top = origin((column.x, column.high, column.z), self.min_y, camera).as_vec3();
let max = top + Vec3::splat(16.0);
let inside = frustum.contains(min, max);
if !inside && !frustum.intersects(min, max) {
continue;
}
let ox = f64::from(column.x * 16) - camera.x;
let oz = f64::from(column.z * 16) - camera.z;
let min_x = ox as f32;
let min_z = oz as f32;
let low_oy = (f64::from(self.min_y + column.low * 16) - camera.y) as f32;
let high_oy = (f64::from(self.min_y + column.high * 16) - camera.y) as f32;
let min = Vec3::new(min_x, low_oy, min_z);
let max = Vec3::new(min_x + 16.0, high_oy + 16.0, min_z + 16.0);
let inside = frustum.contains(min, max);
if !inside && !frustum.intersects(min, max) {
continue;
}
let dx8 = ox + 8.0;
let dz8 = oz + 8.0;
let xz_sq = dx8 * dx8 + dz8 * dz8;
@@ -22,5 +22,6 @@ struct Stars {
if HDR_WRITE_LIGHT && stars.hdr_light.w != 0.0 {
return vec4(hdr_written(stars.color.rgb * b, 0.175, stars.hdr_settings, stars.hdr_shares), b);
}
if HDR_WRITE_LIGHT { return vec4(stars.color.rgb * b, b); }
return vec4(stars.color.rgb * b, 0.175);
}
@@ -79,7 +79,7 @@ pub struct Post {
/// The last TAA frame's unjittered `view_proj` and camera, `None` once a frame ran without it.
taa_previous: Option<(DMat4, DVec3)>,
taa_this_frame: TaaUniform,
light_key: Option<([f32; 4], [f32; 4], [[f32; 4]; 2])>,
light_key: Option<(bool, [f32; 4], [f32; 4], [[f32; 4]; 2])>,
}
impl Post {
@@ -263,12 +263,21 @@ impl Post {
}
}
// History stores the baked gain as well as the picture. Reject it before jitter records
// the next frame whenever that gain or the frame's representation changes.
pub(super) fn light_representation(
// History stores baked light, so a change must reject it before jitter records the next frame.
pub(super) fn baked_light(
&mut self,
key: Option<([f32; 4], [f32; 4], [[f32; 4]; 2])>,
gain: bool,
settings: [f32; 4],
shares: [f32; 4],
windows: [[f32; 4]; 2],
) {
// These uniforms do not change baked colour without gain. Keep compatibility
// history stable when only its final display settings change.
let key = Some(if gain {
(true, settings, shares, windows)
} else {
(false, [0.0; 4], [0.0; 4], [[0.0; 4]; 2])
});
if self.light_key != key {
self.taa_previous = None;
self.light_key = key;
@@ -745,13 +754,15 @@ mod tests {
}
#[test]
fn written_light_changes_reject_history_before_the_next_jitter() {
fn baked_light_changes_and_returning_from_a_pack_reject_taa_history() {
let Some((device, _)) = test_device() else {
eprintln!("TAA baked-light GPU skipped: no test device");
return;
};
let mut post = Post::new(&device, wgpu::TextureFormat::Rgba16Float);
post.taa = true;
if !post.supports_taa() {
eprintln!("TAA baked-light GPU skipped: TAA unsupported");
return;
}
let key = (
@@ -759,11 +770,11 @@ mod tests {
[0.2, 0.5, 0.6, 1.0],
[[0.1; 4], [0.4; 4]],
);
for key in [Some(key), None, Some(key)] {
post.light_representation(key);
for gain in [true, false, true] {
post.baked_light(gain, key.0, key.1, key.2);
post.jitter([32, 32], true, Mat4::IDENTITY, DVec3::ZERO);
assert_eq!(post.taa_this_frame.jitter[2], 1.0);
post.light_representation(key);
post.baked_light(gain, key.0, key.1, key.2);
post.jitter([32, 32], true, Mat4::IDENTITY, DVec3::ZERO);
assert_eq!(post.taa_this_frame.jitter[2], 0.0);
}
@@ -774,10 +785,26 @@ mod tests {
4..=7 => changed.1[field - 4] += 0.01,
_ => changed.2[(field - 8) / 4][(field - 8) % 4] += 0.01,
}
post.light_representation(Some(changed));
post.baked_light(true, changed.0, changed.1, changed.2);
post.jitter([32, 32], true, Mat4::IDENTITY, DVec3::ZERO);
assert_eq!(post.taa_this_frame.jitter[2], 1.0);
}
post.baked_light(false, key.0, key.1, key.2);
post.jitter([32, 32], true, Mat4::IDENTITY, DVec3::ZERO);
assert_eq!(post.taa_this_frame.jitter[2], 1.0);
post.baked_light(false, [0.0; 4], [0.0; 4], [[0.0; 4]; 2]);
post.jitter([32, 32], true, Mat4::IDENTITY, DVec3::ZERO);
assert_eq!(post.taa_this_frame.jitter[2], 0.0);
// A pack disables TAA even when the light settings stay the same. Its first
// world frame after the pack is dropped must not reuse the pre-pack history.
post.jitter([32, 32], false, Mat4::IDENTITY, DVec3::ZERO);
post.jitter([32, 32], true, Mat4::IDENTITY, DVec3::ZERO);
assert_eq!(post.taa_this_frame.jitter[2], 1.0);
post.jitter([32, 32], true, Mat4::IDENTITY, DVec3::ZERO);
assert_eq!(post.taa_this_frame.jitter[2], 0.0);
eprintln!(
"TAA baked-light GPU checked gain, all settings/shares/windows and the first frame after a pack"
);
}
fn test_device() -> Option<(wgpu::Device, wgpu::Queue)> {
@@ -0,0 +1,697 @@
//! A native mod's world warp ([`native_world_warp`](super::native_render::native_world_warp),
//! docs/native-mods.md "World warps"): WGSL that every terrain vertex, far terrain's tiles
//! included, and entity vertex goes through, so a mod can bend the drawn world itself (its real
//! blocks, light and depth, seen from any side) rather than a picture of it.
//!
//! The stock shaders hold only comment markers where a warp goes in (terrain.wgsl's
//! `[world warp ...]`, entity.wgsl's and static_entity.wgsl's): a frame without a warp draws
//! with exactly the shaders it always did. For a warp, copies of them get the markers filled,
//! the declarations of `world_warp.wgsl`, the mod's WGSL and, for the terrain, the pixel stage of
//! `world_warp_fragment.wgsl` with the mod's `world_fragment` or [`DEFAULT_FRAGMENT`]; the
//! pipelines built from them read the warp's uniform at group 2.
//!
//! Those pipelines don't cull back faces (a warp may turn a face over), and a frame drawn with
//! them leaves nothing out by where it was (`Renderer::render`): what is out of view, hidden or
//! facing away may be brought into view. It also keeps to the per-section draws, which these
//! pipelines are built for.
use super::native_render::WorldWarp;
use super::{SolidPipeline, TERRAIN_ATTRIBUTES, TerrainVertex, Vertex};
use glam::DVec3;
use std::sync::Arc;
/// The line in a warp's source after which the terrain's pixel stage begins.
const FRAGMENTS: &str = "// [fragments]";
/// The pixel stage of a warp without one: the terrain as the game draws it, less faces it cut.
pub const DEFAULT_FRAGMENT: &str = "fn world_fragment(f: Fragment) -> Painted {
if f.cut > 0.0 { return Painted(vec4(0.0, 0.0, 0.0, -1.0), 1.0); }
return Painted(sprite(f.uv, f.layer) * f.color, 1.0);
}
";
/// What terrain.wgsl's markers are filled with: the warp's two values in `TerrainOut`, and how
/// they cross to the pixel stage in `TerrainWire`, after its five locations.
const VARYINGS: &str = "warp_home: vec4<f32>,
warp_at: vec3<f32>,";
const WIRE: &str = "@location(5) warp_home: vec4<f32>,
@location(6) warp_at: vec3<f32>,";
const TO_WIRE: &str = "w.warp_home = o.warp_home;
w.warp_at = o.warp_at;";
const FROM_WIRE: &str = "o.warp_home = w.warp_home;
o.warp_at = w.warp_at;";
const VERTEX: &str = "let warp_home = position + origin;
let warped = world_warp(warp_home, origin);
let pos = warped.pos;";
const VERTEX_OUT: &str = "out.color = out.color * vec4(max(warped.tint, vec3(0.0)), 1.0);
out.warp_home = vec4(warp_home, select(0.0, 1.0, warped.tint.r < 0.0));
out.warp_at = pos;";
/// And entity.wgsl's and static_entity.wgsl's: an entity's vertex is its own `section`; a block
/// entity's is its section's corner.
const ENTITY: &str = "let at = world_warp(position, position).pos;";
const STATIC_ENTITY: &str =
"let section_corner = vec3<f32>(block_delta) - static_entity.camera_fraction.xyz;
let camera_relative = world_warp(section_corner + position, section_corner).pos;";
/// The plainest warp, for the tests of the shaders it makes.
#[cfg(test)]
pub(super) const IDENTITY: &str = "fn world_warp(pos: vec3<f32>, section: vec3<f32>) -> Warped { return Warped(pos, vec3(1.0)); }\n";
/// `shader` with what stands between the markers `// [name]` and `// [end name]` replaced.
fn fill(shader: &str, name: &str, with: &str) -> Result<String, String> {
let (from, to) = (format!("// [{name}]"), format!("// [end {name}]"));
let a = shader
.find(&from)
.ok_or_else(|| format!("no `{from}` in the shader"))?;
let b = shader[a..]
.find(&to)
.ok_or_else(|| format!("no `{to}` in the shader"))?
+ a;
Ok(format!("{}{with}{}", &shader[..a], &shader[b + to.len()..]))
}
/// A warp's source split into what every warped shader takes and the terrain's pixel stage.
fn split(source: &str) -> (&str, Option<&str>) {
match source.find(FRAGMENTS) {
Some(at) => (&source[..at], Some(&source[at + FRAGMENTS.len()..])),
None => (source, None),
}
}
/// The warp's copy of terrain.wgsl.
pub(super) fn terrain_shader(source: &str) -> Result<String, String> {
let (vertex, fragment) = split(source);
let fragment = fragment
.filter(|f| f.contains("fn world_fragment"))
.unwrap_or(DEFAULT_FRAGMENT);
let shader = fill(&super::terrain_shader(), "world warp varyings", VARYINGS)?;
let shader = fill(&shader, "world warp wire", WIRE)?;
let shader = fill(&shader, "world warp to wire", TO_WIRE)?;
let shader = fill(&shader, "world warp from wire", FROM_WIRE)?;
let shader = fill(&shader, "world warp vertex", VERTEX)?;
let shader = fill(&shader, "world warp out", VERTEX_OUT)?;
Ok(format!(
"{shader}\n{}\n{vertex}\n{}\n{fragment}",
include_str!("world_warp.wgsl"),
include_str!("world_warp_fragment.wgsl"),
))
}
/// The warp's copy of entity.wgsl with static_entity.wgsl, for every entity pipeline it draws.
pub(super) fn entity_shader(source: &str) -> Result<String, String> {
let (vertex, _) = split(source);
let shader = fill(&super::static_entity_shader(), "world warp entity", ENTITY)?;
let shader = fill(&shader, "world warp static entity", STATIC_ENTITY)?;
Ok(format!(
"{shader}\n{}\n{vertex}",
include_str!("world_warp.wgsl")
))
}
fn validate(source: &str) -> Result<(), String> {
let module = naga::front::wgsl::parse_str(source).map_err(|e| e.emit_to_string(source))?;
naga::valid::Validator::new(
naga::valid::ValidationFlags::all(),
naga::valid::Capabilities::all(),
)
.validate(&module)
.map_err(|e| e.emit_to_string(source))?;
Ok(())
}
/// The warp's uniform (`world_warp.wgsl`'s `WorldWarpUniform`): the mod's sixteen floats, then
/// the camera split into f32 and what f32 left out of it, with the frame's partial tick.
#[repr(C)]
#[derive(Clone, Copy, Debug, Default, PartialEq, bytemuck::Pod, bytemuck::Zeroable)]
struct Uniform {
params: [[f32; 4]; 4],
camera: [f32; 4],
camera_rest: [f32; 4],
}
fn uniform(warp: &WorldWarp, camera: DVec3, partial_tick: f32) -> Uniform {
let (params, _) = warp.params.as_chunks::<4>();
let params: [[f32; 4]; 4] = params.try_into().expect("sixteen floats");
let at = camera.as_vec3();
let rest = camera - at.as_dvec3();
Uniform {
params,
camera: [at.x, at.y, at.z, partial_tick],
camera_rest: [rest.x as f32, rest.y as f32, rest.z as f32, 0.0],
}
}
/// What the warp's pipelines are built with: the renderer's own layouts and formats.
pub(super) struct Kit {
/// terrain.wgsl's group 0, and with flat draws the per-section draws' page group 1
/// (`vs_section` reads its vertices from it).
pub terrain: wgpu::BindGroupLayout,
pub pages: Option<wgpu::BindGroupLayout>,
/// entity.wgsl's group 0 and static_entity.wgsl's group 1.
pub entity: wgpu::BindGroupLayout,
pub static_entity: wgpu::BindGroupLayout,
pub format: wgpu::TextureFormat,
pub sprite_constants: [(&'static str, f64); 2],
/// Whether the display offers HDR output: the solid terrain is built lit by it too.
pub hdr_output: bool,
}
/// The warp's pipelines, in place of the renderer's while it is on.
pub(super) struct Pipelines {
pub solid: SolidPipeline,
pub translucent: wgpu::RenderPipeline,
pub entities: wgpu::RenderPipeline,
pub static_entities: wgpu::RenderPipeline,
pub translucent_items: wgpu::RenderPipeline,
pub text: wgpu::RenderPipeline,
pub entity_shadows: wgpu::RenderPipeline,
pub hdr_mark: wgpu::RenderPipeline,
pub hdr_entity_mark: wgpu::RenderPipeline,
}
/// The warp's uniform, and what binds it.
struct Bound {
layout: wgpu::BindGroupLayout,
buffer: wgpu::Buffer,
group: wgpu::BindGroup,
}
/// The renderer's world warp: nothing made until a mod gives one.
pub(super) struct WorldWarps {
kit: Kit,
bound: Option<Bound>,
built: Option<(Arc<str>, Pipelines)>,
/// The last source that didn't compile, so it is tried once.
refused: Option<Arc<str>>,
/// Whether the log said a warp went undrawn under a shader pack drawing the terrain.
told_undrawable: bool,
}
impl WorldWarps {
pub fn new(kit: Kit) -> Self {
Self {
kit,
bound: None,
built: None,
refused: None,
told_undrawable: false,
}
}
/// Readies this frame's warp: builds its pipelines when its source is new and writes its
/// uniform. Whether the frame draws with it: not when it is off, didn't compile, or the
/// frame can't take it (`drawable`: a shader pack draws the terrain).
pub fn frame(
&mut self,
device: &wgpu::Device,
uploads: &mut super::upload::Uploads,
encoder: &mut wgpu::CommandEncoder,
warp: &WorldWarp,
camera: DVec3,
partial_tick: f32,
drawable: bool,
) -> bool {
let Some(source) = &warp.source else {
return false;
};
let same = |s: &Arc<str>| Arc::ptr_eq(s, source) || **s == **source;
if !self.built.as_ref().is_some_and(|(s, _)| same(s)) {
if self.refused.as_ref().is_some_and(same) {
return false;
}
match self.build(device, source) {
Ok(pipelines) => self.built = Some((source.clone(), pipelines)),
Err(e) => {
log::warn!("Horizon: a mod's world warp did not compile: {e}");
self.refused = Some(source.clone());
self.built = None;
return false;
}
}
}
if !warp.on {
return false;
}
if !drawable {
if !self.told_undrawable {
log::info!(
"Horizon: a mod's world warp isn't drawn while a shader pack draws the terrain"
);
self.told_undrawable = true;
}
return false;
}
let bound = self.bound(device);
uploads.buffer(
encoder,
&bound.buffer,
0,
bytemuck::bytes_of(&uniform(warp, camera, partial_tick)),
);
true
}
/// The pipelines of the warp `frame` readied.
pub fn pipelines(&self) -> Option<&Pipelines> {
self.built.as_ref().map(|(_, p)| p)
}
/// The warp's group 2, once a frame has drawn with it.
pub fn group(&self) -> Option<&wgpu::BindGroup> {
self.bound.as_ref().map(|b| &b.group)
}
fn bound(&mut self, device: &wgpu::Device) -> &Bound {
self.bound.get_or_insert_with(|| bind(device))
}
fn build(&mut self, device: &wgpu::Device, source: &str) -> Result<Pipelines, String> {
let started = std::time::Instant::now();
let terrain = terrain_shader(source)?;
let entity = entity_shader(source)?;
validate(&terrain)?;
validate(&entity)?;
// Validation failures above are the mod's; past here wgpu's are the game's.
let warp = &self.bound.get_or_insert_with(|| bind(device)).layout;
let kit = &self.kit;
let layout = |label, groups: &[Option<&wgpu::BindGroupLayout>]| {
device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some(label),
bind_group_layouts: groups,
..Default::default()
})
};
let terrain_layout = layout(
"Warped terrain layout",
&[Some(&kit.terrain), None, Some(warp)],
);
let pages_layout = kit.pages.as_ref().map(|pages| {
layout(
"Warped terrain layout (pulled)",
&[Some(&kit.terrain), Some(pages), Some(warp)],
)
});
let entity_layout = layout(
"Warped entity layout",
&[Some(&kit.entity), None, Some(warp)],
);
let static_layout = layout(
"Warped static block-entity layout",
&[Some(&kit.entity), Some(&kit.static_entity), Some(warp)],
);
let module = |label, source: String| {
device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some(label),
source: wgpu::ShaderSource::Wgsl(source.into()),
})
};
let terrain = module("Warped terrain", terrain);
let entity = module("Warped entities", entity);
let instance_attributes = wgpu::vertex_attr_array![5=>Float32x4];
let instances = wgpu::VertexBufferLayout {
array_stride: 16,
step_mode: wgpu::VertexStepMode::Instance,
attributes: &instance_attributes,
};
let vertices = wgpu::VertexBufferLayout {
array_stride: std::mem::size_of::<TerrainVertex>() as u64,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &TERRAIN_ATTRIBUTES,
};
let buffered = [Some(vertices), Some(instances.clone())];
let pulled = [None, Some(instances)];
// As gfx.rs's: a float frame takes light where others take marks.
let write_light = (
"HDR_WRITE_LIGHT",
f64::from(u8::from(kit.format == super::LINEAR_FRAME)),
);
let lit = [
kit.sprite_constants[0],
kit.sprite_constants[1],
write_light,
];
let plain = [
kit.sprite_constants[0],
kit.sprite_constants[1],
("HDR_LIGHTING", 0.0),
write_light,
];
let depth = |write| wgpu::DepthStencilState {
format: wgpu::TextureFormat::Depth32Float,
depth_write_enabled: Some(write),
depth_compare: Some(wgpu::CompareFunction::GreaterEqual),
stencil: Default::default(),
bias: Default::default(),
};
// As gfx.rs's terrain pipelines, without back-face culling.
let terrain_pipeline = |label,
layout: &wgpu::PipelineLayout,
vertex,
buffers: &[Option<wgpu::VertexBufferLayout<'_>>],
fragment,
constants: &[(&str, f64)],
blend| {
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some(label),
layout: Some(layout),
vertex: wgpu::VertexState {
module: &terrain,
entry_point: Some(vertex),
compilation_options: Default::default(),
buffers,
},
primitive: wgpu::PrimitiveState::default(),
depth_stencil: Some(depth(true)),
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &terrain,
entry_point: Some(fragment),
compilation_options: wgpu::PipelineCompilationOptions {
constants,
..Default::default()
},
targets: &[Some(wgpu::ColorTargetState {
format: kit.format,
blend,
write_mask: wgpu::ColorWrites::ALL,
})],
}),
multiview_mask: None,
cache: None,
})
};
// The per-section solid draws read the arena page with flat draws (`vs_section`), and
// vertex buffers without.
let (solid_layout, solid_vertex, solid_buffers): (_, _, &[_]) = match &pages_layout {
Some(pages) => (pages, "vs_section", &pulled),
None => (&terrain_layout, "vs_terrain", &buffered),
};
let solid = |constants: &[(&str, f64)]| {
terrain_pipeline(
"Warped terrain solid",
solid_layout,
solid_vertex,
solid_buffers,
"fs_warp_solid",
constants,
None,
)
};
let solid = SolidPipeline {
plain: solid(&plain),
hdr: kit.hdr_output.then(|| solid(&lit)),
};
let translucent = terrain_pipeline(
"Warped terrain translucent",
&terrain_layout,
"vs_terrain",
&buffered,
"fs_warp_translucent",
&lit,
Some(wgpu::BlendState::ALPHA_BLENDING),
);
let entity_attributes = wgpu::vertex_attr_array![0=>Float32x3,1=>Float32x2,2=>Uint32,3=>Unorm8x4,4=>Uint8x4,5=>Snorm8x4,6=>Float32x2];
let entity_vertices = wgpu::VertexBufferLayout {
array_stride: std::mem::size_of::<Vertex>() as u64,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &entity_attributes,
};
let origin_attributes = wgpu::vertex_attr_array![7=>Sint32x3];
let static_buffers = [
Some(entity_vertices.clone()),
Some(wgpu::VertexBufferLayout {
array_stride: std::mem::size_of::<[i32; 3]>() as u64,
step_mode: wgpu::VertexStepMode::Instance,
attributes: &origin_attributes,
}),
];
let entity_buffers = [Some(entity_vertices)];
// As gfx.rs's `entity_pipeline_with`, for the pipelines a warped frame swaps.
let entity_pipeline = |look: &EntityLook| {
let (layout, vertex, buffers): (_, _, &[_]) = if look.static_mesh {
(&static_layout, "vs_static_entity", &static_buffers)
} else {
(&entity_layout, "vs_entity", &entity_buffers)
};
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some(look.label),
layout: Some(layout),
vertex: wgpu::VertexState {
module: &entity,
entry_point: Some(vertex),
compilation_options: Default::default(),
buffers,
},
primitive: wgpu::PrimitiveState {
cull_mode: look.cull,
..Default::default()
},
depth_stencil: Some(wgpu::DepthStencilState {
bias: look.bias,
..depth(look.depth_write)
}),
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &entity,
entry_point: Some(look.fragment),
compilation_options: wgpu::PipelineCompilationOptions {
constants: &[write_light],
..Default::default()
},
targets: &[Some(wgpu::ColorTargetState {
format: kit.format,
blend: look.blend,
write_mask: look.write_mask,
})],
}),
multiview_mask: None,
cache: None,
})
};
let pipelines = Pipelines {
solid,
translucent,
entities: entity_pipeline(&ENTITIES),
static_entities: entity_pipeline(&STATIC_ENTITIES),
translucent_items: entity_pipeline(&TRANSLUCENT_ITEMS),
text: entity_pipeline(&TEXT),
entity_shadows: entity_pipeline(&ENTITY_SHADOWS),
hdr_mark: entity_pipeline(&HDR_MARK),
hdr_entity_mark: entity_pipeline(&HDR_ENTITY_MARK),
};
log::info!(
"Horizon: a mod's world warp built in {} ms",
started.elapsed().as_millis()
);
Ok(pipelines)
}
}
fn bind(device: &wgpu::Device) -> Bound {
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("World warp"),
entries: &[wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
}],
});
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("World warp"),
size: std::mem::size_of::<Uniform>() as u64,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("World warp"),
layout: &layout,
entries: &[wgpu::BindGroupEntry {
binding: 0,
resource: buffer.as_entire_binding(),
}],
});
Bound {
layout,
buffer,
group,
}
}
/// How one of the entity pipelines a warped frame swaps draws: as gfx.rs builds the stock one
/// of the same name.
struct EntityLook {
label: &'static str,
static_mesh: bool,
cull: Option<wgpu::Face>,
depth_write: bool,
bias: wgpu::DepthBiasState,
blend: Option<wgpu::BlendState>,
fragment: &'static str,
write_mask: wgpu::ColorWrites,
}
const NO_BIAS: wgpu::DepthBiasState = wgpu::DepthBiasState {
constant: 0,
slope_scale: 0.0,
clamp: 0.0,
};
const ENTITIES: EntityLook = EntityLook {
label: "Warped entities",
static_mesh: false,
cull: None,
depth_write: true,
bias: wgpu::DepthBiasState {
constant: 128,
slope_scale: 0.0,
clamp: 0.0,
},
blend: Some(wgpu::BlendState::ALPHA_BLENDING),
fragment: "fs_entity",
write_mask: wgpu::ColorWrites::ALL,
};
const STATIC_ENTITIES: EntityLook = EntityLook {
label: "Warped static block entities",
static_mesh: true,
bias: NO_BIAS,
blend: None,
fragment: "fs_entity_unmarked",
..ENTITIES
};
const TRANSLUCENT_ITEMS: EntityLook = EntityLook {
label: "Warped translucent items",
bias: NO_BIAS,
..ENTITIES
};
const TEXT: EntityLook = EntityLook {
label: "Warped polygon offset text",
cull: Some(wgpu::Face::Back),
bias: wgpu::DepthBiasState {
constant: 10,
slope_scale: 1.0,
clamp: 0.0,
},
fragment: "fs_entity_unmarked",
..ENTITIES
};
const ENTITY_SHADOWS: EntityLook = EntityLook {
label: "Warped entity shadows",
cull: Some(wgpu::Face::Back),
depth_write: false,
bias: wgpu::DepthBiasState {
constant: 128,
slope_scale: 0.015,
clamp: 0.0,
},
fragment: "fs_entity_shadow",
..ENTITIES
};
const HDR_MARK: EntityLook = EntityLook {
label: "Warped HDR particle mark",
depth_write: false,
bias: NO_BIAS,
blend: Some(wgpu::BlendState::REPLACE),
fragment: "fs_hdr_mark",
write_mask: wgpu::ColorWrites::ALPHA,
..ENTITIES
};
/// `HDR_MARK` at the entities' depth bias, for what they drew.
const HDR_ENTITY_MARK: EntityLook = EntityLook {
label: "Warped HDR entity mark",
bias: ENTITIES.bias,
..HDR_MARK
};
#[cfg(test)]
mod tests {
use super::*;
const BEND: &str = "fn world_warp(pos: vec3<f32>, section: vec3<f32>) -> Warped {
let lift = sin(pos.x * 0.1 + warp.params[0].x) * warp.params[0].y + section.y * 0.0;
return Warped(pos + vec3(0.0, lift, 0.0), vec3(1.0));
}
";
#[test]
fn a_warp_without_fragments_draws_the_terrain_as_the_game_does() {
let terrain = terrain_shader(BEND).unwrap();
validate(&terrain).unwrap();
assert!(terrain.contains(DEFAULT_FRAGMENT));
let entity = entity_shader(BEND).unwrap();
validate(&entity).unwrap();
}
#[test]
fn a_warps_own_fragments_replace_the_default_and_stay_out_of_the_entities() {
let source = format!(
"{BEND}{FRAGMENTS}
fn world_fragment(f: Fragment) -> Painted {{
if !f.front || f.light.x < 1.0 {{ return Painted(vec4(0.0, 0.0, 0.0, -1.0), 1.0); }}
let far = length(f.home - f.at) + f.cut;
return Painted(sprite(f.uv * 2.0, f.layer) * f.color, 1.0 - min(far, 1.0));
}}
"
);
let terrain = terrain_shader(&source).unwrap();
validate(&terrain).unwrap();
assert!(!terrain.contains(DEFAULT_FRAGMENT));
let entity = entity_shader(&source).unwrap();
validate(&entity).unwrap();
assert!(!entity.contains("world_fragment"));
}
#[test]
fn the_stock_shaders_hold_nothing_of_a_warp() {
for (name, shader) in [
("terrain", super::super::terrain_shader()),
("entity", super::super::static_entity_shader()),
] {
validate(&shader).unwrap();
for word in ["world_warp(", "warp_home", "Warped", "group(2)"] {
assert!(!shader.contains(word), "{name}.wgsl holds {word}");
}
}
}
#[test]
fn every_marker_is_found() {
let terrain = terrain_shader(BEND).unwrap();
let entity = entity_shader(BEND).unwrap();
for shader in [&terrain, &entity] {
assert!(!shader.contains("// [world warp"), "a marker left unfilled");
}
assert!(terrain.contains("let warped = world_warp(warp_home, origin);"));
assert!(entity.contains("let at = world_warp(position, position).pos;"));
assert!(entity.contains("world_warp(section_corner + position, section_corner)"));
assert_eq!(
fill("a // [x] b // [end x] c", "x", "B"),
Ok("a B c".to_owned())
);
assert!(fill("a // [x] b", "x", "B").is_err());
}
#[test]
fn a_warp_that_does_not_compile_is_refused_with_naga_s_reason() {
let terrain =
terrain_shader("fn world_warp(pos: vec3<f32>) -> Warped { return 1; }").unwrap();
let e = validate(&terrain).unwrap_err();
assert!(e.contains("world_warp"), "{e}");
}
#[test]
fn the_uniform_keeps_the_camera_exact_far_out() {
let mut warp = WorldWarp::default();
warp.params[5] = 2.0;
let camera = DVec3::new(29_999_984.3, 70.25, -12_345_678.9);
let u = uniform(&warp, camera, 0.25);
assert_eq!(u.params[1][1], 2.0);
assert_eq!(u.camera[3], 0.25);
for i in 0..3 {
let whole = f64::from(u.camera[i]) + f64::from(u.camera_rest[i]);
assert!((whole - camera[i]).abs() < 1e-6, "{whole} {}", camera[i]);
}
assert_eq!(std::mem::size_of::<Uniform>(), 96);
}
}
@@ -0,0 +1,21 @@
// A native mod's world warp (gfx/world_warp.rs, docs/native-mods.md "World warps"): what the
// game declares around the mod's WGSL in the warp's own copies of terrain.wgsl and entity.wgsl.
// None of it is in the shaders a frame without a warp draws with.
// What `world_warp` makes of a vertex: where to draw it, from the camera, and what to multiply
// its colour by (terrain only; red under 0 marks its faces cut: `Fragment.cut`).
struct Warped {
pos: vec3<f32>,
tint: vec3<f32>,
}
struct WorldWarpUniform {
// `WorldWarp::params`.
params: array<vec4<f32>, 4>,
// xyz: the camera in the world as f32; `camera_rest.xyz` what f32 left out of it, so
// `(p - warp.camera.xyz) - warp.camera_rest.xyz` is the world's point `p` from the camera,
// exact far from the world's origin. w: the frame's partial tick, 0 to 1, for motion
// smoother than the ticks `params` are set in.
camera: vec4<f32>,
camera_rest: vec4<f32>,
}
@group(2) @binding(0) var<uniform> warp: WorldWarpUniform;
@@ -0,0 +1,70 @@
// A world warp's terrain pixels (gfx/world_warp.rs): added to the warp's copy of terrain.wgsl
// only. `world_fragment` is the mod's if its source has one after `// [fragments]`, else
// `DEFAULT_FRAGMENT` in gfx/world_warp.rs (the terrain as the game draws it).
// A pixel of the land as `world_fragment` gets it.
struct Fragment {
// Where this point of the world really is, from the camera: `world_warp`'s `pos`.
home: vec3<f32>,
// Where it is drawn, from the camera.
at: vec3<f32>,
// `sprite(uv, layer)` is the block's texture here.
uv: vec2<f32>,
layer: u32,
// The vertices' colour: biome tint, light, ambient occlusion and the warp's tint.
color: vec4<f32>,
// Sky and block light, 0 to 15.
light: vec2<f32>,
// Over 0 where a vertex of the face had a tint with red under 0.
cut: f32,
// False on a face's back: the warp's pipelines don't cull, so both sides draw.
front: bool,
}
// What `world_fragment` makes of it: a colour (alpha under 0 leaves the pixel out) and how much
// of the game's fog it takes, 0 to 1.
struct Painted {
color: vec4<f32>,
fog: f32,
}
fn warp_fragment(in: TerrainOut, front: bool) -> Fragment {
return Fragment(in.warp_home.xyz, in.warp_at, in.uv, in.layer & 0x7fffu, in.color,
in.scene.yx, in.warp_home.w, front);
}
// As `solid_color`, with the mod's colour in place of the texture's.
@fragment fn fs_warp_solid(wire: TerrainWire, @builtin(front_facing) front: bool) -> @location(0) vec4<f32> {
let in = from_wire(wire);
let dx = dpdx(in.local);
let dy = dpdy(in.local);
if HDR_LIGHTING && globals.hdr_light.z != 0.0 {
hdr_normal = normalize(cross(dy, dx));
}
// (far terrain's tiles and the sections replacing them still cross-fade)
if faded(in.fade, in.local, dx, dy) { discard; }
let p = world_fragment(warp_fragment(in, front));
if p.color.a < 0.0 { discard; }
let color = mix(globals.fog_color * vec4(1.0, 1.0, 1.0, p.color.a), p.color, in.visibility);
// Bit 15 of the layer marks opaque leaves (forceOpaque): no alpha cutout.
if color.a < 0.5 && (in.layer & 0x8000u) == 0u { discard; }
let finished = solid_finish_at(in, color, color, dpdx(in.uv), dpdy(in.uv));
return solid_light(vec4(mix(color.rgb, finished.rgb, p.fog), finished.a));
}
// As `fs_translucent`.
@fragment fn fs_warp_translucent(wire: TerrainWire, @builtin(front_facing) front: bool) -> @location(0) vec4<f32> {
let in = from_wire(wire);
if HDR_WRITE_LIGHT && globals.hdr_light.w != 0.0 {
hdr_normal = normalize(cross(dpdy(in.local), dpdx(in.local)));
}
let p = world_fragment(warp_fragment(in, front));
if p.color.a < 0.0 { discard; }
let color = mix(globals.fog_color * vec4(1.0, 1.0, 1.0, p.color.a), p.color, in.visibility);
if color.a < 0.01 { discard; }
let fogged = apply_fog(color, in.spherical, in.cylindrical);
let finished = vec4(mix(color.rgb, fogged.rgb, p.fog), color.a);
if HDR_WRITE_LIGHT && globals.hdr_light.w != 0.0 {
let fog = max(linear_fog(in.spherical, globals.fog.x, globals.fog.y),
linear_fog(in.cylindrical, globals.fog.z, globals.fog.w)) * globals.fog_color.a;
let mark = hdr_scene_alpha(mix(hdr_scene(in.scene.x, in.scene.y, hdr_facing()), globals.hdr_light.y, fog));
return vec4(hdr_written(finished.rgb, mark, globals.hdr_settings, globals.hdr_shares), finished.a);
}
return finished;
}
@@ -2394,6 +2394,19 @@ mod tests {
assert_eq!(s.horizon.entity_culling, EntityCullingMode::Off);
}
#[test]
fn left_and_right_step_a_focused_choice_back_and_on() {
let mut s = Settings::default();
let mut ui = ui();
row(&mut ui, &s, Item::Horizon(H::EntityCulling));
focus_on(&mut ui, Item::Horizon(H::EntityCulling));
assert_eq!(key(&mut ui, &mut s, K::ArrowLeft), [CLICK, CHANGED]);
assert_eq!(s.horizon.entity_culling, EntityCullingMode::Asynchronous);
assert_eq!(key(&mut ui, &mut s, K::ArrowRight), [CLICK, CHANGED]);
assert_eq!(s.horizon.entity_culling, EntityCullingMode::Off);
assert_eq!(ui.focused_row(), Some(Item::Horizon(H::EntityCulling)));
}
#[test]
fn entity_culling_precheck_slider_is_only_live_for_async() {
let item = Item::Horizon(H::EntityCullingPrecheck);
+1 -11
View File
@@ -34,7 +34,7 @@ struct Params {
// Horizon's logo, 1 inside, 0 outside, for calibration's test pictures.
@group(0) @binding(3) var logo: texture_2d<f32>;
// The float frame with HUD coverage (Hdr::layered): the frame keeps the world as light
// (fs_resolve), the HUD draws on a picture of its own (`hud`, fs_keep), and `coverage` says how
// directly from its writers; the HUD draws on a picture of its own (`hud`, fs_keep), and `coverage` says how
// much of the world it hides there (gfx/hud_coverage.rs).
@group(0) @binding(4) var coverage: texture_2d<f32>;
@group(0) @binding(5) var hud: texture_2d<f32>;
@@ -211,16 +211,6 @@ fn display(light: vec3<f32>) -> vec4<f32> {
return display(light);
}
// Hdr::resolve, after the world and the hand: the marks they left (`world` holds the frame as
// they drew it) become light in the frame, in gamma as the frame stores it, alpha 1, past the
// peak if they lift it there (the tone curve eases it in). What draws after it (camera overlays,
// the menu blur) draws on light, and nothing is lifted later.
@fragment fn fs_resolve(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> {
let w = frame_texel(textureLoad(world, vec2<i32>(pos.xy), 0));
let shows = params.world > 0.5 && params.peak > 1.0;
return vec4(pow(lit(w.rgb, w.a, shows, false).light, vec3(1.0 / 2.2)), 1.0);
}
// The tone curve's knee: up to it light stays as it is. Where HDR lighting eased into the peak
// before the curve (`lit`), 60% of it, so bright textures keep their detail; at least paper white.
fn tone_knee(headroom: f32) -> f32 {
+35 -1
View File
@@ -1355,7 +1355,15 @@ impl HudAtlas {
.filter(|&size| size != (w, h));
let flags = mcmeta.as_deref().and_then(TextureFlags::from_mcmeta);
let animated = frames && animation.is_some();
let (gw, gh) = gui_size.unwrap_or((w, h));
// The cap below keeps GUI textures small; a particle sprite isn't capped, as
// vanilla's particle atlas stitches any size (packs draw light shafts and fog
// banks from 1024-wide textures).
let particle = name.starts_with("particle/") || name.contains(":particle/");
let (gw, gh) = if particle {
(0, 0)
} else {
gui_size.unwrap_or((w, h))
};
let maps = read_maps
.then(|| {
let stem = path.file_stem()?.to_str()?;
@@ -3427,6 +3435,32 @@ mod tests {
assert!(!atlas.sprites.contains_key("gems:item/ruby"));
fs::remove_dir_all(&root).unwrap();
}
/// A pack's particle sprite of any size packs whole, as vanilla's particle atlas stitches
/// it (a light shaft drawn from a 1024-wide texture); the cap on GUI textures stays.
#[test]
fn large_particle_sprites_pack_and_large_gui_textures_dont() {
let root =
std::env::temp_dir().join(format!("horizon-atlas-big-particle-{}", std::process::id()));
let _ = fs::remove_dir_all(&root);
let rays = root.join("assets/mypack/textures/particle/light_rays");
fs::create_dir_all(&rays).unwrap();
write_strip(
&rays.join("02.png"),
1024,
1024,
&[[250, 240, 200, 255]],
None,
);
let gui = root.join("assets/minecraft/textures/gui");
fs::create_dir_all(&gui).unwrap();
write_strip(&gui.join("huge.png"), 2048, 64, &[[1, 2, 3, 255]], None);
let atlas = HudAtlas::build(&root).unwrap();
let rect = atlas.sprites["mypack:particle/light_rays/02"];
assert_eq!(rect.texels((atlas.width, atlas.height)), (1024, 1024));
assert_eq!(texel_at(&atlas, rect, 512, 512), [250, 240, 200, 255]);
assert!(!atlas.sprites.contains_key("gui/huge"));
fs::remove_dir_all(&root).unwrap();
}
/// OptiFine random entity variants outside `textures/` pack under the names
/// `RandomTextures` gives them; one beside its texture packs once, under its own.
#[test]
@@ -12,8 +12,6 @@ impl Gpu {
let instance = crate::gfx::test_gpu::instance();
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).ok()?;
if !hud_coverage::supported(
(false, true, true),
true,
adapter.limits().max_color_attachments,
adapter.get_downlevel_capabilities().flags,
adapter.get_texture_format_features(hud_coverage::FORMAT),
@@ -0,0 +1,11 @@
//! The HUD kit: small immediate-mode pieces Horizon's own overlays share, so they fit around
//! vanilla's HUD and look alike (`docs/ui-kit.md`). Nothing here reaches gameplay; the game
//! builds the inputs (`Game::hud_room`) and the overlays draw with them.
//!
//! - `room`: what of a view an overlay may use, clear of the boss bars.
//! - `plate`: the box behind a notice's text.
//!
//! Vanilla's own display lists (`hud_layout`, `screen_draw`) never use the kit: they stay
//! exactly vanilla's.
pub mod plate;
pub mod room;
@@ -0,0 +1,64 @@
//! Plates: the boxes Horizon's HUD overlays draw behind their text, one place for each look so
//! overlays that mean the same thing look the same.
//!
//! A plate is drawn first, then the overlay's text and glyphs over it. Only the kinds the
//! overlays use today are here; each keeps the colours its overlay always had. Today that is
//! one, shared by the controller notice and splitscreen's prompts.
//!
//! ```
//! use horizon_client_render::hud_kit::plate::Plate;
//!
//! // A notice's box: a dark fill and a faint 1 px edge inside its area.
//! assert_eq!(Plate::Notice.fill(), 0xD200_0000);
//! assert_eq!(Plate::Notice.edge(), Some(0x5AFF_FFFF));
//! ```
//!
//! In a draw: `plate::draw(batch, atlas, Plate::Notice, [x, y, w, h])`, the area in GUI px.
use crate::hud::{HudAtlas, HudBatch, rgba};
/// A plate's look.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Plate {
/// Something to read now: controller notices and splitscreen's prompts. A dark fill with a
/// faint edge.
Notice,
}
impl Plate {
/// The fill colour as `0xAARRGGBB`.
pub fn fill(self) -> u32 {
match self {
Self::Notice => 0xD200_0000,
}
}
/// The 1 GUI px edge's colour as `0xAARRGGBB`, drawn inside the area; `None` for none.
pub fn edge(self) -> Option<u32> {
match self {
Self::Notice => Some(0x5AFF_FFFF),
}
}
}
/// `[a, r, g, b]` of `0xAARRGGBB` as the batch's colour.
fn colour(argb: u32) -> u32 {
let [a, r, g, b] = argb.to_be_bytes();
rgba(r, g, b, a)
}
/// Draws `plate` over `[x, y, w, h]` (GUI px): its fill, then its edge inside the area.
pub fn draw(batch: &mut HudBatch, atlas: &HudAtlas, plate: Plate, [x, y, w, h]: [f32; 4]) {
batch.fill(atlas, x, y, w, h, colour(plate.fill()));
if let Some(edge) = plate.edge() {
let edge = colour(edge);
for [ex, ey, ew, eh] in [
[x, y, w, 1.0],
[x, y + h - 1.0, w, 1.0],
[x, y, 1.0, h],
[x + w - 1.0, y, 1.0, h],
] {
batch.fill(atlas, ex, ey, ew, eh, edge);
}
}
}
@@ -0,0 +1,137 @@
//! Room: what of a view Horizon's own HUD overlays may use. Vanilla's HUD elements are
//! keep-outs; an overlay asks the room where it fits instead of assuming a fixed spot, so it
//! doesn't cover them. Today the only keep-out is the boss bars at the top centre.
//!
//! The room is built once a frame from what the HUD shows (`Game::hud_room` in gameplay). With
//! nothing showing it is empty, and every overlay stays exactly where it always was.
//!
//! All sizes are GUI px, from the view's top left. An area is `[x, y, w, h]`.
//!
//! ```
//! use horizon_client_render::hud_kit::room::{Keep, Room};
//! use horizon_client_render::hud_overlays::boss_bars_bottom;
//!
//! // A centred notice wants y = 18.
//! let empty = Room::new(320.0, 240.0);
//! assert_eq!(empty.below_top(18.0), 18.0);
//! // One boss bar ends at y 17: no overlap, so the notice stays.
//! let one = Room::new(320.0, 240.0).with(Keep::BossBars, [69.0, 0.0, 182.0, 17.0]);
//! assert_eq!(one.below_top(18.0), 18.0);
//! // Two end at y 36: the notice goes below them, a gap past.
//! let bars = boss_bars_bottom(240, 2) as f32;
//! let two = Room::new(320.0, 240.0).with(Keep::BossBars, [69.0, 0.0, 182.0, bars]);
//! assert_eq!(two.below_top(18.0), bars + 2.0);
//! ```
/// An area in GUI px: `[x, y, w, h]` from the view's top left.
pub type Area = [f32; 4];
/// The gap, in GUI px, an overlay leaves below a keep-out it moved past.
pub const GAP: f32 = 2.0;
/// A HUD element Horizon's overlays keep clear of.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Keep {
/// The boss bars and their names at the top centre (`hud_overlays::boss_bars_bottom`).
BossBars,
}
/// A view and the keep-outs its HUD shows this frame.
///
/// Invariants: the view's size is what the overlays draw in (the `HudBatch`'s size), and an
/// empty keep-out (no width or height) is never stored, so a room built from hidden elements
/// equals `Room::new`.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Room {
width: f32,
height: f32,
keeps: Vec<(Keep, Area)>,
}
impl Room {
/// A `width` x `height` GUI px view with nothing to keep clear of.
pub fn new(width: f32, height: f32) -> Self {
Self {
width,
height,
keeps: Vec::new(),
}
}
/// This room with `area` kept clear of as `keep`; an empty area is ignored.
pub fn with(mut self, keep: Keep, area: Area) -> Self {
self.add(keep, area);
self
}
/// Keeps clear of `area` as `keep`; an empty area is ignored.
pub fn add(&mut self, keep: Keep, area: Area) {
if area[2] > 0.0 && area[3] > 0.0 {
self.keeps.push((keep, area));
}
}
/// The view's width in GUI px.
pub fn width(&self) -> f32 {
self.width
}
/// The view's height in GUI px.
pub fn height(&self) -> f32 {
self.height
}
/// The keep-outs, in the order they were added.
pub fn keeps(&self) -> impl Iterator<Item = (Keep, Area)> + '_ {
self.keeps.iter().copied()
}
/// How far, in GUI px from the view's top, the keep-outs at the top reach down: the boss
/// bars' bottom, or 0 with none.
pub fn top(&self) -> f32 {
self.keeps()
.map(|(Keep::BossBars, [_, y, _, h])| y + h)
.fold(0.0, f32::max)
}
/// Where a centred overlay that wants its top at `wanted` (GUI px) goes: at `wanted` unless
/// a keep-out reaches past it (`top() > wanted`, a real overlap; touching isn't one), else
/// `GAP` below that keep-out.
pub fn below_top(&self, wanted: f32) -> f32 {
let top = self.top();
if top > wanted { top + GAP } else { wanted }
}
/// The view below the top keep-outs, with `GAP` past them: where a centred overlay may go.
/// The whole view when nothing is kept clear of.
pub fn usable(&self) -> Area {
let top = self.top();
let top = if top > 0.0 { top + GAP } else { 0.0 };
[0.0, top, self.width, (self.height - top).max(0.0)]
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn an_empty_room_is_the_whole_view_and_moves_nothing() {
let room = Room::new(320.0, 240.0).with(Keep::BossBars, [69.0, 0.0, 182.0, 0.0]);
assert_eq!(room, Room::new(320.0, 240.0));
assert_eq!(room.usable(), [0.0, 0.0, 320.0, 240.0]);
assert_eq!(room.below_top(18.0), 18.0);
}
#[test]
fn an_overlay_moves_only_when_the_bars_reach_past_it() {
let bars =
|bottom: f32| Room::new(500.0, 300.0).with(Keep::BossBars, [159.0, 0.0, 182.0, bottom]);
// Ending above or at its top: no overlap.
assert_eq!(bars(17.0).below_top(18.0), 18.0);
assert_eq!(bars(18.0).below_top(18.0), 18.0);
// Past it: below, a gap away.
assert_eq!(bars(36.0).below_top(18.0), 38.0);
assert_eq!(bars(36.0).usable(), [0.0, 38.0, 500.0, 262.0]);
}
}
@@ -50,10 +50,14 @@ pub fn text_with_backdrop(
text(out, t, x, y, color, true);
}
/// `Arrays.sort(T[], Comparator)` for the short arrays the HUD sorts: Java's
/// TimSort below `MIN_MERGE` (a natural run, then binary insertion), which
/// matters when the comparator is not a total order. Longer arrays fall back
/// to a stable sort.
/// `Arrays.sort(T[], Comparator)` for the short arrays the HUD sorts. Its Javadoc
/// says it is adapted from Tim Peters's list sort for Python, and for fewer than 32
/// elements that sort is one run plus binary insertion, as Tim Peters describes it
/// in `listsort.txt` (CPython's `Objects/listsort.txt`): take the run at the start,
/// reversed in place when it is strictly descending, then insert each remaining
/// element by binary search after the elements equal to it, so the sort is stable.
/// The order of the comparisons matters because vanilla's comparator here is not a
/// total order. Longer arrays fall back to a stable sort.
pub fn java_sort<T>(a: &mut [T], cmp: impl Fn(&T, &T) -> Ordering) {
let n = a.len();
if n < 2 {
@@ -63,7 +67,7 @@ pub fn java_sort<T>(a: &mut [T], cmp: impl Fn(&T, &T) -> Ordering) {
a.sort_by(cmp);
return;
}
// countRunAndMakeAscending.
// The run at the start: strictly descending (then reversed) or non-descending.
let mut run = 2;
if cmp(&a[1], &a[0]) == Ordering::Less {
while run < n && cmp(&a[run], &a[run - 1]) == Ordering::Less {
@@ -75,7 +79,7 @@ pub fn java_sort<T>(a: &mut [T], cmp: impl Fn(&T, &T) -> Ordering) {
run += 1;
}
}
// binarySort.
// Binary insertion of the rest, each element after the last one not greater.
for start in run..n {
let (mut left, mut right) = (0, start);
while left < right {
@@ -571,6 +575,41 @@ pub fn boss_overlay(w: i32, h: i32, bars: &[BossBar], font: &Font) -> Vec<GuiDra
out
}
/// `BossHealthOverlay`'s first bar's top edge, in GUI px (its name sits 9 px above).
pub const BOSS_BAR_TOP: i32 = 12;
/// `BossHealthOverlay`'s step from one bar to the next, in GUI px (`10 + 9`).
pub const BOSS_BAR_STEP: i32 = 10 + 9;
/// A boss bar's sprite height, in GUI px.
pub const BOSS_BAR_HEIGHT: i32 = 5;
/// A boss bar's sprite width, in GUI px; it is centred on the view.
pub const BOSS_BAR_WIDTH: i32 = 182;
/// How many of `n` boss bars `BossHealthOverlay.extractRenderState` draws on a view `h` GUI px
/// tall: it stops once the next bar's top reaches a third of the height (integer division,
/// as vanilla's `guiHeight() / 3`), but always draws the first.
pub fn boss_bars_drawn(h: i32, n: usize) -> usize {
let mut drawn = 0;
let mut y = BOSS_BAR_TOP;
while drawn < n {
drawn += 1;
y += BOSS_BAR_STEP;
if y >= h / 3 {
break;
}
}
drawn
}
/// The bottom edge, in GUI px from the view's top, of the last of `n` boss bars a view `h` GUI
/// px tall shows (`boss_overlay`'s layout); 0 when there are none. Names sit above their bar,
/// so nothing of the overlay reaches below this.
pub fn boss_bars_bottom(h: i32, n: usize) -> i32 {
match boss_bars_drawn(h, n) {
0 => 0,
k => BOSS_BAR_TOP + BOSS_BAR_STEP * (k as i32 - 1) + BOSS_BAR_HEIGHT,
}
}
/// `JumpableVehicleBar.extractBackground`: `cooldown` is the mount's jump
/// cooldown and `scale` the player's `getJumpRidingScale()`.
pub fn jump_bar(w: i32, h: i32, cooldown: i32, scale: f32) -> Vec<GuiDraw> {
@@ -1381,3 +1420,64 @@ mod text_position_tests {
assert_eq!(relative, Some(-4));
}
}
#[cfg(test)]
mod boss_bar_room_tests {
use super::*;
/// `BossHealthOverlay.extractRenderState` worked by hand: bars at y 12, 31, 50, 69, 88...,
/// each 5 tall, until the next top reaches `guiHeight() / 3`.
#[test]
fn boss_bars_end_where_vanillas_overlay_stops() {
assert_eq!(boss_bars_bottom(300, 0), 0);
assert_eq!(boss_bars_bottom(300, 1), 17);
assert_eq!(boss_bars_bottom(300, 2), 36);
assert_eq!(boss_bars_bottom(300, 3), 55);
// A third of 300 is 100: the fifth bar is the last (its next top would be 107).
assert_eq!(boss_bars_drawn(300, 9), 5);
assert_eq!(boss_bars_bottom(300, 9), 93);
// A short view still draws the first.
assert_eq!(boss_bars_bottom(60, 3), 17);
// `guiHeight() / 3` is integer division: 152 / 3 = 50 stops at two, 153 / 3 = 51 not.
assert_eq!(boss_bars_drawn(152, 3), 2);
assert_eq!(boss_bars_drawn(153, 3), 3);
}
#[test]
fn boss_bars_bottom_is_below_every_drawn_bar() {
// The missing-glyph metrics: no game assets needed.
let font = Font::load(
std::path::Path::new("/nonexistent-horizon-hud-test-resources"),
&std::collections::HashMap::new(),
&[],
(1, 1),
(0, 0),
crate::hud::Rect {
u0: 0.0,
v0: 0.0,
u1: 1.0,
v1: 1.0,
w: 1,
h: 1,
},
);
let bars: Vec<BossBar> = (0..6)
.map(|_| BossBar {
name: Text::literal(""),
progress: 1.0,
color: 0,
overlay: 0,
})
.collect();
for h in [60, 120, 152, 153, 240, 300, 600] {
let bottom = boss_overlay(400, h, &bars, &font)
.iter()
.filter_map(|d| match d {
GuiDraw::Sprite { y, h, .. } => Some(y + h),
_ => None,
})
.max();
assert_eq!(bottom, Some(boss_bars_bottom(h, bars.len())), "height {h}");
}
}
}
+2
View File
@@ -55,6 +55,7 @@ pub mod graphics_style;
pub mod graphics_ui;
pub mod hdr_calibration;
pub mod hud;
pub mod hud_kit;
pub mod hud_layout;
pub mod hud_overlays;
pub mod inventory_effects;
@@ -109,6 +110,7 @@ pub mod shader_pack_list;
pub mod shader_ui;
pub mod signs;
pub mod skins;
pub mod social;
pub mod sound_engine;
pub mod sound_events;
pub mod sound_preview;
+9 -7
View File
@@ -653,15 +653,17 @@ fn pack_lighting(
sky_light: bool,
) -> Vec<Light> {
let open = if sky_light { 0xF0 } else { 0x00 };
let mut data = [open; 4096];
(0..sections)
.map(|si| {
if si * 16 >= h {
return Light::Uniform(open);
}
let mut data = Box::new([open; 4096]);
for ly in 0..16 {
let y = si * 16 + ly;
if y >= h {
let out_row = ly << 8;
data[out_row..out_row + 256].fill(open);
continue;
}
let y_offset = y * (W * W);
@@ -674,7 +676,7 @@ fn pack_lighting(
}
}
}
Light::packed(data)
Light::from_slice(&data)
})
.collect()
}
@@ -1281,7 +1283,7 @@ fn relight_layer(
}
#[cfg(test)]
mod tests {
pub(crate) mod tests {
use super::*;
use crate::chunk::{HEIGHT, MIN_Y, SECTIONS};
@@ -1310,13 +1312,13 @@ mod tests {
assert_eq!(dirty(&[(0, 0, 0)]).len(), 8);
}
const AIR: u32 = 0;
pub(crate) const AIR: u32 = 0;
const STONE: u32 = 1;
const TORCH: u32 = 2;
pub(crate) const TORCH: u32 = 2;
const SLAB: u32 = 3;
/// Air, stone, torch and a bottom slab (full bottom face, half sides,
/// empty top), with a third face shape for the half sides.
fn tables() -> LightTables {
pub(crate) fn tables() -> LightTables {
let cell = |dampening, emission, faces: [u16; 6]| Cell {
dampening,
emission,
@@ -1335,7 +1337,7 @@ mod tests {
3,
)
}
fn world(edit: impl Fn(&mut Chunk)) -> HashMap<(i32, i32), Arc<Chunk>> {
pub(crate) fn world(edit: impl Fn(&mut Chunk)) -> HashMap<(i32, i32), Arc<Chunk>> {
world_of(crate::chunk::ColumnLayout::VANILLA, edit)
}
/// Lit chunks around (0, 0) of `layout`, stone up to y 0.
+41 -6
View File
@@ -10,6 +10,7 @@ use crate::{
pub mod cache;
mod mesh;
pub mod packed;
mod samples;
use horizon_world::registry::BlockStateId;
use std::{
collections::VecDeque,
@@ -957,7 +958,7 @@ type Reduced = FxHashMap<(i32, i32), Arc<ReducedColumns>>;
/// every tile's inputs from it (gathering on the render thread stalled the
/// frame, tens of milliseconds for a large job on a 4-thread machine).
struct Snapshot {
samples: FxHashMap<TileKey, Arc<Vec<Column>>>,
samples: samples::Samples,
saved: Saved,
reduced: Reduced,
/// The plan's tiles, to tell terrain not drawn yet from terrain out of
@@ -1267,6 +1268,8 @@ pub struct Lod {
/// Stale tiles `refresh`ed again while sampled: that sample may be older.
restale: FxHashSet<TileKey>,
active: FxHashSet<TileKey>,
/// The active tiles' `ready` samples, as mesh jobs read them (`sync_sample`).
mesh_samples: samples::Samples,
/// Tiles needing a mesh, with the revision their job must carry.
dirty: FxHashMap<TileKey, u64>,
/// The newest revision of each active tile's mesh.
@@ -1657,6 +1660,7 @@ impl Lod {
ready: FxHashMap::default(),
complete: FxHashMap::default(),
active: FxHashSet::default(),
mesh_samples: samples::Samples::default(),
dirty: FxHashMap::default(),
mesh_version: FxHashMap::default(),
shown_version: FxHashMap::default(),
@@ -1935,9 +1939,11 @@ impl Lod {
if active != was_active {
if active {
self.active.insert(node);
self.sync_sample(node);
self.removed_pending.remove(&node);
} else {
self.active.remove(&node);
self.sync_sample(node);
self.removed_pending.insert(node);
self.cancel_mesh(node);
self.mesh_version.remove(&node);
@@ -2210,6 +2216,7 @@ impl Lod {
}
}
for key in modified {
self.sync_sample(key);
self.mark_data_changed(key);
}
}
@@ -2271,6 +2278,7 @@ impl Lod {
return;
}
let existed = self.ready.insert(key, Arc::new(samples)).is_some();
self.sync_sample(key);
self.refresh_ready(key, existed);
}
}
@@ -2505,6 +2513,8 @@ impl Lod {
.collect();
self.plan = plan;
self.ready.retain(|key, _| self.plan.nodes.contains(key));
let nodes = &self.plan.nodes;
self.mesh_samples.retain(|key| nodes.contains(key));
// Queued tiles the view left are not sampled (a running one is
// dropped when it finishes, `finish_sample`).
drop_queued_samples(&self.shared, &mut self.pending, |key| {
@@ -2572,6 +2582,7 @@ impl Lod {
.retain(|key| !self.active.contains(key));
let changed: Vec<_> = old.symmetric_difference(&self.active).copied().collect();
for &key in &changed {
self.sync_sample(key);
if old.contains(&key) && !self.active.contains(&key) {
self.cancel_mesh(key);
self.removed_pending.insert(key);
@@ -2857,12 +2868,10 @@ impl Lod {
if keys.is_empty() {
return;
}
#[cfg(debug_assertions)]
self.check_mesh_samples();
let snapshot = Arc::new(Snapshot {
samples: self
.active
.iter()
.filter_map(|key| self.ready.get(key).map(|columns| (*key, columns.clone())))
.collect(),
samples: self.mesh_samples.clone(),
saved: self.saved.clone(),
reduced: self.reduced.clone(),
nodes: self.plan.nodes.clone(),
@@ -2895,6 +2904,31 @@ impl Lod {
self.outstanding_meshes += count;
self.shared.mesh_wake.notify_all();
}
/// Brings `mesh_samples` up to date for `key` after its `active` or `ready` changed.
fn sync_sample(&mut self, key: TileKey) {
let samples = self.ready.get(&key).filter(|_| self.active.contains(&key));
self.mesh_samples.set(key, samples);
}
/// `mesh_samples` holds every active tile's ready samples and nothing else (debug builds).
#[cfg(debug_assertions)]
fn check_mesh_samples(&self) {
let kept = self.mesh_samples.iter().count();
let mut want = 0;
for key in &self.active {
if let Some(columns) = self.ready.get(key) {
want += 1;
let got = self.mesh_samples.get(key);
assert!(
got.is_some_and(|got| Arc::ptr_eq(got, columns)),
"mesh samples of {key:?} out of date"
);
}
}
assert_eq!(
kept, want,
"mesh samples keep tiles no longer active or ready"
);
}
fn finer_of(&self, key: TileKey) -> Vec<TileKey> {
if !self.plan.split.contains(&key) {
return Vec::new();
@@ -4852,6 +4886,7 @@ mod tests {
lod.active.insert(key);
lod.ready
.insert(key, Arc::new(vec![Column::default(); CELLS * CELLS]));
lod.sync_sample(key);
lod.mark_mesh(key);
lod.set_near_loading(true);
lod.dispatch_meshes([0, 0]);
@@ -0,0 +1,105 @@
//! The samples mesh jobs read (`Snapshot::samples`): the active tiles' ready columns, kept
//! as the tiles change rather than gathered again for every batch of jobs. Kept in shards of
//! nearby tiles behind `Arc`s: a batch's copy (`clone`) copies the shards' handles, and a
//! change copies only its shard, and only while a job still reads it.
use super::{Column, TileKey};
use crate::fast_hash::FxHashMap;
use std::sync::Arc;
type Shard = FxHashMap<TileKey, Arc<Vec<Column>>>;
/// 8 by 8 tiles of one level a shard.
fn shard_of(key: TileKey) -> (i32, i32, u8) {
(key.x >> 3, key.z >> 3, key.level)
}
#[derive(Clone, Default)]
pub(super) struct Samples {
shards: FxHashMap<(i32, i32, u8), Arc<Shard>>,
}
impl Samples {
pub fn get(&self, key: &TileKey) -> Option<&Arc<Vec<Column>>> {
self.shards.get(&shard_of(*key))?.get(key)
}
/// Sets `key`'s samples (`None`: not active or not ready), copying its shard only when
/// they change.
pub fn set(&mut self, key: TileKey, samples: Option<&Arc<Vec<Column>>>) {
let at = shard_of(key);
match samples {
Some(samples) => {
let shard = self.shards.entry(at).or_default();
if shard.get(&key).is_none_or(|s| !Arc::ptr_eq(s, samples)) {
Arc::make_mut(shard).insert(key, samples.clone());
}
}
None => {
let Some(shard) = self.shards.get_mut(&at) else {
return;
};
if shard.contains_key(&key) {
Arc::make_mut(shard).remove(&key);
if shard.is_empty() {
self.shards.remove(&at);
}
}
}
}
}
/// Keeps the tiles `keep` accepts.
pub fn retain(&mut self, mut keep: impl FnMut(&TileKey) -> bool) {
self.shards.retain(|_, shard| {
if !shard.keys().all(&mut keep) {
Arc::make_mut(shard).retain(|key, _| keep(key));
}
!shard.is_empty()
});
}
#[cfg(any(test, debug_assertions))]
pub fn iter(&self) -> impl Iterator<Item = (&TileKey, &Arc<Vec<Column>>)> {
self.shards.values().flat_map(|shard| shard.iter())
}
}
impl FromIterator<(TileKey, Arc<Vec<Column>>)> for Samples {
fn from_iter<I: IntoIterator<Item = (TileKey, Arc<Vec<Column>>)>>(iter: I) -> Self {
let mut samples = Self::default();
for (key, columns) in iter {
samples.set(key, Some(&columns));
}
samples
}
}
#[cfg(test)]
mod tests {
use super::*;
fn key(x: i32, z: i32, level: u8) -> TileKey {
TileKey { x, z, level }
}
#[test]
fn a_change_leaves_a_batchs_copy_as_it_was() {
let a = Arc::new(Vec::new());
let b = Arc::new(Vec::new());
let mut live: Samples = [(key(0, 0, 0), a.clone()), (key(40, -3, 1), a.clone())]
.into_iter()
.collect();
let batch = live.clone();
live.set(key(0, 0, 0), Some(&b));
live.set(key(40, -3, 1), None);
live.set(key(-9, 2, 0), Some(&b));
assert!(Arc::ptr_eq(batch.get(&key(0, 0, 0)).unwrap(), &a));
assert!(batch.get(&key(40, -3, 1)).is_some());
assert!(batch.get(&key(-9, 2, 0)).is_none());
assert!(Arc::ptr_eq(live.get(&key(0, 0, 0)).unwrap(), &b));
assert!(live.get(&key(40, -3, 1)).is_none());
live.retain(|k| k.x < 0);
assert_eq!(
live.iter().map(|(k, _)| *k).collect::<Vec<_>>(),
[key(-9, 2, 0)]
);
assert_eq!(batch.iter().count(), 2);
}
}
+101 -6
View File
@@ -450,7 +450,7 @@ impl SectionMesh {
self.packed.clear();
self.packed_far = false;
if packed::packing() && quads > 0 && self.solid_tints.len() == quads {
self.packed = packed::layout(&self.solid, &self.solid_tints);
packed::layout_into(&self.solid, &self.solid_tints, &mut self.packed);
}
self.solid_tints.clear();
}
@@ -644,6 +644,8 @@ struct Info {
block_entity: bool,
tints: [Tint; 2],
tint_count: u8,
/// A resource pack's Polytone colormap over its tints (an index in `MeshTables::colormaps`).
colormap: Option<u16>,
/// `OffsetType` as (max horizontal, max vertical): vertical 0 for XZ.
offset: Option<(f32, f32)>,
/// `isSolid()` (legacy), for fluid corner heights.
@@ -700,6 +702,9 @@ pub struct MeshTables {
lava_still: u16,
lava_flow: u16,
animated_layers: Vec<bool>,
/// Resource packs' Polytone block and fluid colormaps (#11486), and the water's.
colormaps: Vec<Arc<crate::environment::Colormap>>,
water_colormap: Option<u16>,
}
fn stem_color(age: u8) -> u32 {
let a = u32::from(age);
@@ -842,6 +847,7 @@ impl MeshTables {
block_entity: s.block_entity,
tints,
tint_count,
colormap: None,
offset,
legacy_solid: s.legacy_solid,
overlay: s.has_class("HalfTransparentBlock") || s.has_class("LeavesBlock"),
@@ -875,8 +881,52 @@ impl MeshTables {
lava_still: layer("minecraft:block/lava_still")?,
lava_flow: layer("minecraft:block/lava_flow")?,
animated_layers,
colormaps: Vec::new(),
water_colormap: None,
})
}
/// These tables with the colormaps of the Polytone block and fluid modifiers of the packs
/// merged at `pack_root` (wiki "Colormaps"): a tinted block they target takes its colour
/// from the colormap, and its own tint where the colormap's pixel is transparent.
#[must_use]
pub fn with_polytone_colormaps(
mut self,
pack_root: &std::path::Path,
registry: &horizon_world::registry::BlockRegistry,
) -> Self {
let modifiers = crate::environment::polytone_colormap_modifiers(pack_root);
if modifiers.is_empty() {
return self;
}
let mut loaded: std::collections::HashMap<String, Option<u16>> = Default::default();
let mut index = |id: &str, colormaps: &mut Vec<Arc<crate::environment::Colormap>>| {
*loaded.entry(id.to_owned()).or_insert_with(|| {
let colormap = crate::environment::Colormap::load(pack_root, id)?;
colormaps.push(Arc::new(colormap));
Some((colormaps.len() - 1) as u16)
})
};
for (fluid, targets, id) in &modifiers {
if *fluid {
if targets.matches("minecraft:water", |_| false) {
self.water_colormap = index(id, &mut self.colormaps);
}
continue;
}
let Some(i) = index(id, &mut self.colormaps) else {
continue;
};
for (state, info) in self.info.iter_mut().enumerate() {
let Some(block) = registry.state(BlockStateId(state as u32)) else {
continue;
};
if info.tint_count > 0 && targets.matches(&block.name, |_| false) {
info.colormap = Some(i);
}
}
}
self
}
/// Whether a terrain sprite layer has an animated clip.
pub fn is_animated(&self, layer: u16) -> bool {
self.animated_layers
@@ -971,7 +1021,7 @@ impl MeshTables {
}
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 mut tints = Tints::new(region, biomes, zoom, blend_radius, origin).with_colormaps(self);
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
@@ -989,7 +1039,8 @@ impl MeshTables {
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 mut tints =
Tints::new(region, biomes, zoom, blend_radius, origin).with_colormaps(self);
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))
@@ -1382,6 +1433,11 @@ struct Tints<'a> {
layers: [Option<Box<Layer>>; 16],
/// `uniform_biome`, once computed.
uniform: Option<Option<u16>>,
/// The packs' colormaps (`MeshTables::colormaps`), the water's, and their colours so far
/// by colormap and section-local block.
colormaps: &'a [Arc<crate::environment::Colormap>],
water_colormap: Option<u16>,
sampled: std::collections::HashMap<(u16, [i32; 3]), Option<u32>>,
}
impl<'a> Tints<'a> {
fn new(
@@ -1400,8 +1456,41 @@ impl<'a> Tints<'a> {
origin,
layers: Default::default(),
uniform: None,
colormaps: &[],
water_colormap: None,
sampled: std::collections::HashMap::new(),
}
}
/// With the packs' colormaps of `tables`.
fn with_colormaps(mut self, tables: &'a MeshTables) -> Self {
self.colormaps = &tables.colormaps;
self.water_colormap = tables.water_colormap;
self
}
/// Colormap `index`'s colour at block `(x, y, z)` (section-local), `None` where its pixel
/// is transparent.
fn colormap(&mut self, index: u16, x: i32, y: i32, z: i32) -> Option<u32> {
if let Some(&known) = self.sampled.get(&(index, [x, y, z])) {
return known;
}
let colormap = self.colormaps.get(usize::from(index))?;
let id = Self::biome_id(&self.zoom, self.region, self.plains, self.origin, [x, y, z]);
let [cx, cz, base_y] = self.origin;
let at = [cx * 16 + x, base_y + y, cz * 16 + z];
let mut host = crate::environment::BlockHost::new(at, self.biomes.get(id));
let colour = colormap.sample(&mut host).map(|c| c & 0xFF_FFFF);
self.sampled.insert((index, [x, y, z]), colour);
colour
}
/// The water's tint at block `(x, y, z)`: the packs' water colormap, else the biome's.
fn water(&mut self, x: i32, y: i32, z: i32) -> u32 {
if let Some(index) = self.water_colormap
&& let Some(colour) = self.colormap(index, x, y, z)
{
return colour;
}
self.get(Resolver::Water, x, y, z)
}
/// The biome of block `(x, y, z)` (section-local x/z, y in 0..16), as `BiomeManager.getBiome`
/// picks it.
fn biome(&self, x: i32, y: i32, z: i32) -> &'a str {
@@ -1645,7 +1734,8 @@ impl Mesher<'_> {
self.zoom,
self.blend_radius,
[center.x, center.z, base_y],
);
)
.with_colormaps(self.tables);
let info = &self.tables.info;
let full_faces = &self.tables.full_faces;
let catalog = &self.render.model_catalog;
@@ -1674,7 +1764,7 @@ impl Mesher<'_> {
continue;
}
if state.fluid.kind() != FluidKind::Empty {
let water = tints.get(Resolver::Water, x, y, z);
let water = tints.water(x, y, z);
let shade = (
self.nether_shading,
self.shader_pack_lighting,
@@ -1808,13 +1898,18 @@ impl Mesher<'_> {
if index < 0 || index >= i32::from(state.tint_count) {
return None;
}
if let Some(colormap) = state.colormap
&& let Some(colour) = tints.colormap(colormap, x, y, z)
{
return Some(colour);
}
Some(match state.tints[(index as usize).min(1)] {
Tint::None => return None,
Tint::Grass | Tint::GrassBlock | Tint::SugarCane => tints.get(Resolver::Grass, x, y, z),
Tint::TallGrassUpper => tints.get(Resolver::Grass, x, y, z),
Tint::Foliage => tints.get(Resolver::Foliage, x, y, z),
Tint::DryFoliage => tints.get(Resolver::Dry, x, y, z),
Tint::Water => tints.get(Resolver::Water, x, y, z),
Tint::Water => tints.water(x, y, z),
Tint::Constant(c) => c,
Tint::LilyPad => 0x20_8030,
Tint::Redstone(p) => redstone_color(p),
@@ -340,12 +340,11 @@ pub(super) fn tesselate(
} else {
(tables.lava_still, tables.lava_flow, None)
};
let target = if water {
&mut out.translucent
let (target, sprite_layers) = if water {
(&mut out.translucent, &mut out.sprite_layers)
} else {
&mut out.solid
(&mut out.solid, &mut out.sprite_layers)
};
let mut sprite_layers = Vec::new();
let mut quad = |v: [([f32; 3], [f32; 2]); 4],
layer: u16,
color: u32,
@@ -497,7 +496,6 @@ pub(super) fn tesselate(
d as usize,
);
}
out.sprite_layers.extend(sprite_layers);
}
#[cfg(test)]
@@ -335,6 +335,7 @@ mod tests {
block_entity: false,
tints: [super::super::Tint::None; 2],
tint_count: 0,
colormap: None,
offset: None,
legacy_solid: false,
overlay: leaves,
@@ -199,16 +199,29 @@ pub fn packed_vertex(words: &[u32; 6], corner: usize) -> TerrainVertex {
}
}
thread_local! {
static RECORDS_SCRATCH: std::cell::RefCell<Vec<Option<[u32; 6]>>> =
const { std::cell::RefCell::new(Vec::new()) };
}
/// The compact layout of sorted solid quads with their tints (`SectionMesh::packed`), or
/// nothing when it would be no smaller than their vertices.
pub fn layout(solid: &[TerrainVertex], tints: &[u32]) -> Vec<[u32; 6]> {
let mut out = Vec::new();
layout_into(solid, tints, &mut out);
out
}
/// Writes the compact layout of sorted solid quads with their tints into `out`,
/// reusing `out`'s allocation and a thread-local record scratch buffer.
pub fn layout_into(solid: &[TerrainVertex], tints: &[u32], out: &mut Vec<[u32; 6]>) {
let quads = solid.as_chunks::<4>().0;
let records: Vec<Option<[u32; 6]>> = quads
.iter()
.zip(tints)
.map(|(q, &t)| pack_quad(q, t))
.collect();
place(&records, quads)
RECORDS_SCRATCH.with(|cell| {
let mut records = cell.borrow_mut();
records.clear();
records.extend(quads.iter().zip(tints).map(|(q, &t)| pack_quad(q, t)));
place_into(&records, quads, out);
});
}
/// A mesh's solid range from a record per quad, `None` where no record fits:
@@ -220,6 +233,17 @@ pub fn layout(solid: &[TerrainVertex], tints: &[u32]) -> Vec<[u32; 6]> {
/// Shared with `lod::packed`, whose records hold a far tile's quads instead but
/// spill and pad the same way.
pub fn place(records: &[Option<[u32; 6]>], quads: &[[TerrainVertex; 4]]) -> Vec<[u32; 6]> {
let mut out = Vec::new();
place_into(records, quads, &mut out);
out
}
pub fn place_into(
records: &[Option<[u32; 6]>],
quads: &[[TerrainVertex; 4]],
out: &mut Vec<[u32; 6]>,
) {
out.clear();
let n = quads.len();
let packed = records.iter().filter(|r| r.is_some()).count();
let full = n - packed;
@@ -229,9 +253,9 @@ pub fn place(records: &[Option<[u32; 6]>], quads: &[[TerrainVertex; 4]]) -> Vec<
0
};
if n + pad + 4 * full >= 4 * n {
return Vec::new();
return;
}
let mut out = Vec::with_capacity(n + pad + 4 * full);
out.reserve(n + pad + 4 * full);
let mut next = n + pad;
for (i, record) in records.iter().enumerate() {
out.push(record.unwrap_or_else(|| {
@@ -249,7 +273,6 @@ pub fn place(records: &[Option<[u32; 6]>], quads: &[[TerrainVertex; 4]]) -> Vec<
);
}
}
out
}
/// Quads `layout` gave a record of their own.
@@ -112,3 +112,72 @@ fn one_biome_shortcut_blends_like_the_full_square() {
}
}
}
/// A pack root holding `files` (path, bytes), for colormap tests.
fn pack(files: &[(&str, Vec<u8>)]) -> std::path::PathBuf {
static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
let n = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let root = std::env::temp_dir().join(format!(
"horizon-mesher-colormap-{}-{n}",
std::process::id()
));
for (path, bytes) in files {
let path = root.join(path);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, bytes).unwrap();
}
root
}
/// A one-row PNG of RGBA pixels.
fn png_row(pixels: &[[u8; 4]]) -> Vec<u8> {
let mut out = Vec::new();
let mut encoder = png::Encoder::new(&mut out, pixels.len() as u32, 1);
encoder.set_color(png::ColorType::Rgba);
encoder.set_depth(png::BitDepth::Eight);
let mut writer = encoder.write_header().unwrap();
writer.write_image_data(&pixels.concat()).unwrap();
writer.finish().unwrap();
out
}
#[test]
fn a_polytone_colormap_tints_by_biome_and_keeps_the_biome_colour_where_transparent() {
let biomes = Biomes::from_jsons(&[
(
"minecraft:plains",
serde_json::json!({"effects": {"water_color": 0x3F76E4}}),
),
(
"minecraft:desert",
serde_json::json!({"effects": {"water_color": 0x32A598}}),
),
]);
// Left to right: transparent, red. Desert maps to column 1, plains (unnamed) to column 0.
let root = pack(&[
(
"assets/mypack/polytone/colormaps/water.png",
png_row(&[[0, 0, 0, 0], [255, 0, 0, 255]]),
),
(
"assets/mypack/polytone/colormaps/water.json",
br#"{"x_axis": "biome_id", "y_axis": 0, "biome_id_mapper": {"minecraft:desert": 1, "texture_size": 2}}"#
.to_vec(),
),
]);
let colormaps = vec![Arc::new(
crate::environment::Colormap::load(&root, "mypack:water").unwrap(),
)];
for (biome, expected) in [(1, 0xFF_0000), (0, 0x3F_76E4)] {
let chunks: Vec<Vec<Chunk>> = (0..3)
.map(|rz| (0..3).map(|rx| filled(rx - 1, rz - 1, biome)).collect())
.collect();
let region: [[Option<&Chunk>; 3]; 3] =
std::array::from_fn(|rz| std::array::from_fn(|rx| Some(&chunks[rz][rx])));
let mut tints = Tints::new(&region, &biomes, BiomeZoom::new(0), 0, [0, 0, 64]);
tints.colormaps = &colormaps;
tints.water_colormap = Some(0);
assert_eq!(tints.water(3, 4, 5), expected, "biome {biome}");
}
std::fs::remove_dir_all(root).unwrap();
}
@@ -84,6 +84,7 @@ fn info(solid: bool) -> Info {
block_entity: false,
tints: [Tint::None; 2],
tint_count: 0,
colormap: None,
offset: None,
legacy_solid: false,
overlay: false,
@@ -12,7 +12,7 @@
return oit_transmittance_fragment(entity_color(in, front).a, in.clip);
}
@fragment fn fs_oit_entity_accumulate(in: Out, @builtin(front_facing) front: bool) -> @location(0) vec4<f32> {
return oit_accumulate_fragment(entity_written_color(in, front), in.clip);
return oit_accumulate_fragment(entity_written_color(in, entity_color(in, front)), in.clip);
}
// Text keeps its source gain, as fs_entity_unmarked does in the forward pass.
@fragment fn fs_oit_text_accumulate(in: Out, @builtin(front_facing) front: bool) -> @location(0) vec4<f32> {
+94 -6
View File
@@ -34,8 +34,11 @@ pub struct Pack {
pub description_style: Style,
pub compatible: bool,
pub compatibility: crate::pack_screen::Compat,
/// Overlay directories active for this game version, in `pack.mcmeta` order.
/// Overlay directories active for this game version, in `pack.mcmeta` order: also those a
/// Polytone `polytone_condition` turns on.
pub overlays: Vec<String>,
/// The values of the pack's Polytone config entries.
pub polytone_configs: horizon_polytone::pack::Configs,
/// `filter.block` entries (namespace and path patterns) hiding resources
/// of the packs below this one.
pub filter: Vec<Filter>,
@@ -416,12 +419,12 @@ fn overlay_applies(entry: &serde_json::Value) -> bool {
_ => false,
}
}
fn overlays_of(meta: &serde_json::Value) -> Vec<String> {
fn overlays_of(meta: &serde_json::Value, configs: &horizon_polytone::pack::Configs) -> Vec<String> {
meta["overlays"]["entries"]
.as_array()
.into_iter()
.flatten()
.filter(|e| overlay_applies(e))
.filter(|e| horizon_polytone::pack::overlay_active(e, configs, overlay_applies(e)))
.filter_map(|e| e["directory"].as_str().map(str::to_owned))
// OverlayMetadataSection's DIR_VALIDATOR: one folder of the pack, `[-_a-zA-Z0-9.]+`.
.filter(|d| {
@@ -468,12 +471,66 @@ pub(crate) fn parse_meta(bytes: &[u8]) -> Option<serde_json::Value> {
.next()?
.ok()
}
/// A pack's Polytone config entries (`assets/<namespace>/polytone/config_entries/<name>.json`,
/// in the pack's base, not its overlays) at their default values.
pub fn polytone_configs(source: &Source) -> horizon_polytone::pack::Configs {
let mut configs = horizon_polytone::pack::Configs::default();
let id_of = |name: &str| -> Option<String> {
let rest = name.strip_prefix("assets/")?;
let (namespace, rest) = rest.split_once('/')?;
let entry = rest
.strip_prefix("polytone/config_entries/")?
.strip_suffix(".json")?;
Some(format!("{namespace}:{entry}"))
};
match source {
Source::Dir(d) => {
let Ok(namespaces) = fs::read_dir(d.join("assets")) else {
return configs;
};
for namespace in namespaces.flatten() {
let folder = namespace.path().join("polytone/config_entries");
for file in fs::read_dir(&folder).into_iter().flatten().flatten() {
let name = format!(
"assets/{}/polytone/config_entries/{}",
namespace.file_name().to_string_lossy(),
file.file_name().to_string_lossy()
);
if let (Some(id), Ok(bytes)) = (id_of(&name), fs::read(file.path()))
&& let Ok(json) = serde_json::from_slice(&bytes)
{
configs.add_entry(id, &json);
}
}
}
}
Source::Zip(z) => {
let Ok(zip) = Zip::open(z) else {
return configs;
};
let names: Vec<String> = zip
.names()
.filter(|n| id_of(n).is_some())
.map(str::to_owned)
.collect();
for name in names {
if let (Some(id), Ok(bytes)) = (id_of(&name), zip.read(&name))
&& let Ok(json) = serde_json::from_slice(&bytes)
{
configs.add_entry(id, &json);
}
}
}
}
configs
}
fn pack(id: String, title: Text, source: Source) -> Option<Pack> {
pack_for(id, title, source, RESOURCE_FORMAT)
}
/// A pack whose compatibility is against `format`.
fn pack_for(id: String, title: Text, source: Source, format: (i64, i64)) -> Option<Pack> {
let meta = read_meta(&source)?;
let polytone_configs = polytone_configs(&source);
let (raw_description, description_style) = component_root(&meta["pack"]["description"]);
Some(Pack {
id,
@@ -483,7 +540,8 @@ fn pack_for(id: String, title: Text, source: Source, format: (i64, i64)) -> Opti
description_style,
compatible: compatible(&meta),
compatibility: compatibility_for(&meta, format),
overlays: overlays_of(&meta),
overlays: overlays_of(&meta, &polytone_configs),
polytone_configs,
filter: filter_of(&meta),
source,
})
@@ -1930,6 +1988,36 @@ mod tests {
let _ = fs::remove_dir_all(base);
}
#[test]
fn polytone_conditions_turn_overlays_on_from_the_packs_configs() {
let dir =
std::env::temp_dir().join(format!("horizon-packs-polytone-{}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
let configs = dir.join("assets/mypack/polytone/config_entries");
fs::create_dir_all(&configs).unwrap();
fs::write(configs.join("fog.json"), r#"{"default_value": "ON"}"#).unwrap();
fs::write(
configs.join("birds.json"),
r#"{"default_value": 0, "max": 300}"#,
)
.unwrap();
fs::write(
dir.join("pack.mcmeta"),
r#"{"pack":{"description":"p","pack_format":97},"overlays":{"entries":[
{"directory":"opt_fog","formats":[0,0],"polytone_condition":"config('mypack:fog') == 'ON'"},
{"directory":"opt_birds","formats":[0,0],"polytone_condition":"config('mypack:birds') > 0"},
{"directory":"opt_plain","formats":[0,0]}]}}"#,
)
.unwrap();
let pack = pack(
"file/p".into(),
Text::literal("p"),
Source::Dir(dir.clone()),
)
.unwrap();
assert_eq!(pack.overlays, ["opt_fog"]);
let _ = fs::remove_dir_all(dir);
}
#[test]
fn overlays_and_filters() {
let v = |s: &str| serde_json::from_str::<serde_json::Value>(s).unwrap();
let meta = v(r#"{"overlays":{"entries":[
@@ -1937,7 +2025,7 @@ mod tests {
{"formats":{"min_inclusive":97,"max_inclusive":97},"directory":"new"},
{"min_format":97,"max_format":[97,5],"directory":"also"},
{"formats":50,"directory":"no"}]}}"#);
assert_eq!(overlays_of(&meta), ["new", "also"]);
assert_eq!(overlays_of(&meta, &Default::default()), ["new", "also"]);
// Only one plain folder of the pack, as vanilla's DIR_VALIDATOR has it.
let meta = v(r#"{"overlays":{"entries":[
{"formats":97,"directory":".."},
@@ -1946,7 +2034,7 @@ mod tests {
{"formats":97,"directory":"a/b"},
{"formats":97,"directory":"C:"},
{"formats":97,"directory":"ok_1.21-x"}]}}"#);
assert_eq!(overlays_of(&meta), ["ok_1.21-x"]);
assert_eq!(overlays_of(&meta, &Default::default()), ["ok_1.21-x"]);
let base = std::env::temp_dir().join(format!("horizon-packs-ov-{}", std::process::id()));
let _ = fs::remove_dir_all(&base);
let vanilla = base.join("vanilla");
+102 -2
View File
@@ -28,6 +28,8 @@ use std::{
pub use horizon_client_core::particle_type::ParticleType;
pub use horizon_items::firework::{DYE_FIREWORK_COLORS, FireworkExplosion, FireworkShape};
mod native;
pub mod polytone;
mod polytone_gpu;
pub use native::{ParticleController, ParticleUpdate};
/// `ParticleOptions` with the parameters of the option classes used here.
@@ -64,6 +66,8 @@ pub struct ParticleOptions {
/// `nextInt(40) + 10` after constructing the particle. Set by the client for an older
/// server only, never read from the wire.
pub legacy_trail_lifetime: bool,
/// What the Polytone emitter that spawned this custom particle sets on it.
pub polytone_seed: Option<std::sync::Arc<polytone::Seed>>,
}
impl ParticleOptions {
pub fn simple(ty: ParticleType) -> Self {
@@ -80,6 +84,7 @@ impl ParticleOptions {
mod_particle: None,
legacy_entity_effect: None,
legacy_trail_lifetime: false,
polytone_seed: None,
}
}
/// A native mod's `SimpleParticleType`: the sprite set of
@@ -522,6 +527,12 @@ pub struct Particle {
/// `Particle.random`: the particle's own generator (`RandomSource.create()`).
random: LegacyRandom,
controller: Option<Box<dyn native::Controller>>,
/// A resource pack's Polytone custom particle type, for its `limit`.
polytone_type: Option<std::sync::Arc<str>>,
/// How a Polytone custom particle's quad turns (`rotation_mode`).
orientation: polytone_gpu::Orientation,
/// Blended additively (a Polytone `additive_translucent` particle).
additive: bool,
}
fn aabb_at(p: [f64; 3], w: f32, h: f32) -> Aabb {
let w = f64::from(w) / 2.0;
@@ -883,6 +894,9 @@ impl Particle {
frame_ticks: 0,
random: LegacyRandom::new(0),
controller: None,
polytone_type: None,
orientation: polytone_gpu::Orientation::Camera,
additive: false,
}
}
/// The normal age/removal test and previous pose, without vanilla motion.
@@ -1277,6 +1291,13 @@ pub struct ParticleEngine {
/// A native mod's sprite sets, by namespaced particle name.
mod_sets: HashMap<String, (u32, Option<ModBehaviour>)>,
set_sprites: Vec<Vec<u32>>,
/// Resource packs' Polytone custom particle types (#11486).
polytone: Option<std::sync::Arc<polytone::Registry>>,
/// The packs' GPU particles' records, one store per type, and the ticks they count in.
polytone_gpu: Vec<polytone_gpu::Store>,
polytone_ticks: u64,
/// The last frame's additively blended quads (`take_additive`).
additive: std::sync::Mutex<Vec<Vertex>>,
/// `TrackingEmitter`s: particle type, entity feet position, width, height, ticks left.
emitters: Vec<(ParticleOptions, [f64; 3], f64, f64, u32)>,
/// Camera position for the 32-block spawn limiter.
@@ -1469,6 +1490,10 @@ impl ParticleEngine {
sets: HashMap::new(),
mod_sets: HashMap::new(),
set_sprites: Vec::new(),
polytone: None,
polytone_gpu: Vec::new(),
polytone_ticks: 0,
additive: std::sync::Mutex::default(),
emitters: Vec::new(),
starters: Vec::new(),
tracked_explosions: Vec::new(),
@@ -1689,6 +1714,28 @@ impl ParticleEngine {
particle.controller = Some(Box::new(controller));
Some(particle)
}
/// Sets (or clears) the resource packs' Polytone custom particle types.
pub fn set_polytone(&mut self, registry: Option<std::sync::Arc<polytone::Registry>>) {
let registry = registry.filter(|r| !r.is_empty());
let same = match (&self.polytone, &registry) {
(Some(a), Some(b)) => std::sync::Arc::ptr_eq(a, b),
(None, None) => true,
_ => false,
};
if !same {
self.polytone_gpu.clear();
}
self.polytone = registry;
}
/// The quads the last render blends additively (`RenderPipelines` additive, src alpha and
/// one), for the frame's additive pass.
pub fn take_additive(&self) -> Vec<Vertex> {
std::mem::take(&mut *self.additive.lock().unwrap_or_else(|e| e.into_inner()))
}
/// Live GPU particle records (spawns, before `area` spreads them), for tests and status.
pub fn polytone_gpu_records(&self) -> usize {
self.polytone_gpu.iter().map(polytone_gpu::Store::len).sum()
}
/// Choose a frame from this particle's declared sprite set.
pub fn set_sprite_frame(&self, particle: &mut Particle, frame: usize) {
if let Some(sprites) = particle.set.and_then(|s| self.set_sprites.get(s as usize))
@@ -2092,6 +2139,27 @@ impl ParticleEngine {
let ty = o.ty;
// A native mod's SimpleParticleType: vanilla's TextureSheetParticle with
// the sprite set its particles/<name>.json names, no tint, 20..40 ticks.
if let Some(name) = &o.mod_particle
&& let Some(registry) = &self.polytone
&& let Some(def) = registry.get(name)
{
let (registry, def) = (registry.clone(), def.clone());
let particle = polytone::make(self, &registry, name, &def, o, p, v, seed)?;
if def.gpu.is_some() {
// A GPU particle is only a record: it never becomes an engine particle.
let now = self.polytone_ticks;
let store = match self.polytone_gpu.iter().position(|s| s.is(&def)) {
Some(i) => &mut self.polytone_gpu[i],
None => {
self.polytone_gpu.push(polytone_gpu::Store::new(&def)?);
self.polytone_gpu.last_mut()?
}
};
store.spawn(&particle, now, seed as u64);
return None;
}
return Some(particle);
}
if let Some(name) = &o.mod_particle {
let (set, behaviour) = *self.mod_sets.get(name)?;
let mut r = LegacyRandom::new(seed);
@@ -3726,6 +3794,14 @@ impl ParticleEngine {
}
}
pub fn tick(&mut self, level: &ParticleLevel) {
self.polytone_ticks += 1;
for store in &mut self.polytone_gpu {
store.expire(self.polytone_ticks);
}
// The sounds the packs' custom particles played last tick.
if let Some(registry) = &self.polytone {
self.sounds.extend(registry.take_sounds());
}
self.tick_starters();
self.tick_explosions(level);
for (options, feet, width, height, life) in std::mem::take(&mut self.emitters) {
@@ -4718,6 +4794,8 @@ impl ParticleEngine {
let base = Quat::from_rotation_y(std::f32::consts::PI - yaw.to_radians())
* Quat::from_rotation_x(-pitch.to_radians());
let mut light_cache = ParticleLightCache::default();
let mut additive = self.additive.lock().unwrap_or_else(|e| e.into_inner());
additive.clear();
let mut section_cache =
(CULL && frustum.is_some()).then(ParticleSectionFrustumCache::default);
for p in &self.particles {
@@ -4789,7 +4867,15 @@ impl ParticleEngine {
) - camera;
(pos.as_vec3(), p.quad_size(partial))
});
let rotation = if p.roll != 0.0 {
let rotation = if p.orientation != polytone_gpu::Orientation::Camera {
let velocity = FVec3::new(
p.velocity[0] as f32,
p.velocity[1] as f32,
p.velocity[2] as f32,
);
let roll = p.o_roll + (p.roll - p.o_roll) * partial;
polytone_gpu::orient(p.orientation, center, velocity, roll, base)
} else if p.roll != 0.0 {
base * Quat::from_rotation_z(p.o_roll + (p.roll - p.o_roll) * partial)
} else {
base
@@ -4871,6 +4957,7 @@ impl ParticleEngine {
r.u0 + du * p.uv[2],
r.v0 + dv * p.uv[3],
);
let target: &mut Vec<Vertex> = if p.additive { &mut additive } else { &mut *out };
for rotation in rotations.into_iter().flatten() {
for (n, uv) in [
((1.0, -1.0), [u1, v1]),
@@ -4879,7 +4966,7 @@ impl ParticleEngine {
((-1.0, -1.0), [u0, v1]),
] {
let v = center + rotation * FVec3::new(n.0, n.1, 0.0) * size;
out.push(Vertex {
target.push(Vertex {
normal: [0; 4],
pos: v.to_array(),
uv,
@@ -4896,6 +4983,19 @@ impl ParticleEngine {
}
}
}
for store in &self.polytone_gpu {
store.render(
&self.atlas,
camera,
base,
self.polytone_ticks,
partial,
&mut light_cache,
light_at,
out,
&mut additive,
);
}
}
}
/// How brightly particle `ty` shines in HDR output, as `mesher::hdr_tier`: flames,
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,408 @@
//! How resource packs' Polytone particles are drawn (#11486): the rotation modes of custom
//! particles, and GPU particles (wiki "GPU Particles"). A GPU particle never ticks: its spawn is a
//! record (position, velocity, spawn time, lifetime, seed, colour, size, roll), and where it is,
//! how big, what colour and which frame are a function of that record and the time. Here the
//! function runs when the frame's particle quads are built, beside the engine's own particles.
use super::{ParticleLevel, ParticleLightCache};
use crate::hud::Rect;
use crate::mesher::Vertex;
use glam::{DVec3, Mat3, Quat, Vec3 as FVec3};
use horizon_polytone::particles::{CustomParticle, Gpu, RenderType};
use std::collections::HashMap;
use std::sync::Arc;
/// How a particle quad turns (`rotation_mode`).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub(super) enum Orientation {
/// `look_at_xyz`: faces the camera (what every other mode falls back to).
#[default]
Camera,
/// `look_at_y`: turns around Y only.
AroundY,
/// `look_up`: lies flat, facing up.
Flat,
/// `movement_aligned`: stands along its velocity, facing the camera around it.
Movement,
}
impl Orientation {
pub(super) fn parse(mode: &str) -> Self {
match mode {
"look_at_y" => Self::AroundY,
"look_up" => Self::Flat,
"movement_aligned" => Self::Movement,
_ => Self::Camera,
}
}
}
/// The rotation of a quad (corners in its XY plane, facing +Z) at `center` (relative to the
/// camera) moving along `velocity`, rolled by `roll`; `camera` is the camera-facing rotation.
pub(super) fn orient(
mode: Orientation,
center: FVec3,
velocity: FVec3,
roll: f32,
camera: Quat,
) -> Quat {
let rolled = |q: Quat| q * Quat::from_rotation_z(roll);
match mode {
Orientation::Camera => rolled(camera),
Orientation::AroundY => rolled(Quat::from_rotation_y((-center.x).atan2(-center.z))),
Orientation::Flat => rolled(Quat::from_rotation_x(-std::f32::consts::FRAC_PI_2)),
Orientation::Movement => {
let up = velocity.normalize_or_zero();
let right = up.cross((-center).normalize_or_zero()).normalize_or_zero();
if right == FVec3::ZERO {
return rolled(camera);
}
Quat::from_mat3(&Mat3::from_cols(right, up, right.cross(up)))
}
}
}
/// One GPU particle spawn.
#[derive(Clone, Copy, Debug)]
struct Record {
position: [f64; 3],
velocity: [f64; 3],
/// The engine's tick it spawned on, and its life in ticks.
spawn: u64,
lifetime: f32,
seed: u64,
rgba: [f32; 4],
size: f32,
roll: f32,
}
/// A GPU particle type's records: a ring of `limit` slots, the oldest overwritten.
pub(super) struct Store {
def: Arc<CustomParticle>,
gpu: Gpu,
orientation: Orientation,
/// The texture's sprite name in the particle atlas (`ns:particle/path`).
sprite: String,
records: Vec<Record>,
next: usize,
limit: usize,
}
/// The atlas name of a full texture path: `ns:textures/particle/a/b.png` is `ns:particle/a/b`
/// (`particle/a/b` in `minecraft`).
fn sprite_name(texture: &str) -> String {
let (namespace, path) = texture.split_once(':').unwrap_or(("minecraft", texture));
let path = path
.trim_start_matches("textures/")
.trim_end_matches(".png");
if namespace == "minecraft" {
path.to_owned()
} else {
format!("{namespace}:{path}")
}
}
/// SplitMix64: a well-mixed number from `seed` and `i`, for each quad's own randoms.
fn mix(seed: u64, i: u64) -> u64 {
let mut z = seed ^ i.wrapping_mul(0x9E37_79B9_7F4A_7C15);
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
z ^ (z >> 31)
}
/// A number in 0..1 from `mix`.
fn unit(seed: u64, i: u64) -> f64 {
(mix(seed, i) >> 11) as f64 / (1u64 << 53) as f64
}
/// Where a record's particle is after `t` ticks, and how fast it moves: gravity pulls down,
/// friction (`friction` kept per tick, as a continuous drag) slows it toward a terminal velocity.
fn motion(gpu: &Gpu, position: [f64; 3], velocity: [f64; 3], t: f64) -> ([f64; 3], [f64; 3]) {
let drag = if gpu.friction > 0.0 && gpu.friction < 1.0 {
-gpu.friction.ln()
} else {
0.0
};
let gravity = [0.0, -gpu.gravity, 0.0];
let mut at = [0.0; 3];
let mut moving = [0.0; 3];
for i in 0..3 {
let (v, g) = (velocity[i], gravity[i]);
if drag > 1.0e-9 {
let e = (-drag * t).exp();
at[i] = position[i] + v * (1.0 - e) / drag + g * (t / drag - (1.0 - e) / (drag * drag));
moving[i] = v * e + g * (1.0 - e) / drag;
} else {
at[i] = position[i] + v * t + 0.5 * g * t * t;
moving[i] = v + g * t;
}
}
(at, moving)
}
impl Store {
pub(super) fn new(def: &Arc<CustomParticle>) -> Option<Self> {
let gpu = def.gpu.clone()?;
Some(Self {
def: def.clone(),
orientation: Orientation::parse(&def.rotation_mode),
sprite: sprite_name(&gpu.texture),
gpu,
records: Vec::new(),
next: 0,
// The wiki's default ring capacity.
limit: def.limit.map_or(16_384, |l| l.max(1) as usize),
})
}
pub(super) fn is(&self, def: &Arc<CustomParticle>) -> bool {
Arc::ptr_eq(&self.def, def)
}
/// Records a spawn from a particle its initializer set up (position, motion, life, look).
pub(super) fn spawn(&mut self, p: &super::Particle, now: u64, seed: u64) {
if p.lifetime <= 0 {
return;
}
let record = Record {
position: p.position,
velocity: p.velocity,
spawn: now,
lifetime: p.lifetime as f32,
seed,
rgba: p.rgba,
size: p.size,
roll: p.roll,
};
if self.records.len() < self.limit {
self.records.push(record);
} else {
self.records[self.next] = record;
self.next = (self.next + 1) % self.limit;
}
}
/// Forgets the records whose life is over.
pub(super) fn expire(&mut self, now: u64) {
let before = self.records.len();
self.records
.retain(|r| (now.saturating_sub(r.spawn) as f32) < r.lifetime);
if self.records.len() != before {
self.next = 0;
}
}
pub(super) fn len(&self) -> usize {
self.records.len()
}
/// The quads of every live record at `now + partial` ticks.
#[allow(clippy::too_many_arguments)]
pub(super) fn render(
&self,
atlas: &HashMap<String, (Rect, bool)>,
camera: DVec3,
facing: Quat,
now: u64,
partial: f32,
light_cache: &mut ParticleLightCache,
light_at: &dyn Fn([i32; 3]) -> u8,
out: &mut Vec<Vertex>,
additive: &mut Vec<Vertex>,
) {
if self.def.render_type == RenderType::Invisible {
return;
}
let Some(&(rect, _)) = atlas.get(&self.sprite) else {
return;
};
let gpu = &self.gpu;
let translucent = matches!(
self.def.render_type,
RenderType::Translucent | RenderType::AdditiveTranslucent
);
let blend_add = self.def.render_type == RenderType::AdditiveTranslucent;
let target = if blend_add { additive } else { out };
let layer = if translucent && !blend_add {
crate::gfx::TRANSLUCENT_PARTICLE_OVERLAY
} else {
0
};
let (count, area) = gpu.area.map_or((1, [0.0; 3]), |(size, n)| (n, size));
let frames = gpu.frames.max(1);
for r in &self.records {
let t = (now.saturating_sub(r.spawn)) as f32 + partial;
if t >= r.lifetime {
continue;
}
let life = (t / r.lifetime).clamp(0.0, 1.0);
let (at, moving) = motion(gpu, r.position, r.velocity, f64::from(t));
// Fading in and out, and the colour and size toward their ends.
let [fade_in, fade_out] = gpu.fade.map(|f| f as f32);
let mut alpha = r.rgba[3];
if fade_in > 0.0 {
alpha *= (life / fade_in).min(1.0);
}
if fade_out > 0.0 {
alpha *= ((1.0 - life) / fade_out).min(1.0);
}
let mut rgb = [r.rgba[0], r.rgba[1], r.rgba[2]];
if let Some(end) = gpu.color_end {
let channel = |shift: u32| ((end >> shift) & 0xff) as f32 / 255.0;
let to = [channel(16), channel(8), channel(0)];
for i in 0..3 {
rgb[i] += (to[i] - rgb[i]) * life;
}
alpha += (alpha * channel(24) - alpha) * life;
}
let size = gpu
.size_end
.map_or(r.size, |end| r.size + (end as f32 - r.size) * life);
let c = |v: f32| (v.clamp(0.0, 1.0) * 255.0) as u32;
let color = c(alpha) << 24 | c(rgb[2]) << 16 | c(rgb[1]) << 8 | c(rgb[0]);
let frame = if self.def.random_sprite {
(mix(r.seed, 0) % u64::from(frames)) as u32
} else {
((life * frames as f32) as u32).min(frames - 1)
};
let (dv, v0) = ((rect.v1 - rect.v0) / frames as f32, rect.v0);
let (u0, u1) = (rect.u0, rect.u1);
let (v0, v1) = (v0 + dv * frame as f32, v0 + dv * (frame + 1) as f32);
let velocity = FVec3::new(moving[0] as f32, moving[1] as f32, moving[2] as f32);
for quad in 0..u64::from(count) {
// Each quad of an area has its own place in the box, sway phase and roll.
let offset: [f64; 3] = if count > 1 {
std::array::from_fn(|i| (unit(r.seed, quad * 8 + i as u64 + 1) - 0.5) * area[i])
} else {
[0.0; 3]
};
let phase = unit(r.seed, quad * 8 + 4) * std::f64::consts::TAU;
let sway = gpu.sway * (f64::from(t) * 0.1 + phase).sin();
let sway_z = gpu.sway * (f64::from(t) * 0.07 + phase).cos();
let world = [
at[0] + offset[0] + sway,
at[1] + offset[1],
at[2] + offset[2] + sway_z,
];
let block = ParticleLevel::block_at(world);
let packed = light_cache.get(block, light_at);
// kill_below_heightmap: under the surface, where the sky doesn't reach.
if gpu.kill_below_heightmap && packed >> 4 < 15 {
continue;
}
let block_light = u32::from((packed & 15).max(self.def.light_level));
let light = (u32::from(packed >> 4) * 16) | ((block_light * 16) << 8);
let center = (DVec3::from_array(world) - camera).as_vec3();
let roll = r.roll
+ gpu.spin as f32 * t
+ if count > 1 {
(unit(r.seed, quad * 8 + 5) * std::f64::consts::TAU) as f32
} else {
0.0
};
let rotation = orient(self.orientation, center, velocity, roll, facing);
let half = FVec3::new(size, size * gpu.aspect as f32, 0.0);
for (n, uv) in [
((1.0, -1.0), [u1, v1]),
((1.0, 1.0), [u1, v0]),
((-1.0, 1.0), [u0, v0]),
((-1.0, -1.0), [u0, v1]),
] {
let corner = FVec3::new(n.0 * half.x, n.1 * half.y, 0.0);
target.push(Vertex {
normal: [0; 4],
pos: (center + rotation * corner).to_array(),
uv,
layer,
color,
light,
glint_uv: [0.0, 0.0],
});
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn gpu(gravity: f64, friction: f64) -> Gpu {
Gpu {
texture: String::new(),
frames: 1,
gravity,
friction,
size_end: None,
color_end: None,
fade: [0.0; 2],
sway: 0.0,
spin: 0.0,
aspect: 1.0,
area: None,
kill_below_heightmap: false,
}
}
#[test]
fn texture_paths_name_their_atlas_sprites() {
assert_eq!(
sprite_name("davidparticles:textures/particle/weather/rain.png"),
"davidparticles:particle/weather/rain"
);
assert_eq!(
sprite_name("minecraft:textures/particle/generic_7.png"),
"particle/generic_7"
);
}
#[test]
fn motion_follows_gravity_and_drag() {
// No drag: a thrown body, x = v t, y = -g t^2 / 2.
let (at, v) = motion(&gpu(0.1, 1.0), [0.0; 3], [1.0, 0.0, 0.0], 10.0);
assert_eq!(at, [10.0, -5.0, 0.0]);
assert!((v[1] + 1.0).abs() < 1e-12);
// Drag: the fall speed tends to g / drag (terminal velocity).
let friction = 0.9;
let (_, v) = motion(&gpu(0.1, friction), [0.0; 3], [0.0; 3], 1000.0);
assert!((v[1] + 0.1 / -f64::ln(friction)).abs() < 1e-9);
}
#[test]
fn orientations_turn_the_quad_normal() {
let normal = |q: Quat| q * FVec3::Z;
let close = |a: FVec3, b: FVec3| (a - b).length() < 1e-5;
// Lying flat: facing up.
assert!(close(
normal(orient(
Orientation::Flat,
FVec3::ONE,
FVec3::ZERO,
0.0,
Quat::IDENTITY
)),
FVec3::Y
));
// Around Y: facing the camera horizontally (the camera at the origin, the quad east).
assert!(close(
normal(orient(
Orientation::AroundY,
FVec3::new(5.0, 3.0, 0.0),
FVec3::ZERO,
0.0,
Quat::IDENTITY
)),
-FVec3::X
));
// Along its motion: its up is the velocity, its face toward the camera.
let q = orient(
Orientation::Movement,
FVec3::new(0.0, 0.0, -4.0),
FVec3::new(0.0, -2.0, 0.0),
0.0,
Quat::IDENTITY,
);
assert!(close(q * FVec3::Y, -FVec3::Y));
assert!(close(normal(q), FVec3::Z));
}
}
+127 -2
View File
@@ -155,6 +155,10 @@ pub struct Context {
pub source: Option<Who>,
/// The projectile's owner, when the source is a projectile.
pub owner: Option<Who>,
/// The saved UUIDs of `source` and `owner` (`VibrationInfo`'s `uuid` and
/// `projectileOwnerUuid`), when the game knows them for that entity.
pub source_uuid: Option<[i32; 4]>,
pub owner_uuid: Option<[i32; 4]>,
/// `affectedState` is in `dampens_vibrations`.
pub dampening_state: bool,
/// The source is a spectator.
@@ -309,14 +313,29 @@ impl Data {
}
}
fn info_to_nbt(info: &Info) -> Tag {
Tag::compound(vec![
let mut fields = vec![
("game_event", Tag::String(info.event.into())),
("distance", Tag::Float(info.distance.to_bits())),
(
"pos",
Tag::List(info.pos.iter().map(|v| Tag::Double(v.to_bits())).collect()),
),
])
];
// `VibrationInfo.CODEC`: each UUID an int array of four, absent for none.
if let Some(u) = info.source_uuid {
fields.push(("source", Tag::IntArray(u.to_vec())));
}
if let Some(u) = info.owner_uuid {
fields.push(("projectile_owner", Tag::IntArray(u.to_vec())));
}
Tag::compound(fields)
}
/// A UUID in its saved int array form; anything else is no UUID.
fn uuid_field(tag: &Tag, key: &str) -> Option<[i32; 4]> {
match tag.get(key) {
Some(Tag::IntArray(v)) => <[i32; 4]>::try_from(v.as_slice()).ok(),
_ => None,
}
}
fn info_from_nbt(tag: &Tag) -> Option<Info> {
let event = intern(&horizon_client_core::nbt_value::text(
@@ -342,8 +361,12 @@ fn info_from_nbt(tag: &Tag) -> Option<Info> {
event,
distance,
pos,
// The live entities are found again by UUID only when needed; a
// loaded vibration names none of them.
source: None,
owner: None,
source_uuid: uuid_field(tag, "source"),
owner_uuid: uuid_field(tag, "projectile_owner"),
})
}
@@ -623,6 +646,8 @@ mod tests {
pos: [0.0; 3],
source: None,
owner: None,
source_uuid: None,
owner_uuid: None,
}
}
@@ -748,6 +773,106 @@ mod tests {
assert_eq!(back.selector.current.map(|(_, t)| t), Some(41));
}
fn identified(event: &'static str, distance: f32) -> Info {
Info {
source: Some(Who::Mob(7)),
owner: Some(Who::Mob(8)),
source_uuid: Some([100, 0, 0, 1]),
owner_uuid: Some([100, 0, 0, 2]),
..info(event, distance)
}
}
#[test]
fn vibration_uuids_save_as_source_and_projectile_owner() {
let tag = info_to_nbt(&identified("minecraft:step", 3.0));
assert_eq!(tag.get("source"), Some(&Tag::IntArray(vec![100, 0, 0, 1])));
assert_eq!(
tag.get("projectile_owner"),
Some(&Tag::IntArray(vec![100, 0, 0, 2]))
);
// Read back, the identities stay; the live entities do not.
let back = info_from_nbt(&tag).unwrap();
assert_eq!(back.source_uuid, Some([100, 0, 0, 1]));
assert_eq!(back.owner_uuid, Some([100, 0, 0, 2]));
assert_eq!((back.source, back.owner), (None, None));
// A live entity with no saved identity writes nothing for it.
let unnamed = Info {
source: Some(Who::Mob(7)),
..info("minecraft:step", 3.0)
};
let tag = info_to_nbt(&unnamed);
assert!(tag.get("source").is_none() && tag.get("projectile_owner").is_none());
}
#[test]
fn a_loaded_vibration_keeps_only_well_formed_uuids() {
let base = |extra: Vec<(&'static str, Tag)>| {
let mut fields = vec![
("game_event", Tag::String("minecraft:step".into())),
("distance", Tag::Float(3.0f32.to_bits())),
("pos", Tag::List(vec![Tag::Double(0f64.to_bits()); 3])),
];
fields.extend(extra);
info_from_nbt(&Tag::compound(fields)).unwrap()
};
let absent = base(vec![]);
assert_eq!((absent.source_uuid, absent.owner_uuid), (None, None));
let malformed = base(vec![
("source", Tag::IntArray(vec![1, 2, 3])),
(
"projectile_owner",
Tag::String("00000064-0000-0000-0000-000000000002".into()),
),
]);
assert_eq!((malformed.source_uuid, malformed.owner_uuid), (None, None));
// Only the codec's fields are kept: an unknown key is not saved again.
let extra = base(vec![("horizon:raw", Tag::Int(1))]);
assert!(info_to_nbt(&extra).get("horizon:raw").is_none());
}
#[test]
fn a_reloaded_listener_drops_stale_uuids() {
let mut d = Data::default();
d.schedule(identified("minecraft:step", 2.0), 40);
d.tick(41, true, true, |_| 5);
assert_eq!(d.current.and_then(|i| i.source_uuid), Some([100, 0, 0, 1]));
// A listener saved with no identity replaces the old one, not merges.
d = Data::from_nbt(
&Data {
current: Some(info("minecraft:step", 2.0)),
..Data::default()
}
.to_nbt(),
);
assert_eq!(
d.current.map(|i| (i.source_uuid, i.owner_uuid)),
Some((None, None))
);
}
#[test]
fn the_chosen_candidate_carries_its_uuids_into_the_travelling_vibration() {
let mut d = Data::default();
d.schedule(identified("minecraft:step", 2.0), 40);
let saved = Data::from_nbt(&d.to_nbt());
assert_eq!(
saved.selector.current.map(|(i, _)| i.source_uuid),
Some(Some([100, 0, 0, 1]))
);
// The candidate read back is the one that starts travelling.
let mut d = saved;
let t = d.tick(41, true, true, |_| 5);
let travelling = t.started.map(|(i, _)| i).unwrap();
assert_eq!(travelling.source_uuid, Some([100, 0, 0, 1]));
assert_eq!(travelling.owner_uuid, Some([100, 0, 0, 2]));
let saved = Data::from_nbt(&d.to_nbt()).current.unwrap();
assert_eq!(
(saved.source_uuid, saved.owner_uuid),
(Some([100, 0, 0, 1]), Some([100, 0, 0, 2]))
);
}
#[test]
fn wool_on_the_line_occludes() {
let origin = [0.5, 0.5, 0.5];
@@ -390,6 +390,10 @@ impl Settings {
// Horizon's own key bindings live in horizon-extras.txt, not in options.txt.
let emote = settings.extras.emote_key.clone();
settings.keys.set("key.emote", &emote);
// `Options.load`: `sharePresence` decides what the friends service posts.
crate::social::set_share_presence(crate::social::SharePresence::of(
settings.file.int("sharePresence").clamp(0, 2) as u8,
));
settings.describe_for_crashes();
// Options.load: the backend asked for at startup, what a restart is measured from.
Self {
@@ -751,7 +751,8 @@ const SCROLL_RATE: f32 = 18.0;
pub(crate) enum Value {
/// A tick box.
Switch(bool),
/// A value stepped between `<` `>`, as it reads now.
/// A value stepped between `<` `>`, as it reads now: a press steps it on, a Shift press back,
/// and focused, Left and Right do the same.
Choice(String),
/// A slider: left and right step it (`steps`: its stops, else a pixel of the track), a press
/// on the track sets it (a controller's focus does the same).
@@ -992,7 +993,7 @@ pub(crate) trait Hosted {
/// The one way a list scrolls: the wheel, a right stick and a D-pad past the view's edge.
fn scroll(&mut self, s: &Settings, size: (f32, f32), amount: f32);
/// Typing in the search field, Tab and the arrows between controls, Left and Right on a
/// slider, Enter or Space to press, Escape to leave.
/// slider or a choice, Enter or Space to press, Escape to leave.
fn key(
&mut self,
s: &mut Settings,
@@ -1415,6 +1416,9 @@ impl<S: Screen> Shell<S> {
key: matches!(row.value, Value::Key(..)).then_some(waits),
};
self::row(batch, atlas, cols, y, &view);
if row.live && matches!(row.value, Value::Choice(_)) {
crate::widgets::steps_value(cols.row(y));
}
crate::widgets::last_narratable(|n| n.hint = Some(row.described().to_owned()));
}
/// The strip under the plate: the hovered or focused row's name and what it does; with none,
@@ -1738,6 +1742,14 @@ impl<S: Screen + Default> Hosted for Shell<S> {
self.did(&[DRAGGED]);
return vec![DRAGGED];
}
// A choice's `<` `>`: Left steps it back and Right on, as a Shift press and a press.
if matches!(key, K::ArrowLeft | K::ArrowRight)
&& let Some(row) = focused
&& row.live
&& matches!(row.value, Value::Choice(_))
{
return self.activate(s, row, key == K::ArrowLeft, None);
}
if matches!(
key,
K::ArrowUp | K::ArrowDown | K::ArrowLeft | K::ArrowRight
@@ -2303,6 +2315,43 @@ mod tests {
assert_eq!(key(&mut shell, K::Escape), vec![Output::Done]);
}
#[test]
fn left_and_right_step_a_focused_choice_and_leave_other_rows() {
let mut s = Settings::default();
let mut shell = Shell::<Probe>::default();
let mut key = |shell: &mut Shell<Probe>, k| shell.key(&mut s, k, None, false, SIZE);
shell.set_focus(Some(Target::Row(CHOICE)));
// Right is a press, Left a Shift press: the screen's own way back, and the focus stays.
assert_eq!(key(&mut shell, K::ArrowRight), vec![CLICK, CHANGED]);
assert_eq!(key(&mut shell, K::ArrowLeft), vec![CLICK, CHANGED]);
assert_eq!(shell.screen.pressed, [(CHOICE, false), (CHOICE, true)]);
assert_eq!(shell.focus, Some(Target::Row(CHOICE)));
assert!(shell.changed);
// A row that isn't a choice: Left goes to the page list, as before.
shell.set_focus(Some(Target::Row(FIRST_FILLER)));
assert!(key(&mut shell, K::ArrowLeft).is_empty());
assert!(matches!(shell.focus, Some(Target::Page(_))));
assert_eq!(shell.screen.pressed.len(), 2);
}
#[test]
fn a_drawn_choice_tells_the_pad_its_arrows_step_it() {
let atlas = atlas();
let s = Settings::default();
let mut shell = Shell::<Probe>::default();
let mut batch = HudBatch::new([1708, 960]);
shell.draw(&s, &mut batch, &atlas, (0.0, 0.0), false, None);
crate::widgets::end_frame(u64::MAX);
let view = shell.view(&s, (batch.width, batch.height));
let rect_of = |id| {
let mut placed = shell.placed(&view);
view.cols.row(placed.find(|(r, _)| r.id == id).unwrap().1)
};
assert!(crate::widgets::last_steps_value(rect_of(CHOICE)));
assert!(!crate::widgets::last_steps_value(rect_of(SLIDER)));
assert!(!crate::widgets::last_steps_value(rect_of(FIRST_FILLER)));
}
/// A controller presses with a click, then moves its own focus: only keys show this one.
#[test]
fn leaving_the_search_field_gives_the_keys_back() {
+66 -8
View File
@@ -24,12 +24,43 @@ pub struct Textures {
pub signed: bool,
}
/// The `getTexture` endpoint's valid URIs from Minecraft services discovery.
/// The `getTexture` endpoint's valid URIs in production's services discovery
/// document (`MinecraftServicesEnvironment.PROD`).
pub const TEXTURE_URIS: [&str; 2] = [
"https://textures.minecraft.net/texture/{textureId}",
"http://textures.minecraft.net/texture/{textureId}",
];
/// `MinecraftServicesDiscoveryService`'s valid texture URIs: the discovery
/// document's `profiles` / `getTexture` `validUris`, which
/// `MinecraftServicesSessionService.isAllowedTextureDomain` checks texture URLs
/// against. Production's document lists [`TEXTURE_URIS`], so they are used
/// without asking it; another environment (`HORIZON_API_DISCOVERY`) is asked
/// once per game, and keeps [`TEXTURE_URIS`] when its document lists none or
/// can't be fetched.
pub fn texture_uris() -> &'static [String] {
static URIS: std::sync::OnceLock<Vec<String>> = std::sync::OnceLock::new();
URIS.get_or_init(|| {
crate::auth::discovery_overridden()
.then(|| crate::auth::discovery().ok())
.flatten()
.and_then(|doc| discovery_texture_uris(&doc))
.unwrap_or_else(|| TEXTURE_URIS.map(str::to_owned).to_vec())
})
}
/// The `profiles` / `getTexture` endpoint's `validUris` in a discovery
/// document, when it lists any.
fn discovery_texture_uris(doc: &serde_json::Value) -> Option<Vec<String>> {
let uris: Vec<String> = doc
.pointer("/discovery/profiles/endpoints/getTexture/validUris")?
.as_array()?
.iter()
.filter_map(|u| u.as_str().map(str::to_owned))
.collect();
(!uris.is_empty()).then_some(uris)
}
/// `java.util.Base64.getDecoder().decode`.
pub fn base64_decode(text: &str) -> Option<Vec<u8>> {
use base64::{
@@ -44,7 +75,7 @@ pub fn base64_decode(text: &str) -> Option<Vec<u8>> {
/// `unpackTextures(property)`: the textures of a `textures` property's
/// base64 JSON payload.
pub fn unpack(value: &str, signature: Signature, valid_uris: &[&str]) -> Textures {
pub fn unpack(value: &str, signature: Signature, valid_uris: &[impl AsRef<str>]) -> Textures {
let empty = Textures::default();
let Some(json) = base64_decode(value)
.map(|b| String::from_utf8_lossy(&b).into_owned())
@@ -68,7 +99,7 @@ pub fn unpack(value: &str, signature: Signature, valid_uris: &[&str]) -> Texture
};
let allowed = valid_uris
.iter()
.any(|u| url.starts_with(&u.replace("{textureId}", "")));
.any(|u| url.starts_with(&u.as_ref().replace("{textureId}", "")));
if !allowed {
return empty;
}
@@ -95,7 +126,7 @@ pub fn unpack(value: &str, signature: Signature, valid_uris: &[&str]) -> Texture
/// those textures be unsigned (`getInsecureSkinInformation`).
pub fn profile_skin_url(profile: &serde_json::Value) -> Option<String> {
let (value, signature) = parse_profile(profile).textures?;
unpack(&value, signature, &TEXTURE_URIS).skin
unpack(&value, signature, texture_uris()).skin
}
/// The textures of a Minecraft services `/minecraft/profile` answer: the
@@ -114,7 +145,7 @@ pub fn services_profile_textures(profile: &serde_json::Value) -> Textures {
.and_then(|t| t.get("url"))
.and_then(|u| u.as_str())
.filter(|url| {
TEXTURE_URIS
texture_uris()
.iter()
.any(|u| url.starts_with(&u.replace("{textureId}", "")))
})
@@ -127,7 +158,7 @@ pub fn services_profile_textures(profile: &serde_json::Value) -> Textures {
.and_then(|t| t.get("url"))
.and_then(|u| u.as_str())
.filter(|url| {
TEXTURE_URIS
texture_uris()
.iter()
.any(|u| url.starts_with(&u.replace("{textureId}", "")))
})
@@ -155,7 +186,7 @@ pub fn profile_skin_url_nbt(profile: &horizon_world::nbt::Tag) -> Option<String>
.then(|| nbt_text(p.get("value")))
.flatten()
})?;
unpack(&value, Signature::Unsigned, &TEXTURE_URIS).skin
unpack(&value, Signature::Unsigned, texture_uris()).skin
}
/// `ResolvableProfile.skinPatch().body()` of a skull's `profile` NBT: the
@@ -478,7 +509,7 @@ struct HeadProfile {
fn prepare_head(profile: &serde_json::Value) -> HeadProfile {
let info = parse_profile(profile);
let skin = info.textures.as_ref().and_then(|(value, signature)| {
let url = unpack(value, *signature, &TEXTURE_URIS).skin?;
let url = unpack(value, *signature, texture_uris()).skin?;
let location = skin_location(&texture_hash(&url)?);
Some((url, location))
});
@@ -1362,6 +1393,33 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir);
}
/// `MinecraftServicesDiscoveryService`: another environment's document
/// names its own texture host; one that lists none names nothing.
#[test]
fn texture_uris_come_from_the_discovery_document() {
use super::{Signature, discovery_texture_uris, unpack};
let doc = serde_json::json!({"discovery": {"profiles": {"endpoints": {"getTexture": {
"validUris": ["https://skins.example.org/t/{textureId}"]
}}}}});
let uris = discovery_texture_uris(&doc).unwrap();
assert_eq!(uris, ["https://skins.example.org/t/{textureId}"]);
// {"textures":{"SKIN":{"url":"https://skins.example.org/t/abc"}}}
let ours =
"eyJ0ZXh0dXJlcyI6eyJTS0lOIjp7InVybCI6Imh0dHBzOi8vc2tpbnMuZXhhbXBsZS5vcmcvdC9hYmMifX19";
assert_eq!(
unpack(ours, Signature::Signed, &uris).skin.as_deref(),
Some("https://skins.example.org/t/abc")
);
assert_eq!(
unpack(ours, Signature::Signed, &super::TEXTURE_URIS).skin,
None
);
let none = serde_json::json!({"discovery": {"profiles": {"endpoints": {}}}});
assert_eq!(discovery_texture_uris(&none), None);
let empty = serde_json::json!({"discovery": {"profiles": {"endpoints": {"getTexture": {"validUris": []}}}}});
assert_eq!(discovery_texture_uris(&empty), None);
}
/// `MinecraftProfileTexture.getMetadata("model")`: `slim` marks a slim
/// skin; no metadata is the wide (classic) model.
#[test]
+860
View File
@@ -0,0 +1,860 @@
//! Vanilla's friends machinery without its screens: authlib's
//! `MinecraftServicesFriendsService` (`getFriends`, `updateFriends`,
//! `updateAttributes`, `updatePresence`), `RemoteFriendListUpdateHandler`'s worker and
//! state, `PlayerSocialManager`'s flags, and `PresenceHandler`'s posting.
//!
//! `title::poll_account` starts this the way `Minecraft`'s constructor does: the
//! account's `friendsPreferences` (`UserFlag.FRIENDS_ENABLED`,
//! `UserFlag.ACCEPT_FRIEND_INVITES`) decide whether the worker reads the lists. No
//! screen shows them yet; the overlay and the Online options are later parts.
//!
//! The requests keep authlib's cache rules: `If-None-Match` with the last `ETag`, a 304
//! or an empty answer keeps the cached lists, `Retry-After` sets the poll interval, and
//! `getFriendData` waits out a ten-second cooldown between reads. The presence statuses
//! are `PresenceHandler.getPresenceStatus`'s, supplied by the client each tick.
use crate::auth::{self, AuthSession};
use serde_json::{Value, json};
use std::{
sync::{
Arc, Mutex, OnceLock,
atomic::{AtomicBool, Ordering},
},
thread,
time::{Duration, Instant},
};
/// authlib's `FriendsService.ResultCode`.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ResultCode {
Success,
Error,
ServiceNotAvailable,
TooManyRequests,
Forbidden,
UpgradeNeeded,
ConnectionIssue,
TemporaryUnavailable,
UnknownProfile,
Unauthorized,
GenericError,
}
/// `FriendDto`: one friend or one pending request.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Friend {
pub id: u128,
pub name: String,
}
/// authlib's `FriendData`: the three lists the service answers.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct FriendData {
pub friends: Vec<Friend>,
pub incoming: Vec<Friend>,
pub outgoing: Vec<Friend>,
}
/// authlib's `PresenceStatus`, as `updatePresence` stores it.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum PresenceStatus {
Online,
PlayingOffline,
PlayingRealms,
PlayingServer,
PlayingHostedServer,
Offline,
}
impl PresenceStatus {
/// `PresenceStatus.name()`: the string `updatePresence` sends and
/// `PresenceStatusDto.status` carries.
fn wire(self) -> &'static str {
match self {
Self::Online => "ONLINE",
Self::PlayingOffline => "PLAYING_OFFLINE",
Self::PlayingRealms => "PLAYING_REALMS",
Self::PlayingServer => "PLAYING_SERVER",
Self::PlayingHostedServer => "PLAYING_HOSTED_SERVER",
Self::Offline => "OFFLINE",
}
}
}
/// `PresenceStatusDto`.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Presence {
pub id: u128,
pub status: PresenceStatus,
}
/// `RemoteFriendListUpdateHandler.State`.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum State {
Loading,
UpgradeNeeded,
ConnectionIssue,
UserMayLackActiveProfile,
Unauthorized,
TemporaryUnavailable,
GenericError,
Success,
}
impl State {
/// `RemoteFriendListUpdateHandler.mapResultCodeToState`.
fn of(code: ResultCode) -> Self {
match code {
ResultCode::Success => Self::Success,
ResultCode::UpgradeNeeded => Self::UpgradeNeeded,
ResultCode::ConnectionIssue => Self::ConnectionIssue,
ResultCode::UnknownProfile => Self::UserMayLackActiveProfile,
ResultCode::Unauthorized => Self::Unauthorized,
ResultCode::TemporaryUnavailable
| ResultCode::Forbidden
| ResultCode::ServiceNotAvailable
| ResultCode::TooManyRequests => Self::TemporaryUnavailable,
ResultCode::Error | ResultCode::GenericError => Self::GenericError,
}
}
}
/// `Options.sharePresence` (`PresenceSharing`): how much of the player the service sees.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum SharePresence {
None,
Limited,
All,
}
impl SharePresence {
/// `options.txt`'s `sharePresence` (0 none, 1 limited, 2 all; `Options` defaults to
/// all).
pub fn of(setting: u8) -> Self {
match setting {
0 => Self::None,
1 => Self::Limited,
_ => Self::All,
}
}
}
/// `MinecraftServicesFriendsService`: the HTTP client and its caches.
#[derive(Default)]
struct Service {
friends_etag: Option<String>,
presence_etag: Option<String>,
friends_cache: FriendData,
presence_cache: Vec<Presence>,
/// `getFriendsPollInterval`/`getPresencePollInterval`: the service's `Retry-After`.
friends_poll: Option<Duration>,
presence_poll: Option<Duration>,
/// `requestCooldown`: ten seconds between `getFriendData` requests.
cooldown: Option<Instant>,
/// `requestPending`: one list request at a time.
pending: bool,
}
impl Service {
/// `canMakeRequest`.
fn can_make_request(&self) -> bool {
self.cooldown.is_none_or(|at| Instant::now() > at)
}
}
/// `RemoteFriendListUpdateHandler`.
struct Handler {
enabled: bool,
state: State,
latest: FriendData,
/// `lastUpdateNanos`: a read at that moment is due; `None` reads at once.
last_update: Option<Instant>,
/// A forced read (`forceUpdate`) is due, whatever the interval.
forced: bool,
/// `getUpdateIntervalNanos`'s foreground: the friends overlay is open.
foreground: bool,
}
impl Default for Handler {
fn default() -> Self {
Self {
enabled: false,
state: State::Loading,
latest: FriendData::default(),
last_update: None,
forced: false,
foreground: false,
}
}
}
impl Handler {
/// `getUpdateIntervalNanos`: the service's interval (a minute when it named none),
/// five times as long in the background.
fn interval(&self, service: &Service) -> Duration {
let foreground = service.friends_poll.unwrap_or(Duration::from_secs(60));
if self.foreground {
foreground
} else {
foreground * 5
}
}
}
/// `PresenceHandler`.
struct PresenceHandler {
latest: Vec<Presence>,
last_post: Instant,
/// `updatePresence`: post at the next tick.
update: bool,
/// `PresenceHandler.getPresenceStatus`'s answer, from the client's state.
playing: PresenceStatus,
}
impl Default for PresenceHandler {
fn default() -> Self {
Self {
latest: Vec::new(),
// `lastPresencePost = Instant.now()`: the first post waits an interval.
last_post: Instant::now(),
// Its constructor sets `updatePresence = true`.
update: true,
playing: PresenceStatus::Online,
}
}
}
/// `PlayerSocialManager`'s flags.
#[derive(Default)]
struct Manager {
friend_list_enabled: bool,
allow_friend_requests: bool,
}
struct Inner {
service: Service,
handler: Handler,
presence: PresenceHandler,
manager: Manager,
share: SharePresence,
started: bool,
}
impl Default for Inner {
fn default() -> Self {
Self {
service: Service::default(),
handler: Handler::default(),
presence: PresenceHandler::default(),
manager: Manager::default(),
share: SharePresence::All,
started: false,
}
}
}
fn inner() -> &'static Arc<Mutex<Inner>> {
static INNER: OnceLock<Arc<Mutex<Inner>>> = OnceLock::new();
INNER.get_or_init(|| Arc::new(Mutex::new(Inner::default())))
}
fn lock() -> std::sync::MutexGuard<'static, Inner> {
inner().lock().unwrap_or_else(|e| e.into_inner())
}
/// `Minecraft`'s constructor: make the social manager, whose `friendListEnabled` comes
/// from the account's `friendsPreferences`, and start the updater when it is on.
/// Called once; later calls leave the running manager alone.
pub fn init(friends_enabled: bool, allow_requests: bool) {
{
let mut inner = lock();
if inner.started {
return;
}
inner.started = true;
inner.manager.friend_list_enabled = friends_enabled;
inner.manager.allow_friend_requests = allow_requests;
inner.handler.enabled = friends_enabled;
}
spawn_updater();
}
/// `PlayerSocialManager.setFriendListEnabled`: the Online options switch.
pub fn set_friend_list_enabled(enabled: bool) {
let mut inner = lock();
inner.manager.friend_list_enabled = enabled;
inner.handler.enabled = enabled;
if enabled {
inner.handler.last_update = None;
}
}
/// `PlayerSocialManager.isFriendListEnabled`.
pub fn is_friend_list_enabled() -> bool {
lock().manager.friend_list_enabled
}
/// `PlayerSocialManager.isAllowFriendRequests`.
pub fn allow_friend_requests() -> bool {
lock().manager.allow_friend_requests
}
/// `PlayerSocialManager.setAllowFriendRequests`.
pub fn set_allow_friend_requests(allow: bool) {
lock().manager.allow_friend_requests = allow;
}
/// `PlayerSocialManager.getFriends`.
pub fn friends() -> Vec<Friend> {
lock().handler.latest.friends.clone()
}
/// `PlayerSocialManager.getIncomingRequests`.
pub fn incoming_requests() -> Vec<Friend> {
lock().handler.latest.incoming.clone()
}
/// `PlayerSocialManager.getOutgoingRequests`.
pub fn outgoing_requests() -> Vec<Friend> {
lock().handler.latest.outgoing.clone()
}
/// `PlayerSocialManager.getFriendListState`.
pub fn friend_list_state() -> State {
lock().handler.state
}
/// `PresenceHandler.getLatestPresence`.
pub fn latest_presence() -> Vec<Presence> {
lock().presence.latest.clone()
}
/// `RemoteFriendListUpdateHandler`'s foreground interval: the friends overlay is open.
pub fn set_foreground(open: bool) {
lock().handler.foreground = open;
}
/// `Options.sharePresence`: what presence the handler posts.
pub fn set_share_presence(share: SharePresence) {
lock().share = share;
}
/// `PresenceHandler.getPresenceStatus`, supplied by the client from its world state.
pub fn set_presence_status(status: PresenceStatus) {
lock().presence.playing = status;
}
/// `Minecraft.runTick`: `PresenceHandler.tick`, once per client tick.
pub fn tick() {
let due = {
let inner = lock();
if !inner.manager.friend_list_enabled || inner.handler.latest.friends.is_empty() {
return;
}
let interval = inner
.service
.presence_poll
.unwrap_or(Duration::from_secs(60));
let since = inner.presence.last_post.elapsed();
(inner.presence.update && since >= interval) || since >= interval * 5
};
if due {
update_presence();
}
}
/// `PresenceHandler.updatePresence`.
fn update_presence() {
let status = {
let mut inner = lock();
inner.presence.update = false;
inner.presence.last_post = Instant::now();
match inner.share {
SharePresence::None => PresenceStatus::Offline,
SharePresence::Limited => PresenceStatus::Online,
SharePresence::All => inner.presence.playing,
}
};
let _ = thread::Builder::new()
.name("Friends presence".into())
.spawn(move || {
if let Some(presence) = post_presence(status) {
lock().presence.latest = presence;
}
});
}
/// `PresenceHandler.tryUpdatePresence`: the world was entered (or the overlay opened).
pub fn try_update_presence() {
lock().presence.update = true;
}
/// `PresenceHandler.sendOfflinePresence`: the game is closing.
pub fn send_offline_presence() {
static SENT: AtomicBool = AtomicBool::new(false);
{
let inner = lock();
if !inner.manager.friend_list_enabled || inner.handler.latest.friends.is_empty() {
return;
}
}
// `Minecraft.close` happens once; a second quit path must not post twice.
if SENT.swap(true, Ordering::Relaxed) {
return;
}
let _ = thread::Builder::new()
.name("Friends offline".into())
.spawn(|| {
let _ = post_presence(PresenceStatus::Offline);
});
}
/// `FriendsService.sendFriendRequest(name)`.
pub fn send_friend_request(name: &str) -> ResultCode {
friend_action(json!({"name": name, "updateType": "ADD"}))
}
/// `FriendsService.removeFriend`/`declineIncomingFriendRequest`/
/// `revokeOutgoingFriendRequest` (`updateType` REMOVE).
pub fn remove_friend(id: u128) -> ResultCode {
friend_action(json!({"profileId": uuid(id), "updateType": "REMOVE"}))
}
/// `FriendsService.acceptIncomingFriendRequest`: ADD by profile id.
pub fn accept_incoming_friend_request(id: u128) -> ResultCode {
friend_action(json!({"profileId": uuid(id), "updateType": "ADD"}))
}
/// `FriendsService.updateFriendSettings`: the account's flags, server-side.
pub fn update_friend_settings(friends_enabled: bool, allow_requests: bool) -> ResultCode {
let Some(session) = session() else {
return ResultCode::Unauthorized;
};
let body = json!({
"friendsPreferences": {
"friends": toggle(friends_enabled),
"acceptInvites": toggle(allow_requests),
}
});
let Some(url) = url("updateAttributes") else {
return ResultCode::ServiceNotAvailable;
};
match send_json(&session, "POST", &url, &body.to_string(), None) {
Ok(_) => ResultCode::Success,
Err(failure) => failure.code,
}
}
fn toggle(on: bool) -> &'static str {
if on { "ENABLED" } else { "DISABLED" }
}
/// `MinecraftServicesFriendsService.putFriendAction`: the answer already is the new
/// lists, as authlib caches it, and the handler reads again.
fn friend_action(body: Value) -> ResultCode {
let Some(session) = session() else {
return ResultCode::Unauthorized;
};
let Some(url) = url("updateFriends") else {
return ResultCode::ServiceNotAvailable;
};
let answer = match send_json(&session, "PUT", &url, &body.to_string(), None) {
Ok(answer) => answer,
Err(failure) => return failure.code,
};
let code = if answer.body.is_empty() {
ResultCode::Success
} else {
match parse_friends(&answer.body) {
Some(data) => {
let mut inner = lock();
inner.service.friends_cache = data;
inner.service.friends_etag = None;
inner.service.cooldown = Some(Instant::now() + Duration::from_secs(10));
ResultCode::Success
}
None => ResultCode::Error,
}
};
if code == ResultCode::Success {
// `PlayerSocialManager.runAction` forces a read after a successful action.
lock().handler.forced = true;
}
code
}
/// `RemoteFriendListUpdateHandler.start`'s worker: one read per interval while the list
/// is enabled, the first one as soon as it starts.
fn spawn_updater() {
static STARTED: AtomicBool = AtomicBool::new(false);
if STARTED.swap(true, Ordering::Relaxed) {
return;
}
let _ = thread::Builder::new()
.name("Friends List".into())
.spawn(|| {
loop {
thread::sleep(Duration::from_secs(1));
let due = {
let inner = lock();
if !inner.handler.enabled {
continue;
}
inner.handler.forced
|| inner
.handler
.last_update
.is_none_or(|at| at.elapsed() >= inner.handler.interval(&inner.service))
};
if due {
update_friend_data();
}
}
});
}
/// `RemoteFriendListUpdateHandler.runUpdateFriendDataInternal`.
fn update_friend_data() {
let etag = {
let mut inner = lock();
if inner.service.pending {
// Another read is in flight; its answer is the cache's.
return;
}
if !inner.service.can_make_request() {
inner.handler.forced = false;
inner.handler.state = State::Success;
inner.handler.latest = inner.service.friends_cache.clone();
inner.handler.last_update = Some(Instant::now());
return;
}
inner.service.pending = true;
inner.service.cooldown = Some(Instant::now() + Duration::from_secs(10));
inner.service.friends_etag.clone()
};
log::debug!("Performing Friends List update");
let result = match session() {
Some(session) => fetch_friends(&session, etag.as_deref()),
None => Err(Failure {
code: ResultCode::Unauthorized,
}),
};
let mut inner = lock();
inner.service.pending = false;
inner.handler.forced = false;
inner.handler.last_update = Some(Instant::now());
match result {
Ok(answer) => {
if let Some(retry) = answer.retry_after {
inner.service.friends_poll = Some(retry);
}
if let Some(etag) = answer.etag {
inner.service.friends_etag = Some(etag);
}
if answer.status != 304 && !answer.body.is_empty() {
match parse_friends(&answer.body) {
Some(data) => inner.service.friends_cache = data,
None => {
// authlib's parse failure is an ERROR result: the handler keeps
// the cached lists and shows the generic error.
inner.handler.latest = inner.service.friends_cache.clone();
inner.handler.state = State::GenericError;
return;
}
}
}
inner.handler.latest = inner.service.friends_cache.clone();
inner.handler.state = State::Success;
}
Err(failure) => {
inner.handler.latest = inner.service.friends_cache.clone();
inner.handler.state = State::of(failure.code);
log::warn!(
"Friends List update failed with result code: {:?}",
failure.code
);
}
}
}
/// `MinecraftServicesFriendsService.presence`: post one status, cache the answer.
/// `None` means the answer said nothing new (304, an empty body or a failure); the
/// handler keeps its last presence then.
fn post_presence(status: PresenceStatus) -> Option<Vec<Presence>> {
let session = session()?;
let url = url("updatePresence")?;
let body = json!({"status": status.wire()}).to_string();
let etag = lock().service.presence_etag.clone();
let answer = send_json(&session, "POST", &url, &body, etag.as_deref()).ok()?;
let mut inner = lock();
if let Some(retry) = answer.retry_after {
inner.service.presence_poll = Some(retry);
}
if let Some(etag) = answer.etag {
inner.service.presence_etag = Some(etag);
}
if answer.status == 304 || answer.body.is_empty() {
return None;
}
// `PresenceResponse`: a missing `presence` field is an empty list, replacing the
// cached one; malformed JSON keeps the cache, as authlib's exception path does.
if let Ok(value) = serde_json::from_str::<Value>(&answer.body) {
let presence = presence_of(&value);
inner.service.presence_cache = presence.clone();
return Some(presence);
}
None
}
/// `MinecraftServicesFriendsService.getFriendData`'s request.
fn fetch_friends(session: &AuthSession, etag: Option<&str>) -> Result<Answer, Failure> {
let url = url("getFriends").ok_or(Failure {
code: ResultCode::ServiceNotAvailable,
})?;
send_json(session, "GET", &url, "", etag)
}
/// One HTTP answer: its status, cache headers and body.
struct Answer {
status: u16,
etag: Option<String>,
retry_after: Option<Duration>,
body: String,
}
struct Failure {
code: ResultCode,
}
/// The discovery document's `player` service endpoint.
fn session() -> Option<Arc<AuthSession>> {
auth::current().filter(|s| !s.expired())
}
fn uuid(id: u128) -> String {
let hex = format!("{id:032x}");
format!(
"{}-{}-{}-{}-{}",
&hex[0..8],
&hex[8..12],
&hex[12..16],
&hex[16..20],
&hex[20..32]
)
}
fn url(name: &str) -> Option<String> {
let doc = auth::discovery().ok()?;
auth::endpoint(&doc, "player", name)
}
/// authlib's `MinecraftClient`: the bearer token, `If-None-Match` when a cache has an
/// `ETag`, and `Retry-After` back to the caller.
fn send_json(
session: &AuthSession,
method: &str,
url: &str,
body: &str,
etag: Option<&str>,
) -> Result<Answer, Failure> {
let bearer = format!("Bearer {}", session.access_token());
let agent = auth::agent();
let mut request = match method {
"GET" => agent.get(url),
"POST" => agent.post(url),
"PUT" => agent.put(url),
_ => {
return Err(Failure {
code: ResultCode::Error,
});
}
};
request = request.set("Authorization", &bearer);
if let Some(etag) = etag {
request = request.set("If-None-Match", etag);
}
let response = if method == "GET" {
request.call()
} else {
request
.set("Content-Type", "application/json; charset=utf-8")
.send_string(body)
}
.map_err(http_error)?;
let status = response.status();
let etag = response.header("ETag").map(str::to_owned);
let retry_after = response.header("Retry-After").and_then(parse_retry_after);
if status == 304 {
return Ok(Answer {
status,
etag,
retry_after,
body: String::new(),
});
}
let body = response.into_string().map_err(|_| Failure {
code: ResultCode::Error,
})?;
Ok(Answer {
status,
etag,
retry_after,
body,
})
}
/// authlib's `MinecraftClient.parseRetryAfter`: whole seconds above zero.
fn parse_retry_after(value: &str) -> Option<Duration> {
value
.trim()
.parse::<u64>()
.ok()
.filter(|seconds| *seconds > 0)
.map(Duration::from_secs)
}
/// `MinecraftServicesFriendsService.handleHttpError`.
fn http_error(error: ureq::Error) -> Failure {
let code = match error {
ureq::Error::Status(status, _) => match status {
429 => ResultCode::TooManyRequests,
500.. => ResultCode::ServiceNotAvailable,
403 => ResultCode::Forbidden,
400 => ResultCode::UnknownProfile,
401 => ResultCode::Unauthorized,
_ => ResultCode::Error,
},
ureq::Error::Transport(_) => ResultCode::ConnectionIssue,
};
Failure { code }
}
/// authlib's `FriendsListResponse` (`getFriends`/`updateFriends` body).
pub fn parse_friends(body: &str) -> Option<FriendData> {
let v: Value = serde_json::from_str(body).ok()?;
Some(FriendData {
friends: parse_friend_list(v.get("friends")),
incoming: parse_friend_list(v.get("incomingRequests")),
outgoing: parse_friend_list(v.get("outgoingRequests")),
})
}
fn parse_friend_list(value: Option<&Value>) -> Vec<Friend> {
value
.and_then(|v| v.as_array())
.into_iter()
.flatten()
.filter_map(|friend| {
Some(Friend {
id: auth::parse_uuid(friend.get("profileId")?.as_str()?)?,
name: friend.get("name")?.as_str()?.to_owned(),
})
})
.collect()
}
/// authlib's `PresenceResponse`.
pub fn parse_presence(body: &str) -> Option<Vec<Presence>> {
let v: Value = serde_json::from_str(body).ok()?;
Some(presence_of(&v))
}
/// `PresenceResponse.presence`: a missing list reads as empty, as Gson's null does.
fn presence_of(v: &Value) -> Vec<Presence> {
v.get("presence")
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter_map(|entry| {
let status = entry.get("status")?.as_str()?;
let status = match status {
"ONLINE" => PresenceStatus::Online,
"PLAYING_OFFLINE" => PresenceStatus::PlayingOffline,
"PLAYING_REALMS" => PresenceStatus::PlayingRealms,
"PLAYING_SERVER" => PresenceStatus::PlayingServer,
"PLAYING_HOSTED_SERVER" => PresenceStatus::PlayingHostedServer,
_ => PresenceStatus::Offline,
};
Some(Presence {
id: auth::parse_uuid(entry.get("profileId")?.as_str()?)?,
status,
})
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_friends_answer_reads_its_three_lists() {
let data = parse_friends(
r#"{"friends":[{"profileId":"0123456789abcdef0123456789abcdef","name":"Alex"}],
"incomingRequests":[],"outgoingRequests":[{"profileId":"fedcba9876543210fedcba9876543210","name":"Steve"}]}"#,
)
.unwrap();
assert_eq!(
data.friends,
vec![Friend {
id: 0x0123_4567_89ab_cdef_0123_4567_89ab_cdef,
name: "Alex".into()
}]
);
assert!(data.incoming.is_empty());
assert_eq!(data.outgoing[0].name, "Steve");
// The service's answer to an account with no friends, as the live check
// captured it: the three lists plus an `empty` flag the parser ignores.
let empty = parse_friends(
r#"{"empty":true,"friends":[],"incomingRequests":[],"outgoingRequests":[]}"#,
)
.unwrap();
assert_eq!(empty, FriendData::default());
}
#[test]
fn a_presence_answer_reads_each_status() {
let presence = parse_presence(
r#"{"presence":[
{"profileId":"0123456789abcdef0123456789abcdef","pmid":"00000000000000000000000000000000","status":"PLAYING_REALMS","lastUpdated":"2026-11-08T12:00:00Z"},
{"profileId":"fedcba9876543210fedcba9876543210","pmid":"00000000000000000000000000000000","status":"OFFLINE","lastUpdated":"2026-11-08T12:00:00Z"}]}"#,
)
.unwrap();
assert_eq!(presence[0].status, PresenceStatus::PlayingRealms);
assert_eq!(presence[1].status, PresenceStatus::Offline);
// `PresenceResponse` without its list is empty, not missing.
assert_eq!(parse_presence("{}"), Some(Vec::new()));
assert_eq!(parse_presence("not json"), None);
}
#[test]
fn result_codes_map_to_the_handlers_states() {
assert_eq!(State::of(ResultCode::Success), State::Success);
assert_eq!(
State::of(ResultCode::TooManyRequests),
State::TemporaryUnavailable
);
assert_eq!(
State::of(ResultCode::UnknownProfile),
State::UserMayLackActiveProfile
);
assert_eq!(State::of(ResultCode::UpgradeNeeded), State::UpgradeNeeded);
assert_eq!(
State::of(ResultCode::ConnectionIssue),
State::ConnectionIssue
);
}
#[test]
fn the_share_presence_option_maps_to_its_levels() {
assert_eq!(SharePresence::of(0), SharePresence::None);
assert_eq!(SharePresence::of(1), SharePresence::Limited);
assert_eq!(SharePresence::of(2), SharePresence::All);
assert_eq!(SharePresence::of(9), SharePresence::All);
}
#[test]
fn retry_after_reads_whole_positive_seconds() {
assert_eq!(parse_retry_after("30"), Some(Duration::from_secs(30)));
assert_eq!(parse_retry_after(" 5 "), Some(Duration::from_secs(5)));
assert_eq!(parse_retry_after("0"), None);
assert_eq!(parse_retry_after("later"), None);
}
}
+1 -1
View File
@@ -721,7 +721,7 @@ impl Sprites {
/// A PNG's width, height and pixels as RGBA. From the prepared data file
/// when it holds the texture: no file opened per texture, nor for its absent
/// LabPBR maps.
fn read_rgba(path: &Path) -> Result<(u32, u32, Vec<u8>)> {
pub(crate) fn read_rgba(path: &Path) -> Result<(u32, u32, Vec<u8>)> {
let bytes = horizon_core::prepared_files::read(path)?;
let mut decoder = png::Decoder::new(std::io::Cursor::new(bytes));
decoder.ignore_checksums(true);
@@ -12,7 +12,10 @@ struct StaticEntityUniform {
@location(5) normal: vec4<f32>, @location(6) glint_uv: vec2<f32>,
@location(7) section_origin: vec3<i32>) -> Out {
let block_delta = section_origin - static_entity.camera_block.xyz;
// [world warp static entity]
// (a world warp's pipelines put the vertex through the mod's `world_warp`: gfx/world_warp.rs)
let camera_relative = vec3<f32>(block_delta) + position - static_entity.camera_fraction.xyz;
// [end world warp static entity]
var out = entity_vertex(camera_relative,
uv, overlay & 0xffffu, color, light, normal);
out.glint_uv = glint_uv;
+72 -42
View File
@@ -286,7 +286,7 @@ enum Job {
Decorate(i32, i32, Box<[Proto; 9]>, Vec<(usize, Chunk)>),
Finish(Proto),
Light(i32, i32, Box<Region>, u64, bool),
Mesh(i32, i32, i32, Box<Region>, u64, MeshSettings),
Mesh(i32, i32, i32, Arc<Region>, u64, MeshSettings),
/// Proto chunks checked out to be saved (`ChunkGenerator::save_protos`),
/// off the main thread like vanilla's IOWorker.
Save(Vec<((i32, i32), Proto)>),
@@ -1185,13 +1185,12 @@ impl Streaming {
/// The resident sections `LevelExtractor.allChanged` queues again. An
/// unlit column will queue itself when its light job completes.
fn mark_loaded_sections_dirty(world: &mut World, lit: &FxHashSet<(i32, i32)>) {
let mask = SectionMask::below(world.layout().sections());
for &(x, z) in world.chunks.keys() {
if !lit.contains(&(x, z)) {
continue;
}
for sy in 0..world.layout().sections() as i32 {
world.dirty_sections.insert((x, sy, z));
}
world.dirty_sections.insert_column_mask((x, z), mask);
}
}
/// A column received from a server, with the light it sent
@@ -1204,10 +1203,12 @@ impl Streaming {
world.chunks.insert(key, Arc::new(chunk));
self.lit.insert(key);
self.generated_total += 1;
let mask = SectionMask::below(world.layout().sections());
for dz in -1..=1 {
for dx in -1..=1 {
for sy in 0..world.layout().sections() as i32 {
world.dirty_sections.insert((key.0 + dx, sy, key.1 + dz));
let n = (key.0 + dx, key.1 + dz);
if n == key || world.chunks.contains_key(&n) {
world.dirty_sections.insert_column_mask(n, mask);
}
}
}
@@ -1525,7 +1526,7 @@ impl Streaming {
z,
)
}
fn region(world: &World, x: i32, z: i32) -> Option<Box<Region>> {
fn region(world: &World, x: i32, z: i32) -> Option<Arc<Region>> {
let mut region: Region = Default::default();
for dz in 0..3 {
for dx in 0..3 {
@@ -1537,7 +1538,7 @@ impl Streaming {
);
}
}
Some(Box::new(region))
Some(Arc::new(region))
}
pub fn is_lit(&self, x: i32, z: i32) -> bool {
self.lit.contains(&(x, z))
@@ -1937,8 +1938,8 @@ impl Streaming {
}) {
continue;
}
let keys: Vec<(i32, i32)> =
(0..9).map(|i| (x + (i % 3) - 1, z + (i / 3) - 1)).collect();
let keys: [(i32, i32); 9] =
std::array::from_fn(|i| (x + (i as i32 % 3) - 1, z + (i as i32 / 3) - 1));
// A final neighbour cannot be generated again: a generator that
// can decorates beside it (a chunk of an older world), writing
// into it as vanilla's features do; the others skip decoration
@@ -1947,40 +1948,33 @@ impl Streaming {
if keys.iter().any(|k| self.decorating_finals.contains(k)) {
continue;
}
let has_final = keys.iter().any(|k| world.chunks.contains_key(k));
if has_final && !self.saves_beside_final {
self.decorated.insert((x, z));
waiting.borrow_mut().insert((x, z), false);
continue;
}
if !keys.iter().all(|k| {
world.chunks.contains_key(k) || matches!(self.protos.get(k), Some(Some(_)))
}) {
continue;
}
let finals: Vec<(usize, Chunk)> = keys
.iter()
.enumerate()
.filter_map(|(i, k)| Some((i, (**world.chunks.get(k)?).clone())))
.collect();
if !finals.is_empty() && !self.saves_beside_final {
self.decorated.insert((x, z));
waiting.borrow_mut().insert((x, z), false);
continue;
}
if !keys.iter().enumerate().all(|(i, k)| {
finals.iter().any(|(f, _)| *f == i)
|| matches!(self.protos.get(k), Some(Some(_)))
}) {
continue;
}
let region: Vec<Proto> = keys
.iter()
.enumerate()
.map(|(i, k)| {
if finals.iter().any(|(f, _)| *f == i) {
return Box::new(()) as Proto;
}
self.light_views.remove(k);
self.protos
.insert(*k, None)
.flatten()
.expect("present proto")
})
.collect();
let region: Box<[Proto; 9]> = match region.try_into() {
Ok(r) => Box::new(r),
Err(_) => continue,
};
let region: Box<[Proto; 9]> = Box::new(std::array::from_fn(|i| {
let k = &keys[i];
if finals.iter().any(|(f, _)| *f == i) {
return Box::new(()) as Proto;
}
self.light_views.remove(k);
self.protos
.insert(*k, None)
.flatten()
.expect("present proto")
}));
self.decorating += 1;
self.decorating_finals
.extend(finals.iter().map(|(i, _)| keys[*i]));
@@ -2264,10 +2258,14 @@ impl Streaming {
world.dirty_light.extend(columns);
}
/// Sections whose meshes sample a changed light value (faces and corners
/// read light from neighbouring blocks).
/// read light from neighbouring blocks). Every column whose stored light
/// changed is saved again (`ChunkHolder.sectionLightChanged` marks the
/// chunk unsaved): an edit's light reaches up to 15 blocks into columns
/// the edit itself did not mark, which may already have been saved.
fn mark_light_changes(world: &mut World, changed: &LightChanges) {
if let Some(log) = &mut world.light_log {
for (x, sy, z) in changed.sections() {
for (x, sy, z) in changed.sections() {
world.modified.insert((x, z));
if let Some(log) = &mut world.light_log {
log.entry((x, z)).or_default().insert(sy as usize);
}
}
@@ -4369,6 +4367,38 @@ mod tests {
}
}
/// An edit's light change in a neighbouring column marks that column
/// unsaved too, or a column saved before the edit keeps its old light on
/// disk.
#[test]
fn light_spilling_into_a_saved_neighbour_marks_it_unsaved() {
use crate::light::tests::{AIR, TORCH, tables, world};
let tables = tables();
let mut w = World::new(horizon_world::registry::BlockStateId(0), 0);
for (key, chunk) in world(|c| {
c.set(14, 0, 8, TORCH);
}) {
w.chunks.insert(key, chunk);
}
assert!(w.modified.is_empty());
Arc::make_mut(w.chunks.get_mut(&(0, 0)).unwrap()).set(14, 0, 8, AIR);
let mut changed = crate::light::LightChanges::default();
crate::light::relight(
&mut w.chunks,
&tables,
true,
&[(14, 0, 8)],
false,
&mut changed,
);
assert_eq!(w.chunks[&(1, 0)].light(0, 0, 8) & 15, 0); // was 12
Streaming::mark_light_changes(&mut w, &changed);
assert!(w.modified.contains(&(1, 0)));
assert!(w.modified.contains(&(0, 0)));
// The torch never reached x -1: that column's light is unchanged.
assert!(!w.modified.contains(&(-1, 0)));
}
fn resident_column(world: &mut World, light: u8) -> Chunk {
let mut chunk = Chunk::empty(2, -3);
chunk.set(1, 10, 1, 1);
+29 -8
View File
@@ -40,7 +40,7 @@ struct Globals {
hdr_windows: array<vec4<f32>, 2>,
// HDR lighting: x how strongly the sky lights the world now (`Sky::sky_light_factor`),
// y what the sky and fog get: the same, but none under water; z 1 with HDR output on.
// w: this entire frame writes light, so alpha is coverage rather than a mark.
// w: apply the no-pack HDR gain; HDR_WRITE_LIGHT independently keeps float alpha as coverage.
hdr_light: vec4<f32>,
hdr_settings: vec4<f32>,
hdr_shares: vec4<f32>,
@@ -224,6 +224,10 @@ struct TerrainOut {
// The camera-relative height, for the water mirror's cut at the plane.
height: f32,
emissive_layer: u32,
// [world warp varyings]
// (a native mod's world warp adds its own here, and in `TerrainWire`, `to_wire` and
// `from_wire`: gfx/world_warp.rs)
// [end world warp varyings]
}
// `TerrainOut` in 5 interpolant locations instead of 12. A GPU's vertex stage pays for each
// location it exports (AMD's parameter cache gives one a whole slot, however few of its four
@@ -242,6 +246,8 @@ struct TerrainWire {
@location(3) d: vec4<f32>,
// `layer`, `emission`, `fade` and `emissive_layer`.
@location(4) @interpolate(flat) e: vec4<u32>,
// [world warp wire]
// [end world warp wire]
}
fn to_wire(o: TerrainOut) -> TerrainWire {
var w: TerrainWire;
@@ -251,6 +257,8 @@ fn to_wire(o: TerrainOut) -> TerrainWire {
w.c = vec4(o.uv, o.scene);
w.d = vec4(o.spherical, o.cylindrical, o.height, 0.0);
w.e = vec4(o.layer, o.emission, o.fade, o.emissive_layer);
// [world warp to wire]
// [end world warp to wire]
return w;
}
fn from_wire(w: TerrainWire) -> TerrainOut {
@@ -268,6 +276,8 @@ fn from_wire(w: TerrainWire) -> TerrainOut {
o.emission = w.e.y;
o.fade = w.e.z;
o.emissive_layer = w.e.w;
// [world warp from wire]
// [end world warp from wire]
return o;
}
// Whether a fading tile or section leaves this pixel out (gfx/lod.rs's
@@ -662,7 +672,10 @@ fn hdr_facing() -> f32 {
}
fn terrain_vertex(position: vec3<f32>, uv: vec2<f32>, layer: u32, color: vec4<f32>,
light: vec2<u32>, origin: vec3<f32>, w: f32) -> TerrainOut {
// [world warp vertex]
// (a world warp's pipelines put the point through the mod's `world_warp`: gfx/world_warp.rs)
let pos = position + origin;
// [end world warp vertex]
var out: TerrainOut;
out.clip = fade_depth(globals.view_proj * vec4(pos, 1.0), w);
out.local = position;
@@ -695,6 +708,8 @@ fn terrain_vertex(position: vec3<f32>, uv: vec2<f32>, layer: u32, color: vec4<f3
const CHUNK_FULLY_VISIBLE_RANGE: f32 = 16.0;
out.visibility = mix(1.0, instance_visibility(w),
clamp((length(pos) - CHUNK_FULLY_VISIBLE_RANGE) / CHUNK_FULLY_VISIBLE_RANGE, 0.0, 1.0));
// [world warp out]
// [end world warp out]
return out;
}
fn linear_fog(d: f32, start: f32, end: f32) -> f32 {
@@ -762,11 +777,14 @@ fn solid_color(in: TerrainOut) -> vec4<f32> {
fn solid_finish(in: TerrainOut, texel: vec4<f32>, color: vec4<f32>) -> vec4<f32> {
return solid_light(solid_finish_at(in, texel, color, dpdx(in.uv), dpdy(in.uv)));
}
// A finished solid pixel as the frame keeps it: its mark in alpha, or on the written lane its
// light, alpha 1.
// A finished solid pixel as the frame keeps it: on the float frame its colour, alpha 1, lit by
// HDR's gain where it applies (`hdr_light.w`); else its mark in alpha.
fn solid_light(finished: vec4<f32>) -> vec4<f32> {
if HDR_WRITE_LIGHT && globals.hdr_light.w != 0.0 {
return vec4(hdr_written(finished.rgb, finished.a, globals.hdr_settings, globals.hdr_shares), 1.0);
if HDR_WRITE_LIGHT {
if globals.hdr_light.w != 0.0 {
return vec4(hdr_written(finished.rgb, finished.a, globals.hdr_settings, globals.hdr_shares), 1.0);
}
return vec4(finished.rgb, 1.0);
}
return finished;
}
@@ -915,7 +933,7 @@ struct NativeModelOut {
color = vec4(color.rgb * shade, color.a);
}
let finished = apply_fog(color, in.spherical, in.cylindrical);
return vec4(finished.rgb, select(finished.a, 1.0, HDR_WRITE_LIGHT && globals.hdr_light.w != 0.0));
return vec4(finished.rgb, select(finished.a, 1.0, HDR_WRITE_LIGHT));
}
// Sky (SkyRenderer): the 10-vertex sky disc fan at y = 16 (radius 512) in
@@ -1050,8 +1068,11 @@ fn sky_along_ray_base(d: vec3<f32>) -> vec3<f32> {
}
fn sky_finished(color: vec3<f32>) -> vec4<f32> {
let mark = 1.0 - 0.24 * globals.hdr_light.y;
if HDR_WRITE_LIGHT && globals.hdr_light.w != 0.0 {
return vec4(hdr_written(color, mark, globals.hdr_settings, globals.hdr_shares), 1.0);
if HDR_WRITE_LIGHT {
if globals.hdr_light.w != 0.0 {
return vec4(hdr_written(color, mark, globals.hdr_settings, globals.hdr_shares), 1.0);
}
return vec4(color, 1.0);
}
return vec4(color, mark);
}
+23 -16
View File
@@ -1057,9 +1057,9 @@ pub fn tooltip(batch: &mut HudBatch, atlas: &HudAtlas, lines: &[Text], cursor: (
}
tooltip_box(batch, atlas, lines, x, y, w, h);
}
/// `WidgetTooltipHolder.createTooltipPositioner`'s focused branch
/// (`MenuTooltipPositioner`): a widget with the keyboard focus keeps the
/// pointer where it is, and its tooltip stays against the widget at `rect`.
/// `WidgetTooltipHolder.createTooltipPositioner` for a hovered widget
/// (`MenuTooltipPositioner`): the tooltip follows the pointer's x and keeps
/// 3 to 5 px from the widget at `rect`.
pub fn tooltip_menu(
batch: &mut HudBatch,
atlas: &HudAtlas,
@@ -1132,14 +1132,16 @@ pub fn menu_tooltip_position(
}
pos
}
/// `MenuTooltipPositioner.getOffset`: how far the tooltip keeps from the
/// widget's top (or bottom) edge, from `MAX_DISTANCE_TO_WIDGET` 5 to
/// `MAX_OVERLAP_WITH_WIDGET` 3 as the pointer nears the edge.
/// The distance over the height is an int division in vanilla, so the lerp
/// is `height - 3` until the pointer is a whole height away, then 5.
/// `MenuTooltipPositioner.getOffset`: how far the tooltip goes past the
/// widget's top (or bottom) edge, `height - 3` (`MAX_OVERLAP_WITH_WIDGET`)
/// with the pointer on the edge, down to `MAX_DISTANCE_TO_WIDGET` 5 a whole
/// height away. The distance over the height is a float division (`i2f`,
/// `fdiv` in 26.3's bytecode), so the offset moves smoothly with the pointer;
/// then `Mth.lerp` and Java's float rounding.
fn widget_offset(at: i32, edge: i32, height: i32) -> i32 {
let d = ((at - edge).unsigned_abs().min(height as u32) / height as u32) as f32;
((height as f32 - 3.0) + d * (5.0 - (height as f32 - 3.0))).round() as i32
let distance = (at - edge).unsigned_abs().min(height as u32) as f32;
let start = (height - 3) as f32;
horizon_core::jmath::round_f32(start + distance / height as f32 * (5.0 - start))
}
fn tooltip_size(atlas: &HudAtlas, lines: &[Text]) -> (f32, f32) {
let w = lines
@@ -2487,16 +2489,21 @@ mod tests {
let screen = (427, 240);
let widget = [100, 100, 150, 20];
let size = (80, 10);
// Pointer at the widget's top edge: the tooltip drops 3 px, then
// `getOffset`'s int division gives `height - 3` = 17 more.
// Pointer at the widget's top edge: the tooltip drops 3 px to 103,
// then `getOffset` adds round(lerp(3 / 20, 17, 5)) = round(15.2) = 15.
assert_eq!(
menu_tooltip_position(screen, (100, 100), widget, size),
(112, 120)
(112, 118)
);
// Near the right edge its left side mirrors to the pointer's left.
assert_eq!(
menu_tooltip_position(screen, (400, 100), widget, size),
(308, 120)
(308, 118)
);
// Halfway down the offset follows the pointer: 113 + round(9.2).
assert_eq!(
menu_tooltip_position(screen, (100, 110), widget, size),
(112, 122)
);
// A whole widget height from the edge, the offset is 5.
assert_eq!(
@@ -2504,10 +2511,10 @@ mod tests {
(112, 125)
);
// A widget too close to the bottom: the tooltip goes above it,
// 203 - (10 + 6) - 17.
// 203 - (10 + 6) - round(lerp(17 / 20, 17, 5)) = 203 - 16 - 7.
assert_eq!(
menu_tooltip_position(screen, (100, 200), [100, 200, 150, 20], size),
(112, 170)
(112, 180)
);
}
}
@@ -107,6 +107,9 @@ thread_local! {
static SCROLL_AREAS: RefCell<(Vec<ScrollArea>, Vec<ScrollArea>)> = const { RefCell::new((Vec::new(), Vec::new())) };
/// The steps screens declared since `end_frame` ([`focus_link`]), and the last frame's.
static FOCUS_LINKS: RefCell<(Vec<FocusLink>, Vec<FocusLink>)> = const { RefCell::new((Vec::new(), Vec::new())) };
/// The targets whose value left and right step ([`steps_value`]) since `end_frame`, and the
/// last frame's.
static STEPPERS: RefCell<(Vec<[f32; 4]>, Vec<[f32; 4]>)> = const { RefCell::new((Vec::new(), Vec::new())) };
}
/// Splitscreen: one player's buttons and focus targets, kept aside while another player's game
@@ -120,6 +123,7 @@ pub struct Saved(
(u64, u64),
(Vec<ScrollArea>, Vec<ScrollArea>),
(Vec<FocusLink>, Vec<FocusLink>),
(Vec<[f32; 4]>, Vec<[f32; 4]>),
);
/// This thread's buttons and targets, leaving none.
@@ -132,6 +136,7 @@ pub fn take() -> Saved {
ACTION_SCREENS.take(),
SCROLL_AREAS.take(),
FOCUS_LINKS.take(),
STEPPERS.take(),
)
}
@@ -141,6 +146,7 @@ pub fn put(saved: Saved) {
TARGETS.set(saved.1);
SCROLL_AREAS.set(saved.5);
FOCUS_LINKS.set(saved.6);
STEPPERS.set(saved.7);
ACTIONS.set(saved.2);
ACTION_GLYPHS.set(saved.3);
ACTION_SCREENS.set(saved.4);
@@ -288,6 +294,7 @@ pub fn cover_frame() {
ACTIONS.with_borrow_mut(|(current, _)| current.clear());
SCROLL_AREAS.with_borrow_mut(|(current, _)| current.clear());
FOCUS_LINKS.with_borrow_mut(|(current, _)| current.clear());
STEPPERS.with_borrow_mut(|(current, _)| current.clear());
}
/// This frame drew a list with something to scroll, in `rect`, scrolled `amount`
@@ -353,6 +360,18 @@ pub fn linked_step(focus: [f32; 4], down: bool) -> Option<[f32; 4]> {
})
}
/// Left and right on the target at `rect` step its value, back and on, rather than move the focus
/// (the settings shell's choices, whose `<` `>` say so): Right as a press, Left as a Shift press.
pub fn steps_value(rect: [f32; 4]) {
STEPPERS.with_borrow_mut(|(current, _)| current.push(rect));
}
/// Whether the last drawn frame said left and right step the value of the target at `focus`.
pub fn last_steps_value(focus: [f32; 4]) -> bool {
let same = |rect: &[f32; 4]| rect.iter().zip(focus).all(|(a, b)| (a - b).abs() < 0.5);
STEPPERS.with_borrow(|(_, last)| last.iter().any(same))
}
/// The focus targets of the last drawn frame.
pub fn last_targets() -> Vec<Target> {
TARGETS.with_borrow(|(_, last, _)| last.clone())
@@ -433,6 +452,9 @@ pub fn end_frame(frames: u64) {
FOCUS_LINKS.with_borrow_mut(|(current, last)| {
*last = std::mem::take(current);
});
STEPPERS.with_borrow_mut(|(current, last)| {
*last = std::mem::take(current);
});
NARRATION.with_borrow_mut(|(current, last)| {
*last = std::mem::replace(current, Narration::EMPTY);
});
+9 -2
View File
@@ -9,6 +9,9 @@ use horizon_core::random::LegacyRandom;
use horizon_world::registry::BlockStateId;
use std::sync::Arc;
mod modified;
pub use modified::ModifiedChunks;
/// Sections (x, section index, z) waiting to be remeshed, kept by column
/// (a bit per section index), so the mesh scheduler looks at each column once
/// rather than at every section of it.
@@ -21,6 +24,10 @@ impl DirtySections {
debug_assert!((0..SectionMask::CAPACITY as i32).contains(&sy));
self.columns.entry((x, z)).or_default().insert(sy as usize);
}
/// Mark all sections in `mask` dirty for column `(x, z)`.
pub fn insert_column_mask(&mut self, (x, z): (i32, i32), mask: SectionMask) {
*self.columns.entry((x, z)).or_default() |= mask;
}
pub fn contains(&self, &(x, sy, z): &(i32, i32, i32)) -> bool {
(0..SectionMask::CAPACITY as i32).contains(&sy)
&& self
@@ -83,7 +90,7 @@ pub struct World {
/// was generated rather than read from the save: the game tells native mods.
pub loaded_chunks: Vec<(i32, i32, bool)>,
/// Chunks changed since the last save.
pub modified: FxHashSet<(i32, i32)>,
pub modified: ModifiedChunks,
/// Block entities, and the order they tick in.
pub block_entities: crate::block_entities::BlockEntities,
/// Each chest's `ContainerOpenersCounter`: opener count and largest
@@ -220,7 +227,7 @@ impl World {
loaded_entities: Vec::new(),
entities_pending: FxHashSet::default(),
loaded_chunks: Vec::new(),
modified: FxHashSet::default(),
modified: ModifiedChunks::default(),
attachments: Default::default(),
attachment_syncs: FxHashSet::default(),
block_entities: Default::default(),
@@ -0,0 +1,101 @@
//! `LevelChunk.markUnsaved` and `ChunkMap`'s insertion-ordered eager queue.
use crate::fast_hash::{FxHashMap, FxHashSet};
use std::{
collections::BTreeMap,
ops::Deref,
time::{Duration, Instant},
};
type Key = (i32, i32);
const COOLDOWN: Duration = Duration::from_millis(10_000);
#[derive(Default)]
pub struct ModifiedChunks {
dirty: FxHashSet<Key>,
// Ordered independently of the dirty set: an autosave leaves queue entries
// in place until the eager loop visits them, including cooled-down ones.
order: BTreeMap<u64, Key>,
queued: FxHashMap<Key, u64>,
sequence: u64,
next_save: FxHashMap<Key, Instant>,
}
impl Deref for ModifiedChunks {
type Target = FxHashSet<Key>;
fn deref(&self) -> &Self::Target {
&self.dirty
}
}
impl IntoIterator for ModifiedChunks {
type Item = Key;
type IntoIter = std::collections::hash_set::IntoIter<Key>;
fn into_iter(self) -> Self::IntoIter {
self.dirty.into_iter()
}
}
impl ModifiedChunks {
pub fn insert(&mut self, key: Key) -> bool {
let changed = self.dirty.insert(key);
if changed && !self.queued.contains_key(&key) {
self.sequence += 1;
self.order.insert(self.sequence, key);
self.queued.insert(key, self.sequence);
}
changed
}
pub fn extend(&mut self, keys: impl IntoIterator<Item = Key>) {
for key in keys {
self.insert(key);
}
}
pub fn remove(&mut self, key: &Key) -> bool {
self.dirty.remove(key)
}
pub fn drain(&mut self) -> impl Iterator<Item = Key> + '_ {
self.dirty.drain()
}
/// Like `drain`: queue entries of chunks no longer dirty go at the next eager pass.
pub fn clear(&mut self) {
self.dirty.clear();
}
/// `ChunkMap.scheduleUnload`: forget the cooldown, including clean chunks.
pub fn unloaded(&mut self, key: &Key) {
self.next_save.remove(key);
}
/// `ChunkMap.saveAllChunks(false)`: clear cooldowns, then save all dirty
/// full chunks without a limit, starting a fresh cooldown for each write.
pub fn autosaved(&mut self, keys: &[Key], now: Instant) {
self.next_save.clear();
self.next_save
.extend(keys.iter().map(|&key| (key, now + COOLDOWN)));
}
/// `ChunkMap.saveChunksEagerly` / `saveChunkIfNeeded`. `available` checks
/// both haveTime and activeChunkWrites before EACH candidate; `save` takes
/// an immutable snapshot and queues it. Failed submissions stay dirty.
pub fn save_eagerly(
&mut self,
now: Instant,
mut available: impl FnMut() -> bool,
mut loaded: impl FnMut(Key) -> bool,
mut save: impl FnMut(Key) -> bool,
) {
let mut saved = 0;
let mut remove = Vec::new();
for (&sequence, &key) in &self.order {
if saved == 20 || !available() {
break;
}
if !loaded(key) || !self.dirty.contains(&key) {
remove.push((sequence, key));
} else if self.next_save.get(&key).is_none_or(|&at| now >= at) && save(key) {
self.dirty.remove(&key);
self.next_save.insert(key, now + COOLDOWN);
remove.push((sequence, key));
saved += 1;
}
}
for (sequence, key) in remove {
self.order.remove(&sequence);
self.queued.remove(&key);
}
}
}
+35 -5
View File
@@ -17,6 +17,9 @@ use zip::{CompressionMethod, ZipArchive};
/// The most one entry inflates to: far above any real pack file, but a zip
/// bomb stops here.
const MAX_ENTRY: usize = 1 << 30;
/// The most every entry inflates to together, once per archive: per-entry
/// limits still let a bomb spend its budget one 1 GiB entry at a time.
const MAX_TOTAL: usize = 4 << 30;
/// One archive with its entries indexed by name (`\` read as `/`), files
/// and folders apart.
@@ -27,6 +30,8 @@ pub struct Zip {
folders: BTreeMap<String, usize>,
/// The archive's length: no size an entry claims can be bigger.
len: usize,
/// The bytes left for every entry together, drawn down as they are read.
budget: AtomicU64,
}
fn invalid(e: impl std::fmt::Display) -> io::Error {
io::Error::new(io::ErrorKind::InvalidData, e.to_string())
@@ -147,6 +152,7 @@ impl Zip {
entries,
folders,
len,
budget: AtomicU64::new(MAX_TOTAL as u64),
})
}
/// The central directory on, as read at opening: every entry's name, sizes
@@ -172,9 +178,15 @@ impl Zip {
self.index(name).is_some()
}
pub fn read(&self, name: &str) -> io::Result<Vec<u8>> {
self.read_within(name, &self.budget)
}
/// `read`, drawing `budget` down as entries are returned: a per-entry cap
/// is not a total one, and a bomb spends its budget one 1 GiB entry at a
/// time otherwise.
fn read_within(&self, name: &str, budget: &AtomicU64) -> io::Result<Vec<u8>> {
let (method, raw) = self.raw(name)?;
match method {
CompressionMethod::Stored => Ok(raw),
let bytes = match method {
CompressionMethod::Stored => raw,
CompressionMethod::Deflated => {
let mut decoded = Vec::with_capacity(raw.len().min(MAX_ENTRY));
inflate(&raw)
@@ -184,10 +196,12 @@ impl Zip {
if decoded.len() > MAX_ENTRY {
return Err(invalid("entry inflates past the size limit"));
}
Ok(decoded)
decoded
}
_ => Err(invalid("unsupported zip compression")),
}
_ => return Err(invalid("unsupported zip compression")),
};
take(budget, bytes.len() as u64)?;
Ok(bytes)
}
/// What [`Self::read`] reads, written to `out` a piece at a time as it inflates, each piece
/// taken from `budget`: the bytes left for everything sharing it. Past the budget it stops
@@ -450,6 +464,22 @@ mod tests {
assert!(zip.read("missing").is_err());
}
/// A per-entry cap is not a total one: an archive whose entries together
/// inflate past the budget is refused, whatever each entry alone says.
#[test]
fn entries_share_one_total_inflated_budget() {
let zip = Zip::from_bytes(archive()).unwrap();
let tight = AtomicU64::new(16);
assert!(zip.read_within("a/b.txt", &tight).is_err());
let zip = Zip::from_bytes(archive()).unwrap();
let tight = AtomicU64::new(12);
assert_eq!(
zip.read_within("pack.mcmeta", &tight).unwrap(),
b"{\"pack\":{}}"
);
assert!(zip.read_within("a/b.txt", &tight).is_err());
}
/// The offset of the central-directory header of `name`.
fn central_header(bytes: &[u8], name: &str) -> usize {
let mut at = 0;
+2 -2
View File
@@ -92,8 +92,8 @@ dunce = "1.0.5"
# Use the registered URI handler without routing links through Explorer/cmd.
open = { version = "5.4.5", features = ["shellexecute-on-windows"] }
# DirectX 12, where Vulkan has no adapter or device, or by choice (`graphicsApi`, graphics.rs).
# Shaders compile with FXC (d3dcompiler_47.dll, part of Windows), or DXC when dxcompiler.dll is
# on the PATH.
# Shaders compile with FXC (d3dcompiler_47.dll, part of Windows), or on integrated GPUs with DXC
# (dxcompiler.dll beside the game, gfx/dx12_compiler.rs).
wgpu = { version = "30.0.1", default-features = false, features = ["dx12"] }
[target.'cfg(target_os = "macos")'.dependencies]
@@ -5,7 +5,7 @@
//! usage: environment_fixture RESOURCES INPUT OUTPUT
use horizon_client::{
biome::Biomes,
environment::{Environment, apply_weather},
environment::{Environment, Sample},
};
use std::{collections::HashMap, fmt::Write as _, fs, path::Path};
@@ -22,22 +22,22 @@ pub fn main() -> horizon_client::Result<()> {
for line in input.lines() {
let f: Vec<&str> = line.split(' ').collect();
let dimension = f[0];
let environment =
match environments.entry(dimension.to_owned()) {
std::collections::hash_map::Entry::Occupied(e) => e.into_mut(),
std::collections::hash_map::Entry::Vacant(e) => e.insert(
Environment::load_dimension(root, dimension, dimension == "overworld")?,
),
};
let environment = match environments.entry(dimension.to_owned()) {
std::collections::hash_map::Entry::Occupied(e) => e.into_mut(),
std::collections::hash_map::Entry::Vacant(e) => {
e.insert(Environment::load_dimension(root, dimension)?)
}
};
let info = biomes.get(biomes.id(&format!("minecraft:{}", f[4])));
let mut sky = environment.sample(
f[1].parse()?,
info.sky.map(horizon_client::biome::rgb),
info.fog.map(horizon_client::biome::rgb),
);
if dimension == "overworld" {
apply_weather(&mut sky, f[2].parse()?, f[3].parse()?);
}
// The weather layers only exist where the dimension can have weather.
let sky = environment.sample(&Sample {
day_time: f[1].parse()?,
rain: f[2].parse()?,
thunder: f[3].parse()?,
blend: None,
biome: Some(&info.attributes),
..Sample::default()
});
let v3 = |c: [f32; 3]| format!("[{:?},{:?},{:?}]", c[0], c[1], c[2]);
let v4 = |c: [f32; 4]| format!("[{:?},{:?},{:?},{:?}]", c[0], c[1], c[2], c[3]);
let mut row = format!("{{\"case\":\"{line}\"");
@@ -72,6 +72,7 @@ pub fn main() -> horizon_client::Result<()> {
"visual/cloud_fog_end_distance",
format!("{:?}", sky.cloud_fog_end),
);
put("visual/cloud_height", format!("{:?}", sky.cloud_height));
out.push_str(&row);
out.push_str("}\n");
}
@@ -96,6 +96,8 @@ fn run(
blend: blended.then_some(&blend),
biome: &biome,
clock: &|_| case["ticks"].as_i64().unwrap_or(0),
post_blend: None,
post_biome: None,
};
for attribute in ATTRIBUTES.iter().filter(|a| a.kind != Kind::Other) {
let value = system.value(attribute, &conditions).ok_or("no value")?;
@@ -86,6 +86,7 @@ mod particle_providers_probe;
mod pick_block_data_fixture;
mod place_recipe_fixture;
mod player_sync_fixture;
mod portal_tickets_fixture;
mod raid_fixture;
mod random_tick_sections_probe;
mod recipe_book_fixture;
@@ -100,6 +101,7 @@ mod screen_layout_fixture;
mod section_mesh_fixture;
mod server_files_probe;
mod sheep_dye_fixture;
mod shulker_bullet_probe;
mod situational_music_fixture;
mod skin_image_fixture;
mod sound_engine_fixture;
@@ -202,6 +204,7 @@ probes! {
pick_block_data_fixture,
place_recipe_fixture,
player_sync_fixture,
portal_tickets_fixture,
raid_fixture,
random_tick_sections_probe,
recipe_book_fixture,
@@ -219,6 +222,7 @@ probes! {
skin_image_fixture,
late_entity_pair_fixture,
sheep_dye_fixture,
shulker_bullet_probe,
allay_interaction_fixture,
entity_sound_packet_fixture,
sound_engine_fixture,
@@ -34,10 +34,13 @@ pub fn main() -> Result<(), Box<dyn std::error::Error>> {
let args: Vec<_> = crate::args().collect();
let input = fs::read_to_string(&args[1])?;
let mut lines = input.lines();
let root: Vec<&str> = lines.next().ok_or("empty input")?.split(' ').collect();
let vanilla = PathBuf::from(root[1]);
let ids: Vec<String> = root[3..].iter().map(|s| (*s).to_owned()).collect();
let all = packs::discover(&vanilla, &PathBuf::from(root[2]));
let root: Vec<String> = serde_json::from_str(lines.next().ok_or("empty input")?)?;
if root.len() < 3 || root[0] != "root" {
return Err("invalid resource-pack header".into());
}
let vanilla = PathBuf::from(&root[1]);
let ids = root[3..].to_vec();
let all = packs::discover(&vanilla, &PathBuf::from(&root[2]));
let enabled = packs::enabled(&all, &ids);
let overlay = PathBuf::from(&args[2]).with_extension("overlay");
packs::build_overlay(&vanilla, &enabled, &overlay)?;
@@ -0,0 +1,100 @@
//! A level's saved chunk tickets through `chunk_tickets.dat`, for VanillaPortalTicketsOracle:
//! each case's file is loaded, its `PORTAL` tickets ticked, refreshed and reloaded over a
//! restart, and every step prints what the file then holds and which of the case's chunks a
//! `PORTAL` ticket holds now.
use horizon_client::save::{LevelShape, SaveDir, read_dat, write_dat};
use horizon_client::ticket_levels::ChunkTickets;
use horizon_world::nbt::{Tag, parse_snbt};
use serde_json::Value;
use std::collections::HashSet;
use std::io::Write;
use std::path::Path;
fn chunk(pos: &Value) -> (i32, i32) {
(
pos[0].as_i64().unwrap() as i32,
pos[1].as_i64().unwrap() as i32,
)
}
/// Save the level's tickets, then list each saved one (type, level, chunk and ticks_left,
/// "-" where it is left out) and "held x z" for each of `chunks` a `PORTAL` ticket holds.
fn snapshot(
(save, file): (&SaveDir, &Path),
forced: &HashSet<(i32, i32)>,
tickets: &ChunkTickets,
case: &Value,
) -> Vec<String> {
save.save_chunk_tickets(forced, tickets.portal_tickets())
.unwrap();
let data = read_dat(file).unwrap();
let mut rows = Vec::new();
if let Some(Tag::List(saved)) = data.get("data").and_then(|d| d.get("tickets")) {
for t in saved {
let (Some(Tag::String(kind)), Some(Tag::Int(level)), Some(Tag::IntArray(pos))) =
(t.get("type"), t.get("level"), t.get("chunk_pos"))
else {
panic!("unexpected ticket {t:?}")
};
let left = match t.get("ticks_left") {
Some(Tag::Long(n)) => n.to_string(),
None => "-".into(),
Some(other) => format!("{other:?}"),
};
let kind = kind.to_string_lossy();
rows.push(format!("{kind} {level} {} {} {left}", pos[0], pos[1]));
}
}
for (x, z) in case["chunks"].as_array().unwrap().iter().map(chunk) {
if tickets.portal_tickets().iter().any(|t| t.chunk == (x, z)) {
rows.push(format!("held {x} {z}"));
}
}
rows.sort();
rows
}
pub fn main() {
let args: Vec<_> = super::args().collect();
let input = std::fs::read_to_string(&args[1]).unwrap();
let mut out = std::fs::File::create(&args[2]).unwrap();
let root = std::env::temp_dir().join(format!("horizon-portal-tickets-{}", std::process::id()));
for line in input.lines() {
let _ = std::fs::remove_dir_all(&root);
let case: Value = serde_json::from_str(line).unwrap();
let (data, _) = parse_snbt(case["saved"].as_str().unwrap()).unwrap();
let file = root.join("data/minecraft/chunk_tickets.dat");
write_dat(&file, Tag::compound([("data", data)])).unwrap();
// How the game opens a level (`world_loading::make_streaming`, `open_level`).
let save = SaveDir::new(root.clone(), LevelShape::OVERWORLD);
let mut forced = save.load_forced_chunks();
let mut tickets = ChunkTickets::with_saved_portal(save.load_portal_tickets());
let level = (&save, file.as_path());
let mut trace = vec![snapshot(level, &forced, &tickets, &case)];
for op in case["ops"].as_array().unwrap() {
match op["op"].as_str().unwrap() {
"tick" => {
let loading: HashSet<_> = op["loading"]
.as_array()
.into_iter()
.flatten()
.map(chunk)
.collect();
for _ in 0..op["times"].as_u64().unwrap_or(1) {
tickets.tick_portal(|t| !loading.contains(&t.chunk));
}
}
"portal" => tickets.add_portal(chunk(&op["pos"])),
// The last snapshot saved them: the level opens again from the file.
"restart" => {
forced = save.load_forced_chunks();
tickets = ChunkTickets::with_saved_portal(save.load_portal_tickets());
}
other => panic!("unknown operation {other}"),
}
trace.push(snapshot(level, &forced, &tickets, &case));
}
writeln!(out, "{}", serde_json::to_string(&trace).unwrap()).unwrap();
}
let _ = std::fs::remove_dir_all(&root);
}
@@ -0,0 +1,126 @@
//! Shulker bullets for tools/test_shulker_bullet_parity.py: where a shulker's
//! bullet starts (`shulker::bullet_origin` through `Projectile::shulker_bullet`)
//! and the block test that turns it in flight
//! (`collisions::projectile_can_stand_on`), one line per fixture.
#![forbid(unsafe_code)]
use horizon_client::{
Result, collisions,
mobs::{Mob, MobTypes, Who, shulker},
projectiles::{Projectile, ProjectileWorld},
world::World,
};
use horizon_core::{
geometry::{Aabb, Vec3},
position::BlockPos,
};
use horizon_world::{chunk::BlockState, registry::BlockStateId};
use serde_json::Value;
use std::{
fs::File,
io::{BufRead, BufReader, BufWriter, Write},
path::Path,
sync::Arc,
};
/// The bullet's first move only asks which neighbours are empty: all are.
struct Open(Vec<(Who, Aabb)>);
impl ProjectileWorld for Open {
fn clip(&self, _: Vec3, _: Vec3) -> Option<(Vec3, [i32; 3], [i32; 3])> {
None
}
fn in_water(&self, _: Aabb) -> bool {
false
}
fn targets(&self) -> &[(Who, Aabb)] {
&self.0
}
}
fn vector(v: &Value) -> Vec3 {
let n = |i: usize| v[i].as_f64().unwrap();
Vec3::new(n(0), n(1), n(2))
}
/// `"minecraft:oak_slab[type=top]"` as a registry state.
fn parse_state(text: &str) -> BlockState {
let (name, properties) = text.split_once('[').unwrap_or((text, ""));
BlockState {
name: name.into(),
properties: properties
.trim_end_matches(']')
.split(',')
.filter(|p| !p.is_empty())
.map(|p| {
let (key, value) = p.split_once('=').unwrap();
(key.to_string(), value.to_string())
})
.collect(),
}
}
pub fn main() -> Result<()> {
let args: Vec<_> = crate::args().collect();
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client")
.join(horizon_core::VANILLA_VERSION);
let registry = horizon_world::level::data::load_registry(&root)?;
let binding = collisions::load(&root, &registry)?;
let types = MobTypes::load(&root)?;
let kind = Arc::new(
types
.types
.get("minecraft:shulker")
.ok_or("no shulker")?
.clone(),
);
let mut out = BufWriter::new(File::create(&args[2])?);
for line in BufReader::new(File::open(&args[1])?).lines() {
let f: Value = serde_json::from_str(&line?)?;
if f["mode"] == "origin" {
let mut m = Mob::new(1, kind.clone(), vector(&f["position"]), 0.0);
m.extra.variant3 = f["face"].as_u64().unwrap() as u8;
m.anim.v[shulker::PEEK] = f32::from_bits(f["peek"].as_u64().unwrap() as u32);
m.extra.model_scale = f["scale"].as_f64().unwrap() as f32;
let target = Aabb::new(Vec3::new(-0.5, 0.0, -0.5), Vec3::new(0.5, 1.0, 0.5))
.move_by(vector(&f["target"]));
let axis = match m.extra.variant3 {
0 | 1 => 1,
2 | 3 => 2,
_ => 0,
};
let world = Open(vec![(Who::Mob(2), target)]);
let at = shulker::bullet_origin(&m);
let p = Projectile::shulker_bullet(&world, at, Who::Mob(1), Who::Mob(2), axis, 1);
let b = p.position;
writeln!(
out,
"{:016x} {:016x} {:016x}",
b.x.to_bits(),
b.y.to_bits(),
b.z.to_bits()
)?;
} else {
let pos = &f["pos"];
let pos = BlockPos::new(
pos[0].as_i64().unwrap() as i32,
pos[1].as_i64().unwrap() as i32,
pos[2].as_i64().unwrap() as i32,
);
let mut world = World::new(BlockStateId(0), 0);
let chunk = (pos.x >> 4, pos.z >> 4);
world.chunks.insert(
chunk,
Arc::new(horizon_client::chunk::Chunk::empty(chunk.0, chunk.1)),
);
let state = registry.resolve(&parse_state(f["block"].as_str().unwrap()))?;
world.set(pos.x, pos.y, pos.z, state)?;
let y = f["y"].as_f64().unwrap();
writeln!(
out,
"{}",
collisions::projectile_can_stand_on(&world, &binding, pos, y)
)?;
}
}
Ok(())
}
+182 -2
View File
@@ -628,6 +628,11 @@ pub trait World {
fn block_entity_nbt(&self, _pos: [i32; 3]) -> Option<horizon_world::nbt::Tag> {
None
}
/// Read-only contents of a loaded named predicate, when this context
/// supplies a predicate registry. Other contexts keep references absent.
fn predicate(&self, _id: &str) -> Option<&Value> {
None
}
}
/// An item stack as predicates see it.
#[derive(Clone, Debug, Default)]
@@ -642,9 +647,18 @@ pub struct ItemView {
/// family unprojected and unsupported; an empty compound is actual absence.
pub custom_data: Option<horizon_world::nbt::Tag>,
}
/// Query snapshot of supported known movement, in blocks/tick, and actual fall distance.
#[derive(Clone, Copy, Debug)]
pub struct MovementView {
pub per_tick: [f64; 3],
pub fall_distance: f64,
}
/// An entity as predicates see it.
#[derive(Clone, Debug, Default)]
pub struct EntityView {
/// None is unprojected/unsupported, not a fabricated stationary entity.
pub movement: Option<MovementView>,
pub kind: String,
pub position: [f64; 3],
pub on_fire: bool,
@@ -1010,6 +1024,51 @@ pub fn distance(v: &Value, a: [f64; 3], b: [f64; 3]) -> bool {
(d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt(),
)
}
/// `MinMaxBounds.Doubles.matches` and `matchesSqr`, only for movement predicates.
fn movement_range(v: &Value, value: f64, squared: bool) -> bool {
let (mut min, mut max) = match v {
Value::Null => (None, None),
Value::Number(n) => {
let Some(n) = n.as_f64() else { return false };
(Some(n), Some(n))
}
Value::Object(o) => {
let read = |key| match o.get(key) {
None => Some(None),
Some(n) => n.as_f64().map(Some),
};
let (Some(min), Some(max)) = (read("min"), read("max")) else {
return false;
};
(min, max)
}
_ => return false,
};
if squared {
min = min.map(|n| n * n);
max = max.map(|n| n * n);
}
if min.is_some_and(|n| n > value) {
return false;
}
max.is_none_or(|n| n >= value)
}
/// `MovementPredicate.matches`: known movement is scaled by 20 before the ordered tests.
fn movement(p: &Value, m: MovementView) -> bool {
if !p.is_object() {
return false;
}
let [x, y, z] = m.per_tick.map(|v| v * 20.0);
movement_range(&p["x"], x, false)
&& movement_range(&p["y"], y, false)
&& movement_range(&p["z"], z, false)
&& movement_range(&p["speed"], x * x + y * y + z * z, true)
&& movement_range(&p["horizontal_speed"], x * x + z * z, true)
&& movement_range(&p["vertical_speed"], y.abs(), false)
&& movement_range(&p["fall_distance"], m.fall_distance, false)
}
/// `EntityPredicate` (26.x keyed sub-predicates).
pub fn entity(v: &Value, e: &EntityView, w: &dyn World, origin: [f64; 3]) -> bool {
if v.is_null() {
@@ -1027,6 +1086,7 @@ pub fn entity(v: &Value, e: &EntityView, w: &dyn World, origin: [f64; 3]) -> boo
location(p, [e.position[0], e.position[1] - 0.5, e.position[2]], w)
}
"minecraft:distance" => distance(p, e.position, origin),
"minecraft:movement" => e.movement.is_some_and(|m| movement(p, m)),
"minecraft:flags" => {
let f = |k: &str, have: bool| p[k].as_bool().is_none_or(|want| want == have);
f("is_on_fire", e.on_fire)
@@ -1402,9 +1462,63 @@ pub fn recipe_ingredients(v: &Value, used: &[ItemView], w: &dyn World) -> bool {
}
/// A `ContextAwarePredicate`: a list of loot conditions (or one condition).
/// Existing callers have no random context and keep their leaf behavior.
/// Invalid references are distinct from false: inversion must not accept them.
pub fn conditions(v: &Value, ctx: &Entities, w: &dyn World) -> bool {
conditions_with(v, ctx, &mut |v, ctx| condition_leaf(v, ctx, w))
references_valid(v, w) && conditions_with(v, ctx, &mut |v, ctx| condition_leaf(v, ctx, w))
}
/// Every named predicate `v` reaches is loaded, and none reaches itself.
pub fn references_valid(v: &Value, w: &dyn World) -> bool {
predicate_references(v, w, &mut BTreeSet::new()).is_ok()
}
enum InvalidReference {
Missing,
Cycle,
}
/// `Validatable.validateHolder`'s active-name rule, as a defensive check on
/// this evaluation context. Check every typed reference before boolean
/// short-circuiting; this does not implement registry/startup admission.
fn predicate_references(
v: &Value,
w: &dyn World,
active: &mut BTreeSet<String>,
) -> Result<(), InvalidReference> {
match v {
Value::String(id) => {
let id = full(id).into_owned();
if !active.insert(id.clone()) {
return Err(InvalidReference::Cycle);
}
let result = w
.predicate(&id)
.ok_or(InvalidReference::Missing)
.and_then(|v| predicate_references(v, w, active));
active.remove(&id);
result
}
Value::Array(list) => list
.iter()
.try_for_each(|v| predicate_references(v, w, active)),
Value::Object(o) => {
let kind = o
.get("condition")
.or_else(|| o.get("type"))
.and_then(Value::as_str)
.unwrap_or("");
match &*full(kind) {
"minecraft:inverted" => predicate_references(&v["term"], w, active),
"minecraft:all_of" | "minecraft:any_of" => v["terms"]
.as_array()
.into_iter()
.flatten()
.try_for_each(|v| predicate_references(v, w, active)),
_ => Ok(()),
}
}
_ => Ok(()),
}
}
/// The existing condition traversal with a caller-owned leaf context.
@@ -1534,6 +1648,10 @@ fn condition_leaf(v: &Value, ctx: &Entities, w: &dyn World) -> bool {
}
// An inline entity predicate (old-style).
Value::Object(_) => ctx.this.is_some_and(|e| entity(v, e, w, ctx.origin)),
// A named predicate reference: its loaded definition, walked like any other.
Value::String(id) => w
.predicate(&full(id))
.is_some_and(|v| conditions_with(v, ctx, &mut |v, ctx| condition_leaf(v, ctx, w))),
_ => false,
}
}
@@ -3493,4 +3611,66 @@ mod tests {
}
assert_eq!(draws, 0);
}
#[cfg(test)]
mod movement_model_tests {
use super::*;
use serde_json::json;
#[test]
fn added_model_movement_units_exact_bounds_and_squared_bounds() {
// Source-derived model proof; no moving Java/runtime claim.
let m = MovementView {
per_tick: [3.0 / 20.0, -4.0 / 20.0, 0.0],
fall_distance: 7.25,
};
assert_eq!(m.per_tick.map(|n| n * 20.0), [3.0, -4.0, 0.0]);
for p in [
json!({"x":3.0,"y":-4.0,"z":0.0}),
json!({"x":{"min":3.0,"max":3.0}}),
json!({"speed":5.0,"horizontal_speed":3.0,"vertical_speed":4.0,"fall_distance":7.25}),
json!({"speed":{"min":5.0,"max":5.0}}),
// Doubles boundsSqr maps each bound through Mth.square, including its sign.
json!({"speed":-5.0,"horizontal_speed":{"max":-3.0}}),
json!({}),
] {
assert!(movement(&p, m), "{p}");
}
for p in [
json!({"x":3.0000000001}),
json!({"x":{"min":3.0000000001}}),
json!({"y":{"max":-4.0000000001}}),
json!({"speed":{"max":4.9999999999}}),
json!({"horizontal_speed":{"min":-4.0}}),
json!({"vertical_speed":-4.0}),
json!({"fall_distance":{"max":7.2499999999}}),
] {
assert!(!movement(&p, m), "{p}");
}
assert!(!movement_range(&json!(3.0000000001), 3.0, false));
assert!(
range_f(&json!(3.0000000001), 3.0),
"existing global tolerance is untouched"
);
}
#[test]
fn added_model_unprojected_is_distinct_from_actual_stationary() {
let p = json!({"minecraft:movement":{}});
let view = EntityView {
kind: "minecraft:pig".into(),
..Default::default()
};
// Unprojected is explicitly unsupported, not claimed Java-correct.
assert!(!entity(&p, &view, &super::Flat, [0.0; 3]));
let view = EntityView {
movement: Some(MovementView {
per_tick: [0.0; 3],
fall_distance: 0.0,
}),
..view
};
assert!(entity(&p, &view, &super::Flat, [0.0; 3]));
}
}
}
+9 -1
View File
@@ -8,7 +8,7 @@ use crate::{
blocks::{BlockFacts, Fluid},
command::args::GameMode,
items::ItemStack,
particles::{ParticleEngine, ParticleOptions, ParticleType as T, along_axis},
particles::{ParticleEngine, ParticleLevel, ParticleOptions, ParticleType as T, along_axis},
world::World,
};
use horizon_core::{
@@ -600,6 +600,14 @@ fn do_animate_tick(
}
let state = ctx.state(pos);
block_animate_tick(tables, engine, ctx, state, pos, center);
// Resource packs' Polytone block emitters (#11486): nothing without such a pack.
let level = ParticleLevel {
world: ctx.world,
facts: ctx.facts,
collision: ctx.collision,
player: None,
};
engine.polytone_animate(&level, pos);
let facts = ctx.facts.get(state);
if facts.fluid != Fluid::None {
fluid_animate_tick(
+2 -2
View File
@@ -351,7 +351,7 @@ impl OwnerLookup for () {
}
}
impl ArrowScene<'_, '_> {
fn block(&self, p: BlockPos) -> BlockStateId {
pub fn block(&self, p: BlockPos) -> BlockStateId {
self.world.block(p.x, p.y, p.z)
}
fn is_air(&self, state: BlockStateId) -> bool {
@@ -408,7 +408,7 @@ impl ArrowScene<'_, '_> {
Some((hit.location, hit.block, hit.direction.normal()))
}
/// `CollisionGetter.noCollision(null, box)` over blocks.
fn no_block_collision(&self, b: Aabb) -> bool {
pub fn no_block_collision(&self, b: Aabb) -> bool {
let (lo, hi) = (b.min_position(), b.max_position());
for x in (lo.x.floor() as i32 - 1)..=(hi.x.floor() as i32 + 1) {
for y in (lo.y.floor() as i32 - 1)..=(hi.y.floor() as i32 + 1) {
+15 -1
View File
@@ -61,6 +61,12 @@ fn read_tag(root: &Path, name: &str, depth: u32) -> Vec<Arc<str>> {
impl Banners {
pub fn load(root: &Path) -> Self {
Self::load_with_defaults(root, None)
}
pub(crate) fn load_with_defaults(
root: &Path,
defaults: Option<&horizon_world::item_defaults::Defaults>,
) -> Self {
let mut banners = Self::default();
let mut files: Vec<_> = fs::read_dir(root.join("data/minecraft/banner_pattern"))
.into_iter()
@@ -92,7 +98,15 @@ impl Banners {
});
}
banners.no_item_required = read_tag(root, "no_item_required", 0);
if let Ok(raw) = horizon_world::item_defaults::read(root) {
let owned_defaults;
let defaults = match defaults {
Some(defaults) => Some(defaults),
None => {
owned_defaults = horizon_world::item_defaults::read(root).ok();
owned_defaults.as_ref()
}
};
if let Some(raw) = defaults {
for (item, c) in raw {
let id: Arc<str> = Arc::from(item.as_str());
if let Some(dye) = c["minecraft:dye"].as_str() {
+10 -10
View File
@@ -16,16 +16,16 @@ pub use horizon_client_render::{
cit_armor, cit_assets, cit_glint, client_resources, clouds, collisions, command_block,
containers, controller_ui, crash, custom_sky, datapack, dynamic_light, effects, environment,
extras, fast_hash, fluid_render, fog, font, gfx, graphics, graphics_style, graphics_ui,
hdr_calibration, hud, hud_layout, hud_overlays, inventory_effects, java_text, jigsaw_block,
json_folder, level_loading, light, lod, logging, menu_blur, mesher, minimap, mipmap,
mod_browser, mod_list, model_library, models, multiline_edit, native_emissive, options_file,
options_fixes, options_ui, overlay, pack_browser, pack_screen, packs, pad_cursor, pad_glyph,
par, particles, poi_sections, post_chain, potent_sulfur, preedit, providers, pvp, pvp_hud,
random_source, screen_draw, screen_effects, sculk, section_mask, server_terrain, settings,
shader_options, shader_pack_list, signs, skins, sound_engine, sound_events, sound_preview,
spans, split_layout, split_map, split_profiles, sprites, streaming, structure_block, tab_list,
telemetry, terrain_model, test_block, test_instance_block, testgen, title_edition, trims, ui,
vault, weather, widgets, world, worldborder, zip,
hdr_calibration, hud, hud_kit, hud_layout, hud_overlays, inventory_effects, java_text,
jigsaw_block, json_folder, level_loading, light, lod, logging, menu_blur, mesher, minimap,
mipmap, mod_browser, mod_list, model_library, models, multiline_edit, native_emissive,
options_file, options_fixes, options_ui, overlay, pack_browser, pack_screen, packs, pad_cursor,
pad_glyph, par, particles, poi_sections, post_chain, potent_sulfur, preedit, providers, pvp,
pvp_hud, random_source, screen_draw, screen_effects, sculk, section_mask, server_terrain,
settings, shader_options, shader_pack_list, signs, skins, social, sound_engine, sound_events,
sound_preview, spans, split_layout, split_map, split_profiles, sprites, streaming,
structure_block, tab_list, telemetry, terrain_model, test_block, test_instance_block, testgen,
title_edition, trims, ui, vault, weather, widgets, world, worldborder, zip,
};
pub mod attack_strength;
pub mod attribute_updates;
+4 -16
View File
@@ -4,6 +4,7 @@
//! reconnects, a solo game's controller coming and going) decides that for itself.
use crate::gamepad::{Button, GlyphStyle};
use crate::hud::{HudAtlas, HudBatch, rgba};
use crate::hud_kit::plate::{self, Plate};
use crate::pad_glyph;
use crate::toasts::Slide;
use std::time::{Duration, Instant};
@@ -117,7 +118,8 @@ impl NoticeSlot {
}
!finished
}
/// The notice under `top` on a view: a strip in the middle, its words wrapped to the view (a
/// The notice under `top` (GUI px; the caller keeps it below the boss bars with the HUD's
/// `Room`) on a view: a strip in the middle, its words wrapped to the view (a
/// toast is as wide as its longest line: off a narrow view's edge), sliding down from the
/// view's edge.
pub fn draw(&mut self, batch: &mut HudBatch, atlas: &HudAtlas, top: f32, shown_for: Duration) {
@@ -142,7 +144,7 @@ impl NoticeSlot {
let (pw, ph) = (inner.min(max) + 16.0, 20.0 + 10.0 * lines.len() as f32);
let px = ((w - pw) / 2.0).floor();
let py = (top - (1.0 - notice.slide.portion) * (top + ph)).floor();
panel(batch, atlas, [px, py, pw, ph]);
plate::draw(batch, atlas, Plate::Notice, [px, py, pw, ph]);
let white = rgba(255, 255, 255, 255);
let tx = (px + (pw - width(&notice.title)) / 2.0).floor();
batch.text(atlas, &notice.title, tx, py + 6.0, white, true);
@@ -157,20 +159,6 @@ impl NoticeSlot {
}
}
/// The prompts' and notices' box: dark, a faint edge.
pub fn panel(batch: &mut HudBatch, atlas: &HudAtlas, [px, py, pw, ph]: [f32; 4]) {
batch.fill(atlas, px, py, pw, ph, rgba(0, 0, 0, 210));
let edge = rgba(255, 255, 255, 90);
for [x, y, fw, fh] in [
[px, py, pw, 1.0],
[px, py + ph - 1.0, pw, 1.0],
[px, py, 1.0, ph],
[px + pw - 1.0, py, 1.0, ph],
] {
batch.fill(atlas, x, y, fw, fh, edge);
}
}
#[cfg(test)]
mod tests {
use super::*;
+292 -42
View File
@@ -14,7 +14,7 @@ use crate::{
use horizon_core::{
geometry::{Aabb, Direction, Vec3, VoxelShape, border::BorderGeometry},
position::BlockPos,
random::RandomSource,
random::{LegacyRandom, RandomSource},
};
use horizon_world::{
block_collision::BlockCollisionRegistry,
@@ -1101,10 +1101,20 @@ fn lava_cauldron_contacts(
pub struct ObstacleGrid {
cells: crate::fast_hash::FxHashMap<(i32, i32, i32), Vec<u32>>,
boxes: Vec<Aabb>,
/// Each box's entity's `getRootVehicle` when it rides something; empty
/// when none was given.
roots: Vec<Option<u32>>,
found: Vec<u32>,
}
impl ObstacleGrid {
const CELL: f64 = 8.0;
pub fn new(boxes: Vec<Aabb>) -> Self {
Self::with_roots(boxes, Vec::new())
}
/// [`Self::new`] with the vehicle at the bottom of what each box's entity
/// rides (`roots[i]` for `boxes[i]`), so [`Self::near_except`] can leave a
/// mover's own riders out.
pub fn with_roots(boxes: Vec<Aabb>, roots: Vec<Option<u32>>) -> Self {
let mut cells: crate::fast_hash::FxHashMap<(i32, i32, i32), Vec<u32>> = Default::default();
for (i, b) in boxes.iter().enumerate() {
let (lo, hi) = Self::span(*b);
@@ -1116,7 +1126,12 @@ impl ObstacleGrid {
}
}
}
Self { cells, boxes }
Self {
cells,
boxes,
roots,
found: Vec::new(),
}
}
fn span(b: Aabb) -> ((i32, i32, i32), (i32, i32, i32)) {
let cell = |v: Vec3| {
@@ -1132,30 +1147,54 @@ impl ObstacleGrid {
self.boxes.is_empty()
}
/// The boxes meeting `area` (`getEntityCollisions`'s query box), in the order they were given.
pub fn near(&self, area: Aabb, out: &mut Vec<Aabb>) {
pub fn near(&mut self, area: Aabb, out: &mut Vec<Aabb>) {
self.near_except(area, None, out);
}
/// [`Self::near`] without the boxes of what rides `vehicle` (given with
/// [`Self::with_roots`]): `Entity.canCollideWith` leaves out
/// `isPassengerOfSameVehicle`, so a stand's riders do not stop it.
pub fn near_except(&mut self, area: Aabb, vehicle: Option<u32>, out: &mut Vec<Aabb>) {
out.clear();
if self.boxes.is_empty() {
return;
}
let kept = |i: usize| vehicle.is_none() || self.roots.get(i).copied().flatten() != vehicle;
let (lo, hi) = Self::span(area);
let mut found: Vec<u32> = Vec::new();
let side = |lo: i32, hi: i32| (i64::from(hi) - i64::from(lo) + 1).max(0);
let cells = side(lo.0, hi.0)
.saturating_mul(side(lo.1, hi.1))
.saturating_mul(side(lo.2, hi.2));
// An area over more cells than there are boxes (a stand loaded with a
// huge Motion asks for one millions of blocks long): test each box
// once rather than walk the cells.
if cells > self.boxes.len() as i64 {
out.extend(
self.boxes
.iter()
.enumerate()
.filter(|&(i, b)| kept(i) && b.intersects(area))
.map(|(_, b)| *b),
);
return;
}
self.found.clear();
for x in lo.0..=hi.0 {
for y in lo.1..=hi.1 {
for z in lo.2..=hi.2 {
if let Some(list) = self.cells.get(&(x, y, z)) {
found.extend(list);
self.found.extend(list);
}
}
}
}
found.sort_unstable();
found.dedup();
out.extend(
found
.into_iter()
.map(|i| self.boxes[i as usize])
.filter(|b| b.intersects(area)),
);
self.found.sort_unstable();
self.found.dedup();
for &i in &self.found {
let b = self.boxes[i as usize];
if kept(i as usize) && b.intersects(area) {
out.push(b);
}
}
}
}
@@ -1315,6 +1354,9 @@ pub struct ExperienceOrb {
pub health: i32,
/// `Entity.noGravity`: `ExperienceOrb.tick`'s `applyGravity` does nothing.
pub no_gravity: bool,
/// `ExperienceOrb.followingPlayer`: the followed player's entity id, kept
/// until they are gone or further than 8 blocks. Never saved.
pub following: Option<u32>,
}
impl ExperienceOrb {
/// `ExperienceOrb.hurtServer`: the damage comes off the health as an
@@ -1466,7 +1508,9 @@ impl ExperienceOrb {
) -> bool {
self.ticks += 1;
self.previous = self.position;
self.update_fluids(world, fluids);
// `Entity.baseTick`'s fluid step, whose `updateFluidOnEyes` answer the
// water branch below reads: the orb's eye, not its feet, decides.
let eye_in_water = self.update_fluids(world, fluids);
let block = world.block(
self.position.x.floor() as i32,
self.position.y.floor() as i32,
@@ -1481,7 +1525,10 @@ impl ExperienceOrb {
// `boolean colliding = !level.noCollision(getBoundingBox())`: an orb
// inside blocks takes no gravity until it is pushed out below.
let colliding = !crate::collisions::no_collision_empty(&mut scene, bounds);
if facts.get(block).fluid == Fluid::Water {
// ExperienceOrb.tick: `isEyeInFluid(FluidTags.WATER)` floats the orb,
// so an orb whose eye dips into the surface floats with its feet
// still in the air block above it.
if eye_in_water {
self.set_underwater_movement();
} else if !colliding {
self.velocity.y -= self.get_gravity();
@@ -1932,6 +1979,7 @@ pub struct ItemFrame {
pub check_interval: u32,
/// `Entity.getCustomName` (`CustomName`), copied onto the dropped frame.
pub custom_name: Option<horizon_world::nbt::Tag>,
pub random: LegacyRandom,
}
impl ItemFrame {
pub fn step(&self) -> [i32; 3] {
@@ -2106,7 +2154,7 @@ impl ArmorStand {
in_water: bool,
border: Option<&BorderGeometry>,
) {
if let Some(step) = self.tick_move(scene, facts, climbing, in_water, border) {
if let Some(step) = self.tick_move(scene, facts, climbing, in_water, border, &[]) {
let medium = if in_water {
StandMedium::Water
} else {
@@ -2374,10 +2422,12 @@ impl ArmorStand {
}
self.health > 0.5
}
/// EntityType.ARMOR_STAND dimensions (0.5 x 1.975, half for small).
/// EntityType.ARMOR_STAND dimensions (0.5 x 1.975F, half for small).
pub fn bounds(&self) -> Aabb {
let s = if self.small { 0.5 } else { 1.0 };
let (w, h) = (0.25 * s, 1.975 * s);
// EntityDimensions.makeBoundingBox adds the float height, widened
// (halving it for the small stand is exact).
let (w, h) = (0.25 * s, f64::from(1.975_f32) * s);
let p = self.position;
Aabb::new(
Vec3::new(p.x - w, p.y, p.z - w),
@@ -2402,7 +2452,10 @@ impl ArmorStand {
/// The game runs the fluid push first (`Entity::push_by_fluid`), then
/// this up to the blocks' push, then the bubble column
/// (`Entity::push_by_bubble_column`), then [`ArmorStand::tick_settle`].
/// None: a marker or a stand with `NoGravity` does not travel.
/// `entities` are the boxes `EntityGetter.getEntityCollisions` gives the
/// stand (a living shulker, a boat, a happy ghast held still), which stop
/// its move as blocks do. None: a marker or a stand with `NoGravity` does
/// not travel.
pub fn tick_move(
&mut self,
scene: &mut crate::collisions::SceneCollisions,
@@ -2410,6 +2463,7 @@ impl ArmorStand {
climbing: &horizon_world::climbing::ClimbingRegistry<'_>,
in_water: bool,
border: Option<&BorderGeometry>,
entities: &[Aabb],
) -> Option<StandStep> {
use crate::entity_rules::EntityRules;
self.previous = self.position;
@@ -2468,18 +2522,20 @@ impl ArmorStand {
}
let mut velocity = wanted;
let mut horizontal_collision = false;
// Entity.collide: the world border's walls stop the stand too.
// Entity.collide: the world border's walls and the entities that
// `canBeCollidedWith` the stand stop it too.
let moved = SceneCollisions {
world: scene.world,
binding: scene.binding,
subject: scene.subject,
}
.resolve_in_border(
.resolve_in_border_with(
self.bounds(),
wanted,
0.0,
self.on_ground,
border.map(|b| (b, [self.position.x, self.position.z])),
entities,
);
if let Ok(moved) = moved {
self.position = Vec3::new(
@@ -2489,24 +2545,7 @@ impl ArmorStand {
);
self.on_ground = wanted.y < 0.0 && moved.y > wanted.y;
// Entity.setOnGroundWithMovement: the block under the feet.
let context = horizon_world::collision_context::CollisionContext::for_entity(
horizon_world::collision_context::CollisionEntity {
identity: horizon_world::entity_obstruction::EntityIdentity(u64::from(self.id)),
kind: horizon_world::collision_context::EntityContextKind::Living,
y: self.position.y,
descending: false,
main_hand: Some(horizon_world::collision_context::CollisionItem::EMPTY),
},
false,
);
let position = self.position;
let _ =
self.support
.check_support(self.on_ground, self.bounds(), Some(wanted), |area| {
horizon_world::ground_support::find_supporting_block(
scene, context, position, area, 1_000_000,
)
});
self.ground_support(scene, wanted);
if (wanted.x - moved.x).abs() >= f64::from(1.0e-5_f32) {
horizontal_collision = true;
velocity.x = 0.0;
@@ -2525,6 +2564,115 @@ impl ArmorStand {
self.velocity = velocity;
Some(StandStep { falling, friction })
}
/// `Entity.move(MoverType.SHULKER, delta)`: an opening shulker lid
/// shoves the stand (one with physics) by `delta`. Blocks, the world
/// border and `entities` (the boxes `getEntityCollisions` gives the stand)
/// cut the move short, a move cut to almost nothing is not made, and an
/// axis that hits something loses its speed
/// (`restituteMovementAfterCollisions`: a stand does not bounce, so the
/// speed becomes `-speed * 0`).
///
/// The stand has already travelled this tick (stands tick before mobs
/// here). `travels_after` says vanilla ticks it after the shulker (it
/// joined the level later, as a stand placed on the shell does), so
/// there the push comes first and its own travel follows:
/// - a push nothing stops leaves `on_ground` and the speed as that
/// travel left them (it lands the stand again on the risen lid);
/// - a push down that something stops: the travel that follows starts
/// with no vertical speed, moves nothing vertically (`on_ground`
/// false) and ends with gravity's speed only, which the stand's own
/// travel, stopped by the same floor, already gave it;
/// - a push up that a ceiling stops loses the vertical speed, so the
/// stand stays pinned on the next tick as vanilla's does (vanilla
/// shows gravity's speed by then, and once the lid stops, the stand
/// falls through it one tick later than vanilla's).
///
/// Otherwise vanilla ticks it before the shulker, as here, and the push
/// sets `on_ground` and the block under the feet itself. Left out: the
/// block speed factor on the horizontal speed, the fall distance a push
/// down through the air adds, and a slime or bed bounce.
pub fn shove(
&mut self,
scene: SceneCollisions,
delta: Vec3,
border: Option<&BorderGeometry>,
entities: &[Aabb],
travels_after: bool,
) {
let mut scene = SceneCollisions {
subject: self.feet_are_leather_boots().then_some(
crate::collisions::PowderSnowSubject {
leather_boots: true,
..Default::default()
},
),
..scene
};
let Ok(moved) = SceneCollisions {
world: scene.world,
binding: scene.binding,
subject: scene.subject,
}
.resolve_in_border_with(
self.bounds(),
delta,
0.0,
self.on_ground,
border.map(|b| (b, [self.position.x, self.position.z])),
entities,
) else {
return;
};
let length = moved.length_squared();
if length > 1.0e-7 || delta.length_squared() - length < 1.0e-7 {
self.position = self.position.add_vector(moved);
}
// Mth.equal on x and z; y only when the push had a vertical part.
if (delta.x - moved.x).abs() >= f64::from(1.0e-5_f32) {
self.velocity.x = -self.velocity.x * 0.0;
}
if (delta.z - moved.z).abs() >= f64::from(1.0e-5_f32) {
self.velocity.z = -self.velocity.z * 0.0;
}
let vertical = moved.y != delta.y;
if travels_after {
if vertical {
self.on_ground = false;
if delta.y > 0.0 {
self.velocity.y = -self.velocity.y * 0.0;
}
self.ground_support(&mut scene, moved);
}
} else {
if vertical && delta.y != 0.0 {
self.velocity.y = -self.velocity.y * 0.0;
}
self.on_ground = vertical && delta.y < 0.0;
self.ground_support(&mut scene, moved);
}
}
/// `Entity.checkSupportingBlock` after a move by `movement`: the block
/// under the feet while `on_ground`, none otherwise.
fn ground_support(&mut self, scene: &mut SceneCollisions, movement: Vec3) {
let context = horizon_world::collision_context::CollisionContext::for_entity(
horizon_world::collision_context::CollisionEntity {
identity: horizon_world::entity_obstruction::EntityIdentity(u64::from(self.id)),
kind: horizon_world::collision_context::EntityContextKind::Living,
y: self.position.y,
descending: false,
main_hand: Some(horizon_world::collision_context::CollisionItem::EMPTY),
},
false,
);
let (position, bounds) = (self.position, self.bounds());
let _ = self
.support
.check_support(self.on_ground, bounds, Some(movement), |area| {
horizon_world::ground_support::find_supporting_block(
scene, context, position, area, 1_000_000,
)
});
}
/// `HoneyBlock.entityInside`: side-slide movement after the stand's travel.
/// `ArmorStand.hasPhysics` / `Entity.isAffectedByBlocks` exclude markers
/// and NoGravity stands. Slide effects are separate from this movement.
@@ -3338,6 +3486,7 @@ mod world_border_tests {
count: 1,
ticks: 0,
health: 5,
following: None,
};
orb.tick(
&world,
@@ -4010,7 +4159,7 @@ mod entity_collision_tests {
Vec3::new(x + 1.375, 64.5625, 1.375),
)
};
let grid = ObstacleGrid::new((0..1000).map(|i| boat(f64::from(i) * 100.0)).collect());
let mut grid = ObstacleGrid::new((0..1000).map(|i| boat(f64::from(i) * 100.0)).collect());
let mut out = Vec::new();
// Around the 7th boat: only it.
let area = Aabb::new(Vec3::new(699.0, 63.0, -1.0), Vec3::new(701.0, 66.0, 1.0));
@@ -4022,12 +4171,46 @@ mod entity_collision_tests {
assert!(out.is_empty());
// A box spanning cells is found from any of them, once.
let wide = Aabb::new(Vec3::new(0.0, 0.0, 0.0), Vec3::new(20.0, 1.0, 1.0));
let grid = ObstacleGrid::new(vec![wide]);
let mut grid = ObstacleGrid::new(vec![wide]);
let area = Aabb::new(Vec3::new(17.0, 0.0, 0.0), Vec3::new(18.0, 1.0, 1.0));
grid.near(area, &mut out);
assert_eq!(out, vec![wide]);
}
#[test]
fn obstacle_grid_leaves_out_a_movers_riders_and_answers_a_huge_area_at_once() {
let unit = |x: f64| Aabb::new(Vec3::new(x, 64.0, 0.0), Vec3::new(x + 1.0, 65.0, 1.0));
// The shulker at x = 2 rides stand 7; the one at x = 0 and the boat
// at x = 4 ride nothing.
let mut grid = ObstacleGrid::with_roots(
vec![unit(0.0), unit(2.0), unit(4.0)],
vec![None, Some(7), None],
);
let mut out = Vec::new();
let area = Aabb::new(Vec3::new(-1.0, 63.0, -1.0), Vec3::new(6.0, 66.0, 2.0));
grid.near_except(area, Some(7), &mut out);
assert_eq!(out, vec![unit(0.0), unit(4.0)], "stand 7 ignores its rider");
grid.near_except(area, Some(8), &mut out);
assert_eq!(
out,
vec![unit(0.0), unit(2.0), unit(4.0)],
"stand 8 does not"
);
// A stand loaded with Motion 1e10 asks for an area 1e10 blocks long
// (about 1.25e9 cells): the boxes in it come back in order, at once.
let huge = Aabb::new(Vec3::new(-1.0, 63.0, -1.0), Vec3::new(1.0e10, 66.0, 2.0));
let start = std::time::Instant::now();
grid.near(huge, &mut out);
assert_eq!(out, vec![unit(0.0), unit(2.0), unit(4.0)]);
let far = Aabb::new(
Vec3::new(-1.0e300, 63.0, -1.0),
Vec3::new(1.0e300, 66.0, 2.0),
);
grid.near(far, &mut out);
assert_eq!(out.len(), 3);
assert!(start.elapsed() < std::time::Duration::from_secs(1));
}
/// A farm: 5000 drops and 1000 separated vehicles. Run with `-- --ignored --nocapture`.
#[test]
#[ignore = "timing"]
@@ -4053,7 +4236,7 @@ mod entity_collision_tests {
hits += boxes.iter().filter(|b| b.intersects(area)).count();
}
let scan = start.elapsed();
let grid = ObstacleGrid::new(boxes);
let mut grid = ObstacleGrid::new(boxes);
let start = std::time::Instant::now();
let (mut out, mut found) = (Vec::new(), 0);
for item in &items {
@@ -4333,6 +4516,70 @@ mod armor_stand_motion_tests {
}
}
/// `Entity.collide` (#11877): the boxes `getEntityCollisions` gives a
/// stand (a shulker's here, one block on the test scene's ground, whose
/// top is at y = 4) stop it as blocks do. Dropped from y = 8 it rests on
/// the box's top at y = 5; sliding east at 2 blocks a tick from x = 18 it
/// stops with its east side on the box's west face, x = 20. With no boxes
/// it falls to the ground and slides on.
#[test]
fn a_stand_lands_on_and_stops_at_the_entity_boxes_it_is_given() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client")
.join(horizon_core::VANILLA_VERSION);
if !root.join("movement-properties.nbt").exists() {
return;
}
let assets = crate::assets::Assets::load(&root).unwrap();
let render = assets.reload(&root).unwrap();
let facts = BlockFacts::load(&root, assets.registry).unwrap();
let collision = crate::collisions::load(&root, assets.registry).unwrap();
let world = World::test_scene(&assets, &render, 3).unwrap();
let empty = Default::default();
let climbing = horizon_world::climbing::ClimbingRegistry::new(
collision.registry(),
&empty,
&empty,
&empty,
)
.unwrap();
let shulker = Aabb::new(Vec3::new(20.0, 4.0, 20.0), Vec3::new(21.0, 5.0, 21.0));
let run = |stand: &mut ArmorStand, boxes: &[Aabb], ticks: usize| {
for _ in 0..ticks {
let mut scene = SceneCollisions {
world: &world,
binding: &collision,
subject: None,
};
if let Some(step) =
stand.tick_move(&mut scene, &facts, &climbing, false, None, boxes)
{
stand.tick_settle(StandMedium::Air, step);
}
}
};
for (boxes, floor) in [(&[shulker][..], 5.0), (&[][..], 4.0)] {
let mut stand = ArmorStand::new(1, Vec3::new(20.5, 8.0, 20.5), 0.0);
run(&mut stand, boxes, 20);
let y = stand.position.y;
assert!((y - floor).abs() < 1.0e-9, "rests at {floor}, got {y}");
assert!(stand.on_ground, "on the ground at {floor}");
}
for (boxes, blocked) in [(&[shulker][..], true), (&[][..], false)] {
let mut stand = ArmorStand::new(1, Vec3::new(18.0, 4.0, 20.5), 0.0);
stand.on_ground = true;
stand.velocity = Vec3::new(2.0, 0.0, 0.0);
run(&mut stand, boxes, 1);
let east = stand.bounds().max_position().x;
if blocked {
assert!((east - 20.0).abs() < 1.0e-7, "stops at the box, got {east}");
assert_eq!(stand.velocity.x, 0.0, "the wall takes its speed");
} else {
assert!((east - 20.25).abs() < 1.0e-7, "goes on, got {east}");
}
}
}
#[test]
fn a_stand_in_lava_halves_its_motion_and_sinks_by_a_quarter_of_gravity() {
// LivingEntity.travelInLava, measured on the real server: a stand at
@@ -4642,6 +4889,7 @@ mod orb_underwater_tests {
count: 1,
ticks: 0,
health: 5,
following: None,
};
orb.set_underwater_movement();
[
@@ -4675,6 +4923,7 @@ mod render_light_tests {
count: 1,
ticks: 0,
health: 5,
following: None,
}
}
@@ -4871,6 +5120,7 @@ mod orb_lava_tests {
count: 1,
ticks: 0,
health,
following: None,
}
}
+73 -5
View File
@@ -57,7 +57,7 @@ pub fn update_entity_fluids(
velocity: &mut Vec3,
world: &World,
fluids: &FluidResources,
) -> (f64, f64) {
) -> (f64, f64, bool) {
let mut interaction = EntityFluidInteraction::new([WATER, LAVA]);
let mut scene = EntityFluidWorld {
bounds,
@@ -68,10 +68,13 @@ pub fn update_entity_fluids(
fluids,
};
if interaction.update(&mut scene, false, 4096).is_err() {
return (0.0, 0.0);
return (0.0, 0.0, false);
}
let water_height = interaction.fluid_height(&scene, WATER);
let lava_height = interaction.fluid_height(&scene, LAVA);
// `updateFluidOnEyes`'s answer, kept by the interaction's trackers:
// whether the entity's eye is inside the water it scanned.
let eye_in_water = interaction.is_eye_in_fluid(&FluidTags, WATER);
let _ = interaction.apply_current(&mut scene, WATER, 0.014);
let _ = interaction.apply_current(
&mut scene,
@@ -82,7 +85,7 @@ pub fn update_entity_fluids(
0.0023333333333333335
},
);
(water_height, lava_height)
(water_height, lava_height, eye_in_water)
}
impl ItemEntity {
/// `Entity.getEyeY` of an item (eye height 0.2125), where
@@ -94,13 +97,17 @@ impl ItemEntity {
#[cfg(test)]
pub(super) fn update_fluids(&mut self, world: &World, fluids: &FluidResources) -> (f64, f64) {
let (bounds, at, eye) = (self.bounds(), self.position, self.fluid_eye_y());
update_entity_fluids(bounds, at, eye, &mut self.velocity, world, fluids)
let (water, lava, _) =
update_entity_fluids(bounds, at, eye, &mut self.velocity, world, fluids);
(water, lava)
}
}
impl ExperienceOrb {
/// `Entity.updateFluidInteraction`: refreshes fluid immersion over the
/// orb's box and pushes its velocity by any water or lava current.
pub fn update_fluids(&mut self, world: &World, fluids: &FluidResources) -> (f64, f64) {
/// Answers `isEyeInFluid(FluidTags.WATER)` for the eye the scan reads,
/// `0.425` above the orb's feet.
pub fn update_fluids(&mut self, world: &World, fluids: &FluidResources) -> bool {
update_entity_fluids(
self.bounds(),
self.position,
@@ -109,6 +116,7 @@ impl ExperienceOrb {
world,
fluids,
)
.2
}
}
impl ArmorStand {
@@ -419,6 +427,7 @@ mod tests {
count: 1,
ticks: 0,
health: 5,
following: None,
};
let mut still = orb.clone();
assert!(still.tick(
@@ -469,6 +478,65 @@ mod tests {
assert!(carried.position.x > at.x + 0.5);
}
/// `ExperienceOrb.tick`'s water branch reads `isEyeInFluid(FluidTags.WATER)`,
/// not the feet's block: an orb whose feet hang in the air under the
/// surface, with its eye in the water, floats (`setUnderwaterMovement`)
/// instead of falling.
#[test]
fn an_orb_whose_eye_dips_into_water_floats_with_its_feet_in_air() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client")
.join(horizon_core::VANILLA_VERSION);
if !root.join("movement-properties.nbt").exists() {
return;
}
let assets = crate::assets::Assets::load(&root).unwrap();
let render = assets.reload(&root).unwrap();
let facts = crate::blocks::BlockFacts::load(&root, assets.registry).unwrap();
let fluids = FluidResources::new(&assets, &facts).unwrap();
let collision = crate::collisions::load(&root, assets.registry).unwrap();
let mut world = World::test_scene(&assets, &render, 3).unwrap();
let water = assets.registry.default_state("minecraft:water").unwrap();
for x in 19..=21 {
for z in 19..=21 {
world.set(x, 5, z, water).unwrap();
}
}
let at = Vec3::new(20.5, 4.6, 20.5);
let mut orb = ExperienceOrb {
rotation: [0.0, 0.0],
join: Default::default(),
no_gravity: false,
id: 1,
position: at,
previous: at,
velocity: Vec3::ZERO,
value: 1,
age: 0,
on_ground: false,
count: 1,
ticks: 0,
health: 5,
following: None,
};
assert!(orb.tick(
&world,
&facts,
&collision,
None,
true,
&mut || 0.0,
None,
&fluids,
&mut |_, _| {},
));
assert!(
orb.velocity.y > 0.0,
"the eye in the water floats the orb, got {}",
orb.velocity.y
);
}
/// `Entity.baseTick` pushes a boat by the current it sits in: a boat box
/// in water[level=1..7] gains velocity along the flow, and the same box in
/// still source water does not. Vanilla's boat tick runs this before
+163 -17
View File
@@ -49,6 +49,13 @@ pub fn uuid(tag: &Tag) -> Option<[i32; 4]> {
pub fn motion(tag: &Tag) -> Vec3 {
read_doubles::<3>(tag.get("Motion")).map_or(Vec3::ZERO, |m| Vec3::new(m[0], m[1], m[2]))
}
/// A saved Motion as `Entity.load` takes it: a component faster than 10 blocks
/// a tick loads as 0.
pub fn loaded_motion(tag: &Tag) -> Vec3 {
let m = motion(tag);
let clamp = |v: f64| if v.abs() > 10.0 { 0.0 } else { v };
Vec3::new(clamp(m.x), clamp(m.y), clamp(m.z))
}
pub fn rotation(tag: &Tag) -> [f32; 2] {
read_doubles::<2>(tag.get("Rotation")).map_or([0.0; 2], |r| [r[0] as f32, r[1] as f32])
}
@@ -223,6 +230,33 @@ pub fn who_uuid(who: Who, uuid_of: &mut impl FnMut(u32) -> Option<[i32; 4]>) ->
Who::Mob(id) => uuid_of(id),
}
}
/// Replace every reference to the player `old` in a saved entity with `new`. Older
/// Horizon worlds named their single player by the offline UUID (`Owner` on tamed
/// animals and horses, a projectile's `Owner` and an item's `Thrower`, a fox's
/// `Trusted`, `angry_at`, a leash's `UUID`); when such a world opens under the
/// account (`lan::playerdata::migrate_owner`), a chunk's entities are rewritten as
/// they load, and their next save keeps the account's UUID. A reference is the
/// player's four ints, wherever the entity keeps it; an entity's own `UUID` never
/// names the player.
pub fn migrate_player_uuid(tag: &mut Tag, old: [i32; 4], new: [i32; 4]) {
if old == new {
return;
}
match tag {
Tag::IntArray(uuid) if uuid.as_slice() == old.as_slice() => uuid.copy_from_slice(&new),
Tag::List(list) => {
for tag in list {
migrate_player_uuid(tag, old, new);
}
}
Tag::Compound(map) => {
for tag in map.values_mut() {
migrate_player_uuid(tag, old, new);
}
}
_ => {}
}
}
/// The entity a stored UUID names, while it is in the level: `player` (the
/// player's own UUID, when one is in the level), or the mob `uuids` gives that
/// UUID to. `uuids` is the game layer's id -> UUID map, so this is the read half
@@ -1449,12 +1483,20 @@ pub fn mob_to_nbt(
// the attack damage.
use crate::mobs::finalize::Attr;
// Every other living entity's first tick makes the movement_speed instance
// (LivingEntity.removeFrost reads it through getAttribute).
let made = if species == crate::mobs::Species::Rabbit {
vec![Attr::MovementSpeed, Attr::AttackDamage]
} else {
vec![Attr::MovementSpeed]
};
// (LivingEntity.removeFrost reads it through getAttribute); before it, the
// mob has only the instances it was loaded with (the kept list above).
let speed_at_load =
crate::mobs::doors::is_zombie(species) || species == crate::mobs::Species::Piglin;
let mut made = Vec::new();
if speed_at_load || m.age > 0 {
made.push(Attr::MovementSpeed);
}
if species == crate::mobs::Species::Rabbit {
made.push(Attr::AttackDamage);
}
// AttributeMap.pack writes the list even when no instance exists.
map.entry("attributes".into())
.or_insert_with(|| Tag::List(Vec::new()));
for attr in made {
let base = crate::mobs::finalize::base_value(m, attr);
let mut list = match map.get(&"attributes".into()) {
@@ -1492,6 +1534,15 @@ pub fn mob_to_nbt(
if matches!(m.species(), S::Evoker | S::Illusioner) {
map.insert("SpellTicks".into(), Tag::Int(m.extra.counter));
}
// Vindicator.addAdditionalSaveData: `Johnny` only while the flag is set
// (`if (this.isJohnny) output.putBoolean("Johnny", true)`).
if m.species() == S::Vindicator {
if m.extra.johnny {
map.insert("Johnny".into(), Tag::Byte(1));
} else {
map.remove(&"Johnny".into());
}
}
crate::mobs::raider::write_nbt(m, map);
crate::mobs::riders::write_nbt(m, map);
// LivingEntity equipment: what the mob holds now.
@@ -2135,6 +2186,13 @@ pub fn mob_from_nbt(
.map(Box::new),
);
}
if m.extra
.effects
.iter()
.any(|e| e.0 == "minecraft:health_boost")
{
crate::mobs::finalize::health_boost_changed(&mut m);
}
}
m.silent = flag(tag, "Silent");
m.invulnerable_flag = flag(tag, "Invulnerable");
@@ -2181,12 +2239,9 @@ pub fn mob_from_nbt(
if m.species() == crate::mobs::Species::Piglin
&& let Some(Tag::List(items)) = tag.get("Inventory")
{
// InventoryCarrier.readInventoryFromTag (eight slots).
m.extra.inventory = items
.iter()
.filter_map(crate::save::item_from_nbt)
.take(8)
.collect();
// InventoryCarrier.readInventoryFromTag -> SimpleContainer
// .fromItemList (eight slots).
m.extra.inventory = crate::mobs::villager::load_container(&m, items, 8);
}
}
if matches!(
@@ -2663,6 +2718,11 @@ pub fn mob_from_nbt(
) {
m.extra.counter = number(tag.get("SpellTicks")).unwrap_or(0) as i32;
}
// Vindicator.readAdditionalSaveData: `Johnny`, false when absent. A name
// that is Johnny latches the flag again on the next AI step.
if m.species() == crate::mobs::Species::Vindicator {
m.extra.johnny = flag(tag, "Johnny");
}
crate::mobs::raider::read_nbt(&mut m, tag);
crate::mobs::riders::read_nbt(&mut m, tag);
// A mannequin is an `Avatar`, not a `Mob`: it reads none of
@@ -2985,6 +3045,9 @@ pub fn orbs_from_nbt(tag: &Tag) -> Vec<ExperienceOrb> {
count,
ticks: 0,
health: number(tag.get("Health")).unwrap_or(5) as i32,
// ExperienceOrb's followingPlayer is transient: never saved, so a
// loaded orb starts without one.
following: None,
};
vec![orb]
}
@@ -3890,6 +3953,7 @@ pub fn item_frame_from_nbt(tag: &Tag, id: u32) -> Option<ItemFrame> {
invisible: flag(tag, "Invisible"),
check_interval: 0,
custom_name: tag.get("CustomName").cloned(),
random: crate::random_source::entity(),
})
}
/// ArmorStand.addAdditionalSaveData with LivingEntity's `equipment`.
@@ -4004,7 +4068,7 @@ pub fn armor_stand_from_nbt(tag: &Tag, id: u32) -> Option<ArmorStand> {
// AttributeMap.load makes an instance for each saved attribute.
a.speed_instance = matches!(tag.get("attributes"), Some(Tag::List(list))
if list.iter().any(|attr| text(attr.get("id")).as_deref() == Some(SPEED_ATTRIBUTE)));
a.velocity = motion(tag);
a.velocity = loaded_motion(tag);
a.health = float(tag.get("Health")).unwrap_or(20.0) as f32;
a.fire_ticks = number(tag.get("Fire")).unwrap_or(0) as i32;
a.custom_name = tag.get("CustomName").map(name_text);
@@ -4037,8 +4101,9 @@ pub fn armor_stand_from_nbt(tag: &Tag, id: u32) -> Option<ArmorStand> {
}
Some(a)
}
/// A boat entity (`minecraft:<wood>_boat`, `minecraft:bamboo_chest_raft`...):
/// chest boats keep their slots in `Items` (`ContainerHelper.saveAllItems`).
/// `AbstractBoat.addAdditionalSaveData` / `ContainerEntity.addChestVehicleSaveData`:
/// only chest boats own container fields; a pending table replaces `Items`.
/// Ordinary slots use `ContainerHelper.saveAllItems` through the existing item encoder.
pub fn boat_to_nbt(
id: &str,
position: Vec3,
@@ -4058,8 +4123,22 @@ pub fn boat_to_nbt(
fall_distance: 0.0,
}
.non_mob_tag(raw);
if let (Some(items), Tag::Compound(map)) = (items, &mut tag) {
map.insert("Items".into(), crate::save::encode_items(items));
if let Tag::Compound(map) = &mut tag {
map.remove(&"Items".into());
if let Some(items) = items {
let table = crate::save::text(map.get(&"LootTable".into()))
.and_then(|id| horizon_client_core::identifier::normalize_id(&id));
if let Some(table) = table {
map.insert("LootTable".into(), Tag::String(table.as_str().into()));
} else {
map.remove(&"LootTable".into());
map.remove(&"LootTableSeed".into());
map.insert("Items".into(), crate::save::encode_items(items));
}
} else {
map.remove(&"LootTable".into());
map.remove(&"LootTableSeed".into());
}
}
tag
}
@@ -4523,6 +4602,50 @@ mod tests {
use super::*;
use crate::items::ItemStack;
/// Older worlds name their single player by the offline UUID; the migration rewrites
/// every reference to it (owner, thrower, trusted list, anger, leash) and nothing else.
#[test]
fn a_players_offline_uuid_becomes_the_accounts_everywhere_in_an_entity() {
let old = [1, 2, 3, 4];
let new = [5, 6, 7, 8];
let mut tag = Tag::compound([
("id", string("minecraft:wolf")),
("UUID", Tag::IntArray(vec![9, 9, 9, 9])),
("Owner", Tag::IntArray(old.to_vec())),
("Thrower", Tag::IntArray(old.to_vec())),
("angry_at", Tag::IntArray(old.to_vec())),
(
"Trusted",
Tag::List(vec![
Tag::IntArray(old.to_vec()),
Tag::IntArray(vec![3, 3, 3, 3]),
]),
),
(
"leash",
Tag::compound([("UUID", Tag::IntArray(old.to_vec()))]),
),
("Health", Tag::Float(20.0f32.to_bits())),
]);
migrate_player_uuid(&mut tag, old, new);
assert_eq!(tag.get("Owner"), Some(&Tag::IntArray(new.to_vec())));
assert_eq!(tag.get("Thrower"), Some(&Tag::IntArray(new.to_vec())));
assert_eq!(tag.get("angry_at"), Some(&Tag::IntArray(new.to_vec())));
assert_eq!(
tag.get("Trusted"),
Some(&Tag::List(vec![
Tag::IntArray(new.to_vec()),
Tag::IntArray(vec![3, 3, 3, 3]),
]))
);
assert_eq!(
tag.get("leash").and_then(|leash| leash.get("UUID")),
Some(&Tag::IntArray(new.to_vec()))
);
// The entity's own UUID is not the player's.
assert_eq!(tag.get("UUID"), Some(&Tag::IntArray(vec![9, 9, 9, 9])));
}
#[test]
fn empty_saved_tags_are_omitted_without_changing_raw_or_nonempty_tags() {
let base = Base {
@@ -5103,6 +5226,27 @@ mod tests {
assert!(!armor_stand_from_nbt(&cleared, 7).unwrap().no_gravity);
}
/// `Entity.load`: a Motion component past 10 blocks a tick loads as 0,
/// the others as saved.
#[test]
fn armor_stand_drops_a_motion_component_past_ten() {
let doubles = |v: [f64; 3]| Tag::List(v.map(|d| Tag::Double(d.to_bits())).to_vec());
let tag = Tag::compound([
("id", string("minecraft:armor_stand")),
("Pos", doubles([0.0, 64.0, 0.0])),
("Motion", doubles([1.0e10, -0.5, -10.5])),
]);
let a = armor_stand_from_nbt(&tag, 7).unwrap();
assert_eq!(a.velocity, Vec3::new(0.0, -0.5, 0.0));
let tag = Tag::compound([
("id", string("minecraft:armor_stand")),
("Pos", doubles([0.0, 64.0, 0.0])),
("Motion", doubles([10.0, 0.0, -10.0])),
]);
let a = armor_stand_from_nbt(&tag, 7).unwrap();
assert_eq!(a.velocity, Vec3::new(10.0, 0.0, -10.0), "10 itself stays");
}
fn falling_decode_registry() -> BlockRegistry {
BlockRegistry::from_nbt(&Tag::compound([
("DataVersion", Tag::Int(horizon_world::DATA_VERSION)),
@@ -5903,6 +6047,7 @@ mod tests {
count: 1,
ticks: 0,
health: 5,
following: None,
};
let tag = orb_to_nbt(&orb, [0; 4]);
assert_eq!(tag.get("Fire"), Some(&Tag::Short(0)));
@@ -5966,6 +6111,7 @@ mod tests {
count: 3,
ticks: 0,
health: 5,
following: None,
};
let tag = orb_to_nbt(&o, [0; 4]);
let back = orbs_from_nbt(&tag);
File diff suppressed because it is too large. Load diff
@@ -136,6 +136,9 @@ impl AdvState {
/// The game's world for predicates.
struct View<'a, 'g>(&'a Game<'g>);
impl adv::World for View<'_, '_> {
fn predicate(&self, id: &str) -> Option<&Value> {
self.0.fns.store.predicates.get(id)
}
fn dimension(&self) -> &str {
self.0.dim.id()
}
@@ -512,6 +515,10 @@ impl Game<'_> {
tool: None,
block_pos: None,
};
// An invalid reference anywhere fails the whole tree, even under an inversion.
if !adv::references_valid(v, &View(self)) {
return false;
}
adv::conditions_with(v, &ctx, &mut |leaf, ctx| {
adv::literal_random_chance(leaf, &mut || self.world.random.next_float())
.unwrap_or_else(|| adv::conditions(leaf, ctx, &View(self)))
@@ -768,7 +775,21 @@ impl Game<'_> {
super::persist::add_passengers(&mut tag, riders);
tag
});
let movement = (m.extra.mount.is_none()
&& m.rider.is_none()
&& self.riding != Some(m.id)
&& self.lan.as_ref().is_none_or(|host| {
host.hub
.guests
.iter()
.all(|guest| guest.vehicle != Some(m.id))
}))
.then_some(adv::MovementView {
per_tick: [m.velocity.x, m.velocity.y, m.velocity.z],
fall_distance: m.fall_distance,
});
EntityView {
movement,
kind: m.kind.id.clone(),
position: [m.position.x, m.position.y, m.position.z],
on_fire: m.fire_ticks > 0,
@@ -3289,3 +3310,114 @@ mod custom_data_equipment_tests {
);
}
}
#[cfg(test)]
mod movement_predicate_tests {
use super::super::native_machines::tests::server_game;
use horizon_core::geometry::Vec3;
use horizon_core::position::BlockPos;
use serde_json::json;
#[test]
#[ignore = "requires prepared resources and scratch cwd/homes"]
fn actual_registered_movement_predicate_uses_admitted_mob() {
let mut game = server_game();
game.build_registries();
game.set_allow_commands(true);
game.block_ticks
.add_empty_chunk(BlockPos::new(0, 0, 0).chunk())
.unwrap();
assert!(game.run_command("forceload add 0 0"));
assert!(game.run_command("fill 0 199 0 15 199 15 stone"));
assert!(game.world.loaded(4, 4));
let root = std::env::current_dir().unwrap().join("movement-world");
let pack = root.join("datapacks/movement");
let folder = pack.join("data/motion/predicate");
std::fs::create_dir_all(&folder).unwrap();
std::fs::write(pack.join("pack.mcmeta"), r#"{"pack":{"description":"Movement fixture","min_format":[121,0],"max_format":[121,0]}}"#).unwrap();
let entity =
|p| json!({"type":"minecraft:entity_properties","entity":"this","predicate":p});
let movement =
|p| entity(json!({"minecraft:entity_type":"minecraft:pig","minecraft:movement":p}));
let high = movement(json!({"x":{"min":100}}));
let zero = movement(json!({"x":{"min":0,"max":0}}));
let probes = [
("high", high.clone(), false),
("zero", zero.clone(), true),
("empty", movement(json!({})), true),
(
"not_high",
json!({"type":"minecraft:inverted","term":high}),
true,
),
(
"not_zero",
json!({"type":"minecraft:inverted","term":zero}),
false,
),
];
for (name, p, _) in &probes {
std::fs::write(folder.join(format!("{name}.json")), p.to_string()).unwrap();
}
std::fs::write(
folder.join("identity.json"),
entity(json!({"minecraft:entity_type":"minecraft:pig"})).to_string(),
)
.unwrap();
std::fs::write(
folder.join("always.json"),
r#"{"type":"minecraft:all_of","terms":[]}"#,
)
.unwrap();
game.world_root = root;
game.reload_datapacks(false);
assert!(game.run_command("scoreboard objectives add motion dummy"));
assert!(game.run_command("execute if predicate motion:always"));
let clock = game.world.game_time;
let actor = "000002a1-0000-0000-0000-000000000001";
assert!(game.run_command("summon pig 4.5 200 4.5 {UUID:[I;673,0,0,1],NoAI:1b,NoGravity:1b,Health:10f,Motion:[0d,0d,0d]}"));
assert!(game.run_command(&format!("execute as {actor} if entity @s[type=pig,nbt={{UUID:[I;673,0,0,1],Health:10f,Pos:[4.5d,200d,4.5d],Motion:[0d,0d,0d],NoAI:1b,NoGravity:1b}}]")));
assert!(game.run_command(&format!("execute as {actor} if predicate motion:identity")));
let id = *game
.persist
.uuids
.iter()
.find(|(_, u)| **u == [673, 0, 0, 1])
.unwrap()
.0;
let mob = game.mobs.iter().find(|m| m.id == id).unwrap();
assert_eq!(mob.velocity, Vec3::ZERO);
assert_eq!(mob.fall_distance, 0.0);
assert!(mob.no_ai && mob.no_gravity);
assert!(mob.extra.mount.is_none());
let view = game.entity_view_of(super::super::commands::Target::Mob(id));
assert_eq!(view.kind, "minecraft:pig");
let mut failures = Vec::new();
for (name, p, want) in probes {
assert!(
game.fns
.store
.predicates
.contains_key(&format!("motion:{name}"))
);
let direct = game.conditions_hold(&p, Some(&view), [4.5, 200.0, 4.5]);
if direct != want {
failures.push(format!("direct {name}: {direct} != {want}"));
}
assert!(game.run_command("scoreboard players set result motion 91"));
assert!(game.run_command("scoreboard players set success motion 17"));
game.run_command(&format!("execute store result score result motion store success score success motion as {actor} if predicate motion:{name}"));
for key in ["result", "success"] {
let value = game.score.board.score(key, "motion").unwrap().value;
if value != i32::from(want) {
failures.push(format!("registered {name} {key}: {value}"));
}
}
}
assert_eq!(game.world.game_time, clock, "zero observation ticks");
assert!(
failures.is_empty(),
"actual movement predicate mismatches: {failures:?}"
);
}
}
+1
View File
@@ -458,6 +458,7 @@ impl Game<'_> {
Screen::DebugOptions => "debug-options",
Screen::Mod => "mod",
Screen::HudEditor => "hud-editor",
Screen::Studio => "studio",
}
}
pub(super) fn inventory_counts(&self) -> BTreeMap<String, i64> {
+544 -1
View File
@@ -193,7 +193,8 @@ impl ArmorStandAssets {
/// `TrimTextureKey`: the trim texture of `stack` on its humanoid
/// layer (`humanoid` or `humanoid_leggings`), if it has a trim.
fn trim_texture(&self, asset: &str, kind: &str, stack: &ItemStack) -> Option<TrimTexture> {
let (material, pattern) = stack.extra.as_ref()?.trim.as_ref()?;
let (material, pattern) =
horizon_client::item_data::trim::named(stack.extra.as_ref()?.trim.as_ref()?)?;
let overrides = self
.trim_overrides
.get(asset)
@@ -857,6 +858,13 @@ impl Game<'_> {
let mut dead = Vec::new();
// ServerLevel.tick: only entities in entity-ticking chunks tick.
let ticking = self.entity_ticking_area();
// EntityGetter.getEntityCollisions: the boxes that stop a stand's move
// (a living shulker, a boat, a happy ghast held still), gathered when
// the first stand travels. A server's stand rides nothing (`vehicle`
// is set on a server's client only), but mobs can ride it (`/ride`):
// `isPassengerOfSameVehicle` leaves those riders out.
let mut obstacles: Option<horizon_client::entities::ObstacleGrid> = None;
let mut nearby = Vec::new();
for i in 0..self.armor_stands.len() {
let at = self.armor_stands[i].position;
if !ticking(at.x.floor() as i32, at.z.floor() as i32) {
@@ -865,6 +873,11 @@ impl Game<'_> {
continue;
}
self.armor_stand_location_effects(i, false);
// ArmorStand.travel: a marker or a NoGravity stand does not move.
let travels = !self.armor_stands[i].marker && !self.armor_stands[i].no_gravity;
if travels && obstacles.is_none() {
obstacles = Some(self.stand_entity_collisions());
}
let a = &mut self.armor_stands[i];
a.previous = a.position;
a.previous_yaw = a.yaw;
@@ -872,11 +885,36 @@ impl Game<'_> {
a.first_tick = false;
// aiStep's freezing step (removeFrost) makes the speed instance.
a.speed_instance = true;
// LivingEntity.aiStep -> EntityEquipment.tick, before movement.
// Inspect in slot order, then mutate only each actual owned compass.
let clear = self.armor_stands[i].equipment.each_ref().map(|stack| {
stack.as_ref().is_some_and(|stack| {
stack.item.as_ref() == "minecraft:compass"
&& super::maps::lodestone_target_gone(self, stack, self.dim.id())
})
});
for (stack, clear) in self.armor_stands[i].equipment.iter_mut().zip(clear) {
if clear && let Some(stack) = stack {
stack.data_mut().set_other(
"minecraft:lodestone_tracker",
Some(horizon_world::nbt::Tag::Compound(Default::default())),
);
}
}
let a = &mut self.armor_stands[i];
let (water_height, lava_height) =
a.push_by_fluid(&self.world, &self.res.motion().fluids);
let in_water = water_height > 0.0;
let medium =
horizon_client::entities::StandMedium::from_heights(water_height, lava_height);
// Entity.collide asks for the entities meeting the box stretched
// by this tick's movement (the fluid push is already in it).
nearby.clear();
if let Some(grid) = obstacles.as_mut().filter(|_| travels) {
let area = a.bounds().expand_towards(a.velocity);
let area = area.inflate(Vec3::new(1.0e-7, 1.0e-7, 1.0e-7));
grid.near_except(area, Some(a.id), &mut nearby);
}
let (world, registry, facts, collision) = (
&self.world,
self.pack.assets.registry,
@@ -895,6 +933,7 @@ impl Game<'_> {
&self.res.motion().climbing,
in_water,
Some(self.border.geometry()),
&nearby,
) {
// LivingEntity.aiStep: `travel` first, then the blocks' push.
a.tick_settle(medium, step);
@@ -953,6 +992,60 @@ impl Game<'_> {
}
Ok(())
}
/// The armor stands' share of `Shulker.onPeekAmountChange`: the opening
/// lid of mob `shulker` moves every stand with physics (vanilla's
/// `!noPhysics`: not a marker, not `NoGravity`) whose box meets `area` by
/// `delta`, through the stand's `shove`. The stand the shulker
/// rides is left out (`isPassengerOfSameVehicle`). Stands tick before mobs
/// here, while vanilla ticks a stand that joined after the shulker (one
/// placed on it) after the shulker: `shove` makes up for the order.
pub(super) fn lid_pushes_armor_stands(&mut self, shulker: u32, area: Aabb, delta: [f64; 3]) {
if self.armor_stands.is_empty() {
return;
}
let delta = Vec3::new(delta[0], delta[1], delta[2]);
let mut obstacles: Option<horizon_client::entities::ObstacleGrid> = None;
// (the stand the shulker rides, when it joined), worked out at the first stand it meets.
let mut shell: Option<(Option<u32>, horizon_client::join_seq::JoinSeq)> = None;
let mut nearby = Vec::new();
for i in 0..self.armor_stands.len() {
let a = &self.armor_stands[i];
if a.marker || a.no_gravity || a.health <= 0.0 || !a.bounds().intersects(area) {
continue;
}
let (ridden, joined) = *shell.get_or_insert_with(|| {
let j = self.mobs.iter().position(|m| m.id == shulker);
(
j.and_then(|j| self.mob_root_vehicle(j)),
j.map_or(horizon_client::join_seq::JoinSeq::UNJOINED, |j| {
self.mobs[j].join
}),
)
});
if Some(a.id) == ridden {
continue;
}
let grid = obstacles.get_or_insert_with(|| self.stand_entity_collisions());
let a = &mut self.armor_stands[i];
// Entity.collide: the entities meeting the box stretched by the push.
let reach = a.bounds().expand_towards(delta);
let reach = reach.inflate(Vec3::new(1.0e-7, 1.0e-7, 1.0e-7));
grid.near_except(reach, Some(a.id), &mut nearby);
let scene = horizon_client::collisions::SceneCollisions {
world: &self.world,
binding: self.collision,
subject: None,
};
let travels_after = a.join > joined;
a.shove(
scene,
delta,
Some(self.border.geometry()),
&nearby,
travels_after,
);
}
}
/// `Ravager.roar` with `mob_griefing` on: every armor stand in `area` (the
/// ravager's box grown by 4) takes `strongKnockback` from the ravager at
/// `from`. Its `mobAttack` leaves the stand whole (`ArmorStand.hurtServer`:
@@ -1065,6 +1158,10 @@ impl Game<'_> {
pub(super) fn break_armor_stand_by_anything(&mut self, i: usize) {
let a = self.armor_stands.remove(i);
let at = [a.position.x, a.position.y, a.position.z];
// brokenByAnything's dropAllDeathLoot, before the equipment.
if !a.small && self.ui.rules.bool("mob_drops") {
self.armor_stand_death_loot(at, a.fire_ticks > 0);
}
let (pos, drops) = broken_armor_stand_equipment(a);
for stack in drops {
self.pop_resource(pos, stack);
@@ -1074,6 +1171,115 @@ impl Game<'_> {
self.entity_killed_event(at, false);
self.world.modified.insert((pos[0] >> 4, pos[2] >> 4));
}
/// `LivingEntity.dropFromLootTable` for a stand that is not small, with
/// `mob_drops` on (`shouldDropLoot`): its type's table
/// `minecraft:entities/armor_stand` with seed 0 (`getLootTableSeed`), so
/// the table's random sequence or else the level's random, each stack
/// spawned at the stand's position. Its view is only the type and whether
/// it burns; no damage source, killer or player is known here.
fn armor_stand_death_loot(&mut self, at: [f64; 3], on_fire: bool) {
const TABLE: &str = "minecraft:entities/armor_stand";
let Some(loot) = horizon_client::structure_blocks::loot_data(
&self.options.resources,
&self.pack_files(),
) else {
return;
};
if !loot.has_table(TABLE) {
return;
}
let params = horizon_world::loot::EntityLootParams {
this: horizon_world::loot::LootEntity {
on_fire,
baby: false,
..horizon_world::loot::LootEntity::new("minecraft:armor_stand")
},
..horizon_world::loot::EntityLootParams::default()
};
let stacks = match loot.random_sequence(TABLE) {
Some(id) => {
let random = self.rand_sequences.get(id, self.level.seed);
loot.roll_entity(TABLE, random, &params)
}
None => loot.roll_entity(TABLE, &mut self.world.random, &params),
};
for stack in stacks.iter().filter(|s| !s.is_empty()) {
self.spawn_item_own(super::creatures::from_loot(stack), at, 10);
}
}
/// `ArmorStand.hurtServer` for a command's damage of `damage_type` from the
/// causing entity `causing`, up to its sources that act and still return
/// false: a blast breaks the stand (`brokenByAnything`, `kill`), fire that
/// ignites stands lights it (or burns a burning one 0.15), and burning
/// takes 4 health. Every other source is refused here, so this is always
/// false and `/damage` fails as vanilla's does.
pub(super) fn command_damage_stand(
&mut self,
id: u32,
damage_type: &str,
causing: Option<super::commands::Target>,
) -> bool {
use super::commands::Target;
let Some(i) = self.armor_stands.iter().position(|a| a.id == id) else {
return false;
};
let registry = &self.motion.health.damage;
let Some(kind) = registry.id(damage_type) else {
return false;
};
let tagged = |tag: &str| registry.in_tag(kind, tag);
if !self.ui.rules.bool("mob_griefing") && matches!(causing, Some(Target::Mob(_))) {
return false;
}
if tagged("minecraft:bypasses_invulnerability") {
self.kill_target(Target::Stand(id));
return false;
}
// Entity.isInvulnerableToBase: the flag, unless a creative player hits.
let creative = match causing {
Some(Target::Player) => self.creative,
Some(Target::Guest(conn)) => self.command_guest(conn).is_some_and(|g| g.creative()),
_ => false,
};
let invulnerable = self
.persist
.raw
.get(&id)
.is_some_and(|tag| horizon_client::entity_nbt::flag(tag, "Invulnerable"));
let a = &self.armor_stands[i];
if (invulnerable && !creative) || a.invisible || a.marker {
return false;
}
let (explosion, ignites, burns) = (
tagged("minecraft:is_explosion"),
tagged("minecraft:ignites_armor_stands"),
tagged("minecraft:burns_armor_stands"),
);
if explosion {
self.break_armor_stand_by_anything(i);
} else if ignites {
if self.armor_stands[i].fire_ticks > 0 {
self.cause_stand_damage(i, 0.15);
} else {
// igniteForSeconds(5.0f): at least 100 ticks.
let a = &mut self.armor_stands[i];
a.fire_ticks = a.fire_ticks.max(100);
}
} else if burns && self.armor_stands[i].health > 0.5 {
self.cause_stand_damage(i, 4.0);
}
false
}
/// `ArmorStand.causeDamage`: the health less `amount`, broken by anything
/// at 0.5 or less.
fn cause_stand_damage(&mut self, i: usize, amount: f32) {
let health = self.armor_stands[i].health - amount;
if health <= 0.5 {
self.break_armor_stand_by_anything(i);
} else {
self.armor_stands[i].health = health;
}
}
/// ArmorStandRenderer: the stand with its default pose, then armor layers.
pub(super) fn armor_stand_geometry(
&self,
@@ -2117,3 +2323,340 @@ mod pumpkin_equippable_use_tests {
);
}
}
#[cfg(test)]
mod stand_lodestone_inventory_tick_tests {
use horizon_client::motion::PlayerMotion;
use horizon_core::{geometry::Vec3, position::BlockPos};
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES and scratch homes/cwd"]
fn actual_stand_equipment_compasses_tick_after_loaded_lodestone_removal() {
let mut game = crate::game::native_machines::tests::server_game();
game.build_registries();
game.set_allow_commands(true);
game.level.health = 20.0;
game.player = Some(PlayerMotion::new(game.motion, Vec3::new(14.5, 200.0, 14.5)).unwrap());
game.block_ticks
.add_empty_chunk(BlockPos::new(0, 0, 0).chunk())
.unwrap();
assert!(game.run_command("forceload add 0 0"));
assert!(game.run_command("fill 0 199 0 15 199 15 stone"));
assert!(game.run_command("setblock 12 200 12 lodestone"));
assert!(game.world.loaded(12, 12));
let mut ids = Vec::new();
for (n, dim, tracked, target) in [
(1, "minecraft:overworld", true, true),
(2, "minecraft:overworld", false, true),
(3, "minecraft:the_nether", true, true),
(4, "minecraft:overworld", true, false),
] {
let who = format!("0000a551-0000-0000-0000-{n:012x}");
let x = f64::from(n * 2) + 0.5;
assert!(game.run_command(&format!("summon armor_stand {x} 200.0 4.5 {{UUID:[I;42321,0,0,{n}],Health:20f,NoGravity:1b}}")));
let id = game
.armor_stands
.iter()
.find(|a| game.persist.uuid_of(a.id) == Some([42321, 0, 0, n]))
.unwrap()
.id;
let tracker = format!(
"{}tracked:{tracked}",
if target {
format!("target:{{dimension:\"{dim}\",pos:[I;12,200,12]}},")
} else {
String::new()
}
);
for hand in ["mainhand", "offhand"] {
assert!(game.run_command(&format!("item replace entity {who} weapon.{hand} with compass[minecraft:lodestone_tracker={{{tracker}}},minecraft:custom_data={{marker:{n}}}] 1")));
}
let a = game.armor_stands.iter().find(|a| a.id == id).unwrap();
assert_eq!(a.position, Vec3::new(x, 200.0, 4.5));
assert!(a.no_gravity);
assert!(a.first_tick, "fresh actual stand has not ticked yet");
assert_eq!(a.health, 20.0);
for stack in a.equipment[..2].iter().flatten() {
assert_eq!(&*stack.item, "minecraft:compass");
assert_eq!(stack.count, 1);
assert_eq!(
stack
.data()
.unwrap()
.other("minecraft:lodestone_tracker")
.unwrap()
.get("target")
.is_some(),
target
);
}
assert!(game.persist.raw.remove(&id).is_some());
ids.push((id, n, target));
}
for hand in ["mainhand", "offhand"] {
assert!(game.run_command(&format!("item replace entity @s weapon.{hand} with compass[minecraft:lodestone_tracker={{target:{{dimension:\"minecraft:overworld\",pos:[I;12,200,12]}},tracked:true}}] 1")));
}
assert!(game.run_command("setblock 12 200 12 air"));
assert_eq!(
&*game.res.facts().get(game.world.block(12, 200, 12)).name,
"minecraft:air"
);
game.world.game_time += 1;
game.tick_lodestone_compasses();
for slot in [0, crate::game::shield::OFFHAND_SLOT] {
assert!(
game.stack_in(slot)
.unwrap()
.data()
.unwrap()
.other("minecraft:lodestone_tracker")
.unwrap()
.get("target")
.is_none(),
"actual player tick positive control"
);
}
assert!(
game.entity_ticking_area()(2, 4),
"actual entity-ticking chunk admission"
);
game.tick_armor_stands().unwrap();
let mut mismatches = Vec::new();
for (id, n, target) in ids {
let a = game.armor_stands.iter().find(|a| a.id == id).unwrap();
assert_eq!(a.position, Vec3::new(f64::from(n * 2) + 0.5, 200.0, 4.5));
assert_eq!(a.health, 20.0);
assert!(a.no_gravity);
for (slot, stack) in a.equipment[..2].iter().enumerate() {
let stack = stack.as_ref().unwrap();
assert_eq!(stack.count, 1);
let data = stack.data().unwrap();
assert_eq!(
data.other("minecraft:custom_data").unwrap().get("marker"),
Some(&horizon_world::nbt::Tag::Int(n))
);
let present = data
.other("minecraft:lodestone_tracker")
.unwrap()
.get("target")
.is_some();
if present != (target && n != 1) {
mismatches.push((n, slot, present));
}
}
let saved = horizon_client::entity_nbt::armor_stand_to_nbt(a, [42321, 0, 0, n], None);
assert!(
saved.get("equipment").is_some(),
"actual no-raw model writer"
);
for slot in 0..2 {
let key = horizon_client::entities::ArmorStand::SLOTS[slot];
let saved_target = saved
.get("equipment")
.unwrap()
.get(key)
.unwrap()
.get("components")
.unwrap()
.get("minecraft:lodestone_tracker")
.unwrap()
.get("target")
.is_some();
let modeled_target = a.equipment[slot]
.as_ref()
.unwrap()
.data()
.unwrap()
.other("minecraft:lodestone_tracker")
.unwrap()
.get("target")
.is_some();
assert_eq!(
saved_target, modeled_target,
"no-raw writer preserves actual tracker state"
);
}
}
assert!(
mismatches.is_empty(),
"actual stand equipment inventory tick mismatches: {mismatches:?}"
);
}
}
#[cfg(test)]
mod postbaseline_lodestone_controls {
//! Added after baseline: source-backed slots and preservation, not new runtime breadth.
use super::Game;
use horizon_client::{chunk::Chunk, entities::ArmorStand, items::ItemStack};
use horizon_core::position::BlockPos;
use std::sync::Arc;
fn stack<'g>(game: &'g Game<'_>, id: u32, slot: usize) -> &'g ItemStack {
game.armor_stands
.iter()
.find(|a| a.id == id)
.unwrap()
.equipment[slot]
.as_ref()
.unwrap()
}
fn has_target(stack: &ItemStack) -> bool {
stack
.data()
.unwrap()
.other("minecraft:lodestone_tracker")
.unwrap()
.get("target")
.is_some()
}
fn item(id: &str, tracker: &str, marker: usize, count: usize) -> String {
format!(
"{{id:\"{id}\",count:{count},components:{{\"minecraft:lodestone_tracker\":{{{tracker}}},\"minecraft:custom_data\":{{marker:{marker}}}}}}}"
)
}
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES and scratch homes/cwd"]
fn postbaseline_six_slots_noncompass_guard_and_dimension_preservation() {
let mut game = crate::game::native_machines::tests::server_game();
game.build_registries();
game.set_allow_commands(true);
game.block_ticks
.add_empty_chunk(BlockPos::new(0, 0, 0).chunk())
.unwrap();
assert!(game.run_command("forceload add 0 0"));
assert!(game.run_command("fill 0 199 0 15 199 15 stone"));
assert!(game.run_command("setblock 12 200 12 lodestone"));
let tracked = "target:{dimension:\"minecraft:overworld\",pos:[I;12,200,12]},tracked:true";
let slots = ArmorStand::SLOTS
.iter()
.enumerate()
.map(|(slot, key)| format!("{key}:{}", item("minecraft:compass", tracked, slot, 1)))
.collect::<Vec<_>>()
.join(",");
assert!(game.run_command(&format!("summon armor_stand 4.5 200.0 4.5 {{UUID:[I;42321,0,0,100],Health:20f,NoGravity:1b,equipment:{{{slots}}}}}")));
let mixed = format!(
"mainhand:{},offhand:{},head:{},feet:{}",
item("minecraft:stone", tracked, 66, 3),
item(
"minecraft:compass",
"target:{dimension:\"minecraft:the_nether\",pos:[I;12,200,12]},tracked:true",
77,
1
),
item(
"minecraft:compass",
"target:{dimension:\"minecraft:overworld\",pos:[I;12,200,12]},tracked:false",
88,
1
),
item("minecraft:compass", "tracked:true", 99, 1)
);
assert!(game.run_command(&format!("summon armor_stand 8.5 200.0 8.5 {{UUID:[I;42321,0,0,101],Health:20f,NoGravity:1b,equipment:{{{mixed}}}}}")));
game.world
.chunks
.insert((4, 4), Arc::new(Chunk::empty(4, 4)));
game.block_ticks
.add_empty_chunk(BlockPos::new(64, 0, 64).chunk())
.unwrap();
assert!(game.run_command("fill 64 199 64 79 199 79 stone"));
assert!(game.run_command(&format!("summon armor_stand 65.5 200.0 65.5 {{UUID:[I;42321,0,0,102],Health:20f,NoGravity:1b,equipment:{{mainhand:{}}}}}",item("minecraft:compass",tracked,100,1))));
let id = |n| {
game.armor_stands
.iter()
.find(|a| game.persist.uuid_of(a.id) == Some([42321, 0, 0, n]))
.unwrap()
.id
};
let (all, mixed, inactive) = (id(100), id(101), id(102));
// Direct class ticks do not refresh the headless fixture's tickets.
// Use the existing ticket model: only chunk zero is forced to tick.
game.tickets.update(Some(crate::ticket_levels::Sources {
forced: vec![(0, 0)],
..Default::default()
}));
assert!(game.entity_ticking_area()(4, 4));
assert!(!game.entity_ticking_area()(65, 65));
assert!(game.world.loaded(65, 65));
let before: Vec<_> = (0..6)
.map(|slot| {
game.armor_stands
.iter()
.find(|a| a.id == mixed)
.unwrap()
.equipment[slot]
.as_ref()
.map(horizon_items::item_nbt::item_to_nbt)
})
.collect();
assert!(has_target(stack(&game, mixed, 0)));
assert!(has_target(stack(&game, mixed, 1)));
assert!(has_target(stack(&game, mixed, 5)));
assert!(!has_target(stack(&game, mixed, 2)));
assert!(has_target(stack(&game, inactive, 0)));
for slot in 0..6 {
assert!(has_target(stack(&game, all, slot)));
}
let dimension = game.dim;
let chunks: std::collections::HashSet<_> = game.world.chunks.keys().copied().collect();
assert!(game.run_command("setblock 12 200 12 air"));
game.world.game_time += 1;
game.tick_armor_stands().unwrap();
assert!(
!game
.armor_stands
.iter()
.find(|a| a.id == all)
.unwrap()
.first_tick,
"active stand really ticked"
);
assert!(
game.armor_stands
.iter()
.find(|a| a.id == inactive)
.unwrap()
.first_tick,
"non-ticking guard unchanged"
);
for slot in 0..6 {
let s = stack(&game, all, slot);
assert!(!has_target(s), "source-backed equipment slot {slot}");
assert_eq!(s.count, 1);
assert_eq!(
s.data()
.unwrap()
.other("minecraft:custom_data")
.unwrap()
.get("marker"),
Some(&horizon_world::nbt::Tag::Int(slot as i32))
);
}
let after: Vec<_> = (0..6)
.map(|slot| {
game.armor_stands
.iter()
.find(|a| a.id == mixed)
.unwrap()
.equipment[slot]
.as_ref()
.map(horizon_items::item_nbt::item_to_nbt)
})
.collect();
assert_eq!(
before, after,
"actual non-compass/untracked/other-dimension/targetless stacks preserved"
);
assert!(has_target(stack(&game, inactive, 0)));
assert_eq!(game.dim, dimension);
assert_eq!(
game.world
.chunks
.keys()
.copied()
.collect::<std::collections::HashSet<_>>(),
chunks
);
}
}
@@ -913,7 +913,7 @@ impl Game<'_> {
return None;
};
let (value, signature) = profile.textures()?;
let url = skins::unpack(&value, signature, &skins::TEXTURE_URIS).skin?;
let url = skins::unpack(&value, signature, skins::texture_uris()).skin?;
let location = skins::skin_location(&skins::texture_hash(&url)?);
Some((url, location))
});
Loaded 100 of 517 files, more files were not shown because too many files have changed in this diff. Show more