Compare commits

...
Author SHA1 Message Date
Ludovic Marie e0a47e280a Union union/pt2-c: #2570 461a8757e 2026-10-29 14:25:23 +01:00
Ludovic Marie 6a303666e6 Union union/pt2-c: #3337 de25583ee 2026-10-29 14:25:22 +01:00
Ludovic Marie e753734a9c Union union/pt2-c: #3148 04cd6ece2 2026-10-29 14:25:22 +01:00
Ludovic Marie f2614a57ff Union union/pt2-c: #3522 5485e189f 2026-10-29 14:25:21 +01:00
Ludovic Marie eb0ab4dd61 Union union/pt2-c: #3479 dc8f01ea1 2026-10-29 14:25:20 +01:00
Ludovic Marie 2b126bc88c Union union/pt2-c: #3478 b65c319b2 2026-10-29 14:25:19 +01:00
Ludovic Marie a7f30b3263 Union union/pt2-c: #3477 8fb51b0c4 2026-10-29 14:25:18 +01:00
Ludovic Marie d85d6f0865 Union union/pt2-c: #3476 0a00df764 2026-10-29 14:25:17 +01:00
Ludovic Marie 0f71e9b8b0 Union union/pt2-c: #3325 1d01a2d69 2026-10-29 14:25:17 +01:00
Ludovic Marie e3776a6b1a Union union/pt2-c: #3391 ca1b200c7 2026-10-29 14:25:16 +01:00
Ludovic Marie ec03cda7c3 Union union/pt2-c: #3390 b603db617 2026-10-29 14:25:16 +01:00
Ludovic Marie d9e1386453 Union union/pt2-c: #3368 dffd3973b 2026-10-29 14:25:15 +01:00
Ludovic Marie 4513e6e8eb Union union/pt2-c: #3367 779b77353 2026-10-29 14:25:14 +01:00
Ludovic Marie 3307f31d76 Union union/pt2-c: #3366 84185641b 2026-10-29 14:25:13 +01:00
Ludovic Marie 3909394b9c Union union/pt2-c: #3365 e60455d12 2026-10-29 14:25:13 +01:00
Ludovic Marie d4298692b4 Union union/pt2-c: #3364 043f9c7b7 2026-10-29 14:25:12 +01:00
Ludovic MarieandClaude Opus 5.5 461a8757e3 Merge main into the branch
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 13:19:44 +01:00
Ludovic MarieandClaude Opus 5.5 dc8f01ea19 Merge main into the branch
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 12:28:51 +01:00
Ludovic MarieandClaude Opus 5.5 b65c319b2a Merge main into the branch
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 12:28:50 +01:00
Ludovic MarieandClaude Opus 5.5 8fb51b0c4a Merge main into the branch
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 12:28:50 +01:00
Ludovic MarieandClaude Opus 5.5 0a00df7640 Merge main into the branch
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 12:28:49 +01:00
Ludovic MarieandClaude Opus 5.5 dffd3973bc Merge main into perf/far-tiles-biome-memo
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 12:07:12 +01:00
Ludovic MarieandClaude Opus 5.5 84185641bc Merge main into perf/worldgen-surface-biomes
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 12:07:11 +01:00
Ludovic MarieandClaude Opus 5.5 e60455d12c Merge main into perf/worldgen-biome-stage
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 12:07:11 +01:00
Ludovic MarieandClaude Opus 5.5 043f9c7b7b Merge main into perf/worldgen-structure-climate
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 12:07:11 +01:00
Ludovic Marie 73ae9f6cf1 Merge branch 'upd/3478' into upd/3479 2026-10-29 11:46:40 +01:00
Ludovic Marie 69fc342a1a Merge remote-tracking branch 'origin/main' into upd/3478
# Conflicts:
#	crates/horizon-client/src/game/world_loading.rs
#	crates/horizon-client/src/vanillagen.rs
#	crates/horizon-world/src/worldgen/generator.rs
2026-10-29 11:46:37 +01:00
Ludovic Marie ad7904946e Merge remote-tracking branch 'origin/main' into upd/3477
# Conflicts:
#	crates/horizon-world/src/worldgen/generator.rs
2026-10-29 11:45:59 +01:00
Ludovic Marie ca1b200c79 Merge remote-tracking branch 'origin/main' into upd/3391
# Conflicts:
#	crates/horizon-world/src/worldgen/generator.rs
2026-10-29 11:09:33 +01:00
Ludovic Marie b603db6176 Merge remote-tracking branch 'origin/main' into upd/3390
# Conflicts:
#	crates/horizon-world/src/worldgen/generator.rs
2026-10-29 11:09:18 +01:00
Ludovic Marie 779b773530 Merge remote-tracking branch 'origin/main' into upd/3367
# Conflicts:
#	crates/horizon-world/src/worldgen/generator_decoration.rs
2026-10-29 11:08:25 +01:00
Ludovic Marie 1d01a2d696 Merge remote-tracking branch 'origin/main' into upd/3325 2026-10-29 10:51:09 +01:00
Ludovic Marie 04cd6ece23 Drop the test that only exercised std::mem::replace (the fix is the one line in as_guest) 2026-10-29 10:16:08 +01:00
Ludovic Marie 9218dc3e7d Leave horizon-server's lists test as on main (not part of this fix) 2026-10-29 10:15:47 +01:00
Ludovic Marie 5b66bd0ec0 Merge remote-tracking branch 'origin/main' into upd/3148 2026-10-29 10:15:46 +01:00
levzzzandOpenAI Codex c91ca07115 Merge current main and use its preset-aware terrain interface
Co-Authored-By: OpenAI Codex <[email protected]>
2026-10-29 13:58:50 +05:00
Alexander KrylovandHermes Agent 5485e189f8 Options: Horizon's own captions read the bundled language
The options pages, the mod and shader pack lists, the agent button and the
multiplayer friend screens drew their own labels as literals, so a client with
ru_ru stayed English there. They read `horizon.*` keys now, and the bundled
namespace carries both languages for every one of them.

Co-Authored-By: Hermes Agent (Nous Research) <[email protected]>
2026-10-29 08:35:09 +03:00
ThatCakeIsAPieandClaude Opus 5.5 a849545f63 Harness: chunks generated with a world's data packs against vanilla
test_datapack_worldgen_parity.py runs the whole-chunk oracle and
chunk_dump with HORIZON_DATAPACKS: the oracle selects every pack of the
folder after vanilla through vanilla's pack repository, chunk_dump reads
them through worldgen::overrides into GeneratorData::load_with.
tools/fixtures/datapacks holds Horizon's own pack (MIT), overriding a
noise, a density function, the overworld noise settings, a placed
feature and a biome tag, and adding a noise and two density functions.
Each site must match vanilla with the pack and differ from Horizon
without it. HORIZON_PARITY_DATAPACKS compares another folder of packs.

docs/world-generation.md describes what a pack changes and what Horizon
leaves out; the README lists world generation among what data packs do.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 18:12:53 -05:00
ThatCakeIsAPieandClaude Opus 5.5 b0e65dec06 Data packs: a world's packs change its world generation
The enabled packs of <world>/datapacks (in datapacks.json's order) now
reach the chunk generator: their worldgen files replace and join the
prepared bundles, and the log lists what Horizon leaves out (new biomes,
dimension types, folders the generator does not read, noise taller than
the level). Generator data is cached per set of pack entries, beside the
plain bundle the world screens preload. datapack::enabled_ids reads which
packs are on, as /datapack does.

A world without data packs reads the same bundle as before. Sapling
growth and bone meal still place vanilla's features.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 18:12:53 -05:00
ThatCakeIsAPieandClaude Opus 5.5 d1ef7b4fb0 Worldgen: data packs' entries over the prepared bundles
WorldgenOverrides maps a data pack's worldgen files (noise, density
functions, noise settings, material rules and conditions, carvers,
features, placed features, structures, sets, pools, processor lists,
biomes, level stems) to the prepared bundle each joins, the highest pack
winning, and its block, fluid and biome tag files to the tag bundles in
pack order, as vanilla builds its registries from the enabled packs.
Carvers and features use 26.3's folder names (worldgen/carver,
worldgen/feature). A pack's new biome is left out, with the tag entries
and climate points that name it. Dimension types are reported, not
applied: each level keeps its vanilla build height for now.

Nothing calls it yet.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 18:12:52 -05:00
ThatCakeIsAPieandClaude Opus 5.5 25662576ff Worldgen: the generator loads a world's data packs over the bundles
GeneratorData::load_with reads every bundle through WorldgenOverrides
(the loader threads included), so a data pack's noises, density
functions, noise settings, rules, carvers, features, structures and
biome changes reach generation. Pack tag files join the prepared tags;
a tag that names something missing is left out with a warning, as
vanilla's TagLoader does, where the prepared tags alone must still all
resolve. A pack's level stem replaces the world preset's dimension.
Noise settings that reach past the dimension's build height fill only
the part inside it (NoiseSettings.clampToHeightAccessor), and the
generator says so, since every level keeps its vanilla height for now.

load is load_with with no packs, so vanilla generation is unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 18:12:52 -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
Lyle ColeandClaude Opus 5.5 51244a64f6 Decoration: column scans, per-chunk heightmap reads, zoom-free uniform biomes
Three read paths in decoration:
- Speleothem clusters (and large dripstone) scan each column up and down
  for the floor and ceiling, up to 512 blocks, one region read per block.
  FeatureWorld::scan_column walks a chunk's column directly: an all-air
  section is tested once (the predicate is a pure state test), a mixed
  one is read without a section lookup per block. Same blocks, same stop.
- OreFeature's any_height_at_least read each column of its square through
  a region lookup and a checked heightmap read; now each chunk's part of
  the square is read from its heightmap in one pass (any column will do,
  so order is free).
- Placement biome checks ran the biome zoom (eight hashed corner
  distances) for every position. When the 2x2x2 quarts the zoom picks
  from lie in biome sections that each hold one biome, the same one, the
  answer is that biome whatever the zoom picks; per-section uniformity is
  worked out once per region.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 14:57:18 -05:00
Lyle ColeandClaude Opus 5.5 1d25065363 Decoration: cut per-region and per-shape-update setup
Three costs that came before any feature work:
- the biomes present around the chunk sorted every quart of the nine
  chunks' columns into a BTreeSet; a bitset gives the same ids in the same
  ascending order;
- every updateShape call built and dropped a whole Ticks (tick maps,
  deadline index) just to capture the ticks it schedules; capturing
  leaves the rest untouched, so the region keeps one;
- the nine stand-in chunks left in the proto chunks while the region
  holds their terrain were allocated at full height; one section does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 14:57:18 -05:00
ThatCakeIsAPieandClaude Opus 5.5 22f0e30d07 Block reads: inline the region-to-palette path
A block read in decoration (and the surface stage's reads) went through
out-of-line calls: the region's chunk lookup, TerrainChunk::block,
TerrainBlocks::block and BlockSection::get, whose io::Result error path
kept it from inlining. They now inline into the feature code (the fast
build has no cross-crate LTO, so the core accessors need #[inline]), and
TerrainBlocks reads the section through an infallible accessor: its index
is a local index, always below 4096. #1790 takes the division out of
PackedStorage::read; this leaves storage alone.

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

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 14:57:16 -05:00
Lyle ColeandClaude Opus 5.5 5a75089f61 Biome stage: biome ids by bitset, names by a last-entry memo
Two lookups in the biome stage:
- the biome ids present around the chunk were gathered by inserting
  every quart of the nine neighbour chunks into a BTreeSet; a bitset
  gives the same ids in the same ascending order;
- each quart's biome name was looked up in the name -> id map. Names
  point into the biome source's parameter list and neighbouring quarts
  mostly resolve to the same entry, so the last entry's id is kept and
  the map is asked only when the name changes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 14:57:15 -05:00
Lyle ColeandClaude Opus 5.5 483ad5209c Biome stage: build free neighbour biome chunks before waiting
Each chunk needs the biome chunks of its 3x3 neighbourhood, built once by
whichever thread gets there first (per-chunk work lock). With several
workers on neighbouring chunks, a thread often blocked on a neighbour
another thread was building while other neighbours were still unbuilt.
The stage now first builds (or reads) every neighbour whose lock is free
(try_lock), then waits for the ones still being built. Each biome chunk
is still built exactly once, deterministically; only the order of the
work changes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 14:57:15 -05:00
Lyle ColeandClaude Opus 5.5 91369d11c5 Structure column checks: sample the column's climate in one volume
couldStructureExistInColumn (jigsaw projection, onTopOfChunkCenter,
temples, mansions) samples the column through createResolverForChunk:
one bulk climate volume of 1 x n x 1 quarts, then each quart from the
bottom. Horizon took one scalar climate sample per quart instead, and a
column with no valid biome walks all of them, so a few such start chunks
held most of the structure stage while neighbours waited on them. The
column is now one prepared volume through the biome stage's resolver
path, as vanilla does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 14:57:14 -05:00
ThatCakeIsAPieandClaude Opus 5.5 82eeba7753 Structure checks: keep each worker's single noise-biome samples
Structure placement asks the biome of one quart at a time: several
structures test the same quarts of a start chunk, and column checks walk
whole columns when no quart matches. Each answer is a full climate sample
and parameter search. Workers now keep the answers, up to 65536 quarts;
the generator is seed-bound, so they cannot go stale.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 14:57:14 -05:00
degepieandClaude Opus 5.5 de25583ee5 perf_bench: compile the driver mod with JAVA_HOME's javac
The vanilla runs start `$JAVA_HOME/bin/java`, but the driver mod was
built with whatever `javac` came first on PATH. With JAVA_HOME on a JDK 25
and an older default JDK on PATH (21 on Ubuntu 24.04, for example),
`javac --release 25` fails with "release version 25 not supported" before
any game starts. Use JAVA_HOME's javac when JAVA_HOME is set, as the java
lookups below already do; without it, PATH is used as before.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UE3PMKbnTcxfBSeT5uQWCK
2026-10-29 00:21:18 +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
Shreyas 380d71d8a8 lists: fall back to offline resolution when services do not answer in tests 2026-10-28 16:31:23 +05:30
Shreyas 0543cf0345 Select the picked hotbar slot in singleplayer and LAN
Preserve the guest's updated hotbar selection when restoring the host
player state after an action. The previous restore sequence discarded
the guest's new selection while keeping the inventory changes.

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

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

Fixes #3031
2026-10-28 16:00:22 +05:30
Ludovic MarieandClaude Opus 5.5 e1d825312f Read the flat world's horizon from WorldGen's preset
main's dark disc and void fog read WorldGen.flat, which is now the preset
(seen by sirrayi's review).

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 12:39:11 +01:00
Claude Codeandminimax-m3.1-flash 25b84dfc2e Format the world preset test
rustfmt keeps the registry read on one line.

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

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

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

Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>
2026-10-27 16:11:14 +05:00
65 changed files with 3088 additions and 382 deletions

No files matched your search

+1 -1
View File
@@ -46,7 +46,7 @@ The [documentation](https://playhorizon.dev/docs/) covers each of these.
- **The game:** singleplayer with vanilla's world generation, mobs, villagers, raids, redstone, every command, the HUD and the recipe book, on saves in vanilla's format. Worlds from 1.8 to 26.2 are upgraded as vanilla does. [Status table](#project-status).
- **Horizon's own extras:** new menus (vanilla's are one setting away), graphics presets, distant terrain up to 1024 chunks, controller support, optional zoom, minimap and more, all off by default where they would change the vanilla look. See [Settings](https://playhorizon.dev/docs/settings).
- **Resource packs and data packs:** resource packs work, and data packs load functions, predicates and item modifiers.
- **Resource packs and data packs:** resource packs work, and data packs load functions, predicates, item modifiers and world generation (vanilla's heights and biomes for now).
- **Playing with others:** invite friends from the pause menu's Friends button with a code, Open to LAN, and joining servers, online-mode ones with the Microsoft account the launcher signs in with. See [Multiplayer](https://playhorizon.dev/docs/multiplayer).
- **Mods:** Horizon has its own mods, installed from the launcher. The official ones (Zoom, FPS Counter, Keystrokes and Minimap) are native Rust that the launcher builds into your game, with no Rust to install; older WebAssembly mods still run while they move. Java mod jars do not run in Horizon. See [Mods and data packs](https://playhorizon.dev/docs/mods).
+7
View File
@@ -266,6 +266,13 @@ mod tests {
assert_eq!(en.get("horizon.options.controller"), Some("Controller"));
let ru = Language::load(&[&empty], "ru_ru");
assert_eq!(ru.get("horizon.options.controller"), Some("Геймпад"));
// Every bundled string carries both languages: a key added to one file
// and forgotten in the other shows as a key name on screen.
for key in en.map().keys() {
assert!(ru.get(key).is_some(), "{key} has no ru_ru value");
}
assert_eq!(en.get("horizon.pages.mods"), Some("Mods"));
assert_eq!(ru.get("horizon.pages.mods"), Some("Моды"));
}
#[test]
@@ -256,6 +256,10 @@ impl Biomes {
.copied()
.unwrap_or(0)
}
/// Whether the game has the biome `name`.
pub fn contains(&self, name: &str) -> bool {
self.index.contains_key(name)
}
pub fn get(&self, id: u16) -> &BiomeInfo {
&self.list[usize::from(id).min(self.list.len() - 1)]
}
+288 -1
View File
@@ -2,7 +2,10 @@
//! the parts of them commands use: `.mcfunction` functions (with macros and
//! line continuation), function tags and predicates.
use crate::zip::Zip;
use horizon_world::nbt::Tag;
use horizon_world::{
nbt::Tag,
worldgen::overrides::{self, PackFile, WorldgenOverrides},
};
use std::{
collections::BTreeMap,
path::{Path, PathBuf},
@@ -125,6 +128,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.
@@ -421,9 +532,138 @@ fn hash_normalize(s: &str) -> String {
}
}
/// The ids of `packs` (a world's, from `discover`) that are enabled, lowest
/// priority first: those `<world>/datapacks.json` lists as enabled, in its
/// order, then every pack it lists neither way, in name order.
pub fn enabled_ids(world: &Path, packs: &[DataPack]) -> Vec<String> {
let saved: Option<serde_json::Value> = std::fs::read(world.join("datapacks.json"))
.ok()
.and_then(|b| serde_json::from_slice(&b).ok());
let list = |key: &str| -> Vec<String> {
saved
.as_ref()
.and_then(|v| v[key].as_array())
.map(|a| {
a.iter()
.filter_map(|s| s.as_str().map(str::to_owned))
.collect()
})
.unwrap_or_default()
};
let disabled = list("disabled");
let mut enabled: Vec<String> = list("enabled")
.into_iter()
.filter(|id| packs.iter().any(|p| &p.id == id))
.collect();
for p in packs {
if !enabled.contains(&p.id) && !disabled.contains(&p.id) {
enabled.push(p.id.clone());
}
}
enabled
}
// ---- worldgen ----------------------------------------------------------------
/// The worldgen entries of `packs` (lowest priority first, the built-in pack
/// left out: the prepared bundles are it) for the chunk generator, and what
/// it leaves out, for the log. `biomes` says which biomes the game has: a
/// pack's new biome is left out (the game has no colours, sounds or id for it).
pub fn worldgen_overrides(
packs: &[&DataPack],
biomes: impl Fn(&str) -> bool,
) -> (WorldgenOverrides, Vec<String>) {
let mut out = WorldgenOverrides::default();
let mut not_read: BTreeMap<String, usize> = BTreeMap::new();
let mut warnings = Vec::new();
for pack in packs {
for file in pack.list_kinds(&overrides::PACK_KINDS) {
let read = || String::from_utf8(pack.read(&file)?).ok();
match out.add_file(&pack.id, &file, read) {
PackFile::Read | PackFile::Other => {}
PackFile::DimensionType { id, min_y, height } => warnings.push(format!(
"{}: dimension type {id} (min_y {min_y}, height {height}) is not used: \
the dimension keeps its vanilla build height",
pack.id
)),
PackFile::NotRead(kind) => {
*not_read.entry(format!("{}: {kind}", pack.id)).or_default() += 1
}
PackFile::Unreadable => warnings.push(format!("{}: {file}: unreadable", pack.id)),
}
}
}
warnings.extend(out.finish(biomes));
for (kind, files) in not_read {
warnings.push(format!("{kind}: {files} files the generator does not read"));
}
(out, warnings)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn enabled_packs_override_worldgen_in_order() {
let dir = std::env::temp_dir().join(format!("hz-datapack-worldgen-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let write = |pack: &str, p: &str, t: &str| {
let path = dir.join("datapacks").join(pack).join(p);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, t).unwrap();
};
for pack in ["a", "b", "off"] {
write(pack, "pack.mcmeta", "{}");
write(pack, "data/minecraft/worldgen/noise/cave.json", pack);
write(
pack,
"data/minecraft/tags/worldgen/biome/is_hill.json",
"{\"values\":[]}",
);
}
write("a", "data/minecraft/worldgen/biome/new_peaks.json", "{}");
write("a", "data/minecraft/worldgen/biome/plains.json", "{}");
write(
"a",
"data/minecraft/worldgen/world_preset/normal.json",
"{}",
);
write(
"b",
"data/minecraft/dimension_type/overworld.json",
"{\"min_y\":-64,\"height\":2096}",
);
// `b` is enabled first, so `a` (enabled after it) wins.
std::fs::write(
dir.join("datapacks.json"),
"{\"enabled\":[\"file/b\",\"file/a\"],\"disabled\":[\"file/off\"]}",
)
.unwrap();
let packs = discover(&dir);
let enabled = enabled_ids(&dir, &packs);
assert_eq!(enabled, ["file/b", "file/a"]);
let enabled: Vec<&DataPack> = enabled
.iter()
.map(|id| packs.iter().find(|p| &p.id == id).unwrap())
.collect();
let (o, warnings) = worldgen_overrides(&enabled, |b| b == "minecraft:plains");
assert_eq!(
o.entries("noises").collect::<Vec<_>>(),
[("minecraft:cave", "a")]
);
assert_eq!(
o.entries("biomes").collect::<Vec<_>>(),
[("minecraft:plains", "{}")]
);
let tag_packs: Vec<&str> = o.tags("biome_tags").map(|(_, p, _)| p).collect();
assert_eq!(tag_packs, ["file/b", "file/a"]);
assert_eq!(warnings.len(), 3, "{warnings:?}");
assert!(warnings[0].contains("height 2096"));
assert!(warnings[1].contains("minecraft:new_peaks"));
assert!(warnings[2].contains("worldgen/world_preset"));
let _ = std::fs::remove_dir_all(&dir);
}
use horizon_world::nbt::parse_snbt;
#[test]
@@ -566,4 +806,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);
}
}
+10 -1
View File
@@ -534,6 +534,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.
@@ -589,6 +591,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();
@@ -633,6 +636,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) {
@@ -695,6 +701,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 }
}
@@ -991,9 +998,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
+146 -90
View File
@@ -217,7 +217,7 @@ impl Opt {
Opt::NotificationDisplayTime => "options.notifications.display_time",
Opt::Narrator => "options.narrator",
Opt::TextBackground => "options.accessibility.text_background",
Opt::ChatDelay => "Chat Delay",
Opt::ChatDelay => "horizon.options.chat_delay",
Opt::DistortionEffects => "options.screenEffectScale",
Opt::FovEffects => "options.fovEffectScale",
Opt::DarknessPulsing => "options.darknessEffectScale",
@@ -603,25 +603,25 @@ impl Opt {
} else {
"options.key.hold"
}),
Opt::PadLightbar => Text::literal(match s.controller.lightbar {
gamepad::Lightbar::Health => "Health",
gamepad::Lightbar::Dimension => "Dimension",
gamepad::Lightbar::Off => "Off",
Opt::PadLightbar => tr(match s.controller.lightbar {
gamepad::Lightbar::Health => "horizon.options.lightbar.health",
gamepad::Lightbar::Dimension => "horizon.options.lightbar.dimension",
gamepad::Lightbar::Off => "options.off",
}),
Opt::PadGlyphs => Text::literal(match s.controller.glyphs {
GlyphStyle::Auto => "Auto",
GlyphStyle::Xbox => "Xbox",
GlyphStyle::PlayStation => "PlayStation",
GlyphStyle::Nintendo => "Nintendo",
Opt::PadGlyphs => tr(match s.controller.glyphs {
GlyphStyle::Auto => "horizon.options.glyphs.auto",
GlyphStyle::Xbox => "horizon.options.glyphs.xbox",
GlyphStyle::PlayStation => "horizon.options.glyphs.playstation",
GlyphStyle::Nintendo => "horizon.options.glyphs.nintendo",
}),
Opt::PadMenuNav => Text::literal(match s.controller.menu_nav {
gamepad::MenuNav::Focus => "Focus",
gamepad::MenuNav::Pointer => "Pointer",
Opt::PadMenuNav => tr(match s.controller.menu_nav {
gamepad::MenuNav::Focus => "horizon.options.menu_focus",
gamepad::MenuNav::Pointer => "horizon.options.menu_pointer",
}),
Opt::PadKeyboard => Text::literal(match s.controller.keyboard {
gamepad::TextKeyboard::Auto => "Auto",
gamepad::TextKeyboard::Horizon => "Horizon",
gamepad::TextKeyboard::Steam => "Steam",
Opt::PadKeyboard => tr(match s.controller.keyboard {
gamepad::TextKeyboard::Auto => "horizon.options.keyboard.auto",
gamepad::TextKeyboard::Horizon => "horizon.options.keyboard.horizon",
gamepad::TextKeyboard::Steam => "horizon.options.keyboard.steam",
}),
_ => Text::empty(),
}
@@ -1012,7 +1012,11 @@ pub enum Event {
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,
@@ -1078,6 +1082,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).
@@ -1112,6 +1123,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,
@@ -1164,22 +1178,14 @@ fn option_widget(s: &Settings, opt: Opt, x: f32, y: f32, w: f32) -> Widget {
/// What a button opening one of Horizon's pages says on hover.
fn horizon_page_tooltip(page: Page) -> Option<&'static str> {
Some(match page {
Page::Horizon => "Horizon's own settings: graphics, controllers, extras and mods.",
Page::Graphics => "Graphics, simply: each setting says what it costs.",
Page::GraphicsAdvanced => {
"Every graphics setting, Horizon's included, with what each costs."
}
Page::Controller => "Controllers: their buttons, sticks, vibration and menu navigation.",
Page::Extras => {
"Optional additions, all off by default: the game stays vanilla until you turn one on."
}
Page::Agent => "Let your own AI agent play: horizon-ctl and MCP clients on this computer.",
Page::ShaderPacks => {
"Shader packs from the shaderpacks folder: Horizon's own and those in the shaders/ format. One runs at a time."
}
Page::Mods => {
"Mods made for Horizon, from the horizon-mods folder. Each runs in a sandbox and stays off until you turn it on."
}
Page::Horizon => "horizon.pages.settings.tooltip",
Page::Graphics => "horizon.pages.graphics.tooltip",
Page::GraphicsAdvanced => "horizon.pages.graphics_advanced.tooltip",
Page::Controller => "horizon.pages.controller.tooltip",
Page::Extras => "horizon.pages.extras.tooltip",
Page::Agent => "horizon.pages.agent.tooltip",
Page::ShaderPacks => "horizon.pages.shader_packs.tooltip",
Page::Mods => "horizon.pages.mods.tooltip",
_ => return None,
})
}
@@ -1219,9 +1225,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;
@@ -1269,16 +1314,16 @@ impl OptionsUi {
Page::Language => "options.language.title",
Page::Font => "options.font.title",
Page::Packs => "resourcePack.title",
Page::Mods => "Mods",
Page::Mods => "horizon.pages.mods",
Page::NativeMod(_) => "Mod Settings",
Page::ShaderPacks => "Shader Packs",
Page::ShaderPacks => "horizon.pages.shader_packs",
Page::Graphics | Page::GraphicsAdvanced => "options.videoTitle",
Page::Controller => "Controller Settings",
Page::PadBinds => "Controller Binds",
Page::Extras => "Horizon Extras",
Page::Controller => "horizon.pages.controller",
Page::PadBinds => "horizon.pages.controller_binds",
Page::Extras => "horizon.pages.extras",
Page::Credits => "credits_and_attribution.screen.title",
Page::Horizon => "Horizon Settings",
Page::Agent => "AI Agent",
Page::Horizon => "horizon.pages.settings",
Page::Agent => "horizon.pages.agent",
}
}
fn scrollbar_x(&self, width: f32) -> f32 {
@@ -1416,21 +1461,23 @@ impl OptionsUi {
Page::Horizon => {
// Two to a row, as vanilla's OptionsList.addSmall lays out
// buttons; each opens a page built from vanilla's widgets.
let open = |label: &str, page: Page| {
button(0.0, 0.0, 150.0, Text::literal(label), Act::Push(page))
};
let open =
|key: &str, page: Page| button(0.0, 0.0, 150.0, tr(key), Act::Push(page));
vec![
Row::Buttons(
open("Graphics...", Page::Graphics),
Some(open("Advanced Graphics...", Page::GraphicsAdvanced)),
open("horizon.pages.open.graphics", Page::Graphics),
Some(open(
"horizon.pages.open.graphics_advanced",
Page::GraphicsAdvanced,
)),
),
Row::Buttons(
open("Shader Packs...", Page::ShaderPacks),
Some(open("Controller Settings...", Page::Controller)),
open("horizon.pages.open.shader_packs", Page::ShaderPacks),
Some(open("horizon.pages.open.controller", Page::Controller)),
),
Row::Buttons(
open("Extras...", Page::Extras),
Some(open("Mods...", Page::Mods)),
open("horizon.pages.open.extras", Page::Extras),
Some(open("horizon.pages.open.mods", Page::Mods)),
),
// No AI hides the AI Agent page and everything AI.
Row::Buttons(
@@ -1438,7 +1485,7 @@ impl OptionsUi {
Some(if s.extras.no_ai {
self.extra_button(s, crate::extras::Item::NoAi)
} else {
open("AI Agent...", Page::Agent)
open("horizon.pages.open.agent", Page::Agent)
}),
),
]
@@ -1457,7 +1504,7 @@ impl OptionsUi {
0.0,
0.0,
150.0,
Text::literal("Copy Connection Details"),
tr("horizon.agent.copy_details"),
Act::CopyAgentDetails,
)),
)];
@@ -1471,14 +1518,14 @@ impl OptionsUi {
0.0,
0.0,
150.0,
Text::literal("Horizon Graphics..."),
tr("horizon.pages.open.horizon_graphics"),
Act::Push(Page::Graphics),
),
Some(button(
0.0,
0.0,
150.0,
Text::literal("Advanced Graphics..."),
tr("horizon.pages.open.graphics_advanced"),
Act::Push(Page::GraphicsAdvanced),
)),
)];
@@ -1573,7 +1620,7 @@ impl OptionsUi {
0.0,
0.0,
310.0,
Text::literal("Controller Settings..."),
tr("horizon.pages.open.controller"),
Act::Push(Page::Controller),
),
None,
@@ -1586,7 +1633,7 @@ impl OptionsUi {
0.0,
0.0,
310.0,
Text::literal("Controller Binds..."),
tr("horizon.pages.open.controller_binds"),
Act::Push(Page::PadBinds),
),
None,
@@ -1644,9 +1691,7 @@ impl OptionsUi {
Page::Font => pairs(&[Opt::ForceUnicode, Opt::JapaneseGlyphs]),
Page::Mods => {
if s.mods.installed.is_empty() {
return vec![Row::Header(
"No mods: put .hmod files in the horizon-mods folder",
)];
return vec![Row::Header("horizon.mods.empty")];
}
s.mods
.installed
@@ -1656,9 +1701,9 @@ impl OptionsUi {
let mut label = Text::literal(format!("{}: ", installed.title))
.append(on_off(s.mods.is_on(i)));
if installed.problem.is_some() {
label = label.append(" (Stopped)");
label = label.append(tr("horizon.mods.stopped"));
} else if installed.dev {
label = label.append(" (Dev)");
label = label.append(tr("horizon.mods.dev"));
}
let dead = installed.dev || installed.id.is_empty();
// A running mod with a settings page (capability
@@ -1669,7 +1714,7 @@ impl OptionsUi {
0.0,
0.0,
150.0,
Text::literal("Settings"),
tr("horizon.mods.settings"),
Act::ModSettings(i),
);
Row::Buttons(
@@ -1686,17 +1731,17 @@ impl OptionsUi {
Page::NativeMod(i) => self.native_rows(s, i),
Page::ShaderPacks => {
if s.shader_packs.is_empty() {
return vec![Row::Header(
"No shader packs: put them in the shaderpacks folder",
)];
return vec![Row::Header("horizon.shader_packs.empty")];
}
s.shader_packs
.iter()
.enumerate()
.map(|(i, pack)| {
let kind = match pack.kind {
crate::shader_pack_list::Kind::Horizon => " (Horizon)",
crate::shader_pack_list::Kind::Classic => "",
crate::shader_pack_list::Kind::Horizon => {
lang::get("horizon.shader_packs.kind_horizon")
}
crate::shader_pack_list::Kind::Classic => String::new(),
};
let label = Text::literal(format!("{}{kind}: ", pack.title))
.append(on_off(s.horizon.shader_pack == pack.file));
@@ -1756,8 +1801,8 @@ impl OptionsUi {
// Vanilla's Telemetry Data and Credits slots: Horizon's
// settings and its mods, as a mod adds its button here.
// Credits & Attribution (WinScreen's credits) follows them.
("Horizon Settings...", Some(Page::Horizon)),
("Mods...", Some(Page::Mods)),
("horizon.pages.open.horizon_settings", Some(Page::Horizon)),
("horizon.pages.open.mods", Some(Page::Mods)),
("options.credits_and_attribution", Some(Page::Credits)),
];
let rows = entries.len().div_ceil(2) as f32;
@@ -2190,18 +2235,12 @@ impl OptionsUi {
content = y + self.scroll - top + 2.0;
out[list_start..].iter_mut().for_each(|w| w.list = true);
let folder = match page {
Page::Mods => Some(("Open Mod Folder", Act::OpenModFolder)),
Page::ShaderPacks => Some(("Open Pack Folder", Act::OpenShaderPackFolder)),
Page::Mods => Some(("horizon.mods.open_folder", Act::OpenModFolder)),
Page::ShaderPacks => Some(("pack.openFolder", Act::OpenShaderPackFolder)),
_ => None,
};
if let Some((label, act)) = folder {
out.push(button(
cx - 154.0,
h - 27.0,
150.0,
Text::literal(label),
act,
));
out.push(button(cx - 154.0, h - 27.0, 150.0, tr(label), act));
out.push(button(cx + 4.0, h - 27.0, 150.0, tr("gui.done"), Act::Done));
} else {
out.push(done(h - 27.0));
@@ -2273,6 +2312,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);
@@ -2313,7 +2353,7 @@ impl OptionsUi {
} = &wd.kind
&& let Some(text) = horizon_page_tooltip(*page)
{
tooltip = Some(atlas.font.split(&Text::literal(text), 170.0));
tooltip = Some(atlas.font.split(&tr(text), 170.0));
}
let key = match &wd.kind {
WKind::Button {
@@ -2340,10 +2380,7 @@ impl OptionsUi {
} = &wd.kind
&& !opt.available()
{
tooltip = Some(atlas.font.split(
&Text::literal("This feature is not yet available in Horizon."),
170.0,
));
tooltip = Some(atlas.font.split(&tr("horizon.options.unavailable"), 170.0));
}
if let WKind::Button {
act: Act::ToggleMod(i),
@@ -2484,9 +2521,7 @@ impl OptionsUi {
if search.value.is_empty() && !search.focused {
batch.text_styled(
atlas,
&Text::literal("Search settings...")
.with_color(text::GRAY)
.italic(),
&tr("horizon.options.search").with_color(text::GRAY).italic(),
search.text_x(),
search.text_y(),
u32::MAX,
@@ -2568,7 +2603,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 => {}
}
@@ -2820,8 +2858,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,
@@ -2853,13 +2897,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));
}
}
}
_ => {}
@@ -3246,8 +3295,15 @@ mod tests {
ui.show(Page::Mods);
let empty = ui.layout(&s, size.0, size.1).0;
assert!(empty.iter().any(|w| {
matches!(&w.kind, WKind::Text { text, .. } if text.plain().contains(".hmod"))
matches!(&w.kind, WKind::Text { text, .. } if text.plain() == "horizon.mods.empty")
}));
// The empty page points at the bundled key; the English line behind it
// is what a player reads, so both halves are checked here.
let en = crate::lang::Language::load(&[], "en_us");
assert!(
en.get("horizon.mods.empty")
.is_some_and(|line| line.contains(".hmod"))
);
let open_folder = empty
.iter()
.find(|w| {
@@ -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,
+21 -6
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);
@@ -99,6 +99,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))
@@ -11,5 +11,57 @@
"horizon.options.look_deadzone": "Look Deadzone",
"horizon.options.button_glyphs": "Button Glyphs",
"horizon.options.menu_navigation": "Menu Navigation",
"horizon.options.on_screen_keyboard": "On-Screen Keyboard"
"horizon.options.on_screen_keyboard": "On-Screen Keyboard",
"horizon.options.chat_delay": "Chat Delay",
"horizon.options.lightbar.health": "Health",
"horizon.options.lightbar.dimension": "Dimension",
"horizon.options.glyphs.auto": "Auto",
"horizon.options.glyphs.xbox": "Xbox",
"horizon.options.glyphs.playstation": "PlayStation",
"horizon.options.glyphs.nintendo": "Nintendo",
"horizon.options.menu_focus": "Focus",
"horizon.options.menu_pointer": "Pointer",
"horizon.options.keyboard.auto": "Auto",
"horizon.options.keyboard.horizon": "Horizon",
"horizon.options.keyboard.steam": "Steam",
"horizon.options.unavailable": "This feature is not yet available in Horizon.",
"horizon.options.search": "Search settings...",
"horizon.pages.mods": "Mods",
"horizon.pages.shader_packs": "Shader Packs",
"horizon.pages.controller": "Controller Settings",
"horizon.pages.controller_binds": "Controller Binds",
"horizon.pages.extras": "Horizon Extras",
"horizon.pages.settings": "Horizon Settings",
"horizon.pages.agent": "AI Agent",
"horizon.pages.settings.tooltip": "Horizon's own settings: graphics, controllers, extras and mods.",
"horizon.pages.graphics.tooltip": "Graphics, simply: each setting says what it costs.",
"horizon.pages.graphics_advanced.tooltip": "Every graphics setting, Horizon's included, with what each costs.",
"horizon.pages.controller.tooltip": "Controllers: their buttons, sticks, vibration and menu navigation.",
"horizon.pages.extras.tooltip": "Optional additions, all off by default: the game stays vanilla until you turn one on.",
"horizon.pages.agent.tooltip": "Let your own AI agent play: horizon-ctl and MCP clients on this computer.",
"horizon.pages.shader_packs.tooltip": "Shader packs from the shaderpacks folder: Horizon's own and those in the shaders/ format. One runs at a time.",
"horizon.pages.mods.tooltip": "Mods made for Horizon, from the horizon-mods folder. Each runs in a sandbox and stays off until you turn it on.",
"horizon.pages.open.graphics": "Graphics...",
"horizon.pages.open.graphics_advanced": "Advanced Graphics...",
"horizon.pages.open.shader_packs": "Shader Packs...",
"horizon.pages.open.controller": "Controller Settings...",
"horizon.pages.open.extras": "Extras...",
"horizon.pages.open.mods": "Mods...",
"horizon.pages.open.agent": "AI Agent...",
"horizon.pages.open.horizon_graphics": "Horizon Graphics...",
"horizon.pages.open.controller_binds": "Controller Binds...",
"horizon.pages.open.horizon_settings": "Horizon Settings...",
"horizon.mods.empty": "No mods: put .hmod files in the horizon-mods folder",
"horizon.mods.stopped": " (Stopped)",
"horizon.mods.dev": " (Dev)",
"horizon.mods.settings": "Settings",
"horizon.mods.open_folder": "Open Mod Folder",
"horizon.shader_packs.empty": "No shader packs: put them in the shaderpacks folder",
"horizon.shader_packs.kind_horizon": " (Horizon)",
"horizon.agent.copy_details": "Copy Connection Details",
"horizon.multiplayer.join": "Join",
"horizon.multiplayer.cancel_reconnecting": "Cancel Reconnecting",
"horizon.multiplayer.join_friend": "Join a Friend",
"horizon.multiplayer.invite_code": "Invite Code",
"horizon.multiplayer.relay": "Relay (optional)"
}
@@ -11,5 +11,57 @@
"horizon.options.look_deadzone": "Мёртвая зона обзора",
"horizon.options.button_glyphs": "Символы кнопок",
"horizon.options.menu_navigation": "Навигация по меню",
"horizon.options.on_screen_keyboard": "Экранная клавиатура"
"horizon.options.on_screen_keyboard": "Экранная клавиатура",
"horizon.options.chat_delay": "Задержка чата",
"horizon.options.lightbar.health": "Здоровье",
"horizon.options.lightbar.dimension": "Измерение",
"horizon.options.glyphs.auto": "Авто",
"horizon.options.glyphs.xbox": "Xbox",
"horizon.options.glyphs.playstation": "PlayStation",
"horizon.options.glyphs.nintendo": "Nintendo",
"horizon.options.menu_focus": "Фокус",
"horizon.options.menu_pointer": "Указатель",
"horizon.options.keyboard.auto": "Авто",
"horizon.options.keyboard.horizon": "Horizon",
"horizon.options.keyboard.steam": "Steam",
"horizon.options.unavailable": "Эта возможность в Horizon пока недоступна.",
"horizon.options.search": "Поиск настроек...",
"horizon.pages.mods": "Моды",
"horizon.pages.shader_packs": "Шейдерпаки",
"horizon.pages.controller": "Настройки контроллера",
"horizon.pages.controller_binds": "Назначения кнопок контроллера",
"horizon.pages.extras": "Дополнения Horizon",
"horizon.pages.settings": "Настройки Horizon",
"horizon.pages.agent": "ИИ-агент",
"horizon.pages.settings.tooltip": "Собственные настройки Horizon: графика, контроллеры, дополнения и моды.",
"horizon.pages.graphics.tooltip": "Графика без лишнего: у каждой настройки написано, чего она стоит.",
"horizon.pages.graphics_advanced.tooltip": "Все графические настройки, включая собственные настройки Horizon, и их цена.",
"horizon.pages.controller.tooltip": "Контроллеры: кнопки, стики, вибрация и навигация по меню.",
"horizon.pages.extras.tooltip": "Дополнительные возможности, все выключены по умолчанию: игра остаётся ванильной, пока вы не включите что-то.",
"horizon.pages.agent.tooltip": "Пусть играет ваш ИИ-агент: horizon-ctl и MCP-клиенты на этом компьютере.",
"horizon.pages.shader_packs.tooltip": "Шейдерпаки из папки shaderpacks: собственные паки Horizon и паки в формате shaders/. Одновременно работает один.",
"horizon.pages.mods.tooltip": "Моды для Horizon из папки horizon-mods. Каждый работает в песочнице и выключен, пока вы его не включите.",
"horizon.pages.open.graphics": "Графика...",
"horizon.pages.open.graphics_advanced": "Продвинутая графика...",
"horizon.pages.open.shader_packs": "Шейдерпаки...",
"horizon.pages.open.controller": "Настройки контроллера...",
"horizon.pages.open.extras": "Дополнения...",
"horizon.pages.open.mods": "Моды...",
"horizon.pages.open.agent": "ИИ-агент...",
"horizon.pages.open.horizon_graphics": "Графика Horizon...",
"horizon.pages.open.controller_binds": "Назначения кнопок...",
"horizon.pages.open.horizon_settings": "Настройки Horizon...",
"horizon.mods.empty": "Модов нет: положите файлы .hmod в папку horizon-mods",
"horizon.mods.stopped": " (остановлен)",
"horizon.mods.dev": " (разработка)",
"horizon.mods.settings": "Настройки",
"horizon.mods.open_folder": "Открыть папку модов",
"horizon.shader_packs.empty": "Шейдерпаков нет: положите их в папку shaderpacks",
"horizon.shader_packs.kind_horizon": " (Horizon)",
"horizon.agent.copy_details": "Скопировать данные подключения",
"horizon.multiplayer.join": "Подключиться",
"horizon.multiplayer.cancel_reconnecting": "Отменить переподключение",
"horizon.multiplayer.join_friend": "Играть с другом",
"horizon.multiplayer.invite_code": "Код приглашения",
"horizon.multiplayer.relay": "Ретранслятор (необязательно)"
}
@@ -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,
+18 -1
View File
@@ -9136,6 +9136,15 @@ impl Game<'_> {
return Ok(());
}
self.join_world_rest()?;
// Create World's data packs, in the world before it opens.
if let Some(dir) = create.and_then(|o| o.pack_dir.as_deref()) {
let world = std::env::current_dir()?.join("saves").join(folder);
let packs = create.map_or(&[][..], |o| &o.data_packs[..]);
if let Err(e) = horizon_client::datapack::install(dir, &world, packs) {
log::warn!("Horizon: data packs for {folder}: {e}");
}
let _ = std::fs::remove_dir_all(dir);
}
if self
.options
.execution_role
@@ -9267,7 +9276,8 @@ impl Game<'_> {
book.to_be_displayed.retain(|r| index.contains(r));
self.dim = dim;
self.world_root = save_root.clone();
self.world_flat = horizon_client::save::WorldGen::read(&save_root).is_some_and(|w| w.flat);
self.world_flat = horizon_client::save::WorldGen::read(&save_root)
.is_some_and(|w| w.preset == horizon_client::save::WorldPreset::Flat);
self.block_ticks = Ticks::new(self.level.seed as i32);
self.tick_chunks.clear();
self.clear_entities();
@@ -13242,6 +13252,13 @@ impl ApplicationHandler for Game<'_> {
Action::Connect { address } => {
self.connect_server(&address, &self.options.player_name())?
}
Action::DataPacks { dir, selected } => {
self.keys.clear();
self.screen = Screen::Options;
self.options_from_title = true;
let resources = self.vanilla_root();
self.ui.options.open_data_packs(dir, selected, resources);
}
Action::None => {}
}
}
+2 -2
View File
@@ -564,7 +564,7 @@ impl Game<'_> {
"hardcore": options.hardcore,
"difficulty": horizon_client::title::DIFFICULTIES[options.difficulty()],
"allow_commands": options.commands(*creative),
"superflat": options.flat,
"world_type": options.world_type.id(),
"generate_structures": options.structures,
"bonus_chest": options.bonus_chest(),
}),
@@ -577,7 +577,7 @@ impl Game<'_> {
} => json!({
"mode": "customize-flat",
"fields": { "name": name, "seed": seed },
"superflat": options.flat,
"superflat": options.world_type == horizon_client::save::WorldPreset::Flat,
"biome": flat.settings().biome,
"layers": flat.settings().layers.iter().map(|layer| json!({
"block": layer.block,
+20 -23
View File
@@ -298,15 +298,14 @@ impl Lists {
}
}
/// The world presets of 26.3's `worldgen/world_preset`.
const WORLD_PRESETS: [&str; 7] = [
"minecraft:normal",
"minecraft:flat",
"minecraft:large_biomes",
"minecraft:amplified",
"minecraft:single_biome_surface",
"minecraft:debug_all_block_states",
"minecraft:flat_all_dimensions",
/// DedicatedServerProperties.LEGACY_PRESET_NAMES: the old `level-type`
/// names, before the presets had their own ids.
const LEGACY_PRESET_NAMES: [(&str, horizon_client::save::WorldPreset); 2] = [
("default", horizon_client::save::WorldPreset::Normal),
(
"largebiomes",
horizon_client::save::WorldPreset::LargeBiomes,
),
];
/// Identifier.tryParse: `namespace:path`, or a path in `minecraft`.
@@ -323,11 +322,18 @@ impl Game<'_> {
/// DedicatedServerProperties.createDimensions for a world that doesn't
/// exist yet: `level-type`'s preset, a flat world's `generator-settings`
/// and `generate-structures`, written before the world opens so that
/// streaming reads them. Horizon generates the normal and flat presets.
/// streaming reads them.
fn write_world_gen(&self, dir: &std::path::Path, props: &ServerProperties) -> Result<()> {
let level_type = &props.level_type;
let preset = parse_identifier(level_type)
.filter(|id| WORLD_PRESETS.contains(&id.as_str()))
.as_deref()
.and_then(horizon_client::save::WorldPreset::of_id)
.or_else(|| {
LEGACY_PRESET_NAMES
.iter()
.find(|(name, _)| *name == level_type)
.map(|(_, preset)| *preset)
})
.unwrap_or_else(|| {
log_from(
"Worker-Main-1",
@@ -336,18 +342,9 @@ impl Game<'_> {
"Failed to parse level-type {level_type}, defaulting to minecraft:normal"
),
);
"minecraft:normal".to_owned()
horizon_client::save::WorldPreset::Normal
});
let flat = preset == "minecraft:flat";
if !flat && preset != "minecraft:normal" {
log_from(
"Worker-Main-1",
"WARN",
&format!(
"Horizon doesn't generate the {preset} world preset yet: using minecraft:normal"
),
);
}
let flat = preset == horizon_client::save::WorldPreset::Flat;
let settings = if flat {
let biomes = self.res.biomes();
let (settings, errors) = horizon_client::flatgen::settings_from_json(
@@ -363,7 +360,7 @@ impl Game<'_> {
None
};
let options = horizon_client::save::WorldGen {
flat,
preset,
structures: props.generate_structures,
bonus_chest: false,
};
@@ -675,9 +675,7 @@ impl Game<'_> {
let new_mode = self.mode_id();
self.mode = mode;
self.creative = creative;
self.selected = std::mem::replace(&mut g.selected, selected);
// `g.selected` now holds the host's value: swap the inventories back.
std::mem::swap(&mut self.selected, &mut g.selected);
g.selected = std::mem::replace(&mut self.selected, selected);
std::mem::swap(&mut self.inventory, &mut g.inventory);
std::mem::swap(&mut self.armor, &mut g.armor);
std::mem::swap(&mut self.offhand, &mut g.offhand);
@@ -232,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);
@@ -365,6 +365,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);
@@ -426,6 +435,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
@@ -304,6 +304,26 @@ pub(super) fn preload_server_loot(resources: &std::path::Path) {
}
}
/// The worldgen entries of the world's enabled data packs, logging what
/// the generator leaves out of them.
fn world_worldgen_overrides(
world_root: &std::path::Path,
biomes: &horizon_client::biome::Biomes,
) -> horizon_world::worldgen::overrides::WorldgenOverrides {
let packs = horizon_client::datapack::discover(world_root);
let enabled = horizon_client::datapack::enabled_ids(world_root, &packs);
let enabled: Vec<_> = enabled
.iter()
.filter_map(|id| packs.iter().find(|p| &p.id == id))
.collect();
let (overrides, warnings) =
horizon_client::datapack::worldgen_overrides(&enabled, |b| biomes.contains(b));
for warning in warnings {
log::warn!("Horizon: data pack worldgen: {warning}");
}
overrides
}
pub(super) fn make_streaming(
res: &Resources,
pack: &PackResources,
@@ -356,8 +376,13 @@ pub(super) fn make_streaming(
let structures =
horizon_client::save::WorldGen::read(world_root).is_none_or(|w| w.structures);
let preset = horizon_client::save::WorldGen::preset(world_root);
let inner = Arc::new(VanillaTerrain::for_world(&options.resources.join("worldgen"), pack.assets.registry, res.biomes(), seed, preset, dim.id(), structures).map_err(|e| {
format!("{e}\nPrepare the worldgen bundle with the launcher, or: horizon-prepare --prepare")
let overrides = world_worldgen_overrides(world_root, res.biomes());
let inner = Arc::new(VanillaTerrain::for_world_with(&options.resources.join("worldgen"), &overrides, pack.assets.registry, res.biomes(), seed, preset, dim.id(), structures).map_err(|e| {
if overrides.is_empty() {
format!("{e}\nPrepare the worldgen bundle with the launcher, or: horizon-prepare --prepare")
} else {
format!("{e}\nThe world's data packs change its world generation: one of them may not be made for this version")
}
})?);
if dim == Dim::Overworld && structures {
inner.search_rings_behind();
+13 -5
View File
@@ -618,7 +618,7 @@ impl Screen {
w / 2.0 - 100.0,
h / 4.0 + 96.0 + 18.0,
200.0,
"Join",
&crate::lang::get_or("horizon.multiplayer.join", "Join"),
horizon_friends::invite::parse(code).is_some(),
56,
),
@@ -645,7 +645,10 @@ impl Screen {
w / 2.0 - 100.0,
h / 2.0 + 54.0,
200.0,
"Cancel Reconnecting",
&crate::lang::get_or(
"horizon.multiplayer.cancel_reconnecting",
"Cancel Reconnecting",
),
true,
55,
));
@@ -1012,13 +1015,18 @@ impl Screen {
}
}
Self::Friend { code, relay, focus } => {
centered(batch, "Join a Friend", 20.0, u32::MAX);
centered(
batch,
&crate::lang::get_or("horizon.multiplayer.join_friend", "Join a Friend"),
20.0,
u32::MAX,
);
Self::field(
atlas,
batch,
caret,
focused_at,
"Invite Code",
&crate::lang::get_or("horizon.multiplayer.invite_code", "Invite Code"),
code,
FRIEND_CODE,
max,
@@ -1030,7 +1038,7 @@ impl Screen {
batch,
caret,
focused_at,
"Relay (optional)",
&crate::lang::get_or("horizon.multiplayer.relay", "Relay (optional)"),
relay,
FRIEND_RELAY,
max,
+218 -19
View File
@@ -1905,10 +1905,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);
@@ -1923,6 +1946,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
@@ -1969,6 +1994,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)
}
@@ -2503,14 +2562,83 @@ const INT_RULES: [&str; 11] = [
"respawn_radius",
];
/// The world presets of 26.3's `worldgen/world_preset` that the create
/// screen offers, in the order of `WorldPresetTags.NORMAL`.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum WorldPreset {
Normal,
Flat,
LargeBiomes,
Amplified,
SingleBiomeSurface,
}
impl WorldPreset {
/// The presets, in the order the world-type button cycles through them.
pub const ALL: [Self; 5] = [
Self::Normal,
Self::Flat,
Self::LargeBiomes,
Self::Amplified,
Self::SingleBiomeSurface,
];
/// The preset's registry id (`minecraft:normal`).
pub const fn id(self) -> &'static str {
match self {
Self::Normal => "minecraft:normal",
Self::Flat => "minecraft:flat",
Self::LargeBiomes => "minecraft:large_biomes",
Self::Amplified => "minecraft:amplified",
Self::SingleBiomeSurface => "minecraft:single_biome_surface",
}
}
/// The preset an id names, if it is one of them.
pub fn of_id(id: &str) -> Option<Self> {
Self::ALL.into_iter().find(|preset| preset.id() == id)
}
/// The language key the world-type button shows
/// (`Identifier.toLanguageKey("generator")`).
pub const fn label(self) -> &'static str {
match self {
Self::Normal => "generator.minecraft.normal",
Self::Flat => "generator.minecraft.flat",
Self::LargeBiomes => "generator.minecraft.large_biomes",
Self::Amplified => "generator.minecraft.amplified",
Self::SingleBiomeSurface => "generator.minecraft.single_biome_surface",
}
}
/// The preset the world-type button cycles to next.
pub fn next(self) -> Self {
let index = Self::ALL
.iter()
.position(|preset| *preset == self)
.unwrap_or(0);
Self::ALL[(index + 1) % Self::ALL.len()]
}
/// The preset a saved overworld generator is: the noise settings name
/// the preset, and the single-biome preset shares the overworld's noise
/// settings, so its fixed biome source tells the two apart.
pub fn of_generator(generator: &Tag) -> Self {
if text(generator.get("type")).as_deref() == Some("minecraft:flat") {
return Self::Flat;
}
let source = generator.get("biome_source");
let fixed = text(source.and_then(|s| s.get("type"))).as_deref() == Some("minecraft:fixed");
match text(generator.get("settings")).as_deref() {
Some("minecraft:large_biomes") => Self::LargeBiomes,
Some("minecraft:amplified") => Self::Amplified,
Some("minecraft:overworld") if fixed => Self::SingleBiomeSurface,
_ => Self::Normal,
}
}
}
/// World and player state stored in `level.dat`, the saved-data files and
/// the player file.
/// The world options CreateWorldScreen sets (`WorldOptions` and the world
/// preset) that `world_gen_settings` keeps.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct WorldGen {
/// The `minecraft:flat` preset (Superflat) instead of `normal`.
pub flat: bool,
/// The world preset the world is created with.
pub preset: WorldPreset,
pub structures: bool,
pub bonus_chest: bool,
}
@@ -2524,8 +2652,7 @@ impl WorldGen {
.and_then(|d| d.get("minecraft:overworld"))
.and_then(|o| o.get("generator"));
Some(Self {
flat: text(generator.and_then(|g| g.get("type")))
.is_some_and(|t| t.trim_start_matches("minecraft:") == "flat"),
preset: generator.map_or(WorldPreset::Normal, WorldPreset::of_generator),
structures: flag("generate_structures", true),
bonus_chest: flag("bonus_chest", false),
})
@@ -2558,7 +2685,7 @@ impl WorldGen {
impl Default for WorldGen {
fn default() -> Self {
Self {
flat: false,
preset: WorldPreset::Normal,
structures: true,
bonus_chest: false,
}
@@ -2633,6 +2760,34 @@ pub fn write_world_gen_settings_with(
("preset", string(p)),
])
};
// The overworld generator of worldgen/world_preset/<preset>.json: the
// noise settings and the biome source each preset uses.
let overworld = match options.preset {
WorldPreset::Flat => flat,
WorldPreset::LargeBiomes => dimension(
"minecraft:overworld",
"minecraft:large_biomes",
preset("minecraft:overworld"),
),
WorldPreset::Amplified => dimension(
"minecraft:overworld",
"minecraft:amplified",
preset("minecraft:overworld"),
),
WorldPreset::SingleBiomeSurface => dimension(
"minecraft:overworld",
"minecraft:overworld",
Tag::compound([
("type", string("minecraft:fixed")),
("biome", string("minecraft:plains")),
]),
),
WorldPreset::Normal => dimension(
"minecraft:overworld",
"minecraft:overworld",
preset("minecraft:overworld"),
),
};
write_saved_data(
dir,
"world_gen_settings",
@@ -2646,18 +2801,7 @@ pub fn write_world_gen_settings_with(
(
"dimensions",
Tag::compound([
(
"minecraft:overworld",
if options.flat {
flat
} else {
dimension(
"minecraft:overworld",
"minecraft:overworld",
preset("minecraft:overworld"),
)
},
),
("minecraft:overworld", overworld),
(
"minecraft:the_nether",
dimension(
@@ -3454,11 +3598,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> {
@@ -3466,7 +3617,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,
@@ -3487,8 +3638,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 {
@@ -3511,7 +3667,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,
@@ -3770,6 +3937,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,
@@ -3786,6 +3980,9 @@ 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()?;
region
@@ -3816,6 +4013,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> {
@@ -4355,6 +4553,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]);
+35 -2
View File
@@ -148,6 +148,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,
@@ -356,6 +361,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 {
@@ -408,7 +419,7 @@ impl Title {
} = mode
{
if apply {
options.flat = true;
options.world_type = crate::save::WorldPreset::Flat;
options.flat_settings = Some(flat.settings);
}
self.mode = Mode::Create {
@@ -420,6 +431,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)
@@ -1655,11 +1673,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
+19 -20
View File
@@ -30,14 +30,19 @@ pub struct WorldOptions {
pub difficulty: usize,
/// Allow Cheats as toggled; unset follows the game mode.
pub commands: Option<bool>,
/// The Superflat world preset instead of Default.
pub flat: bool,
/// The world preset the world is created with.
pub world_type: crate::save::WorldPreset,
/// CreateFlatWorldScreen's layers and biome, kept with the world type.
pub flat_settings: Option<super::flat::FlatSettings>,
pub structures: bool,
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 {
@@ -46,11 +51,13 @@ impl Default for WorldOptions {
hardcore: false,
difficulty: 2,
commands: None,
flat: false,
world_type: crate::save::WorldPreset::Normal,
flat_settings: None,
structures: true,
bonus_chest: false,
rules: crate::gamerules::GameRules::default(),
data_packs: vec!["vanilla".into()],
pack_dir: None,
}
}
}
@@ -69,9 +76,8 @@ impl WorldOptions {
.position(|d| *d == level.difficulty)
.unwrap_or(2),
commands: Some(commands),
flat: generation.flat,
flat_settings: generation
.flat
world_type: generation.preset,
flat_settings: (generation.preset == crate::save::WorldPreset::Flat)
.then(|| super::flat::FlatSettings::from_world(dir))
.flatten(),
structures: generation.structures,
@@ -94,7 +100,7 @@ impl WorldOptions {
}
pub fn world_gen(&self) -> crate::save::WorldGen {
crate::save::WorldGen {
flat: self.flat,
preset: self.world_type,
structures: self.structures,
bonus_chest: self.bonus_chest(),
}
@@ -119,8 +125,8 @@ impl WorldOptions {
23 => self.difficulty = (self.difficulty + 1) % DIFFICULTIES.len(),
24 => self.commands = Some(!self.commands(*creative)),
25 => {
self.flat = !self.flat;
if self.flat {
self.world_type = self.world_type.next();
if self.world_type == crate::save::WorldPreset::Flat {
// setWorldType starts the generator from the flat preset.
self.flat_settings = Some(super::flat::FlatSettings::default());
}
@@ -255,14 +261,7 @@ pub(super) fn buttons(w: f32, h: f32, creative: bool, o: &WorldOptions) -> Vec<B
gx,
gy,
150.0,
labelled(
"selectWorld.mapType",
tr(if o.flat {
"generator.minecraft.flat"
} else {
"generator.minecraft.normal"
}),
),
labelled("selectWorld.mapType", tr(o.world_type.label())),
true,
25,
),
@@ -272,7 +271,7 @@ pub(super) fn buttons(w: f32, h: f32, creative: bool, o: &WorldOptions) -> Vec<B
gy,
150.0,
tr("selectWorld.customizeType"),
o.flat,
o.world_type == crate::save::WorldPreset::Flat,
26,
),
// SwitchGrid: 44-wide value-only buttons at the grid's right.
@@ -286,7 +285,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 +299,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,
)
})
+115 -16
View File
@@ -825,37 +825,76 @@ fn create_world_tabs_follow_vanilla_and_their_choices_reach_the_action() {
};
assert!(*creative && !options.hardcore && options.commands(true));
// The World tab (second of three 124-wide tabs from x 54): its seed box
// takes the focus, Superflat and the switches toggle.
// takes the focus, the world type cycles and the switches toggle.
let (action, sound) = f.title.click(54.0 + 124.0 + 10.0, 10.0, size);
assert!(sound && action == Action::None);
let world = labels(&f);
assert_eq!((world[0].1, world[0].2), (85.0, 38.0));
assert_eq!((world[2].1, world[2].2), (85.0 + 266.0, 38.0 + 73.0));
f.title.click(world[0].1 + 10.0, world[0].2 + 10.0, size);
// The world-type button cycles through vanilla's world presets.
let world_type = |f: &Fixture| -> crate::save::WorldPreset {
let Mode::Create { options, .. } = &f.title.mode else {
panic!()
};
options.world_type
};
assert_eq!(world_type(&f), crate::save::WorldPreset::Normal);
for expected in [
crate::save::WorldPreset::Flat,
crate::save::WorldPreset::LargeBiomes,
crate::save::WorldPreset::Amplified,
crate::save::WorldPreset::SingleBiomeSurface,
crate::save::WorldPreset::Normal,
] {
f.title.click(world[0].1 + 10.0, world[0].2 + 10.0, size);
assert_eq!(world_type(&f), expected);
}
// The amplified preset is the third one.
for _ in 0..3 {
f.title.click(world[0].1 + 10.0, world[0].2 + 10.0, size);
}
f.title.click(world[3].1 + 10.0, world[3].2 + 10.0, size);
f.title.click(100.0, 38.0 + 41.0 + 10.0, size);
let Mode::Create { focus, options, .. } = &f.title.mode else {
panic!()
};
assert_eq!(*focus, 1);
assert!(options.flat && options.bonus_chest());
assert_eq!(options.world_type, crate::save::WorldPreset::Amplified);
assert!(options.bonus_chest());
let Action::Create { options, .. } = f.title.create_action() else {
panic!()
};
assert_eq!(
options.world_gen(),
crate::save::WorldGen {
flat: true,
preset: crate::save::WorldPreset::Amplified,
structures: true,
bonus_chest: true
}
);
// 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]
@@ -863,26 +902,86 @@ fn new_world_generation_settings_round_trip() {
let f = Fixture::new();
let dir = f.root.join("flat");
let options = crate::save::WorldGen {
flat: true,
preset: crate::save::WorldPreset::Flat,
structures: false,
bonus_chest: true,
};
crate::save::write_world_gen_settings(&dir, 42, options).unwrap();
assert_eq!(crate::save::WorldGen::read(&dir), Some(options));
let settings = crate::save::world_gen_settings(&dir).unwrap();
let generator = settings
.get("dimensions")
.and_then(|d| d.get("minecraft:overworld"))
.and_then(|o| o.get("generator"));
assert_eq!(
crate::save::text(
settings
.get("dimensions")
.and_then(|d| d.get("minecraft:overworld"))
.and_then(|o| o.get("generator"))
.and_then(|g| g.get("type"))
)
.as_deref(),
crate::save::text(generator.and_then(|g| g.get("type"))).as_deref(),
Some("minecraft:flat")
);
}
#[test]
fn each_world_preset_writes_its_own_generator() {
let f = Fixture::new();
let generator = |preset: crate::save::WorldPreset| {
let dir = f.root.join(preset.id());
let options = crate::save::WorldGen {
preset,
structures: true,
bonus_chest: false,
};
crate::save::write_world_gen_settings(&dir, 42, options).unwrap();
assert_eq!(crate::save::WorldGen::read(&dir), Some(options));
let settings = crate::save::world_gen_settings(&dir).unwrap();
let generator = settings
.get("dimensions")
.and_then(|d| d.get("minecraft:overworld"))
.and_then(|o| o.get("generator"))
.cloned()
.unwrap();
(
crate::save::text(generator.get("type")),
crate::save::text(generator.get("settings")),
crate::save::text(generator.get("biome_source").and_then(|s| s.get("type"))),
)
};
assert_eq!(
generator(crate::save::WorldPreset::Normal),
(
Some("minecraft:noise".to_owned()),
Some("minecraft:overworld".to_owned()),
Some("minecraft:multi_noise".to_owned()),
)
);
assert_eq!(
generator(crate::save::WorldPreset::Amplified),
(
Some("minecraft:noise".to_owned()),
Some("minecraft:amplified".to_owned()),
Some("minecraft:multi_noise".to_owned()),
)
);
assert_eq!(
generator(crate::save::WorldPreset::LargeBiomes),
(
Some("minecraft:noise".to_owned()),
Some("minecraft:large_biomes".to_owned()),
Some("minecraft:multi_noise".to_owned()),
)
);
assert_eq!(
generator(crate::save::WorldPreset::SingleBiomeSurface),
(
Some("minecraft:noise".to_owned()),
Some("minecraft:overworld".to_owned()),
Some("minecraft:fixed".to_owned()),
)
);
assert_eq!(
generator(crate::save::WorldPreset::Flat).0,
Some("minecraft:flat".to_owned())
);
}
#[test]
fn world_fields_edit_at_their_cursor() {
use winit::keyboard::KeyCode;
+59 -6
View File
@@ -19,6 +19,7 @@ use horizon_world::{
generator::{
GeneratorData, GeneratorWorker, OverworldGenerator, ProtoChunk, SurfaceSample,
},
overrides::WorldgenOverrides,
terrain::PreLightChunkStage,
},
};
@@ -154,13 +155,40 @@ impl VanillaTerrain {
dimension: &str,
structures: bool,
) -> crate::Result<Self> {
let data: Arc<GeneratorData> = shared_data(dir, registry)?;
let none = WorldgenOverrides::default();
Self::for_world_with(
dir, &none, registry, biomes, seed, preset, dimension, structures,
)
}
/// `for_world` with the world's data packs over the bundle at `dir`
/// (`datapack::worldgen_overrides`).
pub fn for_world_with(
dir: &Path,
overrides: &WorldgenOverrides,
registry: &'static BlockRegistry,
biomes: &Biomes,
seed: i64,
preset: &str,
dimension: &str,
structures: bool,
) -> crate::Result<Self> {
let data: Arc<GeneratorData> = shared_data_with(dir, overrides, registry)?;
let biome_map = data
.biome_names()
.iter()
.map(|name| biomes.id(name))
.collect();
let generator = OverworldGenerator::for_world(data, seed, preset, dimension, structures)?;
if let Some((asked, kept)) = generator.noise_clamped() {
log::warn!(
"Horizon: {dimension}'s noise settings reach y {}..{}, past its build height; \
only y {}..{} generates",
asked.min_y,
asked.min_y + asked.height,
kept.min_y,
kept.min_y + kept.height
);
}
let unsupported = generator.unsupported_features();
if !unsupported.is_empty() {
log::warn!(
@@ -407,8 +435,12 @@ impl VanillaTerrain {
u64::try_from(self.created.elapsed().as_millis()).unwrap_or(u64::MAX)
}
}
static SHARED_DATA: std::sync::Mutex<Vec<(std::path::PathBuf, Arc<GeneratorData>)>> =
std::sync::Mutex::new(Vec::new());
/// A bundle directory, the data packs over it, and what loading them gave.
type SharedEntry = (std::path::PathBuf, WorldgenOverrides, Arc<GeneratorData>);
static SHARED_DATA: std::sync::Mutex<Vec<SharedEntry>> = std::sync::Mutex::new(Vec::new());
/// Bundles with data packs kept besides the plain ones: a world's dimensions
/// share one, and returning to a world finds it.
const SHARED_WITH_PACKS: usize = 2;
/// Let go of the bundles `shared_data` loaded: for a preload no world used
/// (game/world_loading.rs). A generator that holds one keeps it.
pub fn forget_shared_data() {
@@ -422,15 +454,36 @@ pub fn forget_shared_data() {
pub fn shared_data(
dir: &Path,
registry: &'static BlockRegistry,
) -> crate::Result<Arc<GeneratorData>> {
shared_data_with(dir, &WorldgenOverrides::default(), registry)
}
/// `shared_data` with a world's data packs over the bundle: loaded once for
/// each set of packs' entries, the most recent few kept.
pub fn shared_data_with(
dir: &Path,
overrides: &WorldgenOverrides,
registry: &'static BlockRegistry,
) -> crate::Result<Arc<GeneratorData>> {
let mut cache = SHARED_DATA.lock().unwrap_or_else(|e| e.into_inner());
if let Some((_, data)) = cache.iter().find(|(path, _)| path == dir) {
if let Some((_, _, data)) = cache
.iter()
.find(|(path, o, _)| path == dir && o == overrides)
{
return Ok(data.clone());
}
let started = std::time::Instant::now();
let data = GeneratorData::load(dir, registry)?;
let data = GeneratorData::load_with(dir, registry, overrides)?;
log::info!("Horizon: worldgen data loaded in {:?}", started.elapsed());
cache.push((dir.to_owned(), data.clone()));
for warning in data.warnings() {
log::warn!("Horizon: data pack worldgen: {warning}");
}
if !overrides.is_empty()
&& cache.iter().filter(|e| !e.1.is_empty()).count() >= SHARED_WITH_PACKS
{
let oldest = cache.iter().position(|e| !e.1.is_empty());
cache.remove(oldest.expect("counted"));
}
cache.push((dir.to_owned(), overrides.clone(), data.clone()));
Ok(data)
}
impl ChunkGenerator for VanillaTerrain {
+18 -10
View File
@@ -305,6 +305,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 {
@@ -325,6 +327,7 @@ impl VanillaLodSampler {
cache: FxHashMap::default(),
order: VecDeque::new(),
details: Vec::new(),
approximate: false,
}
}
}
@@ -339,8 +342,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 {
@@ -399,16 +406,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()
+74
View File
@@ -106,6 +106,50 @@ impl BiomeZoom {
}
selected
}
/// The jitter `quart_at_block` adds to each of the eight corners of the
/// quart cell `base` (the block position minus 2, shifted right by 2), as
/// `[x, y, z]` per corner in its corner order (x, then y, then z offset).
/// With `select_corner` this gives `quart_at_block`'s choice for every
/// block of the cell from one mix.
pub fn corner_jitter(self, base: [i32; 3]) -> [[f64; 3]; 8] {
let coordinate = base.map(i64::from);
let seed = self.seed;
const A: i64 = 6364136223846793005;
const B: i64 = 1442695040888963407;
let g = |m: i64| m.wrapping_mul(m.wrapping_mul(A).wrapping_add(B));
let root = g(seed);
std::array::from_fn(|corner| {
let [x, y, z] = [(corner >> 2) & 1, (corner >> 1) & 1, corner & 1].map(|v| v as i64);
let m0 = root.wrapping_add(coordinate[0].wrapping_add(x));
let m1 = g(m0).wrapping_add(coordinate[1].wrapping_add(y));
let m2 = g(m1).wrapping_add(coordinate[2].wrapping_add(z));
let m3 = g(m2).wrapping_add(coordinate[0].wrapping_add(x));
let m4 = g(m3).wrapping_add(coordinate[1].wrapping_add(y));
let m5 = g(m4).wrapping_add(coordinate[2].wrapping_add(z));
let m6 = g(m5).wrapping_add(seed);
let m7 = g(m6).wrapping_add(seed);
[fiddle(m5), fiddle(m6), fiddle(m7)]
})
}
/// The corner (index into `corner_jitter`) `quart_at_block` picks for
/// `block` inside the cell whose jitter is `jitter`.
#[inline]
pub fn select_corner(block: BlockPos, jitter: &[[f64; 3]; 8]) -> usize {
let fractions = [block.x, block.y, block.z].map(|v| f64::from(v.wrapping_sub(2) & 3) / 4.);
let mut selected = 0;
let mut best = f64::INFINITY;
for (corner, jitter) in jitter.iter().enumerate() {
let dx = fractions[0] - ((corner >> 2) & 1) as f64 + jitter[0];
let dy = fractions[1] - ((corner >> 1) & 1) as f64 + jitter[1];
let dz = fractions[2] - (corner & 1) as f64 + jitter[2];
let distance = dz * dz + dy * dy + dx * dx;
if best > distance {
best = distance;
selected = corner;
}
}
selected
}
}
/// Direct noise-biome lookup bypasses the eight-corner zoom.
@@ -224,4 +268,34 @@ mod tests {
}
}
}
#[test]
fn corner_jitter_matches_quart_at_block() {
for seed in [0i64, -1, i64::MIN, 0x0123_4567_89ab_cdef] {
let zoom = BiomeZoom::new(seed);
let check = |block: BlockPos| {
let base = [block.x, block.y, block.z].map(|v| v.wrapping_sub(2) >> 2);
let corner = BiomeZoom::select_corner(block, &zoom.corner_jitter(base));
let offsets = [(corner >> 2) & 1, (corner >> 1) & 1, corner & 1];
let quart: [i32; 3] =
std::array::from_fn(|i| base[i].wrapping_add(offsets[i] as i32));
assert_eq!(
quart,
zoom.quart_at_block(block),
"seed {seed} at {block:?}"
);
};
for x in -20..20 {
for y in -20..20 {
for z in -20..20 {
check(BlockPos::new(x, y, z));
}
}
}
for v in [i32::MIN, i32::MIN + 1, -1, 0, i32::MAX - 1, i32::MAX] {
check(BlockPos::new(v, v, v));
check(BlockPos::new(v, v.wrapping_neg(), 3));
}
}
}
}
+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);
}
@@ -0,0 +1,99 @@
//! Far terrain sampling cost (`GeneratorWorker::lod_grid`): milliseconds per
//! far tile at each tile level, as the client's far terrain requests them
//! (16 x 16 columns), with checksums of what they hold.
//!
//! lod_grid_bench <client resource dir> [--seed S] [--tiles N] [--levels 0,2,4,6] [--dump PATH]
//! [--approximate]
//!
//! `--approximate` samples as Reduced far detail does.
use horizon_world::worldgen::generator::{GeneratorData, OverworldGenerator};
use std::{path::PathBuf, time::Instant};
type Error = Box<dyn std::error::Error + Send + Sync>;
fn main() -> Result<(), Error> {
let mut args = std::env::args().skip(1);
let resources = PathBuf::from(args.next().ok_or("usage: lod_grid_bench <resources>")?);
let (mut seed, mut tiles) = (0i64, 25);
let mut levels = vec![0u8, 2, 4, 6];
let mut dump: Option<std::fs::File> = None;
let mut approximate = false;
while let Some(arg) = args.next() {
match arg.as_str() {
"--seed" => seed = args.next().ok_or("--seed S")?.parse()?,
"--approximate" => approximate = true,
"--tiles" => tiles = args.next().ok_or("--tiles N")?.parse()?,
"--dump" => dump = Some(std::fs::File::create(args.next().ok_or("--dump PATH")?)?),
"--levels" => {
levels = args
.next()
.ok_or("--levels 0,2,4")?
.split(',')
.map(str::parse)
.collect::<Result<_, _>>()?
}
_ => return Err(format!("unknown argument {arg}").into()),
}
}
let registry = Box::leak(Box::new(horizon_world::level::data::load_registry(
&resources,
)?));
let start = Instant::now();
let data = GeneratorData::load(&resources.join("worldgen"), registry)?;
let generator =
OverworldGenerator::for_dimension(data, seed, "minecraft:normal", "minecraft:overworld")?;
let mut worker = generator.worker();
println!(
"seed {seed}, generator ready in {:.1}s",
start.elapsed().as_secs_f64()
);
// Warm the worker's caches once.
worker.lod_grid(0, 0, 16, 16, approximate)?;
for level in levels {
let size = 16i32 << level;
let width = size / 16;
let start = Instant::now();
let mut spans = 0;
// Everything the tiles hold: an exact speedup keeps it.
let mut hash = std::hash::DefaultHasher::new();
// The terrain's shape alone (surface and water heights): an
// approximation of materials or biomes keeps this one.
let mut heights = std::hash::DefaultHasher::new();
for i in 0..tiles {
// Tiles around the origin, as a view would request them.
let (tx, tz) = (i % 5 - 2, i / 5 - 2);
let grid = worker.lod_grid(
tx * size + width / 2,
tz * size + width / 2,
width,
16,
approximate,
)?;
spans += grid.iter().map(|(_, s)| s.len()).sum::<usize>();
std::hash::Hash::hash(&format!("{grid:?}"), &mut hash);
for (surface, _) in &grid {
let shape = surface.as_ref().map(|s| (s.height, s.water.map(|w| w.0)));
std::hash::Hash::hash(&shape, &mut heights);
if let Some(file) = &mut dump {
use std::io::Write;
let s = surface.as_ref();
writeln!(
file,
"{level} {:?} {:?} {:?}",
s.map(|s| s.height),
s.map(|s| s.state.0),
s.map(|s| s.biome.0)
)?;
}
}
}
let ms = start.elapsed().as_secs_f64() * 1000. / tiles as f64;
println!(
"level {level} ({size} blocks, stride {width}): {ms:.1} ms/tile, {:.1} spans/column, output {:016x}, heights {:016x}",
spans as f64 / (tiles * 256) as f64,
std::hash::Hasher::finish(&hash),
std::hash::Hasher::finish(&heights)
);
}
Ok(())
}
@@ -2,6 +2,8 @@
//!
//! chunk_dump <client resource dir> <seed> <dimension> <x0,z0,x1,z1>...
//! With HORIZON_SCAN set, one rectangle lists the structure starts in it.
//! HORIZON_DATAPACKS names a world's datapacks folder of folder packs, each
//! enabled after vanilla in name order (tools/test_datapack_worldgen_parity.py).
//! HORIZON_SAVED_STARTS names a JSON file of saved starts (`[{"id", "chunk":
//! [x, z], "pieces": [...]}]`, jigsaw pieces as a save lists them) that take
//! the place of the starts generation builds in their chunks.
@@ -20,7 +22,10 @@ use horizon_world::{
DATA_VERSION,
nbt::Tag,
registry::BlockRegistry,
worldgen::generator::{GeneratorData, OverworldGenerator, ProtoChunk},
worldgen::{
generator::{GeneratorData, OverworldGenerator, ProtoChunk},
overrides::{PackFile, WorldgenOverrides},
},
};
use serde_json::Value;
use std::{collections::HashMap, fmt::Write as _, fs, io::Write as _, path::Path};
@@ -99,6 +104,50 @@ fn canonical(tag: &Tag, out: &mut String) {
}
}
/// HORIZON_DATAPACKS: a world's datapacks folder of folder packs, each enabled
/// after vanilla in name order, as the oracle selects them.
fn datapacks(dir: &Path) -> Result<WorldgenOverrides, Error> {
fn walk(root: &Path, dir: &Path, out: &mut Vec<String>) {
let Ok(entries) = fs::read_dir(dir) else {
return;
};
let mut entries: Vec<_> = entries.flatten().map(|e| e.path()).collect();
entries.sort();
for path in entries {
if path.is_dir() {
walk(root, &path, out);
} else if let Ok(rel) = path.strip_prefix(root) {
out.push(rel.to_string_lossy().replace('\\', "/"));
}
}
}
let mut packs: Vec<_> = fs::read_dir(dir)?
.flatten()
.map(|e| e.path())
.filter(|p| p.join("pack.mcmeta").exists())
.collect();
packs.sort();
let mut overrides = WorldgenOverrides::default();
for pack in packs {
let id = format!(
"file/{}",
pack.file_name().unwrap_or_default().to_string_lossy()
);
let mut files = Vec::new();
walk(&pack, &pack.join("data"), &mut files);
for file in files {
let read = || fs::read_to_string(pack.join(&file)).ok();
if let PackFile::Unreadable = overrides.add_file(&id, &file, read) {
return Err(format!("{id}: {file}: unreadable").into());
}
}
}
for warning in overrides.finish(|_| true) {
eprintln!("data pack worldgen: {warning}");
}
Ok(overrides)
}
pub fn main() -> Result<(), Error> {
let mut args = crate::args().skip(1);
let resources = std::path::PathBuf::from(args.next().ok_or("client resource directory")?);
@@ -126,7 +175,14 @@ pub fn main() -> Result<(), Error> {
}
return Ok(());
}
let data = GeneratorData::load(&resources.join("worldgen"), blocks)?;
let overrides = match std::env::var_os("HORIZON_DATAPACKS") {
Some(dir) => datapacks(Path::new(&dir))?,
None => WorldgenOverrides::default(),
};
let data = GeneratorData::load_with(&resources.join("worldgen"), blocks, &overrides)?;
for warning in data.warnings() {
eprintln!("data pack worldgen: {warning}");
}
let generator = OverworldGenerator::for_dimension(data, seed, "minecraft:normal", &dimension)?;
let mut worker = generator.worker();
// HORIZON_PHASE: terrain (no decoration), structures (pieces only) or full.
+13 -2
View File
@@ -364,6 +364,10 @@ fn local_ids(state: BlockStateId) -> LocalIds {
ids.insert(state, 0);
ids
}
#[cold]
fn out_of_bounds() -> io::Error {
invalid("section index out of bounds")
}
pub struct BlockSection<'r> {
palette: Vec<BlockStateId>,
local_ids: LocalIds,
@@ -421,12 +425,19 @@ impl<'r> BlockSection<'r> {
pub fn bits(&self) -> u8 {
self.storage.as_ref().map_or(0, PackedStorage::bits)
}
#[inline]
pub fn get(&self, index: usize) -> io::Result<BlockStateId> {
if index >= 4096 {
return Err(invalid("section index out of bounds"));
return Err(out_of_bounds());
}
Ok(self.state(index))
}
/// The state at `index`, which must be below 4096 (a `local_index`).
#[inline]
pub(crate) fn state(&self, index: usize) -> BlockStateId {
debug_assert!(index < 4096);
let id = self.storage.as_ref().map_or(0, |s| s.read(index));
Ok(self.palette[id as usize])
self.palette[id as usize]
}
pub fn set(&mut self, index: usize, state: BlockStateId) -> io::Result<BlockStateId> {
if index >= 4096 {
+15
View File
@@ -137,6 +137,21 @@ impl Heightmap {
Ok((self.data.get(Self::index(x, z))? as i32).wrapping_add(self.min_y))
}
/// Whether any column of the local square `x0..=x1` by `z0..=z1` (each
/// within 0..16) has its first available y at or above `y`.
pub fn any_first_available_at_least(
&self,
[x0, x1]: [i32; 2],
[z0, z1]: [i32; 2],
y: i32,
) -> bool {
debug_assert!(0 <= x0 && x1 < 16 && 0 <= z0 && z1 < 16);
(z0..=z1).any(|z| {
(x0..=x1)
.any(|x| (self.data.read(Self::index(x, z)) as i32).wrapping_add(self.min_y) >= y)
})
}
pub fn highest_taken(&self, x: i32, z: i32) -> Result<i32, HeightmapError> {
Ok(self.first_available(x, z)?.wrapping_sub(1))
}
+1
View File
@@ -51,6 +51,7 @@ pub mod material_extensions;
pub mod material_stage;
pub mod material_surface;
pub mod ore_feature;
pub mod overrides;
pub mod placement;
pub mod projected;
pub mod replace_blobs;
@@ -57,6 +57,11 @@ impl<T: Clone> BiomeChunk<T> {
let section = ((y - bottom) >> 2) as usize;
&self.sections[section][index(quart[0], y, quart[2])]
}
/// The quart Ys `noise_biome` clamps to: the lowest and the highest.
pub fn quart_range(&self) -> (i32, i32) {
let bottom = self.min_y >> 2;
(bottom, bottom + (self.height >> 2) - 1)
}
/// Fill the sections intersecting inclusive generation bounds. Sections are
/// visited bottom to top; each resolver sees X/Y/Z loop order. A failed
/// section retains its previous values while earlier sections stay committed.
@@ -183,6 +188,9 @@ impl<T: Clone> BiomeRegion<T> {
pub fn quart_at_block(&self, block: BlockPos) -> [i32; 3] {
self.zoom.quart_at_block(block)
}
pub fn zoom(&self) -> BiomeZoom {
self.zoom
}
/// Distinct values currently stored anywhere in the chunk. The result is
/// sorted by biome identity; callers must not use this order as registry order.
pub fn present_in_chunk(&self, p: ChunkPos) -> Result<Vec<T>, BiomeRegionError>
@@ -8,7 +8,7 @@ use super::{
int_range, number_text, optional,
placement::{PlacementContext, PlacementQueryError},
required,
tree::{TreeDecoder, TreeResult, kind, read, unit_float, write},
tree::{TreeDecoder, TreeResult, kind, read, scan_column, unit_float, write},
tree_blocks::{self as tb, TreeBlocks},
value_provider::{FloatProvider, IntProvider},
};
@@ -85,14 +85,11 @@ impl Speleothem {
if !self.empty_or_water(read(world, pos)?) {
return Ok(None);
}
// Column.scan: up to range - 1 empty blocks from pos, then the edge.
let mut scan = |step: i32| -> TreeResult<Option<i32>, W::Error> {
let mut p = pos;
let mut i = 1;
while i < range && self.empty_or_water(read(world, p)?) {
p[1] = p[1].wrapping_add(step);
i += 1;
}
Ok(edge(self, read(world, p)?).then_some(p[1]))
let y = scan_column(world, pos, step, range - 1, |s| self.empty_or_water(s))?;
let p = [pos[0], y, pos[2]];
Ok(edge(self, read(world, p)?).then_some(y))
};
let ceiling = scan(1)?;
let floor = scan(-1)?;
@@ -30,6 +30,30 @@ impl FeatureBlocks {
pub trait FeatureWorld {
type Error;
fn block(&mut self, position: [i32; 3]) -> Result<BlockStateId, Self::Error>;
/// Walk the column from `position` by `step` (1 or -1) while `keep`
/// holds for the block there, reading at most `limit` blocks. Returns the
/// y it stopped at: the first refused block, or the one after the last
/// read. `keep` must depend only on the state (it may be skipped for a
/// run of blocks known to be identical).
fn scan_column(
&mut self,
position: [i32; 3],
step: i32,
limit: i32,
mut keep: impl FnMut(BlockStateId) -> bool,
) -> Result<i32, Self::Error>
where
Self: Sized,
{
let mut p = position;
for _ in 0..limit {
if !keep(self.block(p)?) {
break;
}
p[1] = p[1].wrapping_add(step);
}
Ok(p[1])
}
fn set_block(
&mut self,
position: [i32; 3],
@@ -118,6 +118,7 @@ impl ChunkIndex {
cells,
})
}
#[inline]
fn get(&self, x: i32, z: i32) -> Option<usize> {
match self {
Self::Grid {
@@ -246,9 +247,11 @@ impl<'r, B: TerrainBlockStates> TerrainRegion<'r, B> {
pub fn chunks(&self) -> impl Iterator<Item = &TerrainChunk<'r, B>> {
self.chunks.iter()
}
#[inline]
fn index(&self, x: i32, z: i32) -> Option<usize> {
self.index.get(x, z)
}
#[inline]
pub fn chunk(&self, x: i32, z: i32) -> Result<&TerrainChunk<'r, B>, RegionError> {
match self.index(x, z) {
Some(i) => Ok(&self.chunks[i]),
@@ -261,9 +264,22 @@ impl<'r, B: TerrainBlockStates> TerrainRegion<'r, B> {
None => Err(RegionError::MissingChunk([x, z])),
}
}
#[inline]
pub fn block(&self, [x, y, z]: [i32; 3]) -> Result<BlockStateId, RegionError> {
Ok(self.chunk(x >> 4, z >> 4)?.block(x, y, z))
}
/// `FeatureWorld::scan_column` within one chunk's column.
pub fn scan_column(
&self,
[x, y, z]: [i32; 3],
step: i32,
limit: i32,
keep: impl FnMut(BlockStateId) -> bool,
) -> Result<i32, RegionError> {
Ok(self
.chunk(x >> 4, z >> 4)?
.scan_column([x, y, z], step, limit, keep))
}
/// Level-style biome lookup checks the selected chunk's generation status
/// before reading its palette. Raw biome storage remains independently
/// accessible for generation itself. Retained upgrade status permits reading
@@ -650,6 +666,15 @@ where
fn block(&mut self, position: [i32; 3]) -> Result<BlockStateId, Self::Error> {
self.region.block(position)
}
fn scan_column(
&mut self,
position: [i32; 3],
step: i32,
limit: i32,
keep: impl FnMut(BlockStateId) -> bool,
) -> Result<i32, Self::Error> {
self.region.scan_column(position, step, limit, keep)
}
fn set_block(
&mut self,
position: [i32; 3],
File diff suppressed because it is too large. Load diff
@@ -341,10 +341,13 @@ impl Decoration {
let mut biomes = Vec::with_capacity(9);
let (min_y, height) = (region[4].terrain.min_y(), region[4].terrain.height());
for chunk in region.iter_mut() {
// A one-section stand-in while the region holds the terrain: it
// is swapped back below, and allocating the full height for nine
// stand-ins per region cost more than some features.
let empty = super::terrain::TerrainChunk::new(
chunk.position(),
min_y,
height,
16,
data.registry(),
data.block_states().clone(),
)
@@ -380,11 +383,15 @@ impl Decoration {
biomes: BiomeRegion::new(biomes, obfuscate_seed(seed))
.map_err(|e| resource(e.to_string()))?,
zone: GenerationWriteZone::new(center, 1),
center,
uniform_biomes: vec![UNKNOWN_BIOME; 9 * (height / 16).max(0) as usize],
biome_quarts: region[4].biomes().quart_range(),
decoration: self,
temperatures: &mut worker.temperatures,
behaviours: data.behaviours.as_deref(),
// WorldGenRegion.isOutsideBuildHeight: the level's range, not the noise's.
build: (min_y, min_y + height),
shape_ticks: std::cell::RefCell::new(crate::level::Ticks::new(0)),
};
let mut stats = DecorateStats::default();
let blocks = self.blocks;
@@ -504,6 +511,72 @@ impl Decoration {
}
}
/// `a`, and `b` when it differs.
fn span<T: PartialEq>(a: T, b: T) -> (T, Option<T>) {
let b = (a != b).then_some(b);
(a, b)
}
const UNKNOWN_BIOME: u32 = u32::MAX;
const MIXED_BIOMES: u32 = u32::MAX - 1;
impl World<'_> {
/// The biome of a biome section when all its quarts hold the same one
/// (and its chunk has biomes); `None` when mixed or outside the region.
fn uniform_section(&mut self, cx: i32, cz: i32, section: usize) -> Option<BiomeId> {
let (dx, dz) = (
i64::from(cx) - i64::from(self.center.x) + 1,
i64::from(cz) - i64::from(self.center.z) + 1,
);
if !(0..3).contains(&dx) || !(0..3).contains(&dz) {
return None;
}
let sections = self.uniform_biomes.len() / 9;
if section >= sections {
return None;
}
let slot = (dz * 3 + dx) as usize * sections + section;
if self.uniform_biomes[slot] == UNKNOWN_BIOME {
let chunk = ChunkPos::new(cx, cz);
let ready = self.region.chunk(cx, cz).is_ok_and(|c| {
c.highest_generated_status() >= crate::chunk_status::ChunkStatus::Biomes
});
let values = self
.biomes
.chunk(chunk)
.ok()
.and_then(|c| c.section(section));
self.uniform_biomes[slot] = match values {
Some(values) if ready && values.iter().all(|b| *b == values[0]) => values[0].0,
_ => MIXED_BIOMES,
};
}
match self.uniform_biomes[slot] {
MIXED_BIOMES => None,
id => Some(BiomeId(id)),
}
}
/// BiomeManager.getBiome without the zoom when every quart it can pick
/// lies in biome sections of one and the same biome.
fn uniform_biome(&mut self, [x, y, z]: [i32; 3]) -> Option<BiomeId> {
let base = [x, y, z].map(|v| v.wrapping_sub(2) >> 2);
let (bottom, top) = self.biome_quarts;
let section = |q: i32| ((q.clamp(bottom, top) - bottom) >> 2) as usize;
// The chunks and sections holding the quarts base..=base + 1.
let (x0, x1) = span(base[0] >> 2, base[0].wrapping_add(1) >> 2);
let (z0, z1) = span(base[2] >> 2, base[2].wrapping_add(1) >> 2);
let (s0, s1) = span(section(base[1]), section(base[1].wrapping_add(1)));
let found = self.uniform_section(x0, z0, s0)?;
for cx in std::iter::once(x0).chain(x1) {
for cz in std::iter::once(z0).chain(z1) {
for s in std::iter::once(s0).chain(s1) {
if self.uniform_section(cx, cz, s)? != found {
return None;
}
}
}
}
Some(found)
}
}
/// Advances a feature's random stream with no block storage; every write is
/// discarded. Placement modifiers still see the real biome and build bounds.
struct OriginWorld<'a> {
@@ -555,11 +628,19 @@ struct World<'a> {
region: TerrainRegion<'static, GenBlockStates>,
biomes: BiomeRegion<BiomeId>,
zone: GenerationWriteZone,
center: ChunkPos,
/// Per biome section of the nine chunks (chunk-major): its one biome id,
/// MIXED_BIOMES, or UNKNOWN_BIOME until first asked.
uniform_biomes: Vec<u32>,
/// The quart Ys biome lookups clamp to (`BiomeChunk::quart_range`).
biome_quarts: (i32, i32),
decoration: &'a Decoration,
temperatures: &'a mut [BiomeTemperature],
behaviours: Option<&'a crate::level::Behaviours>,
/// Inclusive minimum and exclusive maximum build height.
build: (i32, i32),
/// The tick capture `shape_update` passes to `updateShape`.
shape_ticks: std::cell::RefCell<crate::level::Ticks>,
}
/// Read-only `LevelAccess` view of the decoration region for
/// `BlockState.updateShape` (writes go through `World::set_block`).
@@ -614,8 +695,10 @@ impl World<'_> {
random: horizon_core::random::LegacyRandom::new(0),
};
// The ticks updateShape schedules (LeavesBlock re-measuring its
// distance, waterlogged blocks' fluid) go to the proto chunks.
let mut ticks = crate::level::Ticks::new(0);
// distance, waterlogged blocks' fluid) go to the proto chunks. While
// capturing, scheduling leaves the rest of `Ticks` untouched, so the
// region keeps one instead of building one per update.
let mut ticks = self.shape_ticks.borrow_mut();
ticks.capture = Some(Vec::new());
let updated = behaviours.update_shape(
&mut level,
@@ -658,10 +741,36 @@ impl DecorationWorld for World<'_> {
.map_err(|_| RegionError::InvalidChunks)?
>> 4)
}
/// `BiomeRegion::present_in_chunk`: the distinct ids, ascending, from a
/// bitset instead of sorting every quart of the column.
fn chunk_biomes(&mut self, chunk: ChunkPos) -> Result<Vec<BiomeId>, RegionError> {
self.biomes
.present_in_chunk(chunk)
.map_err(RegionError::Biome)
let biomes = self.biomes.chunk(chunk).map_err(RegionError::Biome)?;
let mut seen: Vec<u64> = Vec::new();
let mut last = None;
let mut section = 0;
while let Some(values) = biomes.section(section) {
for &id in values {
if last == Some(id) {
continue;
}
last = Some(id);
let word = id.0 as usize / 64;
if seen.len() <= word {
seen.resize(word + 1, 0);
}
seen[word] |= 1 << (id.0 % 64);
}
section += 1;
}
let mut out = Vec::new();
for (word, &bits) in seen.iter().enumerate() {
let mut bits = bits;
while bits != 0 {
out.push(BiomeId(word as u32 * 64 + bits.trailing_zeros()));
bits &= bits - 1;
}
}
Ok(out)
}
fn set_currently_generating(&mut self, _: Option<FeatureIdentity>) -> Result<(), RegionError> {
Ok(())
@@ -669,9 +778,19 @@ impl DecorationWorld for World<'_> {
}
impl FeatureWorld for World<'_> {
type Error = RegionError;
#[inline]
fn block(&mut self, position: [i32; 3]) -> Result<BlockStateId, RegionError> {
self.region.block(position)
}
fn scan_column(
&mut self,
position: [i32; 3],
step: i32,
limit: i32,
keep: impl FnMut(BlockStateId) -> bool,
) -> Result<i32, RegionError> {
self.region.scan_column(position, step, limit, keep)
}
fn set_block(
&mut self,
position: [i32; 3],
@@ -1232,6 +1351,9 @@ impl PlacementContext for World<'_> {
self.region.schedule_block_tick(position, block, delay)
}
fn biome_identity(&mut self, position: [i32; 3]) -> Result<BiomeId, PlacementQueryError> {
if let Some(biome) = self.uniform_biome(position) {
return Ok(biome);
}
self.region
.biome_at(
&self.biomes,
@@ -1297,13 +1419,14 @@ impl PlacementContext for World<'_> {
}
}
}
for x in x0..=x1 {
for z in z0..=z1 {
let map = self.region.chunk(x >> 4, z >> 4)?.heightmap(kind).unwrap();
let floor = map
.first_available(x & 15, z & 15)
.map_err(|e| RegionError::from(super::terrain::TerrainError::from(e)))?;
if y <= floor {
// Any column will do, so each chunk's part of the square is read
// from its heightmap in one go.
for cx in x0 >> 4..=x1 >> 4 {
let xs = [x0.max(cx << 4) & 15, x1.min((cx << 4) + 15) & 15];
for cz in z0 >> 4..=z1 >> 4 {
let zs = [z0.max(cz << 4) & 15, z1.min((cz << 4) + 15) & 15];
let map = self.region.chunk(cx, cz)?.heightmap(kind).unwrap();
if map.any_first_available_at_least(xs, zs, y) {
return Ok(Some(true));
}
}
@@ -53,6 +53,16 @@ pub trait MaterialSurfaceQueries {
fn biome(&mut self, _position: BlockPos) -> Result<Option<Arc<str>>, MaterialRuleError> {
Err(MaterialRuleError::MissingBiomeProvider)
}
/// `biome` as the index `biome_index` gives its name, for queries that
/// number their biomes: biome conditions then compare numbers.
fn biome_id(&mut self, _position: BlockPos) -> Result<Option<u32>, MaterialRuleError> {
Err(MaterialRuleError::MissingBiomeProvider)
}
/// `Some` when `biome_id` answers: the index of the biome `name` (`None`
/// inside when no biome has that name).
fn biome_index(&self, _name: &str) -> Option<Option<u32>> {
None
}
fn melts_frozen_ocean(
&mut self,
_biome: &str,
@@ -98,6 +108,12 @@ impl<T: MaterialSurfaceQueries + ?Sized> MaterialSurfaceQueries for &mut T {
fn biome(&mut self, position: BlockPos) -> Result<Option<Arc<str>>, MaterialRuleError> {
(**self).biome(position)
}
fn biome_id(&mut self, position: BlockPos) -> Result<Option<u32>, MaterialRuleError> {
(**self).biome_id(position)
}
fn biome_index(&self, name: &str) -> Option<Option<u32>> {
(**self).biome_index(name)
}
fn cold_enough_to_snow(
&mut self,
biome: &str,
@@ -198,6 +214,7 @@ struct Context<S> {
noise_indices: BTreeMap<(String, bool), usize>,
possible_biomes: Option<BTreeSet<String>>,
biome: Option<Arc<str>>,
biome_id: Option<u32>,
}
struct CachedNoise {
sampler: Arc<dyn Noise>,
@@ -227,6 +244,12 @@ impl<S: MaterialSurfaceQueries> Context<S> {
}
Ok(self.biome.clone())
}
fn biome_id(&mut self) -> Result<Option<u32>, MaterialRuleError> {
if self.biome_id.is_none() {
self.biome_id = self.surface.biome_id(self.position)?;
}
Ok(self.biome_id)
}
fn bind_noise(&mut self, name: &str, is_3d: bool) -> Result<usize, DecodeError> {
let key = (name.to_owned(), is_3d);
if let Some(index) = self.noise_indices.get(&key) {
@@ -287,6 +310,8 @@ struct Compiled<R: RandomSource> {
enum Kind<R: RandomSource> {
Simple(MaterialCondition),
Biome(BTreeSet<String>),
/// `Biome` for queries that number their biomes: a bit per index.
BiomeIds(Vec<u64>),
Constant(bool),
Noise {
sampler: usize,
@@ -317,6 +342,7 @@ where
match self {
Self::Simple(condition) => Self::Simple(condition.clone()),
Self::Biome(names) => Self::Biome(names.clone()),
Self::BiomeIds(bits) => Self::BiomeIds(bits.clone()),
Self::Constant(value) => Self::Constant(*value),
Self::Noise { sampler, min, max } => Self::Noise {
sampler: *sampler,
@@ -416,7 +442,25 @@ impl<R: RandomSource> Compiled<R> {
match &context.possible_biomes {
Some(possible) if names.is_disjoint(possible) => Kind::Constant(false),
Some(possible) if possible.is_subset(&names) => Kind::Constant(true),
_ => Kind::Biome(names),
_ => match names
.iter()
.map(|name| context.surface.biome_index(name))
.collect::<Option<Vec<_>>>()
{
// Names no biome has never match the block's biome.
Some(ids) => {
let mut bits = Vec::new();
for id in ids.into_iter().flatten() {
let word = id as usize / 64;
if bits.len() <= word {
bits.resize(word + 1, 0);
}
bits[word] |= 1 << (id % 64);
}
Kind::BiomeIds(bits)
}
None => Kind::Biome(names),
},
}
}
MaterialCondition::NoiseThreshold {
@@ -510,6 +554,11 @@ impl<R: RandomSource> Compiled<R> {
.ok_or(MaterialRuleError::MissingBiome)?
.as_ref(),
),
Kind::BiomeIds(bits) => {
let id = context.biome_id()?.ok_or(MaterialRuleError::MissingBiome)?;
bits.get(id as usize / 64)
.is_some_and(|word| word >> (id % 64) & 1 == 1)
}
Kind::Simple(MaterialCondition::Hole) => context.depth <= 0,
Kind::Simple(MaterialCondition::Steep) => {
context.gradient[0] <= -4 || context.gradient[1] >= 4
@@ -627,6 +676,7 @@ impl<R: RandomSource, S: MaterialSurfaceQueries> MaterialConditions<R, S> {
noise_indices: BTreeMap::new(),
possible_biomes,
biome: None,
biome_id: None,
},
conditions: Vec::new(),
rules: Vec::new(),
@@ -654,6 +704,7 @@ impl<R: RandomSource, S: MaterialSurfaceQueries> MaterialConditions<R, S> {
pub fn update_y(&mut self, stone_above: i32, stone_below: i32, water_height: i32, y: i32) {
self.context.block_revision = self.context.block_revision.wrapping_add(1);
self.context.biome = None;
self.context.biome_id = None;
self.context.position.y = y;
self.context.stone_above = stone_above;
self.context.stone_below = stone_below;
@@ -129,6 +129,12 @@ impl<S: MaterialSurfaceQueries> MaterialSurfaceQueries for MaterialDensitySource
fn biome(&mut self, position: BlockPos) -> Result<Option<Arc<str>>, MaterialRuleError> {
self.surface.biome(position)
}
fn biome_id(&mut self, position: BlockPos) -> Result<Option<u32>, MaterialRuleError> {
self.surface.biome_id(position)
}
fn biome_index(&self, name: &str) -> Option<Option<u32>> {
self.surface.biome_index(name)
}
fn melts_frozen_ocean(
&mut self,
biome: &str,
@@ -70,6 +70,12 @@ impl<P: PositionalRandom, S: MaterialSurfaceQueries> MaterialSurfaceQueries
fn biome(&mut self, position: BlockPos) -> Result<Option<Arc<str>>, MaterialRuleError> {
self.surface.biome(position)
}
fn biome_id(&mut self, position: BlockPos) -> Result<Option<u32>, MaterialRuleError> {
self.surface.biome_id(position)
}
fn biome_index(&self, name: &str) -> Option<Option<u32>> {
self.surface.biome_index(name)
}
fn melts_frozen_ocean(
&mut self,
biome: &str,
@@ -0,0 +1,366 @@
//! A world's data packs over the prepared worldgen bundles.
//!
//! Vanilla builds its worldgen registries from the enabled packs in order:
//! the built-in pack first, then each enabled pack, a later pack's file for
//! an id replacing an earlier one's. Tags are the exception: every pack's
//! file for a tag adds its entries, and `replace` drops what came before.
//! The prepared bundles are the built-in pack, so the packs above it are
//! entries that replace or join a bundle's (`GeneratorData::load_with`).
use serde_json::Value;
use std::collections::BTreeMap;
/// The bundle of `dimension/<id>.json` level stems. Nothing prepared has
/// it: a pack's level stem stands in for the world preset's.
pub const DIMENSIONS: &str = "dimensions";
/// Pack folders (`data/<ns>/<kind>/`) the chunk generator reads, and the
/// bundle each joins.
const KINDS: [(&str, &str); 14] = [
("dimension", DIMENSIONS),
("worldgen/biome", "biomes"),
// 26.3's names: 26.2 called them configured_carver and configured_feature.
("worldgen/carver", "carvers"),
("worldgen/feature", "features"),
("worldgen/density_function", "densities"),
("worldgen/material_condition", "conditions"),
("worldgen/material_rule", "rules"),
("worldgen/noise", "noises"),
("worldgen/noise_settings", "settings"),
("worldgen/placed_feature", "placed_features"),
("worldgen/processor_list", "processor_lists"),
("worldgen/structure", "structures"),
("worldgen/structure_set", "structure_sets"),
("worldgen/template_pool", "template_pools"),
];
/// Tag folders the chunk generator reads, and the tag bundle each joins.
const TAG_KINDS: [(&str, &str); 3] = [
("tags/block", "block_tags"),
("tags/fluid", "fluid_tags"),
("tags/worldgen/biome", "biome_tags"),
];
/// The pack folders `add_file` looks at, in walk order.
pub const PACK_KINDS: [&str; 4] = ["dimension", "dimension_type", "tags", "worldgen"];
/// `ns:path` for a pack file `data/<ns>/<kind>/<path>.json`.
fn resource_id(file: &str, kind: &str) -> Option<String> {
let (ns, rest) = file.strip_prefix("data/")?.split_once('/')?;
let path = rest
.strip_prefix(kind)?
.strip_prefix('/')?
.strip_suffix(".json")?;
Some(format!("{ns}:{path}"))
}
/// What `add_file` made of a pack file.
#[derive(Debug, PartialEq, Eq)]
pub enum PackFile {
/// An entry or tag file the generator reads.
Read,
/// A dimension type: Horizon keeps each dimension's vanilla build
/// height, so it is not read.
DimensionType { id: String, min_y: i64, height: i64 },
/// A worldgen file of a kind the generator does not read (its folder).
NotRead(String),
/// A generator file whose text could not be read.
Unreadable,
/// Not worldgen.
Other,
}
/// Replacement and added bundle entries, and tag files, from data packs.
#[derive(Clone, Debug, Default, PartialEq, Eq, Hash)]
pub struct WorldgenOverrides {
/// Bundle name → id → the highest pack's JSON text for it.
entries: BTreeMap<String, BTreeMap<String, String>>,
/// Tag bundle name → (tag id, pack id, tag file text), lowest pack first.
tags: BTreeMap<String, Vec<(String, String, String)>>,
}
impl WorldgenOverrides {
pub fn is_empty(&self) -> bool {
self.entries.is_empty() && self.tags.is_empty()
}
/// A pack's file for `id` in `bundle`: called for packs from the lowest
/// to the highest, so the last call for an id wins.
pub fn set(&mut self, bundle: &str, id: &str, json: String) {
self.entries
.entry(bundle.to_owned())
.or_default()
.insert(id.to_owned(), json);
}
/// A pack's tag file for the tag `id` in the tag bundle `bundle`, in
/// pack order.
pub fn add_tag(&mut self, bundle: &str, id: &str, pack: &str, json: String) {
self.tags.entry(bundle.to_owned()).or_default().push((
id.to_owned(),
pack.to_owned(),
json,
));
}
/// The file `file` (`data/<ns>/...`, relative to the pack root) of the
/// pack `pack`, whose text `read` gives. Packs go from the lowest to the
/// highest, each in its own file order.
pub fn add_file(
&mut self,
pack: &str,
file: &str,
read: impl FnOnce() -> Option<String>,
) -> PackFile {
let Some((_, rest)) = file.strip_prefix("data/").and_then(|r| r.split_once('/')) else {
return PackFile::Other;
};
if let Some((bundle, id)) = KINDS
.iter()
.find_map(|(kind, bundle)| Some((bundle, resource_id(file, kind)?)))
{
let Some(json) = read() else {
return PackFile::Unreadable;
};
self.set(bundle, &id, json);
PackFile::Read
} else if let Some((bundle, id)) = TAG_KINDS
.iter()
.find_map(|(kind, bundle)| Some((bundle, resource_id(file, kind)?)))
{
let Some(json) = read() else {
return PackFile::Unreadable;
};
self.add_tag(bundle, &id, pack, json);
PackFile::Read
} else if let Some(id) = resource_id(file, "dimension_type") {
let v: Value = read()
.and_then(|t| serde_json::from_str(&t).ok())
.unwrap_or_default();
PackFile::DimensionType {
id,
min_y: v["min_y"].as_i64().unwrap_or_default(),
height: v["height"].as_i64().unwrap_or_default(),
}
} else if rest.starts_with("worldgen/") || rest.starts_with("tags/worldgen/") {
PackFile::NotRead(rest.rsplit_once('/').map_or(rest, |(k, _)| k).to_owned())
} else {
PackFile::Other
}
}
/// After the last pack: leaves out the packs' new biomes (`known` says
/// which the game has), and what names them names the rest without
/// them: their biome tags' other biomes, and the climate points of the
/// other biomes in a level stem's multi-noise list (the left-out
/// biome's climate goes to its nearest neighbours). Returns what it
/// left out, for the log.
pub fn finish(&mut self, known: impl Fn(&str) -> bool) -> Vec<String> {
let left_out = self.retain("biomes", &known);
let mut warnings: Vec<String> = left_out
.iter()
.map(|id| {
format!("biome {id} is new: Horizon cannot add biomes yet, so it is left out")
})
.collect();
if left_out.is_empty() {
return warnings;
}
let gone = |v: &Value| {
let id = v.as_str().or_else(|| v["id"].as_str()).unwrap_or_default();
let id = if id.contains(':') {
id.to_owned()
} else {
format!("minecraft:{id}")
};
left_out.contains(&id)
};
for (_, _, json) in self.tags.get_mut("biome_tags").into_iter().flatten() {
if let Ok(mut v) = serde_json::from_str::<Value>(json)
&& let Some(values) = v["values"].as_array_mut()
{
let before = values.len();
values.retain(|e| !gone(e));
if values.len() != before {
*json = v.to_string();
}
}
}
for (id, json) in self.entries.get_mut(DIMENSIONS).into_iter().flatten() {
let Ok(mut v) = serde_json::from_str::<Value>(json) else {
continue;
};
let Some(points) = v["generator"]["biome_source"]["biomes"].as_array_mut() else {
continue;
};
let before = points.len();
points.retain(|p| !gone(&p["biome"]));
if points.len() != before {
warnings.push(format!(
"dimension {id}: {} of its {before} biome climate points name left-out \
biomes and are removed",
before - points.len()
));
*json = v.to_string();
}
}
warnings
}
/// The entries packs give `bundle`.
pub fn entries(&self, bundle: &str) -> impl Iterator<Item = (&str, &str)> {
self.entries
.get(bundle)
.into_iter()
.flatten()
.map(|(id, json)| (id.as_str(), json.as_str()))
}
/// The tag files packs give the tag bundle `bundle`, in pack order.
pub fn tags(&self, bundle: &str) -> impl Iterator<Item = (&str, &str, &str)> {
self.tags
.get(bundle)
.into_iter()
.flatten()
.map(|(id, pack, json)| (id.as_str(), pack.as_str(), json.as_str()))
}
/// Removes the entries of `bundle` that `keep` refuses, returning their ids.
pub fn retain(&mut self, bundle: &str, mut keep: impl FnMut(&str) -> bool) -> Vec<String> {
let Some(entries) = self.entries.get_mut(bundle) else {
return Vec::new();
};
let dropped: Vec<String> = entries.keys().filter(|id| !keep(id)).cloned().collect();
for id in &dropped {
entries.remove(id);
}
if entries.is_empty() {
self.entries.remove(bundle);
}
dropped
}
/// `bundle` with the packs' entries over it.
pub fn apply(
&self,
bundle: &str,
mut base: BTreeMap<String, String>,
) -> BTreeMap<String, String> {
for (id, json) in self.entries(bundle) {
base.insert(id.to_owned(), json.to_owned());
}
base
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_highest_pack_wins_and_others_join() {
let mut o = WorldgenOverrides::default();
assert!(o.is_empty());
o.set("noises", "minecraft:cave", "low".into());
o.set("noises", "minecraft:cave", "high".into());
o.set("noises", "pack:new", "new".into());
let base = BTreeMap::from([
("minecraft:cave".to_owned(), "vanilla".to_owned()),
("minecraft:other".to_owned(), "kept".to_owned()),
]);
let merged = o.apply("noises", base);
assert_eq!(merged["minecraft:cave"], "high");
assert_eq!(merged["minecraft:other"], "kept");
assert_eq!(merged["pack:new"], "new");
assert_eq!(o.apply("densities", BTreeMap::new()).len(), 0);
}
#[test]
fn pack_files_go_to_their_bundles() {
let mut o = WorldgenOverrides::default();
let text = |t: &str| {
let t = t.to_owned();
move || Some(t)
};
let mut add = |file: &str, json: &str| o.add_file("file/p", file, text(json));
assert_eq!(
add("data/minecraft/worldgen/noise/cave.json", "1"),
PackFile::Read
);
assert_eq!(
add("data/p/worldgen/density_function/a/b.json", "2"),
PackFile::Read
);
assert_eq!(
add("data/minecraft/worldgen/noise_settings/overworld.json", "3"),
PackFile::Read
);
assert_eq!(
add("data/minecraft/worldgen/structure_set/mineshafts.json", "4"),
PackFile::Read
);
assert_eq!(
add("data/minecraft/dimension/overworld.json", "5"),
PackFile::Read
);
assert_eq!(
add("data/minecraft/tags/worldgen/biome/is_hill.json", "{}"),
PackFile::Read
);
assert_eq!(
add(
"data/minecraft/dimension_type/overworld.json",
r#"{"min_y":-64,"height":2096}"#
),
PackFile::DimensionType {
id: "minecraft:overworld".into(),
min_y: -64,
height: 2096
}
);
assert_eq!(
add("data/minecraft/worldgen/carver/cave.json", "6"),
PackFile::Read
);
assert_eq!(
add("data/minecraft/worldgen/feature/water_delta.json", "7"),
PackFile::Read
);
assert_eq!(
add("data/minecraft/worldgen/configured_carver/cave.json", "{}"),
PackFile::NotRead("worldgen/configured_carver".into())
);
assert_eq!(
add("data/minecraft/worldgen/world_preset/normal.json", "{}"),
PackFile::NotRead("worldgen/world_preset".into())
);
assert_eq!(add("data/p/function/f.mcfunction", ""), PackFile::Other);
assert_eq!(
o.add_file("file/p", "data/p/worldgen/noise/x.json", || None),
PackFile::Unreadable
);
let get = |bundle: &str| o.entries(bundle).collect::<Vec<_>>();
assert_eq!(get("noises"), [("minecraft:cave", "1")]);
assert_eq!(get("densities"), [("p:a/b", "2")]);
assert_eq!(get("settings"), [("minecraft:overworld", "3")]);
assert_eq!(get("structure_sets"), [("minecraft:mineshafts", "4")]);
assert_eq!(get(DIMENSIONS), [("minecraft:overworld", "5")]);
assert_eq!(get("carvers"), [("minecraft:cave", "6")]);
assert_eq!(get("features"), [("minecraft:water_delta", "7")]);
assert_eq!(get("structures"), []);
assert_eq!(o.tags("biome_tags").count(), 1);
}
#[test]
fn new_biomes_are_left_out_of_tags_and_climate_points() {
let mut o = WorldgenOverrides::default();
o.set("biomes", "minecraft:plains", "{}".into());
o.set("biomes", "minecraft:molten_peaks", "{}".into());
o.add_tag(
"biome_tags",
"minecraft:is_hill",
"file/p",
r#"{"values":["minecraft:plains","molten_peaks",{"id":"minecraft:molten_peaks","required":false}]}"#.into(),
);
o.set(
DIMENSIONS,
"minecraft:overworld",
r#"{"generator":{"biome_source":{"biomes":[{"biome":"minecraft:plains"},{"biome":"minecraft:molten_peaks"}]}}}"#.into(),
);
let warnings = o.finish(|id| id == "minecraft:plains");
assert_eq!(warnings.len(), 2, "{warnings:?}");
assert_eq!(o.entries("biomes").count(), 1);
let (_, _, tag) = o.tags("biome_tags").next().unwrap();
assert_eq!(tag, r#"{"values":["minecraft:plains"]}"#);
let (_, stem) = o.entries(DIMENSIONS).next().unwrap();
assert!(!stem.contains("molten_peaks") && stem.contains("minecraft:plains"));
}
}
+46 -3
View File
@@ -172,10 +172,12 @@ impl<B: TerrainBlockStates> HeightmapChunk for TerrainBlocks<'_, B> {
}
}
impl<B: TerrainBlockStates> TerrainBlocks<'_, B> {
#[inline]
fn section_index(&self, y: i32) -> Option<usize> {
let dy = i64::from(y) - i64::from(self.min_y);
(dy >= 0 && dy < i64::from(self.height)).then_some((dy >> 4) as usize)
}
#[inline]
fn block(&self, x: i32, y: i32, z: i32) -> BlockStateId {
let Some(section) = self.section_index(y) else {
return self.void_air;
@@ -183,11 +185,10 @@ impl<B: TerrainBlockStates> TerrainBlocks<'_, B> {
if self.non_air[section] == 0 {
return self.air;
}
self.sections[section]
.get(local_index(x, y, z))
.expect("valid section coordinates")
self.sections[section].state(local_index(x, y, z))
}
}
#[inline]
fn local_index(x: i32, y: i32, z: i32) -> usize {
(((y & 15) << 8) | ((z & 15) << 4) | (x & 15)) as usize
}
@@ -334,9 +335,51 @@ impl<'r, B: TerrainBlockStates> TerrainChunk<'r, B> {
self.blocks.highest_section_y()
}
/// ProtoChunk reads collapse an all-air section to its default AIR state.
#[inline]
pub fn block(&self, x: i32, y: i32, z: i32) -> BlockStateId {
self.blocks.block(x, y, z)
}
/// `FeatureWorld::scan_column` in this chunk: an all-air section is
/// tested once, a mixed one read without looking the section up per block.
pub fn scan_column(
&self,
[x, mut y, z]: [i32; 3],
step: i32,
mut limit: i32,
mut keep: impl FnMut(BlockStateId) -> bool,
) -> i32 {
debug_assert!(step == 1 || step == -1);
let blocks = &self.blocks;
while limit > 0 {
let Some(section) = blocks.section_index(y) else {
if !keep(blocks.void_air) {
return y;
}
y = y.wrapping_add(step);
limit -= 1;
continue;
};
// Blocks from y to this section's end in the walk's direction.
let within = (y - blocks.min_y) & 15;
let run = if step > 0 { 16 - within } else { within + 1 }.min(limit);
limit -= run;
if blocks.non_air[section] == 0 {
if !keep(blocks.air) {
return y;
}
y = y.wrapping_add(step * run);
continue;
}
let storage = &blocks.sections[section];
for _ in 0..run {
if !keep(storage.state(local_index(x, y, z))) {
return y;
}
y = y.wrapping_add(step);
}
}
y
}
pub fn section(&self, index: usize) -> Option<&BlockSection<'r>> {
self.blocks.sections.get(index)
}
+12
View File
@@ -49,6 +49,18 @@ pub(crate) fn read<W: FeatureWorld>(
) -> TreeResult<BlockStateId, W::Error> {
world.block(position).map_err(world_error)
}
/// `FeatureWorld::scan_column`.
pub(crate) fn scan_column<W: FeatureWorld>(
world: &mut W,
position: [i32; 3],
step: i32,
limit: i32,
keep: impl FnMut(BlockStateId) -> bool,
) -> TreeResult<i32, W::Error> {
world
.scan_column(position, step, limit, keep)
.map_err(world_error)
}
pub(crate) fn write<W: FeatureWorld>(
world: &mut W,
position: [i32; 3],
@@ -129,6 +129,51 @@ fn rejects_incompatible_regions_and_routes_negative_chunk_coordinates() {
));
assert_eq!(region.block([0, 7, 0]).unwrap(), BlockStateId(0));
}
/// The column scan matches reading block by block: from inside and outside
/// the build range, both ways, across all-air and mixed sections.
#[test]
fn column_scan_matches_block_by_block_reads() {
let registry = registry();
let chunk = TerrainChunk::new(ChunkPos::new(-1, 0), -32, 64, &registry, Properties).unwrap();
let mut region =
TerrainRegion::new(vec![chunk], WorldGenerationContext::new(-32, 64, 8)).unwrap();
for (y, state) in [
(-30, 2),
(-17, 3),
(-16, 2),
(5, 4),
(6, 0),
(20, 2),
(31, 3),
] {
region
.set_block_worldgen([-13, y, 5], BlockStateId(state))
.unwrap();
}
let keeps: [fn(BlockStateId) -> bool; 3] = [|s| s.0 < 2, |s| s.0 == 0, |s| s.0 != 2];
for keep in keeps {
for start in -40..40 {
for step in [1, -1] {
for limit in [0, 1, 3, 15, 16, 17, 40, 90] {
let mut y = start;
for _ in 0..limit {
if !keep(region.block([-13, y, 5]).unwrap()) {
break;
}
y += step;
}
assert_eq!(
region
.scan_column([-13, start, 5], step, limit, keep)
.unwrap(),
y,
"start {start} step {step} limit {limit}"
);
}
}
}
}
}
#[test]
fn write_callback_controls_mutation_and_receives_all_flags() {
let registry = registry();
+21
View File
@@ -201,6 +201,27 @@ across the overworld, Nether and End, plus rectangles around every structure kin
isolates terrain plus structure pieces, `--phase terrain` stops before features. `HORIZON_ORDER` (a file of `x z` lines) makes both sides
decorate in that order instead of row-major.
### Data packs
A world's enabled data packs (`<world>/datapacks`, in `datapacks.json`'s order) change its generation as vanilla's registries do:
`worldgen::overrides` maps each pack file to the bundle it joins (noises, density functions, noise settings, material rules and
conditions, carvers, configured and placed features, structures, structure sets, template pools, processor lists, biomes, level stems), a
higher pack's file for an id replacing a lower one's, and its block, fluid and biome tag files to the tag bundles in pack order.
`GeneratorData::load_with` reads the bundles with them; the client caches the result per set of pack entries
(`vanillagen::shared_data_with`). What Horizon leaves out is logged: a pack's new biomes (the game has no id, colours or sounds for them;
their tag entries and climate points go with them), dimension types (every dimension keeps its vanilla build height, and noise settings
taller than it generate only inside it, as `clampToHeightAccessor`), and folders the generator does not read. Sapling growth and bone
meal still place vanilla's features.
`tools/test_datapack_worldgen_parity.py` runs the whole-chunk harness with `HORIZON_DATAPACKS=tools/fixtures/datapacks` on both sides
(the oracle selects every pack of the folder after vanilla through vanilla's own pack repository): its pack overrides a noise, a density
function, the overworld noise settings (sea level 70, no aquifers, a new final density with a new noise added), a placed feature and a
biome tag. Every site must match vanilla with the pack and differ from Horizon without it.
Packs must be written for 26.3: its worldgen formats differ from 26.2's (density functions take `left`/`right`, `gradient` replaces
`y_clamped_gradient`, noises take `base_octave`/`octave_count`, block states are strings, surface rules became the `material_rule` and
`material_condition` registries, configured features lost `config`). Vanilla 26.3 refuses a 26.2 worldgen pack as Horizon does.
### Decoration order
Biome decoration is order dependent: a chunk's features write into its eight neighbours, so a tree, ore blob, sculk patch or geode placed
@@ -204,9 +204,15 @@ public final class VanillaChunkGenerationOracle {
Path out = Path.of(args[1]);
long seed = Long.parseLong(args[2]);
String dimension = args[3];
var repo = ServerPacksSource.createVanillaTrustedRepository();
// HORIZON_DATAPACKS: a world's datapacks folder, every pack in it enabled after vanilla in name order.
String datapacks = System.getenv("HORIZON_DATAPACKS");
var repo = datapacks == null ? ServerPacksSource.createVanillaTrustedRepository()
: ServerPacksSource.createPackRepository(Path.of(datapacks), new net.minecraft.world.level.validation.DirectoryValidator(path -> true));
repo.reload();
repo.setSelected(List.of("vanilla"));
var selected = new ArrayList<String>(List.of("vanilla"));
repo.getAvailableIds().stream().filter(id -> id.startsWith("file/")).sorted().forEach(selected::add);
repo.setSelected(selected);
if (!repo.getSelectedIds().containsAll(selected)) throw new IllegalStateException("packs not selected: " + selected + " vs " + repo.getSelectedIds());
var resources = new MultiPackResourceManager(PackType.SERVER_DATA, repo.openAllSelected());
var layers = RegistryLayer.createRegistryAccess();
var staticTags = TagLoader.loadTagsForExistingRegistries(resources, layers.getLayer(RegistryLayer.STATIC));
@@ -309,7 +309,7 @@ public final class VanillaPackScreenOracle {
List<String> initial = new ArrayList<>();
for (var s : c.getAsJsonArray("selected")) initial.add(s.getAsString());
repository.setSelected(initial);
var screen = new PackSelectionScreen(repository, r -> { }, Path.of(c.get("dir").getAsString()), Component.translatable("resourcePack.title"));
var screen = new PackSelectionScreen(repository, r -> { }, Path.of(c.get("dir").getAsString()), Component.translatable(c.get("title").getAsString()));
gui.setScreen(screen);
// PackSelectionScreen.loadPackIcon: a pack with a pack.png gets its own texture.
@SuppressWarnings("unchecked")
@@ -0,0 +1,5 @@
{
"type": "minecraft:add",
"left": "minecraft:overworld/final_density",
"right": "horizon_test:hills"
}
@@ -0,0 +1,10 @@
{
"type": "minecraft:mul",
"left": 0.25,
"right": {
"type": "minecraft:noise",
"noise": "horizon_test:hills",
"xz_scale": 0.5,
"y_scale": 0.0
}
}
@@ -0,0 +1,5 @@
{
"base_amplitude": 1.0,
"base_octave": -7,
"octave_count": 2
}
@@ -0,0 +1,3 @@
{
"values": ["minecraft:plains"]
}
@@ -0,0 +1,8 @@
{
"type": "minecraft:old_blended_noise",
"smear_scale_multiplier": 8.0,
"xz_factor": 40.0,
"xz_scale": 0.25,
"y_factor": 120.0,
"y_scale": 0.125
}
@@ -0,0 +1,6 @@
{
"amplitude_modifiers": [1.0, 2.0, 1.0],
"base_amplitude": 0.9,
"base_octave": -8,
"octave_count": 3
}
@@ -0,0 +1,20 @@
{
"noise": {"min_y": -64, "height": 384},
"default_block": "minecraft:stone",
"default_fluid": "minecraft:water",
"noise_router": {
"chunk_surface_level": "minecraft:overworld/chunk_surface_level",
"continents": "minecraft:overworld/continents",
"depth": "minecraft:overworld/depth",
"erosion": "minecraft:overworld/erosion",
"final_density": "horizon_test:final_density",
"ridges": "minecraft:overworld/ridges",
"temperature": "minecraft:overworld/temperature",
"vegetation": "minecraft:overworld/vegetation"
},
"material_rule": "minecraft:overworld",
"spawn_target": [],
"sea_level": 70,
"disable_mob_generation": false,
"legacy_random_source": false
}
@@ -0,0 +1,16 @@
{
"feature": "minecraft:ore_diamond_small",
"placement": [
{"type": "minecraft:count", "count": 30},
{"type": "minecraft:in_square"},
{
"type": "minecraft:height_range",
"height": {
"type": "minecraft:uniform",
"min_inclusive": {"above_bottom": 0},
"max_inclusive": {"absolute": 40}
}
},
{"type": "minecraft:biome"}
]
}
@@ -0,0 +1,7 @@
{
"pack": {
"description": "Horizon's data pack worldgen harness: overrides and new entries",
"min_format": 121,
"max_format": 121
}
}
+2 -1
View File
@@ -510,7 +510,8 @@ def build_bench_mod(jars):
shutil.rmtree(classes, ignore_errors=True)
classes.mkdir(parents=True)
sources = sorted(map(str, (source / "java").rglob("*.java")))
subprocess.run(["javac", "--release", "25", "-proc:none", "-encoding", "UTF-8", "-d", str(classes),
javac = Path(os.environ["JAVA_HOME"]) / "bin/javac" if os.environ.get("JAVA_HOME") else "javac"
subprocess.run([str(javac), "--release", "25", "-proc:none", "-encoding", "UTF-8", "-d", str(classes),
"-classpath", os.pathsep.join(map(str, jars)), *sources], check=True)
output = build / "horizon-bench.jar"
with zipfile.ZipFile(output, "w", compression=zipfile.ZIP_DEFLATED) as archive:
+79
View File
@@ -0,0 +1,79 @@
#!/usr/bin/env python3
"""Compare chunks generated with a world's data packs block-for-block with vanilla.
The packs of tools/fixtures/datapacks (Horizon's own, MIT) override and add
noises, density functions, the overworld noise settings, a placed feature and
a biome tag. Vanilla side: VanillaChunkGenerationOracle with HORIZON_DATAPACKS,
which selects every pack of the folder after vanilla, as a world does, and
loads the registries through vanilla's own pack repository. Horizon side:
chunk_dump with the same variable, reading the packs through
worldgen::overrides into GeneratorData::load_with. The same terrain sites are
compared for every block state, quart biome and block entity, first after
TERRAIN, then with decoration. Horizon without the packs must differ from
vanilla with them, so the packs are known to take effect.
usage: test_datapack_worldgen_parity.py [--seed N] [--sites K] [--phase terrain|full]
"""
import argparse
import os
import random
import sys
from pathlib import Path
try:
from . import lab, test_chunk_generation_parity as chunks
except ImportError:
import lab
import test_chunk_generation_parity as chunks
# HORIZON_PARITY_DATAPACKS: another folder of packs to compare.
DATAPACKS = Path(os.environ.get('HORIZON_PARITY_DATAPACKS', lab.ROOT / 'tools/fixtures/datapacks'))
SEEDS = [12345, 4242]
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--seed', type=int, action='append')
parser.add_argument('--sites', type=int, default=3, help='terrain sites per seed besides 0,0')
parser.add_argument('--phase', choices=['terrain', 'full'], action='append')
args = parser.parse_args()
oracle = chunks.Oracle()
binary = chunks.rust_binary()
names = chunks.state_names()
build = chunks.build_dir() / 'datapacks'
build.mkdir(exist_ok=True)
differing = 0
pack_effect = 0
for phase in args.phase or ['terrain', 'full']:
os.environ['HORIZON_PHASE'] = phase
for seed in args.seed or SEEDS:
rng = random.Random(seed)
sites = [(0, 0)] + [(rng.randint(-300, 300), rng.randint(-300, 300)) for _ in range(args.sites)]
rects = [f'{x},{z},{x},{z}' for x, z in sites]
tag = f'{seed}-{phase}'
vanilla_path, rust_path, plain_path = (build / f'{side}-{tag}.txt' for side in ('vanilla', 'rust', 'plain'))
os.environ['HORIZON_DATAPACKS'] = str(DATAPACKS)
oracle.run(vanilla_path, ['dump', seed, 'overworld', *rects])
stderr = chunks.run_rust(binary, rust_path, seed, 'overworld', rects)
del os.environ['HORIZON_DATAPACKS']
for line in stderr.splitlines():
if 'data pack' in line:
print(' horizon:', line)
vanilla, rust = chunks.parse(vanilla_path), chunks.parse(rust_path)
counts = chunks.compare(vanilla, rust, names, f'{tag} packs')
differing += counts[0]
print(f'{tag}: {len(vanilla)} chunks with the packs, differing chunks {counts[0]} '
f'(blocks {counts[1]}, biome cells {counts[2]}, block entities {counts[3]})', flush=True)
chunks.run_rust(binary, plain_path, seed, 'overworld', rects)
plain = chunks.compare(vanilla, chunks.parse(plain_path), names, f'{tag} without packs', quiet=True)
pack_effect += plain[0]
print(f'{tag}: without the packs Horizon differs from them in {plain[0]} of {len(vanilla)} chunks ({plain[1]} blocks)')
if not pack_effect:
sys.exit('the packs changed nothing: the harness would not see them')
if differing:
sys.exit(f'{differing} chunks differ from vanilla with the packs')
print('Data pack worldgen parity passed')
if __name__ == '__main__':
main()
+6 -3
View File
@@ -1,7 +1,8 @@
#!/usr/bin/env python3
"""Compare the resource pack screen's drawing with the vanilla client's.
Each case is a pack repository (titles and descriptions as text components,
Each case is a title (the resource pack screen's, or the data pack screen's
Create World opens), a pack repository (titles and descriptions as text components,
with legacy formatting codes, colours and styles; pack sources; compatible,
too old, too new or unknown packs; required, fixed and positioned packs;
icons), an initial selection, a GUI size, in a world or not, and what the
@@ -119,7 +120,9 @@ def cases(count):
(rng.randint(cx - 154, cx + 154), height - 26 + rng.randint(0, 19)),
(rng.randint(cx - 160, cx + 160), rng.randint(height // 2 - 30, height // 2 + 60))])
out.append({'width': width, 'height': height, 'inWorld': rng.random() < 0.3, 'packs': packs, 'selected': selected,
'actions': actions, 'mouse': list(mouse), 'blink': rng.choice([0, 100, 400, 650])})
'actions': actions, 'mouse': list(mouse), 'blink': rng.choice([0, 100, 400, 650]),
# Create World's Data Packs screen is the same screen under its own title.
'title': 'dataPack.title' if n % 4 == 3 else 'resourcePack.title'})
return out
@@ -161,7 +164,7 @@ def main():
for e in sorted(set(errors))[:5]:
print('ORACLE ERROR', e[:1500])
for c, v, r in bad[:args.detail]:
brief = {k: c[k] for k in ('width', 'height', 'inWorld', 'selected', 'actions', 'mouse', 'blink')}
brief = {k: c[k] for k in ('title', 'width', 'height', 'inWorld', 'selected', 'actions', 'mouse', 'blink')}
print('MISMATCH', json.dumps(brief)[:1500])
if v.get('screen') != r.get('screen'):
print(f' screen: vanilla {v.get("screen")} | horizon {r.get("screen")}')
+9 -5
View File
@@ -15,8 +15,8 @@ the worker's number is left out). In a world without structures (`generate-struc
finds none) and how many of the saved chunks hold structure starts or references (none:
ChunkStatusTasks.generateStructureStarts does nothing).
Horizon generates the normal and flat presets only: `large_biomes`, `amplified`, `single_biome_surface` and
`debug_all_block_states` aren't tested.
Horizon generates the normal, flat, amplified, large-biome and single-biome presets:
`debug_all_block_states` isn't tested.
"""
import json
import os
@@ -61,6 +61,9 @@ CASES = {
'generator-settings': '{"layers":[{"block":"minecraft:glass","height":5}],"biome":"nowhere"}',
},
'normal': {'level-type': 'minecraft:normal', 'level-seed': 'abc', 'generate-structures': 'false'},
'amplified': {'level-type': 'minecraft:amplified', 'level-seed': '1'},
'large-biomes': {'level-type': 'minecraft:large_biomes', 'level-seed': '2'},
'single-biome': {'level-type': 'minecraft:single_biome_surface', 'level-seed': '3'},
'unknown': {'level-type': 'Some Type', 'level-seed': '42'},
}
WORKER = re.compile(r'\[Worker-Main-\d+/(WARN|ERROR)\]: (.*)')
@@ -155,9 +158,10 @@ def main():
schema=1, minecraft=lock['minecraft']['version'], platform=platform.platform(),
java=subprocess.check_output(['java', '--version'], text=True).splitlines()[0],
cases=list(CASES), lines=len(vanilla), output_sha256=lab.digest(build / 'horizon.txt', 'sha256'),
scope='a new dedicated-server world: level-type (flat, normal, unknown), a flat world\'s '
'generator-settings, generate-structures, as world_gen_settings.dat and the lines logged; without '
'structures, locate structure\'s replies and the saved chunks\' structure starts and references.'))
scope='a new dedicated-server world: level-type (flat, normal, amplified, large_biomes, '
'single_biome_surface, unknown), a flat world\'s generator-settings, generate-structures, as '
'world_gen_settings.dat and the lines logged; without structures, locate structure\'s replies and '
'the saved chunks\' structure starts and references.'))
print(f'server world gen: {len(CASES)} cases, {len(vanilla)} lines identical')