Compare commits

...
Author SHA1 Message Date
Ludovic Marie 52e0dbd6cc fix: datapack into the render crate 2026-10-28 23:54:58 +01:00
Ludovic Marie f5c58b96ab union: #3415 2026-10-28 23:54:57 +01:00
Ludovic Marie 4b94fb759f union: #3405 2026-10-28 23:54:57 +01:00
Ludovic Marie 2007676d23 union: #3401 2026-10-28 23:54:57 +01:00
Ludovic Marie 517608055c union: #3391 2026-10-28 23:54:57 +01:00
Ludovic Marie e07f41f752 union: #3390 2026-10-28 23:54:57 +01:00
Ludovic Marie 91e0bb7d78 union: #3325 2026-10-28 23:54:57 +01:00
Ludovic Marie 00c78d6662 union: #3295 2026-10-28 23:54:56 +01:00
Ludovic Marie 5788648663 union: #3286 2026-10-28 23:54:56 +01:00
Ludovic Marie 2706c070f3 union: #3259 2026-10-28 23:54:56 +01:00
Ludovic Marie aa8d5c9be8 union: #3258 2026-10-28 23:54:56 +01:00
Ludovic Marie 910c413d86 union: #3257 2026-10-28 23:54:56 +01:00
Ludovic Marie ddde989ce3 union: #3256 2026-10-28 23:54:55 +01:00
Ludovic Marie 7bae97ee4f union: #3255 2026-10-28 23:54:55 +01:00
Ludovic Marie 8ecb38688a union: #3254 2026-10-28 23:54:55 +01:00
Ludovic Marie 9241d6e490 union: #3253 2026-10-28 23:54:55 +01:00
Ludovic Marie 89cdd09a2e union: #3214 2026-10-28 23:54:55 +01:00
Ludovic Marie ed35f7c90f union: #3130 2026-10-28 23:54:55 +01:00
Ludovic Marie 186410848f union: #3049 2026-10-28 23:54:55 +01:00
Ludovic Marie 45f1621c0a union: #2982 2026-10-28 23:54:54 +01:00
Ludovic Marie 0fc7703c72 union: #2981 2026-10-28 23:54:54 +01:00
Ludovic Marie c08bbcfec7 union: #2960 2026-10-28 23:54:54 +01:00
Ludovic Marie 0507b4dc84 union: #2924 2026-10-28 23:54:54 +01:00
Ludovic Marie f8df12f724 union: #2911 2026-10-28 23:54:53 +01:00
Ludovic Marie 99c8c90021 union: #2894 2026-10-28 23:54:53 +01:00
Ludovic Marie 9812674ec9 union: #2839 2026-10-28 23:54:53 +01:00
Ludovic Marie 7ba38fd574 union: #2721 2026-10-28 23:54:53 +01:00
Ludovic Marie 24ca331daa union: #2711 2026-10-28 23:54:53 +01:00
Ludovic Marie 9be7d9602f union: #2705 2026-10-28 23:54:53 +01:00
Ludovic Marie 26ee5035e4 union: #2689 2026-10-28 23:54:52 +01:00
Ludovic Marie c541dc7e75 union: #2515 2026-10-28 23:54:52 +01:00
Ludovic Marie 4d89ce522f union: #2417 2026-10-28 23:54:52 +01:00
Ludovic Marie 03589605fc union: #2383 2026-10-28 23:54:52 +01:00
Ludovic MarieandClaude Opus 5.5 326719539d Merge main into fix/shelf-interaction
main's #2952 added banner, vault and trial spawner arms to lan::chunks::entity_tag next to this branch's brushable and shelf arms; both stay.

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 17:27:45 -05:00
degepie ac7e491f3b Merge remote-tracking branch 'origin/main' into fix/issue-3137-suspicious-stew
# Conflicts:
#	crates/horizon-protocol/src/item.rs
2026-10-29 03:04:30 +05:00
d3grandoandClaude Opus 5.5 e48d8560e0 Show custom names as container titles (#3245)
The server opened every block container with its default title
(container.chest, container.shulkerBox...), so renamed shulker boxes,
copper chests and every other container lost their names on the
server thread, on LAN and on dedicated servers. The menu title now
follows BaseContainerBlockEntity.getDisplayName (the custom name, else
the default name) and, for double chests, ChestBlock's
MENU_PROVIDER_COMBINER (right half, then left, then
container.chestDouble).

The shulker box screen always drew "Shulker Box", and the furnace and
crafter screens ignored the server's title; they now draw it like the
other container screens.

New harness tools/test_container_title_parity.py
(VanillaContainerTitleOracle against container_title_fixture, 148
cases), and a lan_e2e "names" check.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 05:03:35 +07:00
Ludovic MarieandClaude Opus 5.5 b85a9e8350 Merge main into fix/server-core-parity
main added the pack prompt and server dialogs to the screens that stay open when the window loses focus;
they move into pause_if_inactive with the rest of that check.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 22:54:25 +01:00
degepieandClaude Opus 5.5 cb1c3499bf Keep suspicious stew effects across the wire and list them in creative
A stew taken from the creative inventory gave no effect when eaten and
its tooltip listed none (#3137). The protocol read the
`suspicious_stew_effects` component past without decoding it, so the
creative client's `SetCreativeModeSlot` sent the server a bare stew and
the server's copy had nothing for `SuspiciousStewEffects.onConsume` to
apply. Decode and encode it (`Entry.STREAM_CODEC`: mob effect id,
duration) both ways, and add `SuspiciousStewEffects.addToTooltip`'s
creative-only effect lines (`PotionContents.addPotionTooltip`).

Fixes #3137.

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

Fixes #3310
Fixes #3002

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 16:29:10 -05:00
ThatCakeIsAPieandClaude Opus 5.5 598668e4bc Far terrain: stop decoding saved chunks for every distant column
SavedLodSampler::grid put saved chunks over every far tile of 16 blocks
a column or finer: for each column it opened the region file and
decoded the whole saved chunk, and each sampling thread kept only 128.
In a save with many thousands of chunks, the far view stalled for many
minutes after its first one.

- SaveDir keeps which chunks each region file holds (10 s), so columns
  over unsaved ground no longer open region files.
- Decoded saved chunks are shared by the sampling threads (1024, 30 s).
- With Reduced far detail, tiles coarser than 4 blocks a column (about
  1 km out and beyond) keep the generator's terrain, which saved chunks
  were made from; edits that far away show when their tiles refine on
  approach. Full keeps saved chunks up to 16 blocks a column, as before.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 14:57:22 -05:00
ThatCakeIsAPieandClaude Opus 5.5 a1a249e2f6 Far tiles: approximate only with Reduced far detail
The previous two commits trade the exact far tile for speed in wide
cells. They now apply only with Reduced far detail (FarDetail::Reduced,
which already "changes the picture"); Full keeps the exact tiles.

- GeneratorWorker::lod_grid takes `approximate`: one biome per 4x4
  columns, the coarser vertical lattice, and density only around the
  surface (with shared 2D planes) happen only when it is set. Without it
  the tile is sampled as before.
- LodSampler::set_approximate, and Lod::set_reduced_detail sets it for
  the sampling threads; tiles sampled after a change follow it.
- lod_grid_bench --approximate samples as Reduced does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 14:57:21 -05:00
ThatCakeIsAPieandClaude Opus 5.5 eb5e0bb914 Far tiles: sample density only around the surface, share 2D planes
lod_grid sampled the density lattice over the whole build height. It now
finds each tile's surface band on a 64-block lattice made of the fine
lattice's points, then samples the fine lattice from 64 below the lowest
surface to 128 above the highest (and above sea level); below the band a
column is one span of the default block. Thin parts above an air sample
of the coarse lattice (floating islands, overhang lips) can be missed.

Most of a far tile's density work is 2D noise (y-fixed slices), which
the two passes would each compute: SamplerContext::with_planes keeps a
y-fixed slice's plane for its x/z lattice, so the band pass reuses the
coarse pass's. Opt-in, and only far sampling turns it on.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 14:57:21 -05:00
ThatCakeIsAPieandClaude Opus 5.5 d93c3cdd82 Far tiles: one biome per 4x4 columns, a coarser vertical lattice
Approximations for wide far cells, where they are hard to see at their
distance:

- One biome for 4x4 of a tile's columns, the first one asked (its
  surface's), in cells up to 64 blocks wide: far cells don't show a biome
  change a few blocks down, and climate changes over hundreds of blocks.
  Painting asks through the biome zoom, which strays into neighbouring
  quarts; per quart, those strays were most of a tile's climate samples.
- The interpolated density on a vertical lattice of 16 blocks for cells 32
  blocks wide or more, and of 32 from 128, instead of 8.

Distant tiles' biomes and some surface blocks differ from the per-quart
answers, and in the widest tiles the coarser lattice moves the surface.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 14:57:20 -05:00
ThatCakeIsAPieandClaude Opus 5.5 91ed783e8d Far tiles: resolve each biome quart once per tile
lod_grid paints eight depths of every far column through the surface
rule, and each depth asked the biome resolver again: a full climate
sample and parameter search per query, though neighbouring columns and
depths share quarts. The tile now keeps the answers per quart
(Biomes::Memo), for painting and for the columns' surface biome.

lod_grid_bench, vanilla seed 1234: surface heights, blocks and biomes
are the same at every level. At level 6 (64 blocks a column) some
painted blocks below the surface differ: the tile's cached sampler
context answers a repeated quart from what it sampled before, so the
order of queries mattered there.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 14:57:19 -05:00
ThatCakeIsAPieandClaude Opus 5.5 bc3a724c01 lod_grid_bench: far terrain sampling cost per tile level
Times GeneratorWorker::lod_grid as the client's far terrain calls it (16 x
16 columns a tile) for each tile level, with two checksums: everything the
tiles hold (an exact speedup keeps it) and the surface and water heights
alone (the terrain's shape, which an approximation of materials or biomes
keeps). --dump writes each column's height, block and biome.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 14:57:19 -05:00
ThatCakeIsAPieandClaude Opus 5.5 24e316855e Create World: the Data Packs screen
The More tab's Data Packs button opens vanilla's PackSelectionScreen (the
resource pack screen's port, titled dataPack.title) on a new mcworld-
folder: its packs, with their icons, and the built-in one, re-listed when
the folder changes. Create New World copies the folder's packs into the
world's datapacks/ and writes datapacks.json with the selection, as
CreateWorldScreen.copyTempDataPackDirToNewWorld does. Cancel deletes the
folder. Worlds made without opening the screen are unchanged.

The pack screen parity harness also runs the screen under dataPack.title.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 12:50:12 -05:00
Hector Eccles cbf0a4f289 fix: address review findings
- palette lookups only match live slots, not freed ones still on the free list (#3253)
- a slot cleared after a planned write in one batch resets its offset (#3254)
- terrain sections are picked relative to the dimension's MIN_Y (#3255)
2026-10-28 13:43:13 -04:00
Hector Eccles d7ee852c7c fix: address review findings
- palette lookups only match live slots, not freed ones still on the free list (#3253)
- a slot cleared after a planned write in one batch resets its offset (#3254)
- terrain sections are picked relative to the dimension's MIN_Y (#3255)
2026-10-28 13:43:06 -04:00
Hector Eccles 04ad9d5c6e fix: address review findings
- palette lookups only match live slots, not freed ones still on the free list (#3253)
- a slot cleared after a planned write in one batch resets its offset (#3254)
- terrain sections are picked relative to the dimension's MIN_Y (#3255)
2026-10-28 13:42:57 -04:00
Hector Eccles 77bcba839b fix: address review findings
- palette lookups only match live slots, not freed ones still on the free list (#3253)
- a slot cleared after a planned write in one batch resets its offset (#3254)
- terrain sections are picked relative to the dimension's MIN_Y (#3255)
2026-10-28 13:42:49 -04:00
Hector Eccles 9ac7754c45 fix: address review findings
- palette lookups only match live slots, not freed ones still on the free list (#3253)
- a slot cleared after a planned write in one batch resets its offset (#3254)
- terrain sections are picked relative to the dimension's MIN_Y (#3255)
2026-10-28 13:42:40 -04:00
Hector Eccles 677dbd677e fix: address review findings
- palette lookups only match live slots, not freed ones still on the free list (#3253)
- a slot cleared after a planned write in one batch resets its offset (#3254)
2026-10-28 13:42:14 -04:00
Hector Eccles e41443c64c fix: address review findings
- palette lookups only match live slots, not freed ones still on the free list (#3253)
2026-10-28 13:42:04 -04:00
RVZO6andCodex 61713b8359 Fix menu separator texture sampling
Use both rows of the separator textures when tiling headers and footers, including the create-world screen.

Co-Authored-By: Codex (GPT-6.1 Terra) <[email protected]>
2026-10-28 13:16:26 -04:00
Alexander KrylovandHermes Agent 0e266fd807 Windows: link the overlay's mirrored files instead of copying them
`link` copied every file the mirror hands the overlay, so a selection with a
pack on wrote the prepared root out again: 639 MB and 28 181 files here, and a
cold overlay build of 34.4 s against 20.8 s when the same files are hardlinks.
Unix already links them and the overlay only reads them.

A different volume, or a filesystem without hardlinks, still copies.

Refs #3234

Co-Authored-By: Hermes Agent (Nous Research) <[email protected]>
2026-10-28 19:34:16 +03:00
Hector EcclesandOpenCode bebabd0d1a fix: spell out the server music/ambience gates
Co-Authored-By: OpenCode (DeepSeek V4.1 Flash) <[email protected]>
2026-10-28 11:43:51 -04:00
Hector EcclesandOpenCode b84cbb7921 perf: skip the client CPU bundle on a server
A game that draws nothing loaded and baked the client's models in
Game::new anyway: the item frame, glow, map frame and glow map frame
models, the armor-stand and trim assets, the waypoint styles, the minecart
models, the crystal, cushion, dragon, arrow and trident parts, plus the
first-person player arms and the spin-attack model in the world-rest
loader. Game::new now builds GameCpuResources::server for a non-client
role: the painting variants, the boat model table (placement and save
loading ask which boats exist) and the creative tabs stay; the render
models do not. The window shares the full bundle it already prepared, and
the integrated server never renders.

Co-Authored-By: OpenCode (DeepSeek V4.1 Flash) <[email protected]>
2026-10-28 11:42:50 -04:00
Hector EcclesandOpenCode e564f9d6ea perf: wake workers after re-ranking the queue
They may have found the heap empty while it was swapped out; restored
jobs need a notification even when nothing new is submitted.

Co-Authored-By: OpenCode (DeepSeek V4.1 Flash) <[email protected]>
2026-10-28 11:42:50 -04:00
Hector EcclesandOpenCode f8b7a0c2ae fix: elide two needless derefs/lifetimes in the light tests
Co-Authored-By: OpenCode (DeepSeek V4.1 Flash) <[email protected]>
2026-10-28 11:42:50 -04:00
Hector EcclesandOpenCode ce5fa3cb8b perf: skip the sound registry on a server
Audio::silent is only a server's (dedicated or integrated): it sends
sound ids to clients but plays nothing, so it no longer reads
sound.json. A client keeps the registry even with HORIZON_NO_AUDIO set,
because subtitles still need event names.

Co-Authored-By: OpenCode (DeepSeek V4.1 Flash) <[email protected]>
2026-10-28 11:42:50 -04:00
Hector EcclesandOpenCode 0a8720be88 perf: build only the font atlas for a server
A dedicated or integrated server draws nothing, but load_resources built
the full HUD atlas: every GUI, block, item, entity and particle PNG, 256
128x128 map slots and the six panorama faces. The loader now takes a
`visuals` flag from the execution role; a server builds HudAtlas::fonts
plus a 1x1 empty panorama (the construction server_game already used) and
skips ui::load_metadata. Lang and the Assets/RenderAssets rollback path
are unchanged.

Co-Authored-By: OpenCode (DeepSeek V4.1 Flash) <[email protected]>
2026-10-28 11:42:50 -04:00
Hector EcclesandOpenCode 46fba800fa perf: skip decoration re-ranking while no proto can be decorated
A moved centre re-sorted thousands of chunks even with nothing to decorate.
The next proto recomputes the order.

Co-Authored-By: OpenCode (DeepSeek V4.1 Flash) <[email protected]>
2026-10-28 11:42:50 -04:00
Hector EcclesandOpenCode 10415990a5 perf: load the resource tail in three lanes
Split load_world_rest into three independent lanes: items -> recipes ->
transformers -> stations; animate/fog/providers/block-entity renderer ->
behaviours; and mob data/trial spawner, music/ambience, dimension
environments, mob types and player models. Lanes run in a scope when
the worker budget allows it, are merged in the existing order, and the
first error wins. Progress now uses fetch_max so no lane can move the
loading screen backwards.

Co-Authored-By: OpenCode (DeepSeek V4.1 Flash) <[email protected]>
2026-10-28 11:42:50 -04:00
Hector EcclesandOpenCode f56135aec2 perf: reject non-near forced chunks with one bounds compare
Cache the forced chunks' bounding box once per update so columns outside it
skip the per-forced-chunk scan. Compare extra areas as a set so a reordered
guest list does not rebuild the cover grid.

Co-Authored-By: OpenCode (DeepSeek V4.1 Flash) <[email protected]>
2026-10-28 11:42:50 -04:00
Hector EcclesandOpenCode 1d8e192360 perf: key behaviour snapshots with prepare's recorded hashes
horizon-prepare now records the gameplay-facts, block-behaviour and
fluid-tag hashes next to the existing facts, tags and collision ones.
The snapshot key reuses those hashes instead of reading and SHA-256ing
~70 MB on every launch; a source newer than the manifest, or a root
without one, is still hashed. The digest construction is unchanged in
meaning and the snapshot stays disposable.

Co-Authored-By: OpenCode (DeepSeek V4.1 Flash) <[email protected]>
2026-10-28 11:42:50 -04:00
Hector EcclesandOpenCode 1734325664 perf: build one Ranges per decoration candidate
The blocked check asked about up to 24 neighbours and rebuilt Ranges for
each of their nine requested() calls. Hoist it to one per candidate; the
candidate list still comes from the same scan and sort.

Co-Authored-By: OpenCode (DeepSeek V4.1 Flash) <[email protected]>
2026-10-28 11:42:50 -04:00
Hector EcclesandOpenCode 8f7b90cf46 perf: load recipe files in parallel ranges
Split the sorted 2,042-file recipe list into up to eight contiguous
ranges parsed with std::thread::scope. Ranges are concatenated in file
order and their parse warnings printed in that order, so the result and
logs match the serial loader. A single worker keeps the old path.

Co-Authored-By: OpenCode (DeepSeek V4.1 Flash) <[email protected]>
2026-10-28 11:42:50 -04:00
Hector EcclesandOpenCode 60cd02204c perf: share one Arc region across a column's mesh jobs
Build the 3x3 snapshot once per column instead of cloning the boxed region
(and bumping 9 chunk refcounts) for every section.

Co-Authored-By: OpenCode (DeepSeek V4.1 Flash) <[email protected]>
2026-10-28 11:42:50 -04:00
Hector EcclesandOpenCode 43f2d07841 perf: re-rank the job queue outside its mutex
Workers block on the queue mutex before every pop, so one re-rank stalled
the whole pool. Take the heap, re-rank it unlocked, and merge jobs pushed
meanwhile. Compare extra areas as a set so a reordered list does not
re-rank.

Co-Authored-By: OpenCode (DeepSeek V4.1 Flash) <[email protected]>
2026-10-28 11:42:50 -04:00
Hector EcclesandOpenCode b88d6230e9 perf: hand uniform and packed terrain sections to resident columns
A uniform terrain section costs one state read instead of 4,096, and a
non-uniform one hands its palette and packed words over unchanged instead
of re-deriving them from every cell. Resident containers accept any palette
order; pack() still writes vanilla's first-use order.

Co-Authored-By: OpenCode (DeepSeek V4.1 Flash) <[email protected]>
2026-10-28 11:42:41 -04:00
Hector EcclesandOpenCode b2d7ec6b1b fix: avoid an explicit deref in the region cache test
Co-Authored-By: OpenCode (DeepSeek V4.1 Flash) <[email protected]>
2026-10-28 11:42:41 -04:00
Hector EcclesandOpenCode 4c645729d5 perf: reuse the light scratch buffers on each worker
One 48x48xheight region, its queues and the id buffer live with the worker
instead of being allocated and zeroed per light job. Resized only upward
and fully cleared before use.

Co-Authored-By: OpenCode (DeepSeek V4.1 Flash) <[email protected]>
2026-10-28 11:42:41 -04:00
Hector EcclesandOpenCode 36af8b0879 perf: light proto stand-ins without building a gameplay column
light_view only needs blocks and default light; skip biomes, ticks,
structures, postprocessing and block entities.

Co-Authored-By: OpenCode (DeepSeek V4.1 Flash) <[email protected]>
2026-10-28 11:42:41 -04:00
Hector EcclesandOpenCode 93564d764c perf: return the changed-section mask from light jobs
The worker already holds the snapshot, so it marks the sections whose light
differs and the game thread replays only those instead of diffing all 24.

Co-Authored-By: OpenCode (DeepSeek V4.1 Flash) <[email protected]>
2026-10-28 11:42:41 -04:00
Hector EcclesandOpenCode 403b417073 perf: skip relighting already lit diagonals of a new chunk
A diagonal's light can only change through a face neighbour, and a lit
diagonal had all nine columns around it, so only its faces and any unlit
neighbour are queued.

Co-Authored-By: OpenCode (DeepSeek V4.1 Flash) <[email protected]>
2026-10-28 11:42:41 -04:00
Hector EcclesandOpenCode eac87c1a0b fix: pass the region sector bitmap as a slice to free_in
Co-Authored-By: OpenCode (DeepSeek V4.1 Flash) <[email protected]>
2026-10-28 11:41:38 -04:00
Hector EcclesandOpenCode d46bed5c36 perf: hash the world's integer bookkeeping maps with Fx
World.modified, inhabited, chest_openers and block_names used std SipHash
on small integer keys, and modified/inhabited rehash as they grow while an
area unloads. Use the existing crate::fast_hash maps; none of their
iterations is order-sensitive (save order comes from the batch list and
ChunkExtras::block_entity_order). chunks_with_block_entities takes the
names map by hasher so the faster map still fits.

BlockEntities::map stays std HashMap: crate::lan::chunks::body takes the
default-hasher map by type and lan/** is outside this change.

Co-Authored-By: OpenCode (DeepSeek V4.1 Flash) <[email protected]>
2026-10-28 11:07:24 -04:00
Hector EcclesandOpenCode d26c7b42c2 perf: reuse one zlib stream and scratch buffer per thread
Every saved chunk built a fresh flate2 ZlibEncoder and every load a fresh
Decompress, each allocating and initialising a ~256 KiB deflate state.
Keep one of each plus an 8 KiB decoder block in a thread-local and reset
before and after every chunk (including error paths): encoding keeps one
ZlibEncoder and takes its finished buffer, decoding reuses one Decompress.
The encoded bytes stay decodable and the public encode/decode signatures
are unchanged.

Co-Authored-By: OpenCode (DeepSeek V4.1 Flash) <[email protected]>
2026-10-28 11:07:24 -04:00
Hector EcclesandOpenCode 9b9d045150 perf: find section palette ids with a scan and a last-state cache
BlockSection kept a HashMap of state ids per section (24 tables and
allocations per proto chunk) and hashed on every worldgen write. Resident
palettes are almost always tiny, and the resident container already scans
them; use the same scan with a one-entry last lookup so a run of one state
is a compare. set/compact behaviour and the palette, counts and free list
are unchanged.

Co-Authored-By: OpenCode (DeepSeek V4.1 Flash) <[email protected]>
2026-10-28 11:07:24 -04:00
Hector EcclesandOpenCode 50bf909d7b perf: bound the region cache and close idle region files
save.rs kept every Arc<Mutex<RegionFile>> for the process lifetime: one
descriptor, header copy and bitmap per visited region, which a long
exploration accumulates. Track each entry's last use and close regions
past a 64-entry cap or idle for 60 seconds. Only an unheld Arc is evicted
(strong_count == 1), so a reader or the writer keeps its region open and
write ordering is untouched; a later access reopens the file and re-reads
the header.

Co-Authored-By: OpenCode (DeepSeek V4.1 Flash) <[email protected]>
2026-10-28 11:07:24 -04:00
Hector EcclesandOpenCode 1e5cc9031f perf: share the multiply-shift index between paletted storage and PackedStorage
PackedStorage divided by its per-word width on every read and write, while
PalettedContainer already used an exact multiply-shift. Move MAGIC/locate/
mask into storage.rs and use them from both; only storages past 16,384
entries keep the division path.

Co-Authored-By: OpenCode (DeepSeek V4.1 Flash) <[email protected]>
2026-10-28 11:07:24 -04:00
Hector EcclesandOpenCode e2a2cf4387 perf: read region chunks with positioned I/O and a read lock
RegionFile::read took &mut self and seeked twice, so save.rs held the
region Mutex for the whole read and two loaders of one region serialized.
Read through FileExt::read_exact_at (seek_read/seek_write on Windows) so it
takes &self, write the payload and header entries positioned too, and hold
the cache's region in an RwLock: readers share the handle, a write batch
still has it exclusively. The payload-bounds check is unchanged.

Co-Authored-By: OpenCode (DeepSeek V4.1 Flash) <[email protected]>
2026-10-28 11:07:24 -04:00
Hector EcclesandOpenCode ee351caed8 perf: mark only the distinct sections a block write dirties
A write looped 27 block offsets through the dirty-section map; all but a
few hashed the same keys. Mark the exact section set instead: one key in
the interior, the adjacent section on a section face, a neighbour column
on a chunk border, and the diagonal where both borders meet (at most eight
keys). The mesher still sees the same 3x3x3 neighbourhood.

Co-Authored-By: OpenCode (DeepSeek V4.1 Flash) <[email protected]>
2026-10-28 11:07:24 -04:00
Hector EcclesandOpenCode cd4b744abc perf: write a region batch with one header write and an allocation cursor
RegionFile::write did a first-fit scan from sector 0, one seek+write for
the payload, two more for the header entry and an allocator-padded buffer
per chunk; save.rs called it once per record, so a 1,024-chunk region cost
~6,000 syscalls.

write_batch plans every record on copies of the tables and the bitmap
(data first, header second, old runs freed last), keeps a cursor over the
free runs so first-fit chooses the same sectors as before, coalesces
adjacent runs into one write, writes the 8 KiB header once and only
unlinks a chunk's .mcc when the slot had a previous record. save.rs passes
its records to one batched call and syncs once.

Co-Authored-By: OpenCode (DeepSeek V4.1 Flash) <[email protected]>
2026-10-28 11:07:24 -04:00
i114rkandI114rk's AI agent 358b211e97 Boot loading screen: the window paints black with the wordmark, tagline and live console lines before the renderer's first frame
Co-Authored-By: I114rk's AI agent (OpenCode) <[email protected]>
2026-10-28 17:04:55 +03:00
d3grandoandClaude Opus 5.5 af5268eacd Mod API: a sprite shape draws as vanilla's blitSprite, nine-sliced by its gui.scaling
render's sprite shape stretched every sprite over its rect, so a mod drawing
tooltip/background and tooltip/frame (or widget/button) in a box of another
size got smeared borders. It now goes through ui::blit_sprite, which does what
GuiGraphicsExtractor.blitSprite does with the sprite's GuiSpriteScaling:
nine-sliced when its .png.mcmeta says nine_slice (47 vanilla GUI sprites),
stretched otherwise. Tile, which no vanilla sprite uses and Horizon doesn't
read yet, still stretches.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 16:50:55 +07:00
SenjinandClaude Opus 5.5 41d3890a3c Revert "HDR: bright lifts from darker pixels"
Only a soul lantern needed it, and it lifted less for its light level, not for
its texture: that is better fixed where the lift is worked out.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 10:14:15 +01:00
SenjinandClaude Opus 5.5 2f13cb3ac6 HDR: bright lifts from darker pixels
The bright tier's window goes to 0.04-0.28, from a round in the HDR gallery: a soul
lantern's glass (light level 10) now lifts, and the level-15 bright blocks still look
right.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 09:34:28 +01:00
SenjinandClaude Opus 5.5 f52e94cfb9 docs: glowing particles shine in HDR now; translucent blocks don't yet
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 06:41:34 +01:00
Kacpherek e0a48b3f66 Document the 2..128 render distance range 2026-10-28 05:56:49 +01:00
Kacpherek d35efa25ec Accept render distances up to 128 from the command line and horizon-ctl
--render-distance and horizon-ctl render-distance check against the same cap as the options.
2026-10-28 05:56:48 +01:00
Kacpherek b8b15b1682 Raise the render distance cap from 64 to 128 chunks
The options slider and options.txt now accept 2..128. 128 is where the server protocol's view distance (an i8) tops out.
2026-10-28 05:56:48 +01:00
SenjinandClaude Opus 5.5 113d694a87 HDR: very bright lifts over bright's window, a step above bright
Very bright's window (0.88 to 0.94) was tuned on the sun alone: on a block
only the hottest pixels reached it, and they blew out while the rest
stayed below bright. It now lifts over bright's window with the whole
headroom; the sun keeps its yellow corona near the horizon.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 05:22:02 +01:00
Ludovic MarieandClaude Opus 5.5 f049a08e48 packs: keep the available packs in one id order, as PackRepository does
PackRepository.discoverAvailable puts the packs of every source into a
single TreeMap by id, so the folder's file/ packs sort among the built-in
ones rather than after them.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 23:02:52 +01:00
Ludovic MarieandClaude Opus 5.5 f5f5532c19 Merge main into fix/goat-horns
Kept the goat horn use next to main's bundle use in use_item.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 22:41:36 +01:00
Ludovic MarieandClaude Opus 5.5 4d0962e611 Merge main into fix/fog-lava-and-night-vision
Kept fog_night_vision next to main's level_flat/level_min_y and main's
flat-world dark disc.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 22:38:12 +01:00
Ludovic MarieandClaude Opus 5.5 2ee238e5f1 Merge #2931 into #2924: both add vanilla 26.3 options to options_ui.rs
The controls list and the mouse list sit side by side; #2931 lands
first.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 22:28:53 +01:00
Kacpherek 7711bfcdc5 Fail a dialog whose body or input cannot be read
Dialog.DIRECT_CODEC rejects the whole dialog for an unreadable body or input, a single option with no or two initial entries, or a text default longer than its limit. Horizon dropped the bad part and showed the rest.
2026-10-27 22:26:43 +01:00
Kacpherek 2ac08efcb2 Draw dialog sliders, dropdowns, item bodies and dialog lists
The dialog screen skipped number range and single option inputs, item bodies and the entries of a dialog_list, so a server menu showed only its Back button. Draw them as DialogScreen does: a slider and a cycle button, the item widget with its description, and one button per listed dialog (external title, opens it). The client now keeps the server's dialog tags to resolve a #tag list.
2026-10-27 22:26:43 +01:00
Kacpherek 559b83626f Open links from dialog buttons through the link confirmation
DialogScreen hands an open_url to Screen.clickUrlAction (Web Links, Prompt on Links, ConfirmLinkScreen over the screen the dialog leaves). A suggest_command has no screen to take text; change_page is logged as an error.
2026-10-27 22:26:43 +01:00
Kacpherek c2915208fd List built-in packs before folder packs in the pack screen
PackRepository keeps its sources in order (built-ins, then resourcepacks/), each sorted by id. Horizon sorted every id together.
2026-10-27 22:26:43 +01:00
Kacpherek 04bc30606d Take a texture's .mcmeta only from its own pack or a higher one
FallbackResourceManager ignores a .mcmeta from a pack below the one that supplies the file. Horizon used the top .mcmeta of any pack, so an animation could run on a pack's static PNG.
2026-10-27 22:26:43 +01:00
Kacpherek 6893bcff88 Cap server command suggestions at 1000
ServerCommandSuggestionsProvider keeps the first 1000 suggestions of a request; Horizon sent them all.
2026-10-27 22:26:43 +01:00
Kacpherek aae98d3db5 Send vanilla's error text for an invalid login name
There is no multiplayer.disconnect.invalid_name key. Vanilla's validState throws and Connection.exceptionCaught sends disconnect.genericReason with the exception.
2026-10-27 22:26:43 +01:00
Ludovic MarieandClaude Opus 5.5 6bb648db1e Controls: the three switches nothing reads yet show as unavailable
Operator Items Tab, Quit Shortcuts and Control-click are stored and saved
like vanilla's, but no code reads them yet: grey them out like the other
placeholders instead of offering a switch that does nothing.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 22:26:24 +01:00
Kacpherek fece4c6a76 Run function commands at function-permission-level
Function lines parsed at whatever level the caller had, so a tick or load function did nothing in a world without cheats and an owner's function could run /stop. Vanilla compiles every function line at the library's permission (function-permission-level, 2 elsewhere).
2026-10-27 22:26:19 +01:00
Kacpherek a0b19a4820 Stop a dedicated server whose tick runs past max-tick-time
max-tick-time was read and never used. Vanilla's ServerWatchdog (DedicatedServer.initServer, when the value is above 0) logs and writes a crash report once a tick is that far past its due time, then exits with status 1.
2026-10-27 22:26:19 +01:00
Kacpherek 02b1d1f430 Pause on lost focus only after 500 ms without it
Horizon paused the moment the window lost focus. Vanilla's pauseIfInactive waits until focus has been missing for more than 500 ms, so a brief focus steal does not pause.
2026-10-27 22:26:19 +01:00
Kacpherek 052dac817a Drop a guest that misses a keep-alive for 15 seconds
The server waited 30 s for the answer. Vanilla disconnects with a timeout when the last keep-alive is still pending 15 s after it was sent; 30 s is only the read timeout.
2026-10-27 22:19:12 +01:00
Kacpherek 42388d2512 Swap items with shelves and powered shelf chains 2026-10-27 22:02:24 +01:00
Kacpherek 2deaab2602 Render items stored in shelves 2026-10-27 22:02:24 +01:00
Kacpherek ca28a0edad Show items revealed by brushing suspicious blocks 2026-10-27 22:02:18 +01:00
ElyTheCat 80df20837b Options: add Java 26.3 controls 2026-10-27 22:09:31 +03:30
masa-masa-0314andZCode 2092c5a2e8 Worlds: take the save's icon.png from the rendered frame
GameRenderer.tryTakeScreenshotIfNeeded saves a world icon the Select
World list never had: while a local world is on screen without one, the
frame is grabbed after the level and the hand (before the HUD), centre
cropped to a square, averaged down to 64x64 and written to the save's
icon.png once a second until it sticks. The list already drew the icon
when a save had one; nothing ever created it.

Co-Authored-By: ZCode <[email protected]>
2026-10-28 02:29:39 +08:00
Ludovic MarieandClaude Opus 5.5 3246732087 Read the inline instrument through horizon_client_core::scoreboard
#2215 moved items.rs into horizon-items, where scoreboard is not in the
crate root.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 19:13:32 +01:00
Ludovic MarieandClaude Opus 5.5 c215a48c91 Merge main into fix/goat-horns
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 19:11:21 +01:00
Hector EcclesandDeepSeek V4.1 Flash 783c25e9bc Draw chorus plants in End LOD terrain
Distant End terrain now estimates chorus plants from the biome's own
chorus_plant feature: the stem height and the age-5 tip follow the real
feature, while its placement modifiers give the density. Real chorus also
survives the refined, visited and saved far-column summaries, which
previously dropped every non-solid block.

Vanilla reference: ChorusPlantFeature and ChorusFlowerBlock.generatePlant,
already ported in horizon-world.

Co-Authored-By: DeepSeek V4.1 Flash <[email protected]>
2026-10-27 13:15:22 -04:00
Ludovic MarieandClaude Opus 5.5 6aabf3f618 Merge main into fix/goat-horns
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 18:11:43 +01:00
Senjin fbab7d29a5 Merge remote-tracking branch 'origin/main' into hdr-tiers
# Conflicts:
#	crates/horizon-client/src/entity.wgsl
2026-10-27 17:57:13 +01:00
SenjinandClaude Opus 5.5 be26c6db88 HDR: log what the display offers, and when HDR turns on or off
The log already names the adapter and the surface format; a report of HDR
trouble now also shows whether the surface offers HDR10 or scRGB, and the
output, swapchain format and nits it turned on with.

Part of #1441.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 16:57:42 +01:00
iLostName 66b705cec3 sounds: list each namespace's sound files once instead of a lookup per sound 2026-10-27 14:52:56 +01:00
levzzzandminimax-m3.1-flash 1232745064 Keep a traveller that waits for an End gateway's exit
The traveller was dropped from the queue before the exit was resolved, so a
failure to resolve it lost the entity for good. The traveller now waits and
the failure is retried, with the reason in the log.

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

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

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

Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>
2026-10-27 17:10:44 +05:00
Senjin fea8346f23 Merge remote-tracking branch 'origin/main' into hdr-f3 2026-10-27 13:10:29 +01:00
Senjin 523dd491c4 Merge remote-tracking branch 'origin/main' into hdr-tiers
# Conflicts:
#	crates/horizon-client/src/entity.wgsl
#	crates/horizon-client/src/gfx.rs
2026-10-27 13:07:04 +01:00
levzzzandminimax-m3.1-flash 1a56e74324 Fix the f-string in the end cargo audit script
The braces were one too many, so the file did not parse and the Python
tests failed on it. The command it builds is the same now: the item
stack {Item:{id:"minecraft:diamond",count:3}}.

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

Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>
2026-10-27 16:11:14 +05:00
iLostName 7c58bf004f options: the slider test expects saving on release 2026-10-27 10:06:32 +01:00
iLostName 4119284a4f options: sliders apply while dragged and save on release 2026-10-27 09:55:35 +01:00
SenjinandClaude Opus 5.5 957a3c6622 HDR: show the output on the F3 screen
While the HDR setting is on, F3 shows the swapchain HDR presents to, with
the paper white and peak ("HDR: HDR10 (PQ), 200/500 nits"), or that the
display offers none. With the setting off nothing is added, so the screen
stays as vanilla's.

Part of #1441.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 09:33:26 +01:00
SenjinandClaude Opus 5.5 58f2ea700e HDR: light sources shine by the kind of light, not its level
Light level says how far a source lights its surroundings, not how bright
it looks: a candle's flame is as bright as a torch's. Light sources now
get a tier (mesher::hdr_tier): very bright (the sun), bright (glowstone,
campfires, the soul fires), incandescent (flames, lava), low (redstone
torch, magma, a candle's wick) or none (glow lichen). Unlisted ones go by
their level. The tier rides in the emission bits as the level did, and
each has its own share of the headroom and its own lift window
(gfx/hdr.rs TIERS), tuned by eye on an HDR1000 OLED.

Flames, lava drops and obsidian tears now shine too: their tier rides in
the particle vertex's overlay bits, and with HDR on a second draw of the
entities writes only the frame's alpha for them, so their colour still
blends as before.

HORIZON_HDR_DEBUG=1 now greys what isn't lifted and tints what is, so a
lift shows on purple and red light too.

Part of #1441.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 09:07:23 +01:00
Senjin 9da81f9293 Merge remote-tracking branch 'origin/main' into hdr-tiers 2026-10-27 09:01:54 +01:00
Kacpherek 7f8eb60682 Draw campfire smoke fully opaque at first
Campfire smoke started at alpha 0.9 or 0.95. Vanilla leaves it at
SingleQuadParticle's default of 1.0 and only fades it near the end.
2026-10-26 22:40:32 +01:00
Kacpherek 75267d2fc6 Read inline goat horn instruments as well as registry ids 2026-10-26 21:43:33 +01:00
Kacpherek 9b88df32d3 Make goat horns play their sound and show the horn pose
A goat horn did nothing when used. It now plays its instrument at the
player, is held up for the instrument's duration, goes on cooldown, posts
the instrument_play game event and counts as used. Other players hear it,
see the arm raised to the head, and the tooltip names the call.
2026-10-26 19:54:15 +01:00
Kacpherek 36ebd0c545 Follow vanilla's lava fog for Fire Resistance and spectators, and brighten the fog colour with Night Vision 2026-10-26 19:34:12 +01:00
121 changed files with 7697 additions and 948 deletions

No files matched your search

@@ -494,7 +494,11 @@ pub fn canonical(tag: &Tag) -> Option<Tag> {
_ => string("minecraft:air"),
},
);
f.put("facing", Tag::Int(int(tag.get("facing")).unwrap_or(0)));
// Direction.LEGACY_ID_CODEC: the 3D data value as a byte.
f.put(
"facing",
Tag::Byte(int(tag.get("facing")).unwrap_or(0) as i8),
);
f.float("progress", 0.0);
f.flag("extending", false);
f.flag("source", false);
@@ -873,6 +877,23 @@ mod tests {
);
}
/// `PistonMovingBlockEntity.saveAdditional`: `facing` through
/// `Direction.LEGACY_ID_CODEC` (a byte), `progress` a float.
#[test]
fn moving_piston_fields_match_vanilla() {
let out = canon(
"{id:\"minecraft:piston\",x:1,y:2,z:3,blockState:{Name:\"minecraft:stone\"},\
facing:5,progress:0.5f,extending:1b,source:0b}",
);
assert_eq!(out.get("facing"), Some(&Tag::Byte(5)));
assert_eq!(out.get("progress"), Some(&f32_tag(0.5)));
assert_eq!(out.get("extending"), Some(&Tag::Byte(1)));
assert_eq!(out.get("source"), Some(&Tag::Byte(0)));
let fresh = canon("{id:\"minecraft:piston\",x:1,y:2,z:3}");
assert_eq!(fresh.get("facing"), Some(&Tag::Byte(0)));
assert_eq!(fresh.get("blockState"), Some(&string("minecraft:air")));
}
#[test]
fn loot_table_replaces_items_and_zero_seed_is_omitted() {
let out = canon(
+16 -6
View File
@@ -45,20 +45,30 @@ pub fn tooltip(
Category::Neutral,
)];
}
def.effects
.iter()
effect_lines(def.effects.iter().copied(), scale, translate)
}
/// `PotionContents.addPotionTooltip`'s line per effect: its name, potency and
/// scaled duration, for (effect id, duration, amplifier) entries.
pub fn effect_lines<'a>(
effects: impl IntoIterator<Item = (&'a str, i32, i32)>,
scale: f32,
translate: &dyn Fn(&str) -> Option<String>,
) -> Vec<(String, Category)> {
effects
.into_iter()
.map(|(e, duration, amp)| {
let e = e.trim_start_matches("minecraft:");
let mut line =
translate(&format!("effect.minecraft.{e}")).unwrap_or_else(|| (*e).to_owned());
if *amp > 0 {
translate(&format!("effect.minecraft.{e}")).unwrap_or_else(|| e.to_owned());
if amp > 0 {
line.push(' ');
line.push_str(
&translate(&format!("potion.potency.{amp}"))
.unwrap_or_else(|| crate::enchant::level_name(*amp as u32 + 1)),
.unwrap_or_else(|| crate::enchant::level_name(amp as u32 + 1)),
);
}
let instant = effect(e).is_some_and(|d| d.instant);
let scaled = (*duration as f32 * scale) as i32;
let scaled = (duration as f32 * scale) as i32;
if !instant && scaled > 20 {
line = format!("{line} ({})", format_duration(scaled));
}
@@ -74,9 +74,28 @@ pub enum BlockEntity {
/// `DaylightDetectorBlockEntity`: no data, only the ticker
/// (`DaylightDetectorBlock.tickEntity`).
DaylightDetector,
/// Suspicious sand and gravel: saved loot and the transient brushed face.
Brushable(Box<Brushable>),
/// A shelf holds three displayed item stacks.
Shelf(Box<Shelf>),
/// `ChiseledBookShelfBlockEntity`: a `Container` of six one-book slots.
ChiseledBookshelf(Box<ChiseledBookshelf>),
}
/// `BrushableBlockEntity`: the loot table is resolved on the first brush.
#[derive(Clone, Debug, Default)]
pub struct Brushable {
pub loot_table: Option<horizon_world::nbt::Tag>,
pub loot_seed: Option<horizon_world::nbt::Tag>,
pub item: Option<ItemStack>,
/// Direction's legacy network id; not saved to disk by vanilla.
pub hit_direction: Option<i32>,
}
/// `ShelfBlockEntity`'s displayed slots and alignment flag.
#[derive(Clone, Debug, Default)]
pub struct Shelf {
pub items: [Option<ItemStack>; 3],
pub align_to_bottom: bool,
}
/// `ChiseledBookShelfBlockEntity`: six slots and `lastInteractedSlot`.
#[derive(Clone, Debug, PartialEq)]
pub struct ChiseledBookshelf {
+63 -5
View File
@@ -2,8 +2,8 @@
//! kinds, ticking as `AbstractFurnaceBlockEntity.serverTick` with fuel times
//! from the data-driven `context_int_provider`/`context_float_provider` files.
pub use crate::chunk::block_entity::{
Banner, Beacon, BlockEntity, Campfire, ChiseledBookshelf, Crafter, CreakingHeart, EndGateway,
Furnace, FurnaceKind, Jukebox, Lectern, Skull, SkullSkin,
Banner, Beacon, BlockEntity, Brushable, Campfire, ChiseledBookshelf, Crafter, CreakingHeart,
EndGateway, Furnace, FurnaceKind, Jukebox, Lectern, Shelf, Skull, SkullSkin,
};
use crate::items::ItemStack;
use std::collections::HashMap;
@@ -42,6 +42,45 @@ pub fn recipe_used_key(id: &str) -> String {
/// A hopper's slots: the only plain container with five.
pub const HOPPER_SLOTS: usize = 5;
/// `getDefaultName` of the `BaseContainerBlockEntity` that `block` holds: the
/// translation key of its menu's title when it has no custom name. Copper
/// chests hold a `ChestBlockEntity`, shulker boxes of every colour a
/// `ShulkerBoxBlockEntity`.
pub fn default_name_key(block: &str) -> Option<&'static str> {
Some(match block {
"minecraft:chest" | "minecraft:trapped_chest" => "container.chest",
n if n.ends_with("copper_chest") => "container.chest",
"minecraft:barrel" => "container.barrel",
n if n.ends_with("shulker_box") => "container.shulkerBox",
"minecraft:dispenser" => "container.dispenser",
"minecraft:dropper" => "container.dropper",
"minecraft:hopper" => "container.hopper",
"minecraft:furnace" => "container.furnace",
"minecraft:smoker" => "container.smoker",
"minecraft:blast_furnace" => "container.blast_furnace",
"minecraft:brewing_stand" => "container.brewing",
"minecraft:crafter" => "container.crafter",
_ => return None,
})
}
/// `BaseContainerBlockEntity.getDisplayName` (`getName`), the title of the
/// `ClientboundOpenScreenPacket` that opens it: the block entity's custom
/// name (`CustomName`, a literal), else its default name.
pub fn display_name(custom: Option<&str>, default_key: &str) -> horizon_world::nbt::Tag {
match custom {
Some(name) => horizon_protocol::text::literal(name),
None => horizon_protocol::text::translatable(default_key, &[]),
}
}
/// `ChestBlock.MENU_PROVIDER_COMBINER`'s `getDisplayName` for a double chest:
/// the first half's custom name (the right half, `DoubleBlockCombiner`'s
/// `FIRST`), else the second's, else `container.chestDouble`.
pub fn double_chest_name(first: Option<&str>, second: Option<&str>) -> horizon_world::nbt::Tag {
display_name(first.or(second), "container.chestDouble")
}
impl Campfire {
/// `placeFood`'s slot: the first empty one, given the item's
/// `campfire_cooking` time; it takes one item of `held`.
@@ -191,6 +230,14 @@ impl BlockEntity {
recipes_used: HashMap::new(),
}));
}
if matches!(
name,
"minecraft:suspicious_sand"
| "minecraft:suspicious_gravel"
| "minecraft:brushable_block"
) {
return Some(Self::Brushable(Box::default()));
}
if name == "minecraft:creaking_heart" {
return Some(Self::CreakingHeart(Box::default()));
}
@@ -253,6 +300,9 @@ impl BlockEntity {
if name == "minecraft:campfire" || name == "minecraft:soul_campfire" {
return Some(Self::Campfire(Box::default()));
}
if name == "minecraft:shelf" || name.ends_with("_shelf") {
return Some(Self::Shelf(Box::default()));
}
if name == "minecraft:chiseled_bookshelf" {
return Some(Self::ChiseledBookshelf(Box::default()));
}
@@ -295,8 +345,10 @@ impl BlockEntity {
Self::Skull(_) => "minecraft:skull",
Self::EndGateway(_) => "minecraft:end_gateway",
Self::DaylightDetector => "minecraft:daylight_detector",
Self::Brushable(_) => "minecraft:brushable_block",
Self::Campfire(_) => "minecraft:campfire",
Self::ChiseledBookshelf(_) => "minecraft:chiseled_bookshelf",
Self::Shelf(_) => "minecraft:shelf",
}
}
pub fn contents(&self) -> Vec<ItemStack> {
@@ -315,13 +367,15 @@ impl BlockEntity {
| Self::CommandBlock(_)
| Self::EndGateway(_)
| Self::Skull(_)
| Self::DaylightDetector => Vec::new(),
| Self::DaylightDetector
| Self::Brushable(_) => Vec::new(),
Self::Lectern(l) => l.book.iter().cloned().collect(),
Self::Sculk(_) => Vec::new(),
Self::Banner(_) => Vec::new(),
Self::Crafter(c) => c.items.iter().flatten().cloned().collect(),
Self::Campfire(c) => c.items.iter().flatten().cloned().collect(),
Self::ChiseledBookshelf(b) => b.items.iter().flatten().cloned().collect(),
Self::Shelf(b) => b.items.iter().flatten().cloned().collect(),
}
}
pub fn slots(&self) -> &[Option<ItemStack>] {
@@ -337,9 +391,11 @@ impl BlockEntity {
| Self::Banner(_)
| Self::EndGateway(_)
| Self::Skull(_)
| Self::DaylightDetector => &[],
| Self::DaylightDetector
| Self::Brushable(_) => &[],
Self::Crafter(c) => &c.items,
Self::ChiseledBookshelf(b) => &b.items,
Self::Shelf(b) => &b.items,
Self::CreakingHeart(_)
| Self::Spawner(_)
| Self::TrialSpawner(_)
@@ -363,9 +419,11 @@ impl BlockEntity {
| Self::Banner(_)
| Self::EndGateway(_)
| Self::Skull(_)
| Self::DaylightDetector => &mut [],
| Self::DaylightDetector
| Self::Brushable(_) => &mut [],
Self::Crafter(c) => &mut c.items,
Self::ChiseledBookshelf(b) => &mut b.items,
Self::Shelf(b) => &mut b.items,
Self::CreakingHeart(_)
| Self::Spawner(_)
| Self::TrialSpawner(_)
+19
View File
@@ -308,6 +308,25 @@ fn deliver(home: &Path, report: Report) -> Option<PathBuf> {
}
}
/// The report for a hung server tick, written by the watchdog thread
/// (`ServerWatchdog`): the stuck thread's stack is not available to Rust, so
/// the report carries none.
pub fn report_watchdog(home: &Path, side: &'static str, message: String) -> Option<PathBuf> {
deliver(
home,
Report {
time: Local::now(),
description: "Watching Server".into(),
error: format!("{message} (Watching Server)"),
stack: String::new(),
thread: thread_name(),
side,
details: system_details(),
log: crate::logging::recent_lines(),
},
)
}
fn panic_message(info: &std::panic::PanicHookInfo) -> String {
let payload = info.payload();
let text = payload
@@ -125,6 +125,114 @@ pub fn discover(world: &Path) -> Vec<DataPack> {
packs
}
// ---- Create World ------------------------------------------------------------
/// Create World's Data Packs screen (`PackSelectionScreen` on vanilla's
/// `mcworld-` folder): the folder's packs (`PackSource.WORLD`) and the
/// built-in one (`resources` holds its `pack.png`), which can be unselected
/// and stays at the bottom. Horizon has no experiments, so their feature
/// packs are not offered.
pub fn screen_packs(dir: &Path, resources: &Path) -> Vec<crate::pack_screen::PackInfo> {
use crate::{
pack_screen::{Compat, PackInfo, Source, load_icon},
packs::Position,
text::{Style, Text},
};
let format = horizon_core::version::DATA_PACK_FORMAT;
let mut infos: Vec<PackInfo> = crate::packs::folder(dir, format)
.into_iter()
.map(|p| PackInfo {
icon: p.icon_png().as_deref().and_then(load_icon),
id: p.id,
title: p.title,
description: p.raw_description,
description_style: p.description_style,
source: Source::World,
compat: p.compatibility,
required: false,
position: Position::Top,
fixed: false,
})
.collect();
infos.push(PackInfo {
id: "vanilla".into(),
title: Text::translate("dataPack.vanilla.name", &[]),
description: Text::translate("dataPack.vanilla.description", &[]),
description_style: Style::default(),
source: Source::BuiltIn,
compat: Compat::Compatible,
icon: std::fs::read(resources.join("pack.png"))
.ok()
.as_deref()
.and_then(load_icon),
required: false,
position: Position::Bottom,
fixed: false,
});
infos
}
/// A new empty folder for Create World's packs
/// (`Files.createTempDirectory("mcworld-")`).
pub fn temp_folder() -> std::io::Result<PathBuf> {
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos();
let dir = std::env::temp_dir().join(format!("mcworld-{}-{nanos}", std::process::id()));
std::fs::create_dir_all(&dir)?;
Ok(dir)
}
/// What's in a folder of packs, to notice packs added or removed while the
/// screen shows it (`PackSelectionScreen.Watcher`).
pub fn folder_stamp(dir: &Path) -> Vec<(String, Option<std::time::SystemTime>)> {
let mut stamp: Vec<_> = std::fs::read_dir(dir)
.into_iter()
.flatten()
.flatten()
.map(|e| {
let modified = e.metadata().and_then(|m| m.modified()).ok();
(e.file_name().to_string_lossy().into_owned(), modified)
})
.collect();
stamp.sort();
stamp
}
/// Create World's packs into the new world before it opens
/// (`copyTempDataPackDirToNewWorld`): every pack of `dir` goes to
/// `<world>/datapacks`, and `datapacks.json` enables those `selected`
/// (lowest priority first) and disables the rest.
pub fn install(dir: &Path, world: &Path, selected: &[String]) -> Result<(), String> {
let err = |p: &Path, e: std::io::Error| format!("{}: {e}", p.display());
let target = world.join("datapacks");
std::fs::create_dir_all(&target).map_err(|e| err(&target, e))?;
let mut disabled = Vec::new();
for pack in crate::packs::folder(dir, horizon_core::version::DATA_PACK_FORMAT) {
let (crate::packs::Source::Dir(from) | crate::packs::Source::Zip(from)) = &pack.source;
copy(from, &target.join(pack.id.trim_start_matches("file/")))?;
if !selected.contains(&pack.id) {
disabled.push(pack.id);
}
}
if !selected.iter().any(|id| id == "vanilla") {
disabled.push("vanilla".into());
}
let json = serde_json::json!({ "enabled": selected, "disabled": disabled });
let file = world.join("datapacks.json");
std::fs::write(&file, json.to_string()).map_err(|e| err(&file, e))
}
/// A file, or a folder and everything in it.
fn copy(from: &Path, to: &Path) -> Result<(), String> {
let err = |e: std::io::Error| format!("{} -> {}: {e}", from.display(), to.display());
if !from.is_dir() {
return std::fs::copy(from, to).map(drop).map_err(err);
}
std::fs::create_dir_all(to).map_err(err)?;
for e in std::fs::read_dir(from).map_err(err)?.flatten() {
copy(&e.path(), &to.join(e.file_name()))?;
}
Ok(())
}
// ---- functions ---------------------------------------------------------------
/// A `.mcfunction`: plain command lines and macro lines.
@@ -566,4 +674,51 @@ mod tests {
assert_eq!(p.list_kinds(&kinds), wanted);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn create_world_installs_its_packs() {
let root = std::env::temp_dir().join(format!("hz-datapack-install-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
let (dir, world) = (root.join("mcworld"), root.join("world"));
let write = |pack: &str, p: &str, t: &str| {
let path = dir.join(pack).join(p);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, t).unwrap();
};
write("fns", "pack.mcmeta", r#"{"pack":{"pack_format":121}}"#);
write("fns", "data/x/function/f.mcfunction", "say hi");
write("off", "pack.mcmeta", r#"{"pack":{"pack_format":121}}"#);
std::fs::create_dir_all(&dir).unwrap();
let mut zip = zip::ZipWriter::new(std::fs::File::create(dir.join("old.zip")).unwrap());
zip.start_file("pack.mcmeta", zip::write::SimpleFileOptions::default())
.unwrap();
std::io::Write::write_all(&mut zip, br#"{"pack":{"pack_format":48}}"#).unwrap();
zip.finish().unwrap();
let infos = screen_packs(&dir, &root);
let ids: Vec<&str> = infos.iter().map(|p| p.id.as_str()).collect();
assert_eq!(ids, ["file/fns", "file/off", "file/old.zip", "vanilla"]);
let compat: Vec<_> = infos.iter().map(|p| p.compat).collect();
use crate::pack_screen::Compat;
assert_eq!(
compat,
[
Compat::Compatible,
Compat::Compatible,
Compat::TooOld,
Compat::Compatible
]
);
let selected = ["vanilla", "file/old.zip", "file/fns"].map(String::from);
install(&dir, &world, &selected).unwrap();
let packs = discover(&world);
let ids: Vec<&str> = packs.iter().map(|p| p.id.as_str()).collect();
assert_eq!(ids, ["file/fns", "file/off", "file/old.zip"]);
let saved: serde_json::Value =
serde_json::from_slice(&std::fs::read(world.join("datapacks.json")).unwrap()).unwrap();
assert_eq!(
saved,
serde_json::json!({"enabled": selected, "disabled": ["file/off"]})
);
let _ = std::fs::remove_dir_all(&root);
}
}
+13 -1
View File
@@ -56,6 +56,9 @@ struct Out {
@location(11) glint_uv: vec2<f32>,
// 1 drops the quad's back-facing pixels (Vertex::normal w = 1), like a culling render type.
@location(12) @interpolate(flat) cull: u32,
// HDR tier of a light-giving particle (mesher::hdr_tier), from the overlay's
// high bits (particles.rs): fs_hdr_mark marks the frame with it.
@location(13) @interpolate(flat) tier: u32,
}
// Shipped light.glsl; normals are already transformed and RGBA8_SNORM packed.
fn entity_light(normal: vec3<f32>, nether: bool) -> f32 {
@@ -125,8 +128,9 @@ 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, color, light, normal);
var out = entity_vertex(position, uv, overlay & 0xffffu, color, light, normal);
out.glint_uv = glint_uv;
out.tier = overlay >> 16u;
return out;
}
// Retained bolt-local geometry, translated using the same f64 camera
@@ -278,6 +282,14 @@ fn crumbling_uv(uv: vec2<f32>, packed_origin: vec2<f32>, sprite_size: vec2<f32>,
return vec4(in.color.rgb, 1.0);
}
// HDR output: a light-giving particle's pixels marked in the frame's alpha the
// way terrain.wgsl marks a light source's, drawn again after the entities with
// only alpha written, so its colour stays as blended.
@fragment fn fs_hdr_mark(in: Out) -> @location(0) vec4<f32> {
if in.tier == 0u || textureSample(atlas, atlas_sampler, in.uv).a < 0.1 { discard; }
return vec4(0.0, 0.0, 0.0, 0.7 * f32(4u - min(in.tier, 4u)) / 4.0);
}
fn celestial_color(in: Out) -> vec4<f32> {
return textureSample(atlas, atlas_sampler, in.uv) * in.color * in.light_color;
}
+25 -5
View File
@@ -203,6 +203,13 @@ pub struct FogInputs<'a> {
/// `BossHealthOverlay.shouldCreateWorldFog`: a boss bar with
/// `createWorldFog` is shown (the Ender Dragon's).
pub boss_world_fog: bool,
/// The camera entity is a spectator (`LavaFogEnvironment`).
pub spectator: bool,
/// The camera entity has Fire Resistance (`LavaFogEnvironment`).
pub fire_resistance: bool,
/// `GameRenderer.nightVisionScale` while Night Vision is on without
/// Darkness, else 0 (`FogRenderer.computeFogColor`'s brightenFactor).
pub night_vision: f32,
}
/// `FogRenderer.setupFog` + `computeFogColor`.
@@ -272,7 +279,11 @@ pub fn setup_fog(tables: &FogTables, state: &FogState, input: &FogInputs) -> Fog
color[1] += (color[1] * 0.6 - color[1]) * t;
color[2] += (color[2] * 0.6 - color[2]) * t;
}
let brighten = if eye_in_water { water_vision } else { 0.0 };
let brighten = if eye_in_water {
water_vision
} else {
input.night_vision
};
if color.iter().all(|&c| c != 0.0) {
let scale = 1.0 / color[0].max(color[1]).max(color[2]);
color = color.map(|c| c + (c * scale - c) * brighten);
@@ -288,10 +299,16 @@ pub fn setup_fog(tables: &FogTables, state: &FogState, input: &FogInputs) -> Fog
};
match input.fog_type {
FogType::Lava => {
fog.environmental_start = 0.25;
fog.environmental_end = 1.0;
fog.sky_end = 1.0;
fog.cloud_end = 1.0;
// LavaFogEnvironment.setupFog.
(fog.environmental_start, fog.environmental_end) = if input.spectator {
(-8.0, rd * 0.5)
} else if input.fire_resistance {
(0.0, 5.0)
} else {
(0.25, 1.0)
};
fog.sky_end = fog.environmental_end;
fog.cloud_end = fog.environmental_end;
}
FogType::PowderSnow => {
fog.environmental_start = 0.0;
@@ -405,6 +422,9 @@ mod tests {
darkness: None,
darken_world: 0.0,
boss_world_fog: false,
spectator: false,
fire_resistance: false,
night_vision: 0.0,
}
}
#[test]
+142 -19
View File
@@ -349,6 +349,14 @@ struct BorderUniform {
color: [f32; 4],
offset: [f32; 4],
}
/// A frame to save as a PNG: F2's screenshot, an agent's `game_screenshot`,
/// or the icon the Select World list shows.
pub struct ScreenshotRequest {
pub path: PathBuf,
/// `GameRenderer.takeAutoScreenshot`: centre-crop the frame to a square
/// and average it down to this size. `None` saves the frame as drawn.
pub size: Option<[u32; 2]>,
}
pub struct Renderer {
instance: wgpu::Instance,
adapter: wgpu::Adapter,
@@ -407,6 +415,7 @@ pub struct Renderer {
crosshair_group: wgpu::BindGroup,
crosshair_globals: wgpu::Buffer,
entity_pipeline: wgpu::RenderPipeline,
hdr_mark_pipeline: wgpu::RenderPipeline,
weather: weather::Weather,
crumbling_pipeline: wgpu::RenderPipeline,
shadow_blob_pipeline: wgpu::RenderPipeline,
@@ -570,8 +579,8 @@ pub struct Renderer {
pub section_draw_calls: usize,
pub uploaded_bytes: u64,
started: Instant,
/// Write the next presented frame to this PNG path (control socket).
pub screenshot: Option<PathBuf>,
/// The next frame to write to a PNG path (control socket, F2, world icon).
pub screenshot: Option<ScreenshotRequest>,
/// Outcome of the last screenshot: frame size, or the error.
pub screenshot_result: Option<std::result::Result<[u32; 2], String>>,
/// Brightness option (lightmap gamma), 0..1.
@@ -1128,7 +1137,14 @@ impl Renderer {
));
}
surface.configure(&device, &config);
(config, caps.present_modes.clone(), hdr::output(&caps))
let hdr_output = hdr::output(&caps);
if previous.is_none() {
log::info!(
"Horizon renderer: HDR output {}",
hdr_output.map_or("not offered by this display", hdr::Output::name)
);
}
(config, caps.present_modes.clone(), hdr_output)
} else {
let format = wgpu::TextureFormat::Bgra8Unorm;
let config = wgpu::SurfaceConfiguration {
@@ -2046,6 +2062,18 @@ impl Renderer {
"fs_energy_swirl",
wgpu::ColorWrites::ALL,
);
// HDR output's mark on light-giving particles (entity.wgsl fs_hdr_mark).
let hdr_mark_pipeline = entity_pipeline_with(
"HDR particle mark",
None,
false,
wgpu::CompareFunction::GreaterEqual,
Default::default(),
wgpu::BlendState::REPLACE,
false,
"fs_hdr_mark",
wgpu::ColorWrites::ALPHA,
);
// Attribute 7: 6 is the entity vertex buffer's glint UV.
let lightning_attributes = wgpu::vertex_attr_array![7=>Float32x3];
let lightning_vertex = [
@@ -2557,6 +2585,7 @@ impl Renderer {
crosshair_group,
crosshair_globals,
entity_pipeline,
hdr_mark_pipeline,
weather,
crumbling_pipeline,
shadow_blob_pipeline,
@@ -3206,6 +3235,19 @@ impl Renderer {
}
config
}
/// The F3 screen's HDR line while the setting is on: the swapchain HDR presents to with
/// its paper white and peak, or that the surface offers none.
pub fn hdr_line(&self) -> String {
match &self.hdr {
Some(hdr) => format!(
"HDR: {}, {}/{} nits",
hdr.output.name(),
hdr.paper_white.round(),
hdr.peak.round()
),
None => "HDR: not offered by this display".into(),
}
}
/// HDR output (Horizon setting): HDR10 or scRGB at `paper_white` nits, the world's
/// highlights expanded up to `peak` nits. Off where the surface can't present it.
pub fn set_hdr(&mut self, on: bool, paper_white: f32, peak: f32, marker: &std::path::Path) {
@@ -3222,6 +3264,14 @@ impl Renderer {
let format = self.config.format.remove_srgb_suffix();
self.hdr = output.map(|output| hdr::Hdr::new(&self.device, format, output));
self.configure_surface();
match output {
Some(output) => log::info!(
"Horizon renderer: HDR on: {} ({:?}), {paper_white}/{peak} nits",
output.name(),
output.format()
),
None => log::info!("Horizon renderer: HDR off"),
}
}
if let Some(hdr) = &mut self.hdr
&& (hdr.paper_white, hdr.peak) != (paper_white, peak)
@@ -4755,6 +4805,10 @@ impl Renderer {
pass.set_bind_group(0, &self.entity_group, &[]);
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
pass.draw_indexed(0..entity_quads * 6, 0, 0..1);
if self.hdr.is_some() {
pass.set_pipeline(&self.hdr_mark_pipeline);
pass.draw_indexed(0..entity_quads * 6, 0, 0..1);
}
pass.set_bind_group(0, &self.group, &[]);
}
if translucent_item_quads > 0 {
@@ -5086,6 +5140,13 @@ impl Renderer {
&self.entity_group,
&self.indices,
);
// The world icon grabs here: after the level and the hand, before
// the effects and the HUD draw over them (`tryTakeScreenshotIfNeeded`
// runs right after `renderLevel`).
let readback = self
.screenshot
.take_if(|request| request.size.is_some())
.and_then(|request| self.take_readback(request, &mut encoder, &target_texture));
if !frame.screen.is_empty() {
let attachments = [Some(wgpu::RenderPassColorAttachment {
view: &view_target,
@@ -5189,18 +5250,10 @@ impl Renderer {
(&view_target, self.config.format.remove_srgb_suffix()),
);
}
let readback = self.screenshot.take().and_then(|path| {
// The copy and PNG writer both assume 8-bit pixels; a float surface would make
// the row stride too small for the copy and abort in validation.
if self.config.format.block_copy_size(None) == Some(4) {
Some((path, self.copy_frame(&mut encoder, &target_texture)))
} else {
self.screenshot_result = Some(Err(format!(
"screenshots need a 32-bit surface format, not {:?}",
self.config.format
)));
None
}
let readback = readback.or_else(|| {
self.screenshot
.take()
.and_then(|request| self.take_readback(request, &mut encoder, &target_texture))
});
self.uploads.finish(&encoder);
if has_section_copies {
@@ -5227,9 +5280,9 @@ impl Renderer {
if let Some(Some(timer)) = Self::pass_timer(&mut self.pack_post, &mut self.native) {
timer.submitted(&self.queue);
}
if let Some((path, buffer)) = readback {
if let Some((request, buffer)) = readback {
self.screenshot_result = Some(
self.write_screenshot(&path, &buffer)
self.write_screenshot(&request, &buffer)
.map_err(|e| e.to_string()),
);
}
@@ -5283,6 +5336,26 @@ impl Renderer {
fn padded_row(&self) -> u32 {
(self.config.width * 4).next_multiple_of(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT)
}
/// Take a screenshot request: queue the frame's copy, or report why the
/// frame cannot be read back.
fn take_readback(
&mut self,
request: ScreenshotRequest,
encoder: &mut wgpu::CommandEncoder,
texture: &wgpu::Texture,
) -> Option<(ScreenshotRequest, wgpu::Buffer)> {
// The copy and PNG writer both assume 8-bit pixels; a float surface would make
// the row stride too small for the copy and abort in validation.
if self.config.format.block_copy_size(None) == Some(4) {
Some((request, self.copy_frame(encoder, texture)))
} else {
self.screenshot_result = Some(Err(format!(
"screenshots need a 32-bit surface format, not {:?}",
self.config.format
)));
None
}
}
fn copy_frame(
&self,
encoder: &mut wgpu::CommandEncoder,
@@ -5312,7 +5385,11 @@ impl Renderer {
);
buffer
}
fn write_screenshot(&self, path: &std::path::Path, buffer: &wgpu::Buffer) -> Result<[u32; 2]> {
fn write_screenshot(
&self,
request: &ScreenshotRequest,
buffer: &wgpu::Buffer,
) -> Result<[u32; 2]> {
if !self.config.usage.contains(wgpu::TextureUsages::COPY_SRC) {
return Err("this surface does not support frame readback".into());
}
@@ -5350,8 +5427,18 @@ impl Renderer {
}
}
buffer.unmap();
// NativeImage.resizeSubRectTo on takeAutoScreenshot's grab: the icon
// is the frame's centre square averaged down to its size.
let (width, height, pixels) = match request.size {
Some(size) => (
size[0],
size[1],
square_downscale(&pixels, self.config.width, self.config.height, size),
),
None => (self.config.width, self.config.height, pixels),
};
let mut encoder = png::Encoder::new(
std::io::BufWriter::new(std::fs::File::create(path)?),
std::io::BufWriter::new(std::fs::File::create(&request.path)?),
width,
height,
);
@@ -5361,6 +5448,42 @@ impl Renderer {
Ok([width, height])
}
}
/// Centre-crop the frame to a square and average it down to `size`.
pub(crate) fn square_downscale(pixels: &[u8], width: u32, height: u32, size: [u32; 2]) -> Vec<u8> {
let (side, cx, cy) = if width > height {
(height, (width - height) / 2, 0)
} else {
(width, 0, (height - width) / 2)
};
let [tw, th] = size;
let mut out = vec![0u8; (tw * th * 4) as usize];
for ty in 0..th {
let y0 = cy + ty * side / th;
let y1 = cy + (ty + 1) * side / th;
for tx in 0..tw {
let x0 = cx + tx * side / tw;
let x1 = cx + (tx + 1) * side / tw;
let (mut r, mut g, mut b) = (0u32, 0u32, 0u32);
for y in y0..y1 {
for x in x0..x1 {
let p = ((y * width + x) * 4) as usize;
r += u32::from(pixels[p]);
g += u32::from(pixels[p + 1]);
b += u32::from(pixels[p + 2]);
}
}
let count = (x1 - x0) * (y1 - y0);
let o = ((ty * tw + tx) * 4) as usize;
out[o..o + 4].copy_from_slice(&[
(r / count) as u8,
(g / count) as u8,
(b / count) as u8,
255,
]);
}
}
out
}
/// Camera-relative clip planes (`Frustum`).
#[derive(Clone, Copy, Debug)]
pub struct Frustum {
+68 -7
View File
@@ -14,6 +14,12 @@ pub enum Output {
}
impl Output {
pub fn name(self) -> &'static str {
match self {
Self::Pq => "HDR10 (PQ)",
Self::ScRgb => "scRGB",
}
}
pub fn format(self) -> wgpu::TextureFormat {
match self {
Self::Pq => wgpu::TextureFormat::Rgb10a2Unorm,
@@ -54,6 +60,38 @@ struct Targets {
group: wgpu::BindGroup,
}
/// How one tier of light sources (`mesher::hdr_tier`) shines: `share` of the headroom for
/// its brightest pixels, lifted from brightness `start` (none) to `full` (all of it).
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Tier {
pub share: f32,
pub start: f32,
pub full: f32,
}
/// Low, incandescent, bright and very bright.
pub const TIERS: [Tier; 4] = [
Tier {
share: 0.2,
start: 0.08,
full: 0.09,
},
Tier {
share: 0.5,
start: 0.14,
full: 0.38,
},
Tier {
share: 0.6,
start: 0.14,
full: 0.38,
},
Tier {
share: 1.0,
start: 0.14,
full: 0.38,
},
];
pub struct Hdr {
layout: wgpu::BindGroupLayout,
pipeline: wgpu::RenderPipeline,
@@ -62,11 +100,12 @@ pub struct Hdr {
/// The frame's format: what every other pipeline draws in.
format: wgpu::TextureFormat,
targets: Option<Targets>,
/// `HORIZON_HDR_DEBUG=1`: paint expanded pixels magenta.
/// `HORIZON_HDR_DEBUG=1`: grey what isn't expanded, tint what is magenta.
debug: bool,
/// Paper white and peak brightness, in nits.
pub paper_white: f32,
pub peak: f32,
pub tiers: [Tier; 4],
}
impl Hdr {
@@ -134,7 +173,7 @@ impl Hdr {
});
let params = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Horizon HDR parameters"),
size: 32,
size: 80,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
@@ -148,6 +187,7 @@ impl Hdr {
debug: std::env::var_os("HORIZON_HDR_DEBUG").is_some_and(|v| v == "1"),
paper_white: 200.0,
peak: 1000.0,
tiers: TIERS,
}
}
@@ -228,16 +268,19 @@ impl Hdr {
return;
};
let paper = self.paper_white.max(1.0);
let params = [
let mut params = [0.0; 20];
params[..5].copy_from_slice(&[
paper / 80.0,
(self.peak / paper).max(1.0),
f32::from(u8::from(world)),
f32::from(u8::from(self.debug)),
f32::from(u8::from(self.output == Output::Pq)),
0.0,
0.0,
0.0,
];
]);
for (i, t) in self.tiers.iter().enumerate() {
params[8 + i] = t.share;
params[12 + i] = t.start;
params[16 + i] = t.full;
}
queue.write_buffer(&self.params, 0, bytemuck::cast_slice(&params));
let view = target.create_view(&Default::default());
let attachments = [Some(wgpu::RenderPassColorAttachment {
@@ -265,6 +308,24 @@ mod tests {
use super::{Output, pick};
use wgpu::{SurfaceColorSpaces as S, TextureFormat as F};
#[test]
fn hdr_shaders_validate() {
let entity = crate::overlay::wgsl_constants() + include_str!("../entity.wgsl");
for source in [
include_str!("../hdr.wgsl"),
include_str!("../terrain.wgsl"),
&entity,
] {
let module = naga::front::wgsl::parse_str(source).unwrap();
naga::valid::Validator::new(
naga::valid::ValidationFlags::all(),
naga::valid::Capabilities::all(),
)
.validate(&module)
.unwrap();
}
}
fn caps(offered: &[(F, S)]) -> wgpu::SurfaceCapabilities {
wgpu::SurfaceCapabilities {
format_capabilities: offered
@@ -570,6 +570,7 @@ impl Item {
pub enum Action {
ButtonClick,
Changed,
SliderDrag,
OpenAdvanced,
OpenSimple,
Done,
@@ -800,7 +801,7 @@ impl GraphicsUi {
.find(|wd| matches!(wd.target, Target::Item(i) if i == item))
{
item.set_slider(s, ui::slider_value(wd.x, wd.w, x));
return vec![Action::Changed];
return vec![Action::SliderDrag];
}
return Vec::new();
}
@@ -822,11 +823,11 @@ impl GraphicsUi {
if item.slider(s).is_some() {
self.dragging = Some(item);
item.set_slider(s, ui::slider_value(wd.x, wd.w, x));
vec![Action::SliderDrag]
} else {
item.cycle(s, right, framebuffer);
return vec![Action::ButtonClick, Action::Changed];
vec![Action::ButtonClick, Action::Changed]
}
vec![Action::Changed]
}
};
}
@@ -897,7 +898,7 @@ mod tests {
false,
false
),
vec![Action::Changed]
vec![Action::SliderDrag]
);
assert_eq!(
ui.click(
@@ -909,7 +910,7 @@ mod tests {
true,
false
),
vec![Action::Changed]
vec![Action::SliderDrag]
);
assert!(ui.release());
assert!(!ui.release());
+29 -15
View File
@@ -12,23 +12,27 @@ struct Params {
peak: f32,
// 1 when this frame drew a world.
world: f32,
// 1 paints expanded pixels magenta (HORIZON_HDR_DEBUG=1).
// 1 greys what isn't expanded and tints what is (HORIZON_HDR_DEBUG=1).
debug: f32,
// 1 for an HDR10 swapchain, 0 for scRGB.
pq: f32,
// Per tier, low to very bright (hdr.rs `Tier`): the share of the headroom
// the brightest pixels get, and the brightness where the lift starts and
// where it is full.
share: vec4<f32>,
start: vec4<f32>,
full: vec4<f32>,
};
@group(0) @binding(2) var<uniform> params: Params;
// Light sources are lifted evenly rather than by a highlight curve: vanilla's
// textures were drawn for SDR, so a curve on their brightest pixels blows out
// near-white spots (glowstone) and leaves the rest (lava, a jack o'lantern's
// face) nearly as it was. Pixels brighter than LIFT_FULL get STRENGTH of the
// headroom, dimmer ones less down to none at LIFT_START (lava's crust, a
// lantern's metal), and no channel goes past the peak. Brightness is the mean
// of luminance and the brightest channel, so saturated light counts as bright.
const LIFT_START: f32 = 0.18;
const LIFT_FULL: f32 = 0.42;
const STRENGTH: f32 = 0.7;
// face) nearly as it was. Each tier's pixels brighter than its `full` get its
// `share` of the headroom, dimmer ones less down to none at its `start`
// (lava's crust, a lantern's metal), and no channel goes past the peak.
// Brightness is the mean of luminance and the brightest channel, so saturated
// light counts as bright.
// Whether `c` still shows the world pixel `w`: the same, or darkened evenly
// across its channels, as the vignette and other screen-wide tints darken it.
@@ -67,21 +71,31 @@ fn pq(nits: vec3<f32>) -> vec3<f32> {
// Gamma 2.2, as SDR displays and the compositors' SDR paths decode it.
var light = pow(max(c, vec3(0.0)), vec3(2.2));
let w = textureLoad(world, at, 0);
// The world's alpha is 0 on light-emitting blocks, the sun and the moon
// (terrain.wgsl, the celestial pipeline): only those shine.
// The world's alpha is below 0.75 on light sources, the sun and the moon
// (terrain.wgsl, the celestial pipeline): only those shine, by the tier it
// holds, the lower the brighter.
var lifted = false;
if params.world > 0.5 && params.peak > 1.0 && w.a < 0.75 && shows_world(c, w.rgb) {
// Brightness of the world pixel, before the vignette darkens the frame
// toward its edges: a light source shines the same anywhere on screen.
let wl = pow(max(w.rgb, vec3(0.0)), vec3(2.2));
let y = 0.5 * (dot(wl, vec3(0.2126, 0.7152, 0.0722)) + max(wl.r, max(wl.g, wl.b)));
let lift = smoothstep(LIFT_START, LIFT_FULL, y);
let tier = u32(clamp(round(4.0 - 4.0 * w.a / 0.7), 1.0, 4.0)) - 1u;
let lift = smoothstep(params.start[tier], params.full[tier], y);
if lift > 0.0 {
let top = max(light.r, max(light.g, light.b));
let gain = min(1.0 + (params.peak - 1.0) * STRENGTH * lift, params.peak / max(top, 1e-4));
let gain = min(1.0 + (params.peak - 1.0) * params.share[tier] * lift, params.peak / max(top, 1e-4));
light *= max(gain, 1.0);
if params.debug > 0.5 {
light = mix(light, vec3(gain, 0.0, gain), 0.6);
}
lifted = true;
}
}
// HORIZON_HDR_DEBUG=1: what isn't lifted turns grey, what is keeps its
// colour with a magenta tint, so a lift shows on any colour.
if params.debug > 0.5 && params.world > 0.5 {
if lifted {
light *= vec3(1.0, 0.6, 1.0);
} else {
light = vec3(dot(light, vec3(0.2126, 0.7152, 0.0722)));
}
}
let scrgb = light * params.paper;
+53
View File
@@ -346,6 +346,59 @@ impl HudAtlas {
atlas.pixels = Vec::new();
Ok(atlas)
}
/// The boot screen's atlas, before any pack loads: the theme's run-time
/// glyphs (`horizon/ui-text`), a white sprite and the glyph rows over
/// transparent black. Nothing from a pack: the boot screen draws with
/// the theme's bundled faces, so the font here has no sheets to read.
pub fn boot() -> Self {
let (width, height) = (1024, 1024);
let text_rows = 512;
let glyph_top = text_rows + 4;
let mut pixels = vec![0u8; (width * height * 4) as usize];
// The white sprite the fills and the font's underline pass sample.
for row in 0..4 {
let at = ((text_rows + row) * width * 4) as usize;
pixels[at..at + 16].fill(255);
}
let rect = |u0: u32, v0: u32, u1: u32, v1: u32, w: u32, h: u32| Rect {
u0: u0 as f32 / width as f32,
v0: v0 as f32 / height as f32,
u1: u1 as f32 / width as f32,
v1: v1 as f32 / height as f32,
w,
h,
};
let mut sprites = HashMap::new();
sprites.insert(
"horizon/white".into(),
rect(0, text_rows, 4, glyph_top, 4, 4),
);
sprites.insert(
"horizon/ui-text".into(),
rect(0, 0, width, text_rows, width, text_rows),
);
let white = sprites["horizon/white"];
Self {
font: crate::font::Font::load(
Path::new("horizon-boot"),
&sprites,
&pixels,
(width, height),
(glyph_top, height - glyph_top),
white,
),
pixels,
width,
height,
sprites,
translucent: HashSet::new(),
flags: HashMap::new(),
sprite_animations: Vec::new(),
glint: (vec![0; 4], 1, 1),
glint_armor: (vec![0; 4], 1, 1),
white,
}
}
/// Shelf placements of `images` (tallest first) in the narrowest atlas
/// width from 4096 up that keeps the atlas, glyph rows included, within
/// `max` texels a side; and the first row free for glyphs. High-resolution
+1
View File
@@ -21,6 +21,7 @@ pub mod collisions;
pub mod command_block;
pub mod containers;
pub mod crash;
pub mod datapack;
pub mod dynamic_light;
pub mod effects;
pub mod environment;
+174 -23
View File
@@ -266,6 +266,33 @@ const OFFSETS: [(i32, i32, i32); 6] = [
(1, 0, 0),
];
/// Per-worker light scratch, reused between jobs: the 48x48xheight region and
/// its flood-fill queues. Resized only upward; cleared before each job.
pub struct LightScratch {
states: Vec<u16>,
sky: Vec<u8>,
block: Vec<u8>,
present: Vec<bool>,
lowest: Vec<i32>,
queue: VecDeque<u32>,
sky_queue: VecDeque<u32>,
ids: Box<[u32; 4096]>,
}
impl Default for LightScratch {
fn default() -> Self {
Self {
states: Vec::new(),
sky: Vec::new(),
block: Vec::new(),
present: Vec::new(),
lowest: Vec::new(),
queue: VecDeque::new(),
sky_queue: VecDeque::new(),
ids: Box::new([0; 4096]),
}
}
}
/// `region[dz][dx]` holds the chunks around the centre (index 1,1).
/// `sky` is false in dimensions without skylight (the Nether).
pub fn light_chunk(
@@ -273,6 +300,26 @@ pub fn light_chunk(
tables: &LightTables,
sky_light: bool,
) -> Vec<Light> {
light_chunk_masked(region, tables, sky_light).0
}
/// `light_chunk`, plus the sections whose light differs from the centre
/// chunk's snapshot (bit `sy` set): the game thread then replays only those.
pub fn light_chunk_masked(
region: &[[Option<&Chunk>; 3]; 3],
tables: &LightTables,
sky_light: bool,
) -> (Vec<Light>, u32) {
light_chunk_masked_with(region, tables, sky_light, &mut LightScratch::default())
}
/// `light_chunk_masked` with a caller-owned scratch buffer (`LightScratch`).
pub fn light_chunk_masked_with(
region: &[[Option<&Chunk>; 3]; 3],
tables: &LightTables,
sky_light: bool,
scratch: &mut LightScratch,
) -> (Vec<Light>, u32) {
region[1][1].expect("centre chunk");
// Highest occupied section in the region bounds the work.
let mut top_section = 0;
@@ -289,13 +336,30 @@ pub fn light_chunk(
let h = ((top_section + 1) * 16).min(HEIGHT as usize);
let at = |x: usize, y: usize, z: usize| (y * W + z) * W + x;
// Packed cells (`type << 4 | dampening`), not state ids: the flood fill
// then never touches the large `LightTables::cells` table.
let mut states = vec![0u16; W * W * h];
let mut sky = vec![0u8; W * W * h];
let mut block = vec![0u8; W * W * h];
let mut queue = VecDeque::new();
let mut sky_queue = VecDeque::new();
let mut ids = Box::new([0u32; 4096]);
// then never touches the large `LightTables::cells` table. Every used cell
// is cleared: the flood fill reads stale values otherwise.
let cells = W * W * h;
scratch.states.resize(cells, 0);
scratch.states.fill(0);
scratch.sky.resize(cells, 0);
scratch.sky.fill(0);
scratch.block.resize(cells, 0);
scratch.block.fill(0);
scratch.queue.clear();
scratch.sky_queue.clear();
let LightScratch {
states,
sky,
block,
present,
lowest,
queue,
sky_queue,
ids,
} = scratch;
let states = &mut states[..cells];
let sky = &mut sky[..cells];
let block = &mut block[..cells];
for (dz, row) in region.iter().enumerate() {
for (dx, chunk) in row.iter().enumerate() {
let Some(chunk) = chunk else { continue };
@@ -303,7 +367,7 @@ pub fn light_chunk(
if section.is_uniform(0) {
continue;
}
section.unpack(&mut ids);
section.unpack(ids);
for (i, &id) in ids.iter().enumerate() {
let (lx, lz, ly) = (i & 15, (i >> 4) & 15, i >> 8);
let p = at(dx * 16 + lx, si * 16 + ly, dz * 16 + lz);
@@ -319,13 +383,15 @@ pub fn light_chunk(
}
}
// Columns of missing neighbour chunks neither hold nor pass light.
let mut present = vec![false; W * W];
present.resize(W * W, false);
present.fill(false);
for z in 0..W {
for x in 0..W {
present[z * W + x] = region[z / 16][x / 16].is_some();
}
}
let mut lowest = vec![h as i32; W * W];
lowest.resize(W * W, h as i32);
lowest.fill(h as i32);
for z in 0..W {
for x in 0..W {
if !present[z * W + x] || !sky_light {
@@ -420,21 +486,36 @@ pub fn light_chunk(
}
}
};
spread(&mut sky, &mut sky_queue);
spread(&mut block, &mut queue);
pack_lighting(h, &sky, &block, sky_light)
spread(sky, sky_queue);
spread(block, queue);
pack_lighting(h, sky, block, sky_light, region[1][1])
}
/// Extract just the centre column's light; never copy worker snapshot sections.
fn pack_lighting(h: usize, sky: &[u8], block: &[u8], sky_light: bool) -> Vec<Light> {
fn pack_lighting(
h: usize,
sky: &[u8],
block: &[u8],
sky_light: bool,
old: Option<&Chunk>,
) -> (Vec<Light>, u32) {
let at = |x: usize, y: usize, z: usize| (y * W + z) * W + x;
let open = if sky_light { 0xF0 } else { 0x00 };
(0..SECTIONS)
let mut changed = 0u32;
let lights = (0..SECTIONS)
.map(|si| {
let old = old.and_then(|c| c.sections.get(si)).map(|s| &s.light);
if si * 16 >= h {
return Light::Uniform(open);
let light = Light::Uniform(open);
match old {
Some(Light::Uniform(v)) if *v == open => {}
Some(Light::Full(data)) if data.iter().all(|&v| v == open) => {}
_ => changed |= 1 << si,
}
return light;
}
let mut data = Box::new([open; 4096]);
let mut differs = false;
for ly in 0..16 {
let y = si * 16 + ly;
if y >= h {
@@ -443,17 +524,24 @@ fn pack_lighting(h: usize, sky: &[u8], block: &[u8], sky_light: bool) -> Vec<Lig
for lz in 0..16 {
for lx in 0..16 {
let p = at(16 + lx, y, 16 + lz);
data[index(lx as i32, ly as i32, lz as i32)] = sky[p] << 4 | block[p];
let i = index(lx as i32, ly as i32, lz as i32);
let v = sky[p] << 4 | block[p];
data[i] = v;
differs |= old.is_some_and(|old| old.get(i) != v);
}
}
}
if differs {
changed |= 1 << si;
}
if data.iter().all(|&v| v == data[0]) {
Light::Uniform(data[0])
} else {
Light::Full(data)
}
})
.collect()
.collect();
(lights, changed)
}
/// Move a worker's light into current sections, preserving later block/biome
@@ -465,9 +553,18 @@ pub fn apply_lighting(chunk: &mut Chunk, lights: Vec<Light>) {
}
/// `apply_lighting` for a chunk lit before: writes only the sections whose
/// light changed and adds the cells that changed to `changed`.
pub fn apply_relighting(chunk: &mut Chunk, lights: Vec<Light>, changed: &mut LightChanges) {
/// light changed and adds the cells that changed to `changed`. `mask` has a bit
/// per section the worker saw differ from its snapshot.
pub fn apply_relighting_masked(
chunk: &mut Chunk,
lights: Vec<Light>,
mask: u32,
changed: &mut LightChanges,
) {
for (sy, (section, light)) in chunk.sections.iter_mut().zip(lights).enumerate() {
if mask & (1 << sy) == 0 {
continue;
}
let old = &section.light;
if let (Light::Uniform(a), Light::Uniform(b)) = (old, &light)
&& a == b
@@ -488,6 +585,10 @@ pub fn apply_relighting(chunk: &mut Chunk, lights: Vec<Light>, changed: &mut Lig
}
}
pub fn apply_relighting(chunk: &mut Chunk, lights: Vec<Light>, changed: &mut LightChanges) {
apply_relighting_masked(chunk, lights, u32::MAX, changed);
}
/// `DataLayer.get`: the nibble at index `i` (`y << 8 | z << 4 | x`), low first.
#[inline]
fn nibble(layer: &[u8], i: usize) -> u8 {
@@ -1015,6 +1116,11 @@ mod tests {
.collect();
lit.into_iter().collect()
}
fn region(chunks: &HashMap<(i32, i32), Arc<Chunk>>) -> [[Option<&Chunk>; 3]; 3] {
std::array::from_fn(|dz| {
std::array::from_fn(|dx| chunks.get(&(dx as i32 - 1, dz as i32 - 1)).map(|c| &**c))
})
}
fn sky(world: &HashMap<(i32, i32), Arc<Chunk>>, x: i32, y: i32, z: i32) -> u8 {
world[&(x >> 4, z >> 4)].light(x & 15, y, z & 15) >> 4
}
@@ -1242,6 +1348,51 @@ mod tests {
}
}
#[test]
fn light_mask_names_the_sections_the_full_diff_changes() {
let w = world(|c| {
c.set(8, 0, 8, TORCH);
});
let (_, same) = light_chunk_masked(&region(&w), &tables(), true);
assert_eq!(same, 0, "the centre is already lit like this");
let mut edited = w.clone();
Arc::make_mut(edited.get_mut(&(0, 0)).unwrap()).set(8, 0, 8, STONE);
let (lights, mask) = light_chunk_masked(&region(&edited), &tables(), true);
let mut expected = 0;
for (sy, (old, new)) in w[&(0, 0)].sections.iter().zip(&lights).enumerate() {
if (0..4096).any(|i| old.light(i) != new.get(i)) {
expected |= 1 << sy;
}
}
assert_eq!(mask, expected);
assert_ne!(mask, 0);
}
#[test]
fn reused_light_scratch_matches_a_fresh_call() {
let short = world(|c| {
c.set(8, 0, 8, TORCH);
});
let tall = world(|c| {
c.set(8, 0, 8, TORCH);
c.set(9, 200, 9, STONE);
});
let mut scratch = LightScratch::default();
// Shrink, grow, then shrink again: every cell must be cleared.
for w in [&short, &tall, &short] {
let (fresh, fresh_mask) = light_chunk_masked(&region(w), &tables(), true);
let (reused, reused_mask) =
light_chunk_masked_with(&region(w), &tables(), true, &mut scratch);
assert_eq!(fresh_mask, reused_mask);
for (a, b) in fresh.iter().zip(&reused) {
for i in 0..4096 {
assert_eq!(a.get(i), b.get(i));
}
}
}
}
#[test]
fn relighting_reports_exactly_the_changed_positions() {
let mut chunk = Chunk::empty(1, -1);
@@ -1460,7 +1611,7 @@ mod tests {
let mut old = fixture.clone();
let mut new = fixture.clone();
baseline_apply(&mut old, baseline_pack(&fixture, h, &sky, &block));
apply_lighting(&mut new, pack_lighting(h, &sky, &block, true));
apply_lighting(&mut new, pack_lighting(h, &sky, &block, true, None).0);
for (a, b) in old.sections.iter().zip(&new.sections) {
for i in 0..4096 {
assert_eq!(a.light(i), b.light(i));
@@ -1477,14 +1628,14 @@ mod tests {
let mut current = fixture.clone();
let held = shared.then(|| current.clone());
// Warm up and detach for the uniquely-owned scenario.
apply_lighting(&mut current, pack_lighting(h, &sky, &block, true));
apply_lighting(&mut current, pack_lighting(h, &sky, &block, true, None).0);
let started = Instant::now();
for _ in 0..500 {
let retained = shared.then(|| current.clone());
if optimized {
apply_lighting(
black_box(&mut current),
pack_lighting(h, black_box(&sky), black_box(&block), true),
pack_lighting(h, black_box(&sky), black_box(&block), true, None).0,
);
} else {
baseline_apply(
+47 -2
View File
@@ -250,8 +250,17 @@ fn state_kinds(facts: &BlockFacts) -> Vec<u8> {
let state = BlockStateId(i as u32);
let f = facts.get(state);
let fluid = matches!(&*f.name, "minecraft:water" | "minecraft:lava");
// Chorus is thin and non-solid but a visible part of the far End.
let chorus = matches!(
&*f.name,
"minecraft:chorus_plant" | "minecraft:chorus_flower"
);
let kept = !facts.is_air(state)
&& (f.solid_render || f.full_collision || f.name.ends_with("_leaves") || fluid);
&& (f.solid_render
|| f.full_collision
|| f.name.ends_with("_leaves")
|| chorus
|| fluid);
(u8::from(kept) * KEPT)
| (u8::from(fluid) * FLUID)
| (u8::from(state == facts.air) * AIR)
@@ -515,6 +524,8 @@ struct Shared {
kinds: std::sync::OnceLock<Vec<u8>>,
wake: Condvar,
stop: AtomicBool,
/// Reduced far detail: samplers approximate wide cells.
approximate: AtomicBool,
}
/// One background worker and at most two outstanding sample jobs. Only the
/// current view's quadtree and a bounded set of visited summaries are retained.
@@ -567,6 +578,7 @@ impl Lod {
kinds: std::sync::OnceLock::new(),
wake: Condvar::new(),
stop: AtomicBool::new(false),
approximate: AtomicBool::new(false),
});
let worker = shared.clone();
let mesh_context = context.clone();
@@ -610,6 +622,9 @@ impl Lod {
}
};
let [x, _, z] = key.origin();
sample.set_approximate(
shared.approximate.load(Ordering::Relaxed),
);
let samples =
sample.grid(x, z, key.size() / CELLS as i32, CELLS);
if shared.stop.load(Ordering::Relaxed) {
@@ -672,6 +687,7 @@ impl Lod {
}
Job::Sample(key) => {
let [x, _, z] = key.origin();
sampler.set_approximate(worker.approximate.load(Ordering::Relaxed));
let samples = sampler.grid(x, z, key.size() / CELLS as i32, CELLS);
Done::Sample { key, samples }
}
@@ -967,9 +983,11 @@ impl Lod {
self.near_loading = loading;
}
/// Reduced far detail (`graphics::FarDetail::Reduced`): the next update
/// replans when it changes.
/// replans when it changes, and tiles sampled from then on approximate
/// wide cells (`GeneratorWorker::lod_grid`).
pub fn set_reduced_detail(&mut self, reduced: bool) {
self.reduced_detail = reduced;
self.shared.approximate.store(reduced, Ordering::Relaxed);
}
fn held(&self) -> bool {
self.small && self.near_loading
@@ -1810,6 +1828,23 @@ mod tests {
for y in 55..64 {
chunk.set(4, y, 4, water.0);
}
// Chorus is thin and non-solid but must survive the visit summary.
let end_stone = assets
.registry
.default_state("minecraft:end_stone")
.unwrap();
let chorus = assets
.registry
.default_state("minecraft:chorus_plant")
.unwrap();
let flower = assets
.registry
.default_state("minecraft:chorus_flower")
.unwrap();
chunk.set(8, 70, 8, end_stone.0);
chunk.set(8, 71, 8, chorus.0);
chunk.set(8, 72, 8, chorus.0);
chunk.set(8, 73, 8, flower.0);
lod.observe(Arc::new(chunk.clone()), &facts);
receive_observed(&mut lod);
let observed = &lod.saved[&(-1, -1)];
@@ -1817,6 +1852,16 @@ mod tests {
assert_eq!(observed[0].surface.unwrap().state, stone);
assert_eq!(observed[68].surface.unwrap().height, 55);
assert_eq!(observed[68].surface.unwrap().water, Some((64, water)));
let chorus_column = &observed[8 * 16 + 8];
assert_eq!(chorus_column.surface.unwrap().state, flower);
assert_eq!(
chorus_column
.spans
.iter()
.map(|span| (span.min, span.max, span.state))
.collect::<Vec<_>>(),
vec![(70, 71, end_stone), (71, 73, chorus), (73, 74, flower)]
);
let fine = TileKey {
x: -1,
+82 -10
View File
@@ -123,9 +123,25 @@ mod vertex_tests {
fn emission_keeps_the_colour_and_the_free_bits() {
let rgb = super::pack_rgb([0x12, 0x34, 0x56]);
assert_eq!(super::emitting(rgb, 0), rgb);
assert_eq!(super::emitting(rgb, 9), 0xF656_3412);
assert_eq!(super::emitting(rgb, 15), 0xF056_3412);
assert_eq!(super::emitting(rgb, 15) | super::EMISSION_BITS, rgb);
assert_eq!(super::emitting(rgb, super::INCANDESCENT), 0xFD56_3412);
assert_eq!(super::emitting(rgb, super::VERY_BRIGHT), 0xFB56_3412);
assert_eq!(
super::emitting(rgb, super::VERY_BRIGHT) | super::EMISSION_BITS,
rgb
);
}
#[test]
fn light_sources_shine_by_kind_not_light_level() {
use super::{BRIGHT, INCANDESCENT, LOW, hdr_tier};
assert_eq!(hdr_tier("glowstone", 15), BRIGHT);
// A candle's flame is a particle: the block keeps only its wick.
assert_eq!(hdr_tier("torch", 14), INCANDESCENT);
assert_eq!(hdr_tier("red_candle", 3), LOW);
assert_eq!(hdr_tier("glow_lichen", 7), 0);
assert_eq!(hdr_tier("stone", 0), 0);
// Unlisted ones by their light level.
assert_eq!(hdr_tier("ochre_froglight", 15), INCANDESCENT);
assert_eq!(hdr_tier("sculk_sensor", 1), LOW);
}
}
pub struct SectionMesh {
@@ -282,6 +298,8 @@ struct Info {
/// `getShadeBrightness` (ambient occlusion contribution).
shade: f32,
emission: u8,
/// `hdr_tier`.
tier: u8,
/// `emissiveRendering`: drawn and sampled at full brightness.
emissive: bool,
fluid: FluidState,
@@ -450,6 +468,7 @@ impl MeshTables {
permeable: !s.solid_render || s.dampening == 0,
shade,
emission: s.emission,
tier: hdr_tier(name, s.emission),
emissive,
fluid: FluidState::from_block(state)?,
model: s.render_shape == RenderShape::Model,
@@ -513,6 +532,34 @@ impl MeshTables {
let mut tints = Tints::new(region, biomes, zoom, blend_radius, origin);
Mesher::tint(info, 0, &mut tints, [x & 15, y & 15, z & 15])
}
/// `BlockColors` for each of the state's tint indices at `pos`, as the
/// chunk mesher tints them (grass blocks included): `None` for an
/// untinted (-1) layer. For block models drawn outside the chunk mesh.
pub fn block_tints(
&self,
state: BlockStateId,
pos: [i32; 3],
region: &[[Option<&Chunk>; 3]; 3],
biomes: &Biomes,
zoom: BiomeZoom,
blend_radius: i32,
) -> impl Fn(i32) -> Option<u32> + use<> {
let sources = self.info.get(state.0 as usize).map(|info| {
let [x, y, z] = pos;
let origin = [x >> 4, z >> 4, y & !15];
let mut tints = Tints::new(region, biomes, zoom, blend_radius, origin);
let local = [x & 15, y & 15, z & 15];
let colors = [0, 1].map(|i| Mesher::tint(info, i, &mut tints, local));
(colors, i32::from(info.tint_count))
});
move |index| {
let (colors, count) = sources?;
if index < 0 || index >= count {
return None;
}
colors[(index as usize).min(1)]
}
}
/// `BlockState.getOffset` for blocks with a random model offset.
pub fn offset(&self, state: BlockStateId, x: i32, z: i32) -> Option<[f64; 3]> {
offset(self.info.get(state.0 as usize)?, x, z)
@@ -580,13 +627,37 @@ impl Padded {
}
/// Set on a terrain vertex's sprite layer when its texture alpha must be ignored.
pub const OPAQUE_LAYER: u16 = 1 << 15;
/// The bits of `TerrainVertex::color`'s alpha byte that hold the light its face emits, for
/// HDR output's highlights (`gfx/hdr.rs`): 15 minus the level, so the usual 0xFF means none.
/// The byte's other four bits stay set.
/// The bits of `TerrainVertex::color`'s alpha byte that hold how brightly its face shines
/// in HDR output (`gfx/hdr.rs`): 15 minus the tier, so the usual 0xFF means none. The
/// byte's other four bits stay set.
pub const EMISSION_BITS: u32 = 0x0F00_0000;
/// `color` marked as emitting light `level` (0 to 15).
pub fn emitting(color: u32, level: u8) -> u32 {
color & !EMISSION_BITS | u32::from(15 - level.min(15)) << 24
/// HDR tiers, by the kind of light rather than its level: a candle's flame is as bright as
/// a torch's, it only lights less around it. 0 stays as in SDR.
pub const LOW: u8 = 1;
pub const INCANDESCENT: u8 = 2;
pub const BRIGHT: u8 = 3;
/// The sun and lightning.
pub const VERY_BRIGHT: u8 = 4;
/// `color` marked as shining at `tier` (0 to `VERY_BRIGHT`).
pub fn emitting(color: u32, tier: u8) -> u32 {
color & !EMISSION_BITS | u32::from(15 - tier.min(VERY_BRIGHT)) << 24
}
/// The HDR tier of block `name` (no namespace), which emits light `level`.
pub fn hdr_tier(name: &str, level: u8) -> u8 {
match name {
_ if level == 0 => 0,
"glowstone" | "jack_o_lantern" | "campfire" | "soul_campfire" | "soul_lantern"
| "soul_torch" | "soul_wall_torch" | "soul_fire" => BRIGHT,
"torch" | "wall_torch" | "lantern" | "lava" | "crying_obsidian" | "fire"
| "sea_lantern" => INCANDESCENT,
"redstone_torch" | "redstone_wall_torch" | "magma_block" => LOW,
// Its flame is a particle.
_ if name.ends_with("candle") || name.ends_with("candle_cake") => LOW,
// A faint glow in the dark, as in SDR.
"glow_lichen" => 0,
_ if level >= 10 => INCANDESCENT,
_ => LOW,
}
}
const UNSET: u32 = u32::MAX;
const NO_BIOME: u16 = u16::MAX;
@@ -864,6 +935,7 @@ impl Mesher<'_> {
state: &Info,
) {
let q = &face.quad;
let tier = state.tier.max(hdr_tier("", q.light_emission));
let shade_face = q.shade_direction_override.unwrap_or(q.facing);
let directional = lighting::cardinal(self.nether_shading, shade_face);
// Grey level per vertex before tinting, and vanilla light coords.
@@ -903,7 +975,7 @@ impl Mesher<'_> {
} else {
face.layer as u16
},
emitting(pack_rgb(rgb), state.emission.max(q.light_emission)),
emitting(pack_rgb(rgb), tier),
pack_coords(lighting::with_emission(coords[i], q.light_emission)),
));
}
@@ -227,8 +227,11 @@ pub(super) fn tesselate(
} else {
0xFF_FFFF
};
// Lava emits light level 15.
let emission = if water { 0 } else { 15 };
let emission = if water {
0
} else {
crate::mesher::INCANDESCENT
};
let color = |by: f32| {
let rgb = [16, 8, 0].map(|shift| lighting::scale((tint >> shift & 255) as i32, by) as u32);
crate::mesher::emitting(pack_rgb(rgb), emission)
@@ -54,3 +54,40 @@ fn terrain_particles_use_the_particle_sprite_and_vanilla_tint_sources() {
assert_eq!(tint("minecraft:pink_petals"), None);
assert_eq!(tint("minecraft:stone"), None);
}
/// Blocks drawn outside the chunk mesh (moved by a piston) take the chunk
/// mesher's per-layer tints: a grass block's top and side overlay are tinted
/// as in the world, not left gray like its particles.
#[test]
fn block_tints_match_the_chunk_mesh_for_grass_blocks() {
let root =
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../.local/horizon-client/26.3");
if !root.join("horizon-resources.json").exists() {
return;
}
let assets = Assets::load(&root).unwrap();
let render = assets.reload(&root).unwrap();
let facts = BlockFacts::load(&root, assets.registry).unwrap();
let visibility = crate::models::load_visibility(&root, assets.registry).unwrap();
let tables = MeshTables::new(&assets, &render, &facts, visibility).unwrap();
let biomes = Biomes::load(&root).unwrap();
let state = |name| assets.registry.default_state(name).unwrap();
let center = Chunk::empty(0, 0);
let mut region = [[None; 3]; 3];
region[1][1] = Some(&center);
let tints = |name| {
tables.block_tints(
state(name),
[5, 70, 9],
&region,
&biomes,
BiomeZoom::new(0),
2,
)
};
let grass = tints("minecraft:grass_block");
assert!(grass(0).is_some());
assert_eq!(grass(-1), None);
assert_eq!(tints("minecraft:spruce_leaves")(0), Some(0x61_9961));
assert_eq!(tints("minecraft:stone")(0), None);
}
@@ -77,6 +77,7 @@ fn info(solid: bool) -> Info {
permeable: false,
shade: 1.0,
emission: 0,
tier: 0,
emissive: false,
fluid: FluidState::EMPTY,
model: false,
@@ -87,6 +87,9 @@ const MODEL_PARTS: [&str; 7] = [
];
const GRAPHICS_BACKEND: &[&str] = &["default", "opengl", "vulkan"];
const GRAPHICS_PRESET: &[&str] = &["fast", "fancy", "fabulous", "custom"];
/// Horizon's render distance cap (vanilla stops at 32): the server protocol's
/// view distance tops out at 127 anyway.
pub const MAX_RENDER_DISTANCE: i32 = 128;
const INACTIVITY: &[&str] = &["minimized", "afk"];
const CLOUDS: &[&str] = &["false", "fast", "true"];
const MAIN_HAND: &[&str] = &["left", "right"];
@@ -159,7 +162,7 @@ static TABLE: LazyLock<Vec<Def>> = LazyLock::new(|| {
b("reducedDebugInfo", false),
def("renderClouds", O(Clouds), V::Int(2)),
i("cloudRange", 2, 128, 128),
i("renderDistance", 2, 64, 12),
i("renderDistance", 2, MAX_RENDER_DISTANCE, 12),
i("simulationDistance", 5, 32, 12),
u("screenEffectScale", 1.0),
def("soundDevice", O(AnyStr), V::Str(String::new())),
+202 -24
View File
@@ -74,6 +74,27 @@ pub(crate) const ACCESSIBILITY_OPTIONS: &[Opt] = &[
Opt::HighContrastBlockOutlines,
];
/// `ControlsScreen.options`: the controls added by Java Edition 26.3, in
/// their vanilla order. The macOS-only Control-click switch is omitted on
/// other platforms just as `InputQuirks.EMULATE_RIGHT_CLICK_WITH_CTRL_KEY`
/// does in Java.
pub(crate) fn vanilla_control_options() -> Vec<Opt> {
let mut options = vec![
Opt::ToggleCrouch,
Opt::ToggleSprint,
Opt::ToggleAttack,
Opt::ToggleUse,
Opt::AutoJump,
Opt::SprintWindow,
Opt::QuitShortcuts,
];
if cfg!(target_os = "macos") {
options.push(Opt::CtrlClickEmulatesRightClick);
}
options.push(Opt::OperatorItemsTab);
options
}
/// `MouseSettingsScreen.options` in Java Edition 26.3, in vanilla order.
pub(crate) const MOUSE_OPTIONS: &[Opt] = &[
Opt::Sensitivity,
@@ -104,6 +125,11 @@ impl Opt {
| Opt::ChunkFade
| Opt::ImprovedTransparency
| Opt::ChunkBuilder
// Saved, but the creative tabs, the quit keys and the mouse
// don't read them yet.
| Opt::OperatorItemsTab
| Opt::QuitShortcuts
| Opt::CtrlClickEmulatesRightClick
)
}
fn file_key(self) -> Option<&'static str> {
@@ -128,6 +154,9 @@ impl Opt {
Opt::RotateWithMinecarts => "rotateWithMinecart",
Opt::HighContrastBlockOutlines => "highContrastBlockOutline",
Opt::MusicFrequency => "musicFrequency",
Opt::OperatorItemsTab => "operatorItemsTab",
Opt::QuitShortcuts => "quitShortcuts",
Opt::CtrlClickEmulatesRightClick => "ctrlClickEmulatesRightClick",
_ => return None,
})
}
@@ -171,7 +200,13 @@ impl Opt {
Opt::MenuBlur => "options.accessibility.menu_background_blurriness",
Opt::ToggleCrouch => "key.sneak",
Opt::ToggleSprint => "key.sprint",
Opt::ToggleAttack => "key.attack",
Opt::ToggleUse => "key.use",
Opt::AutoJump => "options.autoJump",
Opt::SprintWindow => "options.sprintWindow",
Opt::QuitShortcuts => "options.quitShortcuts",
Opt::CtrlClickEmulatesRightClick => "options.ctrlClickEmulatesRightClick",
Opt::OperatorItemsTab => "options.operatorItemsTab",
Opt::ChatVisibility => "options.chat.visibility",
Opt::ChatColors => "options.chat.color",
Opt::ChatLinks => "options.chat.links",
@@ -239,7 +274,11 @@ impl Opt {
| Opt::GlintStrength
| Opt::PanoramaSpeed => Kind::Slider(s.file.double(self.file_key().unwrap()) as f32),
Opt::Fov => unit(s.fov, 30.0, 110.0),
Opt::RenderDistance => unit(s.render_distance as f32, 2.0, 64.0),
Opt::RenderDistance => unit(
s.render_distance as f32,
2.0,
crate::options_file::MAX_RENDER_DISTANCE as f32,
),
Opt::SimulationDistance => unit(s.simulation_distance as f32, 5.0, 32.0),
Opt::Sensitivity => Kind::Slider(s.sensitivity),
Opt::WheelSensitivity => unit(s.mouse_wheel_sensitivity, 0.01, 10.0),
@@ -269,6 +308,7 @@ impl Opt {
Opt::PadLookSensitivity => Kind::Slider(s.controller.look_sensitivity),
Opt::PadMoveDeadzone => unit(s.controller.move_deadzone, 0.0, 0.5),
Opt::PadLookDeadzone => unit(s.controller.look_deadzone, 0.0, 0.5),
Opt::SprintWindow => unit(s.sprint_window as f32, 0.0, 10.0),
_ => Kind::Cycle,
}
}
@@ -317,6 +357,14 @@ impl Opt {
}
}
Opt::PanoramaSpeed => percent(c, s.file.double("panoramaScrollSpeed") as f32),
Opt::SprintWindow => {
let value = if s.sprint_window == 0 {
tr("options.off")
} else {
Text::tr("options.value", &[&s.sprint_window])
};
generic(c, value)
}
Opt::Fov => match s.fov as i32 {
70 => generic(c, tr("options.fov.min")),
110 => generic(c, tr("options.fov.max")),
@@ -514,7 +562,20 @@ impl Opt {
} else {
"options.key.hold"
}),
Opt::ToggleAttack => tr(if s.toggle_attack {
"options.key.toggle"
} else {
"options.key.hold"
}),
Opt::ToggleUse => tr(if s.toggle_use {
"options.key.toggle"
} else {
"options.key.hold"
}),
Opt::AutoJump => on_off(s.auto_jump),
Opt::QuitShortcuts => on_off(s.quit_shortcuts),
Opt::CtrlClickEmulatesRightClick => on_off(s.ctrl_click_emulates_right_click),
Opt::OperatorItemsTab => on_off(s.operator_items_tab),
Opt::ChatVisibility => tr([
"options.chat.visibility.full",
"options.chat.visibility.system",
@@ -586,7 +647,10 @@ impl Opt {
)
}
Opt::Fov => s.fov = step(30.0, 110.0),
Opt::RenderDistance => s.render_distance = step(2.0, 64.0) as i32,
Opt::RenderDistance => {
s.render_distance =
step(2.0, crate::options_file::MAX_RENDER_DISTANCE as f32) as i32
}
Opt::SimulationDistance => s.simulation_distance = step(5.0, 32.0) as i32,
Opt::Sensitivity => s.sensitivity = v,
Opt::WheelSensitivity => s.mouse_wheel_sensitivity = 0.01 + v * 9.99,
@@ -613,6 +677,7 @@ impl Opt {
Opt::PadLookSensitivity => s.controller.look_sensitivity = v,
Opt::PadMoveDeadzone => s.controller.move_deadzone = step(0.0, 50.0) / 100.0,
Opt::PadLookDeadzone => s.controller.look_deadzone = step(0.0, 50.0) / 100.0,
Opt::SprintWindow => s.sprint_window = step(0.0, 10.0) as i32,
_ => {}
}
if self.affects_preset() {
@@ -704,7 +769,14 @@ impl Opt {
Opt::ChunkBuilder => s.prioritize_chunk_updates = next(s.prioritize_chunk_updates, 3),
Opt::ToggleCrouch => s.toggle_crouch = !s.toggle_crouch,
Opt::ToggleSprint => s.toggle_sprint = !s.toggle_sprint,
Opt::ToggleAttack => s.toggle_attack = !s.toggle_attack,
Opt::ToggleUse => s.toggle_use = !s.toggle_use,
Opt::AutoJump => s.auto_jump = !s.auto_jump,
Opt::QuitShortcuts => s.quit_shortcuts = !s.quit_shortcuts,
Opt::CtrlClickEmulatesRightClick => {
s.ctrl_click_emulates_right_click = !s.ctrl_click_emulates_right_click
}
Opt::OperatorItemsTab => s.operator_items_tab = !s.operator_items_tab,
Opt::ChatVisibility => s.chat_visibility = next(s.chat_visibility, 3),
Opt::ChatColors => s.chat_colors = !s.chat_colors,
Opt::ChatLinks => s.chat_links = !s.chat_links,
@@ -923,13 +995,19 @@ pub enum Event {
ButtonClick,
/// Close the options screens (back to the pause menu or title).
Close,
/// Settings changed: apply them.
/// Settings changed: apply and save them.
Changed,
/// A slider is being dragged: apply settings live but defer saving.
SliderDrag,
/// The language changed (reload translations).
Language(String),
/// The pack selection changed (reload resources).
Packs(Vec<String>),
/// Create World's Data Packs screen closed with a new selection.
DataPacks(Vec<String>),
OpenPackFolder,
/// Data Packs' Open Pack Folder: create the folder if needed and open it.
OpenDataPackFolder(std::path::PathBuf),
OpenModFolder,
/// The Shader Packs page opened: list `shaderpacks/` into `shader_packs`.
ListShaderPacks,
@@ -992,6 +1070,13 @@ pub struct OptionsUi {
pub pack_selection: Vec<String>,
/// The open pack screen, made at its first frame.
pub pack_screen: Option<crate::pack_screen::PackScreen>,
/// The Packs page is Create World's Data Packs screen on this folder.
data_pack_dir: Option<std::path::PathBuf>,
/// The game's resources, for the built-in data pack's icon.
data_pack_resources: std::path::PathBuf,
/// The folder's entries when its packs were listed, and when they were
/// last compared with it.
data_pack_stamp: (Vec<(String, Option<std::time::SystemTime>)>, Instant),
/// A reload failure, dismissed before returning to the current page.
pub resource_error: Option<String>,
/// When the pack screen's search box took the focus (its cursor blink).
@@ -1026,6 +1111,9 @@ impl Default for OptionsUi {
pack_infos: Vec::new(),
pack_selection: Vec::new(),
pack_screen: None,
data_pack_dir: None,
data_pack_resources: Default::default(),
data_pack_stamp: (Vec::new(), Instant::now()),
resource_error: None,
pack_focus: Instant::now(),
in_world: false,
@@ -1133,9 +1221,48 @@ impl OptionsUi {
pub fn open(&mut self, in_world: bool) {
self.open_at(in_world, Page::Main);
}
/// Create World's Data Packs (`openDataPackSelectionScreen`): the Packs
/// page on the new world's pack folder `dir`, `selected` chosen;
/// `resources` holds the built-in pack's icon.
pub fn open_data_packs(
&mut self,
dir: std::path::PathBuf,
selected: Vec<String>,
resources: std::path::PathBuf,
) {
self.open_at(false, Page::Packs);
self.pack_infos = crate::datapack::screen_packs(&dir, &resources);
self.data_pack_resources = resources;
self.pack_selection = selected;
self.data_pack_stamp = (crate::datapack::folder_stamp(&dir), Instant::now());
self.data_pack_dir = Some(dir);
}
/// `PackSelectionScreen.tick`'s watcher: once a second, the Data Packs
/// folder's packs are listed again if it changed, the selection kept
/// (the search text and scroll start over).
fn watch_data_packs(&mut self, atlas: &HudAtlas, size: (f32, f32)) {
let Some(dir) = &self.data_pack_dir else {
return;
};
if self.data_pack_stamp.1.elapsed().as_secs() < 1 {
return;
}
let stamp = crate::datapack::folder_stamp(dir);
let changed = stamp != self.data_pack_stamp.0;
self.data_pack_stamp = (stamp, Instant::now());
if !changed {
return;
}
self.pack_infos = crate::datapack::screen_packs(dir, &self.data_pack_resources);
if let Some(p) = self.pack_screen.take() {
self.pack_selection = p.commit();
self.packs(atlas, size).changed = p.changed;
}
}
/// Open a page whose Done/Escape returns directly to the calling screen.
pub fn open_at(&mut self, in_world: bool, page: Page) {
self.stack.clear();
self.data_pack_dir = None;
self.push(page);
self.in_world = in_world;
self.selected_key = None;
@@ -1458,11 +1585,8 @@ impl OptionsUi {
Act::Push(Page::KeyBinds),
)),
)];
rows.extend(pairs(&[
Opt::ToggleCrouch,
Opt::ToggleSprint,
Opt::AutoJump,
]));
let controls = vanilla_control_options();
rows.extend(pairs(&controls));
// Horizon's controller page, after vanilla's rows.
rows.push(Row::Buttons(
button(
@@ -2169,6 +2293,7 @@ impl OptionsUi {
return;
}
if self.page() == Page::Packs {
self.watch_data_packs(atlas, (w, h));
ui::menu_background(batch, atlas, 0.0, 0.0, w, h, self.in_world);
let blink = self.pack_focus.elapsed().as_millis() as u64;
let m = crate::screen_draw::Measure(&atlas.font);
@@ -2427,6 +2552,7 @@ impl OptionsUi {
match action {
Action::ButtonClick => events.push(Event::ButtonClick),
Action::Changed => events.push(Event::Changed),
Action::SliderDrag => events.push(Event::SliderDrag),
Action::OpenAdvanced => self.push(Page::GraphicsAdvanced),
Action::OpenSimple => {
if self.stack.len() >= 2
@@ -2463,7 +2589,10 @@ impl OptionsUi {
self.pack_focus = Instant::now();
}
match action {
crate::pack_screen::Action::OpenFolder => events.push(Event::OpenPackFolder),
crate::pack_screen::Action::OpenFolder => events.push(match &self.data_pack_dir {
Some(dir) => Event::OpenDataPackFolder(dir.clone()),
None => Event::OpenPackFolder,
}),
crate::pack_screen::Action::Done => events.extend(self.back(s)),
crate::pack_screen::Action::None => {}
}
@@ -2507,7 +2636,7 @@ impl OptionsUi {
let v = ui::slider_value(wd.x, wd.w, x);
self.slider_value = Some((opt, v, Instant::now()));
set_slider(opt, s, v);
events.push(Event::Changed);
events.push(Event::SliderDrag);
}
return events;
}
@@ -2643,7 +2772,7 @@ impl OptionsUi {
let v = ui::slider_value(wd.x, wd.w, x);
self.slider_value = Some((opt, v, Instant::now()));
set_slider(opt, s, v);
events.push(Event::Changed);
events.push(Event::SliderDrag);
}
WKind::LanguageRow { code, .. } => {
if self.language_choice.as_deref() == Some(code.as_str()) {
@@ -2676,7 +2805,7 @@ impl OptionsUi {
let slider = self.dragging.take().is_some();
let graphics_slider = self.graphics.release();
if slider || graphics_slider {
vec![Event::ButtonClick]
vec![Event::ButtonClick, Event::Changed]
} else {
Vec::new()
}
@@ -2714,8 +2843,14 @@ impl OptionsUi {
}
None => {
self.pack_focus = Instant::now();
let title = if self.data_pack_dir.is_some() {
"dataPack.title"
} else {
"resourcePack.title"
};
self.pack_screen = Some(crate::pack_screen::PackScreen::new(
&m,
crate::text::Text::translate(title, &[]),
w,
h,
self.in_world,
@@ -2747,13 +2882,18 @@ impl OptionsUi {
}
Page::Packs => {
// PackSelectionScreen.onClose: PackSelectionModel.commit.
let data = self.data_pack_dir.take();
if let Some(p) = self.pack_screen.take()
&& p.changed
{
let ids = p.commit();
s.resource_packs = ids.clone();
self.pack_selection = ids.clone();
events.push(Event::Packs(ids));
if data.is_some() {
events.push(Event::DataPacks(ids));
} else {
s.resource_packs = ids.clone();
self.pack_selection = ids.clone();
events.push(Event::Packs(ids));
}
}
}
_ => {}
@@ -3049,10 +3189,48 @@ mod tests {
)));
}
#[test]
fn controls_match_java_26_3_option_order() {
let mut ui = OptionsUi::default();
ui.show(Page::Controls);
let options =
ui.list_rows(&Settings::default())
.into_iter()
.fold(Vec::new(), |mut options, row| {
match row {
Row::Big(opt) => options.push(opt),
Row::Pair(a, b) => {
options.push(a);
options.extend(b);
}
_ => {}
}
options
});
assert_eq!(options, vanilla_control_options());
}
#[test]
fn sprint_window_and_new_control_switches_round_trip() {
let mut settings = Settings::default();
set_slider(Opt::SprintWindow, &mut settings, 0.5);
settings.operator_items_tab = true;
settings.quit_shortcuts = false;
settings.ctrl_click_emulates_right_click = true;
settings.toggle_attack = true;
settings.toggle_use = true;
let loaded = Settings::parse(&settings.serialize());
assert_eq!(loaded.sprint_window, 5);
assert!(loaded.operator_items_tab);
assert!(!loaded.quit_shortcuts);
assert!(loaded.ctrl_click_emulates_right_click);
assert!(loaded.toggle_attack && loaded.toggle_use);
}
#[test]
fn render_distance_slider_round_trips_through_extended_options_range() {
let mut s = Settings::default();
for (position, distance) in [(0.0, 2), (0.5, 33), (1.0, 64)] {
for (position, distance) in [(0.0, 2), (0.5, 65), (1.0, 128)] {
set_slider(Opt::RenderDistance, &mut s, position);
assert_eq!(s.render_distance, distance);
let reloaded = Settings::parse(&s.serialize());
@@ -3063,10 +3241,10 @@ mod tests {
);
}
let mut file = crate::options_file::OptionsFile::default();
file.set_text("renderDistance", "64").unwrap();
assert!(file.set_text("renderDistance", "65").is_err());
file.set_text("renderDistance", "128").unwrap();
assert!(file.set_text("renderDistance", "129").is_err());
assert!(file.set_text("renderDistance", "1").is_err());
assert_eq!(file.int("renderDistance"), 64);
assert_eq!(file.int("renderDistance"), 128);
}
fn atlas() -> HudAtlas {
HudAtlas::build(std::path::Path::new("/nonexistent/horizon-test-assets")).unwrap()
@@ -3666,22 +3844,22 @@ mod tests {
let (x, y) = (slider.x + slider.w - 1.0, slider.y + 1.0);
assert_eq!(
ui.click(&mut s, &atlas, x, y, size, [854, 480], false, false),
vec![Event::Changed]
vec![Event::SliderDrag]
);
assert_eq!(s.master_volume, 1.0);
assert_eq!(ui.release(), vec![Event::ButtonClick]);
assert_eq!(ui.release(), vec![Event::ButtonClick, Event::Changed]);
assert!(ui.release().is_empty());
assert_eq!(
ui.click(&mut s, &atlas, x, y, size, [854, 480], false, false),
vec![Event::Changed]
vec![Event::SliderDrag]
);
for cx in [-100.0, 250.0, 1000.0] {
assert_eq!(
ui.click(&mut s, &atlas, cx, -100.0, size, [854, 480], true, false),
vec![Event::Changed]
vec![Event::SliderDrag]
);
}
assert_eq!(ui.release(), vec![Event::ButtonClick]);
assert_eq!(ui.release(), vec![Event::ButtonClick, Event::Changed]);
assert!(
ui.click(&mut s, &atlas, x, y, size, [854, 480], true, false)
.is_empty()
@@ -318,6 +318,8 @@ pub enum Action {
/// The pack screen and its state.
#[derive(Clone, Debug)]
pub struct PackScreen {
/// `resourcePack.title`, or `dataPack.title` from Create World.
pub title: Text,
pub width: i32,
pub height: i32,
pub in_world: bool,
@@ -342,6 +344,7 @@ impl PackScreen {
/// `PackSelectionScreen.init`: the lists filled, the search box focused.
pub fn new(
m: &Measure,
title: Text,
width: i32,
height: i32,
in_world: bool,
@@ -360,6 +363,7 @@ impl PackScreen {
.filter_map(|p| Some((icon_texture(&p.id), p.icon.clone()?)))
.collect();
let mut s = Self {
title,
width,
height,
in_world,
@@ -382,7 +386,7 @@ impl PackScreen {
}
/// The header layout: title, drop info and search box, centred.
fn header(&self, m: &Measure) -> [(i32, i32); 3] {
let tw = m.width(&tr("resourcePack.title"));
let tw = m.width(&self.title);
let dw = m.width(&tr("pack.dropInfo"));
let col = tw.max(dw).max(200);
let frame = self.width.max(col);
@@ -837,7 +841,7 @@ impl PackScreen {
return out;
}
let [(tx, ty), (dx, dy), _] = self.header(m);
d::string_widget(&mut out, m, &tr("resourcePack.title"), tx, ty, 0);
d::string_widget(&mut out, m, &self.title, tx, ty, 0);
d::string_widget(
&mut out,
m,
@@ -1105,7 +1109,7 @@ mod icon_tests {
pack("high_contrast", Some(contrast.clone())),
pack("file/Iconless", None),
];
let screen = PackScreen::new(&m, 480, 320, false, packs, &[]);
let screen = PackScreen::new(&m, tr("resourcePack.title"), 480, 320, false, packs, &[]);
let mut draws = screen.draw(&m, (0, 0), 0);
let mut patches = Vec::new();
screen.bind_icons(&mut draws, &atlas, &mut patches);
@@ -1152,7 +1156,8 @@ mod click_sound_tests {
crate::hud::HudAtlas::build(std::path::Path::new("/nonexistent/horizon-test-assets"))
.unwrap();
let m = Measure(&atlas.font);
let mut screen = PackScreen::new(&m, 480, 320, false, Vec::new(), &[]);
let title = tr("resourcePack.title");
let mut screen = PackScreen::new(&m, title, 480, 320, false, Vec::new(), &[]);
assert!(!screen.button_at(&m, 0.0, 0.0));
assert!(!screen.button_at(
&m,
+51 -8
View File
@@ -181,9 +181,15 @@ pub fn compatible(meta: &serde_json::Value) -> bool {
/// `PackCompatibility.forVersion`: too old, too new, compatible, or unknown
/// when the metadata names no format.
pub fn compatibility(meta: &serde_json::Value) -> crate::pack_screen::Compat {
compatibility_for(meta, RESOURCE_FORMAT)
}
/// `compatibility` against `current`, the resource or the data pack format.
pub fn compatibility_for(
meta: &serde_json::Value,
current: (i64, i64),
) -> crate::pack_screen::Compat {
use crate::pack_screen::Compat;
let pack = &meta["pack"];
let current = RESOURCE_FORMAT;
let range = if let (Some(min), Some(max)) = (
format_of(&pack["min_format"]),
format_of(&pack["max_format"]),
@@ -275,6 +281,10 @@ fn read_meta(source: &Source) -> Option<serde_json::Value> {
serde_json::from_slice(&bytes).ok()
}
fn pack(id: String, title: Text, source: Source) -> Option<Pack> {
pack_for(id, title, source, RESOURCE_FORMAT)
}
/// A pack whose compatibility is against `format`.
fn pack_for(id: String, title: Text, source: Source, format: (i64, i64)) -> Option<Pack> {
let meta = read_meta(&source)?;
let (raw_description, description_style) = component_root(&meta["pack"]["description"]);
Some(Pack {
@@ -284,7 +294,7 @@ fn pack(id: String, title: Text, source: Source) -> Option<Pack> {
raw_description,
description_style,
compatible: compatible(&meta),
compatibility: compatibility(&meta),
compatibility: compatibility_for(&meta, format),
overlays: overlays_of(&meta),
filter: filter_of(&meta),
source,
@@ -305,7 +315,13 @@ pub fn discover(resources: &Path, packs_dir: &Path) -> Vec<Pack> {
out.push(p);
}
}
let mut user: Vec<Pack> = fs::read_dir(packs_dir)
out.extend(folder(packs_dir, RESOURCE_FORMAT));
out
}
/// The packs (folders and zips) in `dir`, by name, as `FolderRepositorySource`
/// finds them; their compatibility is against `format`.
pub fn folder(dir: &Path, format: (i64, i64)) -> Vec<Pack> {
let mut user: Vec<Pack> = fs::read_dir(dir)
.into_iter()
.flatten()
.flatten()
@@ -319,12 +335,11 @@ pub fn discover(resources: &Path, packs_dir: &Path) -> Vec<Pack> {
} else {
return None;
};
pack(format!("file/{name}"), Text::literal(name), source)
pack_for(format!("file/{name}"), Text::literal(name), source, format)
})
.collect();
user.sort_by(|a, b| a.id.cmp(&b.id));
out.extend(user);
out
user
}
/// A file a pack provides: its path (folder packs) and its name inside the pack.
type Provided = (Option<PathBuf>, String);
@@ -380,7 +395,11 @@ fn link(target: &Path, at: &Path) -> io::Result<()> {
if target.is_dir() {
Ok(())
} else {
fs::copy(target, at).map(|_| ())
// The mirror only reads these files, and unix links them: a hardlink gives
// the same view without writing the 639 MB prepared root again, which was
// 33 s of a cold Windows launch with a pack on. Another volume (or a
// filesystem without hardlinks) still copies.
fs::hard_link(target, at).or_else(|_| fs::copy(target, at).map(|_| ()))
}
}
/// Build `out` as `vanilla` with the packs' files layered on top (packs in
@@ -417,6 +436,22 @@ fn build_overlay_uncached(
files.insert(name, (i, provided));
}
}
// FallbackResourceManager: a file's `.mcmeta` counts only from the pack that
// supplies the file or one above it; vanilla's own sits below every pack.
let hidden: BTreeSet<String> = files
.iter()
.filter(|(name, _)| !name.ends_with(".mcmeta"))
.filter_map(|(name, (owner, _))| {
let meta = format!("{name}.mcmeta");
match files.get(&meta) {
Some((meta_owner, _)) if meta_owner >= owner => None,
_ => Some(meta),
}
})
.collect();
for name in &hidden {
files.remove(name);
}
// Vanilla sits below every pack, so filters hide its files too.
let filters: Vec<&Filter> = packs.iter().flat_map(|p| &p.filter).collect();
let touched_dirs: BTreeSet<String> = files
@@ -468,6 +503,7 @@ fn build_overlay_uncached(
vanilla: &Path,
out: &Path,
touched: &BTreeSet<String>,
hidden: &BTreeSet<String>,
files: &BTreeMap<String, (usize, Provided)>,
merged: &BTreeMap<String, Vec<u8>>,
filters: &[&Filter],
@@ -504,6 +540,9 @@ fn build_overlay_uncached(
}
for name in names {
let child = format!("{prefix}{name}");
if hidden.contains(&child) {
continue;
}
if let Some(bytes) = merged.get(&child) {
fs::write(out.join(&child), bytes)?;
} else if let Some((owner, (path, inner))) = files.get(&child) {
@@ -528,6 +567,7 @@ fn build_overlay_uncached(
vanilla,
out,
touched,
hidden,
files,
merged,
filters,
@@ -551,6 +591,7 @@ fn build_overlay_uncached(
vanilla,
out,
&touched_dirs,
&hidden,
&files,
&merged,
&filters,
@@ -741,7 +782,9 @@ impl SelectionModel {
/// `available` in any order; `selected` as options.txt lists them,
/// bottom first.
pub fn new(mut available: Vec<Selectable>, selected: &[String]) -> Self {
// PackRepository.available is a TreeMap by id (String.compareTo).
// PackRepository.discoverAvailable: every source's packs go into one
// TreeMap by id (String.compareTo), so `file/` packs sort among the
// built-in ones.
available.sort_by(|a, b| a.id.encode_utf16().cmp(b.id.encode_utf16()));
available.dedup_by(|a, b| a.id == b.id);
let mut m = Self {
+23 -6
View File
@@ -2212,11 +2212,7 @@ impl ParticleEngine {
};
q.gravity = 3.0e-6;
q.velocity = [v[0], v[1] + f64::from(r.next_float() / 500.0), v[2]];
q.rgba[3] = if ty == T::CampfireSignalSmoke {
0.95
} else {
0.9
};
// Alpha stays at SingleQuadParticle's default of 1.0; the tick fades it.
q.behaviour = Behaviour::Campfire;
q
}
@@ -3818,7 +3814,7 @@ impl ParticleEngine {
layer: if layer {
crate::gfx::TRANSLUCENT_PARTICLE_OVERLAY
} else {
0
u32::from(hdr_tier(p.ty)) << 16
},
color,
light,
@@ -3830,6 +3826,27 @@ impl ParticleEngine {
}
}
}
/// How brightly particle `ty` shines in HDR output, as `mesher::hdr_tier`: flames and
/// glowing drops, not their smoke. Only opaque-layer particles are marked.
fn hdr_tier(ty: ParticleType) -> u8 {
use ParticleType as P;
match ty {
P::Flame
| P::SoulFireFlame
| P::CopperFireFlame
| P::SmallFlame
| P::Lava
| P::DrippingLava
| P::FallingLava
| P::LandingLava
| P::DrippingDripstoneLava
| P::FallingDripstoneLava => crate::mesher::INCANDESCENT,
P::DrippingObsidianTear | P::FallingObsidianTear | P::LandingObsidianTear => {
crate::mesher::LOW
}
_ => 0,
}
}
/// `Particle.LifetimeAlpha.currentAlphaForAge`.
fn lifetime_alpha(a: [f32; 4], age: i32, lifetime: i32, partial: f32) -> f32 {
if (a[0] - a[1]).abs() < 1.0e-5 {
@@ -49,6 +49,9 @@ pub fn sound_source(event: &str) -> usize {
index("ambient")
} else if e.starts_with("music_disc.") {
index("record")
} else if e.starts_with("item.goat_horn.") {
// InstrumentItem.play: SoundSource.RECORDS.
index("record")
} else if e.starts_with("music.") {
index("music")
} else if e.starts_with("weather.") || e.starts_with("entity.lightning_bolt") {
@@ -173,6 +176,12 @@ pub struct Settings {
pub toggle_use: bool,
/// Taps of the sprint key counted as double taps (sprintWindow).
pub sprint_window: i32,
/// Show the operator items tab in a creative inventory.
pub operator_items_tab: bool,
/// Allow platform quit shortcuts to close the game window.
pub quit_shortcuts: bool,
/// On macOS, turn Control-click into a right click.
pub ctrl_click_emulates_right_click: bool,
/// Horizon's own graphics settings (`horizon-graphics.txt`, not options.txt).
pub horizon: crate::graphics::Graphics,
/// Controller settings (`horizon-controller.txt`, Horizon's own).
@@ -255,6 +264,9 @@ impl Default for Settings {
toggle_attack: false,
toggle_use: false,
sprint_window: 7,
operator_items_tab: false,
quit_shortcuts: true,
ctrl_click_emulates_right_click: false,
horizon: Default::default(),
controller: Default::default(),
extras: Default::default(),
@@ -417,6 +429,9 @@ impl Settings {
toggle_attack: f.bool("toggleAttack"),
toggle_use: f.bool("toggleUse"),
sprint_window: f.int("sprintWindow"),
operator_items_tab: f.bool("operatorItemsTab"),
quit_shortcuts: f.bool("quitShortcuts"),
ctrl_click_emulates_right_click: f.bool("ctrlClickEmulatesRightClick"),
horizon: Default::default(),
controller: Default::default(),
extras: Default::default(),
@@ -507,6 +522,13 @@ impl Settings {
put_b(&mut f, "toggleAttack", self.toggle_attack);
put_b(&mut f, "toggleUse", self.toggle_use);
put_i(&mut f, "sprintWindow", self.sprint_window);
put_b(&mut f, "operatorItemsTab", self.operator_items_tab);
put_b(&mut f, "quitShortcuts", self.quit_shortcuts);
put_b(
&mut f,
"ctrlClickEmulatesRightClick",
self.ctrl_click_emulates_right_click,
);
put_b(&mut f, "autoJump", self.auto_jump);
put_s(&mut f, "lang", &self.language);
put_b(&mut f, "forceUnicodeFont", self.force_unicode);
@@ -653,7 +675,13 @@ pub enum Opt {
MenuBlur,
ToggleCrouch,
ToggleSprint,
ToggleAttack,
ToggleUse,
AutoJump,
SprintWindow,
QuitShortcuts,
CtrlClickEmulatesRightClick,
OperatorItemsTab,
ChatVisibility,
ChatColors,
ChatLinks,
@@ -6,7 +6,11 @@
//! instance's random.
use horizon_core::random::RandomSource;
use serde_json::Value;
use std::{collections::HashMap, fs, path::Path};
use std::{
collections::{HashMap, HashSet},
fs,
path::Path,
};
/// `SoundManager.EMPTY_SOUND_LOCATION`.
pub const EMPTY: &str = "minecraft:empty";
@@ -224,6 +228,34 @@ fn parse_sound(v: &Value) -> Option<Sound> {
sentinel: Sentinel::None,
})
}
/// The files under a namespace's `sounds/`, as `sounds/<path>` with `/`.
fn sound_files(namespace: &Path) -> HashSet<String> {
let mut files = HashSet::new();
let mut stack = vec![namespace.join("sounds")];
while let Some(dir) = stack.pop() {
let Ok(entries) = fs::read_dir(&dir) else {
continue;
};
for e in entries.flatten() {
let path = e.path();
// The listing's own type; a link is followed, as `is_file` does.
let Ok(kind) = e.file_type() else {
continue;
};
let (dir, file) = if kind.is_symlink() {
(path.is_dir(), path.is_file())
} else {
(kind.is_dir(), kind.is_file())
};
if dir {
stack.push(path);
} else if file && let Ok(rel) = path.strip_prefix(namespace) {
files.insert(rel.to_string_lossy().replace('\\', "/"));
}
}
}
files
}
impl SoundRegistry {
/// Every `assets/<namespace>/sounds.json` under a resource root, files
/// validated against `assets/<namespace>/sounds/`.
@@ -236,13 +268,19 @@ impl SoundRegistry {
.filter_map(|e| e.file_name().to_str().map(str::to_owned))
.collect();
namespaces.sort();
// Each namespace's sound files are listed once, as the resource manager
// lists a pack, instead of one file lookup per sound (thousands).
let files: HashMap<&str, HashSet<String>> = namespaces
.iter()
.map(|ns| (ns.as_str(), sound_files(&assets.join(ns))))
.collect();
let exists = |ns: &str, path: &str| files.get(ns).is_some_and(|f| f.contains(path));
let mut registry = Self::default();
for namespace in namespaces {
let Ok(text) = fs::read(assets.join(&namespace).join("sounds.json")) else {
for namespace in &namespaces {
let Ok(text) = fs::read(assets.join(namespace).join("sounds.json")) else {
continue;
};
let exists = |ns: &str, path: &str| assets.join(ns).join(path).is_file();
registry.add_file(&namespace, &text, &exists);
registry.add_file(namespace, &text, &exists);
}
registry
}
@@ -369,6 +407,24 @@ mod tests {
use super::*;
use horizon_core::random::LegacyRandom;
#[test]
fn load_keeps_the_sound_files_a_root_holds() {
let root = std::env::temp_dir().join(format!("horizon-sounds-{}", std::process::id()));
let ns = root.join("assets/minecraft");
fs::create_dir_all(ns.join("sounds/block/note")).unwrap();
fs::write(ns.join("sounds/block/note/harp.ogg"), b"").unwrap();
fs::write(
ns.join("sounds.json"),
br#"{"note": {"sounds": ["block/note/harp", "block/note/gone"]}}"#,
)
.unwrap();
let r = SoundRegistry::load(&root);
let _ = fs::remove_dir_all(&root);
let sounds = &r.get("minecraft:note").unwrap().sounds;
let kept: Vec<&str> = sounds.iter().map(|s| s.location.as_str()).collect();
assert_eq!(kept, ["minecraft:block/note/harp"]);
}
#[test]
fn references_weigh_as_much_as_their_event() {
let mut r = SoundRegistry::default();
+268 -53
View File
@@ -5,7 +5,10 @@
use crate::fast_hash::{FxHashMap, FxHashSet};
use crate::{
chunk::{Chunk, SECTIONS},
light::{LightChanges, LightTables, apply_lighting, apply_relighting, light_chunk},
light::{
LightChanges, LightScratch, LightTables, apply_lighting, apply_relighting_masked,
light_chunk_masked_with,
},
mesher::{MeshTables, Mesher, Region, SectionMesh, borrow},
world::World,
};
@@ -99,6 +102,9 @@ pub trait LodSampler: Send {
fn drain_details(&mut self) -> Vec<LodDetail> {
Vec::new()
}
/// Reduced far detail (`graphics::FarDetail::Reduced`): `grid` may then
/// approximate wide cells.
fn set_approximate(&mut self, _approximate: bool) {}
fn sample(&mut self, x: i32, z: i32) -> Option<crate::lod::Surface>;
fn column(&mut self, x: i32, z: i32) -> crate::lod::Column {
crate::lod::Column::from_surface(self.sample(x, z))
@@ -164,7 +170,7 @@ enum Job {
Decorate(i32, i32, Box<[Proto; 9]>),
Finish(Proto),
Light(i32, i32, Box<Region>, u64, bool),
Mesh(i32, i32, i32, Box<Region>, u64, MeshSettings),
Mesh(i32, i32, i32, Arc<Region>, u64, MeshSettings),
/// Proto chunks checked out to be saved (`ChunkGenerator::save_protos`),
/// off the main thread like vanilla's IOWorker.
Save(Vec<((i32, i32), Proto)>),
@@ -174,7 +180,7 @@ pub enum Done {
/// A proto chunk, and whether it was decorated (loaded so from the save).
Proto(i32, i32, Proto, bool),
Decorated(i32, i32, Box<[Proto; 9]>),
Lit(i32, i32, Vec<crate::chunk::Light>, u64),
Lit(i32, i32, Vec<crate::chunk::Light>, u32, u64),
Meshed(i32, i32, i32, SectionMesh, u64),
Saved(Vec<((i32, i32), Proto)>),
}
@@ -262,6 +268,9 @@ pub struct Streaming {
pub radius: i32,
/// `/forceload`ed chunks: kept loaded whatever the distance.
pub forced: HashSet<(i32, i32)>,
/// `forced`'s bounding box, refreshed each `update`: one compare rejects
/// most columns before the per-forced-chunk scan.
forced_bounds: Option<(i32, i32, i32, i32)>,
/// No work is scheduled while paused (title screen).
pub paused: bool,
/// The level loading screen is up: work goes ring by ring (`Ranges::rings`).
@@ -421,6 +430,7 @@ impl GenerationScan {
radius: input.ranges.radius,
extra: input.ranges.extra,
forced: input.ranges.forced,
forced_bounds: input.ranges.forced_bounds,
cover: input.ranges.cover,
band: input.ranges.band,
};
@@ -475,6 +485,8 @@ impl Streaming {
// the worker across mesh jobs, like vanilla's per-thread cache:
// cloning it per job copied every state and face shape.
let mut faces: Option<(Arc<MeshTables>, FaceVisibility)> = None;
// Light scratch stays with the worker across jobs.
let mut light_scratch = LightScratch::default();
loop {
let job = {
let mut queue = shared.queue.lock().unwrap();
@@ -511,7 +523,13 @@ impl Streaming {
Done::Saved(protos)
}
Job::Light(x, z, region, seq, sky) => {
Done::Lit(x, z, light_chunk(&borrow(&region), &light, sky), seq)
let (sections, changed) = light_chunk_masked_with(
&borrow(&region),
&light,
sky,
&mut light_scratch,
);
Done::Lit(x, z, sections, changed, seq)
}
Job::Mesh(x, sy, z, region, seq, settings) => {
let context = shared.context.read().unwrap().clone();
@@ -585,6 +603,7 @@ impl Streaming {
center: (0, 0),
radius: 8,
forced: HashSet::new(),
forced_bounds: None,
paused: false,
loading: false,
sky_light: true,
@@ -700,12 +719,14 @@ impl Streaming {
/// been generated.
fn requeue(&mut self) {
let basis = (self.center, self.loading, self.band);
if self.queue_basis.0 == basis && self.queue_basis.1 == self.extra {
if self.queue_basis.0 == basis && same_areas(&self.queue_basis.1, &self.extra) {
return;
}
self.queue_basis = (basis, self.extra.clone());
let mut queue = self.shared.queue.lock().unwrap();
rank_again(&mut queue, |x, z| self.distance(x, z));
self.queue_basis = (basis, canonical_areas(&self.extra));
rerank_queue(&self.shared.queue, |x, z| self.distance(x, z));
// Workers that found the heap empty while it was swapped out must
// pick the restored jobs up even if nothing new is submitted.
self.shared.ready.notify_all();
}
fn receive_decorated(&mut self, x: i32, z: i32, region: [Proto; 9]) {
self.generation_scan.exhausted = false;
@@ -783,14 +804,7 @@ impl Streaming {
world.dirty_sections.insert((key.0, sy, key.1));
}
}
for dz in -1..=1 {
for dx in -1..=1 {
let n = (key.0 + dx, key.1 + dz);
if !loaded || world.chunks.get(&n).is_some_and(|c| !c.light_loaded) {
world.dirty_light.insert(n);
}
}
}
mark_dirty_light(world, key, loaded, &self.lit);
}
/// The order jobs around the player run in, lowest first.
fn distance(&self, x: i32, z: i32) -> i64 {
@@ -816,7 +830,7 @@ impl Streaming {
}
Some(Box::new(region))
}
fn region(world: &World, x: i32, z: i32) -> Option<Box<Region>> {
fn region(world: &World, x: i32, z: i32) -> Option<Arc<Region>> {
let mut region: Region = Default::default();
for dz in 0..3 {
for dx in 0..3 {
@@ -828,7 +842,7 @@ impl Streaming {
);
}
}
Some(Box::new(region))
Some(Arc::new(region))
}
pub fn is_lit(&self, x: i32, z: i32) -> bool {
self.lit.contains(&(x, z))
@@ -898,6 +912,7 @@ impl Streaming {
if self.paused {
return (uploads, Vec::new());
}
self.forced_bounds = forced_bounds(&self.forced);
ExtraCover::refresh(&mut self.cover, &self.extra);
// The sweeps below walk every loaded column and proto chunk. Run them
// at once when the view moves and every eighth tick otherwise: a
@@ -925,7 +940,7 @@ impl Streaming {
Done::Decorated(x, z, region) => self.receive_decorated(x, z, *region),
Done::Generated(chunk) => self.receive_generated(chunk, world),
Done::Saved(protos) => self.receive_saved(protos),
Done::Lit(x, z, sections, seq) => {
Done::Lit(x, z, sections, mask, seq) => {
if self.lighting.get(&(x, z)) != Some(&seq) {
continue;
}
@@ -942,7 +957,7 @@ impl Streaming {
if first {
apply_lighting(chunk, sections);
} else {
apply_relighting(chunk, sections, &mut changed);
apply_relighting_masked(chunk, sections, mask, &mut changed);
}
// Saved unlit (at the edge of what was loaded), or its light
// changed: saved again.
@@ -1077,7 +1092,10 @@ impl Streaming {
// vanilla's order: decoration is order dependent where write regions
// overlap (see `DecorationOrder`).
let workers = self.threads.len();
if self.decorates && self.order_area != Some((self.center, self.radius)) {
// Re-ranking thousands of chunks buys nothing while no proto can be
// decorated: the next proto triggers it.
let may_decorate = !self.protos.is_empty() || self.decorating > 0;
if self.decorates && may_decorate && self.order_area != Some((self.center, self.radius)) {
let (decorated, chunks) = (&self.decorated, &world.chunks);
// The render distance is the server view distance in singleplayer.
self.decoration_order
@@ -1090,6 +1108,7 @@ impl Streaming {
radius: self.radius,
extra: &self.extra,
forced: &self.forced,
forced_bounds: self.forced_bounds,
cover: ExtraCover::matching(&self.cover, &self.extra),
band: self.band.as_ref(),
};
@@ -1102,36 +1121,30 @@ impl Streaming {
});
}
if self.decorates && self.decorating < workers * 2 {
let wants = |s: &Self, x: i32, z: i32| {
!s.decorated.contains(&(x, z))
&& !world.chunks.contains_key(&(x, z))
&& (s.in_range(x, z, 2) || s.near_finished(x, z, 1))
};
// A wanted chunk holds back higher-ranked ones only if it can be
// decorated: its 3x3 neighbourhood is (or will be) generated as
// protos, none of them already final.
let waits_for = |s: &Self, x: i32, z: i32| {
wants(s, x, z)
&& (-1..=1).all(|dz| {
(-1..=1).all(|dx| {
let n = (x + dx, z + dz);
!world.chunks.contains_key(&n)
&& (s.protos.contains_key(&n)
|| s.generating.contains(&n)
|| s.ranges().requested(n.0, n.1, true))
})
let mut wanted: Vec<(u64, i64, i32, i32)> = {
let ranges = Ranges {
center: self.center,
radius: self.radius,
extra: &self.extra,
forced: &self.forced,
forced_bounds: self.forced_bounds,
cover: ExtraCover::matching(&self.cover, &self.extra),
band: self.band.as_ref(),
};
self.protos
.iter()
.filter(|(k, p)| {
p.is_some()
&& !self.decorated.contains(k)
&& !world.chunks.contains_key(k)
&& (ranges.in_range(k.0, k.1, 2) || ranges.near_finished(k.0, k.1, 1))
})
.map(|(k, _)| (0, ranges.priority(k.0, k.1, self.loading), k.0, k.1))
.collect()
};
let keys: Vec<(i32, i32)> = self
.protos
.iter()
.filter(|(k, p)| p.is_some() && wants(self, k.0, k.1))
.map(|(k, _)| *k)
.collect();
let mut wanted: Vec<(u64, i64, i32, i32)> = keys
.into_iter()
.map(|(x, z)| (0, self.distance(x, z), x, z))
.collect();
// Chunks no ticket reached (forced and extra areas) go last, nearest first.
wanted.sort_unstable_by_key(|w| w.1);
for w in &mut wanted {
@@ -1142,10 +1155,32 @@ impl Streaming {
if self.decorating >= workers * 2 {
break;
}
if self
.decoration_order
.blocked(x, z, |nx, nz| waits_for(self, nx, nz))
{
// One `Ranges` per candidate: `blocked` asks about up to 24
// neighbours, each of their 3x3.
let ranges = Ranges {
center: self.center,
radius: self.radius,
extra: &self.extra,
forced: &self.forced,
forced_bounds: self.forced_bounds,
cover: ExtraCover::matching(&self.cover, &self.extra),
band: self.band.as_ref(),
};
let waits_for = |nx: i32, nz: i32| {
!self.decorated.contains(&(nx, nz))
&& !world.chunks.contains_key(&(nx, nz))
&& (ranges.in_range(nx, nz, 2) || ranges.near_finished(nx, nz, 1))
&& (-1..=1).all(|dz| {
(-1..=1).all(|dx| {
let n = (nx + dx, nz + dz);
!world.chunks.contains_key(&n)
&& (self.protos.contains_key(&n)
|| self.generating.contains(&n)
|| ranges.requested(n.0, n.1, true))
})
})
};
if self.decoration_order.blocked(x, z, waits_for) {
continue;
}
let keys: Vec<(i32, i32)> =
@@ -1220,6 +1255,7 @@ impl Streaming {
radius: self.radius,
extra: &self.extra,
forced: &self.forced,
forced_bounds: self.forced_bounds,
cover: ExtraCover::matching(&self.cover, &self.extra),
band: self.band.as_ref(),
};
@@ -1298,6 +1334,7 @@ impl Streaming {
radius: self.radius,
extra: &self.extra,
forced: &self.forced,
forced_bounds: self.forced_bounds,
cover: ExtraCover::matching(&self.cover, &self.extra),
band: self.band.as_ref(),
},
@@ -1395,6 +1432,7 @@ impl Streaming {
radius: self.radius,
extra: &self.extra,
forced: &self.forced,
forced_bounds: self.forced_bounds,
cover: ExtraCover::matching(&self.cover, &self.extra),
band: self.band.as_ref(),
}
@@ -1550,6 +1588,8 @@ struct Ranges<'a> {
radius: i32,
extra: &'a [((i32, i32), i32)],
forced: &'a HashSet<(i32, i32)>,
/// `forced`'s bounding box, when known.
forced_bounds: Option<(i32, i32, i32, i32)>,
/// `extra` as a grid, when one was made for it: `in_extra` without a
/// scan of every area.
cover: Option<&'a ExtraCover>,
@@ -1623,12 +1663,17 @@ impl ExtraCover {
cover: &'a Option<ExtraCover>,
extra: &[((i32, i32), i32)],
) -> Option<&'a ExtraCover> {
cover.as_ref().filter(|c| c.extra == extra)
cover
.as_ref()
.filter(|c| c.extra == extra || same_areas(&c.extra, extra))
}
/// Make the grid again if `extra` changed; none for one area or none
/// (a scan is as quick), or areas too far apart.
fn refresh(cover: &mut Option<ExtraCover>, extra: &[((i32, i32), i32)]) {
if cover.as_ref().is_some_and(|c| c.extra == extra) {
if cover
.as_ref()
.is_some_and(|c| c.extra == extra || same_areas(&c.extra, extra))
{
return;
}
*cover = Self::new(extra);
@@ -1672,7 +1717,7 @@ impl ExtraCover {
}
}
Some(Self {
extra: extra.to_vec(),
extra: canonical_areas(extra),
x0,
z0,
width,
@@ -1800,6 +1845,15 @@ impl Ranges<'_> {
return true;
}
let reach = (margin - 1).max(0);
// Outside the forced box, no scan can find a forced chunk near enough.
if let Some((x0, z0, x1, z1)) = self.forced_bounds
&& (x.saturating_add(reach) < x0
|| x.saturating_sub(reach) > x1
|| z.saturating_add(reach) < z0
|| z.saturating_sub(reach) > z1)
{
return false;
}
self.forced
.iter()
.any(|f| (x - f.0).abs() <= reach && (z - f.1).abs() <= reach)
@@ -1824,6 +1878,20 @@ impl Drop for Streaming {
}
}
}
/// Mark the columns whose light a newly resident column at `key` can change:
/// itself and its face neighbours, plus any neighbour still unlit. A diagonal
/// column's light can only change through a face neighbour, and a lit diagonal
/// had all nine columns around it, this one included, so it is not relit.
fn mark_dirty_light(world: &mut World, key: (i32, i32), loaded: bool, lit: &FxHashSet<(i32, i32)>) {
for dz in -1..=1 {
for dx in -1..=1 {
let n = (key.0 + dx, key.1 + dz);
if !lit.contains(&n) || (!loaded && (dx == 0 || dz == 0)) {
world.dirty_light.insert(n);
}
}
}
}
/// The section indexes set in a column's bits, lowest first.
fn section_indexes(mut bits: u32) -> impl Iterator<Item = i32> {
std::iter::from_fn(move || {
@@ -1848,6 +1916,36 @@ fn rank_again(queue: &mut BinaryHeap<Queued>, distance: impl Fn(i32, i32) -> i64
}
*queue = BinaryHeap::from(jobs);
}
/// Re-rank the queue without holding its mutex while distances are computed:
/// workers block on that mutex before every pop. Jobs pushed meanwhile keep
/// their own priorities and are merged back in.
fn rerank_queue(queue: &Mutex<BinaryHeap<Queued>>, distance: impl Fn(i32, i32) -> i64) {
let mut heap = std::mem::take(&mut *queue.lock().unwrap());
rank_again(&mut heap, distance);
let mut queue = queue.lock().unwrap();
while let Some(job) = queue.pop() {
heap.push(job);
}
*queue = heap;
}
/// `extra` as a set: the order areas are stored in must not matter.
fn canonical_areas(areas: &[((i32, i32), i32)]) -> Vec<((i32, i32), i32)> {
let mut areas = areas.to_vec();
areas.sort_unstable();
areas.dedup();
areas
}
fn same_areas(canonical: &[((i32, i32), i32)], areas: &[((i32, i32), i32)]) -> bool {
canonical == areas || canonical_areas(areas) == canonical
}
/// The bounding box of the forced chunks, or `None` when there are none.
fn forced_bounds(forced: &HashSet<(i32, i32)>) -> Option<(i32, i32, i32, i32)> {
let mut forced = forced.iter();
let &(x, z) = forced.next()?;
Some(forced.fold((x, z, x, z), |(x0, z0, x1, z1), &(x, z)| {
(x0.min(x), z0.min(z), x1.max(x), z1.max(z))
}))
}
#[cfg(test)]
mod tests {
use super::*;
@@ -1860,6 +1958,7 @@ mod tests {
radius: 32,
extra: &[],
forced: &forced,
forced_bounds: None,
cover: None,
band: None,
};
@@ -1906,6 +2005,115 @@ mod tests {
assert_eq!(queue.pop().and_then(|q| q.at), Some((20, 0)));
}
#[test]
fn a_new_column_relights_itself_and_its_faces_not_lit_diagonals() {
let mut world = World::new(horizon_world::registry::BlockStateId(0), 0);
let mut lit = FxHashSet::default();
for dz in -1..=1 {
for dx in -1..=1 {
let key = (dx, dz);
world.chunks.insert(key, Arc::new(Chunk::empty(dx, dz)));
lit.insert(key);
}
}
// A fresh column queued every lit face and itself, but no lit diagonal.
mark_dirty_light(&mut world, (0, 0), false, &lit);
let expected = [(0, 0), (-1, 0), (1, 0), (0, -1), (0, 1)]
.into_iter()
.collect::<FxHashSet<_>>();
assert_eq!(world.dirty_light, expected);
// A diagonal still unlit stays queued.
lit.remove(&(1, 1));
world.dirty_light.clear();
mark_dirty_light(&mut world, (0, 0), false, &lit);
assert!(world.dirty_light.contains(&(1, 1)));
assert!(!world.dirty_light.contains(&(-1, -1)));
// A column loaded with light only queues its unlit neighbours.
lit.remove(&(0, -1));
world.dirty_light.clear();
mark_dirty_light(&mut world, (0, 0), true, &lit);
assert_eq!(
world.dirty_light,
[(0, -1), (1, 1)].into_iter().collect::<FxHashSet<_>>()
);
}
#[test]
fn reranking_keeps_jobs_submitted_meanwhile() {
let generate = |x, z, priority, order| Queued {
priority,
at: Some((x, z)),
bias: 0,
order,
job: Job::Generate(x, z),
};
let queue = Mutex::new(BinaryHeap::from(vec![
generate(5, 0, 500, 1),
generate(20, 0, 100, 2),
]));
let late = AtomicBool::new(false);
rerank_queue(&queue, |x, z| {
if !late.swap(true, Ordering::Relaxed) {
// `submit` pushes while the re-rank runs.
queue.lock().unwrap().push(generate(9, 0, 324, 3));
}
i64::from(x * x + z * z)
});
let pops: Vec<_> = std::iter::from_fn(|| queue.lock().unwrap().pop())
.map(|q| q.at)
.collect();
assert_eq!(pops, [Some((5, 0)), Some((9, 0)), Some((20, 0))]);
assert!(same_areas(&[], &[]));
let canonical = canonical_areas(&[((1, 2), 3), ((0, 0), 1), ((1, 2), 3)]);
assert_eq!(canonical, [((0, 0), 1), ((1, 2), 3)]);
assert!(same_areas(&canonical, &[((1, 2), 3), ((0, 0), 1)]));
assert!(!same_areas(&canonical, &[((1, 2), 3), ((0, 0), 2)]));
}
#[test]
fn forced_bounds_answers_as_the_scan() {
for forced in [
HashSet::new(),
[(40, -40)].into(),
[(40, -40), (-10, 5), (41, -39)].into(),
[(-500, 200), (500, -200)].into(),
] {
let bounds = forced_bounds(&forced);
let ranges = |bounds| Ranges {
center: (3, -2),
radius: 10,
extra: &[],
forced: &forced,
forced_bounds: bounds,
cover: None,
band: None,
};
let (scan, quick) = (ranges(None), ranges(bounds));
for x in -520..=520 {
for z in -220..=220 {
for margin in 0..5 {
assert_eq!(
scan.in_main_range(x, z, margin),
quick.in_main_range(x, z, margin),
"({x}, {z}) margin {margin}"
);
}
}
}
}
}
#[test]
fn a_reordered_area_set_keeps_the_grid() {
let extra = [((0, 0), 5), ((30, 30), 5)];
let mut cover = None;
ExtraCover::refresh(&mut cover, &extra);
assert!(ExtraCover::matching(&cover, &[((30, 30), 5), ((0, 0), 5)]).is_some());
assert!(ExtraCover::matching(&cover, &[((30, 30), 5), ((0, 1), 5)]).is_none());
}
#[test]
fn view_distance_is_vanillas_shape() {
// ChunkTrackingView.isInViewDistance at render distance 12: a column
@@ -1965,6 +2173,7 @@ mod tests {
radius: self.radius,
extra: &self.extra,
forced: &self.forced,
forced_bounds: None,
cover: None,
band: None,
},
@@ -2279,6 +2488,7 @@ mod tests {
radius,
extra: &[],
forced: &forced,
forced_bounds: None,
cover: None,
band: None,
};
@@ -2335,6 +2545,7 @@ mod tests {
radius: 12,
extra: &[],
forced: &forced,
forced_bounds: None,
cover: None,
band: None,
};
@@ -2362,6 +2573,7 @@ mod tests {
radius,
extra: &[((radius * 2 + 9, 3), 4)],
forced: &forced,
forced_bounds: None,
cover: None,
band: None,
};
@@ -2403,6 +2615,7 @@ mod tests {
radius: 2,
extra: &extra,
forced: &forced,
forced_bounds: None,
cover: None,
band: None,
};
@@ -2477,6 +2690,7 @@ mod cover_tests {
radius: 10,
extra: &extra,
forced: &forced,
forced_bounds: None,
cover,
band: None,
};
@@ -2536,6 +2750,7 @@ mod cover_tests {
radius: 10,
extra: &extra,
forced: &forced,
forced_bounds: None,
cover,
band: None,
};
@@ -143,9 +143,10 @@ fn apply_fog(color: vec4<f32>, spherical: f32, cylindrical: f32) -> vec4<f32> {
// Bit 15 of the layer marks opaque leaves (forceOpaque): no alpha cutout.
let color = sprite(in.uv, in.layer & 0x7fffu) * in.color;
if color.a < 0.5 && (in.layer & 0x8000u) == 0u { discard; }
// Alpha 0 tells HDR output (hdr.wgsl) a light-emitting block's bright pixels
// may shine. Nothing else reads the frame's alpha.
let emissive = select(1.0, 0.0, in.emission > 0u);
// Below 0.75, alpha tells HDR output (hdr.wgsl) a light source's bright
// pixels may shine, and at which tier (mesher::hdr_tier): 0.525 at the
// lowest, 0 at the brightest. Nothing else reads the frame's alpha.
let emissive = select(1.0, 0.7 * f32(4u - min(in.emission, 4u)) / 4.0, in.emission > 0u);
return apply_fog(vec4(color.rgb, emissive), in.spherical, in.cylindrical);
}
@fragment fn fs_translucent(in: TerrainOut) -> @location(0) vec4<f32> {
+126 -38
View File
@@ -79,27 +79,31 @@ pub fn load_metadata(root: &Path) {
}
*NINE_SLICE.write().unwrap_or_else(|e| e.into_inner()) = Some(map);
}
/// The sprite's nine-slice metadata; without any (no resources loaded), its
/// GUI size and the widgets' usual borders.
fn scaling(sprite: &str, r: &Rect) -> NineSlice {
/// The sprite's nine-slice metadata, when its `.png.mcmeta` has any.
fn nine_slice_metadata(sprite: &str) -> Option<NineSlice> {
NINE_SLICE
.read()
.unwrap_or_else(|e| e.into_inner())
.as_ref()
.and_then(|m| m.get(sprite).copied())
.unwrap_or(NineSlice {
width: r.w,
height: r.h,
border: match sprite {
"gui/sprites/widget/button"
| "gui/sprites/widget/button_highlighted"
| "gui/sprites/widget/button_disabled" => [3; 4],
"gui/sprites/widget/slider_handle"
| "gui/sprites/widget/slider_handle_highlighted" => [2, 2, 2, 3],
_ => [1; 4],
},
stretch_inner: false,
})
}
/// The sprite's nine-slice metadata; without any (no resources loaded), its
/// GUI size and the widgets' usual borders.
fn scaling(sprite: &str, r: &Rect) -> NineSlice {
nine_slice_metadata(sprite).unwrap_or(NineSlice {
width: r.w,
height: r.h,
border: match sprite {
"gui/sprites/widget/button"
| "gui/sprites/widget/button_highlighted"
| "gui/sprites/widget/button_disabled" => [3; 4],
"gui/sprites/widget/slider_handle" | "gui/sprites/widget/slider_handle_highlighted" => {
[2, 2, 2, 3]
}
_ => [1; 4],
},
stretch_inner: false,
})
}
/// The region `[x, y, w, h]` of a sprite `size` GUI pixels large, in its
/// own units (`TextureAtlasSprite.getU(x / width)`).
@@ -152,6 +156,35 @@ pub fn nine_slice(
draw_nine_slice(batch, *r, scaling(sprite, r), [x, y, w, h], color);
}
}
/// `GuiGraphicsExtractor.blitSprite`: a sprite over `w` × `h` as its
/// `gui.scaling` says, nine-sliced, or stretched when it has no metadata.
/// (`tile`, which no vanilla sprite uses, isn't read yet: it stretches.)
pub fn blit_sprite(
batch: &mut HudBatch,
atlas: &HudAtlas,
sprite: &str,
x: f32,
y: f32,
w: f32,
h: f32,
color: u32,
) {
if let Some(r) = atlas.sprites.get(sprite) {
draw_sprite(batch, *r, nine_slice_metadata(sprite), [x, y, w, h], color);
}
}
fn draw_sprite(
batch: &mut HudBatch,
r: Rect,
nine_slice: Option<NineSlice>,
[x, y, w, h]: [f32; 4],
color: u32,
) {
match nine_slice {
Some(n) => draw_nine_slice(batch, r, n, [x, y, w, h], color),
None => batch.rect_uv(x, y, w, h, r, color),
}
}
fn draw_nine_slice(
batch: &mut HudBatch,
r: Rect,
@@ -227,28 +260,33 @@ pub fn tiled(
h: f32,
size: f32,
color: u32,
) {
tiled_sized(batch, atlas, sprite, x, y, w, h, [size, size], color);
}
/// Repeat a texture using its width and height in GUI pixels.
#[allow(clippy::too_many_arguments)]
pub fn tiled_sized(
batch: &mut HudBatch,
atlas: &HudAtlas,
sprite: &str,
x: f32,
y: f32,
w: f32,
h: f32,
size: [f32; 2],
color: u32,
) {
let Some(r) = atlas.sprites.get(sprite).copied() else {
return;
};
let mut yy = 0.0;
while yy < h {
let th = size.min(h - yy);
let mut xx = 0.0;
while xx < w {
let tw = size.min(w - xx);
batch.rect_uv(
x + xx,
y + yy,
tw,
th,
sub(r, [size, size], [0.0, 0.0, tw, th]),
color,
);
xx += size;
}
yy += size;
}
tile(
batch,
r,
size,
[0.0, 0.0, size[0], size[1]],
[x, y, w, h],
color,
);
}
/// Text drawn at `scale` times its size (a scaled pose).
pub fn text_scaled(
@@ -434,7 +472,7 @@ pub fn separators(
("gui/header_separator", "gui/footer_separator")
};
let w = batch.width;
tiled(
tiled_sized(
batch,
atlas,
header,
@@ -442,10 +480,10 @@ pub fn separators(
header_bottom - 2.0,
w,
2.0,
32.0,
[32.0, 2.0],
u32::MAX,
);
tiled(
tiled_sized(
batch,
atlas,
footer,
@@ -453,7 +491,7 @@ pub fn separators(
footer_top,
w,
2.0,
32.0,
[32.0, 2.0],
u32::MAX,
);
}
@@ -1291,6 +1329,22 @@ mod tests {
parse_nine_slice(&serde_json::from_str(json).unwrap()).unwrap()
}
#[test]
fn separator_tiles_sample_both_rows_and_clip_the_last_column() {
let mut batch = HudBatch::new([640, 480]);
tile(
&mut batch,
sprite(32, 2),
[32.0, 2.0],
[0.0, 0.0, 32.0, 2.0],
[0.0, 5.0, 40.0, 2.0],
u32::MAX,
);
let q = quads(&batch);
assert_eq!(q.len(), 2);
assert_quad(q[0], ([0.0, 5.0, 32.0, 7.0], [0.0, 0.0, 1.0, 1.0]));
assert_quad(q[1], ([32.0, 5.0, 40.0, 7.0], [0.0, 0.0, 0.25, 1.0]));
}
#[test]
fn a_high_resolution_button_slices_in_its_gui_units() {
// Faithful 32x's widget/button: 400x40 texels, gui.scaling 200x20.
let n = faithful(
@@ -1357,6 +1411,40 @@ mod tests {
assert_quad(q[0], ([4.0, 4.0, 54.0, 54.0], [0.0, 0.0, 1.0, 1.0]));
}
#[test]
fn blit_sprite_nine_slices_a_sprite_whose_metadata_says_so() {
// Vanilla's tooltip/frame: 100x100, border 10, stretched centre.
let n = faithful(
r#"{"gui":{"scaling":{"type":"nine_slice","width":100,"height":100,"border":10,"stretch_inner":true}}}"#,
);
let mut batch = HudBatch::new([640, 480]);
draw_sprite(
&mut batch,
sprite(100, 100),
Some(n),
[10.0, 10.0, 160.0, 26.0],
u32::MAX,
);
let q = quads(&batch);
assert_eq!(q.len(), 9);
// The corners keep their 10 pixels instead of stretching with the box.
assert_quad(q[0], ([10.0, 10.0, 20.0, 20.0], [0.0, 0.0, 0.1, 0.1]));
assert_quad(q[5], ([160.0, 26.0, 170.0, 36.0], [0.9, 0.9, 1.0, 1.0]));
}
#[test]
fn blit_sprite_stretches_a_sprite_without_metadata() {
let mut batch = HudBatch::new([640, 480]);
draw_sprite(
&mut batch,
sprite(9, 9),
None,
[0.0, 0.0, 27.0, 18.0],
u32::MAX,
);
let q = quads(&batch);
assert_eq!(q.len(), 1);
assert_quad(q[0], ([0.0, 0.0, 27.0, 18.0], [0.0, 0.0, 1.0, 1.0]));
}
#[test]
fn borders_shrink_to_half_the_drawn_size() {
let n = NineSlice {
width: 20,
+91 -25
View File
@@ -1,12 +1,10 @@
//! Resident client world: streamed chunk columns plus the bookkeeping the game
//! loop needs to relight and remesh after edits.
use crate::chunk::{Chunk, MIN_Y, SECTIONS};
use crate::fast_hash::{FxHashMap, FxHashSet};
use horizon_core::random::LegacyRandom;
use horizon_world::registry::BlockStateId;
use std::{
collections::{HashMap, HashSet},
sync::Arc,
};
use std::sync::Arc;
/// 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
@@ -65,14 +63,14 @@ pub struct World {
/// Saved entities of chunks that just loaded, spawned by the game.
pub loaded_entities: Vec<((i32, i32), Vec<horizon_world::nbt::Tag>)>,
/// Chunks changed since the last save.
pub modified: HashSet<(i32, i32)>,
pub modified: FxHashSet<(i32, i32)>,
/// Block entities, and the order they tick in.
pub block_entities: crate::block_entities::BlockEntities,
/// Each chest's `ContainerOpenersCounter`: opener count and largest
/// interaction range.
pub chest_openers: HashMap<(i32, i32, i32), (i32, f64)>,
pub chest_openers: FxHashMap<(i32, i32, i32), (i32, f64)>,
/// `BaseContainerBlockEntity.name`: custom names of container block entities.
pub block_names: HashMap<(i32, i32, i32), String>,
pub block_names: FxHashMap<(i32, i32, i32), String>,
/// States (by id) whose writes are recorded in `watched_writes` (sculk
/// blocks, which need a block entity).
pub watched: Vec<bool>,
@@ -80,7 +78,7 @@ pub struct World {
pub watched_writes: Vec<((i32, i32, i32), u32, u32)>,
/// `ChunkAccess.inhabitedTime` of resident chunks: ticks a player spent
/// within spawning range (local difficulty).
pub inhabited: HashMap<(i32, i32), i64>,
pub inhabited: FxHashMap<(i32, i32), i64>,
pub game_time: i64,
pub day_time: i64,
pub weather: Weather,
@@ -177,13 +175,13 @@ impl World {
dirty_light: Default::default(),
light_edits: Vec::new(),
loaded_entities: Vec::new(),
modified: HashSet::new(),
modified: FxHashSet::default(),
block_entities: Default::default(),
chest_openers: HashMap::new(),
block_names: HashMap::new(),
chest_openers: FxHashMap::default(),
block_names: FxHashMap::default(),
watched: Vec::new(),
watched_writes: Vec::new(),
inhabited: HashMap::new(),
inhabited: FxHashMap::default(),
game_time: 0,
day_time: 1000,
weather: Weather::default(),
@@ -274,20 +272,40 @@ impl World {
log.push((x, y, z));
}
self.modified.insert((x >> 4, z >> 4));
// The mesher reads a 3x3x3 neighbourhood for AO, so a write marks
// the sections it can reach: this section, the one above/below
// only on a section face, and a neighbour column only on a chunk
// border (the diagonal only where both borders meet).
let sy = (y - MIN_Y) >> 4;
for dz in -1..=1 {
for dy in -1..=1 {
for dx in -1..=1 {
let (bx, by, bz) = (x + dx, y + dy, z + dz);
let s = (by - MIN_Y) >> 4;
if !(0..SECTIONS as i32).contains(&s) {
continue;
}
self.dirty_sections.insert((bx >> 4, s, bz >> 4));
}
let local_y = y & 15;
let mark = |dirty: &mut DirtySections, bx: i32, bz: i32| {
dirty.insert((bx >> 4, sy, bz >> 4));
if local_y == 0 && sy > 0 {
dirty.insert((bx >> 4, sy - 1, bz >> 4));
}
if local_y == 15 && sy + 1 < SECTIONS as i32 {
dirty.insert((bx >> 4, sy + 1, bz >> 4));
}
};
let (bx, bz) = (x & 15, z & 15);
mark(&mut self.dirty_sections, x, z);
if bx == 0 {
mark(&mut self.dirty_sections, x - 1, z);
} else if bx == 15 {
mark(&mut self.dirty_sections, x + 1, z);
}
if bz == 0 {
mark(&mut self.dirty_sections, x, z - 1);
} else if bz == 15 {
mark(&mut self.dirty_sections, x, z + 1);
}
if (bx == 0 || bx == 15) && (bz == 0 || bz == 15) {
mark(
&mut self.dirty_sections,
if bx == 0 { x - 1 } else { x + 1 },
if bz == 0 { z - 1 } else { z + 1 },
);
}
let _ = sy;
if self.chunks.get(&(x >> 4, z >> 4)).is_some_and(|c| c.lit) {
self.light_edits.push(((x, y, z), previous, state.0));
} else {
@@ -407,11 +425,59 @@ impl Default for HealthRules {
#[cfg(test)]
mod tests {
use super::World;
use crate::chunk::Chunk;
use super::{DirtySections, World};
use crate::chunk::{Chunk, MIN_Y, SECTIONS};
use horizon_world::registry::BlockStateId;
use std::sync::Arc;
/// Every block write marks the same section set the 27-cell loop did, but
/// only the distinct keys: one in the interior, a neighbour section on a
/// section face, a neighbour column on a chunk border and the diagonal at
/// a corner (eight keys at a corner on a section face).
#[test]
fn writes_mark_exactly_the_reachable_sections() {
let mut world = World::new(BlockStateId(0), 0);
world.chunks.insert((0, 0), Arc::new(Chunk::empty(0, 0)));
for (x, y, z) in [
(3, 70, 3),
(0, 70, 3),
(15, 70, 3),
(3, 70, 0),
(0, 70, 0),
(15, 70, 15),
(0, MIN_Y, 0),
(0, 80, 0),
(15, MIN_Y + SECTIONS as i32 * 16 - 1, 15),
] {
world.dirty_sections.clear();
world.set(x, y, z, BlockStateId(1)).unwrap();
let mut expected = DirtySections::default();
for dz in -1..=1 {
for dy in -1..=1 {
for dx in -1..=1 {
let (bx, by, bz) = (x + dx, y + dy, z + dz);
let s = (by - MIN_Y) >> 4;
if (0..SECTIONS as i32).contains(&s) {
expected.insert((bx >> 4, s, bz >> 4));
}
}
}
}
let mut got: Vec<_> = world.dirty_sections.columns().collect();
let mut want: Vec<_> = expected.columns().collect();
got.sort_unstable();
want.sort_unstable();
assert_eq!(got, want, "write at {x} {y} {z}");
if (x & 15, z & 15) == (3, 3) {
assert_eq!(world.dirty_sections.len(), 1);
}
if (x & 15, z & 15, y & 15) == (0, 0, 0) && y > MIN_Y {
// Four columns, each with the section below and this one.
assert_eq!(world.dirty_sections.len(), 8);
}
}
}
/// A box misses a state only where no section it reaches lists it, and
/// unloaded columns or heights past the world count as their air.
#[test]
@@ -0,0 +1,98 @@
//! Container menu titles for tools/test_container_title_parity.py: reads one
//! JSON case per line (see VanillaContainerTitleOracle) and prints the title
//! the server opens the container's menu with, as canonical NBT. A single
//! block entity is placed as the game places it (`block_entity_item::place`,
//! whose `CustomName` the game keeps in `World::block_names`), then named by
//! `containers::display_name`; a double chest's halves by
//! `containers::double_chest_name`.
//!
//! usage: container_title_fixture INPUT OUTPUT
use horizon_client::block_entity_item::place;
use horizon_client::block_entity_nbt::canonical;
use horizon_client::containers::{default_name_key, display_name, double_chest_name};
use horizon_client::save::text;
use horizon_world::nbt::{Tag, parse_snbt};
use serde_json::Value;
use std::fs;
/// ParityData.canon of a text component: sorted keys.
fn canon(tag: &Tag, out: &mut String) {
match tag {
Tag::Compound(map) => {
let mut keys: Vec<_> = map.iter().map(|(k, v)| (k.to_string_lossy(), v)).collect();
keys.sort_by(|a, b| a.0.cmp(&b.0));
out.push('{');
for (i, (k, v)) in keys.into_iter().enumerate() {
if i > 0 {
out.push(',');
}
out.push_str(&k);
out.push(':');
canon(v, out);
}
out.push('}');
}
Tag::List(items) => {
out.push('[');
for (i, item) in items.iter().enumerate() {
if i > 0 {
out.push(',');
}
canon(item, out);
}
out.push(']');
}
other => out.push_str(&horizon_client::item_data::canon_tag(other)),
}
}
fn snbt(value: &Value) -> Option<Tag> {
parse_snbt(value.as_str()?).ok().map(|(tag, _)| tag)
}
/// The custom name the game keeps for a block entity saved as `value`.
fn custom_name(value: &Value) -> Option<Option<String>> {
let saved = canonical(&snbt(value)?)?;
Some(text(saved.get("CustomName")))
}
fn case(line: &str) -> Option<String> {
let case: Value = serde_json::from_str(line).ok()?;
let title = if let Some(first) = case.get("first") {
let first = custom_name(first)?;
let second = custom_name(&case["second"])?;
double_chest_name(first.as_deref(), second.as_deref())
} else {
let saved = canonical(&snbt(&case["nbt"])?)?;
// BlockItem.updateBlockEntityComponents, as Game::apply_placed_item runs it.
let placed = match case.get("patch") {
Some(p) => match snbt(p)? {
Tag::Compound(patch) => place(&saved, &patch, true),
_ => return None,
},
None => saved,
};
let key = default_name_key(case["block"].as_str()?)?;
display_name(text(placed.get("CustomName")).as_deref(), key)
};
let mut out = String::new();
canon(&title, &mut out);
Some(out)
}
pub fn main() -> horizon_client::Result<()> {
let args: Vec<String> = crate::args().collect();
let input = fs::read_to_string(
args.get(1)
.ok_or("usage: container_title_fixture INPUT OUTPUT")?,
)?;
let lines: Vec<String> = input
.lines()
.map(|line| case(line).unwrap_or_else(|| "error".to_owned()))
.collect();
fs::write(
args.get(2).ok_or("missing OUTPUT")?,
lines.join("\n") + "\n",
)?;
Ok(())
}
@@ -41,7 +41,7 @@ pub fn main() -> horizon_client::Result<()> {
let mut arrays: HashMap<(i32, i32), Arrays> = HashMap::new();
for entry in std::fs::read_dir(dim.join("region"))? {
let path = entry?.path();
let mut region = RegionFile::open(&path)?;
let region = RegionFile::open(&path)?;
let positions: Vec<ChunkPos> = region.chunks().map(|(p, _)| p).collect();
for p in positions {
let Some(record) = region.read(p)? else {
@@ -27,6 +27,7 @@ mod chat_screen_fixture;
mod cloud_fixture;
mod command_parse_fixture;
mod container_screen_probe;
mod container_title_fixture;
mod creative_mining_probe;
mod creative_search_fixture;
mod cushion_placement_probe;
@@ -118,6 +119,7 @@ probes! {
cloud_fixture,
command_parse_fixture,
container_screen_probe,
container_title_fixture,
creative_search_fixture,
emitter_particles_probe,
enchanting_fixture,
@@ -126,6 +126,7 @@ pub fn main() -> horizon_client::Result<()> {
.collect();
let mut screen = PackScreen::new(
&m,
Text::translate(c["title"].as_str().unwrap_or("resourcePack.title"), &[]),
int(&c["width"]),
int(&c["height"]),
c["inWorld"] == true,
+27 -1
View File
@@ -552,7 +552,14 @@ impl Audio {
Self::with_output(root, false)
}
fn with_output(root: &Path, output: bool) -> Self {
let registry = SoundRegistry::load(root);
// A server sends sound ids to its clients but plays nothing, so it
// never reads the registry (a client with no output still does, for
// subtitles).
let registry = if output {
SoundRegistry::load(root)
} else {
SoundRegistry::default()
};
if output && registry.is_empty() {
// The game then plays nothing on a working output (#1035, #1076).
log::warn!(
@@ -920,6 +927,25 @@ mod tests {
}
}
/// The server role, not the output flag, decides whether the sound
/// registry is read: `Audio::silent` is only a server's.
#[test]
fn a_server_does_not_read_the_sound_registry() {
let root =
std::env::temp_dir().join(format!("horizon-silent-audio-test-{}", std::process::id()));
let _ = fs::remove_dir_all(&root);
fs::create_dir_all(root.join("assets/minecraft/sounds/gui")).unwrap();
fs::write(
root.join("assets/minecraft/sounds.json"),
br#"{"ui.button.click":{"sounds":["gui/button"]}}"#,
)
.unwrap();
fs::write(root.join("assets/minecraft/sounds/gui/button.ogg"), b"").unwrap();
assert_eq!(SoundRegistry::load(&root).names().count(), 1);
assert!(Audio::silent(&root).event_names().is_empty());
let _ = fs::remove_dir_all(&root);
}
#[test]
fn decoded_sounds_stay_within_the_budget_least_recently_used_out() {
let mut cache = SoundCache::default();
@@ -425,7 +425,7 @@ pub const COMMANDS: &[Cmd] = &[
&[BOX[0], BOX[1], BOX[2], BOX[3], BOX[4], BOX[5], p("block", Ty::Str, "block state")], mcp: true, local: false),
cmd!("container", World, "container X Y Z [ITEM COUNT]", "list a container's items, or add items to it", "", ["horizon-ctl container 3 64 1", "horizon-ctl container 3 64 1 diamond 3"],
&[BLOCK_XYZ[0], BLOCK_XYZ[1], BLOCK_XYZ[2], opt("item", Ty::Str, "item to add"), opt("count", Ty::Int, "how many")], mcp: true, local: false),
cmd!("render-distance", World, "render-distance N", "chunks, 2..64", "", ["horizon-ctl render-distance 8"], &[p("chunks", Ty::Int, "render distance")], mcp: false, local: false),
cmd!("render-distance", World, "render-distance N", "chunks, 2..128", "", ["horizon-ctl render-distance 8"], &[p("chunks", Ty::Int, "render distance")], mcp: false, local: false),
cmd!("wait", World, "wait TICKS", "wait until TICKS game ticks have passed (20 per second)",
"Keys and mouse buttons held with key ... down or mouse ... down stay held while it waits. Release them with key ... up, release or stop.",
["horizon-ctl wait 40", "horizon-ctl key w down && horizon-ctl wait 40 && horizon-ctl key w up"],
+170 -36
View File
@@ -1175,11 +1175,16 @@ fn matches_all(ingredients: &[Ingredient], stacks: &[&ItemStack]) -> bool {
augment(i, ingredients, stacks, &mut owner, &mut seen)
})
}
/// Recipe files are independent reads: use the loader's budget, leaving the
/// caller its own core, and no more than eight workers.
fn recipe_threads() -> usize {
std::thread::available_parallelism()
.map_or(1, |n| n.get().saturating_sub(1))
.clamp(1, 8)
}
impl Recipes {
pub fn load(root: &Path, items: &Items) -> Result<Self> {
let mut crafting = Vec::new();
let mut cooking = Vec::new();
let mut meta = HashMap::new();
let json_files = |dir: &Path| -> Result<Vec<PathBuf>> {
let mut files: Vec<_> = fs::read_dir(dir)?
.flatten()
@@ -1204,15 +1209,120 @@ impl Recipes {
);
}
}
for (prefix, path) in files {
let (crafting, cooking, meta) = Self::load_files(&files, items, recipe_threads())?;
let facts = items
.items
.iter()
.map(|(id, info)| {
(
id.clone(),
Facts {
dye: info.dye.as_deref().and_then(|d| {
crate::block_entity_render::DYES
.iter()
.position(|(name, _)| *name == d)
}),
max_damage: info.max_damage,
max_stack: info.max_stack,
remainder: info.crafting_remainder.as_deref().map(Arc::from),
},
)
})
.collect();
let curses = crate::items::load_tags(root, "enchantment")?
.remove("minecraft:curse")
.unwrap_or_default();
Ok(Self {
crafting,
cooking,
meta,
facts,
curses,
cook_index: Default::default(),
})
}
/// Parse a sorted file list in `threads` contiguous ranges. Ranges are
/// concatenated in file order, so the parallel result equals the serial one.
fn load_files(
files: &[(String, PathBuf)],
items: &Items,
threads: usize,
) -> std::result::Result<
(
Vec<(String, Recipe)>,
Vec<Cooking>,
HashMap<String, (String, String)>,
),
String,
> {
let threads = threads.clamp(1, files.len().max(1));
if threads == 1 {
let mut warnings = Vec::new();
let out = Self::load_range(files, items, 0..files.len(), &mut warnings)?;
for (name, error) in warnings {
log::warn!("Horizon: recipe {name} not loaded: {error}");
}
return Ok(out);
}
let chunk = files.len().div_ceil(threads);
let parts = std::thread::scope(|scope| {
let handles: Vec<_> = (0..threads)
.map(|index| {
let range =
(index * chunk).min(files.len())..((index + 1) * chunk).min(files.len());
scope.spawn(move || {
let mut warnings = Vec::new();
let result = Self::load_range(files, items, range, &mut warnings);
(result, warnings)
})
})
.collect();
handles
.into_iter()
.map(|handle| handle.join().unwrap())
.collect::<Vec<_>>()
});
let mut crafting = Vec::new();
let mut cooking = Vec::new();
let mut meta = HashMap::new();
for (part, warnings) in parts {
for (name, error) in warnings {
log::warn!("Horizon: recipe {name} not loaded: {error}");
}
let (part_crafting, part_cooking, part_meta) = part?;
crafting.extend(part_crafting);
cooking.extend(part_cooking);
meta.extend(part_meta);
}
Ok((crafting, cooking, meta))
}
fn load_range(
files: &[(String, PathBuf)],
items: &Items,
range: std::ops::Range<usize>,
warnings: &mut Vec<(String, serde_json::Error)>,
) -> std::result::Result<
(
Vec<(String, Recipe)>,
Vec<Cooking>,
HashMap<String, (String, String)>,
),
String,
> {
let mut crafting = Vec::new();
let mut cooking = Vec::new();
let mut meta = HashMap::new();
for (prefix, path) in &files[range] {
let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("");
let name = format!("{prefix}{stem}");
// Vanilla's RecipeManager logs a file that does not parse and
// goes on ("Couldn't parse data file").
let v: Value = match serde_json::from_slice(&fs::read(&path)?) {
let bytes = fs::read(path).map_err(|e| e.to_string())?;
let v: Value = match serde_json::from_slice(&bytes) {
Ok(v) => v,
Err(e) => {
log::warn!("Horizon: recipe {name} not loaded: {e}");
warnings.push((name, e));
continue;
}
};
@@ -1397,37 +1507,9 @@ impl Recipes {
crafting.push((name, recipe));
}
}
let facts = items
.items
.iter()
.map(|(id, info)| {
(
id.clone(),
Facts {
dye: info.dye.as_deref().and_then(|d| {
crate::block_entity_render::DYES
.iter()
.position(|(name, _)| *name == d)
}),
max_damage: info.max_damage,
max_stack: info.max_stack,
remainder: info.crafting_remainder.as_deref().map(Arc::from),
},
)
})
.collect();
let curses = crate::items::load_tags(root, "enchantment")?
.remove("minecraft:curse")
.unwrap_or_default();
Ok(Self {
crafting,
cooking,
meta,
facts,
curses,
cook_index: Default::default(),
})
Ok((crafting, cooking, meta))
}
/// Match a `size`x`size` grid (row-major) against the crafting recipes.
pub fn craft(&self, grid: &[Option<ItemStack>], size: usize) -> Option<ItemStack> {
self.find(grid, size).map(|(_, result)| result)
@@ -1799,4 +1881,56 @@ mod tests {
// One red dye on an undyed item gives the dye's own colour.
assert_eq!(apply_dyes(None, &[14]), 0xB02E26);
}
#[test]
fn parallel_recipe_load_matches_serial_in_file_order() {
let dir = std::env::temp_dir().join(format!("horizon-recipes-test-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let recipes = dir.join("data/minecraft/recipe");
std::fs::create_dir_all(&recipes).unwrap();
std::fs::create_dir_all(dir.join("data/minecraft/loot_table")).unwrap();
std::fs::write(dir.join("items.json"), "{}").unwrap();
let mut files = Vec::new();
for i in 0..25 {
let path = recipes.join(format!("{i:03}.json"));
let text = if i % 5 == 0 {
format!(
r#"{{"type":"minecraft:smelting","ingredient":"minecraft:item_{i}","result":{{"id":"minecraft:out_{i}"}},"cookingtime":200}}"#
)
} else {
format!(
r#"{{"type":"minecraft:crafting_shapeless","ingredients":["minecraft:item_{i}"],"result":{{"id":"minecraft:out_{i}"}},"category":"misc","group":"g{i}"}}"#
)
};
std::fs::write(&path, text).unwrap();
files.push((String::new(), path));
}
// A file that does not parse is skipped in both, at its place in order.
let bad = recipes.join("zm-bad.json");
std::fs::write(&bad, "{").unwrap();
files.push((String::new(), bad));
let items = Items::catalog(&dir).unwrap();
let serial = Recipes::load_files(&files, &items, 1).unwrap();
let parallel = Recipes::load_files(&files, &items, 4).unwrap();
let expected: Vec<String> = (0..25)
.filter(|i| i % 5 != 0)
.map(|i| format!("{i:03}"))
.collect();
assert_eq!(
serial
.0
.iter()
.map(|(name, _)| name.clone())
.collect::<Vec<_>>(),
expected
);
assert_eq!(format!("{:?}", serial.0), format!("{:?}", parallel.0));
assert_eq!(format!("{:?}", serial.1), format!("{:?}", parallel.1));
let mut serial_meta: Vec<_> = serial.2.iter().collect();
let mut parallel_meta: Vec<_> = parallel.2.iter().collect();
serial_meta.sort();
parallel_meta.sort();
assert_eq!(serial_meta, parallel_meta);
let _ = std::fs::remove_dir_all(&dir);
}
}
+126 -25
View File
@@ -37,9 +37,11 @@ pub enum Kind {
columns: i32,
button_width: i32,
},
/// `DialogListDialog`: its dialogs as sent (ids, a tag or inline dialogs).
/// `DialogListDialog`: its dialogs as sent (ids, a tag or inline dialogs),
/// and one button per dialog they resolve to (`set_list`).
DialogList {
dialogs: Tag,
entries: Vec<Button>,
exit: Option<Button>,
columns: i32,
button_width: i32,
@@ -325,29 +327,36 @@ impl Control {
fn parse(t: &Tag) -> Option<Self> {
let label = || t.get("label").map(text);
Some(match path(&string(t.get("type"))?) {
"text" => Self::Text {
width: int(t, "width", 200),
label: label()?,
label_visible: boolean(t, "label_visible", true),
initial: string(t.get("initial")).unwrap_or_default(),
max_length: int(t, "max_length", 32),
multiline: t.get("multiline").map(|m| {
let get = |key| number(m.get(key)).map(|v| v as i32);
(get("max_lines"), get("height"))
}),
},
"text" => {
let initial = string(t.get("initial")).unwrap_or_default();
let max_length = int(t, "max_length", 32);
// TextInput: the default text may not be longer than the length.
if initial.encode_utf16().count() > max_length.max(0) as usize {
return None;
}
Self::Text {
width: int(t, "width", 200),
label: label()?,
label_visible: boolean(t, "label_visible", true),
initial,
max_length,
multiline: t.get("multiline").map(|m| {
let get = |key| number(m.get(key)).map(|v| v as i32);
(get("max_lines"), get("height"))
}),
}
}
"boolean" => Self::Boolean {
label: label()?,
initial: boolean(t, "initial", false),
on_true: string(t.get("on_true")).unwrap_or_else(|| "true".into()),
on_false: string(t.get("on_false")).unwrap_or_else(|| "false".into()),
},
"single_option" => Self::SingleOption {
width: int(t, "width", 200),
"single_option" => {
// SingleOptionInput.Entry.CODEC: a full entry or just its id.
options: list(t.get("options"))
let options: Vec<_> = list(t.get("options"))
.iter()
.filter_map(|o| match o {
.map(|o| match o {
Tag::String(id) => Some((id.to_string_lossy(), None, false)),
o => Some((
string(o.get("id"))?,
@@ -355,10 +364,18 @@ impl Control {
boolean(o, "initial", false),
)),
})
.collect(),
label: label()?,
label_visible: boolean(t, "label_visible", true),
},
.collect::<Option<_>>()?;
// A non-empty list with at most one initial entry.
if options.is_empty() || options.iter().filter(|o| o.2).count() > 1 {
return None;
}
Self::SingleOption {
width: int(t, "width", 200),
options,
label: label()?,
label_visible: boolean(t, "label_visible", true),
}
}
"number_range" => {
let float = |key| number(t.get(key)).map(|v| v as f32);
Self::NumberRange {
@@ -395,6 +412,44 @@ impl Control {
)),
}
}
/// The text on a cycle button or slider holding `value`
/// (`CycleButton.createFullName`, `NumberRangeInput.computeLabel`).
pub fn message(&self, value: &InputValue) -> Option<Text> {
match (self, value) {
(
Self::SingleOption {
options,
label,
label_visible,
..
},
InputValue::Option(i),
) => {
let (id, display, _) = options.get(*i)?;
let shown = display.clone().unwrap_or_else(|| Text::literal(id));
Some(if *label_visible {
Text::translate("options.generic_value", &[label.clone(), shown])
} else {
shown
})
}
(
Self::NumberRange {
label,
label_format,
..
},
InputValue::Slider(_),
) => {
let shown = number_string(self.scaled(value)?);
Some(Text::translate(
label_format,
&[label.clone(), Text::literal(shown)],
))
}
_ => None,
}
}
/// `ValueGetter.asTemplateSubstitution`.
pub fn substitution(&self, value: &InputValue) -> String {
match (self, value) {
@@ -536,6 +591,7 @@ impl Dialog {
},
"dialog_list" => Kind::DialogList {
dialogs: t.get("dialogs")?.clone(),
entries: Vec::new(),
exit: button("exit_action"),
columns: int(t, "columns", 2),
button_width: int(t, "button_width", 150),
@@ -543,20 +599,21 @@ impl Dialog {
_ => return None,
};
// DialogBody.COMPACT_LIST_CODEC: one body, or a list of them.
// A body or input it cannot read fails the whole dialog.
let body = match t.get("body") {
Some(Tag::List(items)) => items.iter().filter_map(Body::parse).collect(),
Some(one) => Body::parse(one).into_iter().collect(),
Some(Tag::List(items)) => items.iter().map(Body::parse).collect::<Option<_>>()?,
Some(one) => vec![Body::parse(one)?],
None => Vec::new(),
};
let inputs = list(t.get("inputs"))
.iter()
.filter_map(|i| {
.map(|i| {
Some(Input {
key: string(i.get("key"))?,
control: Control::parse(i)?,
})
})
.collect();
.collect::<Option<_>>()?;
Some(Self {
kind,
title: text(t.get("title")?),
@@ -588,7 +645,51 @@ impl Dialog {
Kind::Notice(b) => vec![b],
Kind::Confirmation { yes, no } => vec![yes, no],
Kind::MultiAction { actions, exit, .. } => actions.iter().chain(exit).collect(),
Kind::ServerLinks { exit, .. } | Kind::DialogList { exit, .. } => exit.iter().collect(),
Kind::ServerLinks { exit, .. } => exit.iter().collect(),
Kind::DialogList { entries, exit, .. } => entries.iter().chain(exit).collect(),
}
}
/// `DialogListDialogScreen.createListActions`: a button for each of `dialogs`
/// (what the list names), labelled by its external title, opening it.
pub fn set_list(&mut self, dialogs: &[Tag]) {
let Kind::DialogList {
entries,
button_width,
..
} = &mut self.kind
else {
return;
};
*entries = dialogs
.iter()
.filter_map(|tag| {
let sub = Dialog::parse(tag)?;
Some(Button {
label: sub.external_title.unwrap_or(sub.title),
tooltip: None,
width: *button_width,
action: Some(Action::Static(Click::ShowDialog(tag.clone()))),
})
})
.collect();
}
/// The dialogs a `dialog_list`'s `dialogs` names: the entries of a `#tag`
/// (by `tag`), registry ids (by `registry`) and dialogs written inline.
pub fn list_entries(
dialogs: &Tag,
tag: &dyn Fn(&str) -> Vec<Tag>,
registry: &dyn Fn(&str) -> Option<Tag>,
) -> Vec<Tag> {
let one = |t: &Tag| match t {
Tag::String(id) => registry(&id.to_string_lossy()),
inline => Some(inline.clone()),
};
match dialogs {
Tag::String(id) if id.to_string_lossy().starts_with('#') => {
tag(&id.to_string_lossy()[1..])
}
Tag::List(items) => items.iter().filter_map(one).collect(),
single => one(single).into_iter().collect(),
}
}
/// What `status` reports of it.
+125 -55
View File
@@ -31,6 +31,44 @@ pub enum Part {
Check { input: usize, rect: [f32; 4] },
/// A button of `Dialog::buttons`.
Button { index: usize, rect: [f32; 4] },
/// A single option input's cycle button.
Cycle { input: usize, rect: [f32; 4] },
/// A number range input's slider.
Slider { input: usize, rect: [f32; 4] },
/// An item body's widget; `body` indexes `Dialog::body`.
Item { body: usize, rect: [f32; 4] },
}
impl Part {
/// The part moved by `(dx, dy)`.
fn moved(self, dx: f32, dy: f32) -> Self {
match self {
Self::Line(t, x, y) => Self::Line(t, x + dx, y + dy),
Self::Field { input, rect } => Self::Field {
input,
rect: shift(rect, dx, dy),
},
Self::Check { input, rect } => Self::Check {
input,
rect: shift(rect, dx, dy),
},
Self::Button { index, rect } => Self::Button {
index,
rect: shift(rect, dx, dy),
},
Self::Cycle { input, rect } => Self::Cycle {
input,
rect: shift(rect, dx, dy),
},
Self::Slider { input, rect } => Self::Slider {
input,
rect: shift(rect, dx, dy),
},
Self::Item { body, rect } => Self::Item {
body,
rect: shift(rect, dx, dy),
},
}
}
}
#[derive(Clone, Debug)]
@@ -55,21 +93,7 @@ struct Element {
impl Element {
/// Its parts moved to `(x, y)`.
fn at(self, x: f32, y: f32) -> impl Iterator<Item = Part> {
self.parts.into_iter().map(move |p| match p {
Part::Line(t, px, py) => Part::Line(t, px + x, py + y),
Part::Field { input, rect } => Part::Field {
input,
rect: shift(rect, x, y),
},
Part::Check { input, rect } => Part::Check {
input,
rect: shift(rect, x, y),
},
Part::Button { index, rect } => Part::Button {
index,
rect: shift(rect, x, y),
},
})
self.parts.into_iter().map(move |p| p.moved(x, y))
}
}
fn shift([x, y, w, h]: [f32; 4], dx: f32, dy: f32) -> [f32; 4] {
@@ -100,9 +124,43 @@ fn message(font: &Font, contents: &Text, width: f32) -> Element {
}
}
/// `InputControlHandlers`: the control for input `i`, if Horizon has it.
fn input(font: &Font, i: usize, control: &Control) -> Option<Element> {
Some(match control {
/// `DialogBodyHandlers.ItemHandler`: the item widget, with its description 2
/// to the right of it, both centred on each other vertically.
fn item(
font: &Font,
body: usize,
description: Option<&(Text, i32)>,
(w, h): (f32, f32),
) -> Element {
let widget = |rect_h: f32, y: f32| Part::Item {
body,
rect: [0.0, y, w, rect_h],
};
let Some((contents, width)) = description else {
return Element {
w,
h,
parts: vec![widget(h, 0.0)],
};
};
let text = message(font, contents, *width as f32);
let height = h.max(text.h);
let mut parts = vec![widget(h, centre(height, h))];
parts.extend(
text.parts
.into_iter()
.map(|p| p.moved(w + 2.0, centre(height, text.h))),
);
Element {
w: w + 2.0 + text.w,
h: height,
parts,
}
}
/// `InputControlHandlers`: the control for input `i`.
fn input(font: &Font, i: usize, control: &Control) -> Element {
match control {
Control::Text {
width,
label,
@@ -111,14 +169,14 @@ fn input(font: &Font, i: usize, control: &Control) -> Option<Element> {
} => {
let w = *width as f32;
if !label_visible {
return Some(Element {
return Element {
w,
h: 20.0,
parts: vec![Part::Field {
input: i,
rect: [0.0, 0.0, w, 20.0],
}],
});
};
}
// CommonLayouts.labeledElement: the label, 4 below it the box,
// both on the left.
@@ -145,8 +203,25 @@ fn input(font: &Font, i: usize, control: &Control) -> Option<Element> {
}],
}
}
Control::SingleOption { .. } | Control::NumberRange { .. } => return None,
})
// CycleButton and AbstractSliderButton: one 20 high button whose
// message holds the label.
Control::SingleOption { width, .. } => Element {
w: *width as f32,
h: 20.0,
parts: vec![Part::Cycle {
input: i,
rect: [0.0, 0.0, *width as f32, 20.0],
}],
},
Control::NumberRange { width, .. } => Element {
w: *width as f32,
h: 20.0,
parts: vec![Part::Slider {
input: i,
rect: [0.0, 0.0, *width as f32, 20.0],
}],
},
}
}
/// `DialogScreen.packControlsIntoColumns`: full rows in a grid 2 apart, what
@@ -210,9 +285,20 @@ impl Layout {
let mut elements: Vec<Element> = dialog
.body
.iter()
.filter_map(|b| match b {
Body::Message { contents, width } => Some(message(font, contents, *width as f32)),
Body::Item { .. } => None,
.enumerate()
.map(|(i, b)| match b {
Body::Message { contents, width } => message(font, contents, *width as f32),
Body::Item {
description,
width,
height,
..
} => item(
font,
i,
description.as_ref(),
(*width as f32, *height as f32),
),
})
.collect();
elements.extend(
@@ -220,26 +306,24 @@ impl Layout {
.inputs
.iter()
.enumerate()
.filter_map(|(i, input_)| input(font, i, &input_.control)),
.map(|(i, input_)| input(font, i, &input_.control)),
);
let buttons = dialog.buttons();
let (listed, footer_buttons, footer_h) = match &dialog.kind {
Kind::MultiAction {
actions,
exit,
columns: n,
exit, columns: n, ..
}
| Kind::DialogList {
exit, columns: n, ..
} => {
let widths: Vec<f32> = actions.iter().map(|b| b.width as f32).collect();
let listed = buttons.len() - usize::from(exit.is_some());
let widths: Vec<f32> = buttons[..listed].iter().map(|b| b.width as f32).collect();
elements.push(columns(&widths, *n as usize, 0));
// ButtonListDialogScreen: the exit button in the footer, else a 5 high one.
let footer = exit.as_ref().map(|_| actions.len()).into_iter().collect();
(
actions.len(),
footer,
if exit.is_some() { BAR } else { 5.0 },
)
let footer = exit.as_ref().map(|_| listed).into_iter().collect();
(listed, footer, if exit.is_some() { BAR } else { 5.0 })
}
Kind::ServerLinks { exit, .. } | Kind::DialogList { exit, .. } => {
Kind::ServerLinks { exit, .. } => {
let footer = exit.as_ref().map(|_| 0).into_iter().collect();
(0, footer, if exit.is_some() { BAR } else { 5.0 })
}
@@ -303,24 +387,10 @@ impl Layout {
/// whether it is in the body (clipped to its area).
pub fn placed(&self, scroll: f32) -> impl Iterator<Item = (Part, bool)> + '_ {
let dy = self.area[1] - scroll;
let body = self.body.iter().map(move |p| {
let p = match p.clone() {
Part::Line(t, x, y) => Part::Line(t, x, y + dy),
Part::Field { input, rect } => Part::Field {
input,
rect: shift(rect, 0.0, dy),
},
Part::Check { input, rect } => Part::Check {
input,
rect: shift(rect, 0.0, dy),
},
Part::Button { index, rect } => Part::Button {
index,
rect: shift(rect, 0.0, dy),
},
};
(p, true)
});
let body = self
.body
.iter()
.map(move |p| (p.clone().moved(0.0, dy), true));
body.chain(self.footer.iter().map(|p| (p.clone(), false)))
}
}
+159
View File
@@ -0,0 +1,159 @@
//! End gateway travel for the entities that step into one:
//! `EndGatewayBlock.entityInside` (the gateway takes the entity in and starts
//! its 40 tick cooldown), `Entity.setAsInsidePortal` and `Entity.handlePortal`
//! (the entity goes through on the next tick), and
//! `TheEndGatewayBlockEntity.getPortalPosition` (where it lands).
use crate::nether_portal::{ENTITY_DELAY, PortalState};
use horizon_core::{geometry::Vec3, position::BlockPos};
/// The End gateway travel of one entity.
#[derive(Clone, Copy, Debug, Default)]
pub struct Traveller {
/// The entity's portal process and cooldown (`Entity.portalProcess`).
state: PortalState,
}
impl Traveller {
/// `Entity.setAsInsidePortal`: the entity is in the gateway and goes
/// through it on the next tick.
pub fn enter(&mut self, gateway: BlockPos) {
self.state.set_inside(gateway, ENTITY_DELAY);
}
/// `Entity.handlePortal`: the entity goes through the gateway it entered
/// (the End gateway has no transition time), or stays where it is.
pub fn travel(&mut self, allowed: bool) -> Option<BlockPos> {
self.state.handle(allowed, 0, ENTITY_DELAY)
}
/// Whether the entity's travel is over: it has no portal process left and
/// its portal cooldown has run out.
pub fn is_done(&self) -> bool {
self.state.cooldown == 0 && self.state.process.is_none()
}
/// The entity's portal cooldown, the ticks it stays out of a portal.
pub fn cooldown(&self) -> i32 {
self.state.cooldown
}
}
/// The End gateway block an entity's box swept through
/// (`Entity.checkInsideBlocks`, which calls `EndGatewayBlock.entityInside`).
pub fn swept_gateway(
from: Vec3,
to: Vec3,
width: f32,
height: f32,
mut is_gateway: impl FnMut(BlockPos) -> bool,
) -> Option<BlockPos> {
let half = f64::from(width) / 2.0;
let bounds = horizon_core::geometry::Aabb::new(
Vec3::new(to.x - half, to.y, to.z - half),
Vec3::new(to.x + half, to.y + f64::from(height), to.z + half),
)
.deflate(Vec3::new(1.0e-5, 1.0e-5, 1.0e-5));
let mut found = None;
let _ =
horizon_core::geometry::sweep_intersected_blocks(from, to, bounds, 1_000_000, |p, _| {
if is_gateway(p) {
found = Some(p);
return false;
}
true
});
found
}
/// The bottom centre of a block (`Vec3.atBottomCenterOf`): where a traveller
/// lands at a gateway's exit.
pub fn bottom_center(p: [i32; 3]) -> Vec3 {
Vec3::new(
f64::from(p[0]) + 0.5,
f64::from(p[1]),
f64::from(p[2]) + 0.5,
)
}
#[cfg(test)]
mod tests {
use super::*;
/// The gateway of the issue: a block at [702, 100, 0] in the End.
const GATEWAY: [i32; 3] = [702, 100, 0];
fn at(x: f64, y: f64, z: f64) -> Vec3 {
Vec3::new(x, y, z)
}
fn is_gateway(p: BlockPos) -> bool {
[p.x, p.y, p.z] == GATEWAY
}
#[test]
fn an_item_and_a_mob_in_a_gateway_are_found_in_it() {
// A dropped item, 0.25 wide, standing in the gateway block.
let item = at(702.5, 100.5, 0.5);
assert_eq!(
swept_gateway(item, item, 0.25, 0.25, is_gateway),
Some(BlockPos::new(702, 100, 0))
);
// A cow, 0.9 wide and 1.4 tall, in the same block.
let cow = at(702.5, 100.0, 0.5);
assert_eq!(
swept_gateway(cow, cow, 0.9, 1.4, is_gateway),
Some(BlockPos::new(702, 100, 0))
);
}
#[test]
fn an_entity_out_of_the_gateway_is_not_in_it() {
// Two blocks above the gateway: nothing to travel through.
let above = at(702.5, 102.5, 0.5);
assert_eq!(swept_gateway(above, above, 0.25, 0.25, is_gateway), None);
// Beside the gateway, outside its block.
let beside = at(704.5, 100.5, 0.5);
assert_eq!(swept_gateway(beside, beside, 0.9, 1.4, is_gateway), None);
}
#[test]
fn an_entity_falling_through_the_gateway_goes_through_it() {
// A cow falling past the gateway block passes through it
// (Entity.checkInsideBlocks visits the blocks along the way).
let from = at(702.5, 103.0, 0.5);
let to = at(702.5, 99.0, 0.5);
assert_eq!(
swept_gateway(from, to, 0.9, 1.4, is_gateway),
Some(BlockPos::new(702, 100, 0))
);
}
#[test]
fn an_entity_goes_through_the_gateway_on_the_tick_after_it_entered() {
let gateway = BlockPos::new(702, 100, 0);
let mut traveller = Traveller::default();
// Nothing happens while the entity is outside the gateway.
assert_eq!(traveller.travel(true), None);
// It enters the gateway, and goes through on the next tick.
traveller.enter(gateway);
assert_eq!(traveller.travel(true), Some(gateway));
// Its portal cooldown keeps it from going back at once.
assert_eq!(traveller.cooldown(), ENTITY_DELAY);
assert!(!traveller.is_done());
}
#[test]
fn an_entity_that_may_not_use_portals_stays_in_the_gateway() {
let gateway = BlockPos::new(702, 100, 0);
let mut traveller = Traveller::default();
traveller.enter(gateway);
// A mob that cannot use a portal does not go through.
assert_eq!(traveller.travel(false), None);
}
#[test]
fn a_traveller_lands_at_the_bottom_centre_of_the_exit() {
// TheEndGatewayBlockEntity.getPortalPosition: the exit's bottom
// centre, the same spot the player arrives at.
assert_eq!(bottom_center([8, 100, 0]), at(8.5, 100.0, 0.5));
}
}
File diff suppressed because it is too large. Load diff
+94 -6
View File
@@ -1,8 +1,11 @@
//! Brushing suspicious sand and gravel: `BrushItem.onUseTick` and
//! `BrushableBlockEntity`. The block's loot table stays in its saved block
//! entity (like a decorated pot's) until the first counted stroke rolls it.
use super::{Game, Hit, decorated_pot, spill, view_vector};
use super::{Game, Hit, decorated_pot, view_vector};
use horizon_client::{
block_entity_render::vertex_light,
containers::BlockEntity,
mesher::Vertex,
particles::{ParticleOptions, ParticleType},
save,
};
@@ -114,6 +117,20 @@ impl Game<'_> {
let time = self.world.game_time;
let mut brush = self.brushing.remove(&pos).unwrap_or_default();
brush.hit_face.get_or_insert(face);
let direction = match brush.hit_face.unwrap() {
[0, -1, 0] => 0,
[0, 1, 0] => 1,
[0, 0, -1] => 2,
[0, 0, 1] => 3,
[-1, 0, 0] => 4,
[1, 0, 0] => 5,
_ => return false,
};
if let Some(BlockEntity::Brushable(b)) =
self.world.block_entities.get_mut(&(pos[0], pos[1], pos[2]))
{
b.hit_direction = Some(direction);
}
brush.resets_at = time + 40;
if time < brush.cooldown_ends_at {
self.brushing.insert(pos, brush);
@@ -139,12 +156,18 @@ impl Game<'_> {
/// `BrushableBlockEntity.unpackLootTable`: the table's roll becomes the
/// block entity's item.
fn unpack_brush_loot(&mut self, pos: [i32; 3]) {
let Some(mut tag) = spill::saved_entity(&self.world, pos) else {
let key = (pos[0], pos[1], pos[2]);
let Some(BlockEntity::Brushable(b)) = self.world.block_entities.get(&key) else {
return;
};
if tag.get("LootTable").is_none() {
if b.loot_table.is_none() {
return;
}
let mut tag = save::encode_block_entity(
key,
&BlockEntity::Brushable(b.clone()),
"minecraft:brushable_block",
);
let loot = horizon_client::structure_blocks::loot_data(&self.options.resources);
decorated_pot::unpack_loot(
&mut tag,
@@ -153,7 +176,12 @@ impl Game<'_> {
&mut self.rand_sequences,
self.level.seed,
);
spill::put_saved_entity(&mut self.world, pos, tag);
if let Some(BlockEntity::Brushable(b)) = self.world.block_entities.get_mut(&key) {
b.loot_table = tag.get("LootTable").cloned();
b.loot_seed = tag.get("LootTableSeed").cloned();
b.item = tag.get("item").and_then(save::item_from_nbt);
}
self.world.modified.insert((pos[0] >> 4, pos[2] >> 4));
}
/// `BrushableBlockEntity.brushingCompleted`: the item drops out of the
@@ -166,8 +194,10 @@ impl Game<'_> {
_ => "minecraft:air",
};
// dropContent: the item at the middle of the block beside that face.
let item = spill::saved_entity(&self.world, pos)
.and_then(|tag| tag.get("item").and_then(save::item_from_nbt));
let item = match self.world.block_entities.get(&(pos[0], pos[1], pos[2])) {
Some(BlockEntity::Brushable(b)) => b.item.clone(),
_ => None,
};
if let Some(mut item) = item {
let face = face.unwrap_or([0, 1, 0]);
let take = 10 + self.world.random.next_int_bound(21).unwrap_or(0);
@@ -221,12 +251,70 @@ impl Game<'_> {
if brush.count != 0 {
self.brushing.insert(pos, brush);
self.schedule_brushable_tick(pos);
} else if let Some(BlockEntity::Brushable(b)) =
self.world.block_entities.get_mut(&(pos[0], pos[1], pos[2]))
{
b.hit_direction = None;
}
if before != after {
self.set_dusted(pos, after);
}
}
/// `BrushableBlockRenderer`: reveal the item from the first brushed face.
pub(super) fn brushable_geometry(&self, camera: glam::DVec3, out: &mut Vec<Vertex>) {
use horizon_assets::item_model::DisplayContext;
for (key, entity) in &self.world.block_entities {
let BlockEntity::Brushable(b) = entity else {
continue;
};
let (Some(item), Some(face)) = (&b.item, b.hit_direction) else {
continue;
};
let at = glam::DVec3::new(f64::from(key.0), f64::from(key.1), f64::from(key.2));
if at.distance_squared(camera) >= 64.0 * 64.0 {
continue;
}
let state = self.world.block(key.0, key.1, key.2);
let progress = self
.pack
.assets
.registry
.state(state)
.and_then(|s| s.properties.get("dusted"))
.and_then(|s| s.parse::<u8>().ok())
.unwrap_or(0);
if progress == 0 {
continue;
}
let offset = f32::from(progress) * 0.075;
let (translation, adjacent, east_west) = match face {
0 => (glam::Vec3::new(0.5, -0.23 - offset, 0.5), [0, -1, 0], false),
1 => (glam::Vec3::new(0.5, 0.25 + offset, 0.5), [0, 1, 0], false),
2 => (glam::Vec3::new(0.5, 0.0, 0.25 - offset), [0, 0, -1], false),
3 => (glam::Vec3::new(0.5, 0.0, 0.73 + offset), [0, 0, 1], false),
4 => (glam::Vec3::new(0.25 - offset, 0.0, 0.5), [-1, 0, 0], true),
5 => (glam::Vec3::new(0.73 + offset, 0.0, 0.5), [1, 0, 0], true),
_ => continue,
};
let item = self.resolve_item(item, DisplayContext::Fixed, None, 0);
if item.is_empty() {
continue;
}
let pose = glam::Mat4::from_translation(
(at - camera).as_vec3() + glam::Vec3::new(0.0, 0.5, 0.0) + translation,
) * glam::Mat4::from_rotation_y(
(86.0_f32 + if east_west { 90.0 } else { 0.0 }).to_radians(),
) * glam::Mat4::from_scale(glam::Vec3::splat(0.5));
let light = self.world.light(
key.0 + adjacent[0],
key.1 + adjacent[1],
key.2 + adjacent[2],
);
self.submit_item_world(&item, pose, vertex_light(light), out);
}
}
/// Level event 3008 (`LevelEventHandler`): the finished block's sound
/// and break particles.
pub(super) fn brush_complete_event(&mut self, pos: [i32; 3], data: i32) {
+4 -1
View File
@@ -1149,7 +1149,10 @@ impl Game<'_> {
return;
};
let path = dir.join(name);
renderer.screenshot = Some(path.clone());
renderer.screenshot = Some(horizon_client::gfx::ScreenshotRequest {
path: path.clone(),
size: None,
});
self.ui.screenshot_message = Some(path);
}
/// After a frame: report a finished F2 screenshot in chat.
+8 -4
View File
@@ -22,7 +22,7 @@ const HELP: &[&str] = &[
"tp X Y Z teleport the player",
"hotbar 1-9 select a hotbar slot",
"give ITEM [COUNT] add items to the inventory",
"render-distance N chunks, 2..64",
"render-distance N chunks, 2..128",
"lod-distance N far surface chunks, 0 disables, up to1024",
"time TICKS set the time of day",
"summon KIND [X Y Z] [baby] spawn a mob (in front of the player without a position)",
@@ -516,8 +516,9 @@ impl Game<'_> {
}
"render-distance" => {
let n: i32 = arg(1)?.parse()?;
if !(2..=64).contains(&n) {
return Err("render distance must be 2..64".into());
let max = horizon_client::options_file::MAX_RENDER_DISTANCE;
if !(2..=max).contains(&n) {
return Err(format!("render distance must be 2..{max}").into());
}
self.settings.render_distance = n;
self.apply_settings();
@@ -587,7 +588,10 @@ impl Game<'_> {
arg(1)?;
// The rest of the line: a path may hold spaces.
let path = line.trim_start().strip_prefix("screenshot").unwrap_or("");
renderer.screenshot = Some(path.trim().into());
renderer.screenshot = Some(horizon_client::gfx::ScreenshotRequest {
path: path.trim().into(),
size: None,
});
self.pending_screenshot = Some(reply.clone());
Ok(None)
}
@@ -416,6 +416,13 @@ impl Game<'_> {
],
);
}
// Horizon's own: HDR's output while the setting is on, under the GPU (a group of its
// own would move others to the other column).
if self.settings.horizon.hdr
&& let Some(r) = self.renderer.as_ref()
{
d.group("system", [r.hdr_line()]);
}
// Horizon's own: the shader pack's GPU time per pass (an empty group would move others).
let pass_lines = self.renderer.as_ref().map_or_else(Vec::new, |r| {
debug_overlay::shader_pass_lines(&r.shader_pass_times())
+102 -1
View File
@@ -26,12 +26,105 @@ use horizon_world::nbt::{JavaString, Tag};
use std::{
io::Write,
path::PathBuf,
sync::{Arc, Mutex, OnceLock, mpsc},
sync::{
Arc, Mutex, OnceLock,
atomic::{AtomicBool, AtomicU64, Ordering},
mpsc,
},
time::{Duration, Instant},
};
mod status;
/// What the tick loop tells `ServerWatchdog`: when the tick in progress was
/// due (`getNextTickTime`), how long a tick is, and whether the server runs.
struct Watchdog {
base: Instant,
next_nanos: AtomicU64,
tick_nanos: AtomicU64,
running: AtomicBool,
}
impl Watchdog {
/// `DedicatedServer.initServer`: a thread that stops a server whose tick
/// runs `max_tick_time` ms past its due time; none when that is 0 or less.
fn start(max_tick_time: i64) -> Option<Arc<Self>> {
let max = Duration::from_millis(u64::try_from(max_tick_time).ok().filter(|m| *m > 0)?);
let watchdog = Arc::new(Self {
base: Instant::now(),
next_nanos: AtomicU64::new(0),
tick_nanos: AtomicU64::new(50_000_000),
running: AtomicBool::new(true),
});
let shared = watchdog.clone();
std::thread::Builder::new()
.name("Server Watchdog".into())
.spawn(move || shared.run(max))
.ok()?;
Some(watchdog)
}
/// The tick loop is about to run the tick due at `next`.
fn tick_due(&self, next: Instant, tick: Duration) {
let nanos = next.saturating_duration_since(self.base).as_nanos();
self.next_nanos
.store(u64::try_from(nanos).unwrap_or(u64::MAX), Ordering::Relaxed);
let tick = u64::try_from(tick.as_nanos()).unwrap_or(u64::MAX);
self.tick_nanos.store(tick, Ordering::Relaxed);
}
/// `ServerWatchdog.run`.
fn run(&self, max: Duration) {
let max = u64::try_from(max.as_nanos()).unwrap_or(u64::MAX);
while self.running.load(Ordering::Relaxed) {
let next = self.next_nanos.load(Ordering::Relaxed);
let now = u64::try_from(self.base.elapsed().as_nanos()).unwrap_or(u64::MAX);
let behind = now.saturating_sub(next);
if behind > max {
let took = format!("{:.2}", behind as f32 / 1e9);
let should = format!(
"{:.2}",
self.tick_nanos.load(Ordering::Relaxed) as f32 / 1e9
);
log_from(
"Server Watchdog",
"ERROR",
&format!("A single server tick took {took} seconds (should be max {should})"),
);
log_from(
"Server Watchdog",
"ERROR",
"Considering it to be crashed, server will forcibly shutdown.",
);
let message = format!(
"ServerWatchdog detected that a single server tick took {took} seconds (should be max {should})"
);
match horizon_client::crash::report_watchdog(
std::path::Path::new("."),
"server",
message,
) {
Some(path) => log_from(
"Server Watchdog",
"ERROR",
&format!(
"This crash report has been saved to: {}",
path.canonicalize().unwrap_or(path).display()
),
),
None => log_from(
"Server Watchdog",
"ERROR",
"We were unable to save this crash report to disk.",
),
}
std::process::exit(1);
}
// Thread.sleep(nextTickTime + maxTickTime - now).
let wait = (next + max).saturating_sub(now).max(1_000_000);
std::thread::sleep(Duration::from_nanos(wait));
}
}
}
/// Double.toString for the magnitudes positions have (no exponent form
/// between 1e-3 and 1e7).
pub(super) fn java_double(v: f64) -> String {
@@ -703,8 +796,13 @@ impl Game<'_> {
let pause_after = props.pause_when_empty_seconds * 20;
let idle_timeout = props.player_idle_timeout;
let mut next = Instant::now();
self.fns.permission = Some(props.function_permission_level.clamp(0, 4) as u8);
let watchdog = Watchdog::start(props.max_tick_time);
while !state.stop {
let tick_start = Instant::now();
if let Some(watchdog) = &watchdog {
watchdog.tick_due(next, self.clock.tick_duration());
}
while let Ok(line) = state.console.try_recv() {
self.console_line(&mut state, &line);
}
@@ -745,6 +843,9 @@ impl Game<'_> {
state.ticked(Instant::now());
self.wait_for_next_server_tick(&mut next, paused);
}
if let Some(watchdog) = &watchdog {
watchdog.running.store(false, Ordering::Relaxed);
}
self.stop_dedicated()
}
+189 -5
View File
@@ -8,7 +8,7 @@ use horizon_client::{
Result,
clipboard::EditShortcut,
confirm_screen::Confirm,
dialog::{AfterAction, Click, Control, Dialog, InputValue},
dialog::{AfterAction, Body, Click, Control, Dialog, InputValue, Kind},
dialog_screen::{BOX, Layout, Part, WARNING_SPRITES, waiting_button},
hud::{HudBatch, rgba},
text::Text,
@@ -26,6 +26,8 @@ struct Open {
fields: Vec<Option<ui::EditBox>>,
focus: Option<usize>,
scroll: ui::Scroll,
/// The number range input whose slider is held.
dragging: Option<usize>,
}
/// The dialog screens a server can open.
@@ -38,6 +40,17 @@ pub(super) struct DialogState {
warning: bool,
}
/// `CycleButton.cycleValue`: the next option (`delta` 1) or the one before
/// it, wrapping.
fn cycle(open: &mut Open, input: usize, delta: i32) {
let Control::SingleOption { options, .. } = &open.dialog.inputs[input].control else {
return;
};
if let Some(InputValue::Option(i)) = open.values.get_mut(input) {
*i = (*i as i32 + delta).rem_euclid(options.len().max(1) as i32) as usize;
}
}
fn warning_texts() -> (Text, Text, [Text; 2]) {
let t = |key: &str| Text::translate(key, &[]);
(
@@ -57,10 +70,35 @@ impl Game<'_> {
/// `showDialog`: the dialog replaces an open one (and a waiting screen);
/// under the warning screen, it is what Back returns to.
pub(super) fn show_dialog(&mut self, tag: &Tag) {
let Some(dialog) = Dialog::parse(tag) else {
let Some(mut dialog) = Dialog::parse(tag) else {
log::warn!("Horizon: failed to show dialog for data {tag:?}");
return;
};
if let Kind::DialogList { dialogs, .. } = &dialog.kind {
let entries = self.remote.as_ref().map(|r| {
let list = r.registries.registries.get("minecraft:dialog");
let by_id = |id: &str| {
let name = if id.contains(':') {
id.to_owned()
} else {
format!("minecraft:{id}")
};
list?.iter().find(|(n, _)| *n == name)?.1.clone()
};
let by_tag = |name: &str| {
let name = if name.contains(':') {
name.to_owned()
} else {
format!("minecraft:{name}")
};
let ids = r.registries.dialog_tags.get(&name).into_iter().flatten();
ids.filter_map(|&i| list?.get(usize::try_from(i).ok()?)?.1.clone())
.collect()
};
Dialog::list_entries(dialogs, &by_tag, &by_id)
});
dialog.set_list(&entries.unwrap_or_default());
}
let values = dialog.inputs.iter().map(|i| i.control.initial()).collect();
let fields = dialog
.inputs
@@ -87,6 +125,7 @@ impl Game<'_> {
fields,
focus: None,
scroll: ui::Scroll::default(),
dragging: None,
});
self.dialogs.waiting = None;
self.capture(false);
@@ -123,6 +162,17 @@ impl Game<'_> {
/// `DialogScreen.runAction`: the click event, then the screen the dialog's
/// `after_action` leaves.
fn run_dialog_action(&mut self, click: Option<Click>, after: AfterAction) -> Result<()> {
// defaultHandleClickEvent: an open_url never sets the screen itself, so
// the dialog stays unless the confirmation takes over (Web Links off,
// or Prompt on Links off, opens nothing or the browser behind it).
if let Some(Click::OpenUrl(url)) = &click
&& !(self.settings.chat_links && self.settings.file.bool("chatLinksPrompt"))
{
if self.settings.chat_links {
super::packs::open_uri(url);
}
return Ok(());
}
match after {
AfterAction::None => {}
AfterAction::Close => self.dialogs.open = None,
@@ -153,7 +203,11 @@ impl Game<'_> {
use horizon_client::clipboard::Clipboard;
self.clipboard.set(&value);
}
Some(other) => log::info!("Horizon: dialog action {other:?} is not supported yet"),
// ConfirmLinkScreen over the screen the dialog leaves.
Some(Click::OpenUrl(url)) => self.click_url(url),
// Screen.insertText: the screen after the dialog takes no text.
Some(Click::SuggestCommand(_)) => {}
Some(other) => log::error!("Don't know how to handle {other:?}"),
None => {}
}
if !self.dialog_open() {
@@ -262,7 +316,8 @@ impl Game<'_> {
let Some(layout) = self.dialog_layout() else {
return;
};
let Some(open) = self.dialogs.open.as_mut() else {
// Out of `self` while it draws: an item body draws through `self`.
let Some(mut open) = self.dialogs.open.take() else {
return;
};
let atlas = &self.gui.atlas;
@@ -291,6 +346,7 @@ impl Game<'_> {
let buttons = open.dialog.buttons();
let in_area = |y: f32| y >= ay && y < ay + ah;
let mut tooltip = None;
let mut item_tip = None;
let mut start = None;
for (part, clipped) in layout.placed(open.scroll.amount) {
if clipped && start.is_none() {
@@ -333,6 +389,64 @@ impl Game<'_> {
true,
);
}
Part::Cycle { input, rect } => {
let [x, y, cw, ch] = rect;
let hover = in_area(cursor.1) && ui::hovered(cursor, x, y, cw, ch);
let Some(label) = open
.values
.get(input)
.and_then(|v| open.dialog.inputs[input].control.message(v))
else {
continue;
};
ui::button(batch, atlas, x, y, cw, ch, &label, true, hover);
}
Part::Slider { input, rect } => {
let [x, y, sw, sh] = rect;
let hover = in_area(cursor.1) && ui::hovered(cursor, x, y, sw, sh);
let Some((InputValue::Slider(position), Some(label))) = open
.values
.get(input)
.map(|v| (v.clone(), open.dialog.inputs[input].control.message(v)))
else {
continue;
};
let held = open.dragging == Some(input);
ui::slider(
batch,
atlas,
x,
y,
sw,
&label,
position,
hover || held,
true,
);
}
Part::Item { body, rect } => {
let [x, y, iw, ih] = rect;
let Some(Body::Item {
item,
show_decorations,
show_tooltip,
..
}) = open.dialog.body.get(body)
else {
continue;
};
let Some(stack) = horizon_items::item_nbt::item_from_nbt(item) else {
continue;
};
// ItemDisplayWidget: the item at its corner, then its decorations.
self.draw_item_model(batch, &stack, x, y);
if *show_decorations {
self.draw_item_decorations(batch, &stack, x, y, None);
}
if *show_tooltip && in_area(cursor.1) && ui::hovered(cursor, x, y, iw, ih) {
item_tip = Some(stack);
}
}
Part::Button { index, rect } => {
let [x, y, bw, bh] = rect;
let hover =
@@ -371,6 +485,10 @@ impl Game<'_> {
.collect();
ui::tooltip(batch, atlas, &lines, cursor);
}
self.dialogs.open = Some(open);
if let Some(stack) = item_tip {
self.draw_stack_tooltip(batch, &stack, cursor.0, cursor.1);
}
}
/// A left click on a dialog screen.
@@ -420,6 +538,7 @@ impl Game<'_> {
self.dialogs.warning = true;
return Ok(());
}
let shift = self.modifiers.shift_key();
let Some(open) = self.dialogs.open.as_mut() else {
return Ok(());
};
@@ -450,6 +569,23 @@ impl Game<'_> {
}
action = Some(None);
}
Part::Cycle {
input,
rect: [x, y, w, h],
} if ui::hovered(cursor, x, y, w, h) => {
// CycleButton.onPress: Shift goes back.
cycle(open, input, if shift { -1 } else { 1 });
action = Some(None);
}
Part::Slider {
input,
rect: [x, y, w, h],
} if ui::hovered(cursor, x, y, w, h) => {
open.dragging = Some(input);
if let Some(v) = open.values.get_mut(input) {
*v = InputValue::Slider(ui::slider_value(x, w, cursor.0));
}
}
Part::Button {
index,
rect: [x, y, w, h],
@@ -479,17 +615,65 @@ impl Game<'_> {
self.run_dialog_action(click, after)
}
/// The mouse wheel over a dialog: its body scrolls 10 a step.
/// The mouse wheel over a dialog: a cycle button under it takes the
/// step, else the body scrolls 10 a step.
pub(super) fn dialog_scroll(&mut self, delta: f32) {
let Some(layout) = self.dialog_layout() else {
return;
};
let cursor = self.cursor;
if let Some(open) = self.dialogs.open.as_mut() {
let [_, ay, _, ah] = layout.area;
if delta != 0.0 && cursor.1 >= ay && cursor.1 < ay + ah {
let hovered = layout
.placed(open.scroll.amount)
.find_map(|(part, _)| match part {
Part::Cycle {
input,
rect: [x, y, w, h],
} if ui::hovered(cursor, x, y, w, h) => Some(input),
_ => None,
});
if let Some(input) = hovered {
// CycleButton.mouseScrolled: up goes back.
cycle(open, input, if delta > 0.0 { -1 } else { 1 });
return;
}
}
open.scroll.wheel(delta, 10.0, ay, ay + ah, layout.content);
}
}
/// The pointer moved with a slider held (`AbstractSliderButton.onDrag`).
pub(super) fn dialog_drag(&mut self) {
let Some(input) = self.dialogs.open.as_ref().and_then(|o| o.dragging) else {
return;
};
let Some(layout) = self.dialog_layout() else {
return;
};
let x = self.cursor.0;
let Some(open) = self.dialogs.open.as_mut() else {
return;
};
let slider = layout.body.iter().find_map(|p| match p {
Part::Slider { input: i, rect } if *i == input => Some(*rect),
_ => None,
});
if let (Some([sx, _, sw, _]), Some(value)) = (slider, open.values.get_mut(input)) {
*value = InputValue::Slider(ui::slider_value(sx, sw, x));
}
}
/// The left button released: a held slider lets go and clicks.
pub(super) fn dialog_release(&mut self) {
if let Some(open) = self.dialogs.open.as_mut()
&& open.dragging.take().is_some()
{
self.audio.play_button_click();
}
}
/// A key pressed on a dialog screen: Escape closes what allows it, Tab
/// moves between the text inputs, the rest goes to the focused one
/// (`shortcut`: the press as an edit shortcut; `text`: what it types).
+230 -7
View File
@@ -3,6 +3,7 @@
//! sky. The dragon fight itself lives in game/dragon.rs: the podium is
//! inactive until the dragon dies.
use super::{Dim, Game};
use horizon_client::nether_portal::ENTITY_DELAY;
use horizon_client::{
Result,
containers::{BlockEntity, EndGateway},
@@ -16,10 +17,18 @@ use horizon_client::{
};
use horizon_core::{
geometry::Vec3,
position::BlockPos,
random::{LegacyRandom, RandomSource},
};
use horizon_world::nbt::Tag;
use std::{collections::HashMap, sync::Arc};
/// A list of doubles, as an NBT list of them.
fn doubles(values: [f64; 3]) -> Tag {
Tag::List(values.iter().map(|v| Tag::Double(v.to_bits())).collect())
}
/// ServerLevel.END_SPAWN_POINT.
pub(super) const END_SPAWN: [i32; 3] = [100, 50, 0];
/// EnderDragonFight gateway ring radius and height.
@@ -38,6 +47,18 @@ pub(super) enum GatewayJob {
Known { exit: [i32; 3], exact: bool },
}
/// An entity travelling through an End gateway: its saved compound and the
/// job that finds where it lands (`TheEndGatewayBlockEntity.getPortalPosition`).
#[derive(Clone, Debug)]
struct GatewayArrival {
/// The entity's compound, as it waits for the exit's chunks.
tag: Tag,
/// The gateway's exit, or the search for one.
job: GatewayJob,
/// The spot to load before the job is resolved.
target: Vec3,
}
impl GatewayJob {
/// TheEndGatewayBlockEntity.getPortalPosition, with legacy save fallbacks.
fn for_gateway(
@@ -94,6 +115,11 @@ pub(super) struct EndState {
job: Option<GatewayJob>,
/// Ticks until the player can use a gateway again (TheEndGatewayBlockEntity cooldown).
gateway_cooldown: u32,
gateways: HashMap<u32, horizon_client::end_gateway::Traveller>,
/// The entities that went through a gateway, waiting for its exit.
arrivals: Vec<GatewayArrival>,
/// The chunks kept loaded for those arrivals.
areas: Vec<(i32, i32)>,
/// The Overworld generator, for stronghold positions (ServerLevel.findNearestMapStructure).
locator: std::sync::Mutex<Option<Arc<VanillaTerrain>>>,
/// Thrown eyes of ender in flight.
@@ -937,6 +963,17 @@ impl Game<'_> {
self.save_gateway_exits()
}
/// The exit a gateway already leads to: its block entity, or the side file
/// that kept it for a gateway whose chunk is not loaded.
fn known_gateway_exit(&mut self, gateway: [i32; 3]) -> Option<([i32; 3], bool)> {
let key = (gateway[0], gateway[1], gateway[2]);
let saved = match self.world.block_entities.get(&key) {
Some(BlockEntity::EndGateway(g)) => g.exit_portal.map(|exit| (exit, g.exact_teleport)),
_ => None,
};
saved.or_else(|| self.gateway_exits().get(&gateway).copied())
}
/// EndGatewayBlock.entityInside and TheEndGatewayBlockEntity.getPortalPosition:
/// travel within the End, waiting for the destination chunks to load.
fn enter_gateway(&mut self, gateway: [i32; 3]) -> Result<()> {
@@ -1033,13 +1070,7 @@ impl Game<'_> {
/// The teleport position of a gateway job, creating the exit gateway (and
/// an island when the chunk has no landing spot) for new destinations.
pub(super) fn resolve_gateway(&mut self, job: GatewayJob, target: Vec3) -> Result<Vec3> {
let bottom_center = |p: [i32; 3]| {
Vec3::new(
f64::from(p[0]) + 0.5,
f64::from(p[1]),
f64::from(p[2]) + 0.5,
)
};
let bottom_center = horizon_client::end_gateway::bottom_center;
let find_exit = |game: &Self, exit: [i32; 3]| {
let p = game.tallest_block([exit[0], exit[1] + 2, exit[2]], 5, false);
[p[0], p[1] + 1, p[2]]
@@ -1049,6 +1080,14 @@ impl Game<'_> {
find_exit(self, e)
}))),
GatewayJob::Find { gateway, exact } => {
// The player and the entities may resolve the same gateway: the
// first one to arrive finds the exit, the others use it.
let known = self.known_gateway_exit(gateway);
if let Some((exit, exact)) = known {
return Ok(bottom_center(exit_target(exit, exact, |e| {
find_exit(self, e)
})));
}
// findExitPortalXZPosTentative over the loaded chunks.
let dir = glam::DVec2::new(f64::from(gateway[0]), f64::from(gateway[2]))
.normalize_or_zero();
@@ -1410,6 +1449,190 @@ impl Game<'_> {
}
}
}
// ---- End gateway travel for the entities ----
/// `EndGatewayBlock.entityInside` for the items and mobs in the End: the
/// gateway blocks their box swept through. A gateway that is cooling down
/// (`TheEndGatewayBlockEntity.triggerCooldown`) takes nobody in.
pub(super) fn find_end_gateway_portals(&mut self) {
if self.remote.is_some() || self.dim != Dim::End {
return;
}
let items: Vec<(u32, Vec3, f32, f32)> = self
.dropped
.iter()
.map(|e| (e.id, e.position, 0.25, 0.25))
.collect();
let mobs: Vec<(u32, Vec3, f32, f32)> = self
.mobs
.iter()
.filter(|m| self.mob_can_use_portal(m))
.map(|m| (m.id, m.position, m.width(), m.height()))
.collect();
let mut inside: Vec<(u32, BlockPos)> = Vec::new();
for (id, to, width, height) in items.into_iter().chain(mobs) {
let from = self.portals.before.get(&id).copied().unwrap_or(to);
let found = horizon_client::end_gateway::swept_gateway(from, to, width, height, |p| {
&*self.res.facts().get(self.world.block(p.x, p.y, p.z)).name
== "minecraft:end_gateway"
});
if let Some(pos) = found {
inside.push((id, pos));
}
}
for (id, pos) in inside {
// One entity at a time: the gateway cools down for 40 ticks.
if !self.gateway_enter([pos.x, pos.y, pos.z]) {
continue;
}
self.end.gateways.entry(id).or_default().enter(pos);
}
}
/// `Entity.handlePortal` for the entities in an End gateway: they go
/// through to the gateway's exit, where they wait for its chunks.
pub(super) fn handle_end_gateway_portals(&mut self) {
if self.remote.is_some() || self.dim != Dim::End {
return;
}
let ids: Vec<(u32, bool)> = self
.dropped
.iter()
.map(|e| (e.id, true))
.chain(self.mobs.iter().map(|m| (m.id, self.mob_can_use_portal(m))))
.collect();
let present: std::collections::HashSet<u32> = ids.iter().map(|(id, _)| *id).collect();
self.end.gateways.retain(|id, _| present.contains(id));
let mut leaving: Vec<(u32, BlockPos)> = Vec::new();
for (id, allowed) in ids {
let Some(state) = self.end.gateways.get_mut(&id) else {
continue;
};
// The End gateway has no transition time (Portal.getPortalTransitionTime).
if let Some(entry) = state.travel(allowed) {
leaving.push((id, entry));
}
if state.is_done() {
self.end.gateways.remove(&id);
}
}
for (id, entry) in leaving {
self.send_through_gateway(id, entry);
}
}
/// `Entity.teleportToPortalDestination` for an entity that goes through an
/// End gateway: it is saved and waits for the exit's chunks.
fn send_through_gateway(&mut self, id: u32, gateway: BlockPos) {
let tag = if let Some(i) = self.dropped.iter().position(|e| e.id == id) {
let uuid = self.uuid_for(id);
let tag = horizon_client::entity_nbt::item_entity_to_nbt(
&self.dropped[i],
uuid,
self.persist_raw(id),
);
self.dropped.remove(i);
self.forget_entity(id);
tag
} else if let Some(i) = self.mobs.iter().position(|m| m.id == id) {
let mut tag = self.mob_tree_nbt(i);
// Entity.removeAfterChangingDimensions drops the leash.
if let Tag::Compound(c) = &mut tag {
c.remove(&"leash".into());
}
// The mobs riding it travel with it (their tags are in its compound).
let mut gone = vec![id];
let mut k = 0;
while k < gone.len() {
let v = gone[k];
gone.extend(
self.mobs
.iter()
.filter(|m| m.extra.mount == Some(v))
.map(|m| m.id),
);
k += 1;
}
self.mobs.retain(|m| !gone.contains(&m.id));
for g in gone {
self.forget_entity(g);
}
tag
} else {
return;
};
let (job, target) = self.gateway_job([gateway.x, gateway.y, gateway.z]);
self.end.arrivals.push(GatewayArrival { tag, job, target });
}
/// The entities that reached a gateway's exit: they land where the
/// gateway leads, once its chunks have loaded.
pub(super) fn resolve_end_gateway_arrivals(&mut self) -> Result<()> {
if self.remote.is_some() || self.dim != Dim::End {
return Ok(());
}
let mut i = 0;
while i < self.end.arrivals.len() {
let (cx, cz) = (
(self.end.arrivals[i].target.x.floor() as i32) >> 4,
(self.end.arrivals[i].target.z.floor() as i32) >> 4,
);
if !self.world.chunks.contains_key(&(cx, cz)) {
i += 1;
continue;
}
let (job, target) = (self.end.arrivals[i].job, self.end.arrivals[i].target);
let at = match self.resolve_gateway(job, target) {
Ok(at) => at,
Err(e) => {
// The traveller waits for the exit: the failure is retried.
log::warn!("End gateway: {e}");
i += 1;
continue;
}
};
let mut tag = self.end.arrivals.remove(i).tag;
if let Tag::Compound(c) = &mut tag {
c.insert("Pos".into(), doubles([at.x, at.y, at.z]));
c.insert("Motion".into(), doubles([0.0, 0.0, 0.0]));
// setPortalCooldown before the teleport.
c.insert("PortalCooldown".into(), Tag::Int(ENTITY_DELAY));
}
let key = ((at.x.floor() as i32) >> 4, (at.z.floor() as i32) >> 4);
match self
.world
.loaded_entities
.iter_mut()
.find(|(k, _)| *k == key)
{
Some((_, tags)) => tags.push(tag),
None => self.world.loaded_entities.push((key, vec![tag])),
}
}
Ok(())
}
/// The chunks the entities waiting to arrive need, kept loaded like the
/// areas a server keeps around its players.
pub(super) fn keep_gateway_destinations(&mut self) {
let areas = &self.end.areas;
self.streaming
.extra
.retain(|&(c, r)| !(r == 1 && areas.contains(&c)));
let mut areas = Vec::new();
for a in &self.end.arrivals {
let c = (
(a.target.x.floor() as i32) >> 4,
(a.target.z.floor() as i32) >> 4,
);
if !areas.contains(&c) {
areas.push(c);
self.streaming.extra.push((c, 1));
}
}
self.end.areas = areas;
}
}
#[cfg(test)]
@@ -36,6 +36,8 @@ pub(super) struct FnState {
load_pending: bool,
pub schedule: Vec<Scheduled>,
next_seq: u64,
/// A dedicated server's `function-permission-level`.
pub permission: Option<u8>,
}
/// What a function reported through `return`.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -45,6 +47,16 @@ pub(super) enum Outcome {
Fail,
}
impl FnState {
/// The level function files compile at (`ServerFunctionLibrary`'s
/// `functionCompilationPermissions`): `function-permission-level` on a
/// dedicated server, game master (2) otherwise. Whoever runs a function,
/// its commands need no more than this.
fn command_level(&self) -> u8 {
self.permission.unwrap_or(2)
}
}
impl Game<'_> {
// ---- loading -----------------------------------------------------------
@@ -278,6 +290,7 @@ impl Game<'_> {
}
self.exec.function_depth += 1;
let outer = self.exec.returned.take();
let level = std::mem::replace(&mut self.ui.registries.permission, self.fns.command_level());
let inner = Source {
silent: true,
store: None,
@@ -292,6 +305,7 @@ impl Game<'_> {
break;
}
}
self.ui.registries.permission = level;
let returned = std::mem::replace(&mut self.exec.returned, outer);
self.exec.function_depth -= 1;
Ok(match returned {
+11 -10
View File
@@ -11,7 +11,7 @@
//! escape hatch. LAN guests and dedicated servers use the same code either
//! way.
use super::{
Game, Options, PackResources, Resources, UiResources,
Game, Options, PackResources, Resources, UiResources, WorldIcon,
execution_role::ExecutionRole,
lan::{LanSettings, Owner},
world_loading::WorldRequest,
@@ -19,7 +19,6 @@ use super::{
use horizon_client::{
Result,
audio::Audio,
hud::HudAtlas,
lan::{Lan, MAX_PLAYERS, NetConfig, hub::HubConfig},
settings::Settings,
title::WorldOptions,
@@ -325,6 +324,15 @@ impl Game<'_> {
};
handle.world_name = world_name;
self.connect_local(pipe, &player)?;
// IntegratedServer.getWorldScreenshotFile: the client grabs the frame
// the Select World list shows, and needs the save folder for it.
self.world_icon = Some(WorldIcon {
path: std::env::current_dir()?
.join("saves")
.join(folder)
.join("icon.png"),
last_attempt: None,
});
self.integrated = Some(handle);
Ok(())
}
@@ -810,14 +818,7 @@ fn server_game(
settings: Settings,
) -> Result<Game<'_>> {
// It draws nothing: only the font, which books and chat measure with.
let atlas = HudAtlas::fonts(&options.resources)?;
let gui = UiResources {
atlas,
panorama: Arc::new(horizon_client::gfx::panorama::Faces {
size: 1,
pixels: [0u8; 4].repeat(6),
}),
};
let gui = UiResources::server(&options.resources)?;
let audio = Audio::silent(&options.resources);
Game::new(res, pack, gui, options, settings, audio, None)
}
+73 -44
View File
@@ -826,35 +826,23 @@ impl Game<'_> {
.facts()
.get(self.world.block(pos.0, pos.1, pos.2))
.name;
let (menu, title, slots) = if &**name == "minecraft:ender_chest" {
let (menu, slots) = if &**name == "minecraft:ender_chest" {
// EnderChestBlock.useWithoutItem: the player's own inventory.
(
"minecraft:generic_9x3",
"container.enderchest",
Slots::Ender,
)
("minecraft:generic_9x3", Slots::Ender)
} else if let Some(BlockEntity::Furnace(f)) = self.world.block_entities.get(&pos) {
// AbstractFurnaceBlock.openContainer: FurnaceMenu, BlastFurnaceMenu, SmokerMenu.
let (menu, title) = match f.kind.recipe_type() {
"minecraft:blasting" => ("minecraft:blast_furnace", "container.blast_furnace"),
"minecraft:smoking" => ("minecraft:smoker", "container.smoker"),
_ => ("minecraft:furnace", "container.furnace"),
let menu = match f.kind.recipe_type() {
"minecraft:blasting" => "minecraft:blast_furnace",
"minecraft:smoking" => "minecraft:smoker",
_ => "minecraft:furnace",
};
(menu, title, Slots::Block(pos))
(menu, Slots::Block(pos))
} else if let Some(BlockEntity::Crafter(_)) = self.world.block_entities.get(&pos) {
// CrafterBlock.useWithoutItem: CrafterMenu over the block entity.
(
"minecraft:crafter_3x3",
"container.crafter",
Slots::Block(pos),
)
("minecraft:crafter_3x3", Slots::Block(pos))
} else if let Some(BlockEntity::Brewing(_)) = self.world.block_entities.get(&pos) {
// BrewingStandBlock.useWithoutItem: BrewingStandMenu.
(
"minecraft:brewing_stand",
"container.brewing",
Slots::Block(pos),
)
("minecraft:brewing_stand", Slots::Block(pos))
} else if let Some((other, right)) = self.chest_partner(pos) {
// ChestBlock.combine: the right half's slots come first.
let halves = if right {
@@ -862,24 +850,33 @@ impl Game<'_> {
} else {
Slots::Double(other, pos)
};
("minecraft:generic_9x6", "container.chestDouble", halves)
("minecraft:generic_9x6", halves)
} else {
let Some(BlockEntity::Chest(items)) = self.world.block_entities.get(&pos) else {
return false;
};
let (menu, title) = match items.len() {
27 => ("minecraft:generic_9x3", "container.chest"),
9 => ("minecraft:generic_3x3", "container.dispenser"),
5 => ("minecraft:hopper", "container.hopper"),
let menu = match items.len() {
27 => "minecraft:generic_9x3",
9 => "minecraft:generic_3x3",
5 => "minecraft:hopper",
_ => return false,
};
let title = match &**name {
"minecraft:barrel" => "container.barrel",
"minecraft:dropper" => "container.dropper",
n if n.ends_with("shulker_box") => "container.shulkerBox",
_ => title,
};
(menu, title, Slots::Block(pos))
(menu, Slots::Block(pos))
};
// The menu provider's getDisplayName: a container's custom name
// (BaseContainerBlockEntity.getName) or its default name.
let custom = |k: (i32, i32, i32)| self.world.block_names.get(&k).map(String::as_str);
let title = match slots {
Slots::Ender => horizon_protocol::text::translatable("container.enderchest", &[]),
Slots::Double(first, second) => {
horizon_client::containers::double_chest_name(custom(first), custom(second))
}
Slots::Block(k) => {
let Some(key) = horizon_client::containers::default_name_key(name) else {
return false;
};
horizon_client::containers::display_name(custom(k), key)
}
};
let Some(items) = host
.hub
@@ -891,14 +888,8 @@ impl Game<'_> {
// ServerPlayer.openMenu closes the menu that was open.
self.guest_container_closed(host, conn);
self.lan_station_closed(host, conn, None);
host.hub.open_container(
conn,
menu,
horizon_protocol::text::translatable(title, &[]),
pos,
slots,
items,
);
host.hub
.open_container(conn, menu, title, pos, slots, items);
true
}
@@ -1246,14 +1237,25 @@ impl Game<'_> {
let hand = if p.hand == 1 { Hand::Off } else { Hand::Main };
let g = &mut host.hub.guests[gi];
(g.yaw, g.pitch) = (p.yaw, p.pitch);
let swung = self.as_guest(host, conn, |g| {
let heard = self.audio.capture.as_ref().map_or(0, Vec::len);
let used = self.as_guest(host, conn, |g| {
// A horn's sound, inline instruments included.
let horn = g.with_hand(hand, |g| {
let held = g.held()?;
g.res.items().instrument(held).map(|i| i.sound)
});
g.guest_use = Some(GuestUse::Item(hand));
let swings = g.swings_started;
let _ = g.use_item();
g.guest_use = None;
g.swings_started != swings
(g.swings_started != swings, horn)
});
let (swung, horn) = match used {
Some((swung, horn)) => (Some(swung), horn),
None => (None, None),
};
self.lan_swing_back(host, conn, swung);
self.guest_instrument_sound(host, conn, heard, horn);
}
S::Attack(p) => self.guest_attack(host, conn, p.id),
S::Interact(p) => {
@@ -2029,8 +2031,11 @@ impl Game<'_> {
let d = vanilla::game_dispatcher();
let level = std::mem::replace(&mut self.ui.registries.permission, permission);
let parse = d.parse(command, &self.ui.registries);
let ((start, end), suggestions) = d.suggestions(&parse, command.len(), &self.ui.registries);
let ((start, end), mut suggestions) =
d.suggestions(&parse, command.len(), &self.ui.registries);
self.ui.registries.permission = level;
// ServerCommandSuggestionsProvider.limitSuggestionCount.
suggestions.truncate(1000);
let utf16 = |byte: usize| command[..byte.min(command.len())].encode_utf16().count() as i32;
let mut w = horizon_protocol::buf::Writer::new();
w.varint(id);
@@ -3232,6 +3237,30 @@ impl Game<'_> {
}
}
/// `InstrumentItem.play`'s `level.playSound(player, ...)`: the others hear
/// a guest's horn now, the guest's own client played it as it used it.
fn guest_instrument_sound(
&mut self,
host: &mut LanHost,
conn: u32,
heard: usize,
horn: Option<String>,
) {
let (Some(horn), Some(capture)) = (horn, self.audio.capture.as_mut()) else {
return;
};
let mut i = heard.min(capture.len());
while i < capture.len() {
if capture[i].0 == horn {
let (name, at, volume, pitch) = capture.remove(i);
let source = horizon_client::settings::sound_source(&name) as i32;
host.hub.sound(&name, at, volume, pitch, source, Some(conn));
} else {
i += 1;
}
}
}
/// Positional sounds and level events of the tick, for the guests.
pub(super) fn lan_broadcast_effects(&mut self, host: &mut LanHost) {
let sounds = self.audio.capture.take().unwrap_or_default();
+11 -3
View File
@@ -435,15 +435,17 @@ pub(super) fn shelf_signal(
_ => 0,
});
}
let tag = super::spill::saved_entity(world, [pos.x, pos.y, pos.z]);
// Only read from the back: a bit for each of the three slots in use.
let facing = info.properties.get("facing").and_then(|f| dir_from_name(f));
if facing.map(Direction::opposite) != Some(direction) {
return Some(0);
}
let items = horizon_client::save::decode_items(tag.as_ref().and_then(|t| t.get("Items")), 3);
let Some(BlockEntity::Shelf(shelf)) = world.block_entities.get(&(pos.x, pos.y, pos.z)) else {
return Some(0);
};
Some(
items
shelf
.items
.iter()
.enumerate()
.map(|(slot, item)| i32::from(item.is_some()) << slot)
@@ -486,6 +488,12 @@ impl Game<'_> {
return;
}
self.chiseled_bookshelf_synced(pos);
if matches!(
self.world.block_entities.get(&pos),
Some(BlockEntity::Shelf(_))
) {
self.mark_block_entity_updated(pos);
}
// A double chest's other half changes with it.
let halves: Vec<Pos> = std::iter::once(pos)
.chain(self.chest_other_half(pos))
@@ -246,9 +246,15 @@ impl Game<'_> {
self.paint_screen(batch, ox, oy, background, true);
let atlas = &self.gui.atlas;
let grey = rgba(64, 64, 64, 255);
let title = self.container_title(self.translate("container.crafter", "Crafter"));
let tw = HudBatch::text_width(atlas, &title);
batch.text(
// A server's crafter menu has the title it was given.
let title = self.remote_menu_title().unwrap_or_else(|| {
horizon_client::text::Text::literal(
self.container_title(self.translate("container.crafter", "Crafter")),
)
.resolve_legacy()
});
let tw = atlas.font.width_text(&title);
batch.text_styled(
atlas,
&title,
ox + ((176.0 - tw) / 2.0).trunc(),
+22 -1
View File
@@ -1224,6 +1224,21 @@ impl Game<'_> {
light: u32,
diffuse: bool,
out: &mut Vec<Vertex>,
) {
self.tinted_block_model_vertices(state, seed, m, light, diffuse, |_| None, out);
}
/// `block_model_vertices` with each face multiplied by `tint` of its
/// tint index (RGB, `None` for untinted faces).
#[allow(clippy::too_many_arguments)]
pub(super) fn tinted_block_model_vertices(
&self,
state: BlockStateId,
seed: i64,
m: glam::Mat4,
light: u32,
diffuse: bool,
tint: impl Fn(i32) -> Option<u32>,
out: &mut Vec<Vertex>,
) {
let Ok(models) = self.pack.render.model_catalog.collect_seeded(state, seed) else {
return;
@@ -1245,11 +1260,17 @@ impl Game<'_> {
D::North | D::South => 204,
D::West | D::East => 153,
};
let color = if diffuse {
let mut color = if diffuse {
u32::MAX
} else {
0xFF00_0000 | shade << 16 | shade << 8 | shade
};
if let Some(t) = tint(face.quad.tint_index) {
color = horizon_client::hud_layout::multiply(
color,
horizon_client::mobs::render::abgr(t),
);
}
let normal = if diffuse {
use horizon_client::item_render::{direction_vector, transform_normal};
let n = transform_normal(m, direction_vector(face.quad.facing));
+12 -2
View File
@@ -6,7 +6,7 @@ use super::Game;
use horizon_client::hud::HudBatch;
#[cfg(feature = "mods")]
use {
horizon_client::{hud::rgba, items::ItemStack, text::Text},
horizon_client::{hud::rgba, items::ItemStack, text::Text, ui},
horizon_mods::Shape,
std::time::Instant,
};
@@ -108,7 +108,17 @@ impl Game<'_> {
let r = s.rect;
let name = s.sprite.strip_prefix("minecraft:").unwrap_or(&s.sprite);
let name = format!("gui/sprites/{name}");
batch.sprite_scaled(atlas, &name, ox + r.x, oy + r.y, r.w, r.h, argb(s.color));
// As vanilla's blitSprite: a frame or a button keeps its border.
ui::blit_sprite(
batch,
atlas,
&name,
ox + r.x,
oy + r.y,
r.w,
r.h,
argb(s.color),
);
}
Shape::Item(i) => {
if self.res.items().get(&i.item).is_none() {
@@ -15,7 +15,15 @@ impl Game<'_> {
) {
let partial = alpha as f32;
let mut states = self.world.moving_pistons.clone();
states.extend(self.world.moving_piston_fallback.iter().copied());
// A piston that just finished bridges the frames until the chunk mesh
// shows the block it placed; drawn over that mesh, it z-fights (#3002).
states.extend(
self.world
.moving_piston_fallback
.iter()
.filter(|(p, _)| self.section_mesh_pending(*p))
.copied(),
);
for (p, piston) in states {
let Some((block, base)) = self.moving_piston_blocks(piston, partial) else {
continue;
@@ -38,6 +46,17 @@ impl Game<'_> {
}
}
}
/// Whether the chunk mesh doesn't show the blocks at `p` yet (edited,
/// meshing, or waiting for its upload).
pub(super) fn section_mesh_pending(&self, p: [i32; 3]) -> bool {
let Some(y) = p[1].checked_sub(horizon_client::chunk::MIN_Y) else {
return false;
};
let section = (p[0] >> 4, y.div_euclid(16), p[2] >> 4);
self.world.dirty_sections.contains(&section)
|| self.streaming.is_meshing(section)
|| self.pending_uploads.iter().any(|(key, _)| *key == section)
}
/// The moved block and, for a retracting piston, its extended base.
fn moving_piston_blocks(
&self,
@@ -81,7 +100,8 @@ impl Game<'_> {
Some((piston.moved_state, None))
}
/// One `MovingBlockRenderState`: the baked model at `at` (camera-relative),
/// seeded by its block position and tinted like a falling block.
/// seeded by its block position and tinted per face with the block colours
/// at that position, as `ModelBlockRenderer` tints it in the chunk mesh.
fn moving_block_vertices(
&self,
state: BlockStateId,
@@ -90,23 +110,31 @@ impl Game<'_> {
light: u32,
out: &mut Vec<Vertex>,
) {
let tint = self
.particle_sprite(state, [seed.x, seed.y, seed.z])
.map(|(_, tint)| tint);
let start = out.len();
self.block_model_vertices(
let chunks = &self.world.chunks;
let (cx, cz) = (seed.x >> 4, seed.z >> 4);
let region: [[Option<&horizon_client::chunk::Chunk>; 3]; 3] = std::array::from_fn(|dz| {
std::array::from_fn(|dx| {
chunks
.get(&(cx + dx as i32 - 1, cz + dz as i32 - 1))
.map(|c| &**c)
})
});
let tint = self.pack.context.tables.block_tints(
state,
[seed.x, seed.y, seed.z],
&region,
self.res.biomes(),
self.streaming.biome_zoom,
self.streaming.biome_blend,
);
self.tinted_block_model_vertices(
state,
horizon_core::random::position_seed(seed),
glam::Mat4::from_translation(at.as_vec3()),
light,
false,
tint,
out,
);
if let Some(tint) = tint {
let tint = horizon_client::mobs::render::abgr(tint);
for vertex in &mut out[start..] {
vertex.color = horizon_client::hud_layout::multiply(vertex.color, tint);
}
}
}
}
@@ -12,7 +12,7 @@
//! portal) until the player is in that dimension and its exit is loaded.
use super::{Dim, Game, Screen};
use horizon_client::Result;
use horizon_client::nether_portal::PortalState;
use horizon_client::nether_portal::{ENTITY_DELAY, PortalState};
use horizon_core::{
geometry::{Aabb, Axis, Vec3},
position::BlockPos,
@@ -29,8 +29,6 @@ use std::collections::HashMap;
/// `Player.getDimensionChangingDelay`.
pub(super) const PLAYER_DELAY: i32 = 10;
/// `Entity.getDimensionChangingDelay`.
const ENTITY_DELAY: i32 = 300;
/// Where in its portal a traveller stood, and where its exit is to be found.
#[derive(Clone, Copy, Debug)]
@@ -89,7 +87,7 @@ pub(super) struct Portals {
/// Items and mobs, by entity id.
entities: HashMap<u32, PortalState>,
/// Positions before this tick's moves, by entity id.
before: HashMap<u32, Vec3>,
pub(super) before: HashMap<u32, Vec3>,
arrivals: Vec<Arrival>,
/// The vehicles server players ride that went through a portal this
/// tick, in their level, with their entry: they carry their riders
@@ -686,7 +684,7 @@ impl Game<'_> {
}
/// `canUsePortal(false)` of a mob: alive, riding nothing, not asleep,
/// not carrying the player, and not a boss that never travels.
fn mob_can_use_portal(&self, m: &horizon_client::mobs::Mob) -> bool {
pub(super) fn mob_can_use_portal(&self, m: &horizon_client::mobs::Mob) -> bool {
use horizon_client::mobs::Species;
m.alive()
&& m.extra.mount.is_none()
+12
View File
@@ -200,6 +200,9 @@ impl Game<'_> {
self.sync_agent_control();
self.ui.options.agent_status = self.agent_status_lines();
}
Event::SliderDrag => {
self.apply_settings();
}
Event::CopyAgentDetails => {
use horizon_client::clipboard::Clipboard;
let text = self.agent_details();
@@ -229,6 +232,15 @@ impl Game<'_> {
let _ = std::fs::create_dir_all(&dir);
super::packs::open_folder(&dir);
}
Event::DataPacks(ids) => {
if let Some(title) = &mut self.title {
title.set_data_packs(ids);
}
}
Event::OpenDataPackFolder(dir) => {
let _ = std::fs::create_dir_all(&dir);
super::packs::open_folder(&dir);
}
Event::OpenModFolder => {
let dir = Self::options_path().with_file_name(horizon_client::mod_list::DIR);
let _ = std::fs::create_dir_all(&dir);
+100 -2
View File
@@ -9,8 +9,13 @@ use horizon_client::{
mobs::Who,
save::{ChunkExtras, random_uuid},
};
use horizon_core::{geometry::Vec3, position::ChunkPos, random::RandomSource, ticks::ChunkTicks};
use horizon_world::{chunk::BlockState, nbt::Tag, registry::BlockStateId};
use horizon_core::{
geometry::{Direction, Vec3},
position::{BlockPos, ChunkPos},
random::RandomSource,
ticks::ChunkTicks,
};
use horizon_world::{chunk::BlockState, level::MovingPiston, nbt::Tag, registry::BlockStateId};
use std::{
collections::{HashMap, HashSet},
sync::Arc,
@@ -66,6 +71,10 @@ fn painting_center(p: &horizon_client::entities::Painting) -> Vec3 {
(lo[2] + hi[2]) / 2.0,
)
}
fn block_entity_pos(tag: &Tag) -> [i32; 3] {
[tag.get("x"), tag.get("y"), tag.get("z")]
.map(|v| horizon_client::save::number(v).unwrap_or(0) as i32)
}
fn chunk_of(p: Vec3) -> (i32, i32) {
((p.x.floor() as i32) >> 4, (p.z.floor() as i32) >> 4)
}
@@ -86,6 +95,19 @@ impl Game<'_> {
extras.fluid_ticks = t.pack(time);
}
extras.last_update = time;
// PistonMovingBlockEntity.saveAdditional: a block caught mid-move is
// saved with its chunk (#3310).
for (p, piston) in &self.block_ticks.moving_pistons {
if (p.x >> 4, p.z >> 4) != (chunk.x, chunk.z) {
continue;
}
extras
.block_entities
.retain(|tag| block_entity_pos(tag) != [p.x, p.y, p.z]);
extras
.block_entities
.push(self.moving_piston_to_nbt(*p, piston));
}
// TheEndGatewayBlockEntity saves its Age (the server copy's).
for tag in &mut extras.block_entities {
let pos = [tag.get("x"), tag.get("y"), tag.get("z")]
@@ -142,6 +164,82 @@ impl Game<'_> {
s.fluid_ticks.clear();
}
}
// PistonMovingBlockEntity.loadAdditional: blocks saved mid-move carry
// on moving; later saves take them from the level (#3310).
let pistons: Vec<_> = saved.as_deref().map_or_else(Vec::new, |s| {
s.block_entities
.iter()
.filter_map(|tag| self.moving_piston_from_nbt(tag))
.collect()
});
if pistons.is_empty() {
return;
}
for &(pos, piston) in &pistons {
self.block_ticks.load_moving_piston(pos, piston);
}
if let Some(c) = self.world.chunks.get_mut(&key) {
let c = Arc::make_mut(c);
if let Some(s) = c.saved.as_mut() {
Arc::make_mut(s).block_entities.retain(|tag| {
let at = block_entity_pos(tag);
!pistons.iter().any(|(p, _)| [p.x, p.y, p.z] == at)
});
}
}
}
/// `PistonMovingBlockEntity.saveAdditional`.
fn moving_piston_to_nbt(&self, pos: BlockPos, piston: &MovingPiston) -> Tag {
let flag = |b: bool| Tag::Byte(i8::from(b));
Tag::compound([
("id", Tag::String("minecraft:piston".into())),
("x", Tag::Int(pos.x)),
("y", Tag::Int(pos.y)),
("z", Tag::Int(pos.z)),
("blockState", self.state_to_nbt(piston.moved_state)),
// Direction.LEGACY_ID_CODEC: the 3D data value as a byte.
("facing", Tag::Byte(piston.direction as i8)),
("progress", Tag::Float(piston.progress_o.to_bits())),
("extending", flag(piston.extending)),
("source", flag(piston.source)),
])
}
/// `PistonMovingBlockEntity.loadAdditional`, for a `minecraft:piston`
/// entity whose block is still a moving piston.
fn moving_piston_from_nbt(&self, tag: &Tag) -> Option<(BlockPos, MovingPiston)> {
use horizon_client::save::{float, number, text};
if text(tag.get("id")).as_deref() != Some("minecraft:piston") {
return None;
}
let [x, y, z] = block_entity_pos(tag);
let here = self.world.block(x, y, z);
if &*self.res.facts().get(here).name != "minecraft:moving_piston" {
return None;
}
let moved_state = tag
.get("blockState")
.and_then(|t| self.state_from_nbt(t))
.or_else(|| self.pack.assets.registry.default_state("minecraft:air"))?;
let direction = usize::try_from(number(tag.get("facing")).unwrap_or(0))
.ok()
.and_then(|i| Direction::ALL.get(i).copied())
.unwrap_or(Direction::Down);
let progress = float(tag.get("progress")).unwrap_or(0.0) as f32;
let flag = |k: &str| number(tag.get(k)).unwrap_or(0) != 0;
Some((
BlockPos::new(x, y, z),
MovingPiston {
moved_state,
direction,
extending: flag("extending"),
source: flag("source"),
progress,
progress_o: progress,
last_ticked: 0,
death_ticks: 0,
id: 0,
},
))
}
/// Spawn the entities of chunks that finished loading.
pub(super) fn spawn_loaded_entities(&mut self) {
+61 -1
View File
@@ -548,6 +548,8 @@ impl Game<'_> {
}
self.clear_waypoints();
self.boss_overlay.reset();
// A world is leaving either way: its icon attempt goes with it.
self.world_icon = None;
if let Some(mut r) = self.remote.take() {
r.session.disconnect();
}
@@ -1325,10 +1327,21 @@ impl Game<'_> {
}
fn set_block(&mut self, pos: [i32; 3], state: i32) {
let state = BlockStateId(state.max(0) as u32);
let previous = self.world.block(pos[0], pos[1], pos[2]);
if self.world.loaded(pos[0], pos[2])
&& self.world.set(pos[0], pos[1], pos[2], state).is_ok()
{
self.sync_block_entity((pos[0], pos[1], pos[2]), state);
// LevelChunk.setBlockState removes the block entity of a block
// that changes: the moving piston the client's moveBlocks made
// stops when the server places what it moved (#3002).
let facts = self.res.facts();
let moving = |s: BlockStateId| &*facts.get(s).name == "minecraft:moving_piston";
if moving(previous) && !moving(state) {
let at = horizon_core::position::BlockPos::new(pos[0], pos[1], pos[2]);
self.block_ticks.moving_pistons.retain(|(p, _)| *p != at);
self.sync_moving_pistons();
}
}
// A block we just placed: play its place sound (the server skips the placer).
if let Some((target, _)) = self.remote.as_ref().and_then(|r| r.placing)
@@ -3699,6 +3712,7 @@ impl Game<'_> {
self.tick_subtitles();
let entity_collisions = self.player_entity_collisions();
let pushed = self.remote_pushes();
let sprint_window = self.settings.sprint_window;
let Some(player) = self.player.as_mut() else {
return Ok(());
};
@@ -3797,7 +3811,7 @@ impl Game<'_> {
collision_binding: self.collision,
resources: self.motion,
}
.controlled_simulation_tick(keys)?;
.controlled_simulation_tick_with_sprint_window(keys, sprint_window)?;
} else {
// LivingEntity.baseTick counts hurtTime down for a dead or riding
// player too (controlled_simulation_tick does it otherwise): left
@@ -4719,6 +4733,13 @@ impl Game<'_> {
.and_then(|h| self.res.items().get(&h.item))
.is_some_and(|i| i.block.is_some());
if let Some(hit) = self.pick() {
// ShelfBlock.useItemOn consumes a nonempty main-hand click client-side
// without predicting the inventory swap or swinging.
let shelf_reacts = hand == Hand::Main
&& held.is_some()
&& !(self.keys.contains(&KeyCode::ShiftLeft)
&& (self.stack_in(self.selected).is_some() || self.offhand.is_some()))
&& self.shelf_hit(&hit).is_some();
let (reacts, changes) = if hand == Hand::Main {
self.remote_block_reacts(&hit, held.as_ref())
} else {
@@ -4745,6 +4766,9 @@ impl Game<'_> {
if let Some(r) = self.remote.as_mut() {
r.use_target = Some(hit.pos);
}
if shelf_reacts {
return true;
}
if reacts {
self.predict_use(&changes, seq);
self.open_command_block_if_editable((hit.pos[0], hit.pos[1], hit.pos[2]));
@@ -5063,6 +5087,12 @@ impl Game<'_> {
}
// SpyglassItem.use (its sound is the server's).
"minecraft:spyglass" => true,
// InstrumentItem.use: the horn plays here too (`playSound(player, ...)`
// skips the player's own client), the server's copy tells the others.
"minecraft:goat_horn" => {
self.remote_use_instrument(held);
return;
}
// Item.use: a kinetic weapon starts being used.
id if horizon_client::spear::Kinetic::of(id).is_some() => true,
// ShieldItem.use: anything that blocks attacks, off cooldown.
@@ -5075,6 +5105,27 @@ impl Game<'_> {
self.using = Some((self.held_slot(), 0));
}
}
/// `InstrumentItem.use` on this client: unless the horn is on cooldown,
/// it is held up, plays its sound at the player and starts its cooldown.
fn remote_use_instrument(&mut self, held: &ItemStack) {
let Some(instrument) = self.res.items().instrument(held) else {
return;
};
let group = horizon_client::cooldowns::group(held, None);
if self.cooldowns.is_on_cooldown(&group) {
return;
}
self.using = Some((self.held_slot(), 0));
let at = self
.player
.as_ref()
.map(|p| [p.position.x, p.position.y, p.position.z]);
self.audio
.play(&instrument.sound, at, instrument.range / 16.0, 1.0);
if instrument.use_duration > 0.0 {
self.add_cooldown(group, instrument.use_ticks());
}
}
/// Whether `stack` stays in use until the button lets go (bows,
/// crossbows, tridents, spyglasses, shields): its use only counts ticks here.
fn held_until_released(&self, stack: &ItemStack) -> bool {
@@ -5168,6 +5219,15 @@ impl Game<'_> {
self.using = in_hand.then_some((slot, ticks + 1));
return;
}
// A goat horn is held up for its instrument's use duration.
if let Some(duration) = stack
.as_ref()
.and_then(|s| self.res.items().instrument(s))
.map(|i| i.use_ticks().max(0) as u32)
{
self.using = (in_hand && ticks + 1 < duration).then_some((slot, ticks + 1));
return;
}
let consumable = stack
.as_ref()
.and_then(|s| self.res.items().get(&s.item))
@@ -118,9 +118,28 @@ impl Game<'_> {
.boss_overlay
.darken_amount(self.clock.fraction() as f32),
boss_world_fog: self.boss_overlay.should_create_world_fog(),
spectator: self.spectator(),
fire_resistance: self.potions.effects.get("fire_resistance").is_some(),
night_vision: self.fog_night_vision(),
},
)
}
/// `FogRenderer.computeFogColor`'s night vision brightenFactor:
/// `GameRenderer.nightVisionScale`, but not while Darkness is on.
fn fog_night_vision(&self) -> f32 {
let partial = self.clock.fraction() as f32;
match self.potions.effects.get("night_vision") {
Some(e) if self.potions.effects.get("darkness").is_none() => {
// MobEffectInstance.endsWithin(200); -1 is infinite.
if e.duration < 0 || e.duration > 200 {
1.0
} else {
0.7 + ((e.duration as f32 - partial) * std::f32::consts::PI * 0.2).sin() * 0.3
}
}
_ => 0.0,
}
}
/// `ClientLevelData.isFlat`: on the server thread, the world-wide flag.
fn level_flat(&self) -> bool {
match self.remote.as_ref() {
+260
View File
@@ -0,0 +1,260 @@
//! The displayed items of `ShelfBlockEntity`.
use super::{Game, Hit, machines::dir_from_name};
use horizon_assets::item_model::DisplayContext;
use horizon_client::{block_entity_render::vertex_light, containers::BlockEntity, mesher::Vertex};
use horizon_core::{
geometry::{Direction, Vec3},
position::BlockPos,
};
use horizon_world::level::hit_slot;
impl Game<'_> {
/// Slot and facing for a click on a shelf's front face.
pub(super) fn shelf_hit(&self, hit: &Hit) -> Option<(usize, Direction, bool)> {
let p = hit.pos;
if !matches!(
self.world.block_entities.get(&(p[0], p[1], p[2])),
Some(BlockEntity::Shelf(_))
) {
return None;
}
let state = self.world.block(p[0], p[1], p[2]);
let data = self.pack.assets.registry.state(state)?;
let facing = data
.properties
.get("facing")
.and_then(|s| dir_from_name(s))?;
let face = Direction::ALL
.into_iter()
.find(|d| d.normal() == hit.face)?;
let slot = hit_slot(
facing,
face,
BlockPos::new(p[0], p[1], p[2]),
Vec3::new(hit.point[0], hit.point[1], hit.point[2]),
1,
3,
)?;
Some((
slot,
facing,
data.properties.get("powered").is_some_and(|s| s == "true"),
))
}
/// `SideChainPartBlock.getAllBlocksConnectedTo`: left to right, at most three.
fn shelf_chain(&self, pos: [i32; 3], facing: Direction) -> Vec<[i32; 3]> {
let left = match facing {
Direction::North => Direction::East,
Direction::East => Direction::South,
Direction::South => Direction::West,
Direction::West => Direction::North,
_ => return Vec::new(),
};
let state = self.world.block(pos[0], pos[1], pos[2]);
let behaviours = self.res.behaviours();
if !behaviours
.tag("minecraft:wooden_shelves")
.is_some_and(|tag| tag.contains(behaviours.block(state)))
{
return Vec::new();
}
let mut result = vec![pos];
for (dir, end, prepend) in [(left, "left", true), (left.opposite(), "right", false)] {
let normal = dir.normal();
for step in 1..3 {
let p = [pos[0] + normal[0] * step, pos[1], pos[2] + normal[2] * step];
let state = self.world.block(p[0], p[1], p[2]);
let Some(info) = self.pack.assets.registry.state(state) else {
break;
};
let behaviours = self.res.behaviours();
let connectable = behaviours
.tag("minecraft:wooden_shelves")
.is_some_and(|tag| tag.contains(behaviours.block(state)))
&& info.properties.get("powered").is_some_and(|s| s == "true")
&& info.properties.get("facing").and_then(|s| dir_from_name(s)) == Some(facing);
if !connectable {
break;
}
let part = info
.properties
.get("side_chain")
.map(String::as_str)
.unwrap_or("unconnected");
if part == "center" || part == end {
if prepend {
result.insert(0, p);
} else {
result.push(p);
}
}
if part != "center" {
break;
}
}
}
result
}
/// `ShelfBlock.useItemOn`: a single swap, or a connected hotbar swap while powered.
pub(super) fn use_shelf(&mut self, hit: &Hit) -> Option<bool> {
use horizon_client::first_person::Hand;
if self.hand != Hand::Main {
return None;
}
let (slot, facing, powered) = self.shelf_hit(hit)?;
let pos = hit.pos;
let state = self.world.block(pos[0], pos[1], pos[2]);
if powered {
let chain = self.shelf_chain(pos, facing);
let mut swapped = false;
let count = chain.len();
for (i, p) in chain.into_iter().enumerate() {
let key = (p[0], p[1], p[2]);
let Some(BlockEntity::Shelf(shelf)) = self.world.block_entities.get_mut(&key)
else {
continue;
};
for n in 0..3 {
let index = 9_i32 - (count as i32 - i as i32) * 3 + n as i32;
if !(0..9).contains(&index) {
continue;
}
let index = index as usize;
swapped |= self.inventory[index].is_some() || shelf.items[n].is_some();
std::mem::swap(&mut self.inventory[index], &mut shelf.items[n]);
}
self.container_changed(key);
let s = self.world.block(p[0], p[1], p[2]);
self.post_block_game_event("minecraft:entity_interact", p, None, Some(s));
}
if !swapped {
return Some(false);
}
self.audio.play(
"minecraft:block.shelf.multi_swap",
Some(pos.map(|n| f64::from(n) + 0.5)),
1.0,
1.0,
);
Some(true)
} else {
let held = self.inventory[self.selected].clone();
let key = (pos[0], pos[1], pos[2]);
let Some(BlockEntity::Shelf(shelf)) = self.world.block_entities.get_mut(&key) else {
return None;
};
let removed = std::mem::replace(&mut shelf.items[slot], held.clone());
self.inventory[self.selected] = if self.creative && removed.is_none() {
held.clone()
} else {
removed.clone()
};
self.container_changed(key);
let vibrations = self.inventory[self.selected].as_ref().is_none_or(|stack| {
let default = self
.res
.items()
.get(&stack.item)
.is_none_or(|item| item.interact_vibrations);
let Some(data) = stack.data() else {
return default;
};
if data
.other
.iter()
.any(|(key, _)| key == "!minecraft:use_effects")
{
return true;
}
data.other
.iter()
.find(|(key, _)| key == "minecraft:use_effects")
.map_or(default, |(_, tag)| {
horizon_client::save::number(tag.get("interact_vibrations")).unwrap_or(1)
!= 0
})
});
if vibrations {
self.post_block_game_event(
"minecraft:item_interact_finish",
pos,
None,
Some(state),
);
}
let sound = match (held.is_some(), removed.is_some()) {
(false, false) => return None,
(true, false) => "minecraft:block.shelf.place_item",
(false, true) => "minecraft:block.shelf.take_item",
(true, true) => "minecraft:block.shelf.single_swap",
};
self.audio
.play(sound, Some(pos.map(|n| f64::from(n) + 0.5)), 1.0, 1.0);
Some(true)
}
}
/// `ShelfRenderer`: three items face out, aligned to the bottom or centre.
pub(super) fn shelf_geometry(&self, camera: glam::DVec3, out: &mut Vec<Vertex>) {
for (key, entity) in &self.world.block_entities {
let BlockEntity::Shelf(shelf) = entity else {
continue;
};
let at = glam::DVec3::new(f64::from(key.0), f64::from(key.1), f64::from(key.2));
if at.distance_squared(camera) >= 64.0 * 64.0 {
continue;
}
let block = self.world.block(key.0, key.1, key.2);
let facing = self
.pack
.assets
.registry
.state(block)
.and_then(|s| s.properties.get("facing"))
.map(String::as_str)
.unwrap_or("north");
let yaw: f32 = match facing {
"south" => 0.0,
"west" => -90.0,
"north" => -180.0,
"east" => -270.0,
_ => 180.0,
};
let packed = BlockPos::new(key.0, key.1, key.2).pack() as u64;
let seed = (packed ^ (packed >> 32)) as i32;
let light = vertex_light(self.world.light(key.0, key.1, key.2));
for (slot, stack) in shelf.items.iter().enumerate() {
let Some(stack) = stack else { continue };
let item = self.resolve_item(
stack,
DisplayContext::OnShelf,
None,
seed.wrapping_add(slot as i32),
);
if item.is_empty() {
continue;
}
let (lo, hi) = item.bounding_box();
let offset_y = -lo.y
- if shelf.align_to_bottom {
0.0
} else {
(hi.y - lo.y) / 2.0
};
let pose =
glam::Mat4::from_translation((at - camera).as_vec3() + glam::Vec3::splat(0.5))
* glam::Mat4::from_rotation_y(yaw.to_radians())
* glam::Mat4::from_translation(glam::Vec3::new(
(slot as f32 - 1.0) * 0.3125,
if shelf.align_to_bottom { -0.25 } else { 0.0 },
-0.25,
))
* glam::Mat4::from_scale(glam::Vec3::splat(0.25))
* glam::Mat4::from_translation(glam::Vec3::new(0.0, offset_y, 0.0));
self.submit_item_world(&item, pose, light, out);
}
}
}
}
+26 -1
View File
@@ -234,6 +234,7 @@ pub(super) fn spawn_loader(
&settings,
started,
true,
true,
&progress,
&mut |gui, root| {
let _ = sender.send(LoaderMsg::Ui(gui, root.to_path_buf()));
@@ -340,6 +341,9 @@ impl Loading {
self.save_menu_settings();
self.apply_menu_settings();
}
Event::SliderDrag => {
self.apply_menu_settings();
}
Event::Close if self.menu.options.resource_error.is_some() => {}
Event::Close => {
self.menu.options_open = false;
@@ -362,6 +366,15 @@ impl Loading {
let _ = std::fs::create_dir_all(&dir);
packs::open_folder(&dir);
}
Event::DataPacks(ids) => {
if let Some(title) = &mut self.menu.title {
title.set_data_packs(ids);
}
}
Event::OpenDataPackFolder(dir) => {
let _ = std::fs::create_dir_all(&dir);
packs::open_folder(&dir);
}
Event::OpenModFolder => {
let dir = Game::options_path().with_file_name(horizon_client::mod_list::DIR);
let _ = std::fs::create_dir_all(&dir);
@@ -423,6 +436,11 @@ impl Loading {
Action::Options => self.open_menu_options(Page::Main),
Action::Accessibility => self.open_menu_options(Page::Accessibility),
Action::Language => self.open_menu_options(Page::Language),
Action::DataPacks { dir, selected } => {
let resources = self.vanilla_root.clone();
self.menu.options.open_data_packs(dir, selected, resources);
self.menu.options_open = true;
}
action => {
let name = self
.options
@@ -611,7 +629,13 @@ impl Loading {
}
pub(super) fn draw_menu(&mut self, batch: &mut HudBatch) {
let Some(gui) = &self.gui else { return };
let logs = horizon_client::logging::recent_lines();
let Some(gui) = &self.gui else {
// The pack's screens do not exist yet: the boot screen holds the
// window, the wordmark and the console's lines while they load.
horizon_client::ui_theme::draw_boot(&self.boot, batch, &logs);
return;
};
if self.menu.pending.is_some() {
horizon_client::level_loading::draw_progress(
batch,
@@ -698,6 +722,7 @@ mod tests {
Loading {
window: None,
renderer: None,
boot: horizon_client::hud::HudAtlas::boot(),
gui: None,
settings: Settings::default(),
fullscreen: horizon_client::window::FullscreenState::new(false),
@@ -1997,6 +1997,16 @@ impl Game<'_> {
{
push(&mut lines, text, rgba(85, 85, 255, 255));
}
// SuspiciousStewEffects.addToTooltip: the creative flag, which vanilla
// sets for the creative screen's own slots, as `name_visible` above.
for (text, c) in horizon_client::item_tooltip::stew_lines(
stack,
!nested && self.screen == Screen::Creative,
&display,
&translate,
) {
push(&mut lines, text, c);
}
// BlockItemStateProperties.addToTooltip.
if let Some(text) = horizon_client::item_tooltip::honey_line(stack, &display, &translate) {
push(&mut lines, text, gray);
+9 -2
View File
@@ -394,8 +394,15 @@ impl Game<'_> {
256.0,
256.0,
);
let title = self.translate("container.shulkerBox", "Shulker Box");
batch.text(atlas, &title, ox + 8.0, oy + 6.0, grey, false);
// ShulkerBoxScreen draws the menu's title: the box's custom name
// (BaseContainerBlockEntity.getDisplayName) or its default name.
let title = self.remote_menu_title().unwrap_or_else(|| {
Text::literal(
self.container_title(self.translate("container.shulkerBox", "Shulker Box")),
)
.resolve_legacy()
});
batch.text_styled(atlas, &title, ox + 8.0, oy + 6.0, grey, false);
let inventory = self.translate("container.inventory", "Inventory");
batch.text(atlas, &inventory, ox + 8.0, oy + 73.0, grey, false);
return;
+42 -1
View File
@@ -157,6 +157,18 @@ fn components_tag(s: &Stack, builtin: &Builtin, synced: &Dynamic) -> Option<Tag>
("mob_factor", Tag::Float(v[5].to_bits())),
]),
Component::Profile(p) => profile_tag(p),
// SuspiciousStewEffects' NBT: each effect by name and its duration.
Component::StewEffects(list) => Tag::List(
list.iter()
.filter_map(|(id, duration)| {
let name = builtin.get("minecraft:mob_effect")?.name(*id)?;
Some(Tag::compound([
("id", Tag::String(name.into())),
("duration", Tag::Int(*duration)),
]))
})
.collect(),
),
// PotionContents' NBT: the potion by name and the custom colour.
Component::Potion { potion, color, .. } => {
let potion = potion.and_then(|id| builtin.get("minecraft:potion")?.name(id));
@@ -393,6 +405,7 @@ mod tests {
};
let dynamic = Dynamic {
registries: Default::default(),
..Default::default()
};
let mut bundle = ItemStack::new("minecraft:bundle", 1);
bundle.data_mut().bundle_contents = vec![ItemStack::new("minecraft:stone", 32)];
@@ -412,7 +425,8 @@ mod tests {
}
/// What a creative client sends (`stack_to_network`) comes back whole on
/// the server: damage, both enchantment lists, repair cost, name, trim.
/// the server: damage, both enchantment lists, repair cost, name, trim,
/// potion and stew effects.
#[test]
fn a_creative_stack_keeps_its_components_across_the_wire() {
let mut components = vec![String::new(); 60];
@@ -428,6 +442,10 @@ mod tests {
"minecraft:stored_enchantments",
),
(component::TRIM, "minecraft:trim"),
(
component::SUSPICIOUS_STEW_EFFECTS,
"minecraft:suspicious_stew_effects",
),
] {
components[id as usize] = name.to_owned();
}
@@ -440,6 +458,7 @@ mod tests {
"minecraft:air",
"minecraft:diamond_chestplate",
"minecraft:splash_potion",
"minecraft:suspicious_stew",
])),
),
(
@@ -450,6 +469,10 @@ mod tests {
"minecraft:potion".to_owned(),
Ids::from_names(names(&["minecraft:water", "minecraft:swiftness"])),
),
(
"minecraft:mob_effect".to_owned(),
Ids::from_names(names(&["minecraft:speed", "minecraft:fire_resistance"])),
),
]
.into(),
};
@@ -470,6 +493,7 @@ mod tests {
),
]
.into(),
..Default::default()
};
let mut stack = ItemStack::new("minecraft:diamond_chestplate", 1);
stack.damage = 5;
@@ -530,5 +554,22 @@ mod tests {
from_network(&wire, &builtin, &dynamic).potion(),
Some("minecraft:swiftness")
);
// SuspiciousStewEffects, as a creative tab's stew has them: without
// them the server's stew gives no effect when eaten (#3137).
let mut stew = ItemStack::new("minecraft:suspicious_stew", 1);
stew.data_mut()
.stew_effects
.push((Arc::from("minecraft:fire_resistance"), 60));
let wire = crate::lan::guest::stack_to_network(
|name| id("minecraft:item", name).unwrap_or(0),
id,
&Some(stew),
)
.unwrap();
let back = from_network(&wire, &builtin, &dynamic);
assert_eq!(
back.data().map(|d| d.stew_effects.clone()),
Some(vec![(Arc::from("minecraft:fire_resistance"), 60)])
);
}
}
+123 -4
View File
@@ -434,14 +434,26 @@ pub fn potion_modifier_lines(
if !is_potion_item(&stack.item) {
return Vec::new();
}
let applied: Vec<(String, u32)> = stack
let effects = stack
.potion()
.and_then(crate::brewing::potion)
.into_iter()
.flat_map(|p| p.effects.iter())
.filter_map(|(effect, _, amplifier)| {
let (attribute, amount, operation) = effect_modifier(effect)?;
let amount = amount * (*amplifier + 1) as f64;
.map(|(effect, _, amplifier)| (*effect, *amplifier));
effect_modifier_lines(effects, translate)
}
/// `addPotionTooltip`'s attribute block for (effect id, amplifier) entries.
fn effect_modifier_lines<'a>(
effects: impl IntoIterator<Item = (&'a str, i32)>,
translate: &dyn Fn(&str) -> Option<String>,
) -> Vec<(String, u32)> {
let applied: Vec<(String, u32)> = effects
.into_iter()
.filter_map(|(effect, amplifier)| {
let (attribute, amount, operation) =
effect_modifier(effect.trim_start_matches("minecraft:"))?;
let amount = amount * (amplifier + 1) as f64;
let line = modifier_line(translate, attribute, amount, operation, false)?;
let color = if amount > 0.0 {
rgba(85, 85, 255, 255)
@@ -465,6 +477,48 @@ pub fn potion_modifier_lines(
lines
}
/// `SuspiciousStewEffects.addToTooltip`: only with the creative tooltip flag,
/// the stew's effects as `PotionContents.addPotionTooltip` lists them (each in
/// its `MobEffectCategory` colour, "No Effects" for none), then their
/// attribute modifiers.
pub fn stew_lines(
stack: &ItemStack,
creative: bool,
display: &TooltipDisplay,
translate: &dyn Fn(&str) -> Option<String>,
) -> Vec<(String, u32)> {
let effects = stack.data().map_or(&[][..], |d| &d.stew_effects[..]);
// The component is the item's default (empty) on a suspicious stew.
if !creative
|| (effects.is_empty() && &*stack.item != "minecraft:suspicious_stew")
|| !display.shows("minecraft:suspicious_stew_effects")
{
return Vec::new();
}
if effects.is_empty() {
let none = translate("effect.none").unwrap_or_else(|| "No Effects".into());
return vec![(none, rgba(170, 170, 170, 255))];
}
// `Entry.createEffectInstance`: amplifier 0.
let entries = effects.iter().map(|(id, duration)| (&**id, *duration, 0));
let mut lines: Vec<(String, u32)> = crate::brewing::effect_lines(entries, 1.0, translate)
.into_iter()
.map(|(line, category)| {
// MobEffectCategory.getTooltipFormatting: harmful red, else blue.
let color = match category {
crate::brewing::Category::Harmful => rgba(255, 85, 85, 255),
_ => rgba(85, 85, 255, 255),
};
(line, color)
})
.collect();
lines.extend(effect_modifier_lines(
effects.iter().map(|(id, _)| (&**id, 0)),
translate,
));
lines
}
/// `ItemStack.components.size()`: the item's default components with the
/// stack's own changes applied.
pub fn component_count(stack: &ItemStack, info: Option<&ItemInfo>) -> usize {
@@ -533,6 +587,17 @@ pub fn detail_lines(
let display = TooltipDisplay::for_stack(stack);
lines.extend(bees_line(stack, &display, translate));
lines.extend(honey_line(stack, &display, translate));
// InstrumentComponent.addToTooltip: the instrument's description.
if display.shows("minecraft:instrument") {
if let Some(id) = crate::items::instrument_id(stack) {
let (namespace, name) = id.split_once(':').unwrap_or(("minecraft", &id));
lines.push(text(&format!("instrument.{namespace}.{name}")));
} else if let Some(description) =
crate::items::inline_instrument(stack).and_then(|tag| tag.get("description"))
{
lines.push(crate::entity_nbt::name_text(description).plain());
}
}
let data = stack.data();
// PotDecorations.addToTooltip.
if let Some(sherds) = data
@@ -754,6 +819,7 @@ pub fn lore_texts(stack: &ItemStack) -> Vec<crate::text::Text> {
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Arc;
#[test]
fn lore_keeps_component_colours_under_dark_purple_italic() {
@@ -820,6 +886,59 @@ mod tests {
assert!(!display.shows("minecraft:attribute_modifiers"));
}
#[test]
fn stew_effects_show_only_with_the_creative_flag() {
let translate = |key: &str| {
Some(
match key {
"effect.minecraft.fire_resistance" => "Fire Resistance",
"effect.minecraft.weakness" => "Weakness",
"effect.minecraft.saturation" => "Saturation",
"effect.none" => "No Effects",
"potion.whenDrank" => "When Applied:",
"attribute.modifier.take.0" => "-%s %s",
"attribute.name.attack_damage" => "Attack Damage",
_ => return None,
}
.to_owned(),
)
};
let (blue, red) = (rgba(85, 85, 255, 255), rgba(255, 85, 85, 255));
let lines = |stack: &ItemStack, creative: bool| {
stew_lines(
stack,
creative,
&TooltipDisplay::for_stack(stack),
&translate,
)
};
let mut stew = ItemStack::new("minecraft:suspicious_stew", 1);
// The item's default, an empty list.
assert_eq!(
lines(&stew, true),
[("No Effects".to_owned(), rgba(170, 170, 170, 255))]
);
stew.data_mut().stew_effects = vec![
(Arc::from("minecraft:fire_resistance"), 60),
(Arc::from("minecraft:weakness"), 140),
(Arc::from("minecraft:saturation"), 7),
];
assert!(lines(&stew, false).is_empty());
assert_eq!(
lines(&stew, true),
[
("Fire Resistance (0:03)".to_owned(), blue),
("Weakness (0:07)".to_owned(), red),
// 20 ticks or less: no duration.
("Saturation".to_owned(), blue),
(String::new(), u32::MAX),
("When Applied:".to_owned(), rgba(170, 0, 170, 255)),
("-4 Attack Damage".to_owned(), red),
]
);
assert!(lines(&ItemStack::new("minecraft:bowl", 1), true).is_empty());
}
#[test]
fn ominous_banner_keeps_its_default_hidden_pattern_tooltip() {
let mut banner = crate::mobs::raider::ominous_banner();
+17
View File
@@ -940,6 +940,23 @@ pub fn entity_tag(entity: &BlockEntity) -> Option<Tag> {
match entity {
BlockEntity::Sign(sign) => Some(Tag::compound(sign.encode())),
BlockEntity::Spawner(s) => Some(s.update_tag()),
BlockEntity::Brushable(b) => {
let mut entries = Vec::new();
if let Some(face) = b.hit_direction {
entries.push(("hit_direction", Tag::Byte(face as i8)));
}
if let Some(item) = &b.item {
entries.push(("item", crate::save::item_to_nbt(item)));
}
Some(Tag::compound(entries))
}
BlockEntity::Shelf(s) => Some(Tag::compound([
("Items", crate::save::encode_items(&s.items)),
(
"align_items_to_bottom",
Tag::Byte(i8::from(s.align_to_bottom)),
),
])),
// BannerBlockEntity.getUpdateTag: saveWithoutMetadata, so the patterns,
// the custom name and the components.
BlockEntity::Banner(b) => {
+15 -5
View File
@@ -32,9 +32,12 @@ use std::{
time::{Duration, Instant},
};
/// ServerCommonPacketListenerImpl.KEEPALIVE_TIME and the disconnect timeout.
/// ServerCommonPacketListenerImpl.keepConnectionAlive: a keep-alive every 15 s,
/// and a client that has not answered the last one by the next is dropped.
const KEEP_ALIVE_EVERY: Duration = Duration::from_secs(15);
const KEEP_ALIVE_TIMEOUT: Duration = Duration::from_secs(30);
/// ReadTimeoutHandler(30) in ServerConnectionListener: a connection that sends
/// nothing for 30 s.
const READ_TIMEOUT: Duration = Duration::from_secs(30);
/// Packets queued for one client beyond this many bytes disconnect it.
const MAX_PENDING: usize = 96 << 20;
/// Vanilla's server-side disconnect during login is a JSON component.
@@ -677,9 +680,16 @@ fn serve_login(
_ => return Err("expected a login hello".into()),
};
if !valid_name(&hello.name) {
// handleHello's validState throws; Connection.exceptionCaught sends the
// exception as the login disconnect.
login_disconnect(
&mut c,
&translatable("multiplayer.disconnect.invalid_name", &[]),
&translatable(
"disconnect.genericReason",
&[
"Internal Exception: java.lang.IllegalStateException: Invalid characters in username",
],
),
);
return Ok(());
}
@@ -950,7 +960,7 @@ fn offer_pack(
fn play(c: Connection, shared: &Arc<Shared>, parts: Parts, address: std::net::SocketAddr) {
let conn_id = shared.next_id();
let events = shared.events.clone();
let _ = c.reader.set_read_timeout(Some(KEEP_ALIVE_TIMEOUT));
let _ = c.reader.set_read_timeout(Some(READ_TIMEOUT));
let (mut reader, writer) = c.split();
let (tx, rx) = channel();
let pending = Arc::new(AtomicUsize::new(0));
@@ -1165,7 +1175,7 @@ fn write_loop(
let old = latency.load(Ordering::Relaxed);
latency.store((old * 3 + ms) / 4, Ordering::Relaxed);
sent_at = None;
} else if at.elapsed() > KEEP_ALIVE_TIMEOUT {
} else if at.elapsed() >= KEEP_ALIVE_EVERY {
let _ = writer.send(
&play::Clientbound::Disconnect(cfg::Disconnect {
reason: translatable("disconnect.timeout", &[]),
+15
View File
@@ -699,6 +699,21 @@ fn network_stack(
},
));
}
// SuspiciousStewEffects: each effect by its `minecraft:mob_effect` id.
if !d.stew_effects.is_empty() {
let effects = d
.stew_effects
.iter()
.filter_map(|(effect, duration)| {
let name = horizon_world::datafix::dynamic::namespaced(effect);
Some((synced_id("minecraft:mob_effect", &name)?, *duration))
})
.collect();
stack.added.push((
horizon_protocol::item::component::SUSPICIOUS_STEW_EFFECTS,
horizon_protocol::item::Component::StewEffects(effects),
));
}
// A loaded crossbow's ammunition.
if !d.charged.is_empty() {
let charged = d
+2 -2
View File
@@ -8,7 +8,7 @@ pub mod arrows;
pub use horizon_client_render::{
assets, atlas_sources, auth, bidi, biome, block_entities, block_entity_anim,
block_entity_render, border_render, breaking, brewing, camera, chat, chunk, chunk_map, clouds,
collisions, command_block, containers, crash, dynamic_light, effects, environment, extras,
collisions, command_block, containers, crash, datapack, dynamic_light, effects, environment, extras,
fast_hash, fog, font, gfx, graphics, graphics_style, graphics_ui, hud, hud_layout,
hud_overlays, inventory_effects, java_text, level_loading, light, lod, logging, menu_blur,
mesher, minimap, mipmap, mod_list, model_library, models, options_file, options_fixes,
@@ -37,7 +37,6 @@ pub mod crafting;
pub mod creative;
pub mod cushion;
pub mod data_cmd;
pub mod datapack;
pub mod debug_chart;
pub mod debug_keys;
pub mod debug_overlay;
@@ -45,6 +44,7 @@ pub mod dialog;
pub mod dialog_screen;
pub mod discord_presence;
pub mod end_crystal;
pub mod end_gateway;
pub mod entities;
pub mod entity_culling;
pub mod entity_nbt;
+12
View File
@@ -244,6 +244,8 @@ pub enum ArmPose {
CrossbowCharge,
/// Raising a trident to throw it (or to launch with Riptide).
ThrowTrident,
/// Playing a goat horn.
TootHorn,
/// A spear held out (`HumanoidModel.ArmPose.SPEAR`), at rest or charging.
Spear,
}
@@ -394,6 +396,10 @@ fn pose_arm(p: &mut Poses, pose: ArmPose, right: bool, h: &Hum) {
p.xrot(arm, x * 0.5 - PI);
p.yrot(arm, 0.0);
}
ArmPose::TootHorn => {
p.xrot(arm, hx.clamp(-1.2, 1.2) - 1.483_529_8);
p.yrot(arm, hy + if right { -FRAC_PI_6 } else { FRAC_PI_6 });
}
ArmPose::CrossbowHold => crossbow_hold(p, hx, hy, right),
ArmPose::CrossbowCharge => crossbow_charge(p, h.charge.0, h.charge.1, right),
// SpearAnimations.thirdPersonHandUse.
@@ -748,6 +754,7 @@ pub fn setup(
Some(_) if using_main && m.extra.blocking => ArmPose::Block,
Some("minecraft:bow") if using_main => ArmPose::Bow,
Some("minecraft:trident") if using_main => ArmPose::ThrowTrident,
Some("minecraft:goat_horn") if using_main => ArmPose::TootHorn,
Some("minecraft:crossbow") if using_main && !m.extra.charged => {
ArmPose::CrossbowCharge
}
@@ -766,6 +773,11 @@ pub fn setup(
ArmPose::Spear
} else if m.extra.using_item > 0 && m.extra.using_off_hand && m.extra.blocking {
ArmPose::Block
} else if m.extra.using_item > 0
&& m.extra.using_off_hand
&& m.extra.off_hand.as_deref() == Some("minecraft:goat_horn")
{
ArmPose::TootHorn
} else {
ArmPose::Item
}
+21 -2
View File
@@ -705,6 +705,14 @@ impl PlayerMotion {
/// Vehicle, item-use and effect bindings remain separate; hunger, flight,
/// water and collision conditions use this actor's actual state.
pub fn sprint_input_tick(&mut self, keys: crate::sprint::Keys) -> Result<()> {
self.sprint_input_tick_with_window(keys, 7)
}
/// The same phase with Java's `Options.sprintWindow` value.
pub fn sprint_input_tick_with_window(
&mut self,
keys: crate::sprint::Keys,
sprint_window: i32,
) -> Result<()> {
let conditions = crate::sprint::Conditions {
sprinting: self.sprinting,
enough_food: self.health.food.has_enough_food(),
@@ -725,7 +733,9 @@ impl PlayerMotion {
// The sprint setter edits attributes and flags, independent of local
// keyboard history. Retain the input prefix even if that setter fails.
let mut state = self.sprint_input;
let result = state.tick(keys, conditions, 7, |value| self.set_sprinting(value));
let result = state.tick(keys, conditions, sprint_window.clamp(0, 10), |value| {
self.set_sprinting(value)
});
self.sprint_input = state;
result
}
@@ -992,6 +1002,14 @@ impl MotionWorld<'_, '_, '_> {
.food_tick(&self.resources.health, self.scene.health_rules)
}
pub fn controlled_simulation_tick(&mut self, keys: crate::sprint::Keys) -> Result<()> {
self.controlled_simulation_tick_with_sprint_window(keys, 7)
}
/// Local simulation with the configured double-tap sprint window.
pub fn controlled_simulation_tick_with_sprint_window(
&mut self,
keys: crate::sprint::Keys,
sprint_window: i32,
) -> Result<()> {
// The base tick (fluids, swimming) precedes LocalPlayer.aiStep.
self.player.health.advance_clock();
self.fluid_base_tick()?;
@@ -1004,7 +1022,8 @@ impl MotionWorld<'_, '_, '_> {
self.player.auto_jump_time -= 1;
keys.jump = true;
}
self.player.sprint_input_tick(keys)?;
self.player
.sprint_input_tick_with_window(keys, sprint_window)?;
self.player.shift_key_down = keys.shift;
let direction = keys.direction();
self.player.move_vector = direction;
+1
View File
@@ -625,6 +625,7 @@ fn configure(
}))?
}
cfg::Clientbound::RegistryData(r) => registries.add(r),
cfg::Clientbound::UpdateTags(t) => registries.add_tags(&t),
cfg::Clientbound::CodeOfConduct(_) => {
reply(cfg::Serverbound::AcceptCodeOfConduct(Empty))?
}
@@ -2,6 +2,10 @@
//! (`Entity.setAsInsidePortal` / `handlePortal`) for nether portals.
use horizon_core::position::BlockPos;
/// `Entity.getDimensionChangingDelay`: the portal cooldown of an entity that
/// went through a portal.
pub const ENTITY_DELAY: i32 = 300;
/// `PortalProcessor` for a nether portal.
#[derive(Clone, Copy, Debug)]
pub struct Process {
+299 -32
View File
@@ -42,10 +42,11 @@ use std::{
fs,
path::{Path, PathBuf},
sync::{
Arc, Condvar, Mutex, OnceLock,
Arc, Condvar, Mutex, OnceLock, RwLock,
atomic::{AtomicU64, Ordering},
mpsc,
},
time::Instant,
};
pub const VERSION_NAME: &str = horizon_core::VANILLA_VERSION;
@@ -54,10 +55,10 @@ pub const VERSION_NAME: &str = horizon_core::VANILLA_VERSION;
/// mutating the world. Each map is visited once, retaining its iteration order
/// within each chunk (including repeated chunk coordinates in the input).
/// An empty batch skips both maps; one chunk uses the direct filter path.
pub fn chunks_with_block_entities<'a>(
pub fn chunks_with_block_entities<'a, N: std::hash::BuildHasher>(
chunks: impl IntoIterator<Item = &'a Arc<Chunk>>,
entities: &HashMap<(i32, i32, i32), crate::containers::BlockEntity>,
names: &HashMap<(i32, i32, i32), String>,
names: &HashMap<(i32, i32, i32), String, N>,
) -> Vec<Chunk> {
let chunks: Vec<_> = chunks.into_iter().collect();
if chunks.is_empty() {
@@ -470,6 +471,13 @@ pub fn encode_block_entity(
}
}
BlockEntity::Sculk(s) => entries.extend(s.to_nbt()),
BlockEntity::Shelf(shelf) => {
entries.push(("Items", encode_items(&shelf.items)));
entries.push((
"align_items_to_bottom",
Tag::Byte(i8::from(shelf.align_to_bottom)),
));
}
// ChiseledBookShelfBlockEntity.saveAdditional.
BlockEntity::ChiseledBookshelf(b) => {
entries.push(("Items", encode_items(&b.items)));
@@ -534,6 +542,16 @@ pub fn encode_block_entity(
BlockEntity::Vault(v) => entries.extend(v.save()),
BlockEntity::Beehive(h) => entries.extend(h.to_nbt()),
BlockEntity::CommandBlock(c) => entries.extend(c.save()),
BlockEntity::Brushable(b) => {
if let Some(table) = &b.loot_table {
entries.push(("LootTable", table.clone()));
if let Some(seed) = &b.loot_seed {
entries.push(("LootTableSeed", seed.clone()));
}
} else if let Some(item) = &b.item {
entries.push(("item", item_to_nbt(item)));
}
}
BlockEntity::DaylightDetector => {}
}
Tag::compound(entries)
@@ -711,8 +729,24 @@ pub fn decode_block_entity(tag: &Tag) -> Option<((i32, i32, i32), crate::contain
_ => 1.0,
};
}
BlockEntity::Brushable(b) => {
b.loot_table = tag.get("LootTable").cloned();
b.loot_seed = tag.get("LootTableSeed").cloned();
b.item = if b.loot_table.is_none() {
tag.get("item").and_then(item_from_nbt)
} else {
None
};
b.hit_direction = int("hit_direction").filter(|v| (0..=5).contains(v));
}
BlockEntity::Sign(sign) => sign.decode(tag),
BlockEntity::Sculk(s) => s.load(tag),
BlockEntity::Shelf(shelf) => {
for (slot, item) in decode_items(tag.get("Items"), 3).into_iter().enumerate() {
shelf.items[slot] = item;
}
shelf.align_to_bottom = int("align_items_to_bottom").unwrap_or(0) != 0;
}
// ChiseledBookShelfBlockEntity.loadAdditional.
BlockEntity::ChiseledBookshelf(b) => {
for (slot, item) in decode_items(tag.get("Items"), 6).into_iter().enumerate() {
@@ -1613,7 +1647,43 @@ pub fn load_light(tag: &Tag, chunk: &mut Chunk, sky_light: bool) {
chunk.light_loaded = true;
}
type RegionCache = Mutex<HashMap<(i32, i32), Option<Arc<Mutex<RegionFile>>>>>;
struct CachedRegion {
/// `None` records a missing file, so a read does not retry the open.
region: Option<Arc<RwLock<RegionFile>>>,
/// When the cache last handed it out; the least recently used goes first.
used: Instant,
}
type RegionCache = Mutex<HashMap<(i32, i32), CachedRegion>>;
/// Enough descriptors for a long exploration without approaching the low
/// soft limits of some systems; a region reopened later re-reads its header.
const REGION_CACHE_CAP: usize = 64;
/// A region nobody used for this long is closed even below the cap.
const REGION_IDLE: std::time::Duration = std::time::Duration::from_secs(60);
/// Close the least recently used unheld regions past `cap`, and any idle one.
/// An `Arc` a reader or the writer holds keeps its region open: dropping the
/// last one closes the descriptor.
fn trim_region_cache(regions: &mut HashMap<(i32, i32), CachedRegion>, cap: usize) {
let now = Instant::now();
let mut order: Vec<_> = regions
.iter()
.map(|(&key, cached)| (cached.used, key))
.collect();
order.sort_unstable();
for (used, key) in order {
if regions.len() <= cap && now.duration_since(used) < REGION_IDLE {
break;
}
let unheld = regions.get(&key).is_some_and(|cached| {
cached
.region
.as_ref()
.is_none_or(|region| Arc::strong_count(region) == 1)
});
if unheld {
regions.remove(&key);
}
}
}
/// The region file at `path` through the cache, which readers and the writer
/// share, so a read never sees a header older than the sectors it points to.
/// `create` makes a missing file (for a write); otherwise a missing file is
@@ -1623,13 +1693,13 @@ fn cached_region(
key: (i32, i32),
path: &Path,
create: bool,
) -> Result<Option<Arc<Mutex<RegionFile>>>> {
) -> Result<Option<Arc<RwLock<RegionFile>>>> {
let mut regions = cache.lock().map_err(|_| "region cache poisoned")?;
if let Some(Some(region)) = regions.get(&key) {
return Ok(Some(region.clone()));
}
if !create && regions.contains_key(&key) {
return Ok(None);
if let Some(cached) = regions.get_mut(&key) {
cached.used = Instant::now();
if cached.region.is_some() || !create {
return Ok(cached.region.clone());
}
}
if create && let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
@@ -1642,11 +1712,18 @@ fn cached_region(
other => other,
};
let region = match region {
Ok(r) => Some(Arc::new(Mutex::new(r))),
Ok(r) => Some(Arc::new(RwLock::new(r))),
Err(_) if !create => None,
Err(e) => return Err(e.into()),
};
regions.insert(key, region.clone());
regions.insert(
key,
CachedRegion {
region: region.clone(),
used: Instant::now(),
},
);
trim_region_cache(&mut regions, REGION_CACHE_CAP);
Ok(region)
}
/// Write `records` into the region files of `folder` in place, as vanilla's
@@ -1680,18 +1757,9 @@ fn write_region_records(
let Some(region) = cached_region(cache, (rx, rz), &path, create)? else {
continue;
};
let mut region = region.lock().map_err(|_| "region file poisoned")?;
let mut changed = false;
for (pos, record) in &list {
match record {
Some(record) => {
region.write(*pos, record)?;
changed = true;
}
None => changed |= region.clear(*pos, timestamp)?,
}
}
if changed {
let mut region = region.write().map_err(|_| "region file poisoned")?;
let batch: Vec<_> = list.iter().map(|(p, r)| (*p, r.as_ref())).collect();
if region.write_batch(&batch, timestamp)? {
region.sync()?;
}
}
@@ -1826,8 +1894,9 @@ fn read_region_record(
let Some(region) = cached_region(cache, key, &path, false)? else {
return Ok(None);
};
// A read lock: several loaders can read one region at the same time.
let record = region
.lock()
.read()
.map_err(|_| "region file poisoned")?
.read(ChunkPos::new(x, z))?;
Ok(match record {
@@ -1892,10 +1961,33 @@ pub struct SaveDir {
pub tables: OnceLock<Arc<SaveTables>>,
/// The dimension's skylight and height, from its folder name.
pub shape: LevelShape,
/// Which chunks each region file holds, for far terrain (`lod_saved`).
lod_regions: Mutex<crate::fast_hash::FxHashMap<(i32, i32), LodRegion>>,
/// Saved chunks decoded for far terrain, shared by its sampling threads
/// (`lod_surfaces_shared`), oldest first.
lod_decoded: Mutex<LodDecoded>,
}
fn is_forced_ticket(ticket: &Tag) -> bool {
matches!(ticket.get("type"), Some(Tag::String(t)) if *t == JavaString::from("minecraft:forced"))
}
#[derive(Default)]
struct LodDecoded {
chunks: crate::fast_hash::FxHashMap<(i32, i32), (std::time::Instant, LodSurfaces)>,
order: std::collections::VecDeque<(i32, i32)>,
}
/// Saved chunks kept decoded for far terrain (each tens of kilobytes), and
/// for how long: edits saved later show after it.
const LOD_DECODED: usize = 1024;
const LOD_DECODED_TTL: std::time::Duration = std::time::Duration::from_secs(30);
/// A region file's saved chunks (bit `x + z * 32`), `None` without the file,
/// as of `checked`.
struct LodRegion {
checked: std::time::Instant,
chunks: Option<[u64; 16]>,
}
/// How long far terrain trusts a region file's chunk list: chunks saved after
/// it was read show in the far view this long after.
const LOD_REGION_TTL: std::time::Duration = std::time::Duration::from_secs(10);
impl SaveDir {
pub fn new(root: PathBuf) -> Self {
let shape = LevelShape::for_dir(&root);
@@ -1910,6 +2002,8 @@ impl SaveDir {
outstanding: Default::default(),
tables: OnceLock::new(),
shape,
lod_regions: Default::default(),
lod_decoded: Default::default(),
}
}
/// 26.3's TicketStorage: persistent tickets live in each dimension's
@@ -1956,6 +2050,40 @@ impl SaveDir {
put(&mut data, "tickets", Tag::List(tickets));
write_saved_data(&self.root, "chunk_tickets", data)
}
/// Whether chunk `x z` is in its region file, from a chunk list kept
/// `LOD_REGION_TTL`. Far terrain asks for every column of the view: it
/// opened the region file each time, also where there is none, which
/// held the far view back for minutes.
fn lod_saved(&self, x: i32, z: i32) -> bool {
let key = (x >> 5, z >> 5);
let mut regions = self.lod_regions.lock().unwrap();
let fresh = regions
.get(&key)
.is_some_and(|region| region.checked.elapsed() < LOD_REGION_TTL);
if !fresh {
let chunks = RegionFile::open(self.region_path("region", key.0, key.1))
.ok()
.map(|region| {
let mut bits = [0u64; 16];
for (pos, _) in region.chunks() {
let index = ((pos.x & 31) + (pos.z & 31) * 32) as usize;
bits[index / 64] |= 1 << (index % 64);
}
bits
});
regions.insert(
key,
LodRegion {
checked: std::time::Instant::now(),
chunks,
},
);
}
let index = ((x & 31) + (z & 31) * 32) as usize;
regions[&key]
.chunks
.is_some_and(|bits| bits[index / 64] & (1 << (index % 64)) != 0)
}
fn region_path(&self, folder: &str, rx: i32, rz: i32) -> PathBuf {
region_path(&self.root, folder, rx, rz)
}
@@ -3362,11 +3490,18 @@ pub struct Persistent<G> {
pub biomes: Arc<Biomes>,
}
type LodSurfaces = Arc<[crate::lod::Column; 256]>;
/// The coarsest far tile spacing whose columns read saved chunks: 16 blocks
/// a column, and 4 with Reduced far detail (`LodSampler::set_approximate`).
const SAVED_LOD_STRIDE: i32 = 16;
const SAVED_LOD_STRIDE_REDUCED: i32 = 4;
struct SavedLodSampler<G: ChunkGenerator> {
generator: Arc<Persistent<G>>,
inner: Box<dyn crate::streaming::LodSampler>,
cache: std::collections::VecDeque<((i32, i32), Option<LodSurfaces>)>,
details: Vec<crate::streaming::LodDetail>,
/// Reduced far detail: saved chunks only in tiles up to
/// `SAVED_LOD_STRIDE_REDUCED` blocks a column.
approximate: bool,
}
impl<G: ChunkGenerator> SavedLodSampler<G> {
fn saved_columns(&mut self, key: (i32, i32)) -> Option<LodSurfaces> {
@@ -3374,7 +3509,7 @@ impl<G: ChunkGenerator> SavedLodSampler<G> {
Some(index) => self.cache.remove(index).unwrap(),
None => (
key,
self.generator.save.lod_surfaces(
self.generator.save.lod_surfaces_shared(
key.0,
key.1,
&self.generator.registry,
@@ -3395,8 +3530,13 @@ impl<G: ChunkGenerator> crate::streaming::LodSampler for SavedLodSampler<G> {
inner: self.inner.fork()?,
cache: Default::default(),
details: Vec::new(),
approximate: self.approximate,
}))
}
fn set_approximate(&mut self, approximate: bool) {
self.approximate = approximate;
self.inner.set_approximate(approximate);
}
fn request_detail(&mut self, chunks: &[(i32, i32)], center: [i32; 2]) {
let mut missing = Vec::new();
for &key in chunks {
@@ -3419,7 +3559,18 @@ impl<G: ChunkGenerator> crate::streaming::LodSampler for SavedLodSampler<G> {
}
fn grid(&mut self, x: i32, z: i32, stride: i32, side: usize) -> Vec<crate::lod::Column> {
let mut columns = self.inner.grid(x, z, stride, side);
if stride <= 16 {
// Saved chunks show in tiles up to 16 blocks a column. With Reduced far
// detail only up to 4 (within about a kilometre): coarser tiles keep
// the generator's terrain, which saved chunks were made from, since
// decoding up to 256 saved chunks for each of them held the whole far
// view back for minutes in large saves. Edits that far away show once
// their tiles refine on approach.
let limit = if self.approximate {
SAVED_LOD_STRIDE_REDUCED
} else {
SAVED_LOD_STRIDE
};
if stride <= limit {
for (index, column) in columns.iter_mut().enumerate() {
let (wx, wz) = (
x + (index % side) as i32 * stride,
@@ -3583,7 +3734,16 @@ fn decode_lod_surfaces(
descriptor.name.as_str(),
"minecraft:water" | "minecraft:lava"
);
(state, fluid, properties.blocks_motion_in_heightmap())
// Chorus is thin and non-solid but still far-End detail.
let chorus = matches!(
descriptor.name.as_str(),
"minecraft:chorus_plant" | "minecraft:chorus_flower"
);
(
state,
fluid,
fluid || properties.blocks_motion_in_heightmap() || chorus,
)
}))
})
.collect::<Option<_>>()?;
@@ -3607,10 +3767,10 @@ fn decode_lod_surfaces(
for y in (0..16).rev() {
let index = y * 256 + cell;
let palette_index = lod_palette_index(blocks, index, 4096, 4)?;
let Some((state, fluid, solid)) = palette[palette_index] else {
let Some((state, fluid, kept)) = palette[palette_index] else {
continue;
};
if !solid && !fluid {
if !kept {
continue;
}
let biome = section
@@ -3669,6 +3829,33 @@ fn decode_lod_surfaces(
}
impl SaveDir {
/// `lod_surfaces`, decoded once for all far sampling threads: each saved
/// chunk lies under far tiles of several levels, sampled on any thread.
fn lod_surfaces_shared(
&self,
x: i32,
z: i32,
assets: &crate::assets::Assets,
biomes: &Biomes,
) -> Option<LodSurfaces> {
if let Some((at, columns)) = self.lod_decoded.lock().unwrap().chunks.get(&(x, z))
&& at.elapsed() < LOD_DECODED_TTL
{
return Some(columns.clone());
}
let columns = self.lod_surfaces(x, z, assets, biomes)?;
let mut decoded = self.lod_decoded.lock().unwrap();
let fresh = (std::time::Instant::now(), columns.clone());
if decoded.chunks.insert((x, z), fresh).is_none() {
decoded.order.push_back((x, z));
while decoded.order.len() > LOD_DECODED {
if let Some(old) = decoded.order.pop_front() {
decoded.chunks.remove(&old);
}
}
}
Some(columns)
}
fn lod_surfaces(
&self,
x: i32,
@@ -3685,8 +3872,11 @@ impl SaveDir {
let root = if let Some(record) = pending {
record.tag().map(|tag| (*tag).clone())?
} else {
if !self.lod_saved(x, z) {
return None;
}
// Far-only reads must not populate the gameplay region cache across the whole view.
let mut region = RegionFile::open(self.region_path("region", x >> 5, z >> 5)).ok()?;
let region = RegionFile::open(self.region_path("region", x >> 5, z >> 5)).ok()?;
region
.read(ChunkPos::new(x, z))
.ok()??
@@ -3715,6 +3905,7 @@ impl<G: ChunkGenerator> ChunkGenerator for Persistent<G> {
inner,
cache: Default::default(),
details: Vec::new(),
approximate: false,
})
}
fn load(&self, x: i32, z: i32) -> Option<Chunk> {
@@ -4082,6 +4273,18 @@ mod tests {
.registry
.default_state("minecraft:oak_leaves")
.unwrap();
let end_stone = assets
.registry
.default_state("minecraft:end_stone")
.unwrap();
let chorus = assets
.registry
.default_state("minecraft:chorus_plant")
.unwrap();
let flower = assets
.registry
.default_state("minecraft:chorus_flower")
.unwrap();
let mut chunk = Chunk::empty(-2, 3);
chunk.set(0, 109, 0, stone.0);
chunk.set(3, 121, 5, stone.0);
@@ -4108,6 +4311,10 @@ mod tests {
for y in 55..64 {
chunk.set(4, y, 4, water.0);
}
// Chorus is thin and non-solid but still part of the far End.
chunk.set(12, 100, 2, end_stone.0);
chunk.set(12, 101, 2, chorus.0);
chunk.set(12, 102, 2, flower.0);
let mut saved = encode(&chunk, assets.registry, &biomes);
put(
&mut saved.tag,
@@ -4165,6 +4372,25 @@ mod tests {
let underwater = surfaces[4 * 16 + 4].surface.unwrap();
assert_eq!((underwater.height, underwater.state), (55, sand));
assert_eq!(underwater.water, Some((64, water)));
let chorus_column = &surfaces[2 * 16 + 12];
assert_eq!(
chorus_column
.surface
.map(|surface| (surface.height, surface.state)),
Some((103, flower))
);
assert_eq!(
chorus_column
.spans
.iter()
.map(|span| (span.min, span.max, span.state))
.collect::<Vec<_>>(),
vec![
(100, 101, end_stone),
(101, 102, chorus),
(102, 103, flower)
]
);
assert!(surfaces[15].surface.is_none(), "saved void stays empty");
assert!(
reopened.regions.lock().unwrap().is_empty(),
@@ -4219,6 +4445,7 @@ mod tests {
inner: Box::new(SavedOnly(Vec::new())),
cache: Default::default(),
details: Vec::new(),
approximate: false,
};
use crate::streaming::LodSampler;
sampler.request_detail(&[(-2, 3)], [-32, 48]);
@@ -4374,6 +4601,46 @@ mod tests {
}
}
#[test]
fn region_cache_closes_idle_regions_and_reopens_their_header() {
let dir = std::env::temp_dir().join(format!("horizon-region-cache-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let path = dir.join("region/r.0.0.mca");
let cache: super::RegionCache = Default::default();
let record = ChunkRecord {
compression: Compression::None,
compressed: vec![1, 2, 3],
timestamp: 9,
};
let region = super::cached_region(&cache, (0, 0), &path, true)
.unwrap()
.unwrap();
region
.write()
.unwrap()
.write(ChunkPos::new(0, 0), &record)
.unwrap();
// A held Arc keeps its region open, even at a zero cap.
super::trim_region_cache(&mut cache.lock().unwrap(), 0);
assert_eq!(cache.lock().unwrap().len(), 1);
drop(region);
super::trim_region_cache(&mut cache.lock().unwrap(), 0);
assert!(cache.lock().unwrap().is_empty());
// The next access reopens the file and re-reads the header.
let region = super::cached_region(&cache, (0, 0), &path, false)
.unwrap()
.unwrap();
let got = region
.read()
.unwrap()
.read(ChunkPos::new(0, 0))
.unwrap()
.unwrap();
assert_eq!(got.compressed, record.compressed);
drop(region);
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn older_worlds_are_upgraded_on_opening() {
let dir = std::env::temp_dir().join(format!("horizon-dv-{}", std::process::id()));
@@ -4408,7 +4675,7 @@ mod tests {
.unwrap()
.unwrap();
// Hold the actual region lock, rather than relying on disk speed or sleeps.
let blocked = region.lock().unwrap();
let blocked = region.write().unwrap();
let entity = |version| Tag::compound([("version", Tag::Int(version))]);
save.save_entities_later(vec![((1, 2), vec![entity(1)])])
.unwrap();
+34 -1
View File
@@ -135,6 +135,11 @@ pub enum Action {
Connect {
address: String,
},
/// Create World's Data Packs screen on the new world's pack folder.
DataPacks {
dir: PathBuf,
selected: Vec<String>,
},
}
pub struct Title {
pub mode: Mode,
@@ -337,6 +342,12 @@ impl Title {
row
}
fn cancel_create(&mut self) {
// removeTempDataPackDir.
if let Mode::Create { options, .. } = &self.mode
&& let Some(dir) = &options.pack_dir
{
let _ = std::fs::remove_dir_all(dir);
}
if self.create_return_to_main {
self.mode = Mode::Main;
} else {
@@ -401,6 +412,13 @@ impl Title {
};
}
}
/// The Data Packs screen's Done (`applyNewPackConfig`): its selection for
/// the new world.
pub fn set_data_packs(&mut self, ids: Vec<String>) {
if let Mode::Create { options, .. } = &mut self.mode {
options.data_packs = ids;
}
}
fn matches_search(&self, world: &WorldEntry) -> bool {
let filter = self.search.to_lowercase();
world.name.to_lowercase().contains(&filter) || world.folder.to_lowercase().contains(&filter)
@@ -1545,11 +1563,26 @@ impl Title {
creative, options, ..
},
Some(id),
) if id != 21 && id != 22 => {
) if !matches!(id, 21 | 22 | 31) => {
options.press(id, creative);
Action::None
}
(Mode::Create { .. }, Some(21)) => self.create_action(),
(Mode::Create { options, .. }, Some(31)) => {
if options.pack_dir.is_none() {
match crate::datapack::temp_folder() {
Ok(dir) => options.pack_dir = Some(dir),
Err(e) => {
log::warn!("Horizon: no folder for data packs: {e}");
return Action::None;
}
}
}
Action::DataPacks {
dir: options.pack_dir.clone().unwrap_or_default(),
selected: options.data_packs.clone(),
}
}
(Mode::Create { .. }, Some(22)) => {
self.cancel_create();
Action::None
@@ -38,6 +38,11 @@ pub struct WorldOptions {
pub bonus_chest: bool,
/// `getGameRules`: the Game Rules screen's values, saved with the world.
pub rules: crate::gamerules::GameRules,
/// The Data Packs screen's selection, lowest priority first.
pub data_packs: Vec<String>,
/// The folder of packs for the new world (vanilla's `mcworld-`
/// temporary folder), made when Data Packs first opens.
pub pack_dir: Option<std::path::PathBuf>,
}
impl Default for WorldOptions {
fn default() -> Self {
@@ -51,6 +56,8 @@ impl Default for WorldOptions {
structures: true,
bonus_chest: false,
rules: crate::gamerules::GameRules::default(),
data_packs: vec!["vanilla".into()],
pack_dir: None,
}
}
}
@@ -286,7 +293,7 @@ pub(super) fn buttons(w: f32, h: f32, creative: bool, o: &WorldOptions) -> Vec<B
28,
),
],
// Experiments and data packs have no screens yet.
// Experiments have no screen yet.
_ => [
"selectWorld.gameRules",
"selectWorld.experiments",
@@ -300,7 +307,7 @@ pub(super) fn buttons(w: f32, h: f32, creative: bool, o: &WorldOptions) -> Vec<B
gy + 28.0 * i as f32,
210.0,
tr(key),
i == 0,
i != 1,
29 + i as u8,
)
})
@@ -358,7 +365,7 @@ impl Title {
let (x0, tw) = tabs(w);
let right = x0 + tw * TABS.len() as f32;
for (x, width) in [(0.0, x0), (right, w - right)] {
ui::tiled(
ui::tiled_sized(
batch,
atlas,
"gui/header_separator",
@@ -366,7 +373,7 @@ impl Title {
TAB_BAR - 2.0,
width,
2.0,
32.0,
[32.0, 2.0],
u32::MAX,
);
}
@@ -441,7 +448,7 @@ impl Title {
_ => {}
}
// The footer separator, drawn over the screen.
ui::tiled(
ui::tiled_sized(
batch,
atlas,
"gui/footer_separator",
@@ -449,7 +456,7 @@ impl Title {
h - FOOTER - 2.0,
w,
2.0,
32.0,
[32.0, 2.0],
u32::MAX,
);
}
+20 -3
View File
@@ -821,12 +821,29 @@ fn create_world_tabs_follow_vanilla_and_their_choices_reach_the_action() {
bonus_chest: true
}
);
// The More tab's Experiments and Data Packs screens don't exist yet.
// The More tab: Experiments has no screen yet; Data
// Packs opens on a new pack folder, and its selection reaches the action.
f.title.click(54.0 + 248.0 + 10.0, 10.0, size);
let more = labels(&f);
assert_eq!(
labels(&f)[..3].iter().map(|b| b.3).collect::<Vec<_>>(),
[true, false, false]
more[..3].iter().map(|b| b.3).collect::<Vec<_>>(),
[true, false, true]
);
let (action, sound) = f.title.click(more[2].1 + 10.0, more[2].2 + 10.0, size);
let Action::DataPacks { dir, selected } = action else {
panic!("{action:?}")
};
assert!(sound && dir.is_dir() && selected == ["vanilla"]);
f.title
.set_data_packs(vec!["vanilla".into(), "file/ttm.zip".into()]);
let Action::Create { options, .. } = f.title.create_action() else {
panic!()
};
assert_eq!(options.data_packs, ["vanilla", "file/ttm.zip"]);
assert_eq!(options.pack_dir.as_ref(), Some(&dir));
// Cancel removes the folder (removeTempDataPackDir).
f.title.escape();
assert!(!dir.exists());
}
#[test]
+208 -1
View File
@@ -71,6 +71,9 @@ pub(crate) mod color {
/// The launcher's type scale and shapes (`horizon_theme`), in dp.
pub(crate) const SMALL: f32 = horizon_theme::CAPTION.px;
pub(crate) const BODY: f32 = horizon_theme::LABEL.px;
/// The boot screen's wordmark (`DISPLAY`) and console lines (`MICRO`).
pub(crate) const DISPLAY: f32 = horizon_theme::DISPLAY.px;
pub(crate) const MICRO: f32 = horizon_theme::MICRO.px;
/// Fields; plates (hover rows, selection); pills (buttons, segments).
pub(crate) const RADIUS: f32 = horizon_theme::RADIUS;
pub(crate) const RADIUS_PLATE: f32 = horizon_theme::RADIUS_PLATE;
@@ -79,9 +82,13 @@ pub(crate) const RADIUS_PLATE: f32 = horizon_theme::RADIUS_PLATE;
pub(crate) enum Face {
Regular,
Medium,
/// The wordmark and page titles (the design system's 500).
SemiBold,
/// Counts, timestamps and the boot screen's console lines.
Mono,
}
struct Fonts {
faces: [FontRef<'static>; 2],
faces: [FontRef<'static>; 4],
}
impl Fonts {
fn load() -> Self {
@@ -90,6 +97,8 @@ impl Fonts {
faces: [
f(horizon_theme::fonts::GEIST_REGULAR),
f(horizon_theme::fonts::GEIST_MEDIUM),
f(horizon_theme::fonts::GEIST_SEMIBOLD),
f(horizon_theme::fonts::GEIST_MONO),
],
}
}
@@ -524,6 +533,115 @@ impl<'a> Ui<'a> {
// ---- the launcher's components ----
}
// ---- the boot screen ----
/// The boot screen's palette on the pure black: a white wordmark and dimmed
/// console lines (the theme's ink is for light pages).
const BOOT_TEXT: u32 = color::hex(0xFFFFFF, 255);
const BOOT_LOG: u32 = color::hex(0x9BA1A6, 255);
/// The boot screen while the menu is not ready yet: the wordmark centred on
/// pure black and the console's last lines at the bottom right, as the
/// terminal prints them.
pub fn draw_boot(atlas: &HudAtlas, batch: &mut HudBatch, logs: &[String]) {
// Pure black under everything: the frame's panorama (the title artwork
// once the bundle is in) must not leak through.
batch.fill(
atlas,
0.0,
0.0,
batch.width,
batch.height,
color::hex(0x000000, 255),
);
wordmark(atlas, batch);
draw_boot_log(atlas, batch, logs);
}
/// The wordmark with its tagline, centred.
fn wordmark(atlas: &HudAtlas, batch: &mut HudBatch) {
let mut ui = Ui::new(atlas, batch, (0.0, 0.0));
let size = (ui.w / 16.0).clamp(DISPLAY, 96.0);
let small = (size * 0.25).clamp(MICRO, 18.0);
let block = size * 1.45 + small * 0.6 + small * 1.5;
let top = (ui.h - block) / 2.0;
ui.text_centered(Face::SemiBold, size, ui.w / 2.0, top, BOOT_TEXT, "Horizon");
ui.text_centered(
Face::Mono,
small,
ui.w / 2.0,
top + size * 1.45 + small * 0.6,
BOOT_LOG,
"Rust based minecraft client",
);
ui.finish();
}
/// The console's last lines at the bottom left, over any loading screen: long
/// lines wrap word-wise, the block grows upwards.
pub fn draw_boot_log(atlas: &HudAtlas, batch: &mut HudBatch, logs: &[String]) {
if logs.is_empty() {
return;
}
let mut ui = Ui::new(atlas, batch, (0.0, 0.0));
let (size, line, pad) = (MICRO, MICRO * 1.5, 16.0);
let max_w = ui.w / 2.0;
let max_rows = (((ui.h - pad) / line) as usize).clamp(1, 24);
let mut y = ui.h - pad - line;
let mut drawn = 0;
'lines: for text in logs.iter().rev() {
let chunks = wrapping_lines(&ui, Face::Mono, size, text, max_w);
for chunk in chunks.iter().rev() {
if drawn == max_rows {
break 'lines;
}
ui.text(Face::Mono, size, pad, y, BOOT_LOG, chunk);
y -= line;
drawn += 1;
}
}
ui.finish();
}
/// The lines `text` wraps into at `max_w`, word-wise; a word wider than
/// `max_w` on its own breaks at the edge.
fn wrapping_lines(ui: &Ui, face: Face, size: f32, text: &str, max_w: f32) -> Vec<String> {
let mut lines = Vec::new();
let mut line = String::new();
for word in text.split(' ') {
let mut word = word;
loop {
let candidate = if line.is_empty() {
word.to_owned()
} else {
format!("{line} {word}")
};
if ui.measure(face, size, &candidate) <= max_w {
line = candidate;
break;
}
if line.is_empty() {
// The word alone does not fit: hard-break it at the edge.
let mut end = 0;
for (i, ch) in word.char_indices() {
if i > 0 && ui.measure(face, size, &word[..i]) > max_w {
break;
}
end = i + ch.len_utf8();
}
lines.push(word[..end].to_owned());
word = &word[end..];
} else {
lines.push(std::mem::take(&mut line));
}
}
}
if !line.is_empty() {
lines.push(line);
}
lines
}
#[cfg(test)]
mod tests {
use super::*;
@@ -555,4 +673,93 @@ mod tests {
}
reset_atlas();
}
#[test]
fn the_boot_atlas_holds_the_run_time_glyph_region_without_a_pack() {
let atlas = HudAtlas::boot();
assert_eq!((atlas.width, atlas.height), (1024, 1024));
assert_eq!(
atlas.sprites["horizon/ui-text"].texels((atlas.width, atlas.height)),
(1024, 512)
);
assert_eq!(
atlas.sprites["horizon/white"].texels((atlas.width, atlas.height)),
(4, 4)
);
}
#[test]
fn the_boot_screen_centres_the_wordmark_and_left_aligns_the_console() {
reset_atlas();
let atlas = HudAtlas::boot();
let mut batch = HudBatch::new([1280, 720]);
draw_boot(&atlas, &mut batch, &[]);
// The first quad is the pure black backdrop over the frame's panorama.
let (w, h) = (batch.width, batch.height);
let bg = &batch.vertices[..6];
assert!(bg.iter().all(|v| v.color == 0xFF00_0000));
assert!(bg.iter().all(|v| v.pos[0] == 0.0 || v.pos[0] == w));
assert!(bg.iter().all(|v| v.pos[1] == 0.0 || v.pos[1] == h));
assert!(
!batch.patches.is_empty(),
"the glyphs are queued for the atlas"
);
let mut batch = HudBatch::new([1280, 720]);
wordmark(&atlas, &mut batch);
let (min, max) = batch
.vertices
.iter()
.map(|v| v.pos[0])
.fold((f32::MAX, f32::MIN), |(a, b), x| (a.min(x), b.max(x)));
assert!(
min < batch.width / 2.0 && max > batch.width / 2.0,
"the wordmark straddles the centre"
);
assert!(((min + max) / 2.0 - batch.width / 2.0).abs() < 1.0);
let long = "[12:34:56] [Render thread/INFO]: Horizon: resources loaded in 1.0s; 1240 block states, 1500 items, 62 biomes; 8 worker threads".to_owned();
let mut batch = HudBatch::new([1280, 720]);
draw_boot_log(&atlas, &mut batch, &[long]);
let (min, max) = batch
.vertices
.iter()
.map(|v| v.pos[0])
.fold((f32::MAX, f32::MIN), |(a, b), x| (a.min(x), b.max(x)));
assert!(min >= 4.0, "the line stays inside the left inset");
assert!(
max - min <= batch.width / 2.0 + 1.0,
"a too wide line is cut to half the screen"
);
let mut batch = HudBatch::new([1280, 720]);
let ui = Ui::new(&atlas, &mut batch, (0.0, 0.0));
let lines = wrapping_lines(
&ui,
Face::Mono,
MICRO,
"the quick brown fox jumps over the lazy dog",
90.0,
);
assert!(lines.len() > 1, "a long line wraps");
assert!(
lines
.iter()
.all(|l| ui.measure(Face::Mono, MICRO, l) <= 90.0),
"every wrapped line fits"
);
assert_eq!(
lines
.iter()
.map(|l| l.split_whitespace().count())
.sum::<usize>(),
9,
"the wrap keeps every word"
);
let mut batch = HudBatch::new([1280, 720]);
draw_boot_log(&atlas, &mut batch, &[]);
assert!(batch.vertices.is_empty() && batch.patches.is_empty());
reset_atlas();
}
}
+75 -25
View File
@@ -2,7 +2,7 @@
//! aquifers, surface rules, carvers and placed features) to chunk streaming.
use crate::{
biome::Biomes,
chunk::{BIOME_STRATEGY, Chunk, HEIGHT, MIN_Y, SECTIONS, Section},
chunk::{BIOME_STRATEGY, Chunk, HEIGHT, MIN_Y, SECTIONS, Section, block_strategy},
save::SavedProto,
streaming::{ChunkGenerator, LodSampler, Proto},
};
@@ -226,28 +226,7 @@ impl VanillaTerrain {
.map(|(id, start, _, _)| (Arc::from(id), (start.x, start.z)))
.collect();
out.structure_starts = chunk.own_starts();
let mut values = Box::new([0u32; 4096]);
for si in 0..SECTIONS as i32 {
let base = MIN_Y + si * 16;
if base + 16 <= lo || base >= hi {
continue;
}
values.fill(0);
let mut any = false;
for y in base.max(lo)..(base + 16).min(hi) {
for z in 0..16 {
for x in 0..16 {
let id = chunk.block(bx + x, y, bz + z).0;
values[((y & 15) << 8 | z << 4 | x) as usize] = id;
any |= id != 0;
}
}
}
if any {
Arc::make_mut(&mut out.sections[si as usize]).blocks =
Section::from_blocks(&values);
}
}
self.copy_blocks(chunk, &mut out);
// Ticks the features scheduled (leaves reshaped at a tree's edge,
// fluids of lakes and springs) run once the chunk is live, as
// LevelChunk takes over ProtoChunk's ticks.
@@ -297,6 +276,77 @@ impl VanillaTerrain {
}
out
}
/// `column`'s block copy alone.
fn copy_blocks(&self, chunk: &ProtoChunk, out: &mut Chunk) {
let pos = chunk.position();
let (bx, bz) = (pos.x * 16, pos.z * 16);
let (lo, hi) = (
chunk.terrain.min_y().max(MIN_Y),
(chunk.terrain.min_y() + chunk.terrain.height()).min(MIN_Y + HEIGHT),
);
let mut values = Box::new([0u32; 4096]);
for si in 0..SECTIONS as i32 {
let base = MIN_Y + si * 16;
if base + 16 <= lo || base >= hi {
continue;
}
// A uniform terrain section (air above the surface, mostly) needs
// no per-cell reads: copy its one state, or leave resident air.
// The terrain's sections start at its own MIN_Y, which differs per
// dimension, so convert this client section to a terrain index.
let section = usize::try_from((base - chunk.terrain.min_y()) >> 4)
.ok()
.and_then(|i| chunk.terrain.section(i));
if let Some(section) = section {
if section.bits() == 0 {
let id = section.get(0).expect("index in range").0;
if id != 0 {
Arc::make_mut(&mut out.sections[si as usize]).blocks =
PalettedContainer::new(block_strategy(), id);
}
continue;
}
// Hand the terrain palette and packed indices over as-is: the
// resident container accepts any palette order and `pack`
// rewrites vanilla's first-use order on save.
if let Some(words) = section.words()
&& let Some(container) = PalettedContainer::from_parts(
block_strategy(),
section.palette().iter().map(|s| s.0).collect(),
section.bits(),
words.to_vec(),
)
{
Arc::make_mut(&mut out.sections[si as usize]).blocks = container;
continue;
}
}
values.fill(0);
let mut any = false;
for y in base.max(lo)..(base + 16).min(hi) {
for z in 0..16 {
for x in 0..16 {
let id = chunk.block(bx + x, y, bz + z).0;
values[((y & 15) << 8 | z << 4 | x) as usize] = id;
any |= id != 0;
}
}
}
if any {
Arc::make_mut(&mut out.sections[si as usize]).blocks =
Section::from_blocks(&values);
}
}
}
/// A proto chunk's blocks as a column, for lighting a finished neighbour:
/// biomes stay at the section default and ticks, structures, postprocessing
/// and block entities are skipped. Never mesh or save this.
fn light_column(&self, chunk: &ProtoChunk) -> Chunk {
let pos = chunk.position();
let mut out = Chunk::empty(pos.x, pos.z);
self.copy_blocks(chunk, &mut out);
out
}
/// The structure starts owned by a chunk (cached by the generator).
pub fn starts_at(
&self,
@@ -507,7 +557,7 @@ impl ChunkGenerator for VanillaTerrain {
}
fn light_view(&self, proto: &Proto) -> Option<Chunk> {
match proto.downcast_ref::<VanillaProto>() {
Some(VanillaProto::Ready(chunk)) => Some(self.column(chunk)),
Some(VanillaProto::Ready(chunk)) => Some(self.light_column(chunk)),
_ => None,
}
}
@@ -629,7 +679,7 @@ impl ChunkGenerator for VanillaTerrain {
crate::structure_blocks::block_entities(&chunk, self.loot.as_deref());
out.block_entities = block_entities;
// The block entities Horizon does not model (decorated pots with their
// sherds and loot table, brushable blocks) stay as placed, like a
// sherds and loot table) stay as placed, like a
// loaded chunk's.
let mut unmodelled: Vec<Tag> = chunk
.block_entities
+171 -17
View File
@@ -242,12 +242,14 @@ fn columns(chunk: &ProtoChunk, biome_map: &[u16], dimension: &str) -> Arc<[Colum
for y in min..max {
let state = chunk.block(x, y, z);
let properties = states.get(state);
let name = registry.state(state).map_or("", |s| s.name.as_str());
if properties.is_air()
|| (!properties.blocks_motion_in_heightmap() && !properties.has_fluid())
|| (!properties.blocks_motion_in_heightmap()
&& !properties.has_fluid()
&& !matches!(name, "minecraft:chorus_plant" | "minecraft:chorus_flower"))
{
continue;
}
let name = registry.state(state).map_or("", |s| s.name.as_str());
let fluid = matches!(name, "minecraft:water" | "minecraft:lava");
let biome = biome_map
.get(chunk.biome_id_at(x, y, z).0 as usize)
@@ -300,6 +302,8 @@ pub(super) struct VanillaLodSampler {
cache: FxHashMap<(i32, i32), Arc<[Column; 256]>>,
order: VecDeque<(i32, i32)>,
details: Vec<LodDetail>,
/// Reduced far detail: `lod_grid` approximates wide cells.
approximate: bool,
}
impl VanillaLodSampler {
pub(super) fn new(generator: Arc<VanillaTerrain>) -> Self {
@@ -318,6 +322,7 @@ impl VanillaLodSampler {
cache: FxHashMap::default(),
order: VecDeque::new(),
details: Vec::new(),
approximate: false,
}
}
}
@@ -331,8 +336,12 @@ impl LodSampler for VanillaLodSampler {
cache: FxHashMap::default(),
order: VecDeque::new(),
details: Vec::new(),
approximate: self.approximate,
}))
}
fn set_approximate(&mut self, approximate: bool) {
self.approximate = approximate;
}
fn request_detail(&mut self, chunks: &[(i32, i32)], center: [i32; 2]) {
let mut missing = Vec::new();
for &key in chunks {
@@ -391,16 +400,17 @@ impl LodSampler for VanillaLodSampler {
self.grid(x, z, width, 1).pop().unwrap_or_default()
}
fn grid(&mut self, x: i32, z: i32, width: i32, side: usize) -> Vec<Column> {
let samples = match self
.worker
.lod_grid(x + width / 2, z + width / 2, width, side)
{
Ok(samples) => samples,
Err(error) => {
log::warn!("Horizon: direct far sampling failed: {error}");
return vec![Column::default(); side * side];
}
};
let samples =
match self
.worker
.lod_grid(x + width / 2, z + width / 2, width, side, self.approximate)
{
Ok(samples) => samples,
Err(error) => {
log::warn!("Horizon: direct far sampling failed: {error}");
return vec![Column::default(); side * side];
}
};
samples
.into_iter()
.enumerate()
@@ -499,10 +509,10 @@ mod tests {
.registry
.default_state("minecraft:grass_block")
.unwrap();
let planted = |biome, width, x, z| {
let planted = |ground, biome, width, x, z| {
let mut column = Column::from_surface(Some(Surface {
height: 80,
state: grass,
state: ground,
water: None,
biome,
}));
@@ -525,8 +535,8 @@ mod tests {
let mut plains_count = 0;
for z in 0..64 {
for x in 0..64 {
forest_count += usize::from(leaves(&planted(forest, 1, x, z)));
plains_count += usize::from(leaves(&planted(plains, 1, x, z)));
forest_count += usize::from(leaves(&planted(grass, forest, 1, x, z)));
plains_count += usize::from(leaves(&planted(grass, plains, 1, x, z)));
}
}
assert!(
@@ -535,10 +545,82 @@ mod tests {
);
for width in [16, 32, 128, 512, 1024] {
assert!(
leaves(&planted(forest, width, 0, 0)),
leaves(&planted(grass, forest, width, 0, 0)),
"forest canopy missing at scale {width}"
);
}
// The End's chorus plants come from the biome's own feature data: only
// end highlands carry it, and only end stone is a valid ground.
let end_stone = assets
.registry
.default_state("minecraft:end_stone")
.unwrap();
let chorus_name = |state| {
matches!(
assets.registry.state(state).unwrap().name.as_str(),
"minecraft:chorus_plant" | "minecraft:chorus_flower"
)
};
let chorus = |column: &Column| column.spans.iter().any(|span| chorus_name(span.state));
let highlands = biomes.id("minecraft:end_highlands");
assert!(
sampler.vegetation.chorus_count(highlands).unwrap_or(0.) > 0.,
"end highlands keep the chorus feature"
);
assert_eq!(
sampler.vegetation.chorus_count(forest),
None,
"a biome without the feature estimates none"
);
let mut highlands_count = 0;
let mut control_count = 0;
for z in 0..64 {
for x in 0..64 {
highlands_count += usize::from(chorus(&planted(end_stone, highlands, 1, x, z)));
control_count += usize::from(chorus(&planted(end_stone, forest, 1, x, z)));
}
}
assert!(
highlands_count > 0 && control_count == 0,
"feature-derived chorus density: highlands={highlands_count}, forest={control_count}"
);
let stem = (0..64)
.flat_map(|z| (0..64).map(move |x| (x, z)))
.map(|(x, z)| planted(end_stone, highlands, 1, x, z))
.find(|column| chorus(column))
.expect("a chorus column");
let plant = stem
.spans
.iter()
.find(|span| chorus_name(span.state))
.unwrap();
assert!(
(2..=5).contains(&(i32::from(plant.max - plant.min))),
"stem height follows the feature's 2..=5"
);
let top = stem.spans.last().unwrap();
assert_eq!(
assets.registry.state(top.state).unwrap().name,
"minecraft:chorus_flower"
);
assert_eq!(stem.surface.unwrap().height, i32::from(top.max));
let mut wide = 0;
for width in [2, 4] {
for z in (0..64).step_by(2) {
for x in (0..64).step_by(2) {
wide += usize::from(chorus(&planted(end_stone, highlands, width, x, z)));
}
}
}
assert!(wide > 0, "nearby LOD cells still draw chorus");
for z in (0..64).step_by(16) {
for x in (0..64).step_by(16) {
assert!(
!chorus(&planted(end_stone, highlands, 16, x, z)),
"a 16-block cell is too wide to read as one plant"
);
}
}
let key = (-64, 0);
sampler.request_detail(&[key], [-1024, 0]);
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
@@ -697,4 +779,76 @@ mod tests {
}
Ok(())
}
/// Vanilla decoration places chorus plants in the End; the far columns must
/// keep them even though they are not motion-blocking blocks.
#[test]
#[ignore = "requires prepared worldgen data in HORIZON_TEST_RESOURCES"]
fn end_far_columns_keep_real_chorus_plants() -> crate::Result<()> {
let root = std::path::PathBuf::from(
std::env::var_os("HORIZON_TEST_RESOURCES").expect("resources"),
);
let assets = crate::assets::Assets::load(&root)?;
let biomes = crate::biome::Biomes::load(&root)?;
let terrain = Arc::new(VanillaTerrain::for_dimension(
&root.join("worldgen"),
assets.registry,
&biomes,
1234,
"minecraft:the_end",
)?);
let mut worker = terrain.generator.worker();
let chorus = |state| {
matches!(
assets.registry.state(state).unwrap().name.as_str(),
"minecraft:chorus_plant" | "minecraft:chorus_flower"
)
};
let mut checked = 0;
'search: for cx in (-160..=160).step_by(16) {
for cz in (-160..=160).step_by(16) {
let (x, z) = (cx * 16 + 8, cz * 16 + 8);
let biome = worker.noise_biomes(&[[x >> 2, 16, z >> 2]]);
if biome.first().and_then(|name| name.as_deref()) != Some("minecraft:end_highlands")
{
continue;
}
if worker.surface_sample(x, z).is_none() {
continue;
}
checked += 1;
if checked > 24 {
break 'search;
}
let Some(chunk) = decorated_region(&mut worker, (cx, cz), &AtomicBool::new(false))?
else {
continue;
};
let min = chunk.terrain.min_y();
let max = min + chunk.terrain.height();
let mut raw = 0;
for y in min..max {
for zz in 0..16 {
for xx in 0..16 {
raw += usize::from(chorus(chunk.block(xx, y, zz)));
}
}
}
if raw == 0 {
continue;
}
let kept: usize = columns(&chunk, &terrain.biome_map, "minecraft:the_end")
.iter()
.flat_map(|column| &column.spans)
.filter(|span| chorus(span.state))
.map(|span| (span.max - span.min) as usize)
.sum();
assert_eq!(
raw, kept,
"far columns keep every real chorus block in {cx} {cz}"
);
return Ok(());
}
}
panic!("no generated end_highlands chunk with chorus among {checked} candidates");
}
}
@@ -1,4 +1,5 @@
//! Seed-stable visual vegetation from the world's own feature data.
//! Seed-stable visual vegetation from the world's own feature data: trees, and
//! the End's chorus plants.
//! shortcut: these positions approximate native placement; saved/visited and
//! completed native chunks replace them rather than changing gameplay worldgen.
use crate::{
@@ -11,6 +12,10 @@ use horizon_world::{
};
use serde_json::Value;
/// The widest LOD cell that draws chorus: a cell is drawn as a full-footprint
/// column, so a one-block plant in a wider cell would read as a coloured slab.
const CHORUS_CELL: i32 = 4;
#[derive(Clone, Copy)]
struct Tree {
leaves: BlockStateId,
@@ -20,8 +25,16 @@ struct Tree {
radius: i32,
count: f64,
}
#[derive(Clone, Copy)]
struct Chorus {
plant: BlockStateId,
flower: BlockStateId,
/// Expected plants per chunk (`minecraft:chorus_plant`'s placement).
count: f64,
}
pub(super) struct Vegetation {
trees: FxHashMap<u16, Vec<Tree>>,
chorus: FxHashMap<u16, Chorus>,
seed: u64,
registry: &'static BlockRegistry,
}
@@ -59,6 +72,7 @@ fn gather(
count: f64,
depth: usize,
out: &mut Vec<Tree>,
chorus: &mut Option<Chorus>,
) {
if depth > 12 || count <= 0. {
return;
@@ -70,7 +84,7 @@ fn gather(
data.configured_feature_json(name)
};
if let Some(json) = json.and_then(|s| serde_json::from_str::<Value>(s).ok()) {
gather(data, &json, placed, count, depth + 1, out);
gather(data, &json, placed, count, depth + 1, out, chorus);
}
return;
}
@@ -95,7 +109,15 @@ fn gather(
}
}
}
gather(data, &value["feature"], false, count, depth + 1, out);
gather(
data,
&value["feature"],
false,
count,
depth + 1,
out,
chorus,
);
return;
}
let config = if value["config"].is_object() {
@@ -104,6 +126,34 @@ fn gather(
value
};
match value["type"].as_str().unwrap_or("") {
"minecraft:chorus_plant" => {
// A stateless feature: the tip is always a fully grown flower, and
// the trunk height (2..=5) is a property of its block behaviour.
let registry = data.registry();
let (Some(plant), Some(base)) = (
registry.default_state("minecraft:chorus_plant"),
registry.default_state("minecraft:chorus_flower"),
) else {
return;
};
let Some(mut state) = registry.state(base).cloned() else {
return;
};
state.properties.insert("age".into(), "5".into());
let Some(flower) = registry.id(&state) else {
return;
};
match chorus {
Some(existing) => existing.count += count,
None => {
*chorus = Some(Chorus {
plant,
flower,
count,
})
}
}
}
"minecraft:tree" => {
let (Some(leaves), Some(log)) = (
block(&config["foliage_provider"], data.registry()),
@@ -137,15 +187,32 @@ fn gather(
remaining * chance,
depth + 1,
out,
chorus,
);
remaining *= 1. - chance;
}
}
gather(data, &config["default"], true, remaining, depth + 1, out);
gather(
data,
&config["default"],
true,
remaining,
depth + 1,
out,
chorus,
);
}
"minecraft:random_boolean_selector" => {
for key in ["feature_true", "feature_false"] {
gather(data, &config[key], true, count * 0.5, depth + 1, out);
gather(
data,
&config[key],
true,
count * 0.5,
depth + 1,
out,
chorus,
);
}
}
"minecraft:simple_random_selector" => {
@@ -158,6 +225,7 @@ fn gather(
count / features.len().max(1) as f64,
depth + 1,
out,
chorus,
);
}
}
@@ -173,9 +241,34 @@ fn hash(seed: u64, x: i32, z: i32) -> u64 {
n = (n ^ n >> 27).wrapping_mul(0x94d049bb133111eb);
n ^ n >> 31
}
/// The chorus plant whose root falls in this output cell: its height, or none.
/// `count` is per chunk, so a 4×4 cell holds one with probability `count/16`;
/// a wider cell scans the cells it covers and keeps the expected density.
fn chorus_at(seed: u64, count: f64, x: i32, z: i32, width: i32) -> Option<i32> {
if width > CHORUS_CELL || count <= 0. {
return None;
}
let probability = (count / 16.).min(1.);
for cz in z.div_euclid(4)..=(z + width - 1).div_euclid(4) {
for cx in x.div_euclid(4)..=(x + width - 1).div_euclid(4) {
let n = hash(seed, cx, cz);
if (n as u32 as f64 / u32::MAX as f64) >= probability {
continue;
}
let tx = cx * 4 + ((n >> 32) & 3) as i32;
let tz = cz * 4 + ((n >> 34) & 3) as i32;
if tx < x || tx >= x + width || tz < z || tz >= z + width {
continue;
}
return Some(2 + ((n >> 40) % 4) as i32);
}
}
None
}
impl Vegetation {
pub(super) fn new(data: &GeneratorData, map: &[u16], seed: i64) -> Self {
let mut trees = FxHashMap::default();
let mut chorus = FxHashMap::default();
for (index, &biome) in map.iter().enumerate() {
let Ok(json) = serde_json::from_str::<Value>(
data.biome_json(horizon_world::worldgen::biome::BiomeId(index as u32)),
@@ -183,11 +276,12 @@ impl Vegetation {
continue;
};
let mut list = Vec::new();
let mut plant = None;
if let Some(steps) = json["features"].as_array() {
for step in steps {
if let Some(features) = step.as_array() {
for feature in features {
gather(data, feature, true, 1., 0, &mut list);
gather(data, feature, true, 1., 0, &mut list, &mut plant);
}
}
}
@@ -195,9 +289,13 @@ impl Vegetation {
if !list.is_empty() {
trees.insert(biome, list);
}
if let Some(plant) = plant {
chorus.insert(biome, plant);
}
}
Self {
trees,
chorus,
seed: seed as u64,
registry: data.registry(),
}
@@ -213,6 +311,10 @@ impl Vegetation {
.registry
.state(surface.state)
.map_or("", |s| s.name.as_str());
if ground == "minecraft:end_stone" {
self.apply_chorus(column, surface, x, z, width, light);
return;
}
if !matches!(
ground,
"minecraft:grass_block"
@@ -306,4 +408,79 @@ impl Vegetation {
});
}
}
/// A chorus plant as its place-and-grow feature leaves it: a 2..=5 block
/// stem under one fully grown flower, or a full-cell stand-in where the
/// output cell covers several blocks.
fn apply_chorus(
&self,
column: &mut Column,
surface: Surface,
x: i32,
z: i32,
width: i32,
light: u16,
) {
let Some(chorus) = self.chorus.get(&surface.biome) else {
return;
};
let Some(height) = chorus_at(self.seed, chorus.count, x, z, width) else {
return;
};
let base = surface.height;
let top = base + height;
column.push(Span {
min: base as i16,
max: top as i16,
state: chorus.plant,
biome: surface.biome,
light,
});
column.push(Span {
min: top as i16,
max: (top + 1) as i16,
state: chorus.flower,
biome: surface.biome,
light,
});
column.surface = Some(Surface {
height: top + 1,
state: chorus.flower,
..surface
});
}
#[cfg(test)]
pub(super) fn chorus_count(&self, biome: u16) -> Option<f64> {
self.chorus.get(&biome).map(|chorus| chorus.count)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn chorus_density_and_height_follow_the_placements() {
let mut plants = 0;
for z in 0..256 {
for x in 0..256 {
if let Some(height) = chorus_at(7, 1., x, z, 1) {
plants += 1;
assert!((2..=5).contains(&height), "height {height}");
}
}
}
// One plant per chunk: 256² blocks hold 256 plants, within hash noise.
assert!((180..=330).contains(&plants), "plants: {plants}");
assert_eq!(chorus_at(7, 1., 0, 0, 1), chorus_at(7, 1., 0, 0, 1));
assert_eq!(chorus_at(7, 1., 0, 0, 16), None);
assert_eq!(chorus_at(7, 0., 0, 0, 1), None);
let mut wide = 0;
for z in (0..64).step_by(4) {
for x in (0..64).step_by(4) {
wide += usize::from(chorus_at(7, 1., x, z, 4).is_some());
}
}
assert!((4..=40).contains(&wide), "4×4-cell plants: {wide}");
}
}
@@ -357,7 +357,7 @@ fn saved_proto_block_entities_keep_their_id() {
generator.decorate(&mut worker, &mut region);
generator.save_protos(&region.iter().collect::<Vec<_>>());
save.flush().unwrap();
let mut file = RegionFile::open(dir.join("region/r.1.0.mca")).unwrap();
let file = RegionFile::open(dir.join("region/r.1.0.mca")).unwrap();
let mut suspicious = 0;
for &(x, z) in &keys {
let Some(record) = file
+8
View File
@@ -36,6 +36,14 @@ impl SamplerContext {
..Self::cached()
}
}
/// Keeps each y-fixed slice's plane (a function of x and z only), so that
/// sampling the same x/z lattice again, at other heights or another
/// vertical step, reuses it. For one evaluation over one graph: far
/// previews sample a tile coarsely, then again around its surface.
pub fn with_planes(mut self) -> Self {
self.planes = Some(Default::default());
self
}
pub fn clear_caches(&mut self) {
if let Some(caches) = &mut self.caches {
caches.clear();
+5
View File
@@ -94,6 +94,8 @@ pub struct SamplerContext {
caches: Option<Vec<cache::CacheCell>>,
fields: WorldFields,
sparse: bool,
/// Planes of y-fixed slices already sampled (`with_planes`).
planes: Option<std::collections::HashMap<slice::PlaneKey, Vec<f32>>>,
}
impl SamplerContext {
fn with_buffer<T>(
@@ -113,6 +115,9 @@ impl SamplerContext {
pub fn reset(&mut self) {
self.caches = self.caches.as_ref().map(|_| Vec::new());
self.fields = WorldFields::default();
if let Some(planes) = &mut self.planes {
planes.clear();
}
}
/// Release retained storage between worlds or after unusually large work.
pub fn clear_scratch(&mut self) {
+48 -15
View File
@@ -19,6 +19,10 @@ impl SliceAxes {
}
}
/// A y-fixed slice's plane: the slice node, the plane's corner (y fixed),
/// its x/z sizes and steps (`SamplerContext::with_planes`).
pub(super) type PlaneKey = (usize, [i32; 3], [usize; 2], [i32; 2]);
#[derive(Clone, Debug)]
pub struct SliceSampler {
pub input: Arc<dyn DensitySampler>,
@@ -61,25 +65,54 @@ impl DensitySampler for SliceSampler {
if reduced == volume {
return self.input.sample_volume(context, buffer, volume);
}
let plane = (fixed[1].is_some() && context.planes.is_some()).then(|| {
let [rx, _, rz] = reduced.sizes();
let [step_x, _, step_z] = volume.steps();
(
self as *const Self as usize,
reduced.minimum(),
[rx, rz],
[step_x, step_z],
)
});
if let Some(key) = &plane
&& let Some(samples) = context.planes.as_ref().and_then(|p| p.get(key))
{
spread(fixed, volume, reduced, samples, buffer);
return Ok(());
}
context.with_buffer(reduced.size(), |context, samples| {
self.input.sample_volume(context, samples, reduced)?;
let [sx, sy, sz] = volume.sizes();
let [rx, ry, _] = reduced.sizes();
for z in 0..sz {
let source_z = if fixed[2].is_some() { 0 } else { z };
for x in 0..sx {
let source_x = if fixed[0].is_some() { 0 } else { x };
let source = (source_x + source_z * rx) * ry;
let target = (x + z * sx) * sy;
let column = &mut buffer[target..target + sy];
if fixed[1].is_some() {
column.fill(samples[source]);
} else {
column.copy_from_slice(&samples[source..source + sy]);
}
}
spread(fixed, volume, reduced, samples, buffer);
if let (Some(key), Some(planes)) = (plane, &mut context.planes) {
planes.insert(key, samples[..reduced.size()].to_vec());
}
Ok(())
})
}
}
/// Copies the `reduced` samples along the fixed axes of `volume`.
fn spread(
fixed: [Option<i32>; 3],
volume: DensityVolume,
reduced: DensityVolume,
samples: &[f32],
buffer: &mut [f32],
) {
let [sx, sy, sz] = volume.sizes();
let [rx, ry, _] = reduced.sizes();
for z in 0..sz {
let source_z = if fixed[2].is_some() { 0 } else { z };
for x in 0..sx {
let source_x = if fixed[0].is_some() { 0 } else { x };
let source = (source_x + source_z * rx) * ry;
let target = (x + z * sx) * sy;
let column = &mut buffer[target..target + sy];
if fixed[1].is_some() {
column.fill(samples[source]);
} else {
column.copy_from_slice(&samples[source..source + sy]);
}
}
}
}
+51
View File
@@ -959,3 +959,54 @@ fn binary_bulk_preserves_child_failure_order_and_output() {
);
}
}
/// A y-fixed slice's plane, kept by `SamplerContext::with_planes`, gives the
/// values fresh sampling gives at other heights and vertical steps, and is
/// sampled once for one x/z lattice.
#[test]
fn kept_planes_match_fresh_slices_across_vertical_lattices() {
use std::sync::atomic::{AtomicUsize, Ordering};
#[derive(Debug)]
struct Counted(NoiseSampler, AtomicUsize);
impl DensitySampler for Counted {
fn sample_value(&self, c: &mut SamplerContext, p: [i32; 3]) -> Result<f32, SampleError> {
self.0.sample_value(c, p)
}
fn sample_volume(
&self,
c: &mut SamplerContext,
out: &mut [f32],
v: DensityVolume,
) -> Result<(), SampleError> {
self.1.fetch_add(1, Ordering::Relaxed);
self.0.sample_volume(c, out, v)
}
}
let input = Arc::new(Counted(
NoiseSampler {
noise: Arc::new(PerlinNoise::new(&mut LegacyRandom::new(5))),
xz_scale: 0.3,
y_scale: 0.7,
},
AtomicUsize::new(0),
));
let slice = slice::SliceSampler {
input: input.clone(),
axes: slice::SliceAxes::Y(0),
};
let lattices = [
DensityVolume::new([5, 9, 4], [-8, -64, 12], [4, 64, 4]).unwrap(),
DensityVolume::new([5, 17, 4], [-8, 0, 12], [4, 8, 4]).unwrap(),
];
let mut kept = SamplerContext::sparse().with_planes();
for (i, volume) in lattices.into_iter().enumerate() {
let mut fresh = vec![0.0; volume.size()];
slice
.sample_volume(&mut SamplerContext::sparse(), &mut fresh, volume)
.unwrap();
let mut reused = vec![1.0; volume.size()];
slice.sample_volume(&mut kept, &mut reused, volume).unwrap();
assert_eq!(fresh, reused, "lattice {i}");
}
// Two fresh evaluations and one kept plane.
assert_eq!(input.1.load(Ordering::Relaxed), 3);
}
+64 -31
View File
@@ -8,6 +8,8 @@
//! in index order, and the value that did not fit is added last. Palettes
//! never shrink while resident; `pack` writes the canonical saved form.
use crate::storage::{locate, mask};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Kind {
/// `Strategy.createForBlockStates`: 4096 entries, linear palettes up to 4
@@ -106,20 +108,6 @@ enum Palette {
List(Vec<u32>),
Global,
}
/// `n / per_word` for `n < 4096` by multiplication: `MAGIC[bits].0 = ceil(2^20 /
/// (64 / bits))` is exact while `n * (64 / bits) < 2^20`. `.1` is the bits a
/// word's entries fill (`64 / bits * bits`): looked up, not divided for, as a
/// division by a run-time value costs tens of cycles on x86.
const MAGIC: [(u32, u32); 33] = {
let mut table = [(0, 0); 33];
let mut bits = 1;
while bits <= 32 {
let per = 64 / bits as u32;
table[bits] = ((1u32 << 20).div_ceil(per), per * bits as u32);
bits += 1;
}
table
};
/// Words of `bits`-wide values: `entries.div_ceil(64 / bits)`.
pub fn storage_len(bits: u8, entries: usize) -> usize {
if bits == 0 {
@@ -128,23 +116,6 @@ pub fn storage_len(bits: u8, entries: usize) -> usize {
entries.div_ceil(64 / usize::from(bits))
}
}
#[inline]
fn locate(bits: u8, i: usize) -> (usize, u32) {
if bits.is_power_of_two() {
// 64 / bits is a power of two: shifts instead of the multiplication.
let log = bits.trailing_zeros();
let word = i >> (6 - log);
(word, ((i & ((64 >> log) - 1)) as u32) << log)
} else {
let (magic, filled) = MAGIC[usize::from(bits)];
let word = (i as u32 * magic) >> 20;
(word as usize, i as u32 * u32::from(bits) - word * filled)
}
}
#[inline]
fn mask(bits: u8) -> u64 {
(1u64 << bits) - 1
}
fn read(words: &[u64], bits: u8, i: usize) -> u32 {
let (word, shift) = locate(bits, i);
((words[word] >> shift) & mask(bits)) as u32
@@ -476,6 +447,46 @@ impl PalettedContainer {
let (palette, ids) = palette_ids(values);
Self::build(strategy, palette, &ids)
}
/// A container over a paletted storage handed over as-is (worldgen's
/// `BlockSection`): `palette` in any order, `bits` wide indices in `words`.
/// None for a configuration this container does not store that way
/// (a global palette, or storage of the wrong size).
pub fn from_parts(
strategy: Strategy,
palette: Vec<u32>,
bits: u8,
words: Vec<u64>,
) -> Option<Self> {
if palette.is_empty() || palette.len() > 1 << bits {
return None;
}
if palette.len() == 1 {
return Some(Self::new(strategy, palette[0]));
}
let config = strategy.config(ceil_log2(palette.len()));
let Config::List(_) = config else {
return None;
};
if strategy.storage_bits(config) != bits {
return None;
}
if words.len() != storage_len(bits, strategy.entries()) {
return None;
}
#[cfg(debug_assertions)]
for i in 0..strategy.entries() {
debug_assert!(
(read(&words, bits, i) as usize) < palette.len(),
"palette id out of range"
);
}
Some(Self {
strategy,
bits,
palette: Palette::List(palette),
words,
})
}
/// A container over a distinct-value `palette` and an id per entry.
fn build(strategy: Strategy, palette: Vec<u32>, ids: &[u32]) -> Self {
let config = strategy.config(ceil_log2(palette.len()));
@@ -772,6 +783,28 @@ mod tests {
assert_eq!(b.bits(), want, "after {n} biomes");
}
}
#[test]
fn from_parts_hands_over_storage_unchanged() {
let values: Vec<u32> = (0..4096).map(|i| [7, 9, 11][i % 3]).collect();
let original = PalettedContainer::from_values(BLOCKS, &values);
let (bits, palette, words) = original.to_wire();
let handed =
PalettedContainer::from_parts(BLOCKS, palette, bits, words.to_vec()).expect("list");
assert_eq!(handed, original);
assert_eq!(handed.pack(), original.pack());
// Dead palette slots are accepted as long as every index is in range.
let (bits, mut palette, words) = original.to_wire();
palette.push(99);
let handed = PalettedContainer::from_parts(BLOCKS, palette, bits, words.to_vec());
assert_eq!(handed.expect("dead slot"), original);
// A global-palette or wrongly sized storage is refused.
assert!(
PalettedContainer::from_parts(BLOCKS, vec![0; 300], 9, vec![0; storage_len(9, 4096)])
.is_none()
);
assert!(PalettedContainer::from_parts(BLOCKS, vec![5, 6, 7], 4, vec![0; 8]).is_none());
}
#[test]
fn matches_a_plain_array_under_random_edits() {
for (strategy, distinct) in [(BLOCKS, 700u64), (BLOCKS, 12), (BIOMES, 9), (BIOMES, 3)] {
+93 -9
View File
@@ -1,5 +1,46 @@
//! Palette indices packed into padded 64-bit words, not a continuous bitstream.
/// `n / per_word` for `n < MAGIC_LEN` by multiplication: `MAGIC[bits].0 =
/// ceil(2^20 / (64 / bits))` is exact while `n * (64 / bits) < 2^20`. `.1` is
/// the bits a word's entries fill (`64 / bits * bits`): looked up, not divided
/// for, as a division by a run-time value costs tens of cycles on x86.
pub(crate) const MAGIC: [(u32, u32); 33] = {
let mut table = [(0, 0); 33];
let mut bits = 1;
while bits <= 32 {
let per = 64 / bits as u32;
table[bits] = ((1u32 << 20).div_ceil(per), per * bits as u32);
bits += 1;
}
table
};
/// Entries `locate` handles exactly for every width: the smallest width (1 bit,
/// 64 per word) gives the bound `2^20 / 64`.
pub(crate) const MAGIC_LEN: usize = 1 << 14;
/// The multiply-shift replaces a ~20-26 cycle x86 divide, but arm64's divider
/// is as cheap as the table lookup: measured on an Apple M-series CPU, 65k
/// reads took 1.5 ms with the division and 2.5 ms with the table (bits 4-16),
/// so only the x86 targets take the table.
const MAGIC_BEATS_DIVISION: bool = cfg!(any(target_arch = "x86", target_arch = "x86_64"));
/// The word and bit shift of entry `i`; exact for `i < MAGIC_LEN`.
#[inline]
pub(crate) fn locate(bits: u8, i: usize) -> (usize, u32) {
if bits.is_power_of_two() {
// 64 / bits is a power of two: shifts instead of the multiplication.
let log = bits.trailing_zeros();
let word = i >> (6 - log);
(word, ((i & ((64 >> log) - 1)) as u32) << log)
} else {
let (magic, filled) = MAGIC[usize::from(bits)];
let word = (i as u32 * magic) >> 20;
(word as usize, i as u32 * u32::from(bits) - word * filled)
}
}
#[inline]
pub(crate) fn mask(bits: u8) -> u64 {
(1u64 << bits) - 1
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum StorageError {
InvalidBits(u8),
@@ -22,6 +63,9 @@ pub struct PackedStorage {
len: usize,
per_word: usize,
mask: u64,
/// Whether `len` is past what [`locate`] handles exactly; such a storage
/// falls back to division.
wide: bool,
words: Vec<u64>,
}
@@ -46,6 +90,7 @@ impl PackedStorage {
len,
per_word,
mask: (1_u64 << bits) - 1,
wide: len > MAGIC_LEN,
words,
})
}
@@ -70,17 +115,27 @@ impl PackedStorage {
&self.words
}
fn location(&self, index: usize) -> Result<(usize, usize), StorageError> {
/// The word and bit shift of an in-range index.
#[inline]
fn slot(&self, index: usize) -> (usize, u32) {
if self.wide || !MAGIC_BEATS_DIVISION {
(
index / self.per_word,
((index % self.per_word) * usize::from(self.bits)) as u32,
)
} else {
locate(self.bits, index)
}
}
fn location(&self, index: usize) -> Result<(usize, u32), StorageError> {
if index >= self.len {
return Err(StorageError::OutOfBounds {
index,
len: self.len,
});
}
Ok((
index / self.per_word,
(index % self.per_word) * usize::from(self.bits),
))
Ok(self.slot(index))
}
pub fn get(&self, index: usize) -> Result<u32, StorageError> {
@@ -93,8 +148,7 @@ impl PackedStorage {
/// `index` must be less than `len`.
pub fn read(&self, index: usize) -> u32 {
debug_assert!(index < self.len);
let word = index / self.per_word;
let shift = (index % self.per_word) * usize::from(self.bits);
let (word, shift) = self.slot(index);
((self.words[word] >> shift) & self.mask) as u32
}
@@ -103,8 +157,7 @@ impl PackedStorage {
/// palette index from the same container. Returns the previous value.
pub fn write(&mut self, index: usize, value: u32) -> u32 {
debug_assert!(index < self.len && u64::from(value) <= self.mask);
let word = index / self.per_word;
let shift = (index % self.per_word) * usize::from(self.bits);
let (word, shift) = self.slot(index);
let old = ((self.words[word] >> shift) & self.mask) as u32;
self.words[word] = (self.words[word] & !(self.mask << shift)) | (u64::from(value) << shift);
old
@@ -177,6 +230,37 @@ mod tests {
}
}
/// The shared multiply-shift index equals division for every width at the
/// sizes that use `PackedStorage`: 4,096-cell sections and 256-cell
/// heightmaps. A storage past the exact bound keeps the division path.
#[test]
fn multiply_shift_matches_division_at_section_and_heightmap_sizes() {
for bits in 1..=32u8 {
for len in [256usize, 4096] {
let per = 64 / usize::from(bits);
let mut storage = PackedStorage::new(bits, len).unwrap();
assert!(!storage.wide);
let mask = storage.mask;
for i in 0..len {
assert_eq!(
storage.slot(i),
(i / per, ((i % per) * usize::from(bits)) as u32),
"bits {bits} len {len} index {i}"
);
let value = (i as u64 & mask) as u32;
assert_eq!(storage.write(i, value), 0);
assert_eq!(storage.read(i), value);
assert_eq!(storage.get(i).unwrap(), value);
}
}
}
let mut wide = PackedStorage::new(1, MAGIC_LEN + 1).unwrap();
assert!(wide.wide);
wide.write(MAGIC_LEN, 1);
assert_eq!(wide.read(MAGIC_LEN), 1);
assert_eq!(wide.get(MAGIC_LEN).unwrap(), 1);
}
#[test]
fn imported_padding_is_preserved_and_invalid_writes_are_atomic() {
let mut storage = PackedStorage::from_words(5, 1, vec![u64::MAX]).unwrap();
+90
View File
@@ -107,6 +107,8 @@ pub struct ItemInfo {
pub weapon_damage: Option<u32>,
/// `use_effects.speed_multiplier` (UseEffects.DEFAULT: 0.2).
pub use_speed: f32,
/// `use_effects.interact_vibrations` (default true).
pub interact_vibrations: bool,
/// `glider` (elytra).
pub glider: bool,
/// `equippable.swappable` and `equip_sound`.
@@ -260,6 +262,8 @@ pub struct Items {
loot: HashMap<String, Value>,
/// The `jukebox_song` registry.
pub songs: HashMap<String, Song>,
/// The `instrument` registry (goat horns).
pub instruments: HashMap<String, Instrument>,
/// The enchantment registry: loot functions enchant the items they roll
/// (`EnchantWithLevelsFunction`, `EnchantRandomlyFunction`), and the
/// station menus share this copy.
@@ -279,6 +283,76 @@ impl Song {
ticks >= self.length_ticks + 20
}
}
/// An `Instrument`: what a goat horn plays.
#[derive(Clone, Debug)]
pub struct Instrument {
pub sound: String,
/// Seconds the horn stays up, and its cooldown.
pub use_duration: f32,
/// How far it carries, in blocks (`range / 16` is its volume).
pub range: f32,
}
impl Instrument {
/// `InstrumentItem.getUseDuration`: `Mth.floor(useDuration * 20)`.
pub fn use_ticks(&self) -> i32 {
(self.use_duration * 20.0).floor() as i32
}
}
/// The id of a goat horn's instrument (`InstrumentItem.getInstrument`): the
/// stack's own `instrument` component, else the item's `ponder_goat_horn`.
/// None for an inline instrument (see `inline_instrument`).
pub fn instrument_id(stack: &ItemStack) -> Option<String> {
if &*stack.item != "minecraft:goat_horn" {
return None;
}
match stack.data().and_then(|d| d.other("minecraft:instrument")) {
Some(horizon_world::nbt::Tag::String(id)) => Some(id.to_string_lossy()),
Some(horizon_world::nbt::Tag::Compound(_)) => None,
_ => Some("minecraft:ponder_goat_horn".to_owned()),
}
}
/// A goat horn's inline `instrument` component (`Instrument.CODEC` also
/// takes the instrument itself instead of a registry id).
pub fn inline_instrument(stack: &ItemStack) -> Option<&horizon_world::nbt::Tag> {
if &*stack.item != "minecraft:goat_horn" {
return None;
}
match stack.data().and_then(|d| d.other("minecraft:instrument")) {
Some(tag @ horizon_world::nbt::Tag::Compound(_)) => Some(tag),
_ => None,
}
}
/// `Instrument.DIRECT_CODEC`: a `sound_event` that is an id or `{sound_id}`,
/// `use_duration` and `range`.
fn parse_instrument(v: &Value) -> Instrument {
let sound = match &v["sound_event"] {
Value::String(s) => s.clone(),
other => other["sound_id"].as_str().unwrap_or("").to_owned(),
};
Instrument {
sound,
use_duration: v["use_duration"].as_f64().unwrap_or(0.0) as f32,
range: v["range"].as_f64().unwrap_or(0.0) as f32,
}
}
fn load_instruments(root: &Path) -> HashMap<String, Instrument> {
let mut instruments = HashMap::new();
let Ok(dir) = fs::read_dir(root.join("data/minecraft/instrument")) else {
return instruments;
};
for entry in dir.flatten() {
let path = entry.path();
let (Some(stem), Ok(bytes)) = (path.file_stem().and_then(|s| s.to_str()), fs::read(&path))
else {
continue;
};
let Ok(v) = serde_json::from_slice::<Value>(&bytes) else {
continue;
};
instruments.insert(format!("minecraft:{stem}"), parse_instrument(&v));
}
instruments
}
fn load_songs(root: &Path) -> HashMap<String, Song> {
let mut songs = HashMap::new();
let Ok(dir) = fs::read_dir(root.join("data/minecraft/jukebox_song")) else {
@@ -663,6 +737,9 @@ impl Items {
use_speed: c["minecraft:use_effects"]["speed_multiplier"]
.as_f64()
.unwrap_or(0.2) as f32,
interact_vibrations: c["minecraft:use_effects"]["interact_vibrations"]
.as_bool()
.unwrap_or(true),
blocks_attacks: BlocksAttacks::parse(&c["minecraft:blocks_attacks"]),
disable_blocking_seconds: c["minecraft:weapon"]["disable_blocking_for_seconds"]
.as_f64()
@@ -756,12 +833,23 @@ impl Items {
item_tag_order: load_tag_lists(root, "item")?,
loot,
songs: load_songs(root),
instruments: load_instruments(root),
enchantments: Arc::new(crate::enchant::Enchantments::load(root)),
})
}
pub fn get(&self, id: &str) -> Option<&ItemInfo> {
self.items.get(id)
}
/// The instrument a goat horn plays: its `instrument` component's
/// registry entry or inline instrument.
pub fn instrument(&self, stack: &ItemStack) -> Option<Instrument> {
if let Some(tag) = inline_instrument(stack) {
return Some(parse_instrument(
&horizon_client_core::scoreboard::tag_to_json(tag),
));
}
self.instruments.get(&instrument_id(stack)?).cloned()
}
/// Only the block and item tags (and the enchantment registry, which the
/// station rules and loot functions are built from), for tools that need
/// tag membership.
@@ -775,6 +863,7 @@ impl Items {
item_tag_order: load_tag_lists(root, "item")?,
loot: HashMap::new(),
songs: HashMap::new(),
instruments: HashMap::new(),
enchantments: Arc::new(crate::enchant::Enchantments::load(root)),
})
}
@@ -793,6 +882,7 @@ impl Items {
item_tags,
loot: HashMap::new(),
songs: HashMap::new(),
instruments: HashMap::new(),
enchantments: Arc::new(crate::enchant::Enchantments::default()),
}
}
+4 -1
View File
@@ -314,7 +314,10 @@ interface render {
record sprite-shape {
/// One of the game's GUI sprites, as `gui/sprites/` names them
/// (`hud/heart/full`, `widget/button`), the resource packs' versions
/// included. An unknown one draws nothing.
/// included, drawn over `rect` as vanilla draws it: nine-sliced when
/// its `gui.scaling` says so (`tooltip/frame`, `widget/button`: the
/// borders keep their size), stretched otherwise. An unknown one
/// draws nothing.
sprite: string,
rect: rect,
/// A tint, 0xAARRGGBB: 0xFFFFFFFF draws it as it is.
+33
View File
@@ -909,6 +909,30 @@ fn steps(
.collect::<Result<_, String>>()?,
))
};
// The fluid tags are the one seed of the behaviour snapshot that is a
// folder; the game keys them by their relative path, as recorded here.
let fluid_hashes = {
let dir = root.join("data/minecraft/tags/fluid");
let mut paths: Vec<PathBuf> = if dir.is_dir() {
fs::read_dir(&dir)
.map_err(|e| format!("{}: {e}", dir.display()))?
.flatten()
.map(|entry| entry.path())
.filter(|path| path.is_file())
.collect()
} else {
Vec::new()
};
paths.sort();
let mut hashes = Value::Object(Vec::new());
for path in paths {
hashes.insert(
&relative_display(&path, root)?,
Value::Str(digest(&path, "sha256")?),
);
}
hashes
};
let mut manifest = vec![("schema".to_string(), Value::Int(SCHEMA.to_string()))];
manifest.extend(run.origin.as_object().unwrap().iter().cloned());
manifest.extend([
@@ -946,10 +970,19 @@ fn steps(
.ok_or("version.json has no world_version")?,
),
("facts_sha256".into(), run.file_hash("facts.json")?),
(
"gameplay_facts_sha256".into(),
run.file_hash("gameplay-facts.json")?,
),
(
"behaviour_sha256".into(),
run.file_hash("block-behaviours.json")?,
),
(
"block_tags_sha256".into(),
run.file_hash("block-tags.json")?,
),
("fluid_tags_sha256".into(), fluid_hashes),
("resource_files".into(), Value::Int(count.to_string())),
]);
write_json(
@@ -3,8 +3,8 @@
//! `item::Slot` and prints, per stack, whether every byte was consumed, the
//! patch's component ids (added, then removed), which of them Horizon
//! decoded into its own lossy types (the rest it reads past keeping their
//! bytes, or decodes into ids it writes back unchanged, like trims) and
//! whether encoding the decoded stack gives the same bytes back.
//! bytes, or decodes into ids it writes back unchanged, like trims and stew
//! effects) and whether encoding the decoded stack gives the same bytes back.
//!
//! cargo run -p horizon-protocol --example item_wire_probe -- vanilla.jsonl rust.jsonl
use horizon_protocol::{
@@ -46,7 +46,12 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let typed: Vec<i32> = stack
.iter()
.flat_map(|s| &s.added)
.filter(|(_, c)| !matches!(c, Component::Raw(_) | Component::Trim { .. }))
.filter(|(_, c)| {
!matches!(
c,
Component::Raw(_) | Component::Trim { .. } | Component::StewEffects(_)
)
})
.map(|(k, _)| *k)
.collect();
json!({
+17 -1
View File
@@ -39,6 +39,7 @@ pub mod component {
pub const CHARGED_PROJECTILES: i32 = 51;
pub const BUNDLE_CONTENTS: i32 = 52;
pub const POTION_CONTENTS: i32 = 53;
pub const SUSPICIOUS_STEW_EFFECTS: i32 = 55;
pub const WRITABLE_BOOK_CONTENT: i32 = 56;
pub const WRITTEN_BOOK_CONTENT: i32 = 57;
pub const TRIM: i32 = 58;
@@ -114,6 +115,9 @@ pub enum Component {
material: i32,
pattern: i32,
},
/// `SuspiciousStewEffects`: each entry's `minecraft:mob_effect` id and
/// duration in ticks.
StewEffects(Vec<(i32, i32)>),
/// `BannerPatternLayers`: each layer's `banner_pattern` registry id and
/// its dye id (`DyeColor.getId`). A layer with a direct pattern has no
/// registry name: that value stays [`Component::Raw`].
@@ -220,6 +224,16 @@ impl Component {
let n = r.count(256)?;
Self::Lore((0..n).map(|_| r.nbt()).collect::<Result<_>>()?)
}
// SuspiciousStewEffects.Entry.STREAM_CODEC: MobEffect.STREAM_CODEC
// (the registry id), then the duration, both varints.
SUSPICIOUS_STEW_EFFECTS => {
let n = r.count(r.remaining())?;
Self::StewEffects(
(0..n)
.map(|_| Ok((r.varint()?, r.varint()?)))
.collect::<Result<_>>()?,
)
}
ENCHANTMENTS | STORED_ENCHANTMENTS => {
let n = r.count(r.remaining())?;
Self::Enchantments(
@@ -415,7 +429,7 @@ impl Component {
w.varint(lines.len() as i32);
lines.iter().for_each(|l| w.nbt(l));
}
Self::Enchantments(list) => {
Self::Enchantments(list) | Self::StewEffects(list) => {
w.varint(list.len() as i32);
for (e, level) in list {
w.varint(*e);
@@ -968,6 +982,8 @@ mod tests {
pattern: 1,
}
}
// night_vision for 100 ticks, poison for the default 160.
55 => *c == Component::StewEffects(vec![(15, 100), (18, 160)]),
_ => matches!(c, Component::Raw(_)),
};
assert!(
+20 -1
View File
@@ -3,7 +3,10 @@
//! registries report. Data-driven registries (dimension types, biomes, chat
//! types, damage types ...) are numbered in the order the server sends them
//! during configuration.
use crate::{Error, Result, packets::configuration::RegistryData};
use crate::{
Error, Result,
packets::configuration::{RegistryData, UpdateTags},
};
use horizon_world::nbt::{JavaString, Tag};
use std::{collections::HashMap, path::Path};
@@ -89,8 +92,24 @@ impl Builtin {
#[derive(Clone, Debug, Default)]
pub struct Dynamic {
pub registries: HashMap<String, Vec<(String, Option<Tag>)>>,
/// The `minecraft:dialog` tags the server sent: each tag's entries by id.
pub dialog_tags: HashMap<String, Vec<i32>>,
}
impl Dynamic {
/// The dialog tags of a `ClientboundUpdateTagsPacket`.
pub fn add_tags(&mut self, packet: &UpdateTags) {
for r in packet
.registries
.iter()
.filter(|r| r.registry == "minecraft:dialog")
{
self.dialog_tags = r
.tags
.iter()
.map(|t| (t.name.clone(), t.ids.clone()))
.collect();
}
}
pub fn add(&mut self, packet: RegistryData) {
let entries = packet.entries.into_iter().map(|e| (e.id, e.data)).collect();
self.registries.insert(packet.registry, entries);
+1 -1
View File
@@ -10,7 +10,7 @@ description = "Scheduling hints for the game's threads: the OS calls the crates
libc = "0.2"
[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.61", features = ["Win32_System_Threading", "Win32_System_WindowsProgramming"] }
windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_Graphics_Gdi", "Win32_System_Threading", "Win32_System_WindowsProgramming", "Win32_UI_WindowsAndMessaging"] }
[lints]
workspace = true
+195
View File
@@ -0,0 +1,195 @@
//! The game window's boot screen before wgpu's first frame: the renderer's
//! device and pipelines take seconds to build, and winit's window sits white
//! until a frame is presented. The game's boot design (`ui_theme::draw_boot`:
//! pure black, the white wordmark, the console's last lines at the bottom
//! left, wrapping word-wise), painted with GDI and replaced by the wgpu frames
//! when the renderer starts drawing.
use std::sync::Once;
use windows_sys::Win32::Foundation::{COLORREF, HWND, RECT};
use windows_sys::Win32::Graphics::Gdi::{
AddFontMemResourceEx, CLEARTYPE_QUALITY, CLIP_DEFAULT_PRECIS, CreateFontW, CreateSolidBrush,
DEFAULT_CHARSET, DT_CALCRECT, DT_CENTER, DT_LEFT, DT_NOCLIP, DT_SINGLELINE, DT_VCENTER,
DT_WORDBREAK, DeleteObject, DrawTextW, FF_DONTCARE, FillRect, GetDC, OUT_DEFAULT_PRECIS,
ReleaseDC, SelectObject, SetBkMode, SetTextColor, TRANSPARENT,
};
use windows_sys::Win32::UI::WindowsAndMessaging::GetClientRect;
/// The wordmark on the pure black.
const WORDMARK: COLORREF = 0x00ff_ffff;
/// The console's lines, `0x9BA1A6` in GDI's `0x00bbggrr`.
const CONSOLE: COLORREF = 0x00a6_a19b;
/// GDI's font table is global to the process: the bundled faces register once
/// across every paint.
static FACES: Once = Once::new();
/// Paints the client area of the window at `hwnd` black, the wordmark centred
/// and the console's last lines at the bottom left — the same look as the
/// game's `ui_theme::draw_boot`, which takes over when the renderer presents
/// its first frame.
///
/// `semibold` and `mono` are the faces' TTF bytes (`Geist SemiBold`,
/// `Geist Mono`), which must outlive the process: `AddFontMemResourceEx`
/// keeps referencing them. Repainting is safe and cheap (a background thread
/// may call this as the console's lines arrive).
pub fn paint(hwnd: isize, semibold: &[u8], mono: &[u8], logs: &[String]) {
let hwnd = hwnd as HWND;
FACES.call_once(|| {
for font in [semibold, mono] {
let count = 0;
unsafe {
AddFontMemResourceEx(
font.as_ptr().cast(),
font.len() as u32,
std::ptr::null(),
&count,
);
}
}
});
unsafe {
let hdc = GetDC(hwnd);
if hdc.is_null() {
return;
}
let mut rect = RECT {
left: 0,
top: 0,
right: 0,
bottom: 0,
};
GetClientRect(hwnd, &mut rect);
let (w, h) = (
(rect.right - rect.left) as f32,
(rect.bottom - rect.top) as f32,
);
// The pure black clear.
let brush = CreateSolidBrush(0);
FillRect(hdc, &rect, brush);
let _ = DeleteObject(brush);
// The type scale of the game's `Ui::new`: physical pixels per dp,
// then the sizes in dp (the wordmark at `DISPLAY`, the console at
// `MICRO` with 1.5 line heights).
let density = (w / 1040.0).min(h / 700.0).clamp(1.0, 4.0);
let size = ((w / density / 16.0).clamp(40.0, 96.0) * density).round();
let line = 11.0 * density * 1.5;
let pad = 16.0 * density;
let face = |px: i32, weight: i32, name: &str| {
let name: Vec<u16> = name.encode_utf16().chain(std::iter::once(0)).collect();
CreateFontW(
-px,
0,
0,
0,
weight,
0,
0,
0,
DEFAULT_CHARSET as u32,
OUT_DEFAULT_PRECIS as u32,
CLIP_DEFAULT_PRECIS as u32,
CLEARTYPE_QUALITY as u32,
FF_DONTCARE as u32,
name.as_ptr(),
)
};
// The wordmark with its tagline, centred.
let small = (size * 0.25).clamp(11.0 * density, 18.0 * density);
let block = size * 1.45 + small * 0.6 + small * 1.5;
let top = (h - block) / 2.0;
let mark = face(-(size as i32), 600, "Geist SemiBold");
let old_mark = SelectObject(hdc, mark);
SetBkMode(hdc, TRANSPARENT as i32);
SetTextColor(hdc, WORDMARK);
let text: Vec<u16> = "Horizon".encode_utf16().collect();
let mut mark_rect = RECT {
left: 0,
top: top as i32,
right: w as i32,
bottom: (top + size * 1.45) as i32,
};
DrawTextW(
hdc,
text.as_ptr(),
text.len() as i32,
&mut mark_rect,
DT_CENTER | DT_SINGLELINE | DT_NOCLIP | DT_VCENTER,
);
let tagline = face(-(small as i32), 400, "Geist Mono");
let old_tagline = SelectObject(hdc, tagline);
SetTextColor(hdc, CONSOLE);
let tagline_text: Vec<u16> = "Rust based minecraft client".encode_utf16().collect();
let tagline_top = top + size * 1.45 + small * 0.6;
let mut tagline_rect = RECT {
left: 0,
top: tagline_top as i32,
right: w as i32,
bottom: (tagline_top + small * 1.5) as i32,
};
DrawTextW(
hdc,
tagline_text.as_ptr(),
tagline_text.len() as i32,
&mut tagline_rect,
DT_CENTER | DT_SINGLELINE | DT_NOCLIP | DT_VCENTER,
);
// The console's last lines at the bottom left, the newest at the
// bottom: long lines wrap word-wise and the block grows upwards, as
// the game's `draw_boot_log` draws it (capped at 24 sub-lines).
let console = face(-(line as i32), 400, "Geist Mono");
let old_console = SelectObject(hdc, console);
SetTextColor(hdc, CONSOLE);
let max_rows = (((h - pad) / line) as usize).clamp(1, 24);
let mut y = h - pad;
let mut drawn = 0;
'lines: for text in logs.iter().rev() {
if text.is_empty() {
continue;
}
let wide: Vec<u16> = text.encode_utf16().collect();
// The wrapped height, measured without drawing (DT_CALCRECT);
// every row is the face's cell height, `line`.
let mut rect = RECT {
left: pad as i32,
top: 0,
right: (w - pad) as i32,
bottom: 0,
};
DrawTextW(
hdc,
wide.as_ptr(),
wide.len() as i32,
&mut rect,
DT_LEFT | DT_WORDBREAK | DT_CALCRECT,
);
let height = rect.bottom.max(line as i32);
let rows = ((height as f32 / line).ceil() as usize).max(1);
// The block may only grow past the top before anything is drawn
// (a newest line wider than the whole screen on a tiny window);
// once something is there, older lines stop.
if drawn + rows > max_rows && drawn > 0 {
break 'lines;
}
rect.top = ((y - height as f32).max(0.0)) as i32;
rect.bottom = y as i32;
DrawTextW(
hdc,
wide.as_ptr(),
wide.len() as i32,
&mut rect,
DT_LEFT | DT_WORDBREAK,
);
y -= height as f32;
drawn += rows;
}
SelectObject(hdc, old_mark);
SelectObject(hdc, old_tagline);
SelectObject(hdc, old_console);
let _ = DeleteObject(mark);
let _ = DeleteObject(tagline);
let _ = DeleteObject(console);
let _ = ReleaseDC(hwnd, hdc);
}
}
+4
View File
@@ -1,6 +1,10 @@
//! Scheduling hints for the game's threads: the few OS calls that the other
//! crates, which forbid unsafe code, can't make themselves.
/// The game window's boot screen, before wgpu's first frame paints over it.
#[cfg(target_os = "windows")]
pub mod boot_paint;
use std::sync::atomic::{AtomicBool, Ordering};
/// Ask the OS to run the calling thread ahead of the process's other
Loaded 100 of 121 files, more files were not shown because too many files have changed in this diff. Show more