Compare commits

...
86 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
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
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
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
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
Evan-lhm 3fa4cbd9a8 Merge remote-tracking branch 'origin/main' into fix/mod-content-below-packs 2026-11-08 13:21:06 +01:00
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
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
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
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
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
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
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
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 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
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
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
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 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 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
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
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
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
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
212 changed files with 10793 additions and 616 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
+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
+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]
@@ -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,
}
}
+28
View File
@@ -660,6 +660,34 @@ mod pack_tests {
}
}
#[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)]
mod color_tests {
use super::*;
+5 -1
View File
@@ -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;
+255 -16
View File
@@ -163,7 +163,8 @@ pub struct Frame {
/// The sky and block light at the camera.
pub sky_light: i32,
pub block_light: i32,
/// The camera's biome's climate (`o.temperature`, `o.downfall`).
/// The camera's biome (`o.biome()`), and its climate (`o.temperature`, `o.downfall`).
pub biome: String,
pub temperature: f32,
pub downfall: f32,
}
@@ -216,6 +217,9 @@ impl horizon_polytone::expr::Host for FrameHost<'_> {
"blockLight" if on(Root::Object) || on(Root::Player) => num(f64::from(f.block_light)),
"temperature" if on(Root::Object) => num(f64::from(f.temperature)),
"downfall" if on(Root::Object) => num(f64::from(f.downfall)),
"biome" if on(Root::Object) || on(Root::Player) || on(Root::Camera) => {
Ok(V::Str(f.biome.as_str().into()))
}
"rand" if on(Root::Random) => {
// A splitmix64 step: the frame's own sequence.
self.seed = self.seed.wrapping_add(0x9e37_79b9_7f4a_7c15);
@@ -265,9 +269,9 @@ fn as_number(value: &Value) -> f64 {
/// A Polytone colormap used as an attribute value (wiki "Colormaps"): an image read at the
/// column and row its two expressions pick, each 0 to 1 with 1 the top-left corner.
struct Colormap {
x: horizon_polytone::expr::Program,
y: horizon_polytone::expr::Program,
pub(crate) struct Colormap {
x: Axis,
y: Axis,
/// Only the lower-left triangle: the row value is scaled by the column value, as vanilla's
/// grass colormap scales downfall by temperature.
triangular: bool,
@@ -278,11 +282,72 @@ struct Colormap {
rgba: Vec<u8>,
}
/// A colormap axis: an expression (or a named sampler, wiki "Default Samplers"), or the
/// `biome_id` sampler with its mapper.
enum Axis {
Expr(horizon_polytone::expr::Program),
/// `biome_id`: the value the mapper gives the biome where it samples (0 for the others),
/// and the expression naming that biome. Unlike the other axes it counts from the left (or
/// top) edge, in pixel coordinates over the mapper's `texture_size`: the column (or row) is
/// `floor(value * size)`, so `1` over a `texture_size` of 16 is the second pixel, and the
/// biomes left out are the first, as the wiki's "start your mapper at 1" advice expects.
BiomeId(HashMap<String, f64>, horizon_polytone::expr::Program),
}
impl Axis {
fn value(&self, host: &mut dyn horizon_polytone::expr::Host) -> Option<f64> {
match self {
Self::Expr(program) => Some(program.eval(host).ok()?.num()),
Self::BiomeId(mapper, biome) => {
let biome = match biome.eval(host).ok()? {
horizon_polytone::expr::Value::Str(s) => s,
_ => return Some(0.0),
};
Some(mapper.get(biome.as_ref()).copied().unwrap_or(0.0))
}
}
}
}
/// A `biome_id_mapper`: inline, or an id of `polytone/biome_id_mappers/<path>.json`. Its values
/// are divided by its `texture_size`, when it has one (pixel coordinates).
fn biome_id_mapper(pack_root: &Path, json: &Json) -> HashMap<String, f64> {
let object = match json {
Json::String(id) => {
let (namespace, path) = id.split_once(':').unwrap_or(("minecraft", id));
let file = pack_root.join(format!(
"assets/{namespace}/polytone/biome_id_mappers/{path}.json"
));
horizon_world::json_file::read::<Json>(&file).unwrap_or(Json::Null)
}
other => other.clone(),
};
let Some(object) = object.as_object() else {
return HashMap::new();
};
let size = object
.get("texture_size")
.and_then(|v| v.as_f64().or_else(|| v.as_str()?.parse().ok()))
.filter(|s| *s > 0.0)
.unwrap_or(1.0);
object
.iter()
.filter(|(k, _)| *k != "texture_size")
.filter_map(|(k, v)| {
let id = if k.contains(':') {
k.clone()
} else {
format!("minecraft:{k}")
};
Some((id, v.as_f64()? / size))
})
.collect()
}
impl Colormap {
/// `assets/<namespace>/polytone/colormaps/<path>.png` and its optional `.json` (without
/// one, the axes are the biome's temperature and downfall). The `biome_id` axis isn't
/// supported yet.
fn load(pack_root: &Path, id: &str) -> Option<Self> {
/// one, the axes are the biome's temperature and downfall).
pub(crate) fn load(pack_root: &Path, id: &str) -> Option<Self> {
let (namespace, path) = id.split_once(':')?;
let base = pack_root.join(format!("assets/{namespace}/polytone/colormaps/{path}"));
let png = base.with_extension("png");
@@ -295,13 +360,26 @@ impl Colormap {
};
let json: Json =
horizon_world::json_file::read(&base.with_extension("json")).unwrap_or(Json::Null);
let axis = |key: &str, default: &str| -> Option<horizon_polytone::expr::Program> {
let compile = horizon_polytone::expr::Program::compile;
let axis = |key: &str, default: &str| -> Option<Axis> {
let src = match &json[key] {
Json::String(s) => s.clone(),
Json::Number(n) => n.to_string(),
_ => default.to_owned(),
};
match horizon_polytone::expr::Program::compile(&src) {
// The named samplers (wiki "Default Samplers") as the expressions they stand for.
let src = match src.trim() {
"biome_id" => {
let mapper = biome_id_mapper(pack_root, &json["biome_id_mapper"]);
return Some(Axis::BiomeId(mapper, compile("o.biome()").ok()?));
}
"temperature" | "legacy_temperature" => "o.temperature".to_owned(),
"downfall" => "o.downfall".to_owned(),
"y_level" => "o.y / 128".to_owned(),
"day_time" => "1 - (g.dayTime % 24000) / 24000".to_owned(),
_ => src,
};
match compile(&src).map(Axis::Expr) {
Ok(program) => Some(program),
Err(e) => {
log::warn!("colormap {id} {key}: {e}");
@@ -322,24 +400,145 @@ impl Colormap {
/// The colour the axes pick, ARGB; `None` for a transparent pixel (the value stays what
/// it was, the default `fallback_colormap: original`) or an axis that fails.
fn sample(&self, host: &mut dyn horizon_polytone::expr::Host) -> Option<u32> {
let x = self.x.eval(host).ok()?.num().clamp(0.0, 1.0);
let mut y = self.y.eval(host).ok()?.num().clamp(0.0, 1.0);
pub(crate) fn sample(&self, host: &mut dyn horizon_polytone::expr::Host) -> Option<u32> {
let x = self.x.value(host)?.clamp(0.0, 1.0);
let mut y = self.y.value(host)?.clamp(0.0, 1.0);
if self.triangular {
y *= x;
}
let pixel = |v: f64, size: u32| {
let p = (1.0 - v) * f64::from(size.saturating_sub(1));
let pixel = |axis: &Axis, v: f64, size: u32| {
let last = size.saturating_sub(1);
if let Axis::BiomeId(..) = axis {
return ((v * f64::from(size)).floor() as u32).min(last);
}
let p = (1.0 - v) * f64::from(last);
let p = if self.rounds { p.round() } else { p.floor() };
(p as u32).min(size.saturating_sub(1))
(p as u32).min(last)
};
let (px, py) = (pixel(x, self.width), pixel(y, self.height));
let (px, py) = (
pixel(&self.x, x, self.width),
pixel(&self.y, y, self.height),
);
let i = (py as usize * self.width as usize + px as usize) * 4;
let [r, g, b, a] = self.rgba.get(i..i + 4)?.try_into().ok()?;
(a != 0).then(|| u32::from(a) << 24 | u32::from(r) << 16 | u32::from(g) << 8 | u32::from(b))
}
}
/// The colormaps of the Polytone block and fluid modifiers of the packs merged at `pack_root`:
/// (a fluid modifier, its targets, the colormap's id). Inline colormaps aren't read.
pub(crate) fn polytone_colormap_modifiers(
pack_root: &Path,
) -> Vec<(bool, horizon_polytone::Targets, String)> {
let mut out = Vec::new();
let namespaces = std::fs::read_dir(pack_root.join("assets"))
.into_iter()
.flatten()
.flatten();
for namespace in namespaces {
for (kind, fluid) in [("block_modifiers", false), ("fluid_modifiers", true)] {
let mut stack = vec![namespace.path().join("polytone").join(kind)];
while let Some(dir) = stack.pop() {
for entry in std::fs::read_dir(&dir).into_iter().flatten().flatten() {
let path = entry.path();
if path.is_dir() {
stack.push(path);
continue;
}
let Ok(json) = horizon_world::json_file::read::<Json>(&path) else {
continue;
};
let Some(id) = json.get("colormap").and_then(Json::as_str) else {
continue;
};
let id = if id.contains(':') {
id.to_owned()
} else {
format!("minecraft:{id}")
};
let relative = path
.strip_prefix(pack_root)
.unwrap_or(&path)
.to_string_lossy();
let implicit = horizon_polytone::implicit_id(&relative, kind);
match horizon_polytone::Targets::parse(json.get("targets"), implicit.as_deref())
{
Ok(targets) => out.push((fluid, targets, id)),
Err(e) => log::warn!("{relative}: {e}"),
}
}
}
}
}
out
}
/// What a block colormap's axes read where it samples: the block (`o.x`, `o.biome()`, its
/// biome's climate) and `r.noise`. The world isn't reachable from the mesher, so the rest fails
/// and the block keeps its own tint.
pub(crate) struct BlockHost<'a> {
at: [i32; 3],
biome: &'a crate::biome::BiomeInfo,
}
impl<'a> BlockHost<'a> {
pub(crate) fn new(at: [i32; 3], biome: &'a crate::biome::BiomeInfo) -> Self {
Self { at, biome }
}
}
/// The simplex noise `r.noise(x, y)` reads, the same for blocks and particles.
pub(crate) fn polytone_noise() -> &'static horizon_core::noise::SimplexNoise {
static NOISE: std::sync::OnceLock<horizon_core::noise::SimplexNoise> =
std::sync::OnceLock::new();
NOISE.get_or_init(|| {
horizon_core::noise::SimplexNoise::new(&mut horizon_core::random::LegacyRandom::new(0))
})
}
impl horizon_polytone::expr::Host for BlockHost<'_> {
fn root(&mut self, root: horizon_polytone::expr::Root) -> horizon_polytone::expr::Value {
use horizon_polytone::expr::Root;
horizon_polytone::expr::Value::Object(match root {
Root::Object => 1,
Root::Random => 2,
_ => 0,
})
}
fn method(
&mut self,
target: &horizon_polytone::expr::Value,
name: &str,
args: &[horizon_polytone::expr::Value],
) -> std::result::Result<horizon_polytone::expr::Value, String> {
use horizon_polytone::expr::Value as V;
let num = |n: f64| Ok(V::Num(n));
match (target, name) {
(V::Object(1), "x" | "y" | "z") => {
num(f64::from(self.at[usize::from(name.as_bytes()[0] - b'x')]))
}
(V::Object(1), "biome") => Ok(V::Str(self.biome.name.as_str().into())),
(V::Object(1), "temperature") => num(f64::from(self.biome.temperature)),
(V::Object(1), "downfall") => num(f64::from(self.biome.downfall)),
(V::Object(2), "noise") => {
let mut coords = args.iter().filter(|v| !matches!(v, V::Str(_))).map(V::num);
let (x, y) = (coords.next().unwrap_or(0.0), coords.next().unwrap_or(0.0));
num(f64::from(polytone_noise().get2(x, y)))
}
_ => Err(format!("{name} is not available to block colormaps")),
}
}
fn function(
&mut self,
name: &str,
_: &[horizon_polytone::expr::Value],
) -> std::result::Result<horizon_polytone::expr::Value, String> {
Err(format!("{name} is not available to block colormaps"))
}
}
/// Turns Polytone layers into vanilla entries, compiling expressions against the frame.
struct Resolver {
frame: Arc<RwLock<Frame>>,
@@ -1029,4 +1228,44 @@ mod tests {
// A transparent pixel keeps the colour the layers before gave (the dimension's #808080).
assert_eq!(sky(0.7), [128.0 / 255.0; 3]);
}
#[test]
fn biome_id_colormaps_read_the_column_their_mapper_gives_the_biome() {
// Left to right: red, green, transparent, blue.
let png = png_row(&[
[255, 0, 0, 255],
[0, 255, 0, 255],
[9, 9, 9, 0],
[0, 0, 255, 255],
]);
let env = with_pack_files(&[
(
"assets/minecraft/polytone/dimension_modifiers/overworld.json",
br#"{"attributes_modifiers": {"visual/sky_color": "mypack:sky"}}"#,
),
(
"assets/mypack/polytone/colormaps/sky.json",
br#"{"x_axis": "biome_id", "y_axis": 0, "biome_id_mapper": "mypack:biomes"}"#,
),
("assets/mypack/polytone/colormaps/sky.png", &png),
(
// Pixel columns from the left, over a texture_size of the image's width.
"assets/mypack/polytone/biome_id_mappers/biomes.json",
br#"{"minecraft:desert": 3, "swamp": 1, "minecraft:plains": 2, "texture_size": 4}"#,
),
]);
let sky = |biome: &str| {
env.set_frame(Frame {
biome: biome.to_owned(),
..Frame::default()
});
env.sample(&Sample::default()).sky_color
};
assert_eq!(sky("minecraft:desert"), [0.0, 0.0, 1.0]);
assert_eq!(sky("minecraft:swamp"), [0.0, 1.0, 0.0]);
// A biome the mapper doesn't name is 0: the first column.
assert_eq!(sky("minecraft:forest"), [1.0, 0.0, 0.0]);
// Plains land on the transparent pixel: the colour before stays.
assert_eq!(sky("minecraft:plains"), [128.0 / 255.0; 3]);
}
}
+221 -53
View File
@@ -185,6 +185,7 @@ pub mod compositor;
pub(crate) mod core_lines;
pub(crate) mod core_sky;
mod cull;
mod dx12_compiler;
mod end_sky;
pub mod entity_skin;
mod flat;
@@ -241,6 +242,7 @@ mod visbuffer;
mod wgsl_prefix;
mod world_post;
pub mod world_primitives;
mod world_warp;
/// A section's instance: its camera-relative origin and, in `w`, the step of
/// its fixed-point UVs (`terrain.wgsl`'s `terrain_uv`).
@@ -1206,6 +1208,8 @@ pub struct Renderer {
solid_cutout: SolidPipeline,
translucent: wgpu::RenderPipeline,
translucent_mark: wgpu::RenderPipeline,
/// A native mod's world warp (`world_warp`): nothing made until a mod gives one.
world_warps: world_warp::WorldWarps,
/// Flat section draws (`flat`), where multi-draw is emulated.
flat: Option<flat::Flat>,
/// Sections upload their solid quads in the compact layout (`mesher::packed`): the solid
@@ -1956,11 +1960,14 @@ fn mip_chain(
/// `allow_nonconformant` offers the adapters wgpu hides for reporting no Vulkan
/// conformance version: Mesa's hasvk, which drives Ivy Bridge and Haswell,
/// reports 0.0.0.0 while it works. `open_device` asks for them only on a second
/// try at a backend that had no adapter at all.
/// try at a backend that had no adapter at all. On DirectX 12, `dx12_compiler`
/// compiles the shaders (`gfx/dx12_compiler.rs`), unless `WGPU_DX12_COMPILER`
/// names another.
fn instance_descriptor(
backends: wgpu::Backends,
window: Option<&Arc<Window>>,
allow_nonconformant: bool,
dx12_compiler: wgpu::Dx12Compiler,
) -> wgpu::InstanceDescriptor {
let mut descriptor = wgpu::InstanceDescriptor::new_without_display_handle();
if let Some(window) = window {
@@ -1980,6 +1987,7 @@ fn instance_descriptor(
.flags
.remove(wgpu::InstanceFlags::VALIDATION_INDIRECT_CALL);
}
descriptor.backend_options.dx12.shader_compiler = dx12_compiler;
let mut descriptor = descriptor.with_env();
if allow_nonconformant {
descriptor
@@ -2378,30 +2386,19 @@ async fn open_backend(
allow_cpu: bool,
) -> std::result::Result<Opened, NoDevice> {
let name = backends_name(backends);
let instance = wgpu::Instance::new(instance_descriptor(
backends,
Some(window),
allow_nonconformant,
));
let surface = if offscreen {
None
} else {
match instance.create_surface(window.clone()) {
Ok(surface) => Some(surface),
Err(e) => return Err(NoDevice::Failed(format!("{name} surface: {e}"))),
}
let open = |dx12_compiler| {
wgpu::Instance::new(instance_descriptor(
backends,
Some(window),
allow_nonconformant,
dx12_compiler,
))
};
let adapter = match instance
.request_adapter(&wgpu::RequestAdapterOptions {
power_preference: wgpu::PowerPreference::HighPerformance,
compatible_surface: surface.as_ref(),
force_fallback_adapter: false,
..Default::default()
})
.await
{
Ok(adapter) => adapter,
Err(e) => return Err(NoDevice::NoAdapter(format!("{name}: {e}"))),
let instance = open(wgpu::Dx12Compiler::Fxc);
let (surface, adapter) = match surface_and_adapter(&instance, window, offscreen).await {
Ok(found) => found,
Err(NoDevice::Failed(e)) => return Err(NoDevice::Failed(format!("{name} surface: {e}"))),
Err(NoDevice::NoAdapter(e)) => return Err(NoDevice::NoAdapter(format!("{name}: {e}"))),
};
// NoAdapter, so `open_with_retry` asks again: with the non-conformant
// adapters shown, wgpu ranks a real GPU (hasvk on Ivy Bridge or Haswell)
@@ -2412,6 +2409,27 @@ async fn open_backend(
adapter.get_info().name
)));
}
// An integrated GPU on DirectX 12 compiles with DXC: the same request
// again, on an instance that loads the DLL beside the game. Anything that
// goes wrong keeps the FXC instance.
let forced = dx12_compiler::forced();
let dxc = if dx12_compiler::wants_dxc(&adapter.get_info(), forced) {
open_dxc(&adapter.get_info(), window, offscreen, open).await
} else {
None
};
let dxc_found = dxc.is_some();
let (instance, surface, adapter) = dxc.unwrap_or((instance, surface, adapter));
if adapter.get_info().backend == wgpu::Backend::Dx12 {
crate::crash::set_detail(
"DirectX 12 shader compiler",
match (forced, dxc_found) {
(true, _) => "WGPU_DX12_COMPILER",
(false, true) => "DXC",
(false, false) => "FXC",
},
);
}
describe_adapter(&adapter);
let info = adapter.get_info();
let limits = adapter.limits();
@@ -2499,6 +2517,73 @@ async fn open_backend(
Err(e) => Err(NoDevice::Failed(format!("{name} device: {e}"))),
}
}
/// A surface for `window` (none `offscreen`) and the adapter `open_backend`
/// asks `instance` for. `Failed` is the surface's error, `NoAdapter` the
/// request's.
async fn surface_and_adapter(
instance: &wgpu::Instance,
window: &Arc<Window>,
offscreen: bool,
) -> std::result::Result<(Option<wgpu::Surface<'static>>, wgpu::Adapter), NoDevice> {
let surface = if offscreen {
None
} else {
Some(
instance
.create_surface(window.clone())
.map_err(|e| NoDevice::Failed(e.to_string()))?,
)
};
let adapter = instance
.request_adapter(&wgpu::RequestAdapterOptions {
power_preference: wgpu::PowerPreference::HighPerformance,
compatible_surface: surface.as_ref(),
force_fallback_adapter: false,
..Default::default()
})
.await
.map_err(|e| NoDevice::NoAdapter(e.to_string()))?;
Ok((surface, adapter))
}
/// The DirectX 12 instance, surface and adapter with DXC for the integrated
/// GPU `fxc` (`gfx/dx12_compiler.rs`), `open` making the instance; `None`, with
/// one line in the log, when the DLL is missing, doesn't load, or the instance
/// offers another GPU: the FXC instance stays.
async fn open_dxc(
fxc: &wgpu::AdapterInfo,
window: &Arc<Window>,
offscreen: bool,
open: impl FnOnce(wgpu::Dx12Compiler) -> wgpu::Instance,
) -> Option<(
wgpu::Instance,
Option<wgpu::Surface<'static>>,
wgpu::Adapter,
)> {
let Some(dll) = dx12_compiler::bundled_dll() else {
log::info!(
"Horizon renderer: no {} beside the game; DirectX 12 compiles shaders with FXC",
dx12_compiler::DLL
);
return None;
};
let instance = open(dx12_compiler::dxc(&dll));
let why = match surface_and_adapter(&instance, window, offscreen).await {
Ok((surface, adapter)) if dx12_compiler::same_adapter(fxc, &adapter.get_info()) => {
log::info!(
"Horizon renderer: DirectX 12 compiles shaders with DXC ({}) on this integrated GPU",
dll.display()
);
return Some((instance, surface, adapter));
}
Ok((_, adapter)) => format!("it offered another GPU, {}", adapter.get_info().name),
Err(NoDevice::Failed(e) | NoDevice::NoAdapter(e)) => e,
};
log::warn!(
"Horizon renderer: DXC from {} didn't load ({why}); DirectX 12 compiles shaders with FXC",
dll.display()
);
None
}
/// What to tell a player whose system offers no usable graphics adapter.
/// Vulkan is loaded at run time (the game links no graphics library), so a
/// missing driver shows here rather than as a missing-DLL dialog.
@@ -4756,6 +4841,7 @@ impl Renderer {
// and cave culling can drop the listed count below MIN_SECTIONS on a normal view:
// so Metal draws vanilla terrain flat at any count.
let min_sections = if flat::is_broken_intel_vulkan(&info)
|| flat::is_adreno_dx12(&info)
|| info.backend == wgpu::Backend::Metal
|| std::env::var("HORIZON_FLAT_DRAWS").as_deref() == Ok("1")
{
@@ -4820,6 +4906,16 @@ impl Renderer {
// (`follow_packed_layout`).
let packed_quads = flat.is_some() && !shader_pack && packed_wanted();
packed::pack_quads(packed_quads);
// A native mod's world warp builds its pipelines with these layouts once it is given one.
let world_warps = world_warp::WorldWarps::new(world_warp::Kit {
terrain: bindings.clone(),
pages: flat.as_ref().map(|f| f.draw_layout.clone()),
entity: entity_bindings.clone(),
static_entity: static_entity_bindings.clone(),
format: frame_format,
sprite_constants,
hdr_output: hdr_output.is_some(),
});
let visbuf = flat
.as_ref()
.filter(|_| visbuffer::wanted(&device))
@@ -4970,6 +5066,7 @@ impl Renderer {
solid_cutout,
translucent,
translucent_mark,
world_warps,
flat_on: flat.is_some(),
packed_quads,
cull_on: cull.is_some(),
@@ -8441,10 +8538,37 @@ impl Renderer {
0,
bytemuck::bytes_of(&crosshair_globals),
);
let pack_terrain = !frame.title
&& self
.pack_post
.as_ref()
.and_then(|p| p.1.as_ref())
.is_some_and(|p| p.has_terrain());
// A native mod's world warp (`world_warp`), asked once a frame. While it draws, nothing is
// left out by where it was (the frustum, the caves, occlusion, faces turned away): the warp
// may bring it into view. Its frames keep to the per-section draws its pipelines are built
// for. A shader pack that draws the terrain itself (`pack_terrain`) isn't warped.
let warp_on = !frame.title && {
let warp = native_render::world_warp();
self.world_warps.frame(
&self.device,
&mut self.uploads,
&mut encoder,
&warp,
frame.camera,
// (the frame's partial tick: `weather::Time::new`'s third value)
frame.weather_time.values[2],
!pack_terrain,
)
};
// Frustum culling and camera-relative origins.
let cull_proj = views.cull().view_proj(pose);
let view = Frustum {
planes: frustum(cull_proj),
planes: if warp_on {
[Vec4::W; 6]
} else {
frustum(cull_proj)
},
};
let camera = frame.camera;
let camera_section = camera_section(camera, self.layout.min_y());
@@ -8452,7 +8576,7 @@ impl Renderer {
// (above the screen's top, the far shore, the trees against the sky).
let mirror_plane = frame
.water_plane
.filter(|_| !frame.title && self.wants_water_plane());
.filter(|_| !frame.title && !warp_on && self.wants_water_plane());
let mirror = mirror_plane.map(|plane| Frustum {
planes: frustum(mirror_view_projection(
cull_proj,
@@ -8462,11 +8586,11 @@ impl Renderer {
// Solid quads facing away from the eye are left out: the bob moves
// the eye off `camera`, so it is found from the projection. With the mirror, those
// facing its eye are kept too: it sits straight below, at this camera-relative y.
let eye = projection_center(cull_proj).filter(|_| self.sides_on);
let eye = projection_center(cull_proj).filter(|_| self.sides_on && !warp_on);
let mirror_low = mirror_plane
.zip(eye)
.map(|(plane, eye)| 2.0 * (plane - frame.camera.y as f32) - eye.y);
let culling = !frame.title && frame.cave_culling;
let culling = !frame.title && frame.cave_culling && !warp_on;
if culling {
crate::spans::time("occlusion", || {
self.start_occlusion(camera, frame.detail_distance)
@@ -8487,12 +8611,6 @@ impl Renderer {
native.shadow.as_ref()?;
native.frame.shadow_view(frame)
});
let pack_terrain = !frame.title
&& self
.pack_post
.as_ref()
.and_then(|p| p.1.as_ref())
.is_some_and(|p| p.has_terrain());
// `LevelRenderer.extractSectionDrawGroups`' fade-in time, for the instances.
let fade_ms = fade_millis(frame.chunk_fade);
// GPU culling's fading sections, decided at `fades.now`: before the gate, the record
@@ -8526,7 +8644,8 @@ impl Renderer {
&& !pack_terrain
&& shadow_view.is_none()
&& mirror_plane.is_none()
&& !displaced;
&& !displaced
&& !warp_on;
#[cfg(debug_assertions)]
if gpu_frame {
if self.gpu_checks.is_multiple_of(64) {
@@ -8821,6 +8940,7 @@ impl Renderer {
&& self.multi_draw
// Occlusion is found from the camera, not the mirror's eye.
&& mirror.is_none()
&& !warp_on
// It tests the CPU's lists, which leave out GPU culling's sections.
&& !gpu_frame
&& visible.len() >= c.min_sections
@@ -8834,7 +8954,7 @@ impl Renderer {
// ones (`Part`), each solid pass drawing its own: `[..opaque_draws]`,
// then the rest; phase 2 likewise. A fading list's quads all draw in the
// cutout pass.
let split_solid = self.info.backend == wgpu::Backend::Metal;
let split_solid = self.info.backend == wgpu::Backend::Metal && !warp_on;
let parts = |fading: bool| -> &'static [(u8, Quads)] {
match (split_solid, fading) {
(false, _) => &[(0, Quads::All)],
@@ -9394,6 +9514,7 @@ impl Renderer {
// path.
let oit_on = self.improved_transparency
&& !frame.title
&& !warp_on
&& self.pack_post.as_ref().is_none_or(|p| p.1.is_none())
&& self.native.is_none()
&& oit::supported(&self.device);
@@ -9807,6 +9928,7 @@ impl Renderer {
// They copy the CPU's lists alone (GPU culling is never offered
// where vanilla frames draw flat).
&& !gpu_frame
&& !warp_on
&& !self.draws.is_empty()
&& (listed >= flat.min_sections
|| pack_terrain
@@ -10355,16 +10477,25 @@ impl Renderer {
let material = self
.native
.as_ref()
.filter(|_| !frame.title)
.filter(|_| !frame.title && !warp_on)
.and_then(|n| Some((n.material.as_ref()?, &n.frame.group)));
// Without HDR output, the solid shaders without its lighting (`SolidPipeline`).
let hdr = self.hdr.is_some();
let solid_pipeline = material.map_or(self.solid.get(hdr), |(m, _)| &m.solid);
// A world warp's pipelines in place of the terrain's and the entities' (`warp_on`).
let warped = self.world_warps.pipelines().filter(|_| warp_on);
let warp_group = self.world_warps.group().filter(|_| warp_on);
let solid_pipeline = match warped {
Some(w) => w.solid.get(hdr),
None => material.map_or(self.solid.get(hdr), |(m, _)| &m.solid),
};
// Two solid passes only where a discard costs the GPU its hidden surface
// removal: Apple's tiled GPUs, behind Metal. Elsewhere the second pass
// would double the solid draw calls and vertex work for nothing.
let split_solid = self.info.backend == wgpu::Backend::Metal;
let translucent_pipeline = material.map_or(&self.translucent, |(m, _)| &m.translucent);
let split_solid = self.info.backend == wgpu::Backend::Metal && !warp_on;
let translucent_pipeline = match warped {
Some(w) => &w.translucent,
None => material.map_or(&self.translucent, |(m, _)| &m.translucent),
};
let pack_flat = material.and_then(|(m, _)| m.flat.as_ref());
// A material stage's translucent pass reads a copy of the depth the solid terrain
// left: it is stored across the pass that ends before the copy.
@@ -10384,6 +10515,7 @@ impl Renderer {
.filter(|n| {
!frame.title
&& !pack_draws_terrain
&& !warp_on
&& n.material.as_ref().is_some_and(|m| m.writes_surface)
})
.map(|n| n.frame.surface_view.clone());
@@ -10686,6 +10818,9 @@ impl Renderer {
let pulled = material.is_none() && self.flat.is_some();
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
pass.set_vertex_buffer(1, self.instances.slice(..));
if let Some(group) = warp_group {
pass.set_bind_group(2, group, &[]);
}
match cull {
Some(cull) => {
// Phase 1 and the boxes against its depth (in
@@ -10817,7 +10952,7 @@ impl Renderer {
if let Some(n) = self
.native
.as_ref()
.filter(|_| !frame.title && !pack_draws_terrain)
.filter(|_| !frame.title && !pack_draws_terrain && !warp_on)
&& let Some(d) = &n.displacement
&& let Some(pipeline) = n.material.as_ref().and_then(|m| m.displaced.as_ref())
{
@@ -10829,8 +10964,17 @@ impl Renderer {
drop(pass);
pass = plain_pass(&mut encoder);
}
// A world warp's entity pipelines read its group 2.
if let Some(group) = warp_group {
pass.set_bind_group(2, group, &[]);
}
let hdr_mark = warped.map_or(&self.hdr_mark_pipeline, |w| &w.hdr_mark);
let hdr_entity_mark =
warped.map_or(&self.hdr_entity_mark_pipeline, |w| &w.hdr_entity_mark);
if static_entity_quads > 0 {
pass.set_pipeline(&self.static_entity_pipeline);
pass.set_pipeline(
warped.map_or(&self.static_entity_pipeline, |w| &w.static_entities),
);
pass.set_bind_group(0, &self.entity_group, &[]);
pass.set_bind_group(1, &self.static_entity_group, &[]);
pass.set_index_buffer(
@@ -10853,18 +10997,18 @@ impl Renderer {
}
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
if entity_quads > 0 {
pass.set_pipeline(&self.entity_pipeline);
pass.set_pipeline(warped.map_or(&self.entity_pipeline, |w| &w.entities));
pass.set_bind_group(0, &self.entity_group, &[]);
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
pass.draw_indexed(0..entity_quads * 6, 0, 0..1);
if light_marks(self.frame_format, self.hdr.is_some()) {
pass.set_pipeline(&self.hdr_entity_mark_pipeline);
pass.set_pipeline(hdr_entity_mark);
pass.draw_indexed(0..entity_quads * 6, 0, 0..1);
}
pass.set_bind_group(0, &self.group, &[]);
}
if text_quads > 0 && !oit_on {
pass.set_pipeline(&self.text_polygon_offset_pipeline);
pass.set_pipeline(warped.map_or(&self.text_polygon_offset_pipeline, |w| &w.text));
pass.set_bind_group(0, &self.entity_group, &[]);
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
pass.draw_indexed(0..text_quads * 6, (entity_quads * 4) as i32, 0..1);
@@ -10873,7 +11017,7 @@ impl Renderer {
// FeatureRenderDispatcher.PreparedFrame.executeTranslucent: the shadows
// come before the translucent items, which then cover them (#7026).
if shadow_quads > 0 && !oit_on {
pass.set_pipeline(&self.shadow_blob_pipeline);
pass.set_pipeline(warped.map_or(&self.shadow_blob_pipeline, |w| &w.entity_shadows));
pass.set_bind_group(0, &self.entity_group, &[]);
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
pass.draw_indexed(
@@ -10888,7 +11032,9 @@ impl Renderer {
pass.set_bind_group(0, &self.group, &[]);
}
if translucent_item_quads > 0 && !oit_on {
pass.set_pipeline(&self.translucent_item_pipeline);
pass.set_pipeline(
warped.map_or(&self.translucent_item_pipeline, |w| &w.translucent_items),
);
pass.set_bind_group(0, &self.entity_group, &[]);
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
pass.draw_indexed(
@@ -10898,7 +11044,7 @@ impl Renderer {
);
if light_marks(self.frame_format, self.hdr.is_some()) {
// A glowing item in the world (Item sheet quads), as entities are.
pass.set_pipeline(&self.hdr_mark_pipeline);
pass.set_pipeline(hdr_mark);
pass.draw_indexed(
0..translucent_item_quads * 6,
((base_quads - translucent_item_quads) * 4) as i32,
@@ -11295,6 +11441,9 @@ impl Renderer {
}
None => {
pass.set_pipeline(translucent_pipeline);
if let Some(group) = warp_group {
pass.set_bind_group(2, group, &[]);
}
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
pass.set_vertex_buffer(1, self.instances.slice(..));
self.draw_sections(
@@ -11304,7 +11453,15 @@ impl Renderer {
solid_draws,
translucent_draws,
);
self.mark_translucent_terrain(&mut pass, None, solid_draws, translucent_draws);
// (its HDR mark would fall where the water was before the warp moved it)
if !warp_on {
self.mark_translucent_terrain(
&mut pass,
None,
solid_draws,
translucent_draws,
);
}
}
}
// LevelRenderer.executeTranslucentAfterTerrain: vanilla's
@@ -11312,7 +11469,10 @@ impl Renderer {
// the quads write depth, so before it they would hide the glass
// or water behind them.
if translucent_particle_quads > 0 && !oit_on {
pass.set_pipeline(&self.entity_pipeline);
pass.set_pipeline(warped.map_or(&self.entity_pipeline, |w| &w.entities));
if let Some(group) = warp_group {
pass.set_bind_group(2, group, &[]);
}
pass.set_bind_group(0, &self.entity_group, &[]);
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
@@ -11323,7 +11483,7 @@ impl Renderer {
);
if light_marks(self.frame_format, self.hdr.is_some()) {
// Fireworks: their sparks are translucent-layer particles.
pass.set_pipeline(&self.hdr_entity_mark_pipeline);
pass.set_pipeline(hdr_entity_mark);
pass.draw_indexed(
0..translucent_particle_quads * 6,
((base_quads - translucent_item_quads - translucent_particle_quads) * 4)
@@ -13778,7 +13938,7 @@ mod tests {
#[test]
fn the_second_try_at_a_backend_shows_nonconformant_adapters() {
let shown = |allow| {
super::instance_descriptor(wgpu::Backends::VULKAN, None, allow)
super::instance_descriptor(wgpu::Backends::VULKAN, None, allow, wgpu::Dx12Compiler::Fxc)
.flags
.contains(wgpu::InstanceFlags::ALLOW_UNDERLYING_NONCOMPLIANT_ADAPTER)
};
@@ -13845,6 +14005,14 @@ mod tests {
} else if name == "oit_clouds.wgsl" {
let base = super::entity_shader() + include_str!("oit.wgsl");
sources.push((path, base + &text));
} else if name == "world_warp_fragment.wgsl" {
// A world warp's copy of terrain.wgsl (gfx/world_warp.rs), with the plainest warp.
let warp = super::world_warp::IDENTITY;
sources.push((path, super::world_warp::terrain_shader(warp).unwrap()));
} else if name == "world_warp.wgsl" {
// And of entity.wgsl with static_entity.wgsl.
let warp = super::world_warp::IDENTITY;
sources.push((path, super::world_warp::entity_shader(warp).unwrap()));
} else {
sources.push((path, text));
}
@@ -14607,7 +14775,7 @@ mod tests {
#[test]
fn indirect_draws_are_validated_only_in_debug_builds_and_on_directx_12() {
let validated = |backends| {
super::instance_descriptor(backends, None, false)
super::instance_descriptor(backends, None, false, wgpu::Dx12Compiler::Fxc)
.flags
.contains(wgpu::InstanceFlags::VALIDATION_INDIRECT_CALL)
};
@@ -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 [
@@ -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"],
@@ -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 },
@@ -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;
}
+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]
@@ -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 {
+5 -5
View File
@@ -1283,7 +1283,7 @@ fn relight_layer(
}
#[cfg(test)]
mod tests {
pub(crate) mod tests {
use super::*;
use crate::chunk::{HEIGHT, MIN_Y, SECTIONS};
@@ -1312,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,
@@ -1337,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.
+100 -5
View File
@@ -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),
@@ -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,
@@ -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,
+84 -4
View File
@@ -29,6 +29,7 @@ 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.
@@ -528,6 +529,10 @@ pub struct Particle {
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;
@@ -890,6 +895,8 @@ impl Particle {
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.
@@ -1286,6 +1293,11 @@ pub struct ParticleEngine {
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.
@@ -1479,6 +1491,9 @@ impl ParticleEngine {
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(),
@@ -1701,7 +1716,25 @@ impl ParticleEngine {
}
/// Sets (or clears) the resource packs' Polytone custom particle types.
pub fn set_polytone(&mut self, registry: Option<std::sync::Arc<polytone::Registry>>) {
self.polytone = registry.filter(|r| !r.is_empty());
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) {
@@ -2110,7 +2143,22 @@ impl ParticleEngine {
&& let Some(registry) = &self.polytone
&& let Some(def) = registry.get(name)
{
return polytone::make(self, &registry.clone(), name, &def.clone(), o, p, v, seed);
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)?;
@@ -3746,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) {
@@ -4738,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 {
@@ -4809,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
@@ -4891,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]),
@@ -4899,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,
@@ -4916,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,
@@ -8,11 +8,11 @@ use super::{
ParticleUpdate, Sprite,
};
use crate::environment::Frame;
use horizon_core::noise::SimplexNoise;
use horizon_core::random::{LegacyRandom, RandomSource};
use horizon_polytone::expr::{Host, Program, Root, Value as V};
use horizon_polytone::particles::{
BlockEmitters, CustomParticle, Emitter, EntityEmitters, LiquidAffinity, RenderType,
SoundEmitter,
};
use horizon_world::registry::BlockRegistry;
use serde_json::Value as Json;
@@ -35,7 +35,82 @@ pub struct Registry {
pub frame: Arc<RwLock<Frame>>,
configs: horizon_polytone::pack::Configs,
blocks: &'static BlockRegistry,
noise: SimplexNoise,
/// Each custom particle type's colormap (`colormap`), by type id.
tints: HashMap<String, Arc<Tint>>,
/// Sound ids as the engine's sound queue holds them, interned once.
sound_names: Mutex<HashMap<String, &'static str>>,
/// Sounds the custom particles played this tick (`ParticleEngine::tick` plays them).
sounds: Mutex<Vec<Sound>>,
}
/// A sound for the engine's queue: id, position, volume, pitch, delayed by the distance.
type Sound = (&'static str, [f64; 3], f32, f32, bool);
/// Where a particle's colormap colour comes from.
enum TintSource {
/// A vanilla biome colour (`grass_color`, `foliage_color`, `dry_foliage_color`,
/// `water_color`): the biome's own, as blocks get it.
Biome(fn(&crate::biome::BiomeInfo) -> u32),
/// A pack's colormap (`polytone/colormaps`), its axes run against the particle.
Pack(crate::environment::Colormap),
}
/// When a particle's colormap is sampled again (`cache`).
#[derive(Clone, Copy, PartialEq, Eq)]
enum TintCache {
EveryTick,
PerPosition,
OnSpawn,
}
/// A custom particle type's colormap: multiplied into its colour.
struct Tint {
source: TintSource,
cache: TintCache,
}
impl Tint {
/// `colormap`: an id, or `{"colormap": id, "cache": ...}`. Inline colormaps aren't read.
fn read(pack_root: &Path, json: &Json) -> Option<Self> {
let (id, cache) = match json {
Json::String(id) => (id.as_str(), TintCache::EveryTick),
Json::Object(o) => (
o.get("colormap")?.as_str()?,
match o.get("cache").and_then(Json::as_str) {
Some("per_position") => TintCache::PerPosition,
Some("on_spawn") => TintCache::OnSpawn,
_ => TintCache::EveryTick,
},
),
_ => return None,
};
let id = if id.contains(':') {
id.to_owned()
} else {
format!("minecraft:{id}")
};
let source = match id.as_str() {
"minecraft:grass_color" => TintSource::Biome(|b| b.grass),
"minecraft:foliage_color" => TintSource::Biome(|b| b.foliage),
"minecraft:dry_foliage_color" => TintSource::Biome(|b| b.dry_foliage),
"minecraft:water_color" => TintSource::Biome(|b| b.water),
_ => TintSource::Pack(crate::environment::Colormap::load(pack_root, &id)?),
};
Some(Self { source, cache })
}
/// The colour (0 to 1 per channel) at the particle in `ctx`; `None` keeps its own.
fn sample(&self, ctx: &mut Ctx) -> Option<[f32; 3]> {
let rgb = match &self.source {
TintSource::Biome(colour) => {
let at = ParticleLevel::block_at(ctx.position());
let info = ctx.biome_info(at)?;
colour(&info)
}
TintSource::Pack(colormap) => colormap.sample(ctx)?,
};
Some([16, 8, 0].map(|shift| ((rgb >> shift) & 0xff) as f32 / 255.0))
}
}
/// Every `.json` under `dir`, with its path relative to `root`.
@@ -104,7 +179,7 @@ impl Registry {
for (relative, json) in json_files(pack_root, &polytone.join("block_modifiers")) {
let implicit = horizon_polytone::implicit_id(&relative, "block_modifiers");
match BlockEmitters::parse(&json, implicit.as_deref()) {
Ok(Some(m)) if !m.particle_emitters.is_empty() => modifiers.push(Arc::new(m)),
Ok(Some(m)) => modifiers.push(Arc::new(m)),
Ok(_) => {}
Err(e) => log::warn!("{relative}: {e}"),
}
@@ -137,6 +212,19 @@ impl Registry {
.clone()
})
.collect();
let tints = types
.iter()
.filter_map(|(id, def)| {
let json = def.render.get("colormap")?;
match Tint::read(pack_root, json) {
Some(tint) => Some((id.clone(), Arc::new(tint))),
None => {
log::warn!("custom particle {id}: colormap {json} not found");
None
}
}
})
.collect();
Self {
types,
block_emitters,
@@ -146,7 +234,9 @@ impl Registry {
frame: Arc::new(RwLock::new(Frame::default())),
configs,
blocks,
noise: SimplexNoise::new(&mut LegacyRandom::new(0)),
tints,
sound_names: Mutex::default(),
sounds: Mutex::default(),
}
}
@@ -156,6 +246,22 @@ impl Registry {
&& self.entity_modifiers.is_empty()
}
/// A sound id as the engine's sound queue holds it.
fn sound_name(&self, id: &str) -> &'static str {
let mut names = self.sound_names.lock().unwrap_or_else(|e| e.into_inner());
if let Some(name) = names.get(id) {
return name;
}
let name: &'static str = Box::leak(id.to_owned().into_boxed_str());
names.insert(id.to_owned(), name);
name
}
/// The sounds custom particles played since the last call.
pub(super) fn take_sounds(&self) -> Vec<Sound> {
std::mem::take(&mut *self.sounds.lock().unwrap_or_else(|e| e.into_inner()))
}
pub fn get(&self, id: &str) -> Option<&Arc<CustomParticle>> {
self.types.get(id)
}
@@ -219,6 +325,54 @@ struct Controller {
ticks: u32,
/// Sprites in its set, for the age-driven frame.
frames: usize,
/// Its colormap, and what the last sample left.
tint: Option<Arc<Tint>>,
tinting: Tinting,
}
/// A particle's colormap state: its colour before the colormap, the block it was last
/// sampled at, and that sample.
#[derive(Clone, Copy, Debug)]
struct Tinting {
base: Option<[f32; 3]>,
at: Option<[i32; 3]>,
rgb: [f32; 3],
}
impl Default for Tinting {
fn default() -> Self {
Self {
base: None,
at: None,
rgb: [1.0; 3],
}
}
}
impl Tinting {
/// Samples the colormap as its `cache` says and multiplies it into the colour.
fn apply(&mut self, tint: &Tint, ctx: &mut Ctx) {
let Subject::Particle(p) = &ctx.subject else {
return;
};
let base = [p.rgba[0], p.rgba[1], p.rgba[2]];
let at = ParticleLevel::block_at(p.position);
let resample = match tint.cache {
TintCache::EveryTick => true,
TintCache::PerPosition => self.at != Some(at),
TintCache::OnSpawn => self.at.is_none(),
};
if resample {
self.at = Some(at);
self.rgb = tint.sample(ctx).unwrap_or([1.0; 3]);
}
self.base = Some(base);
if let Subject::Particle(p) = &mut ctx.subject {
for i in 0..3 {
p.rgba[i] = base[i] * self.rgb[i];
}
}
}
}
impl std::fmt::Debug for Controller {
@@ -280,6 +434,13 @@ impl Ctx<'_> {
Subject::Entity(at) => *at,
}
}
/// The biome at a block, when the level and biomes are known.
fn biome_info(&self, at: [i32; 3]) -> Option<crate::biome::BiomeInfo> {
let chunk = self.level?.world.chunks.get(&(at[0] >> 4, at[2] >> 4))?;
let id = chunk.biome(at[0], at[1], at[2]);
let biomes = self.registry.biomes.read().ok()?;
Some(biomes.as_ref()?.get(id).clone())
}
/// The biome at a block (`minecraft:plains`), when the level and biomes are known.
fn biome(&self, at: [i32; 3]) -> Option<String> {
let chunk = self.level?.world.chunks.get(&(at[0] >> 4, at[2] >> 4))?;
@@ -300,11 +461,18 @@ impl Ctx<'_> {
if name == "biome" {
return Ok(V::Str(self.biome(at).unwrap_or_default().into()));
}
// The biome's climate where it is known, else the camera's.
if name == "temperature" {
return num(f64::from(self.frame.temperature));
let t = self
.biome_info(at)
.map_or(self.frame.temperature, |b| b.temperature);
return num(f64::from(t));
}
if name == "downfall" {
return num(f64::from(self.frame.downfall));
let d = self
.biome_info(at)
.map_or(self.frame.downfall, |b| b.downfall);
return num(f64::from(d));
}
let arg = |i: usize| args.get(i).map_or(0.0, V::num) as i32;
let neighbour = match name {
@@ -517,7 +685,7 @@ impl Host for Ctx<'_> {
// `r.noise(x, y)`: one simplex noise (named noises aren't read yet).
let mut coords = args.iter().filter(|v| !matches!(v, V::Str(_))).map(V::num);
let (x, y) = (coords.next().unwrap_or(0.0), coords.next().unwrap_or(0.0));
num(f64::from(self.registry.noise.get2(x, y)))
num(f64::from(crate::environment::polytone_noise().get2(x, y)))
}
(id, _) if id >= BLOCKS => {
let at = *self
@@ -709,6 +877,45 @@ fn emit(emitters: &[Emitter], ctx: &mut Ctx, out: &mut Vec<(ParticleOptions, [f6
}
}
/// Runs sound emitters from the subject: each plays when its `chance` beats a random draw, at
/// its position relative to the subject (a block's centre).
fn emit_sounds(emitters: &[SoundEmitter], ctx: &mut Ctx, out: &mut Vec<Sound>) {
for emitter in emitters {
let chance = emitter
.fields
.get("chance")
.map_or(Some(1.0), |c| eval(c, ctx))
.unwrap_or(0.0);
if ctx.rand() >= chance {
continue;
}
let field = |ctx: &mut Ctx, key: &str| emitter.fields.get(key).and_then(|p| eval(p, ctx));
let base = match &ctx.subject {
Subject::Block(b) => b.map(|c| f64::from(c) + 0.5),
_ => ctx.position(),
};
if let Some(biomes) = &emitter.biomes
&& !biome_matches(biomes, ctx, ParticleLevel::block_at(base))
{
continue;
}
let at = [
base[0] + field(ctx, "x").unwrap_or(0.0),
base[1] + field(ctx, "y").unwrap_or(0.0),
base[2] + field(ctx, "z").unwrap_or(0.0),
];
if let Some(predicate) = &emitter.state_predicate
&& !state_matches(predicate, ctx, ParticleLevel::block_at(at))
{
continue;
}
let volume = field(ctx, "volume").unwrap_or(1.0) as f32;
let pitch = field(ctx, "pitch").unwrap_or(1.0) as f32;
let name = ctx.registry.sound_name(&emitter.sound);
out.push((name, at, volume, pitch, emitter.distance_delay));
}
}
/// Sets each `keys` field the program map holds on `p` through `apply`.
fn run_fields(
programs: &BTreeMap<String, Program>,
@@ -782,6 +989,10 @@ impl super::ParticleController for Controller {
let Ok(frame) = registry.frame.read() else {
return update;
};
// Expressions see the colour before the colormap.
if let Some(base) = self.tinting.base {
p.rgba[..3].copy_from_slice(&base);
}
let mut random = std::mem::replace(&mut p.random, LegacyRandom::new(0));
let mut ctx = Ctx {
subject: Subject::Particle(p),
@@ -811,6 +1022,18 @@ impl super::ParticleController for Controller {
}
}
emit(&def.emitters, &mut ctx, &mut update.spawned);
if !def.sound_emitters.is_empty() {
let mut sounds = Vec::new();
emit_sounds(&def.sound_emitters, &mut ctx, &mut sounds);
registry
.sounds
.lock()
.unwrap_or_else(|e| e.into_inner())
.extend(sounds);
}
if let Some(tint) = self.tint.clone() {
self.tinting.apply(&tint, &mut ctx);
}
}
drop(ctx);
p.random = random;
@@ -867,12 +1090,11 @@ pub(super) fn make(
velocity: [f64; 3],
seed: i64,
) -> Option<Particle> {
// GPU particles (`"type": "gpu"`) are a renderer of their own, not ported yet.
if def.kind == horizon_polytone::particles::ParticleKind::Gpu {
return None;
}
// `limit`: at most that many of the type alive at once.
if let Some(limit) = def.limit {
// (A GPU particle's `limit` is its ring of records instead.)
if let Some(limit) = def.limit
&& def.gpu.is_none()
{
let alive = engine
.particles
.iter()
@@ -889,8 +1111,11 @@ pub(super) fn make(
particle.gravity = 0.0;
particle.has_physics = def.has_physics;
let mut frames = 0;
// A model particle (`model`) isn't drawn yet: it still ticks and emits.
let hidden = def.render_type == RenderType::Invisible || def.render.contains_key("model");
// A model particle (`model`) isn't drawn yet: it still ticks and emits. A GPU particle is
// drawn from its record instead (`polytone_gpu`).
let hidden = def.render_type == RenderType::Invisible
|| def.render.contains_key("model")
|| def.gpu.is_some();
match engine.mod_sets.get(name) {
Some(&(set, _)) => {
let sprites = engine.set_sprites.get(set as usize)?;
@@ -907,6 +1132,8 @@ pub(super) fn make(
None => return None,
}
particle.no_render = hidden;
particle.orientation = super::polytone_gpu::Orientation::parse(&def.rotation_mode);
particle.additive = def.render_type == RenderType::AdditiveTranslucent;
particle.behaviour = Behaviour::Controlled {
translucent: def.render_type != RenderType::Opaque,
};
@@ -952,6 +1179,8 @@ pub(super) fn make(
customs,
ticks: 0,
frames,
tint: registry.tints.get(name).cloned(),
tinting: Tinting::default(),
}));
Some(particle)
}
@@ -1025,14 +1254,17 @@ impl ParticleEngine {
level: Some(level),
blocks: Vec::new(),
};
let mut sounds = Vec::new();
for modifier in modifiers.iter() {
emit(&modifier.particle_emitters, &mut ctx, &mut out);
emit_sounds(&modifier.sound_emitters, &mut ctx, &mut sounds);
}
drop(ctx);
drop(frame);
for (options, position, velocity) in out {
self.add(&options, position, velocity);
}
self.sounds.extend(sounds);
}
}
@@ -1041,6 +1273,7 @@ mod tests {
use super::*;
use crate::chunk::Chunk;
use crate::world::World;
use glam::DVec3;
use horizon_world::DATA_VERSION;
use horizon_world::block_collision::BlockCollisionRegistry;
use horizon_world::collision::CachedCollisionRegistry;
@@ -1263,4 +1496,135 @@ mod tests {
assert_eq!(engine.pending.len(), 1);
std::fs::remove_dir_all(root).unwrap();
}
#[test]
fn a_gpu_particle_is_a_record_drawn_until_its_life_ends() {
let blocks = blocks();
let root = pack(&[
(
"assets/test/polytone/custom_particles/rain.json",
serde_json::json!({
"type": "gpu", "texture": "test:textures/particle/rain.png",
"render_type": "translucent", "rotation_mode": "movement_aligned",
"initializer": {"lifetime": "3", "size": "0.5"},
"gravity": 0.1, "area": {"size": [4, 4, 4], "count": 3}
}),
),
(
// Additive: drawn in the frame's additive pass instead.
"assets/test/polytone/custom_particles/glow.json",
serde_json::json!({
"type": "gpu", "texture": "test:textures/particle/rain.png",
"render_type": "additive_translucent", "initializer": {"lifetime": "3"}
}),
),
(
"assets/minecraft/polytone/entity_modifiers/player.json",
serde_json::json!({"emitters": [{"particle": "test:rain"}, {"particle": "test:glow"}]}),
),
]);
let scene = scene(blocks);
let level = ParticleLevel {
world: &scene.world,
facts: &scene.facts,
collision: &scene.collision,
player: None,
};
let mut engine = engine(Registry::read(&root, Default::default(), blocks));
let rect = crate::hud::Rect {
u0: 0.0,
v0: 0.0,
u1: 1.0,
v1: 1.0,
w: 16,
h: 16,
};
engine
.atlas
.insert("test:particle/rain".into(), (rect, true));
engine.polytone_entity(&level, "minecraft:player", [1.0, 64.0, 1.0]);
// No engine particle: a record each; the rain drawn as its area's three quads.
assert!(engine.pending.is_empty());
assert_eq!(engine.polytone_gpu_records(), 2);
let mut out = Vec::new();
engine.render(
DVec3::new(1.0, 70.0, 1.0),
0.0,
0.0,
0.0,
&|_| 0xF0,
&mut out,
);
assert_eq!(out.len(), 3 * 4);
assert_eq!(engine.take_additive().len(), 4);
assert!(
out.iter()
.all(|v| v.layer == crate::gfx::TRANSLUCENT_PARTICLE_OVERLAY)
);
for _ in 0..3 {
engine.tick(&level);
}
assert_eq!(
engine.polytone_gpu_records(),
0,
"gone once its 3 ticks are over"
);
std::fs::remove_dir_all(root).unwrap();
}
#[test]
fn a_colormap_tints_the_particle_and_sound_emitters_play() {
let blocks = blocks();
let root = pack(&[
(
"assets/test/polytone/custom_particles/leaf.json",
serde_json::json!({
"has_physics": false, "colormap": "minecraft:foliage_color",
"initializer": {"red": "1", "green": "0.5", "blue": "1"},
"sound_emitters": [{"sound": "block.note_block.harp", "chance": "1", "volume": "0.5"}]
}),
),
(
"assets/minecraft/polytone/entity_modifiers/player.json",
serde_json::json!({"emitters": [{"particle": "test:leaf"}]}),
),
]);
let scene = scene(blocks);
let level = ParticleLevel {
world: &scene.world,
facts: &scene.facts,
collision: &scene.collision,
player: None,
};
let registry = Arc::new(Registry::read(&root, Default::default(), blocks));
// The chunk's biome is id 0: plains, whose foliage is #336699.
registry.set_biomes(&Arc::new(crate::biome::Biomes::from_jsons(&[(
"minecraft:plains",
serde_json::json!({"effects": {"foliage_color": 0x336699}}),
)])));
let mut engine = ParticleEngine::new(Path::new(""), &HashMap::new(), &HashSet::new());
engine.mod_sets.insert("test:leaf".into(), (0, None));
engine.set_sprites.push(vec![10]);
engine.camera = [1.0, 65.0, 1.0];
engine.set_polytone(Some(registry.clone()));
engine.polytone_entity(&level, "minecraft:player", [1.0, 64.0, 1.0]);
let mut p = engine.pending.pop().unwrap();
let tinted = [
0x33 as f32 / 255.0,
0.5 * 0x66 as f32 / 255.0,
0x99 as f32 / 255.0,
];
for _ in 0..2 {
engine.tick_particle(&mut p, &level);
// Multiplied into its own colour, every tick, without compounding.
assert_eq!(&p.rgba[..3], &tinted);
}
let sounds = registry.take_sounds();
assert_eq!(sounds.len(), 2);
assert_eq!(
(sounds[0].0, sounds[0].2, sounds[0].3),
("minecraft:block.note_block.harp", 0.5, 1.0)
);
std::fs::remove_dir_all(root).unwrap();
}
}
@@ -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));
}
}
@@ -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;
+39 -3
View File
@@ -2258,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);
}
}
@@ -4363,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);
@@ -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 {
+5 -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.
@@ -87,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
@@ -224,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);
}
}
}
+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]
+176 -9
View File
@@ -3287,6 +3287,8 @@ struct Game<'a> {
saved_for_exit: bool,
native_client_stopped: bool,
pending_save: Option<saving::ClosingSave>,
/// Scheduled end of the server tick (`MinecraftServer.haveTime`).
server_tick_deadline: Option<Instant>,
/// The last world frame reached the swap chain. A window the compositor
/// stops showing gets no frames, and the event loop must not spin on it.
frame_drawn: bool,
@@ -5552,6 +5554,7 @@ impl Game<'_> {
laps = horizon_client::spans::laps();
let sent = self.lan_broadcast_changes();
laps.lap("tb_broadcast");
self.save_chunks_eagerly();
self.with_level(|b, level, ticks| {
b.run_block_events(level, ticks);
});
@@ -7885,6 +7888,10 @@ impl Game<'_> {
// keep their own packet cutoff (`ordered.announced`, #10063).
self.ticked_entities_through = self.next_entity;
}
// ChunkMap still ticks while /tick freeze holds block/entity ticks.
if !running {
self.save_chunks_eagerly();
}
self.begin_ordered_ticks();
if running {
horizon_client::spans::time("tick_paintings", || self.tick_paintings());
@@ -13997,11 +14004,15 @@ impl Game<'_> {
self.save
.save_chunk_tickets(&self.streaming.forced, self.tickets.portal_tickets())?;
self.streaming.save(&mut self.world, wait);
let now = Instant::now();
let keys: Vec<_> = self.world.modified.drain().collect();
let chunks = self.with_entities(keys.iter().filter_map(|k| self.world.chunks.get(k)));
self.save
.save_later(&chunks, self.pack.assets.registry, world_biomes!(self))?;
// Prepare entities while the chunk writer runs, then use one barrier.
if !wait {
self.world.modified.autosaved(&keys, now);
}
self.save_entities(false)?;
self.save_raids();
if wait {
@@ -14009,6 +14020,51 @@ impl Game<'_> {
}
Ok(())
}
/// `ChunkMap.saveChunksEagerly`: snapshot only while the tick and writer
/// have room. NBT encoding, compression and durable writes stay on workers.
fn save_chunks_eagerly(&mut self) {
// No world open (`world_root` is set by `finish_open_world`): the save
// is `Game::new`'s placeholder under the working directory.
if self.remote.is_some()
|| self.world_root.as_os_str().is_empty()
|| self.agent.hold_server
|| self.clock.sprinting()
|| self
.server_lists
.as_ref()
.is_some_and(|lists| !lists.lock().unwrap().autosave)
{
return;
}
let deadline = self
.server_tick_deadline
.unwrap_or_else(|| Instant::now() + self.clock.tick_duration());
let mut modified = std::mem::take(&mut self.world.modified);
modified.save_eagerly(
Instant::now(),
|| Instant::now() < deadline && self.save.active_chunk_writes() < 128,
|key| self.world.chunks.contains_key(&key),
|key| {
// `saveChunkIfNeeded` saves only a LevelChunk, so a lit one; a
// column still waiting for its light keeps its queue entry.
let Some(chunk) = self.world.chunks.get(&key).filter(|c| c.lit) else {
return false;
};
let chunks = self.with_entities([chunk]);
match self
.save
.save_later(&chunks, self.pack.assets.registry, world_biomes!(self))
{
Ok(_) => true,
Err(e) => {
log::warn!("Horizon: failed to save changed chunks: {e}");
false
}
}
},
);
self.world.modified = modified;
}
/// `GameRenderer.tryTakeScreenshotIfNeeded`: while a local world without a
/// saved icon is on the screen, grab the rendered frame once a second until
/// the save has one. The Select World list shows it as the world's preview.
@@ -14350,6 +14406,7 @@ impl Game<'_> {
camera: camera.to_array(),
sky_light: i32::from(light >> 4),
block_light: i32::from(light & 15),
biome: biomes.get(biome).name.clone(),
temperature: biomes.get(biome).temperature,
downfall: biomes.get(biome).downfall,
};
@@ -14628,7 +14685,9 @@ impl Game<'_> {
} else {
self.bolt_instances(camera)
};
let mut additive = Vec::new();
// Particles blended additively (resource packs' Polytone ones, #11486), from the
// render that built `entities`.
let mut additive = self.particles.take_additive();
// The dragon is drawn in every dimension, as any entity: a dragon
// summoned outside the End (the server's AddEntity) is shown too.
if !title {
@@ -15152,6 +15211,9 @@ impl Game<'_> {
}
});
}
for chunk in &unloaded {
self.world.modified.unloaded(&(chunk.x, chunk.z));
}
let dirty: Vec<_> = unloaded
.iter()
.filter(|c| self.world.modified.remove(&(c.x, c.z)))
@@ -17563,6 +17625,7 @@ impl<'a> Game<'a> {
saved_for_exit: false,
native_client_stopped: false,
pending_save: None,
server_tick_deadline: None,
frame_drawn: true,
world_icon: None,
forced_hit: None,
@@ -18308,17 +18371,18 @@ pub fn run_with() -> Result<()> {
return run_loading(options, early_settings, vanilla_root, None, started);
}
let mut pack_error = None;
options.resources = packs::startup_root(&vanilla_root, &early_settings.resource_packs, &[])
.unwrap_or_else(|e| {
log::warn!("Horizon: resource packs not applied: {e}");
pack_error = Some(e.to_string());
vanilla_root.clone()
});
options.resources = mods::content_root(
&options.resources,
let base = mods::content_root(
&vanilla_root,
&early_settings.mods.enabled,
&options.dev_mods,
);
options.resources =
packs::startup_root(&vanilla_root, &base, &early_settings.resource_packs, &[])
.unwrap_or_else(|e| {
log::warn!("Horizon: resource packs not applied: {e}");
pack_error = Some(e.to_string());
base.clone()
});
let loaded = load_resources(
options.resources.clone(),
launch_world(&options),
@@ -18825,6 +18889,103 @@ mod tests {
));
}
#[test]
fn eager_saves_keep_dirty_order_cooldowns_and_writer_budget() {
use horizon_client::world::ModifiedChunks;
use std::cell::Cell;
let now = Instant::now();
let mut dirty = ModifiedChunks::default();
dirty.extend((0..27).map(|x| (x, 0)));
dirty.autosaved(&[(0, 0), (2, 0)], now);
dirty.remove(&(1, 0)); // already clean
let writes = Cell::new(127);
let mut chosen = Vec::new();
dirty.save_eagerly(
now,
|| writes.get() < 128,
|k| k != (3, 0),
|k| {
chosen.push(k);
writes.set(writes.get() + 1);
true
},
);
assert_eq!(chosen, [(4, 0)]); // cooldown, clean, absent, then budget exhausted
chosen.clear();
dirty.save_eagerly(
now,
|| true,
|_| true,
|k| {
chosen.push(k);
true
},
);
assert_eq!(chosen, (5..25).map(|x| (x, 0)).collect::<Vec<_>>()); // exactly 20
dirty.insert((4, 0)); // a later edit goes AFTER the still-cooled entries
chosen.clear();
dirty.save_eagerly(
now + Duration::from_secs(10),
|| true,
|_| true,
|k| {
chosen.push(k);
true
},
);
assert_eq!(chosen, [(0, 0), (2, 0), (25, 0), (26, 0), (4, 0)]);
dirty.insert((4, 0));
dirty.save_eagerly(
now + Duration::from_secs(11),
|| false,
|_| true,
|_| panic!("no time"),
);
assert!(dirty.contains(&(4, 0)));
dirty.unloaded(&(4, 0)); // unloading removes the cooldown even if reloaded immediately
chosen.clear();
dirty.save_eagerly(
now + Duration::from_secs(11),
|| true,
|_| true,
|k| {
chosen.push(k);
true
},
);
assert_eq!(chosen, [(4, 0)]);
}
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn eager_saves_wait_for_an_open_world_and_for_light() {
let mut game = super::native_machines::tests::server_game();
let dir = std::env::temp_dir().join(format!("horizon-eager-game-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
game.save = Arc::new(horizon_client::save::SaveDir::new(
dir.clone(),
horizon_client::save::LevelShape::OVERWORLD,
));
Arc::make_mut(game.world.chunks.get_mut(&(0, 0)).unwrap()).lit = true;
game.world
.chunks
.insert((1, 0), Arc::new(horizon_client::chunk::Chunk::empty(1, 0)));
game.world.modified.extend([(0, 0), (1, 0)]);
game.save_chunks_eagerly(); // no world open: nothing to save into
assert!(game.world.modified.contains(&(0, 0)));
game.world_root = dir.clone();
game.save_chunks_eagerly();
assert!(!game.world.modified.contains(&(0, 0)));
assert!(game.world.modified.contains(&(1, 0))); // not lit yet
Arc::make_mut(game.world.chunks.get_mut(&(1, 0)).unwrap()).lit = true;
game.save_chunks_eagerly(); // still queued: no new dirty transition needed
assert!(!game.world.modified.contains(&(1, 0)));
game.save.flush().unwrap();
assert!(dir.join("region/r.0.0.mca").exists());
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn integrated_server_save_persists_owner_player_data() {
let dir = std::env::temp_dir().join(format!(
@@ -18911,14 +19072,20 @@ mod loot_kill_guest_tests;
#[cfg(test)]
mod mannequin_save_tests;
#[cfg(test)]
mod mob_landing_support_tests;
#[cfg(test)]
mod orb_data_tests;
#[cfg(test)]
mod stand_break_loot_tests;
#[cfg(test)]
mod stand_command_hurt_tests;
#[cfg(test)]
mod storage_alias_tests;
#[cfg(test)]
mod sulfur_bucket_tests;
#[cfg(test)]
mod sulfur_prime_phase_tests;
#[cfg(test)]
mod test_pos_guest_tests;
#[cfg(test)]
mod vibration_source_tests;
@@ -1158,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);
@@ -1167,6 +1171,42 @@ 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
+1 -1
View File
@@ -744,7 +744,7 @@ impl Game<'_> {
inv_friction = 0.9;
}
Status::UnderWater => {
buoyancy = 0.01;
buoyancy = f64::from(0.01_f32);
inv_friction = 0.45;
}
Status::InAir => inv_friction = 0.9,
@@ -124,7 +124,7 @@ impl Game<'_> {
// ---- /damage ----------------------------------------------------------------
fn damage_kind(ty: &str) -> DamageKind {
pub(in crate::game) fn damage_kind(ty: &str) -> DamageKind {
match ty.trim_start_matches("minecraft:") {
"player_attack" => DamageKind::PlayerAttack,
"mace_smash" => DamageKind::MaceSmash,
+80 -12
View File
@@ -354,7 +354,9 @@ fn hits_target(e: &MobEvent, want: Who) -> bool {
Who::Player,
) => true,
(
MobEvent::AttackMob { target, .. } | MobEvent::AttackMobWithEffect { target, .. },
MobEvent::AttackMob { target, .. }
| MobEvent::DamageMob { target, .. }
| MobEvent::AttackMobWithEffect { target, .. },
Who::Mob(id),
) => *target == id,
(MobEvent::SonicBoom { target, .. }, _) => *target == want,
@@ -1168,10 +1170,25 @@ impl Game<'_> {
self.init_sulfur_attribute_maps();
let uuid = self.level.player_uuid;
let level_id = self.dim.id();
// LivingEntity.isPushable reads onClimbable live; a mob's cached one
// is only refreshed by its own push step, so one that has not ticked
// yet (the first tick after a load, a fresh spawn) or has moved since
// would be pushed out of a ladder or scaffolding. Refreshed here, from
// where each mob stands now, before the snapshot others push it by.
let climbing: Vec<bool> = {
let w = self.mob_world_base(&[]);
self.mobs.iter().map(|m| m.on_climbable(&w)).collect()
};
for (m, climbing) in self.mobs.iter_mut().zip(climbing) {
m.on_climbable = climbing;
}
let mut snapshot = std::mem::take(&mut self.creatures.seen_buf);
snapshot.extend(self.mobs.iter().map(|m| seen(m, uuid)));
let mut events = std::mem::take(&mut self.creatures.event_buf);
events.clear();
// SulfurCube.primeWhenOnPoweredPosition runs before super.tick adopts
// the body: the cubes whose adopted body explodes, as this tick starts.
let primable = self.sulfur_cubes_adopted_explosive();
let mut mobs = std::mem::take(&mut self.mobs);
// Each mob's target as the mods last heard it, by its place in `mobs`
// (`native_living_change_target`).
@@ -1594,7 +1611,7 @@ impl Game<'_> {
self.tick_sniffer_effects();
self.piglin_pickups();
self.sulfur_cube_pickups();
self.sulfur_cube_power_priming();
self.sulfur_cube_power_priming(&primable);
laps.lap("creature_systems");
// Hurt and death sounds of damage dealt outside the mob ticks.
let mut queued = Vec::new();
@@ -2068,8 +2085,21 @@ impl Game<'_> {
}
}
}
/// `Mob.doHurtTarget` and the other melee callers of post-attack effects.
fn mob_attacks_mob(&mut self, target: u32, amount: f32, source: Damage) -> bool {
let hurt = self.mob_hits_mob(target, amount, source);
if hurt {
self.mob_post_attack(
target,
source.attacker,
None,
Some(damage_type(source.kind, false)),
);
}
hurt
}
/// Melee or ranged damage to another mob; whether the hit landed.
fn mob_hits_mob(&mut self, target: u32, amount: f32, mut source: Damage) -> bool {
pub(super) fn mob_hits_mob(&mut self, target: u32, amount: f32, mut source: Damage) -> bool {
if source.attacker_species.is_none()
&& let Some(Who::Mob(id)) = source.attacker
{
@@ -2706,6 +2736,13 @@ impl Game<'_> {
target,
amount,
source,
} => {
self.mob_attacks_mob(target, amount, source);
}
MobEvent::DamageMob {
target,
amount,
source,
} => {
self.mob_hits_mob(target, amount, source);
}
@@ -2720,7 +2757,7 @@ impl Game<'_> {
// CaveSpider, Husk and WitherSkeleton.doHurtTarget: the
// effect sits inside the branch where the hit landed, and
// `LivingEntity.addEffect` takes a mob as well as a player.
if self.mob_hits_mob(target, amount, source)
if self.mob_attacks_mob(target, amount, source)
&& let Some(t) = self.mobs.iter_mut().find(|m| m.id == target)
{
t.add_effect(effect, ticks, amplifier);
@@ -2773,6 +2810,12 @@ impl Game<'_> {
pitch,
)])?;
}
self.mob_post_attack(
target,
source.attacker,
None,
Some(super::creatures::damage_type(source.kind, false)),
);
}
}
MobEvent::SonicBoom {
@@ -2870,6 +2913,14 @@ impl Game<'_> {
}
}
}
if let Who::Mob(id) = target {
self.mob_post_attack(
id,
source.attacker,
None,
Some("minecraft:mob_attack"),
);
}
}
}
MobEvent::Bonemeal { pos } => {
@@ -4809,11 +4860,8 @@ impl Game<'_> {
direct: &str,
damage_type: &str,
) {
if p.owner == Some(Who::Player)
&& let Who::Mob(id) = who
&& let Some(index) = self.mobs.iter().position(|m| m.id == id)
{
self.victim_post_attack(index, direct, Some(damage_type));
if let Who::Mob(id) = who {
self.mob_post_attack(id, p.owner, Some(direct), Some(damage_type));
}
}
/// What an arrow or a thrown trident hits an entity of type `victim`
@@ -5821,6 +5869,12 @@ impl Game<'_> {
});
if hurt {
self.player_hurt_mob(p, id);
self.shot_post_attack(
p,
who,
"minecraft:small_fireball",
"minecraft:fireball",
);
} else if !self.mobs.iter().any(|m| m.id == id) {
// SmallFireball.onHitEntity: 5 damage to a boat or minecart.
self.hurt_vehicle(p, id, 5.0);
@@ -5870,6 +5924,12 @@ impl Game<'_> {
}) {
self.player_hurt_mob(p, id);
}
self.shot_post_attack(
p,
who,
"minecraft:fireball",
"minecraft:fireball",
);
}
}
}
@@ -5954,8 +6014,10 @@ impl Game<'_> {
);
}
Who::Mob(id) => {
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) {
m.damage_projectile(1.0, source, aim, [p.velocity.x, p.velocity.z]);
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id)
&& m.damage_projectile(1.0, source, aim, [p.velocity.x, p.velocity.z])
{
self.shot_post_attack(p, who, "minecraft:llama_spit", "minecraft:spit");
}
}
},
@@ -5987,6 +6049,12 @@ impl Game<'_> {
// The hurt's teleport (`teleportSomewhere`,
// `hitByShulkerBullet`) comes before the levitation.
self.settle_hurt_teleports()?;
self.shot_post_attack(
p,
who,
"minecraft:shulker_bullet",
"minecraft:mob_projectile",
);
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) {
m.add_effect("minecraft:levitation", 200, 0);
}
@@ -8370,7 +8438,7 @@ fn to_loot(stack: &ItemStack) -> horizon_world::loot::LootStack {
}
}
/// A generated loot stack as a Horizon item stack.
fn from_loot(stack: &horizon_world::loot::LootStack) -> ItemStack {
pub(super) fn from_loot(stack: &horizon_world::loot::LootStack) -> ItemStack {
let mut item = ItemStack {
item: Arc::from(stack.item.as_str()),
count: stack.count.max(1) as u32,
@@ -1288,6 +1288,7 @@ impl Game<'_> {
// `/stop`, or a stop signal (the shutdown hook's `halt`): either way the players and levels are
// saved below, after the wait `[transfer]` may make (transfer.rs).
while !self.stop_now(&state) {
self.server_tick_deadline = Some(next + self.clock.tick_duration());
let tick_start = Instant::now();
if let Some(watchdog) = &watchdog {
watchdog.tick_due(next, self.clock.tick_duration());
+66 -5
View File
@@ -928,7 +928,7 @@ fn hurt_source(
direct: Option<&ProjectileKind>,
) -> HurtSource<'static> {
let causing = by.unwrap_or(0);
let melee = direct.is_none() && super::creatures::melee(kind);
let melee = direct.is_none() && (super::creatures::melee(kind) || kind == DamageKind::Thorns);
HurtSource {
damage_type: super::creatures::damage_type(kind, by.is_some()),
causing,
@@ -1583,9 +1583,9 @@ impl Game<'_> {
) -> Hurt {
// `dealDefaultKnockback` for a player's melee: away from the attacker's
// feet. The scope names that player to the knockback hook.
let _attacker = (kind == DamageKind::PlayerAttack)
let _attacker = (matches!(kind, DamageKind::PlayerAttack | DamageKind::Thorns))
.then(|| self.knockback_scope(self.player.as_ref().map_or(0, |p| p.entity_id)));
let from = (kind == DamageKind::PlayerAttack)
let from = (matches!(kind, DamageKind::PlayerAttack | DamageKind::Thorns))
.then(|| self.player.as_ref().map(|p| p.position))
.flatten();
self.hurt_dragon_part_at(part, damage, counts, by, kind, direct, from)
@@ -1633,6 +1633,10 @@ impl Game<'_> {
let pitch = 0.8 + self.world.random.next_float() * 0.3;
self.audio
.play("minecraft:entity.ender_dragon.hurt", Some(at), 5.0, pitch);
if kind == DamageKind::Thorns {
self.audio
.play("minecraft:enchant.thorns.hit", Some(at), 1.0, 1.0);
}
if let Some(s) = source {
d.last_source = Some((
s.damage_type,
@@ -2685,8 +2689,25 @@ impl Game<'_> {
)
})
.collect();
for (mob, allowed) in self.mobs.iter_mut().zip(allowed) {
horizon_client::mobs::dragon_contacts::contact(mob, &frame, allowed);
// knockBack(wing1), knockBack(wing2), hurt(head), hurt(neck): each
// part's list in turn, so Thorns' level and dragon draws follow it.
for part in 0..4 {
for (index, &allowed) in allowed.iter().enumerate() {
if horizon_client::mobs::dragon_contacts::contact_part(
&mut self.mobs[index],
&frame,
allowed,
part,
) {
let id = self.mobs[index].id;
self.mob_post_attack(
id,
Some(horizon_client::mobs::Who::Mob(frame.dragon_id)),
Some("minecraft:ender_dragon"),
Some("minecraft:mob_attack"),
);
}
}
}
}
// checkWalls over head, neck and body.
@@ -3112,6 +3133,35 @@ impl Game<'_> {
});
}
self.combat.forget_noted(canceled);
if !sitting
&& !hurt_lately
&& let Some(id) = self.dragon.as_ref().map(|d| d.id)
{
self.thorns(id);
}
}
/// `DamageEntity.apply`: the dragon's own random, including when the
/// wearer's mob source cannot take health from it.
pub(super) fn dragon_effect_random(&mut self, id: u32) -> Option<f32> {
self.dragon
.as_mut()
.filter(|d| d.id == id)
.map(|d| d.random.next_float())
}
/// `EnderDragon.hurtServer`: Thorns hits the body part, so damage is
/// quartered plus at most one, with the player as its causing entity.
pub(super) fn dragon_thorns(&mut self, attacker: u32, min: f32, max: f32) {
let Some(d) = self.dragon.as_mut().filter(|d| d.id == attacker) else {
return;
};
let damage = d.random.next_float() * (max - min) + min;
self.hurt_dragon(
2,
damage,
true,
Some(self.acting_player()),
DamageKind::Thorns,
);
}
/// Damage from the dragon, which is its causing entity
/// (`DamageSources.mobAttack(dragon)`, `indirectMagic(cloud, dragon)`): the
@@ -3119,6 +3169,13 @@ impl Game<'_> {
fn dragon_hurts_player(&mut self, damage_type: &str, amount: f32) {
self.note_dragon_attacker(damage_type);
self.hurt_player_by(damage_type, amount);
// EnderDragon.hurt(List) runs effects even when hurtServer refused.
// The breath cloud's indirect magic never runs them.
if damage_type == "minecraft:mob_attack"
&& let Some(id) = self.dragon.as_ref().map(|d| d.id)
{
self.thorns(id);
}
}
/// The causing entity of the dragon's next `damage_type` damage, for the
/// death message.
@@ -5689,3 +5746,7 @@ mod tests {
assert_eq!(d.hurt_time, 10);
}
}
#[cfg(test)]
#[path = "thorns_tests.rs"]
mod thorns_tests;
@@ -218,7 +218,9 @@ impl Game<'_> {
/// `doPostAttackEffects`): the victim's equipment first (Thorns), then the
/// attacker's weapon on the victim (fire aspect and wind burst).
pub(super) fn post_attack(&mut self, mob: usize) {
self.victim_post_attack(mob, "minecraft:player", None);
if let Some(id) = self.mobs.get(mob).map(|m| m.id) {
self.mob_post_attack(id, Some(Who::Player), Some("minecraft:player"), None);
}
let Some(held) = self.held().cloned() else {
return;
};
@@ -500,6 +502,7 @@ impl Game<'_> {
self.player_thorns(|g, min, max| {
// DamageEntity: Mth.randomBetween from the hurt entity's own random.
let Some(index) = g.mob_index.find(&g.mobs, attacker) else {
g.dragon_thorns(attacker, min, max);
return;
};
let damage = g.mobs[index].random.next_float() * (max - min) + min;
@@ -645,20 +648,34 @@ impl Game<'_> {
}
}
}
/// `EnchantmentHelper.doPostAttackEffectsWithItemSource`'s `VICTIM` loop
/// after the player's hit hurt the mob at `victim` (`direct` is the hit's
/// direct entity: the player, or its arrow or trident):
/// `runIterationOnEquipment` over what the mob holds and wears, in
/// `EquipmentSlot` order, runs the `minecraft:post_attack` effects with
/// `enchanted: victim` of each enchantment on the player (Thorns).
pub(super) fn victim_post_attack(
/// `EnchantmentHelper.doPostAttackEffectsWithItemSource`'s victim loop:
/// each equipment slot's chance, damage from the attacker's random, then wear.
pub(super) fn mob_post_attack(
&mut self,
victim: usize,
direct: &str,
target: u32,
attacker: Option<Who>,
direct: Option<&str>,
damage_type: Option<&str>,
) {
let attacker_kind = match attacker {
Some(Who::Player) => self.player.as_ref().map(|_| "minecraft:player".to_owned()),
Some(Who::Mob(id)) => self
.mobs
.iter()
.find(|m| m.id == id && !m.removed)
.map(|m| m.kind.id.clone())
.or_else(|| {
self.dragon
.as_ref()
.filter(|d| d.id == id)
.map(|_| "minecraft:ender_dragon".to_owned())
}),
None => None,
};
let attacker = attacker.filter(|_| attacker_kind.is_some());
let direct = direct.or(attacker_kind.as_deref());
use horizon_client::mobs::finalize as f;
let Some(m) = self.mobs.get(victim) else {
let Some(m) = self.mobs.iter().find(|m| m.id == target) else {
return;
};
let (id, kind) = (m.id, m.kind.id.clone());
@@ -700,9 +717,9 @@ impl Game<'_> {
// Enchantment.damageContext: random_chance draws from the level's random.
let ctx = EffectContext {
this: Some(&kind),
attacker: Some("minecraft:player"),
direct_attacker: Some(direct),
is_direct: direct == "minecraft:player",
attacker: attacker_kind.as_deref(),
direct_attacker: direct,
is_direct: direct.is_some() && direct == attacker_kind.as_deref(),
damage_type,
items: Some(world_items!(self)),
..Default::default()
@@ -715,39 +732,70 @@ impl Game<'_> {
&mut self.world.random,
)
});
// The player is the attacker; with no attacker nothing applies.
if ok && effect["affected"] == "attacker" && self.player.is_some() {
self.victim_effect_on_player(&effect["effect"], level, id, slot);
if ok
&& effect["affected"] == "attacker"
&& let Some(attacker) = attacker
{
self.victim_effect(&effect["effect"], level, id, slot, attacker);
}
}
}
}
/// `DamageEntity.apply`, `ChangeItemDamage.apply` (on the piece) and
/// `AllOf` for one `post_attack` effect of the piece the mob `wearer` has
/// in `slot`, on the player that hit it.
fn victim_effect_on_player(&mut self, effect: &Value, level: u32, wearer: u32, slot: usize) {
/// in `slot`, on the entity that hit it.
fn victim_effect(
&mut self,
effect: &Value,
level: u32,
wearer: u32,
slot: usize,
attacker: Who,
) {
match effect["type"]
.as_str()
.map(|t| t.trim_start_matches("minecraft:"))
{
Some("all_of") => {
for e in effect["effects"].as_array().into_iter().flatten() {
self.victim_effect_on_player(e, level, wearer, slot);
self.victim_effect(e, level, wearer, slot, attacker);
}
}
// Mth.randomBetween from the hurt entity's own random (the
// player's, which no level draw shares), then its hurtServer by a
// source the wearer caused: from where the wearer stands.
// DamageEntity.apply: the affected attacker's own random, then
// hurtServer by a source the wearer caused, from its feet.
Some("damage_entity") => {
let min = level_value(&effect["min_damage"], level);
let max = level_value(&effect["max_damage"], level);
let damage = random_source::entity().next_float() * (max - min) + min;
let random = match attacker {
Who::Player => random_source::entity().next_float(),
Who::Mob(id) => match self.mob_index.find(&self.mobs, id) {
Some(index) => self.mobs[index].random.next_float(),
None => match self.dragon_effect_random(id) {
Some(random) => random,
None => return,
},
},
};
let damage = random * (max - min) + min;
let Some(m) = self.mobs.iter().find(|m| m.id == wearer) else {
return;
};
let from = [m.position.x, m.position.y, m.position.z];
let kind = effect["damage_type"].as_str().unwrap_or("minecraft:thorns");
self.mob_hits_player_caused(damage, from, kind, Some(wearer));
match attacker {
Who::Player => {
self.mob_hits_player_caused(damage, from, kind, Some(wearer));
}
Who::Mob(id) => {
let source = horizon_client::mobs::Damage {
kind: Self::damage_kind(kind),
attacker: Some(Who::Mob(wearer)),
from: Some(from),
attacker_species: Some(m.species()),
};
self.mob_hits_mob(id, damage, source);
}
}
}
Some("change_item_damage") => {
let amount = level_value(&effect["amount"], level) as u32;
+21 -5
View File
@@ -230,17 +230,33 @@ impl Game<'_> {
} else {
Vec::new()
};
for m in &mut self.mobs {
let ally = allies.contains(&m.id);
if m.id != f.owner && !ally && m.alive() && m.bounds().intersects(b) {
m.damage(
for index in 0..self.mobs.len() {
let m = &mut self.mobs[index];
let id = m.id;
if id != f.owner
&& !allies.contains(&id)
&& m.alive()
&& m.bounds().intersects(b)
&& m.damage(
6.0,
horizon_client::mobs::Damage {
kind: horizon_client::mobs::DamageKind::Magic,
kind: if owner.is_some() {
horizon_client::mobs::DamageKind::IndirectMagic
} else {
horizon_client::mobs::DamageKind::Magic
},
attacker: owner.map(horizon_client::mobs::Who::Mob),
from: Some(f.pos),
attacker_species: None,
},
)
&& owner.is_some()
{
self.mob_post_attack(
id,
owner.map(horizon_client::mobs::Who::Mob),
Some("minecraft:evoker_fangs"),
Some("minecraft:indirect_magic"),
);
}
}
@@ -714,6 +714,7 @@ impl Game<'_> {
self.wait_for_next_server_tick(&mut next, true);
continue;
}
self.server_tick_deadline = Some(next + self.clock.tick_duration());
let paused = flags.paused.load(Ordering::Relaxed);
if paused && !was_paused {
// IntegratedServer.tickServer: the tick the game pauses saves
+279 -1
View File
@@ -1679,7 +1679,8 @@ impl Game<'_> {
/// it no longer counts among the openers of the lid, and the lid's close
/// sound is played for everyone around (`ChestBlockEntity.onClose`, a
/// `playSound` with no player), the guest included, once the last opener
/// has left.
/// has left. `ContainerOpenersCounter.decrementOpeners` then posts
/// CONTAINER_CLOSE with that closing guest's context.
pub(super) fn guest_container_closed(&mut self, host: &mut LanHost, conn: u32) {
if let Some(g) = host.hub.guest(conn)
&& let Some(pos) = g.menu.as_ref().and_then(|m| m.pos)
@@ -1687,6 +1688,19 @@ impl Game<'_> {
&& self.container_openers_changed(pos, false)
{
self.container_edge_sound(pos, false);
let post = |game: &mut Self| {
game.post_block_game_event(
"minecraft:container_close",
[pos.0, pos.1, pos.2],
Some(horizon_client::mobs::Who::Player),
None,
);
};
if self.lan_acting == Some(conn) {
post(self);
} else {
self.as_guest(host, conn, post);
}
}
}
@@ -6969,3 +6983,267 @@ mod unloaded_chunk_tests {
));
}
}
#[cfg(test)]
mod guest_close_listener_tests {
use super::*;
use crate::game::{Audio, Options, Progress, Settings, load_resources};
use horizon_client::{
chunk::Chunk,
containers::BlockEntity,
lan::{Joined, NetConfig, PlayerSender, guest::Guest, hub::HubConfig},
sculk::Info,
};
use horizon_core::position::ChunkPos;
use horizon_protocol::packets::configuration::ClientInformation;
use std::sync::{Arc, atomic::AtomicI32, mpsc};
fn selected(game: &Game<'_>) -> Option<Info> {
let Some(BlockEntity::Sculk(sensor)) = game.world.block_entities.get(&(9, 100, 4)) else {
panic!("actual command-created sensor must exist");
};
sensor.vibration.selector.current.map(|(info, _)| info)
}
fn reset_listener(game: &mut Game<'_>) {
assert!(game.run_command("setblock 9 100 4 air strict"));
assert!(game.run_command("setblock 9 100 4 sculk_sensor strict"));
game.sculk_prepare();
game.sculk_sync_index();
assert!(selected(game).is_none());
}
fn open(game: &mut Game<'_>, host: &mut LanHost, conn: u32, sequence: i32) -> i32 {
game.lan_guest_packet(
host,
conn,
S::UseItemOn(play::UseItemOn {
hand: 0,
pos: [6, 100, 4],
face: 1,
cursor: [0.5, 1.0, 0.5],
inside: false,
world_border: false,
sequence,
}),
);
let guest = host.hub.guest(conn).unwrap();
assert!(guest.ack_up_to.get() >= sequence);
assert_eq!(guest.health, 20.0);
assert_eq!(guest.position, [4.5, 100.0, 4.5]);
let menu = guest
.menu
.as_ref()
.expect("actual packet must open the chest menu");
assert_eq!(menu.pos, Some((6, 100, 4)));
assert_eq!(menu.size, 27);
menu.window
}
fn close(game: &mut Game<'_>, host: &mut LanHost, conn: u32, window: i32) {
game.lan_guest_packet(
host,
conn,
S::ContainerClose(play::ContainerClose { container: window }),
);
}
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES and scratch homes/cwd"]
fn ordinary_guest_final_close_posts_to_actual_listener() {
let root = std::path::PathBuf::from(
std::env::var_os("HORIZON_TEST_RESOURCES").expect("prepared resources required"),
);
let settings = Settings::default();
let loaded = load_resources(
root.clone(),
None,
&root,
&settings,
std::time::Instant::now(),
false,
true,
&Progress::new(),
&mut |_, _| unreachable!(),
)
.unwrap();
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
options.resources = root.clone();
options.execution_role = crate::game::execution_role::ExecutionRole::IntegratedServer;
let mut game = Game::new(
&loaded.resources,
loaded.pack,
loaded.gui.unwrap(),
options,
settings,
Audio::silent(&root),
None,
)
.unwrap();
game.build_registries();
game.set_allow_commands(true);
game.world
.chunks
.insert((0, 0), Arc::new(Chunk::empty(0, 0)));
game.block_ticks
.add_empty_chunk(ChunkPos::new(0, 0))
.unwrap();
assert!(game.run_command("forceload add 0 0 15 15"));
assert!(game.run_command("fill 0 99 0 15 99 15 stone"));
assert!(game.run_command("setblock 6 100 4 chest"));
assert!(game.run_command("setblock 9 100 4 sculk_sensor strict"));
game.sculk_prepare();
game.sculk_sync_index();
assert!(selected(&game).is_none());
game.lan_host(
super::super::lan::LanSettings {
port: 0,
guest_commands: true,
force_game_mode: false,
},
NetConfig {
announce: false,
..NetConfig::loopback(0, "guest-close-test".into(), 4)
},
HubConfig {
default_game_mode: 0,
guest_commands: true,
force_game_mode: false,
hardcore: false,
view_distance: 2,
simulation_distance: 2,
dimension: "minecraft:overworld".into(),
allow_flight: false,
seed: 0,
max_players: 4,
integrated: false,
levels: Default::default(),
},
)
.unwrap();
let mut host = game.lan.take().unwrap();
let mut receivers = Vec::new();
for conn in 1..=3 {
let (tx, rx) = mpsc::channel();
receivers.push(rx);
let mut guest = Guest::new(
Joined {
conn,
uuid: u128::from(conn) + 11200,
name: format!("CloseGuest{conn}"),
properties: Vec::new(),
info: ClientInformation::new(2),
brand: "test".into(),
sender: PlayerSender::new(tx, Arc::default()),
latency: Arc::new(AtomicI32::new(0)),
address: "127.0.0.1:25565".parse().unwrap(),
expected: false,
configured: Default::default(),
},
conn as i32 + 100,
[4.5, 100.0, 4.5],
2,
);
guest.dimension = "minecraft:overworld".into();
guest.placed = true;
guest.loaded = true;
guest.game_mode = if conn == 3 { 3 } else { 0 };
guest.on_ground = true;
assert_eq!(guest.health, 20.0);
host.hub.guests.push(guest);
}
let clock = game.world.game_time;
let mut missing = Vec::new();
let window = open(&mut game, &mut host, 1, 1);
assert_eq!(game.lid_openers.get(&(6, 100, 4)), Some(&1));
assert_eq!(selected(&game).unwrap().event, "minecraft:container_open");
reset_listener(&mut game);
close(&mut game, &mut host, 1, window + 20);
assert_eq!(
host.hub.guest(1).unwrap().menu.as_ref().unwrap().window,
window
);
assert_eq!(game.lid_openers.get(&(6, 100, 4)), Some(&1));
assert!(selected(&game).is_none());
close(&mut game, &mut host, 1, window);
assert!(host.hub.guest(1).unwrap().menu.is_none());
assert!(!game.lid_openers.contains_key(&(6, 100, 4)));
match selected(&game) {
Some(info) => {
assert_eq!(info.event, "minecraft:container_close");
assert_eq!(info.pos, [6.5, 100.5, 4.5]);
}
None => missing.push("single final close"),
}
reset_listener(&mut game);
let first = open(&mut game, &mut host, 1, 2);
assert_eq!(selected(&game).unwrap().event, "minecraft:container_open");
reset_listener(&mut game);
let last = open(&mut game, &mut host, 2, 1);
assert_eq!(game.lid_openers.get(&(6, 100, 4)), Some(&2));
assert!(selected(&game).is_none());
close(&mut game, &mut host, 1, first);
assert!(host.hub.guest(2).unwrap().menu.is_some());
assert_eq!(game.lid_openers.get(&(6, 100, 4)), Some(&1));
assert!(selected(&game).is_none());
close(&mut game, &mut host, 2, last);
assert!(!game.lid_openers.contains_key(&(6, 100, 4)));
match selected(&game) {
Some(info) => {
assert_eq!(info.event, "minecraft:container_close");
assert_eq!(info.pos, [6.5, 100.5, 4.5]);
}
None => missing.push("shared final close"),
}
reset_listener(&mut game);
let spectator = open(&mut game, &mut host, 3, 1);
assert!(!game.lid_openers.contains_key(&(6, 100, 4)));
assert!(selected(&game).is_none());
close(&mut game, &mut host, 3, spectator);
assert!(host.hub.guest(3).unwrap().menu.is_none());
assert!(selected(&game).is_none());
assert!(game.run_command("setblock 6 100 4 air strict"));
assert!(
game.run_command("summon oak_chest_boat 6.5 100 4.5 {UUID:[I;112,0,0,9],NoGravity:1b}")
);
let boat = game.boats.last().unwrap().id;
// Chest boats open for a sneaking guest; a plain empty-hand click boards.
host.hub
.guests
.iter_mut()
.find(|g| g.conn == 1)
.unwrap()
.sneaking = true;
game.lan_guest_packet(
&mut host,
1,
S::Interact(play::Interact {
id: boat as i32,
hand: 0,
location: [0.0; 3],
secondary: true,
}),
);
let vehicle_window = host
.hub
.guest(1)
.unwrap()
.menu
.as_ref()
.expect("actual vehicle interaction must open menu")
.window;
reset_listener(&mut game);
close(&mut game, &mut host, 1, vehicle_window);
assert!(host.hub.guest(1).unwrap().menu.is_none());
assert_eq!(selected(&game).unwrap().event, "minecraft:container_close");
assert_eq!(selected(&game).unwrap().pos, [6.5, 100.0, 4.5]);
for guest in &host.hub.guests {
assert_eq!(guest.uuid, u128::from(guest.conn) + 11200);
assert_eq!(guest.health, 20.0);
assert_eq!(guest.position, [4.5, 100.0, 4.5]);
}
assert_eq!(game.world.game_time, clock);
assert!(
missing.is_empty(),
"actual ordinary guest final close must be heard: {missing:?}"
);
drop(receivers);
}
}
+6 -4
View File
@@ -864,14 +864,16 @@ impl Game<'_> {
}
/// The areas the level in place's portal tickets keep streamed: around each
/// exit its entity-ticking chunks and their neighbours, and at least the
/// chunks its expiry waits for (`portal_ticket_can_expire`).
/// exit its block-ticking chunks and their neighbours, the ring vanilla
/// keeps `FULL` (level 33), so the outer block-ticking ring is a ticking
/// chunk too; and at least the chunks its expiry waits for
/// (`portal_ticket_can_expire`).
pub(super) fn portal_ticket_areas(&self) -> Vec<((i32, i32), i32)> {
let entity_ticking = i32::from(horizon_client::ticket_levels::ENTITY_TICKING);
let block_ticking = i32::from(horizon_client::ticket_levels::BLOCK_TICKING);
self.tickets
.portal_tickets()
.iter()
.map(|t| (t.chunk, (entity_ticking - t.level).max(1)))
.map(|t| (t.chunk, (block_ticking - t.level).max(1)))
.collect()
}
@@ -0,0 +1,104 @@
//! A mob landing on the edge of a lower block falls on the block that
//! supports it: `Entity.move` refreshes the supporting block
//! (`checkSupportingBlock`) before `checkFallDamage`, whose block is the
//! supporting one (`getOnPosLegacy`). A pig whose centre is over a honey block
//! (top 15/16) but whose box rests on the stone beside it takes the stone's
//! plain fall, not honey's fifth
//! (tools/test_mob_landing_support_parity.py measures the same on vanilla 26.3).
use super::*;
use horizon_client::chunk::Chunk;
use horizon_core::position::ChunkPos;
use std::sync::Arc;
fn game() -> Game<'static> {
let root = std::path::PathBuf::from(
std::env::var_os("HORIZON_TEST_RESOURCES")
.expect("set HORIZON_TEST_RESOURCES to prepared resources"),
);
let settings = Settings::default();
let loaded = load_resources(
root.clone(),
None,
&root,
&settings,
std::time::Instant::now(),
false,
true,
&Progress::new(),
&mut |_, _| unreachable!(),
)
.unwrap();
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
options.resources = root.clone();
options.execution_role = execution_role::ExecutionRole::IntegratedServer;
let res: &'static _ = Box::leak(Box::new(loaded.resources));
let mut game = Game::new(
res,
loaded.pack,
loaded.gui.unwrap(),
options,
settings,
Audio::silent(&root),
None,
)
.unwrap();
game.build_registries();
game.set_allow_commands(true);
game.world
.chunks
.insert((0, 0), Arc::new(Chunk::empty(0, 0)));
game.block_ticks
.add_empty_chunk(ChunkPos::new(0, 0))
.unwrap();
assert!(game.run_command("fill 0 99 0 15 99 15 stone"));
game
}
/// A pig with AI that cannot walk dropped 6 blocks at `x`, over honey at 8
/// and stone at 9; its Health, and the support it has, once it has landed.
fn drop_pig(x: f64) -> (f32, Option<[i32; 3]>, f64) {
let mut game = game();
for command in [
"forceload add 0 0".to_owned(),
"tick freeze".to_owned(),
"fill 0 98 0 15 98 15 stone".to_owned(),
"fill 0 99 0 15 110 15 air".to_owned(),
"setblock 8 99 8 honey_block".to_owned(),
"setblock 9 99 8 stone".to_owned(),
format!(
"summon pig {x} 106 8.5 {{PersistenceRequired:1b,Silent:1b,Health:10f,\
attributes:[{{id:\"minecraft:movement_speed\",base:0.0d}}],UUID:[I;68173,0,0,1]}}"
),
] {
assert!(game.run_command(&command), "{command}");
}
for _ in 0..24 {
assert!(game.run_command("tick step 1"));
game.tick_server(false).unwrap();
}
let pig = game
.mobs
.iter()
.find(|m| m.species() == horizon_client::mobs::Species::Pig)
.expect("the pig");
assert!(pig.on_ground, "the pig has landed");
(pig.health, pig.supporting, pig.position.y)
}
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn a_pig_landing_on_the_stone_beside_honey_takes_the_stones_fall() {
let (health, supporting, y) = drop_pig(8.9);
assert_eq!(y, 100.0, "the box rests on the stone's top");
assert_eq!(supporting, Some([9, 99, 8]), "the stone supports it");
assert_eq!(health, 7.0, "the plain fall of 6 blocks");
}
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn a_pig_landing_fully_on_honey_takes_a_fifth_of_the_fall() {
let (health, supporting, y) = drop_pig(8.5);
assert_eq!(y, 99.9375);
assert_eq!(supporting, Some([8, 99, 8]));
assert_eq!(health, 10.0);
}
@@ -370,6 +370,7 @@ impl Game<'_> {
self.recipe_tables.incoming.clear();
self.recipe_tables.received = None;
self.recipe_tables.tables = None;
self.recipe_ui_forget_server_entries();
self.forget_book();
}
/// `native_recipes_received` for the types that came, once the window
+9 -5
View File
@@ -50,17 +50,21 @@ pub fn open_uri(uri: &str) {
#[cfg(not(windows))]
open_folder(Path::new(uri));
}
/// The resource root to load from, given the vanilla root, the selection and
/// a server's packs, which go on top (`Pack.Position.TOP`, fixed).
/// The resource root to load from: the selection and a server's packs, which go on top
/// (`Pack.Position.TOP`, fixed), laid over `base`. `base` is the mods' content root
/// ([`super::mods::content_root`] of `vanilla`), so mod resources sit below the player's packs
/// as in vanilla, and a pack change leaves the content root's cache alone (#12403). Built-in
/// packs are found in `vanilla`.
pub fn startup_root(
vanilla: &Path,
base: &Path,
selected: &[String],
server: &[Pack],
) -> std::io::Result<PathBuf> {
let all = packs::discover(vanilla, &packs_dir());
let mut enabled = packs::enabled(&all, selected);
enabled.extend_from_slice(server);
packs::resource_root(vanilla, &enabled, &cache_dir())
packs::resource_root(base, &enabled, &cache_dir())
}
fn info(p: &Pack) -> PackInfo {
let built_in = !p.id.starts_with("file/");
@@ -496,8 +500,8 @@ pub(super) fn stage_pack(
japanese: bool,
progress: &super::Progress,
) -> horizon_client::Result<StagedPack> {
let root = startup_root(vanilla_root, selected, server)?;
let root = super::mods::content_root(&root, mods_enabled, dev_mods);
let base = super::mods::content_root(vanilla_root, mods_enabled, dev_mods);
let root = startup_root(vanilla_root, &base, selected, server)?;
progress.set(1);
// The world's models and sprites and the GUI atlas read the same root and nothing of
// each other: about a second each, side by side.
@@ -85,6 +85,10 @@ pub(super) struct RecipeUi {
client: ClientBook,
client_known: Option<BTreeSet<String>>,
client_version: u64,
/// The entries a server's packets carried, by display id: the book is
/// made with them over its own (`Book::use_server_entries`).
server_entries:
std::collections::BTreeMap<i32, horizon_protocol::packets::recipes::RecipeBookEntry>,
/// The display a click last placed (`lastRecipe`), which Enter and Space
/// place again; slot clicks keep it.
last_clicked: Option<i32>,
@@ -376,16 +380,52 @@ impl Game<'_> {
self.recipe_ui.client.displays(),
)
}
/// A new session forgets the entries the last server's packets carried.
pub(super) fn recipe_ui_forget_server_entries(&mut self) {
self.recipe_ui.server_entries.clear();
}
/// The book's model is made again from `Game::recipes` when next needed:
/// the server's recipes arrived, or a new session began.
pub(super) fn forget_book(&mut self) {
self.recipe_ui.book = None;
self.recipe_tables.categories = None;
}
/// Puts a server's entries over `book` (`Book::use_server_entries`). The
/// names come from the connection's registries; a tag's items are the ones
/// the client knows for it (the server's tags are not read).
fn server_entries_over<'a>(
&self,
book: &mut Book,
entries: impl IntoIterator<Item = &'a horizon_protocol::packets::recipes::RecipeBookEntry>,
) {
let Some(r) = self.remote.as_ref() else {
return;
};
let items = self.items();
let name_of = |registry: &str, id: i32| {
r.builtin
.get(registry)
.and_then(|ids| ids.name(id))
.map(str::to_owned)
};
let item = |id: i32| name_of("minecraft:item", id);
let category = |id: i32| name_of("minecraft:recipe_book_category", id);
let tag = |name: &str| items.item_tag_order.get(name).cloned().unwrap_or_default();
book.use_server_entries(
entries,
&horizon_client::recipe_book::PacketNames {
item: &item,
category: &category,
tag: &tag,
},
);
}
fn ensure_book(&mut self) {
if self.recipe_ui.book.is_none() {
let mut book = Book::build(self.recipes(), self.items());
book.set_display_ids(&self.stations().recipe_index);
// A server numbers and describes its recipes itself.
self.server_entries_over(&mut book, self.recipe_ui.server_entries.values());
self.recipe_ui.book = Some(book);
}
if self.recipe_ui.search.is_none() {
@@ -1299,10 +1339,12 @@ impl Game<'_> {
let ui = &mut self.recipe_ui;
if p.replace {
ui.client.clear();
ui.server_entries.clear();
}
let mut toast = Vec::new();
for e in &p.entries {
ui.client.add(e.id);
ui.server_entries.insert(e.id, e.clone());
if e.flags & 2 != 0 {
ui.client.highlight.insert(e.id);
}
@@ -1316,6 +1358,14 @@ impl Game<'_> {
// A local world opened later syncs its book from scratch.
ui.client_known = None;
ui.client_version += 1;
// A replacing packet starts the book again from its own recipes; a
// later one puts its entries over the book as it is.
if p.replace {
ui.book = None;
} else if let Some(mut book) = ui.book.take() {
self.server_entries_over(&mut book, p.entries.iter());
self.recipe_ui.book = Some(book);
}
self.recipe_toast(&toast);
}
/// ClientboundRecipeBookRemovePacket.
@@ -1325,6 +1375,7 @@ impl Game<'_> {
) {
for id in p.ids {
self.recipe_ui.client.remove(id);
self.recipe_ui.server_entries.remove(&id);
}
self.recipe_ui.client_version += 1;
}
@@ -442,6 +442,7 @@ pub(super) fn load_resources(
}
let tables = Arc::new(startup::time("pack.mesh_tables", || {
MeshTables::new(&assets, &render, &facts, visibility)
.map(|t| t.with_polytone_colormaps(&root, assets.registry))
})?);
let pack_step = startup::step("pack.assemble");
let pack = PackResources::assemble(
@@ -137,7 +137,10 @@ impl PackResources {
light: Arc<LightTables>,
) -> Result<Self> {
let visibility = models::load_visibility(root, assets.registry)?;
let tables = Arc::new(MeshTables::new(&assets, &render, facts, visibility)?);
let tables = Arc::new(
MeshTables::new(&assets, &render, facts, visibility)?
.with_polytone_colormaps(root, assets.registry),
);
let item_renderer = horizon_client::item_render::ItemRenderer::load_with_library(
root,
render.model_library.clone(),
@@ -0,0 +1,172 @@
//! A burnt-out armor stand rolls its entity loot table: `ArmorStand.causeDamage`
//! at 0.5 health or below calls `brokenByAnything`, whose `dropAllDeathLoot`
//! rolls `minecraft:entities/armor_stand` (a stand that is not small, with
//! `mob_drops` on) before the equipment pops
//! (tools/test_stand_break_loot_parity.py measures the same on vanilla 26.3).
use super::*;
use horizon_client::chunk::Chunk;
use horizon_core::position::ChunkPos;
use std::sync::Arc;
fn game() -> Game<'static> {
let root = std::path::PathBuf::from(
std::env::var_os("HORIZON_TEST_RESOURCES")
.expect("set HORIZON_TEST_RESOURCES to prepared resources"),
);
let settings = Settings::default();
let loaded = load_resources(
root.clone(),
None,
&root,
&settings,
std::time::Instant::now(),
false,
true,
&Progress::new(),
&mut |_, _| unreachable!(),
)
.unwrap();
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
options.resources = root.clone();
options.execution_role = execution_role::ExecutionRole::IntegratedServer;
let res: &'static _ = Box::leak(Box::new(loaded.resources));
let mut game = Game::new(
res,
loaded.pack,
loaded.gui.unwrap(),
options,
settings,
Audio::silent(&root),
None,
)
.unwrap();
game.build_registries();
game.set_allow_commands(true);
game.world
.chunks
.insert((0, 0), Arc::new(Chunk::empty(0, 0)));
game.block_ticks
.add_empty_chunk(ChunkPos::new(0, 0))
.unwrap();
assert!(game.run_command("fill 0 99 0 15 99 15 stone"));
game
}
/// The world pack's table for armor stands: one stick.
fn with_stick_table(game: &mut Game<'_>, name: &str) {
let dir =
std::env::temp_dir().join(format!("horizon-stand-loot-{}-{name}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let tables = dir.join("datapacks/stand/data/minecraft/loot_table/entities");
std::fs::create_dir_all(&tables).unwrap();
std::fs::write(
dir.join("datapacks/stand/pack.mcmeta"),
r#"{"pack":{"description":"fixture"}}"#,
)
.unwrap();
std::fs::write(
tables.join("armor_stand.json"),
r#"{"type":"minecraft:entity","pools":[{"rolls":1,"entries":[{"type":"minecraft:item","name":"minecraft:stick"}]}]}"#,
)
.unwrap();
std::fs::write(
dir.join("datapacks.json"),
r#"{"enabled":["file/stand"],"disabled":[]}"#,
)
.unwrap();
game.world_root = dir;
game.world_tables = game.build_world_tables(false, &[]).unwrap().0;
// World open warms the loot data with the world's packs (game.rs); the
// break's roll reads that copy.
let _ = horizon_client::structure_blocks::world_loot_data(
&game.options.resources,
&game.pack_files(),
);
// The table the pack installed: a direct roll, far from the stand.
assert!(game.run_command("loot spawn 12.5 100 12.5 loot minecraft:entities/armor_stand"));
assert!(
game.run_command(
"execute if entity @e[type=item,x=12.5,y=100,z=12.5,distance=..1,nbt={Item:{id:\"minecraft:stick\"}}]"
),
"the pack's table is the stand's"
);
assert!(game.run_command("kill @e[type=item]"));
}
fn burn(game: &mut Game<'_>, small: bool) {
let small = if small { ",Small:1b" } else { "" };
assert!(game.run_command(&format!(
"summon armor_stand 2.5 100 8.5 {{NoGravity:1b,Fire:200s,Health:0.6f,UUID:[I;23326,0,0,1]{small},\
equipment:{{head:{{id:\"minecraft:diamond_helmet\",count:1}}}}}}"
)));
// Stands tick only in entity-ticking chunks.
assert!(game.run_command("forceload add 0 0"));
assert!(game.run_command("tick freeze"));
assert!(game.run_command("tick step 1"));
game.tick_server(false).unwrap();
assert!(
!game.run_command("execute if entity 00005b1e-0000-0000-0000-000000000001"),
"the fire breaks the stand"
);
assert!(
game.run_command(
"execute if entity @e[type=item,nbt={Item:{id:\"minecraft:diamond_helmet\"}}]"
),
"its helmet pops"
);
}
const STICK: &str = "execute if entity @e[type=item,x=2.5,y=100,z=8.5,distance=..3,nbt={Item:{id:\"minecraft:stick\"}}]";
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn a_burnt_out_stand_drops_its_loot_table() {
let mut game = game();
with_stick_table(&mut game, "normal");
burn(&mut game, false);
assert!(game.run_command(STICK), "the table's stick drops");
}
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn a_small_burnt_out_stand_drops_no_loot() {
// ArmorStand.isBaby is isSmall: LivingEntity.shouldDropLoot is false.
let mut game = game();
with_stick_table(&mut game, "small");
burn(&mut game, true);
assert!(!game.run_command(STICK));
}
// Controls added after the baseline: the roll is gated by mob_drops, and one
// break rolls the table once.
fn sticks(game: &mut Game<'_>) -> String {
game.chat_capture = Some(Vec::new());
game.run_command(STICK);
let said = game.chat_capture.take().unwrap_or_default();
said.iter().map(horizon_client::text::Text::plain).collect()
}
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn control_mob_drops_off_rolls_nothing() {
let mut game = game();
with_stick_table(&mut game, "nodrops");
assert!(game.run_command("gamerule mob_drops false"));
burn(&mut game, false);
assert!(!game.run_command(STICK));
}
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn control_one_break_rolls_the_table_once() {
let mut game = game();
with_stick_table(&mut game, "once");
burn(&mut game, false);
assert_eq!(sticks(&mut game), "Test passed. Count: 1");
for _ in 0..20 {
assert!(game.run_command("tick step 1"));
game.tick_server(false).unwrap();
}
assert_eq!(sticks(&mut game), "Test passed. Count: 1");
}
@@ -372,12 +372,12 @@ pub(super) fn spawn_loader(
.name("resource-loader".into())
.spawn(move || {
let mut pack_error = None;
let root = packs::startup_root(&vanilla_root, &settings.resource_packs, &[])
let base = mods::content_root(&vanilla_root, &settings.mods.enabled, &dev_mods);
let root = packs::startup_root(&vanilla_root, &base, &settings.resource_packs, &[])
.unwrap_or_else(|error| {
pack_error = Some(error.to_string());
vanilla_root.clone()
base.clone()
});
let root = mods::content_root(&root, &settings.mods.enabled, &dev_mods);
let result = load_resources(
root,
world.as_deref(),
+21 -1
View File
@@ -212,13 +212,33 @@ impl Game<'_> {
/// `SulfurCube.primeWhenOnPoweredPosition`: an explosive cube standing in
/// or next to redstone power lights its fuse (`getBestOwnOrNeighbourSignal`;
/// no block has an own signal, so the neighbour signal is all there is).
pub(super) fn sulfur_cube_power_priming(&mut self) {
/// The resident sulfur cubes whose adopted body (`adopted_body`, the
/// body `collectEquipmentChanges` last took, so its `explosionData`)
/// explodes: those `primeWhenOnPoweredPosition` may prime this tick.
pub(super) fn sulfur_cubes_adopted_explosive(&self) -> Vec<u32> {
self.mobs
.iter()
.filter(|m| {
m.species() == Species::SulfurCube
&& m.extra
.adopted_body
.as_deref()
.and_then(sulfur_cube::archetype_of)
.is_some_and(|a| a.explosion.is_some())
})
.map(|m| m.id)
.collect()
}
/// `primeWhenOnPoweredPosition` for the cubes `primable` names (an
/// adopted explosive body when the tick started).
pub(super) fn sulfur_cube_power_priming(&mut self, primable: &[u32]) {
if !self.ui.rules.bool("tnt_explodes") {
return;
}
for i in 0..self.mobs.len() {
let m = &self.mobs[i];
if m.species() != Species::SulfurCube
|| !primable.contains(&m.id)
|| !sulfur_cube::archetype(m).is_some_and(|a| a.explosion.is_some())
|| !m.alive()
|| sulfur_cube::primed(m)
@@ -0,0 +1,146 @@
//! A loaded sulfur cube's body primes it one tick after it is loaded (H249):
//! `SulfurCube.tick` checks the power (`primeWhenOnPoweredPosition`, which
//! needs the body's `explosionData`) before `super.tick()` adopts the body
//! (`detectEquipmentUpdates` → `collectEquipmentChanges`), so a cube loaded
//! with a TNT body on a redstone block primes on its second tick
//! (tools/test_sulfur_prime_phase_parity.py measures the same on vanilla 26.3).
use super::*;
use horizon_client::chunk::Chunk;
use horizon_core::position::ChunkPos;
use std::sync::Arc;
fn game() -> Game<'static> {
let root = std::path::PathBuf::from(
std::env::var_os("HORIZON_TEST_RESOURCES")
.expect("set HORIZON_TEST_RESOURCES to prepared resources"),
);
let settings = Settings::default();
let loaded = load_resources(
root.clone(),
None,
&root,
&settings,
std::time::Instant::now(),
false,
true,
&Progress::new(),
&mut |_, _| unreachable!(),
)
.unwrap();
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
options.resources = root.clone();
options.execution_role = execution_role::ExecutionRole::IntegratedServer;
let res: &'static _ = Box::leak(Box::new(loaded.resources));
let mut game = Game::new(
res,
loaded.pack,
loaded.gui.unwrap(),
options,
settings,
Audio::silent(&root),
None,
)
.unwrap();
game.build_registries();
game.set_allow_commands(true);
game.world
.chunks
.insert((0, 0), Arc::new(Chunk::empty(0, 0)));
game.block_ticks
.add_empty_chunk(ChunkPos::new(0, 0))
.unwrap();
assert!(game.run_command("fill 0 99 0 15 99 15 stone"));
game
}
fn fuse(game: &Game<'_>) -> i32 {
let cube = game
.mobs
.iter()
.find(|m| m.species() == horizon_client::mobs::Species::SulfurCube)
.expect("the cube");
horizon_client::mobs::sulfur_cube::fuse(cube)
}
fn step(game: &mut Game<'_>) {
assert!(game.run_command("tick step 1"));
game.tick_server(false).unwrap();
}
/// A NoAI cube with a TNT body summoned at 4.5 100 8.5, on `under`.
fn cube_on(game: &mut Game<'_>, under: &str) {
assert!(game.run_command("forceload add 0 0"));
assert!(game.run_command("fill 0 98 0 15 99 15 stone"));
if under != "stone" {
assert!(game.run_command(&format!("setblock 4 99 8 {under}")));
}
for command in [
"tick freeze".to_owned(),
"summon sulfur_cube 4.5 100 8.5 {NoAI:1b,Silent:1b,UUID:[I;45641,0,0,1],\
equipment:{body:{id:\"minecraft:tnt\",count:1}}}"
.to_owned(),
] {
assert!(game.run_command(&command), "{command}");
}
assert_eq!(fuse(game), -1, "not primed when loaded");
}
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn a_cube_loaded_with_a_tnt_body_on_power_primes_on_its_second_tick() {
let mut game = game();
cube_on(&mut game, "redstone_block");
step(&mut game);
assert_eq!(fuse(&game), -1, "its body is adopted after the power check");
step(&mut game);
assert_eq!(fuse(&game), 120);
step(&mut game);
assert_eq!(fuse(&game), 119);
}
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn a_warmed_cube_primes_on_the_first_powered_tick() {
let mut game = game();
cube_on(&mut game, "stone");
for _ in 0..5 {
step(&mut game);
}
assert_eq!(fuse(&game), -1, "no power");
assert!(game.run_command("setblock 5 100 8 redstone_block"));
step(&mut game);
assert_eq!(fuse(&game), 120);
}
// Controls added after the baseline: the old pass's gates still hold for the
// cubes whose body was adopted when the tick started.
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn control_an_adopted_cube_without_power_stays_unprimed() {
let mut game = game();
cube_on(&mut game, "stone");
for _ in 0..10 {
step(&mut game);
}
assert_eq!(fuse(&game), -1);
}
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn control_a_primed_cube_on_power_is_not_primed_again() {
let mut game = game();
assert!(game.run_command("forceload add 0 0"));
assert!(game.run_command("fill 0 98 0 15 99 15 stone"));
assert!(game.run_command("setblock 4 99 8 redstone_block"));
assert!(game.run_command("tick freeze"));
assert!(game.run_command(
"summon sulfur_cube 4.5 100 8.5 {NoAI:1b,Silent:1b,fuse:50,UUID:[I;45641,0,0,1],\
equipment:{body:{id:\"minecraft:tnt\",count:1}}}"
));
assert_eq!(fuse(&game), 50);
for expected in [49, 48, 47] {
step(&mut game);
assert_eq!(fuse(&game), expected, "the countdown goes on, no new fuse");
}
}
@@ -0,0 +1,240 @@
//! Exercise the server's attack entry points with real enchantment data.
use super::*;
use crate::game::*;
use horizon_client::{
mobs::{Damage, DamageKind, Mob, MobEvent, Who, finalize as f},
projectiles::{Hit, Projectile, ProjectileKind},
};
fn with_game(test: impl FnOnce(&mut Game<'_>)) {
let root = PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES").unwrap());
let settings = Settings::default();
let loaded = load_resources(
root.clone(),
None,
&root,
&settings,
Instant::now(),
false,
true,
&Progress::new(),
&mut |_, _| unreachable!(),
)
.unwrap();
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
options.resources = root.clone();
options.execution_role = execution_role::ExecutionRole::IntegratedServer;
let mut g = Game::new(
&loaded.resources,
loaded.pack,
loaded.gui.unwrap(),
options,
settings,
Audio::silent(&root),
None,
)
.unwrap();
g.options.dedicated = Some(Vec::new());
g.world = World::new(g.res.facts().air, 1);
g.player = Some(PlayerMotion::new(g.motion, Vec3::new(30.0, 64.0, 0.5)).unwrap());
g.player.as_mut().unwrap().entity_id = 77;
g.audio.capture = Some(Vec::new());
test(&mut g);
}
fn thorny() -> ItemStack {
let mut stack = ItemStack::new("minecraft:diamond_chestplate", 1);
stack.set_crafting_enchantments(vec![("minecraft:thorns".into(), 255)]);
stack
}
fn pair(g: &mut Game<'_>, slot: usize) -> Damage {
g.mobs.clear();
g.audio.capture.as_mut().unwrap().clear();
let kind = g.res.mob_types().get("minecraft:husk").unwrap().clone();
for (id, x) in [(41, 10.5), (42, 0.5)] {
let mut m = Mob::new(id, kind.clone(), Vec3::new(x, 64.0, 0.5), 0.0);
m.health = 100.0;
g.mobs.push(m);
}
g.mobs[0].random = LegacyRandom::new(1);
f::set_item_stack(&mut g.mobs[0], f::MAINHAND, thorny()); // Retaliation must not recurse.
f::set_item_stack(&mut g.mobs[1], slot, thorny());
Damage::mob(41, [10.5, 64.0, 0.5])
}
fn check_answer(g: &Game<'_>, slot: usize, answers: bool) {
let health = g.mobs[0].health;
assert_eq!(health < 100.0, answers, "attacker health: {health}");
assert_eq!(
f::item_stack(&g.mobs[1], slot).unwrap().damage,
if answers { 2 } else { 0 }
);
assert_eq!(f::item_stack(&g.mobs[0], f::MAINHAND).unwrap().damage, 0);
let sounds: Vec<_> = g
.audio
.capture
.as_ref()
.unwrap()
.iter()
.filter(|s| s.event == "minecraft:enchant.thorns.hit")
.collect();
assert_eq!(sounds.len(), usize::from(answers));
if answers {
assert!((95.0..=99.0).contains(&health));
let source = g.mobs[0].last_damage.unwrap().0;
assert_eq!(source.kind, DamageKind::Thorns);
assert_eq!(source.attacker, Some(Who::Mob(42)));
assert_eq!(sounds[0].position, g.mobs[0].center());
}
}
#[test]
#[ignore = "requires HORIZON_TEST_RESOURCES and fresh Horizon homes"]
fn mob_thorns_melee_projectiles_and_non_attacks() {
with_game(|g| {
for slot in [f::MAINHAND, f::OFFHAND, f::FEET, f::LEGS, f::CHEST, f::HEAD] {
let source = pair(g, slot);
g.handle_mob_events(vec![MobEvent::AttackMob {
target: 42,
amount: 1.0,
source,
}])
.unwrap();
check_answer(g, slot, true);
assert_eq!(g.mobs[0].health, 96.139_27); // Java Random(1), then the husk's 2 armor.
}
for case in 0..5 {
let source = pair(g, f::CHEST);
let event = match case {
0 => MobEvent::AttackMobWithEffect {
target: 42,
amount: 1.0,
source,
effect: "minecraft:poison",
ticks: 20,
amplifier: 0,
},
1 => MobEvent::CubeHurtMob {
cube: 41,
target: 42,
amount: 1.0,
source,
},
2 => MobEvent::IronGolemHit {
target: Who::Mob(42),
amount: 1.0,
source,
},
3 => MobEvent::DamageMob {
target: 42,
amount: 1.0,
source,
},
_ => {
g.mobs[1].invulnerable = 20;
g.mobs[1].last_hurt = 10.0;
MobEvent::AttackMob {
target: 42,
amount: 1.0,
source,
}
}
};
g.handle_mob_events(vec![event]).unwrap();
check_answer(g, f::CHEST, case < 3);
}
for (kind, answers) in [
(
ProjectileKind::Arrow {
base_damage: 2.0,
critical: false,
effect: None,
},
true,
),
(ProjectileKind::Trident, true),
(ProjectileKind::SmallFireball, true),
(ProjectileKind::LargeFireball { power: 0 }, true),
(ProjectileKind::LlamaSpit, true),
(
ProjectileKind::ShulkerBullet {
target: None,
dir: None,
steps: 0,
delta: [0.0; 3],
seed: 1,
},
true,
),
(ProjectileKind::WitherSkull { dangerous: false }, true),
(ProjectileKind::WindCharge, true),
(ProjectileKind::BreezeWindCharge, true),
(ProjectileKind::Snowball, false),
(ProjectileKind::Egg, false),
] {
pair(g, f::CHEST);
let mut p = Projectile::new(kind, g.mobs[1].position, Some(Who::Mob(41)));
p.velocity = Vec3::new(-1.0, 0.0, 0.0);
g.projectile_hit(
&p,
Hit::Entity {
who: Who::Mob(42),
at: p.position,
},
)
.unwrap();
check_answer(g, f::CHEST, answers);
}
pair(g, f::CHEST);
g.insert_fangs(100, [0.5, 64.0, 0.5], 0.0, -7, 41);
g.tick_fangs();
check_answer(g, f::CHEST, true);
});
}
#[test]
#[ignore = "requires HORIZON_TEST_RESOURCES and fresh Horizon homes"]
fn dragon_takes_player_thorns_through_its_body() {
with_game(|g| {
for wing in [false, true] {
g.player = Some(PlayerMotion::new(g.motion, Vec3::new(30.0, 64.0, 0.5)).unwrap());
g.player.as_mut().unwrap().entity_id = 77;
g.armor[1] = Some(thorny());
let mut d = Dragon::new(Vec3::new(0.0, 70.0, 0.0), 0.0, 1);
d.id = SPECIAL;
g.dragon = Some(d);
g.audio.capture.as_mut().unwrap().clear();
if wing {
g.dragon_wing_knock_back(Vec3::ZERO, false);
} else {
g.dragon_hurts_player("minecraft:mob_attack", 10.0);
}
let d = g.dragon.as_ref().unwrap();
// DamageEntity rolls [1,5); the body's cut is damage/4 + min(damage,1).
assert_eq!(d.health, 198.019_12);
assert_eq!(
d.last_source,
Some(("minecraft:thorns", Some(77), Some(77)))
);
assert_eq!(d.hurt_by_player, Some((FightWho::Host, 0)));
assert_eq!(g.armor[1].as_ref().unwrap().damage, 2);
assert_eq!(
g.audio
.capture
.as_ref()
.unwrap()
.iter()
.filter(|s| s.event == "minecraft:enchant.thorns.hit")
.count(),
1
);
}
// Head contacts run effects through a refused player hit; breath does not.
g.dragon.as_mut().unwrap().invulnerable = 0;
g.player.as_mut().unwrap().health.advance_clock();
let health = g.player.as_ref().unwrap().health.health();
g.dragon_hurts_player("minecraft:mob_attack", 1.0);
assert_eq!(g.player.as_ref().unwrap().health.health(), health);
assert_eq!(g.armor[1].as_ref().unwrap().damage, 4);
let before = g.dragon.as_ref().unwrap().health;
g.dragon_hurts_player("minecraft:indirect_magic", 1.0);
assert_eq!(g.dragon.as_ref().unwrap().health, before);
assert_eq!(g.armor[1].as_ref().unwrap().damage, 4);
});
}
+1 -2
View File
@@ -175,8 +175,7 @@ impl Game<'_> {
self.last_hurt_by_player.insert(id, self.lan_acting);
}
if hurt {
let charge = "minecraft:wind_charge";
self.shot_post_attack(p, who, charge, charge);
self.shot_post_attack(p, who, p.kind.id(), "minecraft:wind_charge");
}
}
}
+19 -3
View File
@@ -200,6 +200,7 @@ impl Game<'_> {
from: skull.from,
attacker_species: owner_species,
};
let mut survived_hit = false;
let killed = match self.mobs.iter_mut().find(|m| m.id == id) {
// `WitherBoss.hurtServer`: nothing from a wither friend.
Some(m)
@@ -211,13 +212,28 @@ impl Game<'_> {
}
Some(m) => {
let hit = m.damage(amount, source);
if hit && m.alive() && m.species() != Species::Wither && wither_ticks > 0 {
m.add_effect("minecraft:wither", wither_ticks, 1);
}
survived_hit = hit && m.alive();
hit && !m.alive()
}
None => false,
};
if survived_hit && live_owner.is_some() {
self.mob_post_attack(
id,
live_owner,
Some("minecraft:wither_skull"),
Some("minecraft:wither_skull"),
);
}
if survived_hit
&& wither_ticks > 0
&& let Some(m) = self
.mobs
.iter_mut()
.find(|m| m.id == id && m.species() != Species::Wither)
{
m.add_effect("minecraft:wither", wither_ticks, 1);
}
if killed {
self.heal_skull_owner(p, live_owner);
}
@@ -1146,4 +1146,81 @@ mod tests {
assert_eq!(experience, 0.7);
let _ = std::fs::remove_dir_all(&root);
}
/// Every recipe the game has, as a Horizon server sends it to a Horizon
/// client: the client's own book keeps its entries, none is taken from the
/// packet (a regression of parity with Horizon on both sides otherwise).
/// Needs the prepared resources (`HORIZON_TEST_RESOURCES`, or the checkout's
/// `.local`); passes without them.
#[test]
fn a_horizon_server_of_the_real_recipes_leaves_the_clients_book_as_it_is() {
use crate::{
crafting::Recipes,
items::Items,
recipe_book::{Book, PacketNames, RecipeIndex},
};
let root = std::env::var_os("HORIZON_TEST_RESOURCES")
.map(PathBuf::from)
.unwrap_or_else(|| {
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client")
.join(horizon_core::VANILLA_VERSION)
});
if !root.join("registries.json").exists() {
return;
}
let none = &horizon_world::loot::PackOverlay::NONE;
let sync = SyncData::load(&root, none).unwrap();
let items = Items::catalog(&root, none).unwrap();
let recipes = Recipes::load(&root, &items, none).unwrap();
let mut book = Book::build(&recipes, &items);
book.set_display_ids(&RecipeIndex::load(&root, none));
let before: Vec<(String, i32, Option<String>)> = book
.entries
.iter()
.map(|e| (e.id.clone(), e.display_id, e.group.clone()))
.collect();
let mut sent = Vec::new();
for id in sync.advancements.recipes.keys() {
for bytes in sync.advancements.book_entries(id, &sync, 0) {
sent.push(RecipeBookEntry::read(&mut Reader::new(&bytes)).unwrap());
}
}
assert!(sent.len() > 1000, "{} entries sent", sent.len());
let item = |id: i32| {
sync.builtin
.get("minecraft:item")
.and_then(|i| i.name(id))
.map(str::to_owned)
};
let category = |id: i32| {
sync.builtin
.get("minecraft:recipe_book_category")
.and_then(|i| i.name(id))
.map(str::to_owned)
};
let tag = |name: &str| items.item_tag_order.get(name).cloned().unwrap_or_default();
book.use_server_entries(
&sent,
&PacketNames {
item: &item,
category: &category,
tag: &tag,
},
);
let after: Vec<(String, i32, Option<String>)> = book
.entries
.iter()
.map(|e| (e.id.clone(), e.display_id, e.group.clone()))
.collect();
let changed: Vec<_> = before
.iter()
.zip(&after)
.filter(|(b, a)| b != a)
.take(10)
.collect();
assert_eq!(after.len(), before.len(), "entries were added");
assert!(changed.is_empty(), "entries changed: {changed:?}");
assert!(book.server_requirements.is_empty());
}
}
+57 -1
View File
@@ -5141,7 +5141,9 @@ impl Hub {
g.sent_xp = None;
g.sent_air = (300, 0.0);
g.sent_frozen = 0;
g.sent_shared = 0;
// Not the shared flags: KEEP_ALL_DATA copies the old player's (on fire, invisible,
// crouching...) to the client's new one, so a flag that clears after the change still
// goes to it (ServerEntity.sendDirtyEntityData), as a fire put out in the new level.
g.sent_shoulders = [None; 2];
let (cx, cz) = g.chunk();
g.send(&C::SetChunkCacheCenter(play::SetChunkCacheCenter {
@@ -5624,6 +5626,60 @@ mod tests {
);
}
/// `ClientboundRespawnPacket.KEEP_ALL_DATA`: a level change keeps the client's copy of the
/// player's shared flags, so one that clears afterwards still reaches it. (A creative player
/// who leaves lava through a dimension change kept the fire on its screen.)
#[test]
fn a_shared_flag_cleared_after_a_level_change_reaches_the_player() {
let (mut hub, rx) = hub_with_a_guest();
let shared_flags_sent = |rx: &std::sync::mpsc::Receiver<crate::lan::Out>| {
let mut sent = Vec::new();
while let Ok(out) = rx.try_recv() {
let crate::lan::Out::Packet(body) = out else {
continue;
};
let mut r = horizon_protocol::buf::Reader::new(&body);
let id = r.varint().unwrap();
if let Ok(C::SetEntityData(packet)) = C::decode(id, r.rest()) {
sent.extend(packet.values.iter().filter_map(|(index, _, value)| {
match (index, value) {
(0, Value::Byte(flags)) => Some(*flags),
_ => None,
}
}));
}
}
sent
};
hub.guests[0].sneaking = true;
hub.sync_player_state(0);
assert_eq!(shared_flags_sent(&rx), [0x02]);
let mut world = World::new(horizon_world::registry::BlockStateId(0), 0);
let mut next_entity = 2;
let border = crate::worldborder::WorldBorder::default();
let ctx = TickCtx {
world: &mut world,
entities: &[],
next_entity: &mut next_entity,
spawn: [0.5, 64.0, 0.5],
spawn_angle: [0.0; 2],
difficulty: 2,
difficulty_locked: false,
clock_rate: 1.0,
tick_rate: 20.0,
tick_frozen: false,
tick_steps: Default::default(),
collision: None,
border: &border,
streaming: None,
};
hub.change_level(0, [100.5, 49.0, 0.5], 0.0, None, false, 0, &ctx);
let _ = shared_flags_sent(&rx);
hub.guests[0].sneaking = false;
hub.sync_player_state(0);
assert_eq!(shared_flags_sent(&rx), [0]);
}
/// `ServerGamePacketListenerImpl.forceSendPlayerSupportBlocks`: a client that says it stands
/// while the last move the server resolved came down onto nothing is sent the blocks under
/// its box again (its four corners, each once), so a client that missed a block change cannot
+8 -1
View File
@@ -1962,7 +1962,14 @@ pub enum MobEvent {
ambient: Vec<&'static str>,
color: u32,
},
/// Melee or ranged damage to another mob.
/// Damage that bypasses `EnchantmentHelper.doPostAttackEffects` (roars,
/// beams, retaliation and hot-cube contact).
DamageMob {
target: u32,
amount: f32,
source: Damage,
},
/// An attack that runs post-attack effects when the hit lands.
AttackMob {
target: u32,
amount: f32,
@@ -17,8 +17,15 @@ pub struct Frame {
/// Wings push even while perched and even when teams forbid damage.
/// The normal mob damage handler owns armor, invulnerability frames and last-attacker bookkeeping.
pub fn contact(m: &mut Mob, f: &Frame, teams_allow: bool) {
for part in 0..4 {
contact_part(m, f, teams_allow, part);
}
}
/// `EnderDragon.knockBack` / `hurt`: one wing or head contact; true when it runs post-attack effects,
/// even if the hurt cooldown refused the damage.
pub fn contact_part(m: &mut Mob, f: &Frame, teams_allow: bool, part: usize) -> bool {
if f.recently_hurt || m.removed || m.id == f.dragon_id {
return;
return false;
}
let bounds = m.bounds();
let source = Damage {
@@ -27,8 +34,8 @@ pub fn contact(m: &mut Mob, f: &Frame, teams_allow: bool) {
from: Some([f.origin.x, f.origin.y, f.origin.z]),
attacker_species: None,
};
for wing in &f.wings {
if wing.intersects(bounds) {
if part < 2 {
if f.wings[part].intersects(bounds) {
let dx = m.position.x - f.center.0;
let dz = m.position.z - f.center.1;
let dd = (dx * dx + dz * dz).max(0.1);
@@ -40,16 +47,14 @@ pub fn contact(m: &mut Mob, f: &Frame, teams_allow: bool) {
&& m.ai.last_hurt_by_time < (m.age as i32).saturating_sub(2)
{
m.damage(5.0, source);
return true;
}
}
} else if teams_allow && f.heads[part - 2].intersects(bounds) {
m.damage(10.0, source);
return true;
}
if teams_allow {
for head in &f.heads {
if head.intersects(bounds) {
m.damage(10.0, source);
}
}
}
false
}
#[cfg(test)]
+43 -2
View File
@@ -764,8 +764,10 @@ impl Mob {
match who {
Who::Player => self.spares_players(w),
Who::Mob(id) => {
self.species() == Species::IronGolem
&& w.seen(id).is_some_and(|s| s.species == Species::Creeper)
// `Mob.canAttack` refuses a Ghast for every mob.
w.seen(id).is_some_and(|s| s.species == Species::Ghast)
|| (self.species() == Species::IronGolem
&& w.seen(id).is_some_and(|s| s.species == Species::Creeper))
}
}
}
@@ -3277,6 +3279,45 @@ mod peaceful_targets_tests {
assert!(!wolf.can_keep_target_player(&peaceful, 16.0), "peaceful");
});
}
/// `Mob.canAttack` refuses a Ghast, and `HurtByTargetGoal.canUse` ends
/// with `canAttack(lastHurtByMob)`: a ghast's fireball is not retaliated.
#[test]
fn a_hurt_mob_does_not_retaliate_against_a_ghast() {
let mut seen = None;
with_test_world(&[], |_, types| {
let mob = |id: u32, name: &str, at: Vec3| {
Mob::new(id, Arc::new(types.types[name].clone()), at, 0.0)
};
let ghast = mob(2, "minecraft:ghast", Vec3::new(2.5, 64.0, 0.5));
let zombie = mob(3, "minecraft:zombie", Vec3::new(3.5, 64.0, 0.5));
seen = Some([ghast.seen(None), zombie.seen(None)]);
});
let Some(seen) = seen else { return };
with_test_world(&seen, |w, types| {
let kind = Arc::new(types.types["minecraft:cow"].clone());
let mut m = Mob::new(1, kind, Vec3::new(0.5, 64.0, 0.5), 0.0);
let mut lr = LegacyRandom::new(0);
let mut events = Vec::new();
let mut cx = Cx {
w,
lr: &mut lr,
events: &mut events,
};
m.ai.last_hurt_by = Some(Who::Mob(2));
m.ai.last_hurt_by_time = 5;
assert!(
!HurtByTarget::default().can_use(&mut m, &mut cx),
"a ghast is not a valid attacker to retaliate against"
);
m.ai.last_hurt_by = Some(Who::Mob(3));
m.ai.last_hurt_by_time = 6;
assert!(
HurtByTarget::default().can_use(&mut m, &mut cx),
"a zombie attacker is retaliated against"
);
});
}
}
#[cfg(test)]
mod move_to_block_tests {
+2 -2
View File
@@ -133,7 +133,7 @@ impl Goal for GuardianAttack {
from,
kind: "minecraft:indirect_magic",
}),
Who::Mob(id) => cx.events.push(MobEvent::AttackMob {
Who::Mob(id) => cx.events.push(MobEvent::DamageMob {
target: id,
amount: magic,
source: Damage {
@@ -307,7 +307,7 @@ pub fn ai_step(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
from,
kind: "minecraft:thorns",
}),
Some(Who::Mob(id)) => events.push(MobEvent::AttackMob {
Some(Who::Mob(id)) => events.push(MobEvent::DamageMob {
target: id,
amount: 2.0,
source: Damage {
+6 -3
View File
@@ -740,6 +740,9 @@ impl Mob {
let vertical = wanted.y != moved.y;
self.vertical_collision_below = vertical && wanted.y < 0.0;
self.on_ground = self.vertical_collision_below;
// Entity.setOnGroundWithMovement's checkSupportingBlock, before
// checkFallDamage reads the landing block (getOnPosLegacy).
self.supporting = self.supporting_block(w, moved);
// Strider.checkFallDamage: lava breaks any fall, and the fluid state
// waits for the next baseTick; Bat and Bee.checkFallDamage do nothing.
if self.kind.flags.lava_walker && self.fluids.in_lava {
@@ -778,8 +781,6 @@ impl Mob {
input,
);
}
// Entity.checkSupportingBlock
self.supporting = self.supporting_block(w, moved);
self.movement_emission(w, moved, events);
// Entity.move's last step: soul sand, honey and the like slow the
// horizontal speed of whatever the mob stands in or on.
@@ -1518,7 +1519,9 @@ impl Mob {
self.move_relative(speed, input);
self.do_move(w, events);
let mut m = self.velocity;
if self.horizontal_collision && self.kind.flags.climber {
// LivingEntity.travelInWater: `horizontalCollision && onClimbable()`,
// a ladder or scaffolding under water as well as a climbing spider.
if self.horizontal_collision && self.on_climbable(w) {
m.y = 0.2;
}
m = Vec3::new(
+1 -1
View File
@@ -254,7 +254,7 @@ fn roar(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
Species::Pillager | Species::Vindicator | Species::Evoker | Species::Illusioner
);
if !illager {
events.push(MobEvent::AttackMob {
events.push(MobEvent::DamageMob {
target: s.id,
amount: 6.0,
source: Damage::mob(m.id, from),
@@ -1182,7 +1182,7 @@ pub fn mob_contacts(m: &Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
&& other.bounds.intersects(bounds)
&& crate::scoreboard::pushable_by(own_team, w.teams.of(super::Who::Mob(other.id)), true)
{
events.push(MobEvent::AttackMob {
events.push(MobEvent::DamageMob {
target: other.id,
amount,
source: Damage::of(DamageKind::SulfurCubeHot),
@@ -1781,7 +1781,7 @@ mod mob_contact_tests {
let hits: Vec<_> = events
.iter()
.filter_map(|event| match event {
MobEvent::AttackMob {
MobEvent::DamageMob {
target,
amount,
source,
+1 -1
View File
@@ -45,7 +45,7 @@ pub use crate::gamerules::Value as GameRuleValue;
pub use crate::gfx::WorldHold;
pub use crate::gfx::native_render::{
BlockSpriteArrays, RenderHooks, RenderNeeds, RenderNeedsEvent, WorldDrawn, WorldDrawnEvent,
wgpu,
WorldWarp, WorldWarpEvent, wgpu,
};
pub use crate::hud::HudBatch;
pub use crate::hud_layout::{GuiDraw, HealthHud, HealthInputs};
+76 -27
View File
@@ -18,7 +18,7 @@ use crate::{Result, blocks::MAP_COLORS};
use horizon_core::geometry::{Aabb, Axis, ShapeOp, Vec3, VoxelShape};
use horizon_mods::content::{
Block, BlockShapes, Content, CreativeTab, EntityType, MenuType, MobEffect, Rarity,
StateProperty,
StateProperty, StateValues,
};
use horizon_world::nbt::{self, Compound, JavaString, Limits, Tag};
use serde::{Deserialize, Serialize, de};
@@ -536,17 +536,17 @@ fn encode_shape(shape: &VoxelShape) -> Tag {
Tag::Compound(c)
}
fn resolve_shapes<'a>(
spec: &BlockShapes,
fn resolve_state_values<'a, 's, T>(
spec: &'s StateValues<T>,
block: &str,
states: &Range<u32>,
order: &[(u32, u32)],
combos: &[Vec<(String, String)>],
properties: impl Fn(u32) -> Option<&'a J>,
) -> Result<Vec<VoxelShape>> {
) -> Result<Vec<&'s T>> {
let names = spec.property_names();
let mut used = BTreeSet::new();
let mut shapes = Vec::with_capacity(order.len());
let mut selected = Vec::with_capacity(order.len());
for &(state, combo) in order {
let values = names
.iter()
@@ -561,34 +561,50 @@ fn resolve_shapes<'a>(
_ => None,
}
})
.ok_or_else(|| format!("{block}: state lacks shape property {name}"))
.ok_or_else(|| format!("{block}: state lacks property {name}"))
})
.collect::<std::result::Result<Vec<_>, _>>()?;
let key = values.join(",");
let boxes = if let Some(boxes) = spec.values.get(&key) {
let value = if let Some(value) = spec.values.get(&key) {
used.insert(key);
boxes
value
} else {
spec.default
.as_ref()
.ok_or_else(|| format!("{block}: no shape for {key}"))?
.ok_or_else(|| format!("{block}: no state value for {key}"))?
};
let mut shape = VoxelShape::empty();
for bounds in boxes {
let part = VoxelShape::from_box(Aabb::new(
Vec3::new(bounds[0], bounds[1], bounds[2]),
Vec3::new(bounds[3], bounds[4], bounds[5]),
))?;
shape = shape.join_unoptimized(&part, ShapeOp::OR)?;
}
shapes.push(shape);
selected.push(value);
}
if used.len() != spec.values.len() {
return Err(
format!("{block}: shape values include an unknown property value tuple").into(),
format!("{block}: state values include an unknown property value tuple").into(),
);
}
Ok(shapes)
Ok(selected)
}
fn resolve_shapes<'a>(
spec: &BlockShapes,
block: &str,
states: &Range<u32>,
order: &[(u32, u32)],
combos: &[Vec<(String, String)>],
properties: impl Fn(u32) -> Option<&'a J>,
) -> Result<Vec<VoxelShape>> {
resolve_state_values(spec, block, states, order, combos, properties)?
.into_iter()
.map(|boxes| {
let mut shape = VoxelShape::empty();
for bounds in boxes {
let part = VoxelShape::from_box(Aabb::new(
Vec3::new(bounds[0], bounds[1], bounds[2]),
Vec3::new(bounds[3], bounds[4], bounds[5]),
))?;
shape = shape.join_unoptimized(&part, ShapeOp::OR)?;
}
Ok(shape)
})
.collect()
}
/// A block being added: its declaration and where its states go.
@@ -600,6 +616,7 @@ struct NewBlock<'a> {
shapes: Option<Vec<VoxelShape>>,
collision_shapes: Option<Vec<VoxelShape>>,
context_shapes: BTreeMap<String, Vec<VoxelShape>>,
state_light: Option<Vec<u8>>,
default: u32,
/// The declared state properties' combinations, every property at its
/// default first (one empty combination when the block declares none):
@@ -1079,6 +1096,14 @@ impl<'a> Plan<'a> {
if let Some(spec) = &decl.interaction_shapes {
context_shapes.insert("interaction".into(), resolve(spec)?);
}
let state_light = decl
.state_light
.as_ref()
.map(|spec| {
resolve_state_values(spec, &decl.id, &states, &order, &combos, properties)
.map(|values| values.into_iter().copied().collect())
})
.transpose()?;
// The lookup from (template state, combination) to the state's row.
let mut rows = vec![0; order.len()];
for (position, &(state, combo)) in order.iter().enumerate() {
@@ -1105,6 +1130,7 @@ impl<'a> Plan<'a> {
shapes,
collision_shapes,
context_shapes,
state_light,
default,
combos,
order,
@@ -1260,7 +1286,7 @@ impl<'a> Plan<'a> {
if !b.decl.item && b.wall_of.is_none() {
row.set("item", J::Str("minecraft:air".into()));
}
self.override_gameplay(b, combo, &mut row);
self.override_gameplay(b, state_index, combo, &mut row);
if let Some(shapes) = &b.shapes {
let shape = &shapes[state_index];
let boxes = shape
@@ -1300,7 +1326,13 @@ impl<'a> Plan<'a> {
}
/// The declared properties, on a gameplay row.
fn override_gameplay(&self, b: &NewBlock, combo: &[(String, String)], row: &mut J) {
fn override_gameplay(
&self,
b: &NewBlock,
state_index: usize,
combo: &[(String, String)],
row: &mut J,
) {
let p = &b.decl.properties;
if let Some(ticks) = &p.random_ticks {
row.set("random_ticks", J::Bool(ticks.applies(combo)));
@@ -1329,6 +1361,9 @@ impl<'a> Plan<'a> {
if let Some(v) = p.light {
row.set("emission", J::Num(v.into()));
}
if let Some(values) = &b.state_light {
row.set("emission", J::Num(values[state_index].into()));
}
if let Some(v) = p.requires_tool {
row.set("requires_tool", J::Bool(v));
}
@@ -2796,8 +2831,9 @@ pub fn resource_root(base: &Path, mods: &[ContentMod], cache: &Path) -> Result<P
// A reused root warns as the build did.
let warnings = fs::read_to_string(out.join(WARNINGS)).unwrap_or_default();
warnings.lines().for_each(|w| log::warn!("Horizon: {w}"));
// Used again: kept another day.
let _ = fs::write(out.join(STAMP), &stamp);
// Used again: kept another day. Marked beside the root, not in it: the pack overlay
// laid over the root reads its files, and a rewritten one would rebuild it (#12403).
let _ = fs::write(used_path(&out), b"");
return Ok(out);
}
for warning in build(base, mods, &out)? {
@@ -2812,6 +2848,10 @@ pub fn resource_root(base: &Path, mods: &[ContentMod], cache: &Path) -> Result<P
const ROOT_NAME: usize = 16;
/// How long a root no start or switch used is kept.
const KEEP_ROOTS: std::time::Duration = std::time::Duration::from_secs(24 * 3600);
/// The file beside `root` whose time is its last reuse (`<root>.used`).
fn used_path(root: &Path) -> PathBuf {
root.with_extension("used")
}
/// Removes from `roots` the roots other than `current` whose stamp is older
/// than `keep`, and what an older game left there (one root built straight in
@@ -2835,11 +2875,18 @@ fn prune_roots(roots: &Path, current: &Path, keep: std::time::Duration) {
.bytes()
.all(|b| b.is_ascii_hexdigit())
&& path.is_dir();
let stale = match is_root {
true => fs::metadata(path.join(STAMP))
let age = |file: PathBuf| {
fs::metadata(file)
.and_then(|m| m.modified())
.ok()
.and_then(|t| t.elapsed().ok())
};
let stale = match is_root {
// Its build (the stamp) or its last reuse, whichever is newer.
true => [age(path.join(STAMP)), age(used_path(&path))]
.into_iter()
.flatten()
.min()
.is_none_or(|age| age > keep),
// The old single root's files and folders.
false => old_layout,
@@ -2848,7 +2895,9 @@ fn prune_roots(roots: &Path, current: &Path, keep: std::time::Duration) {
continue;
}
let removed = match path.is_dir() {
true => fs::remove_dir_all(&path),
true => fs::remove_dir_all(&path).inspect(|_| {
let _ = fs::remove_file(used_path(&path));
}),
false => fs::remove_file(&path),
};
if let Err(e) = removed {
+217 -2
View File
@@ -1,5 +1,209 @@
use super::*;
use crate::{assets::Assets, blocks::BlockFacts, items::Items};
use std::sync::Arc;
#[test]
fn state_light_compiles_in_final_order_and_relights_across_a_chunk_boundary() {
use crate::{
chunk::Chunk,
light::{self, LightChanges, LightTables},
};
use horizon_world::{
level::GameplayStateSource,
registry::{BlockRegistry, BlockStateId},
};
use std::collections::HashMap;
let declaration = r#"{"template":"minecraft:test","item":false,"properties":{"light":3},
"state_properties":{"mode":{"type":"enum","values":["off","dim","bright"]}},
"outline_context_shapes":{"item":{"property":"mode","values":{
"dim":[[0,0,0,0.5,1,1]]},"default":[]}},
"state_light":{"properties":["mode","axis"],"values":{
"dim,x":8,"dim,y":9,"bright,x":15,"bright,y":12},"default":0}}"#;
let (base, mods) = block_base_of(
"state-light-propagation",
&[declaration],
r#"{"states":[
["minecraft:air",false,false,"",{"id":"minecraft:air"}],
["minecraft:test",false,false,"",{"id":"minecraft:test","properties":{"axis":"x"}}],
["minecraft:test",false,false,"",{"id":"minecraft:test","properties":{"axis":"y"}}]],
"defaults":{"minecraft:air":0,"minecraft:test":1}}"#,
);
let plan = Plan::new(&base, &mods).unwrap();
let mut facts: J = read_json(&base.join("facts.json")).unwrap();
let mut gameplay: J = read_json(&base.join("gameplay-facts.json")).unwrap();
let Some(J::Arr(rows)) = gameplay.at("states") else {
unreachable!()
};
for (i, row) in rows.iter_mut().enumerate() {
row.set("id", J::Num(i.into()));
row.set("emission", J::Num(if i == 0 { 0 } else { 3 }.into()));
}
plan.json("facts.json", &mut facts).unwrap();
plan.json("gameplay-facts.json", &mut gameplay).unwrap();
fs::write(
base.join("gameplay-facts.json"),
serde_json::to_vec(&gameplay).unwrap(),
)
.unwrap();
let registry =
BlockRegistry::from_facts_json(serde_json::to_vec(&facts).unwrap().as_slice()).unwrap();
let blocks: BTreeSet<_> = (0..registry.len())
.map(|i| {
registry
.state(BlockStateId(i as u32))
.unwrap()
.name
.as_str()
})
.collect();
let blocks: Vec<_> = blocks
.iter()
.map(|name| serde_json::json!({"name":name,"class":["Block"]}))
.collect();
let behaviours = serde_json::json!({
"states": vec![serde_json::json!([0,0]);registry.len()],"blocks":blocks
});
let (facts, projection) =
BlockFacts::load_with_projection(&base, &registry, &behaviours, false).unwrap();
assert_eq!(
facts.get(BlockStateId(1)).emission,
3,
"the template is unchanged"
);
let tables = LightTables::new(&facts);
let mut by_state = BTreeMap::new();
for i in 3..registry.len() as u32 {
let state = registry.state(BlockStateId(i)).unwrap();
let key = (
state.properties["axis"].as_str(),
state.properties["mode"].as_str(),
);
let expected = match key {
(_, "off") => 0,
("x", "dim") => 8,
("y", "dim") => 9,
("x", "bright") => 15,
("y", "bright") => 12,
_ => unreachable!(),
};
assert_eq!(facts.get(BlockStateId(i)).emission, expected);
assert_eq!(
projection[i as usize].unsigned("emission"),
u64::from(expected)
);
assert_eq!(tables.emission(i), expected);
let item_outline = &plan.blocks[0].context_shapes["outline_item"][(i - 3) as usize];
assert_eq!(
item_outline.bounds().map(|bounds| bounds.max(Axis::X)),
(key.1 == "dim").then_some(0.5)
);
by_state.insert(key, i);
}
let off = by_state[&("x", "off")];
let dim = by_state[&("x", "dim")];
let bright = by_state[&("x", "bright")];
assert!(tables.differs(dim, bright));
assert!(tables.differs(bright, off));
let mut world = HashMap::new();
for z in -1..=1 {
for x in -1..=1 {
let mut chunk = Chunk::empty(x, z);
if (x, z) == (0, 0) {
chunk.set(15, 64, 8, dim);
}
world.insert((x, z), Arc::new(chunk));
}
}
let lit: Vec<_> = world
.keys()
.map(|&(x, z)| {
let region = std::array::from_fn(|dz| {
std::array::from_fn(|dx| {
world
.get(&(x + dx as i32 - 1, z + dz as i32 - 1))
.map(AsRef::as_ref)
})
});
let mut chunk = (*world[&(x, z)]).clone();
light::apply_lighting(&mut chunk, light::light_chunk(&region, &tables, false));
chunk.lit = true;
((x, z), Arc::new(chunk))
})
.collect();
world = lit.into_iter().collect();
let level = |world: &HashMap<(i32, i32), Arc<Chunk>>, x: i32| {
world[&(x >> 4, 0)].light(x & 15, 64, 8) & 15
};
assert_eq!((level(&world, 15), level(&world, 16)), (8, 7));
for (state, expected) in [(bright, 15u8), (off, 0u8)] {
Arc::make_mut(world.get_mut(&(0, 0)).unwrap()).set(15, 64, 8, state);
let mut changed = LightChanges::default();
light::relight(
&mut world,
&tables,
false,
&[(15, 64, 8)],
false,
&mut changed,
);
assert_eq!(
(level(&world, 15), level(&world, 16)),
(expected, expected.saturating_sub(1))
);
assert!(!changed.is_empty());
}
fs::remove_dir_all(base.parent().unwrap()).unwrap();
}
#[test]
fn state_light_rejects_unknown_unreachable_and_uncovered_state_tuples() {
for (i, spec) in [
r#"{"property":"missing","default":0}"#,
r#"{"property":"axis","values":{"x":1}}"#,
r#"{"property":"axis","values":{"x":1,"z":2},"default":0}"#,
]
.iter()
.enumerate()
{
let declaration = format!(r#"{{"template":"minecraft:test","state_light":{spec}}}"#);
let (base, mods) = block_base(&format!("state-light-selection-{i}"), &[&declaration]);
assert!(Plan::new(&base, &mods).is_err(), "{spec}");
fs::remove_dir_all(base.parent().unwrap()).unwrap();
}
for (i, light) in ["", r#", "properties":{"light":4}"#].iter().enumerate() {
let declaration = format!(r#"{{"template":"minecraft:test"{light}}}"#);
let (base, mods) = block_base(&format!("state-light-absent-{i}"), &[&declaration]);
let plan = Plan::new(&base, &mods).unwrap();
let mut doc: J =
serde_json::from_str(r#"{"states":[{"emission":6},{"emission":7}]}"#).unwrap();
plan.json("gameplay-facts.json", &mut doc).unwrap();
let expected = if i == 0 { vec![6, 7] } else { vec![4, 4] };
let doc = serde_json::to_value(doc).unwrap();
assert_eq!(doc["states"][2]["emission"], expected[0]);
assert_eq!(doc["states"][3]["emission"], expected[1]);
fs::remove_dir_all(base.parent().unwrap()).unwrap();
}
}
#[test]
fn state_light_from_native_declarations_invalidates_the_content_cache() {
let (base, mut mods) = block_base(
"state-light-cache",
&[r#"{"template":"minecraft:test","state_light":{"default":8}}"#],
);
fs::write(base.join("horizon-resources.json"), b"{}").unwrap();
let initial = stamp(&base, &mods).unwrap();
let declaration = &mut mods[0].content.blocks[0];
declaration.state_light.as_mut().unwrap().default = Some(15);
assert_ne!(stamp(&base, &mods).unwrap(), initial);
mods[0].content.blocks[0]
.state_light
.as_mut()
.unwrap()
.default = Some(8);
assert_eq!(stamp(&base, &mods).unwrap(), initial);
fs::remove_dir_all(base.parent().unwrap()).unwrap();
}
#[test]
#[ignore = "full content root: run explicitly with prepared resources"]
@@ -4795,12 +4999,23 @@ fn roots_unused_for_a_day_are_removed() {
let current = root("00000000000000aa", 3 * 86400);
let recent = root("00000000000000bb", 60);
let stale = root("00000000000000cc", 2 * 86400);
// Built two days ago but reused a minute ago: its `.used` beside it keeps it (#12403).
let reused = root("00000000000000dd", 2 * 86400);
fs::write(used_path(&reused), b"").unwrap();
fs::write(used_path(&stale), b"").unwrap();
let file = fs::File::options()
.write(true)
.open(used_path(&stale))
.unwrap();
file.set_modified(std::time::SystemTime::now() - std::time::Duration::from_secs(2 * 86400))
.unwrap();
drop(file);
// Not a root's name: never touched while the folder has no root of the old layout.
let other = roots.join("notes");
fs::create_dir_all(&other).unwrap();
prune_roots(&roots, &current, KEEP_ROOTS);
assert!(current.exists() && recent.exists() && other.exists());
assert!(!stale.exists());
assert!(current.exists() && recent.exists() && other.exists() && reused.exists());
assert!(!stale.exists() && !used_path(&stale).exists());
// An older game's single root, built straight in the folder, goes.
fs::write(roots.join(STAMP), b"old").unwrap();
fs::create_dir_all(roots.join("assets/minecraft")).unwrap();
+740
View File
@@ -7,6 +7,7 @@ use crate::{
crafting::{Ingredient, Placement, Recipe, Recipes},
items::{ItemStack, Items},
};
use horizon_protocol::packets::recipes::{HolderSet, RecipeBookEntry, RecipeDisplay, SlotDisplay};
use horizon_world::loot::PackOverlay;
use horizon_world::nbt::Tag;
use std::collections::{BTreeSet, HashMap};
@@ -166,6 +167,25 @@ impl Category {
Self::SmokerFood => "smoker_food",
}
}
/// The category of a `minecraft:recipe_book_category` name; none for the
/// ones this book has no tab for (stonecutter, smithing, campfire).
pub fn from_name(name: &str) -> Option<Self> {
let name = name.strip_prefix("minecraft:").unwrap_or(name);
[
Self::CraftingBuilding,
Self::CraftingRedstone,
Self::CraftingEquipment,
Self::CraftingMisc,
Self::FurnaceFood,
Self::FurnaceBlocks,
Self::FurnaceMisc,
Self::BlastBlocks,
Self::BlastMisc,
Self::SmokerFood,
]
.into_iter()
.find(|c| c.name() == name)
}
}
/// A tab button: the search tab or one category.
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
@@ -346,6 +366,13 @@ pub struct Book {
pub placements: HashMap<String, Placement>,
/// Item id -> registry position, to order ingredient choices.
pub order: HashMap<String, usize>,
/// The ingredients a server's entry asks for (`craftingRequirements`), by
/// the entry's display id; none for an entry that sent none.
pub server_requirements: HashMap<i32, Option<Vec<Ingredient>>>,
/// The name each group of a server's entries goes by, by the server's group
/// number (`1 + index`): the name of a book entry kept in that group, so
/// that the entries taken from the packet join it.
pub server_groups: HashMap<i32, String>,
}
pub(crate) fn crafting_category(name: &str) -> Category {
match name {
@@ -443,11 +470,19 @@ impl Book {
entries,
placements,
order,
server_requirements: HashMap::new(),
server_groups: HashMap::new(),
}
}
/// `RecipeDisplayEntry.canCraft`: the recipe's placement ingredients
/// (`craftingRequirements`) can all be found in `stock`.
pub fn can_craft(&self, entry: &Entry, stock: &crate::menu::place::Stacked) -> bool {
// A server's entry: the ingredients it sent, none meaning not craftable.
if let Some(requirements) = self.server_requirements.get(&entry.display_id) {
return requirements
.as_ref()
.is_some_and(|r| stock.can_craft(&r.iter().collect::<Vec<_>>()));
}
self.placements
.get(&entry.id)
.is_some_and(|p| stock.can_craft(&p.ingredients.iter().collect::<Vec<_>>()))
@@ -687,6 +722,267 @@ impl ClientBook {
}
}
/// How a server's packet names things: an item by its registry id, a recipe
/// book category by its id, and the items of a tag.
pub struct PacketNames<'a> {
pub item: &'a dyn Fn(i32) -> Option<String>,
pub category: &'a dyn Fn(i32) -> Option<String>,
pub tag: &'a dyn Fn(&str) -> Vec<String>,
}
impl PacketNames<'_> {
fn holder_set(&self, set: &HolderSet) -> Vec<String> {
match set {
HolderSet::Tag(tag) => (self.tag)(tag.strip_prefix('#').unwrap_or(tag)),
HolderSet::Items(ids) => ids.iter().filter_map(|&id| (self.item)(id)).collect(),
}
}
/// The stacks a slot display shows, in order (`SlotDisplay.resolveForStacks`):
/// what a display builds from a component or a dye is not reproduced, it
/// shows its source.
fn stacks(&self, display: &SlotDisplay, out: &mut Vec<ItemStack>) {
match display {
SlotDisplay::Empty | SlotDisplay::AnyFuel => {}
SlotDisplay::Item(id) => out.extend((self.item)(*id).map(|n| ItemStack::new(&n, 1))),
SlotDisplay::ItemStack(stack) => out.extend(
(self.item)(stack.item).map(|n| ItemStack::new(&n, stack.count.max(1) as u32)),
),
SlotDisplay::Tag(set) => {
out.extend(self.holder_set(set).iter().map(|n| ItemStack::new(n, 1)))
}
SlotDisplay::WithAnyPotion(source) | SlotDisplay::OnlyWithComponent(source, _) => {
self.stacks(source, out)
}
SlotDisplay::Dyed { target, .. } => self.stacks(target, out),
SlotDisplay::SmithingTrim { base, .. } => self.stacks(base, out),
SlotDisplay::WithRemainder { input, .. } => self.stacks(input, out),
SlotDisplay::Composite(list) => list.iter().for_each(|d| self.stacks(d, out)),
}
}
/// A slot display as an ingredient: the items it shows, without repeats.
fn ingredient(&self, display: &SlotDisplay) -> Ingredient {
let mut stacks = Vec::new();
self.stacks(display, &mut stacks);
let mut items: Vec<String> = Vec::new();
for stack in stacks {
if !items.iter().any(|i| **i == *stack.item) {
items.push(stack.item.to_string());
}
}
Ingredient::of(items)
}
}
/// What a server's entry comes to in this book.
pub enum FromPacket {
/// The book's entry (its id is `server:<display id>`), with the ingredients
/// its craftable filter checks.
Shown(Box<Entry>, Option<Vec<Ingredient>>),
/// This book has no place for it: a stonecutter or smithing display, a
/// category without a tab, no result.
Unshowable,
/// Its category id is not one this client knows (a registry it lacks): not
/// a judgement of the entry.
Unknown,
}
/// An entry of a server's `ClientboundRecipeBookAddPacket` as this book's entry.
pub fn entry_from_packet(e: &RecipeBookEntry, names: &PacketNames) -> FromPacket {
let Some(category_name) = (names.category)(e.category) else {
return FromPacket::Unknown;
};
shown(e, names, &category_name).unwrap_or(FromPacket::Unshowable)
}
fn shown(e: &RecipeBookEntry, names: &PacketNames, category_name: &str) -> Option<FromPacket> {
let category = Category::from_name(category_name)?;
let (display, result) = match &e.display {
RecipeDisplay::Shapeless {
ingredients,
result,
..
} => (
Display::Shapeless(ingredients.iter().map(|d| names.ingredient(d)).collect()),
result,
),
RecipeDisplay::Shaped {
width,
height,
ingredients,
result,
..
} => (
Display::Shaped {
width: usize::try_from(*width).ok()?,
height: usize::try_from(*height).ok()?,
cells: ingredients
.iter()
.map(|d| (!matches!(d, SlotDisplay::Empty)).then(|| names.ingredient(d)))
.collect(),
},
result,
),
RecipeDisplay::Furnace {
ingredient, result, ..
} => (Display::Furnace(names.ingredient(ingredient)), result),
RecipeDisplay::Stonecutter { .. } | RecipeDisplay::Smithing { .. } => return None,
};
let mut results = Vec::new();
names.stacks(result, &mut results);
let first = results.first()?.clone();
let variants = if results.len() > 1 {
results
} else {
Vec::new()
};
let requirements = e.requirements.as_ref().map(|sets| {
sets.iter()
.map(|set| Ingredient::of(names.holder_set(set)))
.collect()
});
let entry = Entry {
id: format!("server:{}", e.id),
display_id: e.id,
category,
group: (e.group > 0).then(|| format!("server:{}", e.group - 1)),
display,
result: first,
variants,
stacks: Vec::new(),
};
Some(FromPacket::Shown(Box::new(entry), requirements))
}
/// The items an ingredient accepts, sorted.
fn items_of(i: &Ingredient) -> Vec<&str> {
let mut items: Vec<&str> = i.items.iter().map(String::as_str).collect();
items.sort_unstable();
items
}
/// The items each ingredient of an entry accepts, in any order of ingredients.
fn sorted_sets(e: &Entry) -> Vec<Vec<&str>> {
let mut sets: Vec<Vec<&str>> = e.requirements().into_iter().map(items_of).collect();
sets.sort_unstable();
sets
}
/// Whether the book's own entry is the recipe a server's entry describes: the
/// same result, category and display. A shaped recipe must have the same size
/// and each cell, empty ones included, accepting the same items; a shapeless or
/// smelting one, the same ingredients in any order.
fn same_recipe(own: &Entry, server: &Entry) -> bool {
if own.category != server.category || own.result.item != server.result.item {
return false;
}
match (&own.display, &server.display) {
(
Display::Shaped {
width: w,
height: h,
cells,
},
Display::Shaped {
width: sw,
height: sh,
cells: scells,
},
) => {
(w, h) == (sw, sh)
&& cells.len() == scells.len()
&& cells
.iter()
.zip(scells)
.all(|(a, b)| a.as_ref().map(items_of) == b.as_ref().map(items_of))
}
(Display::Shapeless(_), Display::Shapeless(_))
| (Display::Furnace(_), Display::Furnace(_)) => sorted_sets(own) == sorted_sets(server),
_ => false,
}
}
impl Book {
/// Puts a server's entries over this book. The book numbers its own
/// recipes the way a server of the same recipes does, but a server with
/// a plugin's recipes, a datapack or another order numbers them
/// otherwise: the client of vanilla keeps the entries the packet carries
/// (`ClientRecipeBook.add`), so each is taken as it came. The book's own
/// entry stands when it is the same recipe (`same_recipe`), so a server of
/// this client's own recipes shows what it always did, and the entries
/// taken join the groups of the ones kept. Where the server's entry cannot
/// be shown here, the book's own entry of that number goes: it is another
/// recipe. An entry of a category this client does not know changes nothing.
pub fn use_server_entries<'a>(
&mut self,
entries: impl IntoIterator<Item = &'a RecipeBookEntry>,
names: &PacketNames,
) {
enum Act {
Keep,
Take(Box<Entry>, Option<Vec<Ingredient>>),
Hide,
}
let mut acts = Vec::new();
for e in entries {
let at = self.entry_of_display(e.id);
let act = match entry_from_packet(e, names) {
FromPacket::Unknown => Act::Keep,
FromPacket::Unshowable => Act::Hide,
FromPacket::Shown(entry, requirements) => match at {
Some(i) if same_recipe(&self.entries[i], &entry) => {
// The kept entry names its server group: the entries
// already taken from it take that name too.
if e.group > 0
&& let Some(name) = self.entries[i].group.clone()
{
let old = self.server_groups.insert(e.group, name.clone());
if let Some(old) =
old.filter(|old| *old != name && old.starts_with("server:"))
{
for taken in &mut self.entries {
if taken.id.starts_with("server:")
&& taken.group.as_deref() == Some(old.as_str())
{
taken.group = Some(name.clone());
}
}
}
}
if !self.entries[i].id.starts_with("server:") {
self.server_requirements.remove(&e.id);
}
Act::Keep
}
_ => Act::Take(entry, requirements),
},
};
acts.push((e, at, act));
}
for (e, at, act) in acts {
match act {
Act::Keep => {}
Act::Hide => {
if let Some(i) = at {
self.entries[i].display_id = -1;
}
self.server_requirements.remove(&e.id);
}
Act::Take(mut entry, requirements) => {
if e.group > 0 {
entry.group = Some(
self.server_groups
.entry(e.group)
.or_insert_with(|| format!("server:{}", e.group - 1))
.clone(),
);
}
match at {
Some(i) => self.entries[i] = *entry,
None => self.entries.push(*entry),
}
self.server_requirements.insert(e.id, requirements);
}
}
}
}
}
/// What the server knows of each recipe of the datapack: whether it is
/// special (`Recipe.isSpecial`: never learnable) and how many recipe book
/// displays it has (`RecipeManager.listDisplaysForRecipe`).
@@ -1286,6 +1582,8 @@ mod tests {
],
placements: HashMap::new(),
order: HashMap::new(),
server_requirements: HashMap::new(),
server_groups: HashMap::new(),
};
let mut client = ClientBook::default();
for d in 0..4 {
@@ -1327,6 +1625,8 @@ mod tests {
}],
placements: HashMap::new(),
order: HashMap::new(),
server_requirements: HashMap::new(),
server_groups: HashMap::new(),
};
let lines = |s: &ItemStack| vec![format!("Item {}", s.item), "Stick".to_owned()];
assert!(book.collection_matches(&[0], "STI", &lines));
@@ -1444,4 +1744,444 @@ mod tests {
);
assert_eq!(index.recipe_of_display(0), Some("parity:a_pick"));
}
// ---- a server whose display ids are not this client's (#11705) ----
use horizon_protocol::packets::recipes::RecipeBookAdd;
/// The ids of the items the packet below names.
const ITEMS: [&str; 5] = [
"minecraft:tnt",
"minecraft:piston",
"minecraft:stick",
"minecraft:diamond",
"minecraft:gunpowder",
];
fn item_name(id: i32) -> Option<String> {
ITEMS
.get(usize::try_from(id).ok()?)
.map(|n| (*n).to_owned())
}
/// The server's category ids: 0 building, 1 redstone, 2 equipment, 3 misc,
/// 4 stonecutter (a tab this book has none for); any other is unknown.
fn category_name(id: i32) -> Option<String> {
if id == 4 {
return Some("minecraft:stonecutter".to_owned());
}
["building_blocks", "redstone", "equipment", "misc"]
.get(usize::try_from(id).ok()?)
.map(|n| format!("minecraft:crafting_{n}"))
}
/// The book of this client: it numbers `tnt`, `piston`, `stick` 10, 11, 12.
fn local_book() -> Book {
let entry = |id: &str, display_id, category| Entry {
id: id.into(),
display_id,
category,
group: None,
display: Display::Shapeless(vec![ing(&["minecraft:gunpowder"])]),
result: ItemStack::new(id, 1),
variants: Vec::new(),
stacks: Vec::new(),
};
Book {
entries: vec![
entry("minecraft:tnt", 10, Category::CraftingRedstone),
entry("minecraft:piston", 11, Category::CraftingRedstone),
entry("minecraft:stick", 12, Category::CraftingMisc),
],
placements: HashMap::new(),
order: HashMap::new(),
server_requirements: HashMap::new(),
server_groups: HashMap::new(),
}
}
/// A server entry: a shapeless recipe of one gunpowder for `item`.
fn packet_entry(display: i32, item: i32, category: i32) -> RecipeBookEntry {
RecipeBookEntry {
id: display,
display: RecipeDisplay::Shapeless {
ingredients: vec![SlotDisplay::Item(4)],
result: SlotDisplay::Item(item),
station: SlotDisplay::Item(0),
},
group: 0,
category,
requirements: Some(vec![HolderSet::Items(vec![4])]),
flags: 0,
}
}
/// A server numbering by its own recipes: a plugin recipe at 5, then the
/// vanilla ones far from where this client numbers them. Its category
/// ids: 1 redstone, 3 misc.
fn paper_packet() -> RecipeBookAdd {
RecipeBookAdd {
entries: vec![
packet_entry(5, 3, 3),
packet_entry(110, 0, 1),
packet_entry(111, 1, 1),
packet_entry(112, 2, 3),
],
replace: true,
}
}
/// A server whose TNT has the number this client gives the stick.
fn colliding_packet() -> RecipeBookAdd {
RecipeBookAdd {
entries: vec![packet_entry(12, 0, 1)],
replace: true,
}
}
/// What a tab lists once the client has `packet`.
fn tab_after(packet: &RecipeBookAdd, category: Category) -> Vec<String> {
let mut book = local_book();
let tags = |_: &str| Vec::new();
book.use_server_entries(
&packet.entries,
&PacketNames {
item: &item_name,
category: &category_name,
tag: &tags,
},
);
let mut client = ClientBook::default();
for e in &packet.entries {
client.add(e.id);
}
let mut names: Vec<String> = client
.collections(&book, BookType::Crafting, Tab::Category(category))
.iter()
.flat_map(|c| {
c.entries
.iter()
.map(|&i| book.entries[i].result.item.to_string())
})
.collect();
names.sort();
names
}
#[test]
fn the_book_lists_the_recipes_a_servers_packet_names() {
let packet = paper_packet();
assert_eq!(
tab_after(&packet, Category::CraftingRedstone),
["minecraft:piston", "minecraft:tnt"]
);
assert_eq!(
tab_after(&packet, Category::CraftingMisc),
["minecraft:diamond", "minecraft:stick"]
);
}
#[test]
fn a_number_this_client_gives_another_recipe_shows_the_servers() {
let packet = colliding_packet();
assert_eq!(
tab_after(&packet, Category::CraftingRedstone),
["minecraft:tnt"]
);
assert!(tab_after(&packet, Category::CraftingMisc).is_empty());
}
fn names<'a>(tags: &'a dyn Fn(&str) -> Vec<String>) -> PacketNames<'a> {
PacketNames {
item: &item_name,
category: &category_name,
tag: tags,
}
}
#[test]
fn a_server_of_this_clients_recipes_keeps_the_books_own_entries() {
// The same number, result, category and ingredient count: the entry
// the client made stands, so its order and ghost stay as they were.
let mut book = local_book();
let before: Vec<String> = book.entries.iter().map(|e| e.id.clone()).collect();
let tags = |_: &str| Vec::new();
let packet = RecipeBookAdd {
entries: vec![packet_entry(10, 0, 1), packet_entry(11, 1, 1)],
replace: true,
};
book.use_server_entries(&packet.entries, &names(&tags));
let after: Vec<String> = book.entries.iter().map(|e| e.id.clone()).collect();
assert_eq!(after, before);
assert!(book.server_requirements.is_empty());
}
#[test]
fn an_entry_this_book_cannot_show_hides_the_recipe_that_has_its_number() {
let mut book = local_book();
let tags = |_: &str| Vec::new();
let mut stonecutter = packet_entry(11, 1, 1);
stonecutter.display = RecipeDisplay::Stonecutter {
input: SlotDisplay::Item(4),
result: SlotDisplay::Item(1),
station: SlotDisplay::Item(0),
};
book.use_server_entries(&[stonecutter], &names(&tags));
// Number 11 was the piston here: not on this server.
assert_eq!(book.entry_of_display(11), None);
assert_eq!(book.entry_of_display(10), Some(0));
}
#[test]
fn the_craftable_filter_asks_the_servers_ingredients() {
use crate::menu::place::Stacked;
let mut book = local_book();
let tags = |tag: &str| {
if tag == "minecraft:planks" {
vec!["minecraft:stick".to_owned()]
} else {
Vec::new()
}
};
let mut asks_planks = packet_entry(110, 0, 1);
asks_planks.requirements = Some(vec![HolderSet::Tag("minecraft:planks".into())]);
let mut asks_nothing_known = packet_entry(111, 1, 1);
asks_nothing_known.requirements = None;
let mut asks_none = packet_entry(112, 2, 3);
asks_none.requirements = Some(Vec::new());
book.use_server_entries(&[asks_planks, asks_nothing_known, asks_none], &names(&tags));
let stock = Stacked::default();
let craft = |display| {
let i = book.entry_of_display(display).unwrap();
book.can_craft(&book.entries[i], &stock)
};
// Nothing in hand: the tag's item is missing, no list is never
// craftable (`RecipeDisplayEntry.canCraft`), an empty list always is.
assert!(!craft(110));
assert!(!craft(111));
assert!(craft(112));
}
#[test]
fn a_servers_tag_ingredient_shows_the_items_the_client_knows_for_it() {
let mut book = local_book();
let tags = |tag: &str| {
assert_eq!(tag, "minecraft:planks");
vec!["minecraft:stick".to_owned(), "minecraft:diamond".to_owned()]
};
let mut e = packet_entry(110, 0, 1);
e.display = RecipeDisplay::Shapeless {
ingredients: vec![SlotDisplay::Tag(HolderSet::Tag("minecraft:planks".into()))],
result: SlotDisplay::Item(0),
station: SlotDisplay::Item(0),
};
book.use_server_entries(&[e], &names(&tags));
let entry = &book.entries[book.entry_of_display(110).unwrap()];
let items: Vec<&str> = entry.requirements()[0]
.order
.iter()
.map(String::as_str)
.collect();
assert_eq!(items, ["minecraft:stick", "minecraft:diamond"]);
}
#[test]
fn an_entry_that_asks_for_other_ingredients_is_not_the_books_recipe() {
// The server's TNT takes diamonds where this client's takes gunpowder
// (the stick of planks and the stick of bamboo, the iron ingot of any
// ore): same result, same category, same number of ingredients.
let mut book = local_book();
let tags = |_: &str| Vec::new();
let mut tnt = packet_entry(10, 0, 1);
tnt.display = RecipeDisplay::Shapeless {
ingredients: vec![SlotDisplay::Item(3)],
result: SlotDisplay::Item(0),
station: SlotDisplay::Item(0),
};
book.use_server_entries(&[tnt], &names(&tags));
let entry = &book.entries[book.entry_of_display(10).unwrap()];
assert_eq!(entry.id, "server:10");
assert_eq!(entry.requirements()[0].order, ["minecraft:diamond"]);
}
#[test]
fn the_entries_taken_join_the_group_of_the_ones_kept() {
let mut book = local_book();
book.entries[2].group = Some("planks".into());
let tags = |_: &str| Vec::new();
// The server's group 7: the stick this book has, and a recipe it lacks.
let mut kept = packet_entry(12, 2, 3);
kept.group = 7;
let mut taken = packet_entry(50, 3, 3);
taken.group = 7;
// The taken one comes first in the packet.
let packet = [taken, kept];
book.use_server_entries(&packet, &names(&tags));
let mut client = ClientBook::default();
for e in &packet {
client.add(e.id);
}
let misc = client.collections(
&book,
BookType::Crafting,
Tab::Category(Category::CraftingMisc),
);
assert_eq!(misc.len(), 1);
assert_eq!(misc[0].entries.len(), 2);
}
#[test]
fn an_entry_of_a_category_this_client_lacks_changes_nothing() {
// A registry the client does not have (or a server newer than it):
// every entry is of an unknown category, and the book stays its own.
let mut book = local_book();
let no_category = |_: i32| None;
let tags = |_: &str| Vec::new();
let packet = [packet_entry(10, 0, 1), packet_entry(11, 1, 1)];
book.use_server_entries(
&packet,
&PacketNames {
item: &item_name,
category: &no_category,
tag: &tags,
},
);
assert_eq!(book.entries.len(), 3);
assert!(book.entries.iter().all(|e| !e.id.starts_with("server:")));
assert_eq!(book.entry_of_display(11), Some(1));
// A category id the registry names but a tab does not exist for is
// another matter: that recipe is not this book's.
let mut stonecutter = packet_entry(11, 1, 4);
stonecutter.display = packet[1].display.clone();
book.use_server_entries(&[stonecutter], &names(&tags));
assert_eq!(book.entry_of_display(11), None);
}
/// A shaped server entry of `ingredients` (item ids, -1 an empty cell).
fn shaped_entry(display: i32, width: i32, height: i32, cells: &[i32]) -> RecipeBookEntry {
let mut e = packet_entry(display, 0, 1);
e.display = RecipeDisplay::Shaped {
width,
height,
ingredients: cells
.iter()
.map(|&c| {
if c < 0 {
SlotDisplay::Empty
} else {
SlotDisplay::Item(c)
}
})
.collect(),
result: SlotDisplay::Item(0),
station: SlotDisplay::Item(0),
};
e
}
fn shaped_local(width: usize, height: usize, cells: &[Option<&str>]) -> Book {
let mut book = local_book();
book.entries[0].display = Display::Shaped {
width,
height,
cells: cells.iter().map(|c| c.map(|i| ing(&[i]))).collect(),
};
book
}
#[test]
fn a_shaped_recipe_must_have_the_same_layout_to_be_the_books_own() {
let tags = |_: &str| Vec::new();
let gp = Some("minecraft:gunpowder");
let sand = Some("minecraft:diamond");
// This client's TNT: 3 wide, 2 high.
let cells = [gp, sand, gp, sand, gp, sand];
let kept = |packet: RecipeBookEntry| {
let mut book = shaped_local(3, 2, &cells);
book.use_server_entries(&[packet], &names(&tags));
book.entries[0].id == "minecraft:tnt"
};
// The same layout: the book's entry stands.
assert!(kept(shaped_entry(10, 3, 2, &[4, 3, 4, 3, 4, 3])));
// 2 wide and 3 high, the same ingredients in the same order.
assert!(!kept(shaped_entry(10, 2, 3, &[4, 3, 4, 3, 4, 3])));
// The same size and ingredients with a cell moved (an empty one).
assert!(!kept(shaped_entry(10, 3, 2, &[4, 3, 4, 3, -1, 4])));
// The empty cell counts: a hole where this client has an ingredient.
let mut holed = shaped_local(2, 1, &[gp, None]);
holed.use_server_entries(&[shaped_entry(10, 2, 1, &[4, 3])], &names(&tags));
assert_eq!(holed.entries[0].id, "server:10");
}
/// What the book lists in every tab, as a comparable whole.
fn listing(book: &Book, client: &ClientBook) -> Vec<String> {
let mut out = Vec::new();
for category in [
Category::CraftingBuilding,
Category::CraftingRedstone,
Category::CraftingEquipment,
Category::CraftingMisc,
] {
for c in client.collections(book, BookType::Crafting, Tab::Category(category)) {
let mut one: Vec<String> = c
.entries
.iter()
.map(|&i| {
let e = &book.entries[i];
format!("{}:{}:{:?}", e.display_id, e.result.item, e.group)
})
.collect();
one.sort();
out.push(format!("{category:?} {one:?}"));
}
}
out.sort();
out
}
#[test]
fn packets_one_after_another_end_as_one_rebuild_would() {
let tags = |_: &str| Vec::new();
let group = |mut e: RecipeBookEntry, g: i32| {
e.group = g;
e
};
// The server's group 7 holds the book's own stick (kept, "planks"),
// and two recipes the book lacks, one in each packet, the kept one last.
let first = vec![group(packet_entry(50, 3, 3), 7), packet_entry(110, 0, 1)];
let second = vec![
group(packet_entry(51, 4, 3), 7),
group(packet_entry(12, 2, 3), 7),
];
let make = || {
let mut book = local_book();
book.entries[2].group = Some("planks".into());
book
};
let mut live = make();
live.use_server_entries(&first, &names(&tags));
live.use_server_entries(&second, &names(&tags));
let mut rebuilt = make();
let mut all: Vec<&RecipeBookEntry> = first.iter().chain(&second).collect();
all.sort_by_key(|e| e.id);
rebuilt.use_server_entries(all, &names(&tags));
let mut client = ClientBook::default();
for e in first.iter().chain(&second) {
client.add(e.id);
}
assert_eq!(listing(&live, &client), listing(&rebuilt, &client));
// And the misc tab holds the one group: three entries in one button.
let misc = client.collections(
&live,
BookType::Crafting,
Tab::Category(Category::CraftingMisc),
);
assert_eq!(misc.len(), 1);
assert_eq!(misc[0].entries.len(), 3);
}
#[test]
fn a_kept_entry_leaves_no_server_requirements_behind() {
let mut book = local_book();
let tags = |_: &str| Vec::new();
// Taken once, with its own ingredients.
let mut taken = packet_entry(10, 0, 1);
taken.requirements = Some(vec![HolderSet::Items(vec![3])]);
taken.display = RecipeDisplay::Shapeless {
ingredients: vec![SlotDisplay::Item(3)],
result: SlotDisplay::Item(0),
station: SlotDisplay::Item(0),
};
book.use_server_entries(&[taken], &names(&tags));
assert!(book.server_requirements.contains_key(&10));
// Hidden by an entry this book cannot show: nothing left of it.
let mut stonecutter = packet_entry(10, 0, 4);
stonecutter.display = RecipeDisplay::Stonecutter {
input: SlotDisplay::Item(4),
result: SlotDisplay::Item(0),
station: SlotDisplay::Item(0),
};
book.use_server_entries(&[stonecutter], &names(&tags));
assert!(!book.server_requirements.contains_key(&10));
}
}
+46 -10
View File
@@ -44,7 +44,7 @@ use std::{
path::{Path, PathBuf},
sync::{
Arc, Condvar, Mutex, OnceLock,
atomic::{AtomicU64, Ordering},
atomic::{AtomicU64, AtomicUsize, Ordering},
mpsc,
},
};
@@ -2247,6 +2247,7 @@ enum Record {
Tag(Arc<NamedTag>),
/// A chunk to encode on the writer thread.
Chunk(Arc<Chunk>, Arc<Encoding>),
Proto(Arc<SavedProto>, Arc<Encoding>),
/// A chunk's points of interest, made on the writer thread against the
/// saved ones (`resolve_poi`).
Poi(Arc<Chunk>, Arc<SaveTables>),
@@ -2270,6 +2271,13 @@ impl Record {
e.tables.as_deref(),
e.shape,
))),
Record::Proto(p, e) => Some(Arc::new(encode_proto(
p,
e.registry,
&e.biomes,
e.tables.as_deref(),
e.shape,
))),
Record::Poi(c, t) => encode_poi(c, t).map(Arc::new),
Record::Delete => None,
}
@@ -2437,6 +2445,7 @@ struct Writer {
#[derive(Default)]
struct Outstanding {
count: Mutex<usize>,
chunks: AtomicUsize,
error: Mutex<Option<String>>,
done: Condvar,
}
@@ -2852,19 +2861,26 @@ impl SaveDir {
/// Queue proto chunks for the writer thread; loads see them meanwhile.
pub fn save_protos(
&self,
protos: &[SavedProto],
registry: &BlockRegistry,
biomes: &Biomes,
protos: Vec<SavedProto>,
registry: &'static BlockRegistry,
biomes: &Arc<Biomes>,
) -> Result<()> {
let tables = self.tables.get().map(|t| &**t);
let encoding = Arc::new(Encoding {
registry,
biomes: biomes.clone(),
tables: self.tables.get().cloned(),
shape: self.shape,
});
let records = protos
.iter()
.into_iter()
.map(|p| {
let tag = encode_proto(p, registry, biomes, tables, self.shape);
((p.column.x, p.column.z), Some(tag))
(
(p.column.x, p.column.z),
Record::Proto(Arc::new(p), encoding.clone()),
)
})
.collect();
self.write_records("region", records, false)
self.queue(Folder::Region, records, false)
}
/// Entities saved for a chunk (`entities/`; older chunks may still keep
/// them in the chunk's own `entities` list).
@@ -2909,7 +2925,14 @@ impl SaveDir {
.map_err(|_| "writer poisoned")? += 1;
let mut writer = self.writer.lock().map_err(|_| "writer poisoned")?;
let writer = writer.get_or_insert_with(|| self.start_writer());
let chunks = if folder == Folder::Region {
batch.len()
} else {
0
};
self.outstanding.chunks.fetch_add(chunks, Ordering::Relaxed);
if writer.send.send((folder, batch)).is_err() {
self.outstanding.chunks.fetch_sub(chunks, Ordering::Relaxed);
*self
.outstanding
.error
@@ -2982,6 +3005,9 @@ impl SaveDir {
}
}
}
if kind == Folder::Region {
outstanding.chunks.fetch_sub(batch.len(), Ordering::Relaxed);
}
if let Ok(mut count) = outstanding.count.lock() {
*count -= 1;
outstanding.done.notify_all();
@@ -3007,6 +3033,10 @@ impl SaveDir {
}
self.write_result()
}
/// `ChunkMap.activeChunkWrites`: chunk records, excluding entity/POI IO.
pub fn active_chunk_writes(&self) -> usize {
self.outstanding.chunks.load(Ordering::Relaxed)
}
/// Poll durability without holding up the event loop. Errors remain visible
/// to every waiter, including a later synchronous shutdown.
pub fn writes_finished(&self) -> Result<bool> {
@@ -5276,7 +5306,7 @@ impl<G: ChunkGenerator> ChunkGenerator for Persistent<G> {
.collect();
if let Err(e) = self
.save
.save_protos(&saved, self.registry.registry, &self.biomes)
.save_protos(saved, self.registry.registry, &self.biomes)
{
log::warn!("Horizon: failed to save proto chunks: {e}");
}
@@ -6442,11 +6472,17 @@ mod tests {
save.save_entities_later(vec![((1, 2), vec![entity(2)])])
.unwrap();
assert!(!save.writes_finished().unwrap());
assert_eq!(save.active_chunk_writes(), 0); // entity batches do not count
save.write_records("region", vec![((0, 0), None), ((1, 0), None)], false)
.unwrap();
// The writer is blocked above: two chunk records in ONE queued batch.
assert_eq!(save.active_chunk_writes(), 2);
assert_eq!(save.load_entities(1, 2), vec![entity(2)]);
assert!(super::SessionLock::acquire(&dir).is_err());
drop(blocked);
save.flush().unwrap();
assert!(save.writes_finished().unwrap());
assert_eq!(save.active_chunk_writes(), 0);
let reopened = super::SaveDir::new(dir.clone(), LevelShape::OVERWORLD);
assert_eq!(reopened.load_entities(1, 2), vec![entity(2)]);
save.save_entities_later(vec![((1, 2), vec![])]).unwrap();
@@ -200,3 +200,45 @@ fn opened_loot_parity_fixture() {
.unwrap();
}
}
#[test]
fn eager_save_edits_reach_disk_during_ticks_and_again_after_cooldown() {
use crate::world::ModifiedChunks;
use std::time::{Duration, Instant};
let (registry, biomes) = fixture();
let registry = Box::leak(Box::new(registry));
let biomes = Arc::new(biomes);
let dir = std::env::temp_dir().join(format!("horizon-eager-edits-{}", std::process::id()));
fs::create_dir_all(&dir).unwrap();
let save = SaveDir::new(dir.clone(), LevelShape::OVERWORLD);
let mut chunk = Chunk::empty(0, 0);
let mut dirty = ModifiedChunks::default();
let now = Instant::now();
for tick in [0, 1, 199, 200] {
if tick <= 1 {
chunk.set(1, 64, 2, if tick == 0 { 1 } else { 0 });
dirty.insert((0, 0));
}
dirty.save_eagerly(
now + Duration::from_millis(tick * 50),
|| save.active_chunk_writes() < 128,
|_| true,
|_| {
save.save_later(&[Arc::new(chunk.clone())], registry, &biomes)
.unwrap();
true
},
);
save.flush().unwrap(); // only a barrier: no quit/autosave submission
let reopened = SaveDir::new(dir.clone(), LevelShape::OVERWORLD);
assert_eq!(
reopened
.load(0, 0, registry, &biomes)
.unwrap()
.get(1, 64, 2),
if tick < 200 { 1 } else { 0 }
);
}
drop(save);
fs::remove_dir_all(dir).unwrap();
}
+96 -8
View File
@@ -144,20 +144,25 @@ impl RandomTicks {
}
}
/// Boxes selected by one property (legacy) or an ordered tuple of properties.
/// Values selected by one property or an ordered tuple of state properties.
#[derive(Clone, Debug, Deserialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct BlockShapes {
pub struct StateValues<T> {
#[serde(default)]
pub property: String,
#[serde(default)]
pub properties: Vec<String>,
#[serde(default)]
pub values: BTreeMap<String, Vec<[f64; 6]>>,
pub default: Option<Vec<[f64; 6]>>,
pub values: BTreeMap<String, T>,
pub default: Option<T>,
}
impl BlockShapes {
/// Block-local boxes selected by state.
pub type BlockShapes = StateValues<Vec<[f64; 6]>>;
/// Light emitted by a state, as an integer from 0 to 15.
pub type StateLight = StateValues<u8>;
impl<T> StateValues<T> {
pub fn property_names(&self) -> Vec<&str> {
if self.property.is_empty() {
self.properties.iter().map(String::as_str).collect()
@@ -166,7 +171,7 @@ impl BlockShapes {
}
}
fn check(&self) -> Result<(), String> {
fn check_selection(&self, label: &str) -> Result<(), String> {
let names = self.property_names();
if (!self.property.is_empty() && !self.properties.is_empty())
|| names.iter().any(|name| !state_name(name))
@@ -174,14 +179,25 @@ impl BlockShapes {
|| (names.is_empty() && !self.values.is_empty())
|| (self.values.is_empty() && self.default.is_none())
{
return Err("shapes needs distinct properties and values, or a default".into());
return Err(format!(
"{label} needs distinct properties and values, or a default"
));
}
for key in self.values.keys() {
let values: Vec<_> = key.split(',').collect();
if values.len() != names.len() || values.iter().any(|value| !state_name(value)) {
return Err(format!("invalid shape key {key:?}: one value per property"));
return Err(format!(
"invalid {label} key {key:?}: one value per property"
));
}
}
Ok(())
}
}
impl BlockShapes {
fn check(&self) -> Result<(), String> {
self.check_selection("shapes")?;
for boxes in self.values.values().chain(self.default.iter()) {
if boxes.len() > 16
|| boxes.iter().any(|bounds| {
@@ -196,6 +212,21 @@ impl BlockShapes {
}
}
impl StateLight {
fn check(&self) -> Result<(), String> {
self.check_selection("state_light")?;
if self
.values
.values()
.chain(self.default.iter())
.any(|value| *value > 15)
{
return Err("state_light values are integers 0 to 15".into());
}
Ok(())
}
}
/// The querying entity class, captured by the real caller. Empty and position-only
/// queries have no entity and always use the ordinary shape tables.
#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
@@ -261,6 +292,7 @@ struct BlockFile {
#[serde(default)]
collision_context_shapes: BTreeMap<ShapeEntityKind, BlockShapes>,
interaction_shapes: Option<BlockShapes>,
state_light: Option<StateLight>,
#[serde(default)]
state_properties: BTreeMap<String, StateProperty>,
#[serde(default)]
@@ -300,6 +332,8 @@ pub struct Block {
pub collision_context_shapes: BTreeMap<ShapeEntityKind, BlockShapes>,
/// Ray-face override; does not replace the outline hit point or create a hit.
pub interaction_shapes: Option<BlockShapes>,
/// Per-state emission; takes precedence over `properties.light`.
pub state_light: Option<StateLight>,
/// State properties of its own, which its template does not have.
pub state_properties: BTreeMap<String, StateProperty>,
/// State properties overlaid on the template default.
@@ -887,6 +921,7 @@ impl Content {
outline_context_shapes: file.outline_context_shapes,
collision_context_shapes: file.collision_context_shapes,
interaction_shapes: file.interaction_shapes,
state_light: file.state_light,
state_properties: file.state_properties,
default_state: file.default_state,
item: file.item,
@@ -1170,6 +1205,9 @@ fn check_block(file: &Block) -> Result<(), String> {
{
shapes.check()?;
}
if let Some(light) = &file.state_light {
light.check()?;
}
for (name, property) in &file.state_properties {
if !state_name(name) {
return Err(format!(
@@ -1464,6 +1502,55 @@ capabilities = ["registries"]
content
}
#[test]
fn state_light_checks_ranges_selectors_and_native_declarations() {
let load = |name: &str, table: &str| {
let declaration = format!(
r#"{{"template":"minecraft:stone","properties":{{"light":3}},"state_light":{table}}}"#
);
hmod(
name,
MANIFEST_TEXT,
&[("data/ruby/horizon/block/lamp.json", declaration.as_bytes())],
)
};
let content = load(
"state-light-valid",
r#"{"properties":["facing","lit"],"values":{"north,true":15},"default":0}"#,
)
.unwrap();
assert_eq!(content.blocks[0].properties.light, Some(3));
let table = content.blocks[0].state_light.as_ref().unwrap();
assert_eq!(table.default, Some(0));
assert_eq!(table.values["north,true"], 15);
for (i, table) in [
r#"{"default":-1}"#,
r#"{"default":16}"#,
r#"{"default":256}"#,
r#"{"default":2.5}"#,
r#"{"default":true}"#,
r#"{"default":"15"}"#,
r#"{"properties":["axis","axis"],"default":0}"#,
r#"{"property":"axis","properties":["axis"],"default":0}"#,
r#"{"properties":["axis","lit"],"values":{"x":15},"default":0}"#,
r#"{"values":{"x":15},"default":0}"#,
r#"{}"#,
]
.iter()
.enumerate()
{
assert!(
load(&format!("state-light-invalid-{i}"), table).is_err(),
"{table}"
);
}
let mut declared = Content::declared("ruby");
let mut lamp = content.blocks[0].clone();
lamp.state_light.as_mut().unwrap().default = Some(16);
assert!(declared.declare(&[lamp], &[]).is_err());
assert!(declared.blocks.is_empty());
}
#[test]
fn place_block_checks_files_and_native_declarations() {
for (i, target) in ["ruby:frame", "minecraft:stone"].iter().enumerate() {
@@ -1607,6 +1694,7 @@ capabilities = ["registries"]
outline_context_shapes: BTreeMap::new(),
collision_context_shapes: BTreeMap::new(),
interaction_shapes: None,
state_light: None,
state_properties: BTreeMap::new(),
default_state: BTreeMap::new(),
properties: BlockProperties {
+175 -14
View File
@@ -141,6 +141,50 @@ impl Emitter {
}
}
/// A sound emitter (wiki "Sound Emission"): the sound event, and as expressions how often
/// (`chance`), where (`x`, `y`, `z`) and how (`volume`, `pitch`).
pub struct SoundEmitter {
pub sound: String,
/// `chance`, `x`, `y`, `z`, `volume`, `pitch`: those present.
pub fields: BTreeMap<String, Program>,
/// Delayed by the distance, as fireworks' far bangs.
pub distance_delay: bool,
pub state_predicate: Option<Json>,
pub biomes: Option<Json>,
}
impl SoundEmitter {
pub fn parse(v: &Json) -> Result<Self, String> {
let o = v.as_object().ok_or("a sound emitter is not an object")?;
let sound = o
.get("sound")
.and_then(Json::as_str)
.ok_or("a sound emitter without a sound")?;
let sound = if sound.contains(':') {
sound.to_owned()
} else {
format!("minecraft:{sound}")
};
let (fields, _) = expressions(o, &["chance", "x", "y", "z", "volume", "pitch"])?;
Ok(Self {
sound,
fields,
distance_delay: o.get("distance_delay").and_then(Json::as_bool) == Some(true),
state_predicate: o.get("state_predicate").cloned(),
biomes: o.get("biomes").cloned(),
})
}
}
fn sound_emitters(v: Option<&Json>) -> Result<Vec<SoundEmitter>, String> {
v.and_then(Json::as_array)
.into_iter()
.flatten()
.enumerate()
.map(|(i, e)| SoundEmitter::parse(e).map_err(|err| format!("sound emitter {i}: {err}")))
.collect()
}
fn emitters(v: Option<&Json>) -> Result<Vec<Emitter>, String> {
v.and_then(Json::as_array)
.into_iter()
@@ -189,8 +233,7 @@ pub struct CustomParticle {
pub ticker: BTreeMap<String, Program>,
pub ticker_customs: BTreeMap<String, Program>,
pub emitters: Vec<Emitter>,
/// Sound emitters, kept as JSON.
pub sound_emitters: Vec<Json>,
pub sound_emitters: Vec<SoundEmitter>,
pub has_physics: bool,
pub kill_on_contact: bool,
pub kill_when_still: bool,
@@ -209,6 +252,84 @@ pub struct CustomParticle {
/// Fields kept as JSON for the renderer: `offset`, `colormap`, `model`, a GPU particle's
/// `texture`, `shader` and `uniforms`.
pub render: Map<String, Json>,
/// A GPU particle's own fields (wiki "GPU Particles"), for `"type": "gpu"`.
pub gpu: Option<Gpu>,
}
/// What a GPU particle (`"type": "gpu"`) adds: its whole motion and look are a function of
/// the spawn record and the time, so it never ticks.
#[derive(Clone, Debug, PartialEq)]
pub struct Gpu {
/// The texture's full path (`ns:textures/particle/....png`), a vertical strip of `frames`.
pub texture: String,
pub frames: u32,
/// Downward acceleration (blocks per tick squared) and the velocity kept per tick.
pub gravity: f64,
pub friction: f64,
/// Half-width at death, and colour at death (ARGB).
pub size_end: Option<f64>,
pub color_end: Option<u32>,
/// Fractions of the life spent fading in and out.
pub fade: [f64; 2],
/// Horizontal wobble amplitude (blocks), roll speed (radians per tick), height over width.
pub sway: f64,
pub spin: f64,
pub aspect: f64,
/// One spawn drawn as `count` quads spread over a `size` box.
pub area: Option<([f64; 3], u32)>,
/// Quads under the world surface are hidden.
pub kill_below_heightmap: bool,
}
impl Gpu {
fn parse(o: &Map<String, Json>) -> Result<Self, String> {
let num = |key: &str, default: f64| o.get(key).and_then(Json::as_f64).unwrap_or(default);
let color_end = match o.get("color_end").and_then(Json::as_str) {
Some(c) => Some(
u32::from_str_radix(c.trim_start_matches('#'), 16)
.map_err(|_| format!("color_end {c} is no #AARRGGBB colour"))?,
),
None => None,
};
let pair =
|v: &Json| -> Option<Vec<f64>> { v.as_array()?.iter().map(Json::as_f64).collect() };
let fade = o.get("fade").and_then(pair).unwrap_or_default();
let area = match o.get("area") {
Some(a) => {
let size = a.get("size").and_then(pair).filter(|s| s.len() == 3);
let size = size.ok_or("area needs a size [x, y, z]")?;
let count = a
.get("count")
.and_then(Json::as_u64)
.unwrap_or(1)
.clamp(1, 1024) as u32;
Some(([size[0], size[1], size[2]], count))
}
None => None,
};
Ok(Self {
texture: o
.get("texture")
.and_then(Json::as_str)
.ok_or("a GPU particle needs a texture")?
.to_owned(),
frames: o.get("frames").and_then(Json::as_u64).unwrap_or(1).max(1) as u32,
gravity: num("gravity", 0.0),
friction: num("friction", 1.0),
size_end: o.get("size_end").and_then(Json::as_f64),
color_end,
fade: [
fade.first().copied().unwrap_or(0.0),
fade.get(1).copied().unwrap_or(0.0),
],
sway: num("sway", 0.0),
spin: num("spin", 0.0),
aspect: num("aspect", 1.0),
area,
kill_below_heightmap: o.get("kill_below_heightmap").and_then(Json::as_bool)
== Some(true),
})
}
}
const INITIALIZER: [&str; 13] = [
@@ -286,6 +407,10 @@ impl CustomParticle {
.into_iter()
.filter_map(|k| Some((k.to_owned(), o.get(k)?.clone())))
.collect();
let gpu = match kind {
ParticleKind::Gpu => Some(Gpu::parse(o)?),
_ => None,
};
Ok(Self {
kind,
render_type,
@@ -294,11 +419,7 @@ impl CustomParticle {
ticker,
ticker_customs,
emitters: emitters(o.get("particle_emitters"))?,
sound_emitters: o
.get("sound_emitters")
.and_then(Json::as_array)
.cloned()
.unwrap_or_default(),
sound_emitters: sound_emitters(o.get("sound_emitters"))?,
has_physics: flag("has_physics", true),
kill_on_contact: flag("kill_on_contact", false),
kill_when_still: flag("kill_when_still", true),
@@ -318,6 +439,7 @@ impl CustomParticle {
light_level: count("light_level").map_or(0, |l| l.min(15) as u8),
rotation_mode: text("rotation_mode").unwrap_or("look_at_xyz").to_owned(),
render,
gpu,
})
}
}
@@ -327,7 +449,7 @@ pub struct BlockEmitters {
pub targets: Targets,
pub priority: i32,
pub particle_emitters: Vec<Emitter>,
pub sound_emitters: Vec<Json>,
pub sound_emitters: Vec<SoundEmitter>,
}
impl BlockEmitters {
@@ -337,11 +459,7 @@ impl BlockEmitters {
return Ok(None);
}
let particle_emitters = emitters(json.get("particle_emitters"))?;
let sound_emitters = json
.get("sound_emitters")
.and_then(Json::as_array)
.cloned()
.unwrap_or_default();
let sound_emitters = sound_emitters(json.get("sound_emitters"))?;
if particle_emitters.is_empty() && sound_emitters.is_empty() {
return Ok(None);
}
@@ -410,7 +528,7 @@ mod tests {
}
);
assert_eq!(
kind(json!({"type": "gpu", "render_type": "invisible"})).unwrap(),
kind(json!({"type": "gpu", "texture": "a:textures/particle/b.png"})).unwrap(),
ParticleKind::Gpu
);
assert!(kind(json!({"type": "laser"})).is_err());
@@ -476,4 +594,47 @@ mod tests {
assert_eq!(entity.emitters[0].max_distance, Some(32.0));
assert!(entity.emitters[0].fields.contains_key("count"));
}
#[test]
fn a_gpu_particle_keeps_its_motion_and_look_fields() {
let p = CustomParticle::parse(&serde_json::json!({
"type": "gpu",
"texture": "my_pack:textures/particle/rain.png",
"frames": 4,
"gravity": 0.1,
"fade": [0.4, 0.2],
"color_end": "#80FF0000",
"area": {"size": [6, 6, 6], "count": 2},
"kill_below_heightmap": true
}))
.unwrap();
let gpu = p.gpu.unwrap();
assert_eq!(gpu.texture, "my_pack:textures/particle/rain.png");
assert_eq!((gpu.frames, gpu.gravity, gpu.friction), (4, 0.1, 1.0));
assert_eq!(gpu.fade, [0.4, 0.2]);
assert_eq!(gpu.color_end, Some(0x80FF_0000));
assert_eq!(gpu.area, Some(([6.0, 6.0, 6.0], 2)));
assert!(gpu.kill_below_heightmap);
// Without a texture a GPU particle is no particle.
assert!(CustomParticle::parse(&serde_json::json!({"type": "gpu"})).is_err());
}
#[test]
fn sound_emitters_compile_their_expressions() {
let p = CustomParticle::parse(&serde_json::json!({
"sound_emitters": [
{"sound": "block.bubble_column.upwards_ambient", "chance": "r.rand()*0.3",
"volume": "sqrt(square(o.xd)+square(o.yd)+square(o.zd))", "distance_delay": true}
]
}))
.unwrap();
let e = &p.sound_emitters[0];
assert_eq!(e.sound, "minecraft:block.bubble_column.upwards_ambient");
assert_eq!(e.fields.keys().collect::<Vec<_>>(), ["chance", "volume"]);
assert!(e.distance_delay);
assert!(
CustomParticle::parse(&serde_json::json!({"sound_emitters": [{"chance": "1"}]}))
.is_err()
);
}
}
+30 -99
View File
@@ -3,7 +3,10 @@
use std::collections::BTreeMap;
/// The Iris feature flags (`iris.features.*`) Horizon has. `BLOCK_EMISSION_ATTRIBUTE`: terrain's
/// The feature flags of `iris.features.*` (spec 3.2) Horizon has. A pack that requires one of
/// them loads; one it lacks is noted. Horizon defines no `IRIS_FEATURE_<name>` macro for them
/// (spec OQ-2, docs/cleanroom/README.md): that macro family names another loader, so packs take
/// the path they take without it. `BLOCK_EMISSION_ATTRIBUTE`: terrain's
/// `at_midBlock.w` is the light its block gives. `SEPARATE_HARDWARE_SAMPLERS` (ShaderDoc,
/// "Separate Hardware Shadow Samplers"): `shadowtex0`/`shadowtex1` stay plain depth samplers when
/// hardware filtering is on, `shadowtex0HW`/`shadowtex1HW` compare; Horizon picks a comparison
@@ -143,14 +146,8 @@ impl Macros {
// default; Iris 1.11.7 defines both too, with or without a resource pack's maps).
m.define("MC_NORMAL_MAP", "");
m.define("MC_SPECULAR_MAP", "");
// Packs take Iris's extensions (compute programs, images, custom textures) where this
// is defined.
m.define("IS_IRIS", "");
// Iris 1.11.7's own, as it defines them on 26.3 (observed with a probe pack): its version
// (1.11.7 as 11107), and translucent terrain sorted back to front, as Horizon sorts it
// (vanilla's `VertexSorting.byDistance`, `gfx/translucency.rs`).
m.define("IRIS_VERSION", "11107");
m.define("IRIS_HAS_TRANSLUCENCY_SORTING", "");
// No `IS_IRIS`, `IRIS_VERSION` or `IRIS_HAS_*`: Horizon is not that loader and does not
// claim its identity (spec OQ-2, docs/cleanroom/README.md). Packs take their other path.
// Distant Horizons' block materials, which Iris defines with or without the mod (packs
// compare ids with them unguarded: Exposa's `blockID == DH_BLOCK_LEAVES`), numbered as
// the mod's `EDhApiBlockMaterial` numbers them.
@@ -181,34 +178,13 @@ impl Macros {
self.define("MC_MIPMAP_LEVEL", mipmap_levels.to_string());
}
/// Every feature Horizon has, as `IRIS_FEATURE_<name>`: what `shaders.properties` itself is
/// read with. Iris 1.11.7 defines a feature it has there whether the pack lists it as
/// optional, as required or not at all (probe pack), while its programs see only the optional
/// ones (`define_features`).
pub fn define_every_feature(&mut self) {
for feature in FEATURES {
self.define(format!("IRIS_FEATURE_{feature}"), "");
}
}
/// `iris.features.optional`'s features Horizon has, as `IRIS_FEATURE_<name>`; a note for
/// each feature `iris.features.required` names that it lacks. As in Iris 1.11.7, a name
/// matches in any case, and the macro keeps the name as the pack wrote it (`ssbo` defines
/// `IRIS_FEATURE_ssbo`).
pub fn define_features(&mut self, properties: &crate::Properties) -> Vec<String> {
let words = |key: &str| {
properties
.get(key)
.map(|v| v.split_whitespace().map(str::to_owned).collect::<Vec<_>>())
.unwrap_or_default()
};
for feature in words("iris.features.optional") {
if is_feature(&feature) {
self.define(format!("IRIS_FEATURE_{feature}"), "");
}
}
words("iris.features.required")
.into_iter()
/// A note for each feature `iris.features.required` names that Horizon lacks; a name matches
/// in any case. Optional features need nothing: no macro is defined for them (see `FEATURES`).
pub fn feature_notes(properties: &crate::Properties) -> Vec<String> {
properties
.get("iris.features.required")
.unwrap_or_default()
.split_whitespace()
.filter(|f| !is_feature(f))
.map(|f| format!("the pack requires `{f}`, which is not supported yet"))
.collect()
@@ -565,13 +541,14 @@ mod tests {
.map_err(|e| format!("{e:?}"))
}
/// Packs see what Iris 1.11.7 defines on 26.3: GL and GLSL 3.3, its version and its sorted
/// translucent terrain, so they take the paths they take there.
/// Packs see GL and GLSL 3.3, and no other loader's identity (spec OQ-2): a declaration made
/// only under that condition is seen.
#[test]
fn programs_see_the_versions_iris_reports() {
fn programs_see_gl_33_and_no_other_loader() {
let fsh = "#version 330\nout vec4 c;\n\
#if MC_GL_VERSION == 330 && MC_GLSL_VERSION == 330 && IRIS_VERSION >= 11107 \\\n\
&& defined IRIS_HAS_TRANSLUCENCY_SORTING\nconst float v = 1.0;\n#endif\n\
#if MC_GL_VERSION == 330 && MC_GLSL_VERSION == 330 && !defined IS_IRIS \\\n\
&& !defined IRIS_VERSION && !defined IRIS_HAS_TRANSLUCENCY_SORTING\n\
const float v = 1.0;\n#endif\n\
void main() { c = vec4(v); }\n";
translates(fsh, &Macros::standard()).unwrap();
}
@@ -601,70 +578,24 @@ mod tests {
translates(fsh, &Macros::standard()).unwrap();
}
/// `BLOCK_EMISSION_ATTRIBUTE` (terrain's `at_midBlock.w` is its block's light) is one
/// Horizon has: defined when optional, no note when required.
/// A required feature Horizon has loads without a note, one it lacks gets a note, whatever the
/// case it is written in; no `IRIS_FEATURE_*` macro is defined either way (spec OQ-2).
#[test]
fn block_emission_attribute_is_a_feature() {
fn required_features_are_noted_and_never_defined() {
let properties = crate::Properties::parse(
"iris.features.optional = BLOCK_EMISSION_ATTRIBUTE\n\
iris.features.required = BLOCK_EMISSION_ATTRIBUTE\n",
"iris.features.optional = COMPUTE_SHADERS ssbo NOT_A_FEATURE\n\
iris.features.required = BLOCK_EMISSION_ATTRIBUTE HIGHER_SHADOWCOLOR custom_images \
Not_A_Feature\n",
&Macros::standard(),
);
let mut m = Macros::standard();
assert!(m.define_features(&properties).is_empty());
assert!(m.is_defined("IRIS_FEATURE_BLOCK_EMISSION_ATTRIBUTE"));
}
#[test]
fn iris_features_are_defined_or_noted() {
let properties = crate::Properties::parse(
"iris.features.optional = COMPUTE_SHADERS SEPARATE_HARDWARE_SAMPLERS NOT_A_FEATURE\n\
iris.features.required = ENTITY_TRANSLUCENT SEPARATE_HARDWARE_SAMPLERS NOT_A_FEATURE\n",
&Macros::standard(),
);
let mut m = Macros::standard();
let notes = m.define_features(&properties);
assert!(m.is_defined("IRIS_FEATURE_COMPUTE_SHADERS"));
assert!(m.is_defined("IRIS_FEATURE_SEPARATE_HARDWARE_SAMPLERS"));
assert!(!m.is_defined("IRIS_FEATURE_NOT_A_FEATURE"));
assert_eq!(
notes,
["the pack requires `NOT_A_FEATURE`, which is not supported yet"]
);
// HIGHER_SHADOWCOLOR: Iris 1.11.7 defines it for `iris.features.optional` (probe p7951c).
let mut m = Macros::default();
let notes = m.define_features(&crate::Properties::parse(
"iris.features.optional = HIGHER_SHADOWCOLOR\n\
iris.features.required = HIGHER_SHADOWCOLOR\n",
&Macros::default(),
));
assert!(m.is_defined("IRIS_FEATURE_HIGHER_SHADOWCOLOR") && notes.is_empty());
}
/// Iris 1.11.7, with probe packs of our own (#8996): a feature name matches in any case, and
/// an optional one defines the macro with the name as written, not in upper case.
#[test]
fn feature_names_match_in_any_case() {
let mut m = Macros::standard();
let notes = m.define_features(&crate::Properties::parse(
"iris.features.optional = ssbo Custom_Images\n\
iris.features.required = compute_shaders ssbo custom_images Not_A_Feature\n",
&Macros::standard(),
));
assert!(m.is_defined("IRIS_FEATURE_ssbo"));
assert!(m.is_defined("IRIS_FEATURE_Custom_Images"));
for absent in [
"IRIS_FEATURE_SSBO",
"IRIS_FEATURE_CUSTOM_IMAGES",
"IRIS_FEATURE_compute_shaders",
"IRIS_FEATURE_COMPUTE_SHADERS",
] {
assert!(!m.is_defined(absent), "{absent}");
}
assert_eq!(
notes,
Macros::feature_notes(&properties),
["the pack requires `Not_A_Feature`, which is not supported yet"]
);
let mut m = Macros::standard();
m.define_renderer(4);
let iris: Vec<_> = m.0.keys().filter(|name| name.contains("IRIS")).collect();
assert!(iris.is_empty(), "{iris:?}");
}
#[test]
+14 -22
View File
@@ -86,27 +86,23 @@ pub fn with_options(pack: &Pack, macros: &crate::Macros) -> crate::Macros {
out
}
/// The macros `shaders.properties` is read with: the pack's options over `macros`, and every
/// feature Horizon has (`Macros::define_every_feature`), so a custom uniform or a screen behind
/// `#ifdef IRIS_FEATURE_<name>` is there as in Iris 1.11.7.
/// The macros `shaders.properties` is read with: the pack's options over `macros`. No
/// `IRIS_FEATURE_<name>` is among them (spec OQ-2), so what a pack puts behind one is not read.
pub fn properties_macros(pack: &Pack, macros: &crate::Macros) -> crate::Macros {
let mut out = with_options(pack, macros);
out.define_every_feature();
out
with_options(pack, macros)
}
/// A pack's `shaders.properties` as the game reads it before the programs: parsed with
/// `properties_macros` over `macros` (the standard set and the renderer's), after which `macros`
/// gets the features the pack turns on (`iris.features.*`, `Macros::define_features`). Returns
/// the properties and a note for each required feature Horizon lacks. Whatever reads a pack's
/// programs (the game, the `real_packs` test) reads them with these macros.
/// `properties_macros` over `macros` (the standard set and the renderer's). Returns the
/// properties and a note for each required feature Horizon lacks (`Macros::feature_notes`).
/// Whatever reads a pack's programs (the game, the `real_packs` test) reads them with `macros`.
pub fn read_properties(
pack: &Pack,
macros: &mut crate::Macros,
) -> (crate::Properties, Vec<String>) {
let text = pack.read_text("shaders.properties").unwrap_or_default();
let properties = crate::Properties::parse(&text, &properties_macros(pack, macros));
let notes = macros.define_features(&properties);
let notes = crate::Macros::feature_notes(&properties);
(properties, notes)
}
@@ -426,19 +422,18 @@ iris.features.required = NOT_A_FEATURE
let (properties, notes) = read_properties(&pack, &mut macros);
assert_eq!(properties.get("screen"), Some("x"));
assert_eq!(properties.get("vendor"), Some("other"));
assert!(macros.is_defined("IRIS_FEATURE_SSBO"));
assert!(!macros.is_defined("IRIS_FEATURE_SSBO"));
assert_eq!(
notes,
["the pack requires `NOT_A_FEATURE`, which is not supported yet"]
);
}
/// `shaders.properties` sees every feature Horizon has, listed or not, as Iris 1.11.7 reads
/// it (probe pack, measured there: a custom uniform behind each `#ifdef IRIS_FEATURE_<name>`
/// was set for an optional, a required and an unlisted feature, the optional list written on
/// the last line). The programs still get only the optional ones, as there.
/// Neither `shaders.properties` nor the programs see an `IRIS_FEATURE_<name>` (spec OQ-2):
/// what a pack puts behind one is skipped, for an optional, a required, an unlisted and an
/// unknown feature alike, and a required feature Horizon has gets no note.
#[test]
fn properties_see_every_feature_and_programs_only_the_optional_ones() {
fn properties_and_programs_see_no_feature_macro() {
let bytes = crate::pack::tests::zip(&[(
"shaders/shaders.properties",
"iris.features.required = COMPUTE_SHADERS\n\
@@ -452,12 +447,9 @@ iris.features.required = NOT_A_FEATURE
let mut macros = crate::Macros::standard();
let (properties, notes) = read_properties(&pack, &mut macros);
let read = |name: &str| properties.get(&format!("uniform.float.{name}")).is_some();
assert!(read("optional") && read("required") && read("unlisted"));
assert!(!read("unknown"));
assert!(!read("optional") && !read("required") && !read("unlisted") && !read("unknown"));
assert!(notes.is_empty());
assert!(macros.is_defined("IRIS_FEATURE_CUSTOM_IMAGES"));
assert!(!macros.is_defined("IRIS_FEATURE_COMPUTE_SHADERS"));
assert!(!macros.is_defined("IRIS_FEATURE_SSBO"));
assert!(!macros.is_defined("IRIS_FEATURE_CUSTOM_IMAGES"));
}
#[test]
@@ -2,28 +2,22 @@
use horizon_shaders::{Macros, Properties};
#[test]
fn requested_reversed_culling_selects_the_supported_shader_path() {
fn requested_reversed_culling_loads_and_packs_take_their_base_path() {
let properties = Properties::parse(
"iris.features.optional=REVERSED_CULLING\niris.features.required=REVERSED_CULLING\n",
&Macros::standard(),
);
let mut macros = Macros::standard();
let notes = macros.define_features(&properties);
let notes = Macros::feature_notes(&properties);
assert!(
notes.is_empty(),
"supported reversed culling must not be rejected: {notes:?}"
);
// This is how an original pack chooses a voxel-compatible path rather than
// silently falling back despite the renderer supporting reversed culling.
// Horizon defines no `IRIS_FEATURE_*` (spec OQ-2), so a pack that only asks for reversed
// culling behind that macro keeps its other value; `shadow.culling=reversed` written
// plainly still applies.
let selected = Properties::parse(
"#ifdef IRIS_FEATURE_REVERSED_CULLING\nshadow.culling=reversed\n#else\nshadow.culling=true\n#endif\n",
&macros,
);
assert_eq!(selected.get("shadow.culling"), Some("reversed"));
// The feature is opt-in: the fallback remains selected without a request.
let fallback = Properties::parse(
"#ifdef IRIS_FEATURE_REVERSED_CULLING\nshadow.culling=reversed\n#else\nshadow.culling=true\n#endif\n",
&Macros::standard(),
);
assert_eq!(fallback.get("shadow.culling"), Some("true"));
assert_eq!(selected.get("shadow.culling"), Some("true"));
}
+14 -22
View File
@@ -5,19 +5,13 @@ use horizon_shaders::{
};
#[test]
fn optional_fade_feature_selects_the_pack_transition() {
let mut macros = Macros::standard();
fn requested_fade_feature_loads() {
let properties = Properties::parse(
"iris.features.optional=FADE_VARIABLE\niris.features.required=FADE_VARIABLE\n",
&macros,
&Macros::standard(),
);
let notes = macros.define_features(&properties);
let notes = Macros::feature_notes(&properties);
assert!(notes.is_empty(), "requested fade rejected: {notes:?}");
let selected = Properties::parse(
"#ifdef IRIS_FEATURE_FADE_VARIABLE\nprobe=transition\n#else\nprobe=opaque\n#endif\n",
&macros,
);
assert_eq!(selected.get("probe"), Some("transition"));
}
#[test]
@@ -108,25 +102,23 @@ fn authored_fade_globals_keep_their_own_meaning() {
}
#[test]
fn fade_feature_preserves_negotiation_and_pack_case() {
for (request, expected, rejected) in [
("", "opaque", false),
("iris.features.required=FADE_VARIABLE\n", "opaque", false),
(
"iris.features.optional=fade_variable\n",
"transition",
false,
),
("iris.features.required=UNKNOWN_FADE\n", "opaque", true),
fn fade_feature_negotiation_ignores_case_and_defines_no_macro() {
for (request, rejected) in [
("", false),
("iris.features.required=FADE_VARIABLE\n", false),
("iris.features.required=fade_variable\n", false),
("iris.features.optional=fade_variable\n", false),
("iris.features.required=UNKNOWN_FADE\n", true),
] {
let mut macros = Macros::standard();
let macros = Macros::standard();
let properties = Properties::parse(request, &macros);
assert_eq!(!macros.define_features(&properties).is_empty(), rejected);
assert_eq!(!Macros::feature_notes(&properties).is_empty(), rejected);
// No `IRIS_FEATURE_*` (spec OQ-2): the pack keeps its base path.
let selected = Properties::parse(
"#ifdef IRIS_FEATURE_fade_variable\nprobe=transition\n#else\nprobe=opaque\n#endif\n",
&macros,
);
assert_eq!(selected.get("probe"), Some(expected));
assert_eq!(selected.get("probe"), Some("opaque"));
}
}
+1
View File
@@ -32,6 +32,7 @@ To play, use [the launcher](../launcher/README.md); the project site is [playhor
- [Forking](forking.md): the `horizon-extras` feature that holds Horizon's own extras (cosmetics, Friends, its settings pages...), and building the game without them.
- [Narrator](narrator.md): vanilla's text-to-speech (Ctrl+B): the voices per platform, how screens record what they narrate, `horizon-ctl narration`, and where it still differs from vanilla.
- [Shader packs](shaders.md): Horizon's own WGSL shader packs, how to make and check one (`horizon-shader`).
- [Shader-pack clean room](cleanroom/README.md): how Java shader-pack support was rebuilt without another loader's code: the [format specification](cleanroom/shader-packs-spec.md) and the [provenance log](cleanroom/provenance-log.md) of every source read.
- [Mods](mods.md): content mods (data) and native mods (Rust), how to make, install and publish them. [Native mods](native-mods.md): how the launcher builds them. [Native hook points modelled on NeoForge](native-hooks-neoforge.md): the proposal for new hooks.
- [Upgrading a 26.2 data pack](datapack-upgrade.md): `horizon-port-pack upgrade-datapack` rewrites a 26.2 worldgen pack in 26.3's formats, and how the rewrite is checked against vanilla.
- [Modpacks](modpacks.md): porting whole packs (ATM10 and smaller): import, inventory, carry-over, an agent per mod with a harness against the Java original, coverage, and how "10x" is measured.
+92
View File
@@ -0,0 +1,92 @@
# Clean-room rewrite of shader pack support
> Published on 2026-11-08 as the record of the shader-pack clean room (#6739, #1555). The text is as written in
> October 2026, except that local paths, machine names and the names of private files are replaced by a
> description of what they were, and the last section was added. Nothing else was changed.
Horizon's first shader pack support was written from another loader's source code, under a licence we do not meet,
and it is being removed. This folder holds what is needed to bring the feature back without taking anyone's code: a
specification written from public documentation, and the rules that keep the people who implement it away from
everything else.
## Files
| File | What it is | Who writes it |
|---|---|---|
| [shader-packs-spec.md](shader-packs-spec.md) | The specification: pack layout, programs and their order, `shaders.properties`, the uniform/attribute/sampler interface, render targets, shadow map, settings UI, open questions, sources | spec team |
| [provenance-log.md](provenance-log.md) | Every source the spec team read for the current revision, with revisions and hashes, and every incident | spec team |
| `questions/Q-NNN.md` (created when needed) | One question from the implementer per file | implementer |
| `decisions.md` (created when needed) | The implementer's answer to each open question of the spec, with the test that settled it | implementer |
## Two teams, two sets of sources
Each team is a separate person or agent session. Nobody moves from the spec team to the implementation team or back.
An agent session that has read any forbidden material is disqualified for both roles.
| | Spec team | Implementation team |
|---|---|---|
| May read | Public documentation of the pack format for pack authors (OptiFine's `shaders.txt` / `shaders.properties` docs, the `shaders.properties` documentation site, ShaderDoc, the shaderLABS wiki, tutorials about writing packs); the GLSL and OpenGL specifications; packs' own `.properties` and `.lang` files and folder layout; Minecraft 26.3 | **This folder only** (the spec, its open questions, the answers to its questions); Minecraft 26.3 (the project's reference, as for every Horizon feature); wgpu and naga documentation; Horizon's code outside the removed shader feature |
| May use | web search and fetch for documentation pages; local copies of public packs | running shader packs inside Horizon as black boxes and looking at the result; the packs' own `.properties` and `.lang` files when a test needs them |
| Never | source or binaries of Iris, OptiFine, Oculus, Canvas, Sodium or any other loader or renderer; Horizon's removed shader code (`crates/horizon-shaderpack`, `java_pack.rs`, `gfx/java_stages.rs`, `gfx/java_terrain.rs`, `gfx/pack_stages.rs`, `shader_pack.rs`, `docs/shader-packs.md`, `docs/java-packs.md`), its git history and its PRs; "how X does it" answers built from code | everything in the spec team's "Never" column, **and** the documentation the spec team read (the spec is the only channel), old Horizon notes, reports or issues about the removed feature, and `git log` / `git show` / `git blame` of the removed paths |
The Khronos GLSL and OpenGL specifications are public standards the spec cites by section. Whether the implementation
team may read them directly (they are needed to write a GLSL front end) is Ludovic's call; the spec team's
recommendation is yes, because they describe the language, not any loader.
Running another loader to look at a pack's output is not part of this process. If a comparison with real output is
needed, the spec team records the observation (what was seen, with which pack and settings) in a new spec revision.
## Workflow
1. **Spec.** The spec team writes `shader-packs-spec.md`, tags each statement with its source, and lists in section 9
every point the documentation leaves open or contradicts. Each revision updates the revision line at the top,
`provenance-log.md`, and a short changelog at the end of the spec.
2. **Implementation.** The implementation team builds the feature in Horizon from the spec, in Rust on wgpu/naga,
following Horizon's usual rules (small PRs, harnesses, both single-player paths, `pr_ready.py`). Every PR body says
which spec revision it implements and links the decisions it relied on.
3. **Open questions.** For each OQ of section 9 the implementer decides from first principles and from tests (packs
run in Horizon, vanilla 26.3, the GL conventions in spec 5.5), and records the decision and the test in
`decisions.md`. A decision is never taken from another implementation, its code, its issue tracker or its changelog.
4. **Questions back to the spec team.** When the spec is unclear, the implementer writes `questions/Q-NNN.md`: the
spec section, the question, and what was observed. It contains no code from anywhere else and no guess about how
another loader does it. The spec team answers only with a new revision of the spec (a clarified section, a new OQ,
or a recorded observation), with sources. Answers are never code, pseudo-code taken from an implementation, or
"loader X does Y".
5. **Review.** Reviewers of the implementation PRs follow the same "never" list as the implementer. Reviews compare
against the spec, vanilla and observed output only.
## Showing the result is independent
- **Provenance per section:** spec section 11 maps each section to its sources; `provenance-log.md` pins every source
to a revision and hash. Anyone can check a statement against the public document it cites.
- **Input manifest:** each implementation PR lists what its author read: the spec revision, the questions and answers,
`decisions.md` entries, the vanilla classes and the wgpu/naga pages it relied on.
- **Session records:** the orchestrator keeps the names of the agent sessions that did the spec and the
implementation, and confirms that no implementation session was ever given a forbidden path, file or link.
- **Name tripwire:** CI or the reviewer greps the new code, comments, commits and PRs for internal names of other
loaders (those listed in the maintainer's private agent rules, such as `AlphaTests`, `BlendModeOverride`, `ShadowMatrices`,
`ShaderPackScreen`, JCPP). The interface names a pack uses (uniform names, keys, file names) are format facts from the
spec and are expected.
- **Decisions on record:** every behaviour the documentation does not fix is traced to a `decisions.md` entry and its
test, not to anyone's code.
## Current state
- Revision 1 of the spec: 2026-10-24.
- No implementation has started. The removal of the old feature (`shaders-remove-1`) is a separate task.
## Orchestrator decisions (2026-10-24)
- The implementation team **may read the Khronos GLSL and OpenGL specifications** directly. They are public language standards, not any loader.
- **OQ-2: Horizon does not define another loader's identity macros** (`IS_IRIS`, `IRIS_VERSION`, `IRIS_FEATURE_*`, or OptiFine's version macro if one names OptiFine itself). Horizon is not that loader, and pretending to be it is how the complaint started. Packs that branch on those macros get their non-Iris path. The format's documented `MC_*` macros are defined.
- The old Rust spike is no longer in this folder. It was moved out of every agent's reach, to a private archive outside every repository.
- The implementation starts only after the removal PR (shaders-remove-1) has merged, in a fresh worktree of that main.
## Since publication (2026-11-08)
- The implementation is `crates/horizon-shaders` ([docs/shaders.md](../shaders.md) covers Horizon's own WGSL packs).
Its first slices were written by the implementation team above; later contributors did not have the spec, which
was private until now. Their pull requests follow [CONTRIBUTING.md](../../CONTRIBUTING.md#only-original-code-or-code-whose-license-allows-it).
- A shader-pack pull request now says which sources it used: the sections of this spec, the public documentation
pages, and the packs it ran as black boxes.
- Not published: the implementer's question files and the implementation plan, which were working notes.
+171
View File
@@ -0,0 +1,171 @@
# Provenance log: shader pack specification, revision 1
> Published on 2026-11-08 as the record of the shader-pack clean room (#6739, #1555). The text is as written in
> October 2026, except that local paths, machine names and the names of private files are replaced by a
> description of what they were. Nothing else was changed.
What the spec team read to write [shader-packs-spec.md](shader-packs-spec.md), and nothing else. Section 11 of the
spec maps each section to these sources.
Session: one AI agent (Claude Opus 5.5) working for Ludovic, 2026-10-24, on the maintainer's machine.
Downloads went to a scratch folder outside every repository; they are not kept here.
## 1. Documents fetched
| Source | How | Revision | SHA-256 of the fetched file |
|---|---|---|---|
| OptiFine `OptiFineDoc/doc/shaders.txt` | raw.githubusercontent.com, `sp614x/optifine` `master` | `07ec2ca62f81aa52cb9ed1a7fd49a4489c131b1e` | `159455dc6ab331acebd035c3322e07271faed65ef767e4175b7b7c645aa4cb86` |
| OptiFine `OptiFineDoc/doc/shaders.properties` | same | same | `a0bd75f21932b47f6b4303bf4590e86b2a3a79b302c4a55852d687bf25536f46` |
| OptiFine `OptiFineDoc/doc/properties_files.txt` | same | same | `74fe689551673f5c4a12e8962252a8011d869fdbd289c9d4f0da8e6951a079b3` |
| OptiFine `OptiFineDoc/doc/texture.properties` | same | same | `bead5ccacafae6ac4de5f7176ad979f3def77051684a7656ef27764d80de106d` |
| OptiFine `OptiFineDoc/doc/block.properties` (resource-pack feature, read and not used) | same | same | `813f8562e1713e9dfd96405bf9fa782be8edc3dfb26d9cce22f496f601d17118` |
| Documentation site `shaders.properties`: the 119 `.mdx` pages under `src/content/docs/current/` | raw.githubusercontent.com, `IrisShaders/docs` `main` | `98f84cbe456e7e367afe788885869be46681be8c` | per-page hashes not taken; the commit pins them |
| ShaderDoc: `uniforms.md`, `const-directives.md`, `iris-features.md`, `vertex-format-extensions.md`, `passes/composite.md`, `unsupported-features.md`, `directives/properties.md`, `README.md` | raw.githubusercontent.com, `IrisShaders/ShaderDoc` `master` | `ddf473726710c6504422d192784c1652f8aece98` | `6e8b73a9…` (uniforms), `27a9c7c6…` (const-directives), `a9a8ca5f…` (iris-features), `b739b06a…` (vertex-format-extensions), `f6e1fd4d…` (composite), `e81a07e1…` (unsupported), `7709c3a9…` (properties), `78ab2517…` (README) |
| shaderLABS wiki: Rendering Pipeline (OptiFine, ShadersMod); Shader Stages; Compiler Behavior Notes; LabPBR Material Standard; Legacy Shaderpacks; Main Page (licence) | `index.php?action=raw` | as of 2026-10-24 | `c8e66b61…`, `fe432020…`, `bb841334…`, `294be212…`, `e008b31f…` |
| OptiDocs uniform page | optifine.readthedocs.io, HTML | renders OptiFine doc commit `83d482c` | not kept (only its list of uniform names was compared with `shaders.txt`) |
| GLSL 4.60.8 specification | registry.khronos.org, HTML | 4.60.8 | `795e42c5f71eb709a23035228cd4b328a7cae23766ebd0bbfc6b2705028fe1a5` |
| `gl.xml` | `KhronosGroup/OpenGL-Registry` `main` | `6af574a14089ccfee87efe230ebcdd8742859813` | enum values only |
| LearnOpenGL "About" page | learnopengl.com | 2026-10-24 | licence statement only |
Web searches: two (`shaders.properties` documentation; `MC_VERSION` for 26.1). See incidents.
## 2. Pack files read (non-code only)
From local copies of the BSL, Complementary and Sildur's Vibrant packs (their `shaders/` folders): `shaders.properties`,
`block.properties`, `item.properties`, `entity.properties`, `dimension.properties`, `lang/en_US.lang`, and the folder
and file-extension layout. Read with `grep`/`sed` for key names, directive forms and syntax. No `.vsh`, `.fsh`, `.gsh`,
`.csh` or `.glsl` file was opened. `complementary-unbound` (zip only) was not opened.
## 3. Deliberately not opened
- Any source or binary of Iris, OptiFine, Oculus, Canvas, Sodium or any other loader or renderer.
- Horizon's shader code and docs (`crates/horizon-shaderpack`, `java_pack.rs`, `gfx/java_stages.rs`,
`gfx/java_terrain.rs`, `gfx/pack_stages.rs`, `shader_pack.rs`, `docs/shader-packs.md`, `docs/java-packs.md`), their git
history and their PRs. No file of the Horizon repository was read.
- A private folder holding an earlier experiment with another loader's code (seen in a directory listing; not opened).
- Links inside the documents that lead to code: the documentation site's links to its own GLSL transformer and to the
loader's repository (`Reference/Miscellaneous/patcher`), the tutorial's links to example pack code, ShaderDoc's link to
LearnOpenGL's source file.
## 4. Incidents and caveats
1. **A search result summarised another project's PRs.** The second web search returned two pull requests of a
third-party project (`cinnabar-mc/cinnabar`) about the `MC_VERSION` and `IRIS_VERSION` macros, and the search tool's
summary quoted their content (a proposed 26.x value, a remark about another loader's macros, and one pack's reaction
to `MC_ANISOTROPIC_FILTERING`). The pages were not opened and nothing from that summary is used. The spec's OQ-1
derives the 26.3 value only from the documented `MC_VERSION` rule and leaves it open; `MC_ANISOTROPIC_FILTERING`
is described only as `shaders.txt` documents it.
2. **ShaderDoc's `vertex-format-extensions.md` describes how an old loader computed some attributes.** The spec uses only
the attribute meanings it states (normal from the quad, tangent handedness in `w`, mid-texture coordinate as the
average of the quad's UVs) and cites the standard tangent technique to LearnOpenGL. Its description of internal code
paths is not reproduced. Likewise `const-directives.md` mentions where an old loader hooked vanilla for
`ambientOcclusionLevel`; the spec keeps only the documented brightness curve.
3. **Session context.** The session started with Horizon's `CLAUDE.md` loaded automatically. It names the shader crate
and doc paths in a table (which is how the forbidden list is known) but contains no shader code. Nothing else from
the repository was read.
4. **Model training.** The spec writer is a language model whose training data may include public source code of the
projects above. In this session no such code was consulted, and every statement in the spec is tied to a document
in section 1 or 2. If Ludovic considers prior training exposure disqualifying for the spec writer, the spec can be
re-derived by a reviewer from the same sources: the tags make every statement checkable.
---
# Implementation team: `cleanroom-impl-1`
Session: one AI agent (Claude Opus 5.5) working for Ludovic, 2026-10-24, on the maintainer's machine. Its own
git worktree, branch `feat/shader-packs-discovery`, from origin/main `29c5e65d1` (after the
removal, #1561). Its implementation plan stayed with the private notes (not published).
## What was read
| Source | What for | Sections it fed |
|---|---|---|
| `shader-packs-spec.md` revision 1, `README.md` (orchestrator decisions 2026-10-24), the maintainer's private agent rules | the only description of the feature and the process | all |
| Horizon main `29c5e65d1`: `AGENTS.md`/`CLAUDE.md`, `docs/design-system.md`, `docs/benchmark.md`, `Cargo.toml`, `crates/horizon-core/src/version.rs`, `crates/horizon-client/src/{graphics.rs (tests), options_ui.rs (Page/Event/OptionsUi), zip.rs, gfx.rs (pass labels, depth compare, surface formats), gfx/world_post.rs (header)}`, `crates/horizon-client/src/benchmark.rs` (header), `crates/horizon-threads/Cargo.toml`, `crates/horizon-server-files/Cargo.toml` | where the runtime plugs in (plan 2.1, 2.4), settings (2.5), targets (1), the `MC_VERSION` source (macros.rs) | plan; 1.5.1 |
| naga 30.0.1 (crates.io), through its public API only: a scratch crate outside the repository parsing 18 GLSL snippets I wrote with `naga::front::glsl` and validating them | what naga's GLSL frontend accepts (plan 2.2) | 1.6, OQ-29 |
| GLSL 4.60 §3.3 (preprocessor operators and precedence), from knowledge of the published standard | `condition.rs` | 1.5 |
| Public test packs (local copies) as black boxes: zip listings (folder layout), and the program in `tests/real_packs.rs` reading their `shaders.properties`, `block/item/entity/dimension.properties` and `lang/`. By hand: lines 66–80 of one pack's `shaders.properties` (to see the `#define` the test reported) | parse rates and timings; the decision on `#define` in `shaders.properties` | 1.1, 3.1, 4.1, 8.4; OQ-16 |
No `.vsh`, `.fsh`, `.gsh`, `.csh` or `.glsl` file of any pack was opened.
## Seen, not opened
- Directory listings showed other worktrees of the horizon repo named `shaders-1` and similar, and `glsl-lang-*` crates in
the local cargo registry. None was opened; nothing from them is used.
- The session context listed Horizon's old shader file names (from the crate table of a cached `CLAUDE.md`) and the memory
note that the feature was removed after a licence complaint. No content of those files.
## Incidents
None.
### Slices 2 to 5 (PRs #1599, #1600, #1601 and the slice 5 PRs), 2026-10-24
| Source | What for | Sections it fed |
|---|---|---|
| naga 30.0.1 and wgpu 30.0.1 (crates.io): public API, docs and source of `naga::front::glsl`, `naga::valid`, `wgpu::ShaderSource::Naga`, `Device::push_error_scope`, `TextureFormat::sample_type`; scratch probes outside the repository with GLSL I wrote | which GLSL naga reads and which gaps the rewriter covers (`modf`, global initializers, unsized arrays); how the runtime builds pipelines | 1.6, 2.2, OQ-29 |
| Horizon main: `crates/horizon-client/src/{camera.rs (perspective, view_matrix, depth_far), gfx.rs (Frame, render's world target and passes, device request), gfx/world_post.rs, environment.rs (Sky), fog.rs (FogData), world.rs (light, weather levels), game.rs (Frame construction, celestial_geometry, day_time, hide_gui), game/fov.rs (camera_fluid), game/packs.rs (packs_dir)}`, `docs/benchmark.md`, `docs/horizon-ctl-commands.md` | where the runtime plugs in, reverse-Z to GL depth, sun and moon positions, frame inputs | 5.2, 5.5, 6.2 |
| Public test packs as black boxes through `tests/real_packs.rs` (preprocess every stage file, translate every composite-style program, load each dimension's post programs) and by running BSL in the game with `HORIZON_SHADER_PACK` (log lines and a screenshot, kept on this machine only) | pass rates, timings, failures to fix | 1.4–1.7, 2.1, 6.1 |
No `.vsh`, `.fsh`, `.gsh`, `.csh` or `.glsl` file of any pack was opened to read its code. Horizon's own test pack
(`crates/horizon-shaders/tests/packs/post-test`) was written from the spec alone.
**Caveat (slice 4):** naga failed on one public-pack stage file with an internal handle error that its message did not
locate. To find the construct, the test printed **one line** of that file with every identifier and number masked
(its token shape, `T x = x * ((x < N || x > N) ? N : -N);` in effect), which showed a global initializer with a
ternary; I then reproduced it with my own GLSL in a probe and fixed it in the rewriter. No other line was printed and
the shape was not kept. Flagging it so the auditor can judge.
**Bug found by the first draw (slice 5):** `gl_FragCoord` in programs drawing to buffers was never set (the translator
only set it for `final`); fixed in the slice 5 uniforms PR with a test.
**Slice 6 debugging (2026-10-25):** with the coordinator's agreement that a pack's GLSL is input data, I ran locally
modified copies of BSL (in a scratch folder, never committed) to find why its terrain was black or the whole image went black:
single lines of its lighting function, its deferred program and its TAA library were replaced with outputs of
intermediate values (split-screen colours, `isnan` checks), and I read the few lines around them to place the edits.
The causes found were all ours, fixed from the spec and GL/naga behaviour: naga dropping non-constant global
initializers, comma declarations losing their later declarators, program-enable expressions not seeing the pack's
default-on switches (8.1), and float stand-ins for normalized buffer formats keeping NaN. Complementary was only run as
a black box (its log and screenshots), plus one property line and one GLSL declaration block read to locate a translator
error. No other loader's code was read; nothing from the packs went into Horizon beyond the fixes described.
## 2026-10-25 cleanroom-impl-1, slices 21–23 (#1721–#1723)
- Sources: the spec (rev 1) §2.1, §2.3, §5.3, §5.4; Horizon's current main (`terrain.wgsl` sky, `gfx/stars.rs`, `Game::celestial_geometry`) for the sky geometry.
- Test packs used as data only: grepped single `renderStage` lines in BSL's and Complementary's sky programs to confirm that they read it per draw; no GLSL used as a model.
- No other loader, renderer or pre-removal Horizon history read.
## 2026-10-25 cleanroom-impl-1, #1742–#1744
- Sources: the spec (rev 1) §4.1, §5.1, §5.3; Horizon's current main (arena, flat draws, block models, mesher's vertex format).
- Test packs as data: their `block.properties` id lists (names only) to measure coverage; a local, uncommitted copy of Horizon's own terrain-test pack with a normal visualiser.
- No other loader, renderer or pre-removal Horizon history read.
## 2026-10-25 cleanroom-impl-1, slices 26–28 (#1754–#1756)
- Sources: the spec (rev 1) §1.6, §3.3, §3.4, §5.2, §6.1.7, §8.1; Horizon's current main (`biome.rs` biome registry, `fog.rs` fog data); Minecraft 26.3's biome list (`data/minecraft/worldgen/biome`) and its 1.18 biome renames for legacy `BIOME_` names.
- Test packs used as data, for debugging only: Complementary's fog, volumetric light, FXAA and final lines, and BSL's/Sildurs' `shaders.properties` biome and enable keys, read to locate why their output was wrong. Local, uncommitted copies with single calls switched off or values shown as colours (in a scratch folder). No GLSL used as a model; nothing from the packs went into Horizon.
- No other loader, renderer or pre-removal Horizon history read.
## 2026-10-25 cleanroom-impl-1, slices 29–31 (#1769–#1771: entity spans, alpha test, entities and block entities)
- Sources: the spec (rev 1) §2.3, §3.2 (`alphaTest.<program>`), §4.2, §5.1, §5.3, §5.4, OQ-23; Horizon's current main (`Game::entity_geometry`, `entity.wgsl` overlay codes, `overlay.rs`, `EntityBatch`); the terrain face normal and tangent code of #1744 (ours).
- Test packs used as data, for debugging only: BSL's and Complementary's `entity.properties`/`block.properties` id lists and `alphaTest.*` keys; in BSL's entity program the uniform/attribute declaration lines, the `discard`/alpha lines, and a handful of lighting lines (`NoL`, the lightmap, the `GetLighting` call) read to place a local, uncommitted debug edit that output its albedo. Complementary only as a black box. Two local test packs of ours (`entityId` colours, normal and lightmap visualisers), never committed.
- No other loader, renderer or pre-removal Horizon history read.
## 2026-10-25 cleanroom-impl-1, slices 32–34 (#1784–#1786: translucent entity parts and particles, clouds, rain and snow)
- Sources: the spec (rev 1) §2.1, §2.3, §3.2 (`separateEntityDraws`, `particles.ordering`, `particles.before.deferred`), OQ-3, OQ-23; Horizon's current main (`game/names.rs`, the entity shadow, `clouds.rs`, `weather.rs` and the weather math of `entity.wgsl`); naga's behaviour on code after `discard`/`return`.
- Test packs used as data, for debugging only: BSL's `gbuffers_clouds` program (read whole once, about 200 lines, to see why naga refused the code after its `discard` and why vanilla clouds vanish; nothing taken from it), Complementary only as a black box; screenshots of both with name tags, a campfire, clouds, rain and snow (local, never committed).
- No other loader, renderer or pre-removal Horizon history read.
## 2026-10-25 cleanroom-impl-1, slice 8 group 1 (#1885–#1888: shadow settings and camera, shadow programs, shadow buffers, shadow terrain)
- Sources: the spec (rev 1) §2.1, §2.4, §2.5, §3.2 (`shadowTerrain`, `shadowTranslucent`), §5.2, §6.1.6, §6.3, §7.1, OQ-6, OQ-19; Horizon's current main (`gfx.rs` section lists, `flat.rs`, `page_runs.rs`, `resident.rs` visibility, the translucent sorter); wgpu's docs for comparison samplers, depth copies and load/store operations; first-principles geometry for the shadow camera (look-at from the light, snapping in its own axes).
- Test packs used as data, for debugging only: BSL's `settings.glsl` shadow constants (`shadowMapResolution`, `shadowDistance`, the lines that set them, to read which values the loader should report) and its `shaders.properties` profiles; BSL, Complementary and Sildurs otherwise as black boxes (screenshots, `HORIZON_SHADER_PACK_TIMING`, the loader's notes). Two local debug packs of our own (`shadow.vsh/fsh` writing the occluder height, a `final` that tests it) checked the camera and the map.
- No other loader, renderer or pre-removal Horizon history read.
## 2026-10-25 cleanroom-impl-1, slice 8 PR 41 #1900 (entities and block entities in the shadow pass) and the shadowtex1 copy measurement
- Sources: the spec (rev 1) §2.4, §3.2, OQ-19; Horizon's current main (`pack_entities.rs`, `game/entity_culling.rs`); wgpu's docs for depth attachments, `frag_depth` and depth texture bindings (the dropped fill variant).
- Test packs used as data: `shaders.properties` lines with `shadowEntities`/`shadowBlockEntities` in BSL (under `#ifndef SHADOW_ENTITY`) and Complementary (under `ENTITY_SHADOW`), read to explain which entity shadows each pack asks for; otherwise black boxes (screenshots, timing).
- No other loader, renderer or pre-removal Horizon history read.
## 2026-10-25 cleanroom-impl-1, slice 8 PR 42 (shadow casters: culled entities, first-person player, shadowPlayer, shadowLightBlockEntities)
- Sources: the spec (rev 1) §2.4, §3.2 (`shadowPlayer`, `shadowLightBlockEntities`); Horizon's current main (`entity_culling.rs`, `game/entity_culling.rs`, `game/perspective.rs`, `Game::entity_geometry`, block state light emission in `StateFacts`).
- Test packs: not opened; only our own debug packs (shadow map and height visualisers, local, uncommitted) used for the checks.
- No other loader, renderer or pre-removal Horizon history read.
File diff suppressed because it is too large. Load diff
+16
View File
@@ -128,6 +128,22 @@ ticks still grows: `"random_ticks": true` for every state (or `false`, to stop a
`state_properties`) is one of the values. The block's native `native_random_tick` hook then fires as it does
for a vanilla block that ticks, and its sections count it.
`state_light` selects emission by the template's or declared state properties. It has the same
`property` or ordered `properties`, `values` and optional `default` selection as a shape table;
every value is an integer from 0 to 15:
```json
{ "template": "minecraft:glass", "state_properties": { "lit": { "type": "boolean" } },
"state_light": { "properties": ["lit"], "values": { "false": 0, "true": 15 } } }
```
Unknown properties, unreachable tuples, repeated property names and uncovered states without a
`default` reject the declaration. A selected value takes precedence over `properties.light`,
including zero. Without `state_light`, scalar light and template emission stay unchanged. The
compiler resolves the table once into the normal per-state emission facts, shared by the client,
server and saved resource cache. Light propagation uses those facts when a block changes state;
it performs no declaration lookup or mod callback.
`render_shape` says how a block is drawn whatever its template does, as vanilla's `getRenderShape`:
`"MODEL"` puts its block model in the chunk mesh, `"ENTITYBLOCK_ANIMATED"` leaves the model out for a block whose
moving parts its block entity draws every frame (a turning shaft, a machine's arm), and `"INVISIBLE"` draws nothing.
+68
View File
@@ -1222,6 +1222,12 @@ API 144 adds a player's or mob's breathing, on the single player server thread a
API 145 adds blocks and items a native mod declares in code, as NeoForge's `DeferredRegister.Blocks` and `.Items` drained by `RegisterEvent`, instead of one content file each: `native_blocks(blocks)` and `native_items(items)`, asked once at start on every side, take `horizon_mods::content::Block` and `Item`, the declarations a content mod's `data/<id>/horizon/block/` and `item/` files hold (a vanilla template and what differs; `item: true` gives a block its item). Ids are in the mod's namespace. They join the content mods' blocks and items, inside the content mod whose id is that namespace when it is on (its models, textures, loot and recipes), else on their own with the template's look, so their network ids, saves and the joining client's content check are the files' (docs/mods.md). They pass the files' checks; a namespace whose declarations don't is logged and left out. Bundles that use them need `"hooks": 145`.
`Block.state_light` optionally takes a `content::StateLight` (`StateValues<u8>`), selecting emission
from 0 to 15 by state-property tuples, exactly as the [content declaration](mods.md) does. It overrides
the scalar `BlockProperties.light` only when present. The content compiler validates and resolves
every state once; native, content-only, dedicated-server and client declarations use the same emitted
state facts and ordinary light propagation, without a runtime callback.
`Item.place_block: Option<String>` binds an item to an ordinary `BlockItem` placement of a vanilla
block or a block in the item's namespace, including blocks with `item: false`. It uses the same
[content field](mods.md) on clients and servers. Components still come from the item template;
@@ -4472,6 +4478,68 @@ render thread with the frame's wgpu objects; what a mod draws there shows in tha
shader pack (each run's id is stable from frame to frame: a hash of the entity type's name, a block entity's state),
and `world_depth` to keep the world pass's depth when nothing else needs it. Set what you need and leave the rest:
several mods can ask. It's asked as each frame is built, so keep it cheap.
- `native_world_warp(warp)`: give the frame a [world warp](#world-warps), WGSL every terrain and entity vertex goes
through. Asked once a frame before the world is drawn (not on the title screen's panorama); the last mod to set
one draws.
### World warps
A world warp bends the drawn world itself: every terrain vertex (far terrain's tiles too) and entity vertex goes
through a mod's WGSL, so what moves is the real geometry, with its light and depth, seen from any side. Set
`WorldWarp` in `native_world_warp`:
- `source`: the WGSL, an `Arc<str>` (keep one and hand out clones: a source that compares equal to the last one is
not built again). `None`, the default, is no warp.
- `on`: whether this frame draws with it. A source given while `on` is false is still built, so give it a moment
before it's needed: building takes a while the first time (logged as "a mod's world warp built in N ms").
- `params`: sixteen floats the WGSL reads as `warp.params`, four `vec4<f32>`. Set them every frame.
The source has `world_warp`, and may go on after a line `// [fragments]` with `world_fragment` for the terrain's
pixels:
```wgsl
// pos: the vertex from the camera. section: the corner of the section or far tile it belongs to, from the camera
// (a vertex on the edge between two sections says by itself which it lies on no better than its position does);
// for an entity, its own position; for a block entity, its section's corner.
fn world_warp(pos: vec3<f32>, section: vec3<f32>) -> Warped {
// where the world's point p is from the camera, exact far from the origin:
// (p - warp.camera.xyz) - warp.camera_rest.xyz; warp.camera.w is the frame's partial tick
let lift = sin((pos.x + warp.camera.x) * 0.1 + warp.params[0].x) * warp.params[0].y;
// Warped: pos, where to draw it (from the camera); tint, what its colour is multiplied by
// (terrain only: red under 0 marks the vertex's faces cut)
return Warped(pos + vec3(0.0, lift, 0.0), vec3(1.0));
}
// [fragments]
fn world_fragment(f: Fragment) -> Painted {
// Fragment: home (where the point really is, from the camera: world_warp's pos), at (where it is drawn), uv and
// layer (sprite(f.uv, f.layer) is the block's texture), color (biome tint, light, shading and the warp's tint),
// light (sky and block, 0 to 15), cut (over 0 where a tint had red under 0), front (false on a face's back)
if f.cut > 0.0 || !f.front { return Painted(vec4(0.0, 0.0, 0.0, -1.0), 1.0); } // alpha under 0: left out
return Painted(sprite(f.uv, f.layer) * f.color, 1.0); // Painted: color, and fog (how much of the fog it takes)
}
```
Without a `world_fragment` the terrain is drawn as the game draws it, less the faces a tint cut. The source goes into
copies of the game's terrain and entity shaders (`crates/horizon-client-render/src/gfx/world_warp.rs`), so prefix
what else it declares to keep clear of their names. What follows `// [fragments]` goes into the terrain's only:
entities have no pixel stage of the warp. A source that doesn't compile is logged ("a mod's world warp did not
compile", with naga's message) and the frame draws without it until the source changes.
While a warp draws, the warp's pipelines don't cull back faces and the frame leaves nothing out by where it was:
frustum, cave and occlusion culling (CPU and GPU) and the culling of section faces turned away are off for it, since
the warp may bring any of it into view. That makes the frame cost more: turn the warp off when it's done. A warped
frame also keeps to the plain per-section draws: flat draws, the visibility buffer, improved transparency, the water
mirror, and a Horizon shader pack's material stage and `[displacement]` give way to it for that frame (the pack's sky
and post stages still run; its shadows are of the unwarped world). A shader pack that draws the terrain itself (Iris
and OptiFine format) isn't warped: the frame draws without the warp, logged once. TAA reprojects by the camera alone,
so a warp that moves fast smears as other moving things do. Warped: terrain (solid and translucent), entities, items,
particles, block entities, entity shadows and name tags. Not warped: the hand, sky, clouds, weather, block outlines and
breaking cracks, lightning, and what mods draw themselves (world primitives, the world canvas, native models and
fluids). A frame without a warp costs nothing more: the game's own shaders hold only comments where a warp goes in,
and nothing of the warp's is made until a mod gives one.
`cargo run -p horizon-client-render --example world_warp_check -- <dir>` (with throwaway `HORIZON_HOME`,
`HORIZON_LAUNCHER_HOME`, `XDG_DATA_HOME` and `XDG_CONFIG_HOME`) draws a warp through the renderer offscreen.
### World primitives
+17
View File
@@ -140,6 +140,23 @@ Vanilla seeds every unseeded random (`RandomSource.create()`: each entity's own,
One draw too many or too few shifts every later value, so read the first field that differs. Not covered: randoms on other threads (worldgen and chunk loading: keep scenarios in loaded chunks), the levels other than the player's, randoms created before the step (the server's, the level's sound seeds) except the level's own, `Level.randValue` (random-tick positions) and other `ThreadLocalRandom` or `Math.random` draws, and anything a tick draws from the level's random before what you read (each tick shuffles the chunks near players with it, even with `spawn_mobs` off).
### Fuzzing scenarios
`tools/parity_fuzz.py` runs small generated scenarios in vanilla and Horizon and reports each divergence once, shrunk, with a reproduction (#12498). A batch of scenarios runs as one command-exec script (`test_command_exec_parity.py --groups-file`). It is a tool to run by hand, not a harness: `parity.py` never selects it.
```sh
python3 tools/parity_fuzz.py --smoke --binary target/fast/horizon-client.exe # fixed scenarios that must match (~30 s)
python3 tools/parity_fuzz.py persist --seed 1 --count 60 # summon or place, unload and reload, read
python3 tools/parity_fuzz.py spawn --seed 1 --count 60 # each spawn mechanism, read tick by tick
python3 tools/parity_fuzz.py --replay scenarios.json # run these scenarios (a report's, edited)
```
- **Scenarios.** A scenario is a seed and steps: summon (with NBT), place a block (with NBT), a command, ticks, a dispenser activation, an unload and reload, and reads (`/data get`). Each gets a chunk of its own far from the player (x 4096 and up, y 300, on a stone floor), forceloaded, and syncs both engines' randoms first ("Randoms that repeat"). A reload removes the forced ticket, ticks until both engines saved and dropped the chunk (a guard checks vanilla did), and loads it back: a real save and load through each engine's chunk and entity storage. Summoned entities get fixed UUIDs, so reads find them without selectors.
- **Families** (`tools/parity_fuzz_scenarios.py`). `persist` summons an entity or places a block entity with changed fields, maybe beside other blocks, and reads it before and after a reload. `spawn` makes an entity one way (summon, dispenser, dispenser on a redstone signal, a block broken, an explosion, a piston) and reads it before its first tick and after each of a few. Field names are vanilla's own: the first run summons every entity type and places every block entity bare and caches what vanilla saves for each in the work directory (`defaults.json`); mutations change those fields' values and keep their NBT types. A run of 60 scenarios takes a few minutes; shrinking adds one or two batches per round.
- **Comparison.** A `/data get` read is parsed as SNBT and compared field by field, so a difference names its field. Key order is not compared, nor the order of `attributes` (vanilla's own order changes run to run). Other commands compare like the harness (feedback, styles, results). `ALLOWED` in the script lists the differences that are not bugs, each with its reason.
- **Grouping and shrinking.** Each diverging read is grouped by family, subject type, whether the read came after a reload, and first differing field. Each group's smallest scenario is shrunk (steps and NBT keys dropped, tick counts halved) while that group's divergence stays, trying all edits of a round in one batch. `report.json` in the work directory lists the groups, largest first, each with its seeds, its shrunk scenario as commands, and its differences.
- **Triage before filing.** A group is a lead, not a bug: read the vanilla source for the field, check it isn't a seed-sync limit (above) or harness noise, and search existing issues first.
## Reading vanilla
Besides the Vineflower sources in `.local/minecraft/<version>/sources/`, `tools/rea_vanilla.py` queries the shipped bytecode through REA (callers through lambdas, overloads, a second decompiler); see [decompilation status](decompilation-status.md#querying-the-shipped-bytecode-with-rea).
+4 -1
View File
@@ -1257,5 +1257,8 @@
"Horizon runs a data pack's functions, function tags, predicates, item modifiers, recipes, loot tables, advancements, villager trades, damage types, most tags and most worldgen (biomes, features, structures, dimensions). Other files, such as world presets, are not read yet.": "Horizon runs a data pack's functions, function tags, predicates, item modifiers, recipes, loot tables, advancements, villager trades, damage types, most tags and most worldgen (biomes, features, structures, dimensions). Other files, such as world presets, are not read yet.",
"{title} would change nothing in Horizon yet: Horizon doesn't read its {files}.": "{title} would change nothing in Horizon yet: Horizon doesn't read its {files}.",
"{mod}: using version {version}, made for the Horizon you play": "{mod}: gebruik weergawe {version}, gemaak vir die Horizon wat jy speel",
"Recommended": "Aanbeveel"
"Recommended": "Aanbeveel",
"An older version, {version}, builds on the Horizon you play: installing picks it": "'n Ouer weergawe, {version}, bou op die Horizon wat jy speel: installeer kies dit",
"{mod}: version {version} didn't build, trying {older}": "{mod}: weergawe {version} het nie gebou nie, probeer {older}",
"Use recommended (kept at {version})": "Gebruik aanbevole (gehou op {version})"
}
+4 -1
View File
@@ -1257,5 +1257,8 @@
"Horizon runs a data pack's functions, function tags, predicates, item modifiers, recipes, loot tables, advancements, villager trades, damage types, most tags and most worldgen (biomes, features, structures, dimensions). Other files, such as world presets, are not read yet.": "Horizon runs a data pack's functions, function tags, predicates, item modifiers, recipes, loot tables, advancements, villager trades, damage types, most tags and most worldgen (biomes, features, structures, dimensions). Other files, such as world presets, are not read yet.",
"{title} would change nothing in Horizon yet: Horizon doesn't read its {files}.": "{title} would change nothing in Horizon yet: Horizon doesn't read its {files}.",
"{mod}: using version {version}, made for the Horizon you play": "{mod}: يُستخدم الإصدار {version}، المصنوع لـ Horizon الذي تلعبه",
"Recommended": "موصى به"
"Recommended": "موصى به",
"An older version, {version}, builds on the Horizon you play: installing picks it": "إصدار أقدم، {version}، يُبنى على Horizon الذي تلعبه: التثبيت يختاره",
"{mod}: version {version} didn't build, trying {older}": "{mod}: لم يُبنَ الإصدار {version}، تجربة {older}",
"Use recommended (kept at {version})": "استخدم الموصى به (مثبت على {version})"
}
+4 -1
View File
@@ -1257,5 +1257,8 @@
"Horizon runs a data pack's functions, function tags, predicates, item modifiers, recipes, loot tables, advancements, villager trades, damage types, most tags and most worldgen (biomes, features, structures, dimensions). Other files, such as world presets, are not read yet.": "Horizon runs a data pack's functions, function tags, predicates, item modifiers, recipes, loot tables, advancements, villager trades, damage types, most tags and most worldgen (biomes, features, structures, dimensions). Other files, such as world presets, are not read yet.",
"{title} would change nothing in Horizon yet: Horizon doesn't read its {files}.": "{title} would change nothing in Horizon yet: Horizon doesn't read its {files}.",
"{mod}: using version {version}, made for the Horizon you play": "{mod}: oynadığın Horizon üçün hazırlanmış {version} versiyası istifadə olunur",
"Recommended": "Tövsiyə olunur"
"Recommended": "Tövsiyə olunur",
"An older version, {version}, builds on the Horizon you play: installing picks it": "Köhnə versiya, {version}, oynadığın Horizon üzərində qurulur: quraşdırma onu seçir",
"{mod}: version {version} didn't build, trying {older}": "{mod}: {version} versiyası qurulmadı, {older} sınanır",
"Use recommended (kept at {version})": "Tövsiyə olunanı istifadə et ({version} saxlanılıb)"
}
+4 -1
View File
@@ -1257,5 +1257,8 @@
"Horizon runs a data pack's functions, function tags, predicates, item modifiers, recipes, loot tables, advancements, villager trades, damage types, most tags and most worldgen (biomes, features, structures, dimensions). Other files, such as world presets, are not read yet.": "Horizon runs a data pack's functions, function tags, predicates, item modifiers, recipes, loot tables, advancements, villager trades, damage types, most tags and most worldgen (biomes, features, structures, dimensions). Other files, such as world presets, are not read yet.",
"{title} would change nothing in Horizon yet: Horizon doesn't read its {files}.": "{title} would change nothing in Horizon yet: Horizon doesn't read its {files}.",
"{mod}: using version {version}, made for the Horizon you play": "{mod}: һин уйнаған Horizon өсөн эшләнгән {version} версияһы ҡулланыла",
"Recommended": "Тәҡдим ителә"
"Recommended": "Тәҡдим ителә",
"An older version, {version}, builds on the Horizon you play: installing picks it": "Иҫкерәк версия, {version}, һин уйнаған Horizon-да йыйыла: ҡуйыу уны һайлай",
"{mod}: version {version} didn't build, trying {older}": "{mod}: {version} версияһы йыйылманы, {older} һынала",
"Use recommended (kept at {version})": "Тәҡдим ителгәнде ҡулланырға ({version} ҡалдырылған)"
}
+4 -1
View File
@@ -1257,5 +1257,8 @@
"Horizon runs a data pack's functions, function tags, predicates, item modifiers, recipes, loot tables, advancements, villager trades, damage types, most tags and most worldgen (biomes, features, structures, dimensions). Other files, such as world presets, are not read yet.": "Horizon runs a data pack's functions, function tags, predicates, item modifiers, recipes, loot tables, advancements, villager trades, damage types, most tags and most worldgen (biomes, features, structures, dimensions). Other files, such as world presets, are not read yet.",
"{title} would change nothing in Horizon yet: Horizon doesn't read its {files}.": "{title} would change nothing in Horizon yet: Horizon doesn't read its {files}.",
"{mod}: using version {version}, made for the Horizon you play": "{mod}: выкарыстоўваецца версія {version}, зробленая для Horizon, у які ты гуляеш",
"Recommended": "Рэкамендуецца"
"Recommended": "Рэкамендуецца",
"An older version, {version}, builds on the Horizon you play: installing picks it": "Старэйшая версія, {version}, збіраецца на Horizon, у які ты гуляеш: усталёўка выбірае яе",
"{mod}: version {version} didn't build, trying {older}": "{mod}: версія {version} не сабралася, спрабуем {older}",
"Use recommended (kept at {version})": "Выкарыстоўваць рэкамендаваную (замацавана {version})"
}
+4 -1
View File
@@ -1257,5 +1257,8 @@
"Horizon runs a data pack's functions, function tags, predicates, item modifiers, recipes, loot tables, advancements, villager trades, damage types, most tags and most worldgen (biomes, features, structures, dimensions). Other files, such as world presets, are not read yet.": "Horizon runs a data pack's functions, function tags, predicates, item modifiers, recipes, loot tables, advancements, villager trades, damage types, most tags and most worldgen (biomes, features, structures, dimensions). Other files, such as world presets, are not read yet.",
"{title} would change nothing in Horizon yet: Horizon doesn't read its {files}.": "{title} would change nothing in Horizon yet: Horizon doesn't read its {files}.",
"{mod}: using version {version}, made for the Horizon you play": "{mod}: използва се версия {version}, направена за Horizon, който играеш",
"Recommended": "Препоръчано"
"Recommended": "Препоръчано",
"An older version, {version}, builds on the Horizon you play: installing picks it": "По-стара версия, {version}, се компилира за Horizon, който играеш: инсталирането я избира",
"{mod}: version {version} didn't build, trying {older}": "{mod}: версия {version} не се компилира, опитва се {older}",
"Use recommended (kept at {version})": "Използвай препоръчаната (задържана на {version})"
}
+4 -1
View File
@@ -1257,5 +1257,8 @@
"Horizon runs a data pack's functions, function tags, predicates, item modifiers, recipes, loot tables, advancements, villager trades, damage types, most tags and most worldgen (biomes, features, structures, dimensions). Other files, such as world presets, are not read yet.": "Horizon runs a data pack's functions, function tags, predicates, item modifiers, recipes, loot tables, advancements, villager trades, damage types, most tags and most worldgen (biomes, features, structures, dimensions). Other files, such as world presets, are not read yet.",
"{title} would change nothing in Horizon yet: Horizon doesn't read its {files}.": "{title} would change nothing in Horizon yet: Horizon doesn't read its {files}.",
"{mod}: using version {version}, made for the Horizon you play": "{mod}: implijout a ra ar stumm {version}, graet evit an Horizon a c'hoarit",
"Recommended": "Erbedet"
"Recommended": "Erbedet",
"An older version, {version}, builds on the Horizon you play: installing picks it": "Ur stumm koshoc'h, {version}, a sav war an Horizon a c'hoarit: staliañ a zibab anezhañ",
"{mod}: version {version} didn't build, trying {older}": "{mod}: n'eo ket savet ar stumm {version}, o klask {older}",
"Use recommended (kept at {version})": "Implijout an hini erbedet (miret war {version})"
}
+4 -1
View File
@@ -1257,5 +1257,8 @@
"Horizon runs a data pack's functions, function tags, predicates, item modifiers, recipes, loot tables, advancements, villager trades, damage types, most tags and most worldgen (biomes, features, structures, dimensions). Other files, such as world presets, are not read yet.": "Horizon runs a data pack's functions, function tags, predicates, item modifiers, recipes, loot tables, advancements, villager trades, damage types, most tags and most worldgen (biomes, features, structures, dimensions). Other files, such as world presets, are not read yet.",
"{title} would change nothing in Horizon yet: Horizon doesn't read its {files}.": "{title} would change nothing in Horizon yet: Horizon doesn't read its {files}.",
"{mod}: using version {version}, made for the Horizon you play": "{mod}: koristi se verzija {version}, napravljena za Horizon koji igraš",
"Recommended": "Preporučeno"
"Recommended": "Preporučeno",
"An older version, {version}, builds on the Horizon you play: installing picks it": "Starija verzija, {version}, se gradi na Horizonu koji igraš: instalacija bira nju",
"{mod}: version {version} didn't build, trying {older}": "{mod}: verzija {version} se nije izgradila, pokušava se {older}",
"Use recommended (kept at {version})": "Koristi preporučenu (zadržano na {version})"
}
+4 -1
View File
@@ -1257,5 +1257,8 @@
"Horizon runs a data pack's functions, function tags, predicates, item modifiers, recipes, loot tables, advancements, villager trades, damage types, most tags and most worldgen (biomes, features, structures, dimensions). Other files, such as world presets, are not read yet.": "Horizon runs a data pack's functions, function tags, predicates, item modifiers, recipes, loot tables, advancements, villager trades, damage types, most tags and most worldgen (biomes, features, structures, dimensions). Other files, such as world presets, are not read yet.",
"{title} would change nothing in Horizon yet: Horizon doesn't read its {files}.": "{title} would change nothing in Horizon yet: Horizon doesn't read its {files}.",
"{mod}: using version {version}, made for the Horizon you play": "{mod}: s'utilitza la versió {version}, feta per a l'Horizon que jugues",
"Recommended": "Recomanada"
"Recommended": "Recomanada",
"An older version, {version}, builds on the Horizon you play: installing picks it": "Una versió més antiga, {version}, es compila a l'Horizon que jugues: instal·lar la tria",
"{mod}: version {version} didn't build, trying {older}": "{mod}: la versió {version} no s'ha compilat, provant {older}",
"Use recommended (kept at {version})": "Usa la recomanada (fixada a {version})"
}
+4 -1
View File
@@ -1257,5 +1257,8 @@
"Horizon runs a data pack's functions, function tags, predicates, item modifiers, recipes, loot tables, advancements, villager trades, damage types, most tags and most worldgen (biomes, features, structures, dimensions). Other files, such as world presets, are not read yet.": "Horizon runs a data pack's functions, function tags, predicates, item modifiers, recipes, loot tables, advancements, villager trades, damage types, most tags and most worldgen (biomes, features, structures, dimensions). Other files, such as world presets, are not read yet.",
"{title} would change nothing in Horizon yet: Horizon doesn't read its {files}.": "{title} would change nothing in Horizon yet: Horizon doesn't read its {files}.",
"{mod}: using version {version}, made for the Horizon you play": "{mod}: používá se verze {version}, vytvořená pro Horizon, který hraješ",
"Recommended": "Doporučeno"
"Recommended": "Doporučeno",
"An older version, {version}, builds on the Horizon you play: installing picks it": "Starší verze {version} se sestaví na Horizonu, který hraješ: instalace vybere ji",
"{mod}: version {version} didn't build, trying {older}": "{mod}: verze {version} se nesestavila, zkouší se {older}",
"Use recommended (kept at {version})": "Použít doporučenou (ponecháno na {version})"
}
+4 -1
View File
@@ -1257,5 +1257,8 @@
"Horizon runs a data pack's functions, function tags, predicates, item modifiers, recipes, loot tables, advancements, villager trades, damage types, most tags and most worldgen (biomes, features, structures, dimensions). Other files, such as world presets, are not read yet.": "Horizon runs a data pack's functions, function tags, predicates, item modifiers, recipes, loot tables, advancements, villager trades, damage types, most tags and most worldgen (biomes, features, structures, dimensions). Other files, such as world presets, are not read yet.",
"{title} would change nothing in Horizon yet: Horizon doesn't read its {files}.": "{title} would change nothing in Horizon yet: Horizon doesn't read its {files}.",
"{mod}: using version {version}, made for the Horizon you play": "{mod}: эсӗ вылякан Horizon валли тунӑ {version} версийӗ усӑ курать",
"Recommended": "Сӗнетпӗр"
"Recommended": "Сӗнетпӗр",
"An older version, {version}, builds on the Horizon you play: installing picks it": "Кивӗрех версийӗ, {version}, эсӗ вылякан Horizon ҫинче пухӑнать: лартни ӑна суйлать",
"{mod}: version {version} didn't build, trying {older}": "{mod}: {version} версийӗ пухӑнмарӗ, {older} тӗрӗслетпӗр",
"Use recommended (kept at {version})": "Сӗннине усӑ курас ({version} хӑварнӑ)"
}
+4 -1
View File
@@ -1257,5 +1257,8 @@
"Horizon runs a data pack's functions, function tags, predicates, item modifiers, recipes, loot tables, advancements, villager trades, damage types, most tags and most worldgen (biomes, features, structures, dimensions). Other files, such as world presets, are not read yet.": "Horizon runs a data pack's functions, function tags, predicates, item modifiers, recipes, loot tables, advancements, villager trades, damage types, most tags and most worldgen (biomes, features, structures, dimensions). Other files, such as world presets, are not read yet.",
"{title} would change nothing in Horizon yet: Horizon doesn't read its {files}.": "{title} would change nothing in Horizon yet: Horizon doesn't read its {files}.",
"{mod}: using version {version}, made for the Horizon you play": "{mod}: yn defnyddio fersiwn {version}, wedi'i gwneud ar gyfer yr Horizon rwyt ti'n ei chwarae",
"Recommended": "Argymhellir"
"Recommended": "Argymhellir",
"An older version, {version}, builds on the Horizon you play: installing picks it": "Mae fersiwn hŷn, {version}, yn adeiladu ar yr Horizon rwyt ti'n ei chwarae: mae gosod yn ei dewis",
"{mod}: version {version} didn't build, trying {older}": "{mod}: ni adeiladodd fersiwn {version}, yn rhoi cynnig ar {older}",
"Use recommended (kept at {version})": "Defnyddio'r un a argymhellir (wedi'i chadw ar {version})"
}
Loaded 100 of 212 files, more files were not shown because too many files have changed in this diff. Show more