Compare commits

...
Author SHA1 Message Date
dnorberg 38e40ddba3 Port seeded sky colors and Normal Time celestial controls to 26.3
Build / build (push) Canceled after 0s
2026-11-03 16:56:07 -05:00
dnorberg a7a73d3c17 Port Age lighting and animated sky styles to 26.3 beta
Build / build (push) Canceled after 0s
2026-11-02 15:49:58 -05:00
dnorberg 48fbae9be9 Port structures, weather, vegetation and safe surface arrivals to 26.3 beta
Build / build (push) Canceled after 0s
2026-11-02 09:30:22 -05:00
dnorberg c223f3fdca Port authored Age features and Star Fissures to 26.3 beta
Build / build (push) Canceled after 0s
Advance to 1.0.1.54-beta.1 after player validation. Port material, vegetation, lake, terrain-volume, crystal, Dense Ores and fissure features with saved plans. Update beta documentation and release notes.

Refs #98, #104
2026-11-02 01:51:20 -05:00
77 changed files with 6429 additions and 258 deletions

No files matched your search

+13 -1
View File
@@ -7,9 +7,21 @@ of [Mystcraft Legacy](https://github.com/Mystcraft/Mystcraft-Legacy) to
Minecraft 1.21.1 on NeoForge. Mystcraft veterans will recognize the writing
flow, D'ni-inspired glyphs, libraries, crystals, and instability. Rivencraft
also lets you author modern terrain, biomes, structures, seasons, and celestial
bodies. It is an active pre-public-release project, not a byte-for-byte copy of
bodies. It is an independently developed project, not a byte-for-byte copy of
the 1.12.2 mod.
The gameplay guide below describes the **Minecraft 1.21.1 release**. A separate,
incomplete **Minecraft 26.3 beta** is available: Java 25, NeoForge 26.3.0.99,
and a first-party Age backend (no Dynamic Dimensions or Bad Packets). It now
supports authored terrain/biomes, structure and vegetation pages, independent
Age weather/lighting, seeded and colorable Overworld/Nether/End/Static skies,
Normal Time length/phase/direction/bearing and sun-size controls,
and a growing set of generation features,
but does not yet provide full 1.21.1 gameplay or optional-integration parity.
See the [26.3 beta guide](ports/mc263/README.md) and
[latest beta release notes](ports/mc263/RELEASE_NOTES_1.0.1.78-beta.1.md).
Keep the builds and their saves separate, and back up worlds before updating.
## Getting started
1. Make an Ink Vial from two black dyes, a glass bottle, and a water bucket.
+784
View File
@@ -522,6 +522,787 @@ terrain, biome, and structure generation. Cave and Void are intentionally
excluded until their feature placement rules are ported. This is not yet the
full 1.21.1 feature grammar or instability system.
The local `1.0.1.42-beta.1` follow-up resolves unmodified Rocks to a
seed-shuffled, full-block material and accepts up to 32 ordered Rocks pages.
Each page adds an independent, deterministic surface placement attempt.
Explicit Block modifiers still bind only to the immediately following Rocks
page. The material sequence persists in the Age index; the codec also reads
the single-material format from the previous beta. The remaining feature
families and instability scoring are not yet ported.
The local `1.0.1.43-beta.1` slice uses 26.3's `minecraft:forest_rock`
placed-feature rules and `BlockBlobFeature` for authored Rocks, supplying the
Age's selected material and removing only the vanilla biome filter. This
replaces the port's temporary 3×3 pile algorithm and follows the 1.21.1
generator's authored-placement approach. It affects new chunks only.
The local `1.0.1.44-beta.1` slice extends this Age-scoped feature wrapper to
Flowers, Trees, and Huge Trees. Ordered authored pages retain independent
attempts and use vanilla 26.3 placed-feature rules after biome decoration.
The vegetation plan is persisted alongside terrain, biome, and Rocks plans;
the dedicated-server smoke compares generated flowers and logs against a
same-seed Flat control and checks persistence after restart.
The local `1.0.1.45-beta.1` slice adds authored Dungeons separately from
surface vegetation. Its 26.3 placed-feature JSON mirrors the 1.21.1 eight
monster-room attempts over the world height; ordered duplicate pages add
attempts. The count persists in the Age index and generator codec. The server
smoke creates and samples a dungeon Age, counts monster spawners across a
4×4-chunk area, and verifies the count after restart. Room appearance and
normal book authoring remain in-game checks.
The local `1.0.1.46-beta.1` slice ports Surface Lakes, Deep Lakes, and Small
Lakes, including ordered Fluid modifiers. The 26.3 LakeFeature retains its
vanilla shape and replacement predicates while using the Age's frozen fluid
provider. Surface placement keeps Rivencraft 1.21.1's one-in-twelve rarity;
underground lakes use vanilla placement. Repeated unmodified pages resolve
their fluids independently from a seeded, shuffled source-fluid pool. Lake
plans participate in restore, unload/reload, reseed, and retirement rollback.
The server smoke compares water generation against a same-seed control and
checks explicit/random plans and fluid blocks across restart.
The local `1.0.1.47-beta.1` slice adapts the 1.21.1 FloatingIslandFeature
algorithm to 26.3 chunk access and the biomes already assigned to each chunk.
It retains source range 3, one-in-128 candidate rarity, Y=168..215 center
selection, radii 24..41, coordinate-noise contours, air-only filling, and the
biome-dependent soil cap. It supports Flat/Void in addition to noise terrains;
Block modifiers and independent seeded random materials are frozen in an
ordered terrain-feature plan. Rocks and islands share material resolution.
26.3's final NoiseBasedChunkGenerator merges noise, surface, and carving and
does not expose a pre-surface hook. This port appends islands to the completed
terrain future before decoration; existing terrain is preserved, but base
carvers do not cut the added islands. The smoke verifies actual high-altitude
material placement on noise, Flat, and Void terrain, distinct random material
choices, authored biome preservation, and block fingerprints across restart.
The local `1.0.1.48-beta.1` slice ports TendrilFeature's Legacy-derived
branching solid volumes using the 26.3 chunk mutation and height APIs. Source
range 8, rate 15, size 18, nested node/height selection, branch splitting, and
bedrock preservation match the 1.21.1 implementation. Chunk-local writes and
neighbor-source seeds preserve continuous paths across chunk borders.
The existing terrain-feature plan now supports Tendrils on all terrain types,
including Cave and Void. Compilation retains mixed feature order and applies
the 32-occurrence limit per feature. Block modifiers and seeded random choices
use the shared solid-material resolver; existing save/lifecycle handling carries
both feature types. World validation is manual at the player's request; no
development server is started for this slice.
The local `1.0.1.49-beta.1` slice ports the Legacy-derived Skylands filter
from Rivencraft 1.21.1 with 26.3 chunk height/mutation APIs. The cutoff uses
the same salt, seven octaves, initial cell width 128, amplitude 12, and base
Y=76. Solid/liquid removal and world-height coverage match the reference.
It runs once after base terrain construction and before authored solid
volumes, retaining the feature ordering used by the 1.21.1 implementation.
Skylands is a material-free feature, accepted alongside every terrain type.
Repeated pages retain the same idempotent effect. The existing terrain-feature
plan stores an optional Skylands flag, defaulting to false for earlier beta
Ages; no separate backend/lifecycle path is introduced. A Skylands-only plan
can contain no material choices. Build and static checks are automated; world
generation and restart validation are left to the player.
The local `1.0.1.50-beta.1` slice adapts the 1.21.1 SpheresFeature and
SpikesFeature to 26.3. Spheres retain their source range 8, five-percent
source chance, nested Y selection starting at 32, node geometry, and bedrock
protection. They run after the base terrain/Skylands filter and work on every
terrain type. Spikes retain their one-in-18 chunk chance, 6..37 height roll,
width 1..4, solid-footprint checks, and chunk-local writes during decoration.
They remain unavailable on Cave and Void terrain, matching 1.21.1.
Both page types use ordered saved solid-feature choices, preceding Block
modifiers, and independent seeded materials/occurrences. No backend lifecycle
branch or generated artwork is added. Compilation and static review are the
automated checks; the player handles generation and restart validation.
The local `1.0.1.51-beta.1` slice ports ObelisksFeature using the 26.3 chunk
coordinates and world-height APIs. The site salt, seed mixing, one-in-128
chance, chunk-local site selection, 4×4 plinth, and 2×12×2 shaft match 1.21.1.
Every footing column must be sturdy, dry, and free of block entities; the
base surface can vary by at most two blocks and the full shaft must be open.
No partial structure is written when a placement check fails.
The page joins the existing material-feature plan and shared material resolver.
It runs during surface decoration, with its own occurrence counter, on all
surface terrain types including Flat; Cave and Void remain unsupported.
Spikes and Obelisks use shared stage/restriction classification. Build/static
checks are automated; generation and restart checks remain with the player.
The local `1.0.1.52-beta.1` slice ports CrystalFormationsFeature directly
from 1.21.1 with only registry, ChunkPos, and height API substitutions. It
retains the site salt, one-in-fifteen rarity, 3×3 ground/clearance checks,
three-block center height, randomized outer heights, budding hearts, and
initial small buds. Existing blue crystal growth, sprites, drops, and sounds
are reused without adding assets.
Crystal Formations is an ordered surface feature with fixed crystal material,
not a consumer of Block/Fluid modifiers. It shares the existing saved feature
plan and surface-terrain restrictions (including Flat, excluding Cave/Void),
with independent occurrence seeds and the current 32-page cap. No backend
lifecycle changes are needed. The Obelisks slice passed the player's in-game
check; Crystal Formations build/static checks pass, with generation, growth,
and restart validation handed to the player. No test server is started.
The local `1.0.1.53-beta.1` slice adapts 1.21.1 AgeDenseOres to modern
`AbstractOreFeature` targets and `FeaturePlacer.placeWithBiomeCheck`. It keeps
the common ore tag/name fallback, explicit `rivencraft:dense_ores` placed-
feature opt-in tag, nearby biome discovery, stable registry-ID ordering,
and independent per-occurrence seeds. Registered modded ore features remain
eligible without depending on an ore-providing mod. Biomes and ore placements
are collected once per chunk rather than once per repeated page.
The optional Dense Ores count is saved in the existing terrain-feature plan;
older plans default to zero. Grammar accepts 1–32 occurrences on noise terrain
(including Cave), rejects Flat/Void and material modifiers, and checks explicit
biome pools for at least one eligible ore feature. No extra lifecycle path or
asset is introduced. Random feature selection and instability remain later
port work. Crystal Formations passed the player's in-game check; this slice
passes build/static checks, with ore generation and restart validation manual.
The local `1.0.1.54-beta.1` slice ports Star Fissures. Its footprint matches
1.21.1 exactly (including Legacy attribution); carving uses the dimension's
minimum Y, clears fluids, and protects Cave terrain's roof. The authored flag
is optional in the existing feature-plan codec. Duplicate pages retain the
1.21.1 single-fissure result; all terrains are accepted, with no material
modifier. Random feature selection is not introduced in this slice.
First arrival generates the fissure and stores its spawn/completion in per-Age
SavedData. Re-entry/restart does not repeat carving; an obstructed saved arrival
is rechecked for safe footing. The return block ports modern entity-inside and
TeleportTransition APIs, has a registered Rivencraft block-entity type, and
uses Minecraft's starfield renderer with 1.21.1 face offsets. Shared Overworld
spawn safety search and fallback match 1.21.1, rather than respawning at a bed.
The existing fissure OGG/model are reused. Client renderer registration stays
client-only; the block has no item/drop and is inert in the Overworld.
Dense Ores passed the player's in-game check. Build, footprint comparison,
resource packaging, and static checks pass for this slice. Generation, visual
rendering, actual return travel, and restart checks are left to the player;
no development server is started.
The local `1.0.1.55-beta.1` slice begins the authored structure-set port with
Villages, Mineshafts, and Strongholds. Saved page occurrences use the existing
feature plan's new optional structure list. Terrain gates, random-spread
frequency/spacing scaling, and bounded concentric-ring count/spread follow
1.21.1. Modern `AbstractSpreadingStructurePlacement` accessors replace the old
placement-class accessors. Scaled holder copies never mutate global registries.
Structure-state planning includes authored eligibility biomes while continuing
to sample the actual terrain source. A scoped Structure mixin carries over
1.21.1's authored biome override. The wrapper handles structure starts itself
when a structure plan exists, allowing the hook to recognize the authored Age;
unrelated/unplanned generators remain delegated. Only these three controlled
families are gated in this slice; remaining structure families, random picks,
and biome-mismatch instability remain unported. Existing plans without the
optional list retain their previous structure path. Mod metadata now correctly
calls this target Beta rather than Alpha.
The player passed Star Fissures. Build/static checks pass for this new slice.
A disposable dedicated-server startup reached `Done` without mixin-loading
errors. Its console `/stop` command hit a null-level CommandSourceStack error;
the exact test JVM was then stopped through SIGTERM and saved/shut down. This
was a startup check, not authored generation or density validation. The
disposable world is removed; client authoring, actual structures, density,
biome overrides, and restart checks are handed to the player.
The local `1.0.1.56-beta.1` follow-up corrects the structure biome override.
Client logs confirmed the build-55 mixin was applied, but 26.3 JigsawPlacement
checks `GenerationContext.couldStructureExistInColumn` inside generation,
before the final check bypassed by the 1.21.1 hook. The replacement ModifyArg
changes only the biome predicate passed to the GenerationContext constructor
in Structure.generate for explicitly authored Age structures. Both early
and final checks now see the override; all other generation constraints and
unrelated generators retain their existing behavior.
Build/static checks pass. An isolated `--initSettings` bootstrap initialized
settings without a mixin application error, then FML exited nonzero because
that settings-only mode creates no server thread. This is not a full server
or generation pass. No world was created, and the temporary settings directory
is removed. In-game validation requires a new Age or ungenerated chunks;
previously rejected chunks are not regenerated. No commit/release is made.
The local `1.0.1.57-beta.1` slice completes the existing 1.21.1 structure-page
mapping (Dungeons remains its separate placed feature) and enables 26.3's
Abandoned Camps page. The shared planner now recognizes 15 structure sets.
Page/terrain restrictions match AgeStructureSelection for the 1.21.1 families;
Abandoned Camps follows surface noise terrain. No material modifiers are added.
The separate Fortress/Bastion set resources are reused directly from 1.21.1.
The controlled-set gate includes the vanilla combined Nether set, preventing
unrequested cross-family placement. All structure pages share the previously
validated saved list, density logic, and corrected context-level biome override.
Unknown non-spreading placements are left unchanged rather than cast unsafely.
Unmodified/no-authored-structure Ages retain their prior native path; previously
saved authored plans now gate all supported families in new chunks. Instability
and random feature selection remain pending.
The player passed the build-56 biome fix. Build, diff checks, all 15 registry
and Creative-page mappings, and target structure resource existence checks pass.
Packaged Fortress/Bastion definitions match 1.21.1 byte-for-byte. No server is
started; actual structure placement, terrain-specific limits, repeated density,
and restart validation are handed to the player. Changes remain local.
The local `1.0.1.58-beta.1` slice ports Caves/Ravines feature pages separately
from Cave terrain. A new optional saved carver-page list participates in the
existing feature-plan lifecycle. Grammar accepts noise terrain including Cave,
rejects Flat/Void and material modifiers, and checks the modern CARVER registry.
26.3 combines carving with terrain construction and exposes carvers that emit
geometry rather than mutate blocks. A scoped NoiseBasedChunkGenerator mixin
adds authored geometry to its native CarvingMask immediately before that mask
is tested/applied. It only acts for the matching runtime Age wrapper, using
its frozen seed and actual biome resolver. Vanilla applies the combined mask
with its own aquifer/surface rules; no closed NoiseChunk is accessed afterward.
The source range, salts, Nether cave selection, and Nether ravine skip match
1.21.1. Duplicate Caves/Ravines repeat identical seeds in the baseline, so the
port avoids redundant identical mask passes while retaining authored pages.
Added terrain volumes still follow the combined terrain operation, as noted
for the earlier Floating Islands port.
The player passed structure slice 57. Build/static checks and packaging checks
for the hook, Creative entries, and three required vanilla carvers pass. A
settings-only bootstrap completed initialization without a mixin application
error; FML then returned nonzero because no server thread exists in that mode.
This is not world-generation validation. No world was created; the temporary
settings directory is removed. Actual carving and restart checks are manual,
and no commit/push/publication is performed.
The local `1.0.1.59-beta.1` slice replaces stale per-page port information
and adds the requested red hue for unsupported pages. A shared
PortAuthoredWriting.isPageImplemented classifier uses the same terrain,
controller, material/biome, and feature recognizers as the grammar, without
misclassifying context-dependent modifiers/controllers by testing them alone.
Page tooltips, the extracted item-render color, and the Binder background use
this single availability check. Tinting does not generate extra textures or
change stored page data. Corrupted-page names/descriptions remain obfuscated.
Port language overrides remove the old Standard/one-Biome claim, distinguish
implemented/unimplemented pages and invalid combinations, and correct all
15 structure-family descriptions, material adjacency, and selected terrain/
feature restrictions. The 1.21.1 language assets are unchanged. This is a UI
and information slice, not new generation functionality.
The player passed Caves/Ravines. Build, diff checks, unique language-key checks,
and packaged-language override checks pass. No server/world is started.
Inventory tint, Binder tint/layering, and tooltip readability await the
player's visual check. Changes remain local and unpublished.
The local `1.0.1.60-beta.1` slice implements the four requested 26.3-only
vegetation pages: Poplar Trees, Fallen Poplar Trees, Red Shrubs, and Shelf
Mushrooms. They extend PortVegetationPlan recognition, so grammar, saved plans,
Creative status, item tint, and tooltip availability agree without another
page list. The existing ordered vegetation cap and surface-terrain/biome
restrictions are retained. The 1.21.1 code/assets are unchanged.
Poplar Trees selects only the three vanilla poplar variants, retaining their
relative native weights and six placement attempts. Fallen poplars reuse the
vanilla feature but add authored in-square/surface placement before its sapling
check; the vanilla placed feature alone has no surface-height placement. Red
Shrubs directly reuses its registered patch. No tree/shrub algorithm or artwork
is duplicated.
Shelf Mushrooms is a tree decorator in 26.3, not a placed feature. The authored
page therefore applies ShelfMushroomDecorator to existing connected log hosts
at its position in the vegetation sequence. Section palettes skip log-free
sections, repeated pages reuse the host scan, and tree placements invalidate
that cache. Host ordering and page seeds are deterministic; callback writes
are chunk-local, fluid-free, supported, and height-bounded. The decorator keeps
its native standing/fallen logic and side/age/adjacency checks. This is an
authored extension using the native decorator, not a new vanilla feature ID.
Build/static and resource-reference checks pass. The four pages' descriptions
explain their actual effects and requirements. No server/world is started;
the player validates vegetation appearance, support/tint, ordering, and restart.
Changes remain local and unpublished.
The local `1.0.1.61-beta.1` follow-up separates log-hosted Shelf Mushrooms
from surface plants. A shared requiresSurface classification now governs
terrain/explicit-biome validation; decoration handles Shelf Mushrooms before
the surface-biome skip. Trees, shrubs, and flowers retain their restrictions.
Void/Cave/End-biome log hosts are no longer excluded by the vegetation path.
The host scan is a stable log-position list rather than connected tree groups.
Native tree decorators assume a meaningful base and only decorate a limited
height band, which is unsuitable for log tendrils. The authored extension
therefore checks exposed log faces at all heights, reusing the native decorator's
replaceability/water predicate and block facing/age/survival rules, with spacing
and chunk bounds. Cached scans still avoid repeated full-volume work. No tree
page or log-producing page is required, but actual log hosts must exist during
chunk generation. This supersedes slice 60's whole-tree height-band behavior.
Build/static checks pass. Manual validation: new Void + Oak Log + Tendrils +
Shelf Mushrooms Age, without any tree page; inspect exposed log faces and
restart/new-chunk generation. No server is started and nothing is published.
The player passed the local `1.0.1.62-beta.1` sound slice: the complete 1.21.1
sound manifest/OGGs are reused, book travel emits modifier-selected audio and
cloud bursts, and Star Fissures play audio after arrival as well as departure.
Portal/meteor events are registered but their gameplay ports remain separate.
The local `1.0.1.63-beta.1` slice adds authored Normal/Fast/Slow/Off/Always/Storm
weather policies. One policy is accepted per book, with availability/tint and
tooltips following the shared classifier. Cave terrain always suppresses it.
An omitted policy defaults to Normal for now; random-trait selection is pending.
Unlike 1.21.1, 26.3 ServerLevel.getWeatherData delegates to the server's shared
weather object. A scoped Runtime Age ServerLevel subclass instead loads native
WeatherData from that Age's data storage, alongside its saved policy. Native
weather ticking and client packets remain in charge; only the data owner and
Legacy-style duration policy change. The constructor replaces inherited global
rain/thunder visual levels before publishing the level. Retirement, reseeding,
reload and restart retain the policy through existing per-Age storage lifecycle.
Vanilla /weather remains a global server command; the Age policy is not a
global command override. Native biome precipitation rules still apply.
Build/diff checks pass. A disposable dedicated server created all six weather
policies: every Age reported independent native data and the expected flags/
duration ranges. Setting global thunder left the Off Age clear. Unload/reload
retained Storm, and a full stop/restart restored all six policies and their
live timers. Both servers shut down cleanly; the temporary test world is
deleted after completion. Client appearance, cross-Age travel, authoring/tint,
and Cave suppression remain manual checks.
No commit/push/publication is authorized by the player's testing pass alone.
The local `1.0.1.64-beta.1` follow-up fixes weather-only authoring. Build 63's
server weather checks bypassed the Binder's biome compiler, whose exclusion
list did not yet include weather. As a result weather/storm was incorrectly
resolved as a biome ID despite grammar acceptance. The biome compiler now
selects actual biome-category entries after full-book grammar validation;
controllers retain their separate handling. This avoids repeating the same
missed-exclusion bug as more non-biome page families are ported. The operator
weather fixture now calls that compiler before backend creation. All six
weather-only symbol lists compiled and created their expected weather states
on a fresh dedicated server. Build/diff checks pass. The server shut down
cleanly and its disposable world was removed; player GUI validation remains.
The local `1.0.1.65-beta.1` spawn correction traces non-Cave arrival columns
from maximum build height, inspecting actual fully generated chunk blocks.
The configured Flat-layer shortcut could select ground below later authored
terrain, and the old heightmap-only path did not explicitly ensure an exposed
surface. The new path stops at the first fluid/leaves/collision, requires dry
footing and headroom, and otherwise tries the next column rather than searching
below an obstruction. Chunk lookup is cached per column and a mutable position
avoids per-air-block allocations. A fallback platform goes above the actual
highest obstruction; empty Void retains floor Y=63. Surface footing accepts
leaves, rejects fluid-bearing floors, and checks build/border bounds.
Stored non-Cave Star Fissure arrivals also use the surface search rather than
accepting old underground pockets. Cave terrain keeps its Y=64-down search.
Exact bound Linking Books and the general fissure Overworld-return anchor
search are not redirected to the sky. A read-only surface probe command helps
validate the chosen column/nearby surface (chunk generation is possible).
Build/diff checks pass. A disposable dedicated-server probe selected Y=4 on
plain Flat ground, Y=141 above leaves placed at Y=140, and a neighboring dry
column instead of descending below water added above those leaves. A larger
roof-fixture fill was rejected by vanilla because some target chunks were not
loaded; that fixture is not claimed as tested. The server shut down cleanly,
and its temporary world was deleted. Actual player arrival in the affected
Age and the unchanged Cave/Linking Book paths remain manual checks.
The local `1.0.1.66-beta.1` slice ports the three 1.21.1 lighting controls.
Normal surface types keep the host Overworld holder; Dark disables skylight,
Bright sets ambient light to 0.25. Cave Bright is explicitly supported with
0.25 ambient and no skylight; Cave Normal/Dark have 0.0 ambient and no skylight.
The native vanilla registry-sync path supplies types to clients before travel.
Generated data derives three variants from the exact 26.3 vanilla Overworld
resource, preserving every key except has_skylight and ambient_light. The
generator has declared inputs/outputs and depends on Minecraft artifacts.
The Age index has an optional lighting map, defaulting old entries to Normal.
Creation supplies the choice before constructing the level and saving the
index. Restore/reload/reseed read it from the index; retirement removes it and
failed retirement restores it before reloading. Pages share grammar/tint and
the positive biome-page filter, avoiding the weather-only compiler regression.
Missing lighting stays Normal until the random-trait port; Bright instability
is pending with the instability system. No sky/celestial renderer is added.
The static variants derive vanilla data, not arbitrary data-pack overrides
to the host's Overworld type; broader custom type inheritance is not claimed.
Build/diff checks and generated-data equality checks pass (only the two
lighting fields differ from vanilla). A disposable server with a normal
Overworld compiled all six surface/Cave lighting-page combinations and
reported the expected type, skylight and ambient values. Cave Bright survived
unload/reload and reseeding, and all six choices survived a full restart.
Overworld skylight/ambient stayed unchanged. An earlier Superflat-host fixture
could test surface variants but could not create Cave terrain because the
existing backend requires a noise-based host generator; it was discarded and
the Cave checks ran on a fresh normal-host fixture. Test servers stopped
cleanly and both temporary worlds were deleted. Client-side appearance,
authoring, travel, placed lights and multiplayer registry behavior are manual
checks, not inferred from the server probes. No commit/push/publication.
The local `1.0.1.67-beta.1` corrects the visual failure found in slice 66:
LightmapRenderStateExtractor and lightmap.fsh use AMBIENT_LIGHT_COLOR, while
dimension ambient_light only affects other brightness queries. Merely porting
1.21.1's 0.25 field was insufficient. Bright types now provide white native
ambient color and ambient_light 1.0; Cave Bright retains no skylight. A scoped
NeoForge spawn-placement handler denies natural/chunk-generation MONSTER
spawns in Bright runtime Ages, leaving passive/existing mobs, spawners,
commands/eggs and event spawns untouched. It does not manufacture propagated
block light or alter crop light checks. This supersedes slice 66's Bright
appearance and natural-spawn limitation; existing Bright types update on boot.
Requested follow-up for the color-page port: add ambient-light color as a
separate authorable target using minecraft:visual/ambient_light_color. Keep
the existing number/color mixing semantics and white Bright default; do not
confuse ambient tint with sky/celestial color or its server spawn policy.
Color pages themselves remain unported in this slice.
Build/diff and generated attribute checks pass. A disposable dedicated server
verified the registered event handler denies a successful-default natural
zombie placement in surface/Cave Bright, leaves spawner zombies and natural
cows unchanged, and leaves Normal unaffected. Those are isolated event-policy
probes, not a long-running natural-spawn census. The server shut down and its
temporary world was deleted. Actual client appearance awaits player validation.
The local `1.0.1.68-beta.1` removes the requested-against MobSpawnEvent veto.
An Age-scoped LevelLightEngine.getRawBrightness return modifier contributes
Bright's full ambient minimum of 15 to combined brightness on both sides.
Vanilla Monster.isDarkEnoughToSpawn now sees that light, rather than being
overridden or filtered by mob category/spawn reason. Light-ignoring mobs and
special spawns retain vanilla rules; no spawner exemptions are introduced.
Block/sky storage and propagation remain physical, while combined-light
consumers (including plant checks) see ambient light. Cave Bright retains
no skylight, and the native white render attribute remains. This supersedes
slice 67's separate spawn policy and unchanged-crop-light behavior.
The lighting probe now calls vanilla's darkness predicate and combined
brightness instead of constructing synthetic spawn events. Ambient-color
authoring remains a planned part of the color-page port.
Build/diff checks pass. A disposable dedicated server loaded the required
mixin and reported effective brightness 15 / vanilla darkness false for
surface/Cave Bright, versus 0 / true for Dark. These are direct brightness/
vanilla-predicate probes, not a natural-spawn census or a fully generated
underground-room test; an unloaded Normal column returned native sky light 15.
The server stopped cleanly and its temporary world was deleted. Client visual
validation remains with the player; no publication is performed.
The local `1.0.1.69-beta.1` slice completes missing weather/lighting choices
at book binding. PortEnvironmentSelection resolves explicit supported pages
first and uses independent seed-salted Legacy-rank rolls for omissions.
Normal is rank 1, remaining supported modes rank 2: 3:1:1 lighting and
6:1:1:1:1:1 weather. These weights derive from the active enum pool and reuse
the existing weighted ticket routine, rather than duplicating random-choice
logic or enabling unported Cloudy/Rain/Snow modes. It is rank-policy parity,
not exact seed-stream parity with 1.21.1's broader trait pool.
The selected weather/lighting are passed through the existing save-before-
catalog creation path. Restoring Ages never calls the resolver, so old Ages
retain their saved values. Cave weather is always Off; Cave lighting remains
independent and supports Bright. Explicit developer fixtures remain controlled,
but create_random now uses the same resolver as pageless books. A read-only
environment probe checks seed repeatability and explicit overrides. Superseded
Normal-only fromPages helpers are removed. Random feature, sky/celestial,
blocklist policy and instability work remain pending.
Build/diff checks pass. Disposable dedicated-server probes repeated seeds
0/4/7, verified explicit overrides and independent one-field completion, and
confirmed Cave weather Off with Bright lighting. Three random Ages saved
Always/Normal, Normal/Dark and Fast/Bright respectively; weather/lighting
choices and live weather timers persisted through a full server restart.
Overworld weather/type stayed unchanged. The server stopped cleanly and its
temporary world was deleted. Player book-authoring/visual checks remain
manual. No commit, push or publication is performed.
The local `1.0.1.70-beta.1` slice ports native Overworld/Nether/End sky styles.
The shared grammar accepts one style and the biome compiler ignores it by
category. Sky is saved in an optional Age-index map with Overworld fallback;
restore/reseed/retirement rollback use the saved choice. It does not alter
the missing-weather/lighting seed-completion rules, add random sky selection,
or enable Static Sky/color/individual-celestial pages prematurely.
The dimension-data generator now produces 11 sky/lighting variants from the
vanilla Overworld baseline. It changes visual skybox/fog/cloud settings only
in addition to lighting. Geometry, scale, ceiling, monster settings, default
clock and gameplay attributes are retained; no Nether gameplay or End dragon
fight is inherited. A derived native day timeline omits only sky/fog/cloud
color tracks for non-Overworld styles, preserving schedules and illumination.
Native End/no-sky rendering needs no optional renderer or new artwork. The
Legacy animated Static Sky renderer cannot be replaced by native flat gray
without losing its behavior, so its page remains red/unimplemented.
Bright detection includes all registered sky variants, preserving both white
ambient rendering and the effective brightness floor for surface/Cave Bright.
Data equality checks confirm all 11 variants preserve geometry/gameplay and
the day timeline keeps native tracks except the three excluded visual colors.
Its global time markers remain defined only by minecraft:day, not duplicated
in the derived timeline. The grouped timeline list is registered as a tag.
Client appearance and multiplayer travel validation remain manual.
Registry validation initially caught an invalid inline tag reference and
duplicate global clock markers in the derived day timeline; both were fixed
before handoff. The sky diagnostic also now supplies a position when sampling
the spatial fog attribute. A disposable dedicated server then restored all
sampled types after restart, including Nether normal/Bright and End normal/
Cave Bright, with the expected reddish/purple fog at the sample position,
unchanged geometry/gameplay flags, and effective light 15 on Bright variants.
Cave Bright End retained its type after unload/reload; a reseeded Nether Age
retained its style after restart. Servers stopped cleanly and temporary test
directories/worlds were deleted. These are server/data checks, not a rendered
client pass. No commit/push/publication is performed.
## Static Sky renderer slice
`1.0.1.71-beta.1` ports the 1.21.1 Legacy-style animated Static Sky, superseding
the previous unsupported/red status. The six independent 48×48 noise faces
use the existing default gray tint (146 per channel), noise range 65–224,
and a two-world-tick refresh. A cached 24-vertex cube and a single 288×48
dynamic texture use the native textured sky pipeline instead of rebuilding
thousands of colored cells every frame. No new asset or dependency is needed.
Client-only mixins carry the animation frame in extracted sky state, reset
it with the state, and own/close GPU resources with SkyRenderer. Static types
have native skybox NONE; the extracted state alone requests a sky pass and
the custom renderer replaces it. End flashes and End gameplay are not enabled.
Normal/Dark/Bright/Cave Bright variants preserve the existing lighting policy,
geometry and gameplay. Existing persistence handles STATIC as a sky enum value.
Random sky selection, color pages, and individual celestial bodies remain
separate unfinished work; this slice does not claim complete sky parity.
Verification: port build passed (no automated unit tests configured). A
disposable dedicated server created Static Standard Normal and Static Cave
Bright Ages, confirmed their gray fog, NONE native skybox and correct skylight/
ambient values, then restored both choices after restart. It stopped cleanly;
the temporary server/world was deleted. Client animation, travel between sky
styles, and restart appearance still require manual validation. No user world
was modified, and nothing was committed, pushed or published.
## Seeded sky completion slice
`1.0.1.72-beta.1` completes missing sky controllers using the first seeded
selection from 1.21.1 `AgeSkyProfile.random` (`0x534B5950524F464C` salt).
Inherited and Overworld currently map to the same native Overworld style;
the effective Overworld/Nether/End/Static weights are 2:1:1:1. This deliberately
ports only style selection, not random colors or celestial-body lists.
The player book path and `create_random` fixture now share one environment
record containing weather, lighting and sky. Explicit pages win. Separate
seed streams keep controllers independent, including when one is supplied
explicitly, and Cave weather remains Off. Existing Age index values/defaults
remain authoritative; this does not reroll old Ages or reseeded Age skies.
The one-purpose controlled fixtures keep their explicit Overworld defaults.
The environment probe checks repeatability and every explicit sky override
with and without explicit lighting/weather. Manual seeds 0–11 and -1 covered
all four sky choices, Cave and Void, without changing established weather/
lighting results; signed-long boundary seeds also passed. Disposable-server
fixtures created all four random styles plus Bright Static and an explicit
Overworld override. Static survived unload/reload; Nether retained its saved
style after reseeding to an End-selecting seed. All six choices survived a
server restart. The server stopped cleanly and its temporary world was deleted.
The port build passed (no unit-test sources are configured). Player rendering
still needs validation of new book creation and travel. No user save is changed
and nothing is committed or published.
## Shared colors and ambient-light target slice
`1.0.1.73-beta.1` adapts the 1.21.1 `AgeSkyProfile.ColorAccumulator` rules:
Number 0–24 followed by Red/Green/Blue adds ten times that amount to one
channel, White adds to all, Black subtracts from all, and final channels are
clamped. Unnumbered colors use the same seeded index-salt approach. Color
groups preceding Sky pages, or trailing the book, tint the sky. No colors
means native rendering; omitted channels within an authored group are zero.
The new port-only `lighting/ambient_color` target consumes the preceding group
as ambient color instead. It appears in the existing Lighting creative tab
and uses the shared procedural glyph/item compositor, not generated artwork.
Profiles are frozen in the Age index with native defaults, retained on reload/
reseed/retirement rollback, and included in the full catalog. The catalog
protocol is now 2; both peers need the matching beta. The client replaces its
profile map before catalog acknowledgment, invalidates the current attribute
cache and clears the map on disconnect. Age-only default-layer hooks apply
the native sky/fog/ambient RGB attributes dynamically; vanilla dimensions are
not changed. No runtime dimension registry mutation or RGB variant explosion
is involved. Client sky state carries tint separately from the live world:
Static cells use that tint, and the native End texture uses a color modulator.
Native celestial rendering is otherwise unchanged; authorable bodies remain
pending. Explicit ambient tints do not alter the effective Bright floor or
fabricate block/sky light; native lightmap tinting controls their appearance.
Numbers are supported only before shared colors in this slice. Celestial
numeric modifiers and variable Static resolution (#109) remain unsupported.
Random sky color completion and deprecated legacy fixed-color aliases remain
pending; this is not a claim of full celestial/color parity. Rendering needs
manual checks of authored groups, all sky types, ambient color, no leakage on
travel/reconnect, and persistence. Dedicated-server fixtures exercise known
RGB results, zero/upper amounts, clamping, seeded repeatability, rejection of
dangling numbers, and a catalog encode/decode round trip. All four colored
styles and an uncolored native fixture restored correctly after restart with
the expected sampled RGB attributes. The server stopped cleanly and its
temporary world was deleted. Port build/static checks passed; there are no
unit-test sources configured. Client rendering and live multiplayer sync
remain manual checks. No commit, push, or publication was requested.
## Authored native day-cycle slice
`1.0.1.74-beta.1` ports Normal Time with Full/Half/Double/Zero Length modifiers,
using the fixed values from the 1.21.1 symbol definitions. Number pages before
Length apply the existing `CelestialPageValues` rule (`number × 0.08` days).
Zero chooses a seeded time once and saves it. Missing Normal Time retains the
native cycle. The strict interim grammar requires at most one Length preceding
one Normal Time; broader ordered recovery and multiple celestial bodies remain
separate work. Number targets other than colors and lengths are still unported.
The Age index persists a native-default day-cycle profile. Lifecycle load,
reseed, retirement/rollback and catalog sync include it. Catalog protocol 3
carries the new profile map; clients replace it before acknowledgment, clear it
on logout and invalidate current attributes/sky brightness. Both peers need
the same beta build. A small Age-only Builder mixin preserves native dynamic
timeline ordering and weather layers, but supplies a read-only wrapped clock
manager for this Age's timeline samples. Its clock instances are cached per
definition, read profiles lazily (including during level construction), and
delegate non-Overworld clocks untouched. No global clock mutation occurs.
Native day shading, sun/moon tracks, moon phase, and gameplay attributes in
those timelines share the scaled sample. Zero has a constant integer sample,
rate zero, and partial tick zero, preventing the prior stopped-time bounce.
This does not accelerate blocks/entities, change the native `/time query`
result, make a Nether/End/Static sky show bodies, or port custom bodies. Global
time commands still affect moving samples. Authored RGB overrides and existing
Bright/Dark behavior remain independent. Separate Sun/Moon, angle/phase/size/
direction, eclipses, and randomized celestial completion are still pending.
Fixtures validate parser eligibility, known half/double/zero clock values,
Number overrides, deterministic stopped time, and the shared catalog codec.
At shared native time 18022 the sampled Full time was 18022 (sky light 4),
Half 36044 (sky light ~13.92), Double 9011 (sky light 15), and Zero stayed at
11611 with its unchanged sun angle. An unmodified Age matched Full exactly.
Zero survived unload/reload and reseeding without rerolling; the color fixture
retained its RGB values. All authored profiles, numeric endpoints 1/24, zero
override, native fallback, and the color fixture restored after server restart.
The final port build passed (no unit-test sources configured). Test servers
stopped cleanly; their temporary world was deleted. Visual/client reconnect
checks remain manual. No commit/push/publication is performed without an
explicit request.
## Native cycle phase and direction slice
`1.0.1.75-beta.1` extends the existing day-cycle profile with a saved native
clock origin, phase offset and direction. Phase conversion is adapted directly
from 1.21.1 `AgeDimensionService.applyInitialCelestialPhase`: the symbolic
angle is measured from Nadir, then shifted 18000 native ticks. Rising/Zenith/
Setting/Nadir give 0/6000/12000/18000, and preceding Number pages override a
Phase using the `CelestialPageValues` 15-degree rule (24 wraps to 0). Length
and phase remain independent. Explicit phase starts at Age creation, not at
the current Overworld position divided by Length.
Moving samples are `direction * (nativeTicks - origin) / period + phase`;
Counterclockwise reverses the native day timelines, including moon-phase and
gameplay tracks. Stopped samples remain constant and prefer written phase,
otherwise the previous saved seeded stopping time. Existing profiles without
the new optional fields retain their former behavior. Saved/profile lifecycle
and catalog synchronization already carry the extended codec. Catalog protocol
4 requires matching client/server beta versions. No registry types, textures,
or global clock mutations are added. Angle/size/per-body color and multiple
celestial rendering remain pending, as do eclipses and random celestial
completion. #110's Time/Normal Length naming changes remain separate work.
The interim grammar accepts at most one Length, Phase and Direction preceding
one Normal Time; numbers currently target Color/Length/Phase only. Fixtures
check all phase mappings at a shared origin, forward/reverse deltas, stopped
behavior, parsing of the earlier three-field profile, and full catalog codec
round trips. At native tick 1518, profiles sharing origin 496 and noon offset
sampled 7022 forward and 4978 backward, with sun angles ~10.756° and ~349.244°.
Zero Zenith stayed at 6000 / 0° / sky light 15; Zero Nadir stayed at 18000 /
180° / sky light 4. Number 6 yielded sunrise; Number 24 yielded midnight.
The reverse profile survived unload/reload; reseeding retained frozen midnight.
All sampled profiles restored after a server restart, retaining their origins,
direction and fixed phases; the old-style seeded Zero fixture also retained
its saved stopping time. Build checks passed (no unit-test sources configured).
The test server stopped cleanly and its temporary world was deleted. Rendered
motion and reconnect behavior remain manual. No commit/push/publication is
performed without explicit direction.
## Native orbital bearing slice
`1.0.1.76-beta.1` ports the four Angle pages and the existing 1.21.1
`CelestialPageValues` number rule (15° per number, modulo 360). North/East/
South/West map to 0/90/180/270. The interim grammar accepts one Angle before
Normal Time, independent of Length, Phase and Direction. Missing Angle keeps
the native orientation rather than enabling random-bearing completion.
The cycle codec adds an optional bearing defaulting to -1 (native). Existing
origin/phase/direction defaults and lifecycle persistence remain unchanged.
Catalog protocol 5 carries the extended profile; older peers must update.
Extracted sky state carries/resets bearing. Native SkyRenderer's Y rotation
for the shared sun/moon/star path uses the explicit value; the sunrise/sunset
pose receives the corresponding delta from the native -90° body bearing, with
balanced pose-stack restoration. No live-world lookup occurs during drawing.
Colors, native geometry/textures, phase timing, illumination, and direction
are retained. Angle changes only horizontal orientation, not altitude. Body
counts/sizes/colors and eclipses remain pending; no extra rendering backend,
asset or registry variant is introduced. #110 is deliberately not implemented
as part of this port slice.
The port build passes (no unit-test sources configured), and the targeted
PoseStack invocation signature was checked against the patched native class.
Disposable-server fixtures exercise cardinal values, all 0–24 Number values,
six-field profile fallback, and the complete catalog stream codec. All four
frozen-sunrise fixtures sampled the same time 0 and sky light ~14.19; only
their saved bearings differ. Number 3 gave 45°, Number 24 gave 0°. Unwritten
bearing retained -1. East survived unload/reload; the 45° profile retained its
bearing after reseed. Client rendering/glow alignment and travel isolation
still require manual validation; server probes cannot prove the drawn result.
All six explicit bearings and the native fallback restored correctly after
restart. The test server stopped cleanly and its temporary world was deleted.
No commit/push/publication is performed without an explicit request.
## Celestial-compatible missing-Sky fallback
`1.0.1.77-beta.1` restricts the missing-Sky fallback for celestial writing to
the current renderer's compatible style, Overworld. Normal Time no longer
inherits an unsuitable seeded Nether/End/Static background. The resolver
also recognizes Sun/Moon intentions for future support without enabling
those pages prematurely. Explicit Sky pages always win; no-celestial random
selection, terrain, and independent weather/lighting streams are unchanged.
No packet or saved-profile format changes are needed. Existing Ages retain
their saved style; tests of the fix must create new Ages.
1.21.1 comparison: `AgeSkyProfile.compile` starts with inherited sky style;
the bundled Age profiles all use the Overworld dimension type. Authored Sun/
Moon selects `AgeEnvironmentCompiler.CUSTOM_SKY_EFFECTS`, whose renderer draws
the body list after its background without a sky-style exclusion. Thus the
bundled 1.21.1 paths already avoid this visibility regression, and no follow-up
issue or root-code change is needed for them. This is not a claim about every
possible third-party datapack profile.
The environment diagnostic checks missing/explicit Sky for Normal Time, Sun,
and Moon, including both explicit-page orders, and verifies other environment
choices are unchanged. The day-cycle fixture now supplies no Sky page and
uses the same environment resolver as player books. Client creation/travel
checks remain manual. Build and disposable-server creation checks passed:
Normal Time without Sky selected native Overworld for seeds 0, 6 and 8,
while the same seeds without celestials retained Nether, Static and End.
Explicit-Sky precedence and independent environment choices passed the
diagnostic checks, including Cave terrain. The server stopped cleanly and
its temporary world was deleted. No commit/push/publication is performed
without an explicit request.
## Normal Time sun size
`1.0.1.78-beta.1` ports Small/Normal/Large Celestial Size modifiers to the
native Normal Time sun. Presets are 0.5×/1×/2×. An immediately preceding Number
uses the root compiler's `0.25 + number * (2.75 / 24)` rule, covering 0.25×
through 3×. Root Normal Time creates a size-modified sun and a 1× moon; this
slice preserves that distinction rather than resizing both. Standalone Sun/
Moon controllers, multiple bodies, per-body colors and eclipses remain pending.
No random size completion is added yet.
`PortDayCycle` persists optional `sun_size`, defaults old profiles to 1×,
validates the supported range and transmits it in catalog protocol 6. Size
is extracted into the sky render state and multiplies only the native sun
transform. Native texture/glow and rendering pipeline remain; no new assets
or buffers are allocated. State resets/defaults prevent size leaking to other
dimensions. Clock sampling, illumination and orbit bearing are unaffected.
The `create_size` op fixture freezes the sun at Zenith with Zero Length and
omits Sky to exercise compatible completion. Diagnostic checks cover all
25 numeric sizes, presets, older profile defaults, unchanged timing and
catalog round trips. Disposable dedicated-server checks produced sizes
0.5/1/2/0.25/1.625/3 with the same noon angle and sky light; reload, reseed
and full restart retained them. Build passed. Visual size/glow alignment,
unchanged moon, and travel isolation still require the user's client check.
The server stopped cleanly and its temporary world was deleted. Nothing is
committed, pushed or published without an explicit request.
## Verification of this first slice
The 1.21.1 `compileJava` and `build` tasks passed offline after the backend
@@ -530,3 +1311,6 @@ extraction. The GameTest server ran 93 tests; 92 passed, while
desks instead of one. That desk failure is outside the dimension-backend edit
and must be resolved or rechecked before a release. The 1.21.1 `build` task
passed again after the 26.3 registry slice.
On 2026-11-01, the default 1.21.1 GameTest server was rerun and all 93
required tests passed, including the desk test. Issue #97 was then closed;
remaining 26.3 parity work stays under #98–#104.
+739 -3
View File
@@ -4,9 +4,9 @@ This is an **experimental beta port** for issues #97–#104, not gameplay parity
with the Minecraft 1.21.1 release. The 26.3 backend persists separate Ages and
their seeds. Books, the Book Binder, and the Ink Mixer work, and the Binder can
author Standard, Amplified, Island, Flat, Void, or Cave terrain with supported
biome-distribution pages. Other terrain families, feature/material effects,
portals, and much of
the 1.21.1 gameplay remain unported. Use a test world and keep a backup; do not
biome-distribution pages, selected material/vegetation/structure features, and
independent Age weather and lighting. Celestial systems, instability, crystal
portals, and much of the 1.21.1 gameplay remain unported. Use a test world and keep a backup; do not
substitute this JAR for the 1.21.1 mod or import 1.21.1 saves.
The entries below record how the port was built, including earlier alpha-only
@@ -211,6 +211,742 @@ first surface-feature path; unsupported combinations stay in the Binder.
Random feature selection, unpaired materials, duplicate feature pages, and
instability scoring remain future port steps.
The local `1.0.1.42-beta.1` follow-up lets a Rocks page choose a deterministic
full-block material when no Block page precedes it. Repeated Rocks pages now
add independent placement attempts (up to 32) instead of being rejected. The
ordered material choices are frozen in the Age index and restored after a
restart; old single-material beta Ages still load. An explicit Block page
must still directly precede the Rocks page it modifies. Most other feature
pages and material-feature pairings remain unported.
The local `1.0.1.43-beta.1` slice replaces the temporary 3×3 Rocks piles
with Minecraft 26.3's forest-rock block-blob feature and its placement rules,
matching the 1.21.1 Age feature path more closely. The selected material and
repeat count still come from the frozen Age plan. Only newly generated chunks
use the new rock shape; previously generated chunks remain unchanged.
The local `1.0.1.44-beta.1` slice ports ordered Flowers, Trees, and Huge
Trees pages to surface Ages. Their 26.3 vanilla placed features run after
biome decoration, with the Age's own seed; repeated pages make independent
placement attempts. Cave/Void terrain and cave/Nether/End biome pools are
not treated as surface vegetation. The feature plan survives server restart;
already generated chunks do not gain new decoration.
The local `1.0.1.45-beta.1` slice ports the Dungeons page for Standard,
Amplified, and Island noise terrain. It uses the 1.21.1 placement definition
(eight monster-room attempts per page per chunk across the build height),
stores repeat count with the Age, and rejects Dungeons pages on Flat, Void,
or Cave terrain. New chunks only; existing chunks are not regenerated.
The local `1.0.1.46-beta.1` slice adds Surface Lakes, Deep Lakes, and Small
Lakes on surface noise terrain. A Fluid page immediately before a lake page
selects its fluid. Unmodified pages choose a seeded fluid from the same source-
fluid pool exposed in Creative, with repeated choices and their order saved
with the Age. Surface/Small Lakes retain the 1.21.1 one-in-twelve attempt rule;
Deep Lakes retain vanilla underground placement. These pages currently require
Standard, Amplified, or Island terrain. Existing chunks do not redecorate.
The local `1.0.1.47-beta.1` slice adds authored Floating Islands alongside
Standard, Amplified, Island, Flat, and Void terrain. A preceding Block page
sets the island core material; otherwise each occurrence resolves a seeded
material from the full-block pool. The 1.21.1 shape, high altitude, rarity,
and biome-dependent grass/dirt cap are retained. Repeated pages use independent
island seeds. Cave terrain remains unsupported for this feature.
26.3 combines base noise, surface, and cave carving in one terrain operation.
Floating Islands run after that operation and before decoration, so the added
islands are not cut by the base terrain's carvers. The saved feature plan is
used again after restart and for new chunks; existing chunks keep their blocks.
The local `1.0.1.48-beta.1` slice adds authored Tendrils on every terrain
type, including Cave and Void. A preceding Block page selects the material;
unmodified occurrences resolve seeded materials from the full-block pool.
Repeated pages use independent paths, with up to 32 occurrences per feature.
Mixed Floating Islands/Tendrils retain their authored order in the saved plan.
Tendrils retain the 1.21.1 branching path algorithm, source rates, and vertical
distribution concentrated below Y=128. They can replace existing terrain while
preserving bedrock. As with Floating Islands, they run after 26.3's combined
terrain operation and before decoration. Build checks are automated; in-game
generation, appearance, and restart validation are handed to the player.
The local `1.0.1.49-beta.1` slice adds the Skylands feature page. Its seeded
seven-octave filter removes base terrain at/below a varying cutoff near Y=76
and removes liquid above it, preserving the upper solid terrain. It accepts
all terrain types and no Block modifier. On low Flat terrain this removes
the base layers; on Void it leaves the empty base unchanged.
The filter runs once before authored Floating Islands and Tendrils, matching
1.21.1; duplicate Skylands pages do not compound the filter. Its flag is saved
with the terrain-feature plan. Existing chunks are unchanged. Try Amplified
terrain for a clear visual check; generation and restart validation are manual.
The local `1.0.1.50-beta.1` slice ports Spheres and Spikes using the 1.21.1
generation algorithms. Spheres add solid nodes on all terrain types, including
Cave and Void, while preserving bedrock. Spikes require solid surface terrain:
Standard, Amplified, Island, or Flat. Their circular footprint stays in one
chunk and must have solid ground beneath every column.
Each page accepts a preceding Block material or resolves a seeded random
material when unmodified. Repeated pages have independent placement seeds,
up to 32 per feature. Spheres run during terrain construction, after Skylands;
Spikes run after biome decoration. Both use the existing saved solid-feature
plan. New chunks only; in-game appearance and restart checks remain manual.
The local `1.0.1.51-beta.1` slice ports Obelisks. It preserves the 1.21.1
one-in-128 candidate chance per chunk/occurrence, 4×4 plinth, and 2×2 shaft
extending 12 blocks above it. Placement requires dry, sturdy ground across
the base, at most two blocks of surface variation, and clear/replaceable
space for the complete structure. The footprint stays within its chunk.
Obelisks support Standard, Amplified, Island, and Flat terrain. A preceding
Block page sets their material; otherwise a seeded full-block material is
selected. Repeated pages make independent attempts, up to 32 occurrences.
Spikes and Obelisks share surface-stage classification and preserve their
authored order within that stage. World and restart validation are manual.
The local `1.0.1.52-beta.1` slice ports Crystal Formations using the 1.21.1
surface-mound algorithm and the existing blue crystal blocks. Each occurrence
has a one-in-fifteen candidate chance per chunk; suitable dry, nearly level
ground produces a 3×3 mound with budding crystal and occasional small buds.
The existing budding blocks continue growing crystals through random ticks.
Crystal Formations accepts Standard, Amplified, Island, and Flat terrain,
but not Cave/Void terrain or Block/Fluid modifiers. Up to 32 repeated pages
make independent attempts. The ordered feature plan survives restart, and
only newly generated chunks receive formations. For a quick manual test,
bind a Flat/Plains Age with several Crystal Formations pages, inspect the
blue mounds and bud growth, then revisit after restarting the world.
The local `1.0.1.53-beta.1` slice ports Dense Ores. Each page replays eligible
ore placements from the Age's actual biomes, preserving their height ranges,
replacement rules, and biome filters. Vanilla and modded ore features are
recognized by their ore targets; custom placements can opt in through the
`rivencraft:dense_ores` placed-feature tag, just as in 1.21.1.
Up to 32 pages add independent passes on Standard, Amplified, Island, or Cave
terrain. Flat/Void and Block/Fluid modifiers are unsupported; an explicitly
authored biome pool must contain at least one ore-producing biome. The count
survives restart. Only new chunks gain extra ore, and this slice does not yet
port the later instability system or random-Age feature selection.
For manual validation, create a Standard/Plains Age with several Dense Ores
pages and inspect underground ore abundance, then restart and explore more
chunks. A Cave Age with a Nether biome can check Nether ore placements too.
The local `1.0.1.54-beta.1` slice ports the Star Fissures page. On first book/
operator arrival, it carves the 1.21.1 Legacy-shaped rift near the arrival chunk
and places a starfield portal at the dimension's minimum Y. Cave terrain keeps
its bedrock roof. All terrain types are supported; repeated pages still create
one fissure, and Block/Fluid modifiers are not accepted.
The first arrival and completed generation are saved per Age, so revisits and
restarts do not recarve the rift. Touching its bottom returns entities to a safe
location near the shared Overworld spawn, not a bed, with the original fissure
sound. The portal is unobtainable and inert in the Overworld. Random-Age feature
selection remains later work; author a Star Fissures page to test this slice.
For manual validation, create a Flat/Plains Age with Star Fissures. Look near
the arrival point (generally east/south), descend through the rift, and touch
the starfield to return. Re-enter after restarting; check that the same fissure
and its portal still work. A Cave Age checks that carving leaves the roof intact.
The local `1.0.1.55-beta.1` slice adds authored Villages, Mineshafts, and
Strongholds. Repeated pages (up to 32 per type) scale the registered placement
using the 1.21.1 frequency/spacing and ring-count rules. Copies are Age-local:
the Overworld's placements and biome source are not modified. Authored
structures can bypass their usual biome eligibility without changing the
terrain biome; their instability cost remains pending with the instability port.
Villages and Strongholds allow surface noise terrain and Flat, but not Cave
or Void. Mineshafts allow noise terrain including Cave, but not Flat or Void.
These pages do not accept Block/Fluid modifiers. Their ordered counts persist
in the feature plan. In books using these structure pages, unselected sets
from these three families are disabled; other structure families and random
feature selection remain later port work. Earlier books without this new
structure plan retain their existing native structure behavior.
For manual checks, bind a new Standard/Plains Age with Villages, Mineshafts,
and Strongholds, then use `/locate structure minecraft:village_plains`,
`/locate structure minecraft:mineshaft`, and
`/locate structure minecraft:stronghold` to inspect generated structures.
Compare one page with several copies in a different Age, and restart/revisit.
Flat/Plains with Villages and an otherwise incompatible surface biome with
Villages provide separate terrain and biome-override checks.
The local `1.0.1.56-beta.1` follow-up fixes authored structures outside their
normal biomes. Build 55 only bypassed the final biome check; 26.3's jigsaw
placement can reject a whole biome column earlier. The override now supplies
the generation context's biome predicate before either check, only for the
explicitly authored structures in that Age. Terrain/height/geometry rules and
unrelated structures keep their normal checks.
Test with a new Standard or Flat Age using a normally unsupported village
biome, such as The End, plus a Villages page. Multiple copies increase density.
Use `/locate structure #minecraft:village` rather than selecting only the
Plains variant: an override Age can choose other village styles. Old chunks
that already rejected their structure attempt are not regenerated; existing
build-55 Ages can also test the fix in previously unexplored chunks.
The local `1.0.1.57-beta.1` slice adds the remaining 1.21.1 authored structure
families: Nether Fortresses, Bastions, Trial Chambers, Buried Treasure,
Shipwrecks, Ocean Ruins, Pillager Outposts, Ruined Portals, Nether Fossils,
Ancient Cities, and Trail Ruins. It also enables the existing 26.3-only
Abandoned Camps page using Minecraft's registered camp set and variants.
All 15 supported structure-set pages share saved counts, biome overrides,
and repeated-page density scaling (up to 32 per type). Fortress and Bastion
sets reuse the separate 1.21.1 placement definitions; the combined vanilla
Nether set is disabled for authored structure plans so the pages stay separate.
Unselected supported families are gated in these plans. Already generated
chunks and Ages without an authored structure plan keep their existing path.
Only Villages/Strongholds support Flat; none support Void. Fortress, Bastion,
and Nether Fossils require Cave terrain. Villages/Strongholds, Buried Treasure,
Shipwrecks, Ocean Ruins, Outposts, Trail Ruins, and Abandoned Camps require
surface terrain. Mineshafts, Trial Chambers, Ancient Cities, and Ruined Portals
allow surface noise or Cave terrain. Physical terrain/height checks still apply
even when the authored page overrides biome eligibility. Instability penalties
and random selection of these features are not part of this slice.
For a quick in-game check, author a new Standard Age with Trial Chambers,
Trail Ruins, and Abandoned Camps. Use `/locate structure minecraft:trial_chambers`,
`/locate structure minecraft:trail_ruins`, and
`/locate structure minecraft:abandoned_camp_forest` to inspect actual generation.
Separately, test a Cave/Nether Wastes Age with Fortress and Bastion pages
(`/locate structure minecraft:fortress` and
`/locate structure minecraft:bastion_remnant`). Repeated pages and restart/
new-chunk generation remain useful checks. Structures can still fail when
their geometry or required terrain is unavailable.
The local `1.0.1.58-beta.1` slice enables Caves and Ravines feature pages.
They add carving to the chosen noise terrain (Standard, Amplified, Island,
or Cave); they are not aliases for the Cave terrain page. Flat/Void and
Block/Fluid modifiers remain unsupported, matching the 1.21.1 feature rules.
Authored paths retain 1.21.1's source range and seeded salts, using 26.3's
registered cave/Nether-cave/canyon carvers. Nether-biome sources select the
Nether cave carver and skip Ravines. The paths merge into the native carving
mask before Minecraft applies aquifer, replacement, and surface rules; no
second approximation carves raw air after terrain generation. Biomes are not
replaced. The page list is saved, and old plans default to no extra carving.
Duplicate pages are accepted up to 32 per type, but repeat the same seeded
pattern in 1.21.1; this port skips those redundant passes rather than multiplying
carving density. Random feature selection and instability remain pending.
New chunks only. For manual testing, author a Standard/Plains Age with one
Caves page and one Ravines page and inspect underground in Spectator mode.
Restart and explore more chunks. A separate Cave/Nether Wastes Age with Caves
checks the Nether variant without altering its biome distribution.
The local `1.0.1.59-beta.1` slice audits page presentation for the current port.
Each Symbol Page now reports whether its effect is implemented. Unported pages
have red-tinted item artwork and a red Binder page background, plus a red status
line. Supported pages retain their normal artwork. This indicates port
availability only: supported pages in an invalid order, terrain combination,
or excessive quantity are not individually tinted red. Link Panels are unchanged.
The stale Standard/one-Biome message is removed. Structure descriptions now
describe authored biome overrides rather than falsely requiring vanilla biome
eligibility. Material pages explain the immediate-before-feature requirement;
Flat/Caves/Ravines/Floating Islands descriptions reflect the current rules.
These overrides are specific to the 26.3 language file and do not change 1.21.1.
No new generation effect is introduced in this UI slice.
For manual validation, compare a working Villages or Caves page with an
unported celestial or lighting page in Creative inventory
and the Binder. Only the latter should be red; supported Block/Fluid modifiers
and biome controllers should stay normal even though they need other pages.
The local `1.0.1.60-beta.1` slice implements Poplar Trees, Fallen Poplar Trees,
Red Shrubs, and Shelf Mushrooms. They use the existing saved vegetation plan,
ordered attempts, surface-biome rules, and combined 32-vegetation-page limit.
They accept Standard, Amplified, Island, and Flat terrain, but not Cave/Void
or Block/Fluid modifiers. The four pages now show as supported, without a red tint.
Poplar Trees uses the vanilla red/orange/yellow leaf-litter tree variants with
their relative Dappled Forest weights and six attempts per page per chunk;
it does not also select spruce or fallen trees. Fallen Poplar Trees makes one
surface attempt per page, using the vanilla fallen-tree feature, decorators,
and sapling survival rules. Red Shrubs uses the vanilla patch placement.
All still require suitable ground and space; authoring does not bypass those
requirements, and existing chunks are not redecorated.
Shelf Mushrooms applies Minecraft's standing/fallen-log decorator to existing
log groups, not a new tree feature. It can decorate natural trees or logs from
earlier authored tree pages; no suitable logs means no mushrooms. The vanilla
side/age/spacing and water-avoidance logic is retained. Writes stay within the
current chunk and require valid support. Consecutive mushroom pages reuse the
host scan; adding trees invalidates the cached host list for subsequent pages.
For manual testing, bind a new Flat/Plains Age with Poplar Trees, Fallen Poplar
Trees, Red Shrubs, then Shelf Mushrooms. Check leaf colours, fallen trunks,
shrubs, and mushrooms on logs. The four pages should no longer be red. Restart,
revisit existing terrain, and explore new chunks to check persistence.
The local `1.0.1.61-beta.1` follow-up removes the inappropriate surface-terrain
and surface-biome gates from Shelf Mushrooms only. No tree page is required:
natural logs or generated log Tendrils/Spheres/other features are hosts on
any terrain, including Void and Cave, and in any biome. Other plants keep their
existing terrain/biome restrictions. For example, Void + Oak Log + Tendrils +
Shelf Mushrooms is valid authoring; there must still be exposed log faces.
The mushroom scan now considers log faces throughout the chunk height instead
of treating a connected tendril as a standing tree with a small base-height
decoration band. Placement retains native replaceability/water checks, valid
support, random age, a per-side chance, and spacing checks. It does not grow
hosts or continuously decorate player-built logs after chunk generation.
Test a new Void Age with the example pages and inspect tendril surfaces.
The local `1.0.1.62-beta.1` sound slice reuses the complete 1.21.1 sound manifest
and all six OGG files directly in the build. Successful Linking/Descriptive Book
travel now plays the normal, Intra-Linking, or Disarm sound at departure and
arrival, with the same modifier precedence and cloud bursts as 1.21.1. Failed
travel stays silent. Star Fissures also play their return sound after arrival,
instead of relying only on audio sent immediately before a dimension switch.
Portal and meteor sound events are bundled for their later gameplay ports;
this does not implement those systems. Binding still uses vanilla page audio.
Manual checks: normal book travel, Intra-Linking, Disarm, rejected travel, and
a Star Fissure return. Existing books/Ages do not need to be recreated.
The local `1.0.1.63-beta.1` slice ports the six active weather pages: Normal,
Fast, Slow, Off, Always, and Storm. One weather page is accepted per book and
its policy is saved in the Age's own data directory. Missing weather pages
currently use Normal; random weather selection is still part of the pending
random-trait port. Cave terrain forces Off, matching 1.21.1. Precipitation
still follows biome temperature and precipitation rules; a desert does not
start showing rain merely because Always or Storm is selected.
26.3 normally shares server WeatherData. Runtime Ages now use an isolated
native WeatherData instance with its own persisted rain/thunder timers. The
weather controller runs before the native cycle, allowing vanilla rendering,
lightning, snow and client dimension-specific packets to handle the result.
Fixed weather is refreshed before its timers expire; Fast and Slow use the
1.21.1 duration ranges and preserve saved timers on reload. Overworld weather
and other Ages are not changed. The vanilla `/weather` command still targets
the server's global weather, not the independent Age policy.
Operator diagnostics: `/rivencraft_port age create_weather <name> <seed> <mode>`
creates a Flat test Age; `/rivencraft_port age weather_probe <name>` reports
its policy, rain/thunder state and timers, data independence, and global state.
For manual testing, bind separate Storm/Always/Off books with Plains, compare
the Overworld, revisit and restart, and check that Cave + Storm remains dry.
The six weather pages should no longer be tinted red. Existing Ages get a
Normal independent cycle; author a new book to select another policy.
The local `1.0.1.64-beta.1` fix corrects the Binder's biome compiler: it now
positively selects biome-category pages instead of treating every page not
in an exclusion list as a biome. Weather pages no longer trigger an unknown
biome lookup. The weather fixture command now exercises the same page compiler
before creating an Age, covering the weather-only symbol list used by a Link
Panel + Storm book. Existing Binder contents can be reused after updating.
The local `1.0.1.65-beta.1` spawn correction removes the Flat-layer shortcut
and heightmap-only choice for non-Cave Descriptive Book arrivals. The search
starts at the actual maximum build height and traces each nearby column down
to its first colliding block, leaves, or fluid. A column without safe dry
footing/headroom is skipped instead of descending through its roof or water
into an underground pocket. Leaves are valid footing; fluids are not.
This also revalidates a stored Star Fissure arrival from above on non-Cave
terrain. Cave terrain retains its Y=64-downward search, and bound Linking
Books retain their exact coordinates. If no natural surface is safe, the
safety platform is placed above the actual highest obstruction (or floor
Y=63 in an empty Void), never buried below it. Plain Flat terrain still lands
on its ground, not an artificially elevated platform. No new Age is required.
Operator-only `/rivencraft_port age surface_probe <name> <x> <z>` reports the
actual highest obstruction and a safe nearby surface without creating a
platform or teleporting. It can generate chunks while inspecting the region.
The local `1.0.1.66-beta.1` slice ports Normal, Dark, and Bright Lighting.
One lighting page is accepted per book and its choice is saved in the Age
index before catalog publication. Missing lighting currently defaults to
Normal; random lighting selection and Bright's instability cost await those
systems' later ports. Lighting pages should no longer be tinted red.
Normal surface Ages retain the Overworld type. Dark removes skylight with
ambient light 0.0; Bright uses ambient light 0.25 and retains surface skylight,
matching 1.21.1. **Cave + Bright is supported:** it has ambient light 0.25
without skylight. Cave Normal/Dark use no skylight and ambient light 0.0.
Placed light sources continue working; ambient light is not a placed light
level or a guarantee that monsters cannot spawn.
The build derives three registered dimension-type variants from the current
vanilla Overworld JSON, changing only has_skylight and ambient_light. This
preserves native attributes/timelines and synchronizes types through the
normal login registry exchange; no dynamic client registry mutation is used.
These variants currently use the vanilla type, not custom runtime changes to
the host Overworld dimension type made by another data pack.
Saved lighting survives reload, reseed, restart and retirement rollback.
Existing non-Cave Ages without a choice remain Normal; old Cave Ages now use
the no-skylight baseline. Use new Ages for clear visual comparisons and avoid
confusing previously lit chunks with the current dimension settings.
Manual check: create Normal/Dark/Bright surface books and Cave + Bright;
compare an unlit enclosed room, check placed torches, revisit/restart and
confirm the Overworld stays unchanged. Operator diagnostics:
`/rivencraft_port age create_lighting <name> <seed> <mode> <terrain>` and
`/rivencraft_port age lighting_probe <name>` report the registered type,
skylight and ambient light. Test JAR: `1.0.1.66-beta.1`.
The local `1.0.1.67-beta.1` correction supersedes slice 66's Bright rendering:
26.3's lightmap uses `minecraft:visual/ambient_light_color`, not the old
dimension `ambient_light` field. Bright surface/Cave types now set that native
attribute to white and ambient_light to 1.0. Cave still has no skylight.
Bright Ages reject natural/chunk-generation hostile spawns independently of
stored light levels. Existing mobs, passive mobs, spawners, eggs, commands
and special-event spawns are not deleted or globally disabled. Actual block
light propagation and plant light requirements are unchanged. Existing Bright
Ages receive the corrected registered types after restart; no new Age needed.
Planned color-page target: **ambient light color**, alongside sky/celestial
colors. Reuse the native 26.3 attribute for authored tints; Bright defaults to
neutral white until the color-page port. This does not enable color pages yet.
The local `1.0.1.68-beta.1` replaces slice 67's special spawn veto with actual
effective ambient brightness. The spawn event handler is removed. Bright's
combined light-engine reading now has a minimum of 15 on server and client,
including after sky darkening. Its white rendering attribute is retained.
Vanilla darkness checks therefore reject light-sensitive monsters naturally;
light-ignoring mobs, spawners and other spawn sources use their native rules,
not Rivencraft exemptions or bans. This does not remove existing mobs.
Ambient light is not stored as emitted block light or fabricated skylight.
Consumers of combined brightness, including plant light checks, now see the
ambient contribution. Normal/Dark and non-Rivencraft dimensions are unchanged.
Cave Bright remains without skylight. Existing Bright Ages update on restart.
This supersedes slice 67's statement that crop light checks are unchanged.
The local `1.0.1.69-beta.1` slice seed-completes missing weather and lighting
pages for newly bound books, including Link-Panel-only random Ages. Explicit
pages take precedence. Cave weather remains Off, while Cave lighting can
still choose Bright. Old Ages retain their saved choices; they are not rerolled.
The supported pool uses the 1.21.1 Legacy-rank rule: Normal is rank 1, other
choices rank 2. Lighting weights are 3:1:1 for Normal/Dark/Bright; weather
weights are 6:1:1:1:1:1 for Normal/Fast/Slow/Off/Always/Storm. The common weight
is derived from the supported pool size. Unported Cloudy/Rain/Snow modes are
not selected. Independent seed salts keep a missing lighting roll unchanged
when weather is explicitly written, and vice versa. This matches the rank
policy, not the exact 1.21.1 seed stream/pool. No random feature list, sky or
celestial choices are added by this slice.
`/rivencraft_port age create_random <name> <seed>` uses the same environment
resolver and logs its frozen plan. `/rivencraft_port age environment_probe
<seed> <terrain>` inspects deterministic defaults and explicit Storm/Bright
precedence without creating an Age. Other explicit operator fixtures keep
their Normal defaults to isolate the feature under test.
For manual checking, create several Link-Panel-only books and verify weather/
lighting varies across Ages and persists on revisit/restart. Then author an
explicit Normal Weather + Normal Lighting book, and a Cave + Bright book.
Include explicit Normal pages when comparing terrain without random lighting
or weather changes. Random blocklist policy and instability remain pending.
The local `1.0.1.70-beta.1` slice ports the Overworld, Nether, and End Sky
pages. One sky style is accepted per book and persisted independently of
terrain, weather and lighting. Missing sky pages still use Overworld; random
sky selection is not part of this slice. Static Sky remains unsupported/red
until its Legacy animated renderer is ported, rather than being replaced by
a flat gray backdrop. Color and individual celestial pages are also pending.
Nether Sky uses the native no-sky backdrop and reddish fog; End Sky uses the
native End backdrop. Neither changes dimension height, coordinate scale,
terrain, Nether water/bed rules, or enables an End dragon fight. All lighting
choices work with these styles, including Cave + Bright. Bright's render
attribute and effective ambient level 15 are retained on each variant.
The registered variants derive the vanilla Overworld type and preserve its
gameplay attributes. Non-Overworld skies use a derived day timeline without
the three sky/fog/cloud tint tracks, retaining native gameplay schedules and
illumination while avoiding Overworld day tint on those backdrops. Their
cloud color is transparent. Biome/weather fog modifiers still apply. Types
sync through normal registry login; no new rendering dependency is required.
Sky choices survive reload/reseed/restart and retirement rollback. Old Ages
without a sky choice retain Overworld style.
For visual checks, author Plains + Normal Lighting + No Weather with each
sky page; compare the sky/fog and verify the same terrain and normal bed/water
behavior. Then try Cave + Bright + End Sky and revisit after restarting.
Static Sky should remain red. Operator fixtures:
`/rivencraft_port age create_sky <name> <seed> <sky> <lighting> <terrain>` and
`/rivencraft_port age sky_probe <name>`. Use new books to author a sky choice;
this does not rewrite the sky choice of already-bound books.
The local `1.0.1.71-beta.1` slice adds the previously pending Static Sky.
Its client renderer adapts the 1.21.1 Legacy-style background: six independent
48×48 noise faces, default gray tint `#929292`, and noise intensity 65–224.
The noise updates every two world ticks. A single small dynamic texture and
cached cube replace thousands of per-cell draw vertices; resources are closed
with the sky renderer. No artwork or rendering dependency is added.
Static Sky is now supported rather than red, and persists through the same
Age index as other sky styles. Four registered lighting variants retain the
existing terrain/gameplay rules. Their native skybox is NONE (no End flashes);
only the extracted client state schedules the custom background sky pass.
Animation state resets between frames and does not consult a different live
world while rendering. Color pages, celestial pages, and random sky selection
remain pending.
Build and disposable dedicated-server creation/restart checks passed for
Static + Normal + Standard and Static + Bright + Cave. These do not validate
client rendering. For manual testing, bind a new Static Sky + Normal Lighting
+ No Weather book, look around/above/below for animated gray static, then
visit an Overworld/End Sky Age and return to check there is no sky-state leak.
Revisit after restarting. Also try Static Sky + Cave + Bright (look outside
terrain or in spectator mode). The fixture accepts `static` as its sky value.
The dedicated test server was stopped and its temporary world deleted.
Changes remain local; no commit/push/publication was requested.
The local `1.0.1.72-beta.1` slice completes seeded default sky selection for
new Ages. Missing sky pages now choose Overworld, Nether, End, or Static
instead of always Overworld. This applies both to Link-Panel-only books and
authored books without a sky controller. An explicit sky page always wins.
The selection uses the first random draw and seed salt from 1.21.1's
`AgeSkyProfile.random`. Its equally likely Inherited and Overworld slots both
use the native Overworld presentation in this port, giving effective weights
2:1:1:1. Sky uses an independent random stream, so adding sky/weather/lighting
controllers does not reroll the other choices, terrain, or biomes. Colors,
individual celestial bodies, and random feature completion remain pending.
The chosen sky is saved at Age creation and is not recalculated on travel,
reload, restart, or reseed. Existing Ages (including ones with no saved sky
entry) keep their previous presentation. Explicit diagnostic fixtures retain
their controlled defaults; `create_random` uses the same complete environment
resolver as player books. `environment_probe` now also checks every explicit
sky override and cross-controller independence.
For manual checking, create several new Link-Panel-only books and compare
their sky styles. Also author a terrain/biome book without a sky page, and one
with an explicit Overworld Sky page. Revisit/restart to check persistence.
For repeatable operator fixtures, `create_random <name> 0` selects Nether,
seed 2 selects Overworld, seed 6 Static, and seed 8 End. These seeds only test
the current port selection, not complete 1.21.1 world-generation equivalence.
No commit/push/publication is performed until explicitly requested.
The local `1.0.1.73-beta.1` slice ports shared Red/Green/Blue/White/Black
colors and their Number modifiers using the 1.21.1 additive mixing rules.
Numbers 0–24 directly before a color contribute `number × 10`; White adds to
all three channels and Black subtracts from them. Missing channels begin at
zero, groups can be written in any channel order, and the completed result is
clamped to 0–255 per channel. A color without a number gets a deterministic
seeded amount. Numbers currently require a following color: celestial numeric
targets and Static Sky resolution (#109) are not implemented by this slice.
Color groups preceding a Sky page tint that sky; a trailing color group also
tints the sky, matching the existing 1.21.1 behavior. The new **Ambient Light
Color** page (Lighting creative category) consumes the preceding color group
for ambient illumination instead. Author Bright separately when full effective
light level 15 is wanted; this color target does not change stored block light
or the selected lighting controller. A target without a color group leaves
that color native. For example:
`Number 24 → Blue → Overworld Sky → Number 24 → White → Ambient Light Color → Bright`
This gives a blue sky/fog with white ambient illumination. Do not use an
unmodified Blue page when an exact amount is desired: its amount is seeded.
Explicit colors are saved once; they survive reload/reseed/restart. Existing
Ages without a color profile remain native. There are no random color groups
for books that do not contain color pages yet.
Sky colors override the native sky/fog color attributes, tint Static Sky's
animated cells, and multiply the native End background texture. End texture
detail/brightness remains native; a tint does not make it a flat solid color.
Ambient color uses 26.3's native ambient-light attribute. Both profiles sync in
the Age catalog before travel, not through unbounded RGB dimension types.
Client state is replaced with each catalog and cleared on disconnect. Use the
same beta JAR on server and client: the catalog protocol has changed.
Build and dedicated-server checks cover compiled color groups, native fallback,
all four sky types, catalog encoding/decoding, reload and reseed. Client color
rendering and page availability still need manual checking. Test the example
above, a tinted Static/End Sky, return to an uncolored Age/Overworld, and revisit
after restarting. `create_colors <name> <seed> <sky>` creates a repeatable Flat,
Bright fixture with sky `#643296` and ambient `#6496c8`; `color_probe <name>`
checks saved colors against sampled attributes and round-trips the catalog.
No commit/push/publication is performed until explicitly requested.
The local `1.0.1.74-beta.1` slice adds authored day-cycle lengths through the
existing **Normal Time** page. Put one **Half Length**, **Full Length**,
**Double Length**, or **Zero Length** before Normal Time. These use the
1.21.1 period values 0.5, 1, 2, and 0 normal days respectively. Half runs twice
as fast; Double runs half as fast. A Number immediately before a Length page
overrides it with `number × 0.08` days (0–24), including Number 0 for stopped
time. Normal Time without a Length uses one normal day.
Zero Length freezes the native day timelines at a deterministic, saved random
time. It does not snap back to noon on reload/reseed, and contributes no
fractional clock interpolation. A book with no Normal Time page retains the
existing native cycle; missing periods are not randomly completed yet.
Until the full grammar port, a Length requires one following Normal Time page;
repeated Length/Normal Time pages are unsupported.
The implementation wraps only this Age's native Overworld-clock timeline
sampling, on server and client. It does not change the global clock or tick
rate. Native sun/moon angles, sky/fog/day shading, moon phase, and gameplay
attributes supplied by those timelines use the sampled time. Weather layers
and authored colors remain intact. Explicit RGB sky/fog overrides remain
constant rather than acquiring day tint. Global `/time` changes still shift
moving Ages' underlying clock; Zero remains fixed. `/time query` reports the
global clock, not the scaled sample; use `cycle_probe` to inspect the latter.
This controls the existing native pair only. Separate Sun/Moon pages, custom
angles/phases/directions/sizes, per-body colors, eclipses, and random celestial
completion remain pending. Normal Time does not add visible bodies to End,
Nether or Static skies; include Overworld Sky for the visual cycle test.
Client/server must use the same beta: the catalog protocol is now 3.
For manual testing, bind **Link Panel → Half Length → Normal Time → Overworld
Sky** (optionally add Normal Lighting and No Weather), compare with Full and
Double, then try Zero Length and check that the sky stays still without
bouncing. Revisit after restarting. For numeric authoring, put Number 12 before
Full Length for a 0.96-day cycle. Existing Ages are not rewritten. Operator
fixtures: `create_cycle <name> <seed> <half|full|double|zero> [number]` and
`cycle_probe <name>` under `/rivencraft_port age`.
Port build and disposable-server probes passed for all four lengths and
numeric overrides, native fallback, real sun-angle/light differences, catalog
round trips, unload/reload, and reseed preservation. Rendering/travel/reconnect
checks still require manual validation. Changes remain local until explicitly
requested for commit/push/publication.
The local `1.0.1.75-beta.1` slice adds **Rising**, **Zenith**, **Setting**, and
**Nadir** Phase pages and **Clockwise/Counterclockwise** Direction pages to the
native Normal Time cycle. Put these before Normal Time, alongside an optional
Length. This does not implement separate Sun/Moon bodies or rename Normal
Time/Full Length; the naming cleanup is tracked separately in #110.
The phase conversion matches 1.21.1's `applyInitialCelestialPhase`: written
angles start at midnight, while native day time zero is sunrise. Rising,
Zenith, Setting, and Nadir select native times 0, 6000, 12000, and 18000.
A Number immediately before a Phase overrides it with `number × 15°` from
midnight; Number 6 means sunrise, 12 noon, 18 sunset, and 0 or 24 midnight.
These are native day-time positions, not uneased geometric sun angles.
Written phase is anchored when the Age is created, independently of the
current Overworld time and Length. Moving cycles then progress from that
saved point. Counterclockwise runs the cycle backward; Clockwise is the
default. This reverses the Age's day timelines, including their moon-phase
and gameplay attributes, not just a visual disc. The global clock is untouched.
As before, global `/time` changes can shift moving samples.
With Zero Length, written Phase replaces the seeded random stopping time:
Zero + Zenith stays at noon, Zero + Nadir at midnight. Direction has no motion
to reverse in a stopped cycle. Without a Phase, old behavior and saved Ages
are preserved. Origins, phase offsets, and direction survive reload, restart,
and reseed. Old profile fields default to origin/offset zero and forward
motion. The catalog protocol is now 4; server and client need the same beta.
Interim grammar permits at most one each of Length, Phase and Direction before
one Normal Time target. Angle/bearing, size, multiple bodies and eclipses remain
unsupported. No random phase/direction completion was added. Example visual
checks (include Link Panel, Overworld Sky, Normal Lighting and No Weather):
- `Zero Length → Zenith → Normal Time`: frozen noon, including after restart.
- `Zero Length → Nadir → Normal Time`: frozen midnight.
- `Half Length → Zenith → Counterclockwise → Normal Time`: starts at noon and
travels backward, twice as fast. Compare with Clockwise.
- `Zero Length → Number 6 → Nadir → Normal Time`: Number overrides Nadir to sunrise.
Operator fixture: `/rivencraft_port age create_phase <name> <seed> <length>
<rising|zenith|setting|nadir> <clockwise|counterclockwise> [phaseNumber]`.
`cycle_probe` reports both the saved origin and current native/scaled samples.
Build and disposable-server checks cover named/numeric phases, opposite motion,
fixed day/night light levels, older profile defaults, catalog round trips,
reload and reseed. Client rendering/reconnect checks remain manual. Changes
remain local until explicitly requested for commit/push/publication.
The local `1.0.1.76-beta.1` slice ports **North/East/South/West Angle** pages
for the native Normal Time cycle. Their bearings match the 1.21.1 symbol
values: 0°, 90°, 180°, and 270°. Put at most one Angle before Normal Time.
A Number immediately before it overrides the bearing with `number × 15°`,
wrapping at 360°: Number 3 is 45°, Number 12 is 180°, and Number 24 returns
to 0°. Angle is the orbit's horizontal bearing, distinct from Phase (position
along that orbit) and Direction (which way time progresses).
The client uses the extracted saved bearing to rotate the native sun/moon
path, star field, and sunrise/sunset glow together. The native buffers,
textures, phase, and lighting tracks are retained. Bearing does not change
altitude, day length, light levels, or clock direction. Without an Angle page,
the existing native orientation is preserved; old profiles use the same
fallback. No seeded random bearing completion was added. This does not add
bodies to Nether/End/Static skies or port custom Sun/Moon counts, sizes,
per-body colors, or eclipses. #110 naming work remains separate.
Bearing persists in the existing cycle profile and catalog; the catalog
protocol is now 5, requiring the same beta on server and client. Extracted
bearing resets with sky state and is replaced at each render, preventing an
Age's orientation from leaking into the next world. Sunrise/sunset glow is
rotated relative to the native -90° body-path orientation, not around a
different axis. No new textures or dimension variants are required.
Visual check: bind **Link Panel → Zero Length → Rising Phase → North Angle →
Normal Time → Overworld Sky** (Normal Lighting/No Weather help comparison).
Compare with East/South/West Angle, then put Number 3 before North Angle for
a 45° intermediate bearing. Use Rising/Setting rather than Zenith when
checking bearing: a body directly overhead stays overhead under a horizontal
rotation. Check the glow stays aligned, then travel to an unmodified Age or
Overworld and revisit after restart.
Operator fixture: `/rivencraft_port age create_angle <name> <seed>
<north|east|south|west> [number]` creates a frozen sunrise for comparison.
The fixture validates all 25 numeric overrides, cardinal values, timing
independence, older profile defaults and catalog codec round trips. Dedicated
checks cover persistence and unchanged sampled day lighting; actual rendered
orientation remains a manual check. No commit/push/publication is performed
until explicitly requested.
The local `1.0.1.77-beta.1` slice fixes automatic sky selection for authored
celestials. **Normal Time without a Sky page now selects Overworld Sky**,
the current port's celestial-capable style, instead of sometimes selecting
Nether/End/Static and hiding the native bodies. Explicit Sky pages retain
precedence. Books without celestial pages retain the existing seeded sky
distribution, and lighting/weather choices are unchanged. The resolver also
recognizes Sun/Moon page intentions for their eventual port; this does not
make those still-unported body pages bindable.
This matches the visible outcome of the current bundled 1.21.1 Age path:
its native fallback is Overworld, and its custom sky renderer draws authored
suns/moons over its backgrounds. No 1.21.1 change or follow-up issue is needed
for those bundled paths. The fix applies at creation of new 26.3 Ages; it
does not rewrite existing saved sky choices. Recreate an older affected book
to test the corrected automatic choice. Weather and terrain can still obscure
celestials naturally; this is a compatible-sky rule, not a guarantee of a clear
view from underground or during storms.
Test **Link Panel → Normal Time**, optionally with No Weather and Normal
Lighting, but without any Sky page. The celestial view should be present.
Also test Length/Phase/Angle/Direction + Normal Time without a Sky page and
confirm that an explicit Sky page still wins. `create_cycle` now exercises
this missing-Sky path rather than forcing an explicit sky in its fixture.
`environment_probe` verifies all three celestial intentions, explicit Sky
precedence in either order, and unchanged lighting/weather selection.
No commit/push/publication is performed until explicitly requested.
The local `1.0.1.78-beta.1` slice ports **Small/Normal/Large Celestial**
Size modifiers for Normal Time: 0.5×, 1× and 2× respectively. A Number page
immediately before Size overrides that preset with the existing 1.21.1
0.25×–3× range (Number 0 = 0.25×, 12 = 1.625×, 24 = 3×).
As in 1.21.1, Size before Normal Time sizes its sun; the paired moon stays
normal-sized. Standalone Sun/Moon and independent body sizing remain unported.
Size changes neither day length nor illumination, phase or bearing. Older
profiles default to 1×; new profiles retain size through reload/restart/reseed.
The catalog protocol is now 6, so clients and servers must use matching builds.
Rendering retains the native sun texture/pipeline without new GPU buffers.
Test **Link Panel → Zero Length → Zenith Phase → Large Celestial → Normal
Time**, optionally with No Weather and Normal Lighting. Compare Small and
Normal, then Number 0/12/24 immediately before Size. No Sky page is needed.
The sun should change size, while the moon and Overworld remain unchanged.
Reopen the world to check persistence. `create_size <name> <seed>
<small|normal|large> [number]` creates a frozen-noon fixture without explicit
Sky; `cycle_probe <name>` reports its saved size and catalog round trip.
Build and disposable-server checks passed, including all numeric sizes,
reload, reseed, restart and unchanged sampled lighting/time. Actual rendered
size remains a manual check. The test server stopped and its world was deleted.
No commit/push/publication is performed until explicitly requested.
From the repository root:
```sh
@@ -0,0 +1,51 @@
# Rivencraft 26.3 beta 1.0.1.54-beta.1
This beta collects the player-validated Age feature port slices since
1.0.1.41-beta.1. It is not yet feature-complete with Minecraft 1.21.1.
## Age generation
- Rocks now use the established 1.21.1 forest-rock placement. Unmodified
material-bearing features choose seeded materials independently; a preceding
compatible Block or Fluid page selects the material explicitly.
- Added Flowers, Trees, Huge Trees, Dungeons, Surface Lakes, Deep Lakes, and
Small Lakes using the 1.21.1 placement rules adapted to 26.3.
- Added Floating Islands, Tendrils, Skylands, Spheres, Spikes, and Obelisks
using the existing Rivencraft generation algorithms.
- Added blue Crystal Formations with budding crystal and initially seeded buds.
- Added Dense Ores: repeated pages replay eligible biome ore placements,
including compatible modded ores and explicitly tagged custom placements.
- Added Star Fissures: one Legacy-shaped rift near the first arrival point,
a starfield at the dimension's minimum Y, and return travel to the shared
Overworld spawn with the original fissure sound. Cave roofs are preserved.
- Feature plans, materials, repeated occurrences, and fissure completion
persist across restart. Existing generated chunks are not redecorated.
## Current limits
- Authored features retain terrain restrictions. Most repeated features allow
up to 32 occurrences; exceeding the current authoring cap blocks assembly.
Skylands and Star Fissures retain their single-effect behavior when repeated.
- Put a material page immediately before a compatible feature. Dangling
material assignment, full Legacy grammar recovery, instability, and random
feature selection are not yet ported. Pageless books randomize terrain and
biome distribution, not the newly authored feature list.
- 26.3 combines base terrain, surface, and carving. Added solid volumes run
after that combined operation and before decoration, so base carvers do not
cut those added volumes.
- The full 1.21.1 writing/discovery loop, structures, environmental systems,
crystal portals, and optional integration parity remain in progress. The
presence of a Creative page is not a promise that its effect is ported.
Build and static checks passed. The player reported in-game passes for these
slices. Earlier server smoke coverage was expanded, but the later generation
slices were manually validated; this release does not claim a new full
dedicated-server regression pass.
Requires Minecraft **26.3**, NeoForge **26.3.0.99** or a compatible 26.3 build,
and **Java 25**. Dynamic Dimensions and Bad Packets are not required by this
26.3 build. Do not use Minecraft 1.21.1 dependencies or saves with it. Back up
the entire world before updating; beta-save compatibility is not guaranteed.
Report issues and help with documentation at
[BlackSheep Git](https://git.paddedcell.org/dnorberg/rivencraft).
@@ -0,0 +1,72 @@
# Rivencraft 26.3 beta 1.0.1.65-beta.1
This beta collects the player-tested port slices since 1.0.1.54-beta.1.
It is not yet feature-complete with the Minecraft 1.21.1 release.
## Age generation
- Added authored structure pages: Villages, Mineshafts, Strongholds, Trial
Chambers, Buried Treasure, Shipwrecks, Ocean Ruins, Pillager Outposts,
Ruined Portals, Nether Fossils, Ancient Cities, Trail Ruins, Nether
Fortresses, Bastions, and 26.3's Abandoned Camps. Dungeons remain their
separate feature.
- Fixed the early structure-biome check so authored structures can generate
outside their native biome restrictions, within the supported terrain rules.
Repeated structure pages increase density up to the current authoring cap.
- Added Caves and Ravines feature pages through 26.3's native carving stage.
These are distinct from Cave terrain; Flat/Void support remains pending.
- Added Poplar Trees, Fallen Poplar Trees, Red Shrubs, and Shelf Mushrooms.
Shelf Mushrooms find exposed logs at any height, including log Tendrils in
Void terrain. No Trees page is required.
- Added Normal, Fast, Slow, Off, Always, and Storm weather pages. Each Age
has independent saved weather data and timers; its policy does not change
the Overworld or other Ages. Cave terrain keeps weather disabled.
## Fixes and feedback
- Non-Cave Descriptive Book arrivals trace actual blocks down from maximum
build height instead of using a Flat-layer shortcut or only a heightmap.
The search stops at roofs/fluids rather than selecting underground pockets.
Leaves count as footing; submerged footing is rejected. Fallback platforms
sit above obstructions, with floor Y=63 in an empty Void.
- Stored surface-Age Star Fissure arrivals are rechecked from above. Cave
terrain still searches down from Y=64, and Linking Books retain exact
bound destinations.
- Restored Legacy linking sounds and successful-link cloud bursts. Disarm
and Intra-Linking use their respective sounds. Star Fissure return audio
also plays after arrival so a dimension switch does not discard it.
- Fixed weather-only authoring, including Link Panel + Storm books: non-biome
pages no longer get interpreted as biome IDs.
- Unsupported pages now have a red tint and availability tooltip. Supported
pages and their descriptions reflect the implemented 26.3 behavior.
- Added operator weather and surface diagnostics for testing.
## Current limits
- Only the supported page/terrain combinations can be bound. Most repeated
features are capped at 32; vegetation uses a combined 32-page limit. The
full 1.21.1 grammar recovery and instability systems remain unported.
- Pageless books currently randomize terrain and biome distribution, not the
complete feature/environment list. Omitted weather currently means Normal.
- Base carvers do not carve authored solid volumes added after 26.3's combined
terrain operation. Previously generated chunks are not regenerated.
- The full writing/discovery loop, celestial/lighting systems, crystal portals,
instability, and optional-integration parity remain in progress. Creative
availability alone does not mean a page is supported; check its tooltip.
- Vanilla `/weather` targets global server weather, not the independent Age
policy. Biomes retain their normal precipitation rules.
The player reported in-game passes for these slices. Build/static checks passed.
Targeted disposable dedicated-server checks covered all six weather policies,
Overworld isolation, unload/reload and restart persistence, weather-only page
compilation, and dry surface/leaf spawn probes. This is not a claim of full
dedicated-server or optional-mod regression coverage. Test servers were stopped
and their temporary worlds removed.
Requires Minecraft **26.3**, NeoForge **26.3.0.99** or a compatible 26.3 build,
and **Java 25**. This build does not require Dynamic Dimensions or Bad Packets.
Keep it separate from the 1.21.1 build and saves. Back up entire worlds before
updating; beta-save compatibility is not guaranteed.
Report issues and help improve the wiki at
[BlackSheep Git](https://git.paddedcell.org/dnorberg/rivencraft).
@@ -0,0 +1,59 @@
# Rivencraft 26.3 beta 1.0.1.71-beta.1
This beta collects the player-tested lighting and sky port slices since
1.0.1.65-beta.1. It is not yet feature-complete with Minecraft 1.21.1.
## Lighting and seeded environments
- Added Normal, Dark, and Bright lighting pages with saved per-Age settings.
- Bright works in Cave terrain too, without adding skylight or weather.
- Bright supplies effective ambient light level 15 to native combined-light
checks and the white ambient rendering attribute. Vanilla darkness-based
monster checks respond to that lighting; there is no separate mob-spawn
veto. Mobs that ignore light retain their normal rules.
- Missing weather and lighting controllers now resolve deterministically from
the Age seed, using independent choices and Legacy-style Normal weighting.
Explicit pages win; Cave terrain still forces weather Off. Existing Ages
keep their saved choices rather than rerolling on load.
## Sky styles
- Added Overworld, Nether, End, and animated Static Sky pages, independently
of terrain, weather, and lighting. Choices persist on reload and restart.
- Nether Sky uses a reddish fog backdrop; End Sky uses the native End sky.
These are visual styles, not Nether/End terrain or gameplay conversions:
dimension geometry, coordinate scale, bed/water rules, and dragon behavior
are not changed.
- Static Sky adapts the existing Legacy-style renderer: six independent
48×48 animated gray noise faces. A cached cube and small dynamic texture
avoid rebuilding thousands of per-cell vertices each frame. Client-only
rendering resources are released with the sky renderer.
- Non-Overworld backgrounds have transparent clouds and a derived day
timeline that avoids Overworld sky/fog/cloud color tracks while retaining
gameplay and illumination schedules.
- Updated supported-page feedback and added operator sky/environment probes.
## Current limits and testing
- Missing Sky pages still default to Overworld; random sky selection, color
pages and individual celestial bodies remain pending. Number-controlled
Static Sky resolution is tracked separately in issue #109 for both versions.
- The writing/discovery loop, instability, portals, and optional-integration
parity are still incomplete. Creative availability is not a support claim;
check page tooltips and the beta guide for supported combinations.
- Previously generated chunks are not regenerated. Save compatibility during
the beta is not guaranteed; keep backups and keep both version tracks apart.
The player reported in-game passes for the included slices, including Static
Sky. Build checks passed for both version tracks. Targeted disposable-server
checks covered environment defaults, explicit choices, registered sky/lighting
variants, persistence and restart, including Cave + Bright and Static Sky.
This is not a claim of complete dedicated-server or optional-mod coverage.
Test servers were stopped and temporary worlds removed.
Requires Minecraft **26.3**, NeoForge **26.3.0.99** and **Java 25**. Dynamic
Dimensions and Bad Packets are not required by this build. The 1.21.1 release
and its dependencies are unchanged.
Report issues and help improve the wiki at
[BlackSheep Git](https://git.paddedcell.org/dnorberg/rivencraft).
@@ -0,0 +1,53 @@
# Rivencraft 26.3 beta 1.0.1.78-beta.1
Collects the player-validated port slices since 1.0.1.71-beta.1. This beta is
not yet feature-complete with the Minecraft 1.21.1 version.
## Sky and color writing
- Missing Sky pages now select a deterministic seeded style. Books with
Normal Time and no explicit Sky select celestial-compatible Overworld Sky.
Explicit Sky pages always win; weather and lighting choices stay independent.
- Ported Red, Green, Blue, Black and White color pages. Preceding Number pages
control component strength; unnumbered colors use seeded values. Colors mix
before clamping the completed group. Colors before Sky tint that sky; a
trailing color group also targets the sky.
- Added an Ambient Color controller for an independent ambient-light tint.
Color alone does not change effective illumination or monster-spawn rules.
- Native, End and animated Static backgrounds use saved Age color settings.
## Normal Time celestial controls
- Length pages control the native day cycle; a preceding Number fine-tunes
length. Zero Length freezes the cycle at a seeded time or an authored Phase,
without interpolation bounce or changing the shared Overworld clock.
- Rising, Zenith, Setting and Nadir Phase pages choose the starting phase.
Clockwise and Counterclockwise control direction. North, East, South and
West Angle pages choose orbit bearing. Preceding Number pages fine-tune
Phase and Angle in 15-degree increments.
- Small, Normal and Large Celestial size Normal Time's sun to 0.5×, 1× and 2×.
A preceding Number overrides size from 0.25× to 3×. As in 1.21.1, the paired
moon remains normal-sized. Size does not alter illumination or day length.
- Settings persist through reload/restart and remain stable when reseeding
future terrain. Older profiles retain native/default values.
- Added operator diagnostics for environment, color and celestial profiles.
## Compatibility and testing
- Catalog protocol is now 6: use matching client/server builds.
- Player in-game checks passed for the included slices. Build checks and
targeted disposable-server diagnostics covered numeric modifiers, catalog
round trips, unchanged sampled lighting, reload, reseed and restart.
- Standalone Sun/Moon controllers, multiple bodies, per-body colors, eclipses,
full writing/discovery, instability, portals and optional-integration parity
remain incomplete. Creative availability does not imply full page support.
- Number-controlled Static Sky resolution remains tracked in issue #109;
time-page naming improvements remain tracked in issue #110.
- Existing chunks are not regenerated. Save compatibility is not guaranteed
during beta; keep backups and keep the two Minecraft version tracks apart.
Requires Minecraft **26.3**, NeoForge **26.3.0.99**, and **Java 25**. Dynamic
Dimensions and Bad Packets are not required. Minecraft 1.21.1 is unchanged.
Report issues and help improve the wiki at
[BlackSheep Git](https://git.paddedcell.org/dnorberg/rivencraft).
+71
View File
@@ -121,9 +121,80 @@ def generatePortLanguage = tasks.register('generatePortLanguage', GeneratePortLa
}
sourceSets.main.resources.srcDir generatePortLanguage
// Derive registered presentation variants without duplicating vanilla gameplay settings.
abstract class GeneratePortDimensionTypes extends DefaultTask {
@InputFile abstract RegularFileProperty getMinecraftJar()
@OutputDirectory abstract DirectoryProperty getOutputDir()
@TaskAction void generate() {
new java.util.zip.ZipFile(minecraftJar.get().asFile).withCloseable { archive ->
def entry = archive.getEntry('data/minecraft/dimension_type/overworld.json')
if (entry == null) throw new GradleException('Missing vanilla Overworld dimension type')
String template = archive.getInputStream(entry).withCloseable { it.getText('UTF-8') }
def dayEntry = archive.getEntry('data/minecraft/timeline/day.json')
if (dayEntry == null) throw new GradleException('Missing vanilla day timeline')
def day = archive.getInputStream(dayEntry).withCloseable {
new groovy.json.JsonSlurper().parseText(it.getText('UTF-8'))
}
// Keep gameplay schedules and illumination, but do not tint these
// native non-Overworld sky backgrounds blue/black with the day cycle.
['fog_color', 'sky_color', 'cloud_color'].each { day.tracks.remove("minecraft:visual/${it}".toString()) }
// Markers are global per clock and already owned by minecraft:day.
day.remove('time_markers')
File dayFile = new File(outputDir.get().asFile, 'data/rivencraft/timeline/age_sky_day.json')
dayFile.parentFile.mkdirs()
dayFile.text = groovy.json.JsonOutput.prettyPrint(groovy.json.JsonOutput.toJson(day)) + '\n'
File timelineTag = new File(outputDir.get().asFile, 'data/rivencraft/tags/timeline/age_sky.json')
timelineTag.parentFile.mkdirs()
timelineTag.text = groovy.json.JsonOutput.prettyPrint(groovy.json.JsonOutput.toJson([
values: ['#minecraft:universal', 'rivencraft:age_sky_day',
'minecraft:moon', 'minecraft:early_game']])) + '\n'
['overworld', 'nether', 'end', 'static'].each { sky ->
['age_normal', 'age_dark', 'age_bright', 'age_cave_bright'].each { lighting ->
if (sky == 'overworld' && lighting == 'age_normal') return
String name = lighting + (sky == 'overworld' ? '' : "_sky_${sky}")
def definition = new groovy.json.JsonSlurper().parseText(template)
definition.has_skylight = lighting in ['age_normal', 'age_bright']
definition.ambient_light = lighting in ['age_bright', 'age_cave_bright'] ? 1.0 : 0.0
// 26.3's lightmap reads this attribute, not ambient_light.
if (definition.ambient_light == 1.0)
definition.attributes['minecraft:visual/ambient_light_color'] = '#ffffff'
if (sky != 'overworld') {
definition.skybox = sky == 'end' ? 'end' : 'none'
definition.attributes['minecraft:visual/fog_color'] = sky == 'static' ? '#929292' : sky == 'end' ? '#181318' : '#6b2725'
definition.attributes['minecraft:visual/sky_color'] = sky == 'static' ? '#929292' : sky == 'end' ? '#000000' : '#6b2725'
definition.attributes['minecraft:visual/cloud_color'] = '#00000000'
if (sky == 'nether') {
definition.attributes['minecraft:visual/fog_start_distance'] = 10.0
definition.attributes['minecraft:visual/fog_end_distance'] = 96.0
}
definition.timelines = '#rivencraft:age_sky'
}
File result = new File(outputDir.get().asFile, "data/rivencraft/dimension_type/${name}.json")
result.parentFile.mkdirs()
result.text = groovy.json.JsonOutput.prettyPrint(groovy.json.JsonOutput.toJson(definition)) + '\n'
}
}
}
}
}
def generatePortDimensionTypes = tasks.register('generatePortDimensionTypes', GeneratePortDimensionTypes) {
dependsOn 'createMinecraftArtifacts'
minecraftJar.set(layout.buildDirectory.file("moddev/artifacts/minecraft-patched-${neo_version}.jar"))
outputDir.set(layout.buildDirectory.dir('generated/resources/dimensionTypes'))
}
sourceSets.main.resources.srcDir generatePortDimensionTypes
tasks.named('processResources', ProcessResources).configure {
// Reuse the existing sprite and base model without copying binary assets.
from('../../src/main/resources') {
include 'data/rivencraft/worldgen/structure_set/fortresses.json'
include 'data/rivencraft/worldgen/structure_set/bastions.json'
include 'assets/rivencraft/models/block/star_fissure.json'
include 'assets/rivencraft/blockstates/star_fissure.json'
include 'assets/rivencraft/sounds.json'
include 'assets/rivencraft/sounds/**/*.ogg'
include 'assets/rivencraft/textures/item/crystal_shard.png'
include 'assets/rivencraft/textures/item/page.png'
include 'assets/rivencraft/textures/item/page_link_panel.png'
+1 -1
View File
@@ -7,4 +7,4 @@ neo_version=26.3.0.99
mod_id=rivencraft
mod_name=Rivencraft
mod_license=LGPL-3.0-or-later
mod_version=1.0.1.41-beta.1
mod_version=1.0.1.78-beta.1
+68 -8
View File
@@ -6,19 +6,59 @@
"item.rivencraft.page.blank": "Blank Page",
"item.rivencraft.page.link_panel": "Link Panel Page",
"item.rivencraft.page.unresolved": "Unknown Symbol Page",
"item.rivencraft.page.symbol.port_status": "Binder currently supports Standard terrain and one Biome page; other effects are still being ported.",
"item.rivencraft.page.symbol.implemented": "Implemented in 26.3; book and terrain requirements still apply.",
"symbol.rivencraft.weather.normal.description": "Use an independent normal weather cycle. Cave terrain keeps weather off.",
"symbol.rivencraft.sky.overworld.description": "Use the native Overworld sky. Terrain and lighting remain independently authored.",
"symbol.rivencraft.sky.nether.description": "Use a Nether-style sky and reddish fog, without Nether terrain or gameplay rules.",
"symbol.rivencraft.sky.end.description": "Use the native End sky backdrop, without End terrain or a dragon fight.",
"symbol.rivencraft.sky.static.description": "Animated static surrounds the Age. Preceding color pages tint it; otherwise it is gray.",
"symbol.rivencraft.lighting.ambient_color": "Ambient Light Color",
"symbol.rivencraft.lighting.ambient_color.description": "Apply preceding shared colors to ambient light. Use Bright separately for full effective ambient lighting.",
"symbol.rivencraft.number.description": "Before Color: value × 10 per channel. Before Length: value × 0.08 days. Before Phase/Angle: value × 15 degrees. Before Size: 0.25× to 3× (0-24).",
"symbol.rivencraft.size.small.description": "Before Normal Time: render the sun at 0.5× size. Its paired moon stays normal-sized, matching 1.21.1. A preceding Number overrides the size.",
"symbol.rivencraft.size.normal.description": "Before Normal Time: render the sun at 1× size. A preceding Number selects 0.25× through 3× (0-24). Its paired moon is unchanged.",
"symbol.rivencraft.size.large.description": "Before Normal Time: render the sun at 2× size. A preceding Number selects 0.25× through 3× (0-24). Its paired moon is unchanged.",
"symbol.rivencraft.angle.north.description": "Before Normal Time: set the native celestial orbit bearing to 0 degrees. A preceding Number overrides Angle in 15-degree steps.",
"symbol.rivencraft.angle.east.description": "Before Normal Time: set the native celestial orbit bearing to 90 degrees. A preceding Number overrides Angle in 15-degree steps.",
"symbol.rivencraft.angle.south.description": "Before Normal Time: set the native celestial orbit bearing to 180 degrees. A preceding Number overrides Angle in 15-degree steps.",
"symbol.rivencraft.angle.west.description": "Before Normal Time: set the native celestial orbit bearing to 270 degrees. A preceding Number overrides Angle in 15-degree steps.",
"symbol.rivencraft.phase.rising.description": "Before Normal Time: start this Age's native cycle at sunrise. A preceding Number overrides Phase in 15-degree steps from midnight.",
"symbol.rivencraft.phase.zenith.description": "Before Normal Time: start this Age's native cycle at noon. With Zero Length it remains noon.",
"symbol.rivencraft.phase.setting.description": "Before Normal Time: start this Age's native cycle at sunset. A preceding Number overrides Phase in 15-degree steps from midnight.",
"symbol.rivencraft.phase.nadir.description": "Before Normal Time: start this Age's native cycle at midnight. With Zero Length it remains midnight.",
"symbol.rivencraft.direction.clockwise.description": "Before Normal Time: advance this Age's native day cycle in its usual direction.",
"symbol.rivencraft.direction.counterclockwise.description": "Before Normal Time: run this Age's native day cycle in reverse. Does not change the Overworld clock.",
"symbol.rivencraft.celestial.normal_time.description": "Use the native sun/moon cycle with Length, Phase, Direction, Angle, and sun Size modifiers. Without a Length, use one normal day.",
"symbol.rivencraft.period.zero.description": "Before Normal Time: freeze the Age's day timelines at the written Phase, or a saved seeded time if Phase is omitted. A preceding Number overrides the Length.",
"symbol.rivencraft.period.half.description": "Before Normal Time: a half-length day (twice as fast). A preceding Number sets Length to value × 0.08 days.",
"symbol.rivencraft.period.full.description": "Before Normal Time: a normal-length day. A preceding Number sets Length to value × 0.08 days.",
"symbol.rivencraft.period.double.description": "Before Normal Time: a double-length day (half as fast). A preceding Number sets Length to value × 0.08 days.",
"symbol.rivencraft.color.red.description": "Add red to the next sky or Ambient Light Color target; trailing colors tint the sky. Precede with a Number, or use a seeded amount.",
"symbol.rivencraft.color.green.description": "Add green to the next sky or Ambient Light Color target; trailing colors tint the sky. Precede with a Number, or use a seeded amount.",
"symbol.rivencraft.color.blue.description": "Add blue to the next sky or Ambient Light Color target; trailing colors tint the sky. Precede with a Number, or use a seeded amount.",
"symbol.rivencraft.color.white.description": "Add equally to all RGB channels. Precede with a Number, or use a seeded amount.",
"symbol.rivencraft.color.black.description": "Subtract equally from all RGB channels. Precede with a Number, or use a seeded amount.",
"symbol.rivencraft.lighting.normal.description": "Use normal skylight and ambient brightness. Cave terrain remains without skylight.",
"symbol.rivencraft.lighting.dark.description": "Remove skylight from this Age; placed light sources still work.",
"symbol.rivencraft.lighting.bright.description": "Provide full ambient brightness and effective light level 15. Vanilla light-based spawn rules apply, including in Cave terrain without skylight.",
"symbol.rivencraft.weather.fast.description": "Use the faster Legacy weather cycle in this Age only. Cave terrain keeps weather off.",
"symbol.rivencraft.weather.slow.description": "Use the slower Legacy weather cycle in this Age only. Cave terrain keeps weather off.",
"symbol.rivencraft.weather.off.description": "Keep this Age clear without changing Overworld weather.",
"symbol.rivencraft.weather.always.description": "Keep this Age rainy, or snowy in cold biomes, without thunder. Cave terrain keeps weather off.",
"symbol.rivencraft.weather.storm.description": "Keep this Age rainy and thundering. Precipitation still depends on the biome; Cave terrain keeps weather off.",
"item.rivencraft.page.symbol.unimplemented": "Not yet implemented in 26.3; cannot be bound into an Age.",
"item.rivencraft.link_property.disarm": "Disarm",
"item.rivencraft.link_property.maintain_momentum": "Maintain Momentum",
"item.rivencraft.link_property.intra_linking": "Intra-Linking",
"item.rivencraft.ink_vial": "Ink Vial",
"item.rivencraft.ink_vial.remaining": "Ink: %s/%s",
"item.rivencraft.ink_vial.usage": "Use with paper in the other hand, or place in an Ink Mixer.",
"item.rivencraft.page.blank.port_status": "Base for future symbol writing; not yet usable in this port.",
"item.rivencraft.page.blank.port_status": "Internal symbol-page base; not an authoring ingredient.",
"item.rivencraft.page.link_panel.port_status": "Use with leather in a Book Binder to make a Descriptive Book.",
"block.rivencraft.book_binder": "Book Binder",
"container.rivencraft.book_binder": "Book Binder",
"screen.rivencraft.book_binder.missing_panel": "Link Panel required",
"screen.rivencraft.book_binder.authored_pending": "Writing not yet ported",
"screen.rivencraft.book_binder.authored_pending": "Unsupported page or page combination",
"block.rivencraft.ink_mixer": "Ink Mixer",
"container.rivencraft.ink_mixer": "Ink Mixer",
"screen.rivencraft.ink_mixer.ink": "Ink: %s/%s",
@@ -33,13 +73,33 @@
"itemGroup.rivencraft": "Rivencraft",
"itemGroup.rivencraft.pages.link": "Rivencraft Link Pages",
"symbol.rivencraft.feature.poplar_trees": "Poplar Trees",
"symbol.rivencraft.feature.poplar_trees.description": "Poplar tree generation symbol.",
"symbol.rivencraft.feature.poplar_trees.description": "Add red, orange, and yellow poplar trees to surface terrain with suitable soil.",
"symbol.rivencraft.feature.fallen_poplar_trees": "Fallen Poplar Trees",
"symbol.rivencraft.feature.fallen_poplar_trees.description": "Fallen poplar tree generation symbol.",
"symbol.rivencraft.feature.fallen_poplar_trees.description": "Add fallen poplar logs and stumps to surface terrain with suitable soil.",
"symbol.rivencraft.feature.shelf_mushrooms": "Shelf Mushrooms",
"symbol.rivencraft.feature.shelf_mushrooms.description": "Shelf mushroom generation symbol.",
"symbol.rivencraft.feature.shelf_mushrooms.description": "Add shelf mushrooms to exposed log faces on any terrain, including Void. No tree page is required.",
"symbol.rivencraft.feature.red_shrubs": "Red Shrubs",
"symbol.rivencraft.feature.red_shrubs.description": "Red shrub generation symbol.",
"symbol.rivencraft.feature.red_shrubs.description": "Add red shrub patches to surface terrain with suitable ground.",
"symbol.rivencraft.feature.abandoned_camps": "Abandoned Camps",
"symbol.rivencraft.feature.abandoned_camps.description": "Abandoned camp structure generation symbol."
"symbol.rivencraft.feature.abandoned_camps.description": "Allow abandoned camps on surface noise terrain, including outside their usual biomes.",
"symbol.rivencraft.block.generated.description": "Place immediately before a compatible feature; unpaired materials are not yet supported.",
"symbol.rivencraft.biome.native.description": "Use the terrain's native biome source; do not include Biome pages.",
"symbol.rivencraft.terrain.flat.description": "A level, biome-decorated landscape with its foundation at Y=0.",
"symbol.rivencraft.feature.floating_islands.description": "Add high floating islands alongside noise, Flat, or Void terrain; not Cave terrain.",
"symbol.rivencraft.feature.caves.description": "Add extra cave carving to noise terrain, including Cave; not Flat or Void.",
"symbol.rivencraft.feature.ravines.description": "Add canyon carving to noise terrain; not Flat or Void. Skips Nether biomes.",
"symbol.rivencraft.feature.mineshafts.description": "Allow mineshafts in noise terrain, including Cave, regardless of normal biome eligibility.",
"symbol.rivencraft.feature.nether_fortresses.description": "Allow Nether fortresses in Cave terrain; the authored page overrides biome eligibility.",
"symbol.rivencraft.feature.strongholds.description": "Allow strongholds in surface noise or Flat terrain, including outside their usual biomes.",
"symbol.rivencraft.feature.villages.description": "Allow villages in surface noise or Flat terrain, including outside their usual biomes.",
"symbol.rivencraft.feature.trial_chambers.description": "Allow underground Trial Chambers in noise terrain, including Cave, regardless of biome eligibility.",
"symbol.rivencraft.feature.bastions.description": "Allow Bastion Remnants in Cave terrain; the authored page overrides biome eligibility.",
"symbol.rivencraft.feature.buried_treasure.description": "Allow buried treasure in surface noise terrain; biome eligibility is overridden, but suitable terrain is required.",
"symbol.rivencraft.feature.shipwrecks.description": "Allow shipwrecks in surface noise terrain, including outside their usual ocean and beach biomes.",
"symbol.rivencraft.feature.ocean_ruins.description": "Allow ocean ruins in surface noise terrain, including outside their usual biomes.",
"symbol.rivencraft.feature.pillager_outposts.description": "Allow pillager outposts in surface noise terrain, including outside their usual biomes.",
"symbol.rivencraft.feature.ruined_portals.description": "Allow ruined portals in noise terrain, including Cave, regardless of normal biome eligibility.",
"symbol.rivencraft.feature.nether_fossils.description": "Allow Nether fossils in Cave terrain; the authored page overrides biome eligibility.",
"symbol.rivencraft.feature.ancient_cities.description": "Allow underground Ancient Cities in noise terrain, including Cave, regardless of biome eligibility.",
"symbol.rivencraft.feature.trail_ruins.description": "Allow Trail Ruins in surface noise terrain, including outside their usual biomes."
}
@@ -8,6 +8,8 @@ import net.dnorberg.rivencraft.age.AgeCatalogSync;
import net.dnorberg.rivencraft.block.BuddingCrystalBlock;
import net.dnorberg.rivencraft.block.PortBookBinderBlock;
import net.dnorberg.rivencraft.block.InkMixerBlock;
import net.dnorberg.rivencraft.block.StarFissureBlock;
import net.dnorberg.rivencraft.block.entity.StarFissureBlockEntity;
import net.dnorberg.rivencraft.block.entity.PortBookBinderBlockEntity;
import net.dnorberg.rivencraft.block.entity.InkMixerBlockEntity;
import net.dnorberg.rivencraft.menu.PortBookBinderMenu;
@@ -26,6 +28,7 @@ import net.dnorberg.rivencraft.writing.LinkingBookRecipe;
import net.minecraft.core.component.DataComponentType;
import net.minecraft.core.registries.Registries;
import net.minecraft.resources.Identifier;
import net.minecraft.sounds.SoundEvent;
import net.minecraft.network.codec.ByteBufCodecs;
import net.minecraft.world.level.block.Block;
import net.minecraft.world.level.block.Blocks;
@@ -54,6 +57,19 @@ public final class Rivencraft {
public static final Logger LOGGER = LogUtils.getLogger();
private static final DeferredRegister.Blocks BLOCKS = DeferredRegister.createBlocks(MOD_ID);
private static final DeferredRegister.Items ITEMS = DeferredRegister.createItems(MOD_ID);
private static final DeferredRegister<SoundEvent> SOUNDS =
DeferredRegister.create(Registries.SOUND_EVENT, MOD_ID);
public static final Supplier<SoundEvent> LINK = sound("linking.link");
public static final Supplier<SoundEvent> LINK_DISARM = sound("linking.link-disarm");
public static final Supplier<SoundEvent> LINK_INTRA = sound("linking.link-intra");
public static final Supplier<SoundEvent> LINK_FISSURE = sound("linking.link-fissure");
public static final Supplier<SoundEvent> LINK_PORTAL = sound("linking.link-portal");
public static final Supplier<SoundEvent> METEOR_ROAR = sound("entity.meteor.roar");
private static Supplier<SoundEvent> sound(String name) {
return SOUNDS.register(name, () -> SoundEvent.createVariableRangeEvent(
Identifier.fromNamespaceAndPath(MOD_ID, name)));
}
private static final DeferredRegister<BlockEntityType<?>> BLOCK_ENTITY_TYPES =
DeferredRegister.create(Registries.BLOCK_ENTITY_TYPE, MOD_ID);
private static final DeferredRegister<MenuType<?>> MENUS =
@@ -94,6 +110,12 @@ public final class Rivencraft {
public static final DeferredBlock<Block> CRYSTAL_BLOCK = BLOCKS.registerBlock("crystal_block",
Block::new, () -> BlockBehaviour.Properties.ofFullCopy(Blocks.AMETHYST_BLOCK));
public static final DeferredBlock<StarFissureBlock> STAR_FISSURE =
BLOCKS.registerBlock("star_fissure", StarFissureBlock::new,
() -> BlockBehaviour.Properties.ofFullCopy(Blocks.END_PORTAL).lightLevel(state -> 6));
public static final Supplier<BlockEntityType<StarFissureBlockEntity>>
STAR_FISSURE_BLOCK_ENTITY = BLOCK_ENTITY_TYPES.register("star_fissure", () -> new BlockEntityType<>(
StarFissureBlockEntity::new, STAR_FISSURE.get()));
public static final DeferredBlock<PortBookBinderBlock> BOOK_BINDER = BLOCKS.registerBlock(
"book_binder", PortBookBinderBlock::new,
() -> BlockBehaviour.Properties.ofFullCopy(Blocks.CRAFTING_TABLE).noOcclusion());
@@ -146,6 +168,7 @@ public final class Rivencraft {
public Rivencraft(IEventBus modEventBus) {
BLOCKS.register(modEventBus);
SOUNDS.register(modEventBus);
ITEMS.register(modEventBus);
BLOCK_ENTITY_TYPES.register(modEventBus);
MENUS.register(modEventBus);
@@ -155,6 +178,7 @@ public final class Rivencraft {
PortBiomeSources.register(modEventBus);
PortChunkGenerators.register(modEventBus);
NeoForge.EVENT_BUS.addListener(RuntimeAgeBackend::onLevelLoad);
NeoForge.EVENT_BUS.addListener(net.dnorberg.rivencraft.age.PortAgeWeather::onLevelTickPre);
NeoForge.EVENT_BUS.addListener(PortAgeCommands::register);
NeoForge.EVENT_BUS.addListener(AgeCatalogSync::onLogin);
NeoForge.EVENT_BUS.addListener(AgeCatalogSync::onLogout);
@@ -1,6 +1,8 @@
package net.dnorberg.rivencraft.age;
import java.util.List;
import java.util.Map;
import java.util.HashMap;
import net.dnorberg.rivencraft.Rivencraft;
import net.minecraft.network.RegistryFriendlyByteBuf;
import net.minecraft.network.codec.ByteBufCodecs;
@@ -9,17 +11,23 @@ import net.minecraft.network.protocol.common.custom.CustomPacketPayload;
import net.minecraft.resources.Identifier;
/** Full server level catalog, sent on login and after a runtime Age is added. */
public record AgeCatalogPayload(int version, List<Identifier> dimensions) implements CustomPacketPayload {
public record AgeCatalogPayload(int version, List<Identifier> dimensions,
Map<Identifier, PortAgeColors> colors,
Map<Identifier, PortDayCycle> dayCycles) implements CustomPacketPayload {
public static final Type<AgeCatalogPayload> TYPE = new Type<>(
Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "age_catalog"));
public static final StreamCodec<RegistryFriendlyByteBuf, AgeCatalogPayload> STREAM_CODEC = StreamCodec.composite(
ByteBufCodecs.VAR_INT, AgeCatalogPayload::version,
Identifier.STREAM_CODEC.apply(ByteBufCodecs.list()), AgeCatalogPayload::dimensions,
ByteBufCodecs.map(HashMap::new, Identifier.STREAM_CODEC, PortAgeColors.STREAM_CODEC), AgeCatalogPayload::colors,
ByteBufCodecs.map(HashMap::new, Identifier.STREAM_CODEC, PortDayCycle.STREAM_CODEC), AgeCatalogPayload::dayCycles,
AgeCatalogPayload::new
);
public AgeCatalogPayload {
dimensions = List.copyOf(dimensions);
colors = Map.copyOf(colors);
dayCycles = Map.copyOf(dayCycles);
}
@Override
@@ -21,7 +21,7 @@ public final class AgeCatalogSync {
}
public static void registerPayloads(RegisterPayloadHandlersEvent event) {
var registrar = event.registrar("1");
var registrar = event.registrar("6");
registrar.playToClient(AgeCatalogPayload.TYPE, AgeCatalogPayload.STREAM_CODEC);
registrar.playToServer(AgeCatalogAckPayload.TYPE, AgeCatalogAckPayload.STREAM_CODEC,
(payload, context) -> {
@@ -77,7 +77,8 @@ public final class AgeCatalogSync {
State state = state(server);
state.acknowledged.remove(player.getUUID());
var dimensions = server.levelKeys().stream().map(key -> key.identifier()).sorted().toList();
PacketDistributor.sendToPlayer(player, new AgeCatalogPayload(state.version, dimensions));
PacketDistributor.sendToPlayer(player, new AgeCatalogPayload(state.version, dimensions,
RuntimeAgeBackend.savedColors(server), RuntimeAgeBackend.savedDayCycles(server)));
}
private static void acknowledge(ServerPlayer player, int version) {
@@ -26,7 +26,23 @@ final class AgeLevelIndex extends SavedData {
Codec.STRING.xmap(PortAgeTerrain::valueOf, PortAgeTerrain::name))
.optionalFieldOf("terrain", Map.of()).forGetter(index -> index.terrain),
Codec.unboundedMap(Identifier.CODEC, PortRockPlan.CODEC).optionalFieldOf("rocks", Map.of())
.forGetter(index -> index.rocks)
.forGetter(index -> index.rocks),
Codec.unboundedMap(Identifier.CODEC, PortVegetationPlan.CODEC)
.optionalFieldOf("vegetation", Map.of()).forGetter(index -> index.vegetation),
Codec.unboundedMap(Identifier.CODEC, Codec.intRange(1, 32))
.optionalFieldOf("dungeons", Map.of()).forGetter(index -> index.dungeons),
Codec.unboundedMap(Identifier.CODEC, PortLakePlan.CODEC)
.optionalFieldOf("lakes", Map.of()).forGetter(index -> index.lakes),
Codec.unboundedMap(Identifier.CODEC, PortTerrainFeaturePlan.CODEC)
.optionalFieldOf("terrain_features", Map.of()).forGetter(index -> index.terrainFeatures),
Codec.unboundedMap(Identifier.CODEC, PortLighting.CODEC)
.optionalFieldOf("lighting", Map.of()).forGetter(index -> index.lighting),
Codec.unboundedMap(Identifier.CODEC, PortSkyStyle.CODEC)
.optionalFieldOf("sky", Map.of()).forGetter(index -> index.sky),
Codec.unboundedMap(Identifier.CODEC, PortAgeColors.CODEC)
.optionalFieldOf("colors", Map.of()).forGetter(index -> index.colors),
Codec.unboundedMap(Identifier.CODEC, PortDayCycle.CODEC)
.optionalFieldOf("day_cycles", Map.of()).forGetter(index -> index.dayCycles)
).apply(instance, AgeLevelIndex::new));
static final SavedDataType<AgeLevelIndex> TYPE = new SavedDataType<>(
@@ -41,6 +57,14 @@ final class AgeLevelIndex extends SavedData {
private final Map<Identifier, PortBiomePlan> biomePlans;
private final Map<Identifier, PortAgeTerrain> terrain;
private final Map<Identifier, PortRockPlan> rocks;
private final Map<Identifier, PortVegetationPlan> vegetation;
private final Map<Identifier, Integer> dungeons;
private final Map<Identifier, PortLakePlan> lakes;
private final Map<Identifier, PortTerrainFeaturePlan> terrainFeatures;
private final Map<Identifier, PortLighting> lighting;
private final Map<Identifier, PortSkyStyle> sky;
private final Map<Identifier, PortAgeColors> colors;
private final Map<Identifier, PortDayCycle> dayCycles;
private AgeLevelIndex() {
this.ages = new LinkedHashMap<>();
@@ -49,19 +73,42 @@ final class AgeLevelIndex extends SavedData {
this.biomePlans = new LinkedHashMap<>();
this.terrain = new LinkedHashMap<>();
this.rocks = new LinkedHashMap<>();
this.vegetation = new LinkedHashMap<>();
this.dungeons = new LinkedHashMap<>();
this.lakes = new LinkedHashMap<>();
this.terrainFeatures = new LinkedHashMap<>();
this.lighting = new LinkedHashMap<>();
this.sky = new LinkedHashMap<>();
this.colors = new LinkedHashMap<>();
this.dayCycles = new LinkedHashMap<>();
}
private AgeLevelIndex(Map<Identifier, Long> ages, List<Identifier> retired,
Map<Identifier, Identifier> biomeOverrides,
Map<Identifier, PortBiomePlan> biomePlans,
Map<Identifier, PortAgeTerrain> terrain,
Map<Identifier, PortRockPlan> rocks) {
Map<Identifier, PortRockPlan> rocks,
Map<Identifier, PortVegetationPlan> vegetation,
Map<Identifier, Integer> dungeons,
Map<Identifier, PortLakePlan> lakes,
Map<Identifier, PortTerrainFeaturePlan> terrainFeatures,
Map<Identifier, PortLighting> lighting,
Map<Identifier, PortSkyStyle> sky, Map<Identifier, PortAgeColors> colors,
Map<Identifier, PortDayCycle> dayCycles) {
this.ages = new LinkedHashMap<>(ages);
this.retired = new LinkedHashSet<>(retired);
this.biomeOverrides = new LinkedHashMap<>(biomeOverrides);
this.biomePlans = new LinkedHashMap<>(biomePlans);
this.terrain = new LinkedHashMap<>(terrain);
this.rocks = new LinkedHashMap<>(rocks);
this.vegetation = new LinkedHashMap<>(vegetation);
this.dungeons = new LinkedHashMap<>(dungeons);
this.lakes = new LinkedHashMap<>(lakes);
this.terrainFeatures = new LinkedHashMap<>(terrainFeatures);
this.lighting = new LinkedHashMap<>(lighting);
this.sky = new LinkedHashMap<>(sky);
this.colors = new LinkedHashMap<>(colors);
this.dayCycles = new LinkedHashMap<>(dayCycles);
}
Map<Identifier, Long> entries() {
@@ -88,9 +135,21 @@ final class AgeLevelIndex extends SavedData {
}
PortRockPlan rocks(Identifier id) { return rocks.get(id); }
PortVegetationPlan vegetation(Identifier id) { return vegetation.get(id); }
int dungeons(Identifier id) { return dungeons.getOrDefault(id, 0); }
PortLakePlan lakes(Identifier id) { return lakes.get(id); }
PortTerrainFeaturePlan terrainFeatures(Identifier id) { return terrainFeatures.get(id); }
PortLighting lighting(Identifier id) { return lighting.getOrDefault(id, PortLighting.NORMAL); }
PortSkyStyle sky(Identifier id) { return sky.getOrDefault(id, PortSkyStyle.OVERWORLD); }
PortAgeColors colors(Identifier id) { return colors.getOrDefault(id, PortAgeColors.NATIVE); }
Map<Identifier, PortAgeColors> colors() { return Map.copyOf(colors); }
PortDayCycle dayCycle(Identifier id) { return dayCycles.getOrDefault(id, PortDayCycle.NATIVE); }
Map<Identifier, PortDayCycle> dayCycles() { return Map.copyOf(dayCycles); }
void add(Identifier id, long seed, PortBiomePlan biomePlan, PortAgeTerrain ageTerrain,
PortRockPlan rockPlan) {
PortRockPlan rockPlan, PortVegetationPlan vegetationPlan, int dungeonCount,
PortLakePlan lakePlan, PortTerrainFeaturePlan terrainFeaturePlan, PortLighting light, PortSkyStyle skyStyle,
PortAgeColors colorProfile, PortDayCycle dayCycle) {
if (contains(id)) {
throw new IllegalArgumentException("Age already exists: " + id);
}
@@ -98,6 +157,14 @@ final class AgeLevelIndex extends SavedData {
if (biomePlan != null) biomePlans.put(id, biomePlan);
if (ageTerrain != PortAgeTerrain.STANDARD) terrain.put(id, ageTerrain);
if (rockPlan != null) rocks.put(id, rockPlan);
if (vegetationPlan != null) vegetation.put(id, vegetationPlan);
if (dungeonCount > 0) dungeons.put(id, dungeonCount);
if (lakePlan != null) lakes.put(id, lakePlan);
if (terrainFeaturePlan != null) terrainFeatures.put(id, terrainFeaturePlan);
if (light != PortLighting.NORMAL) lighting.put(id, light);
if (skyStyle != PortSkyStyle.OVERWORLD) sky.put(id, skyStyle);
if (!colorProfile.equals(PortAgeColors.NATIVE)) colors.put(id, colorProfile);
if (!dayCycle.equals(PortDayCycle.NATIVE)) dayCycles.put(id, dayCycle);
setDirty();
}
@@ -107,17 +174,36 @@ final class AgeLevelIndex extends SavedData {
biomePlans.remove(id);
terrain.remove(id);
rocks.remove(id);
vegetation.remove(id);
dungeons.remove(id);
lakes.remove(id);
terrainFeatures.remove(id);
lighting.remove(id);
sky.remove(id);
colors.remove(id);
dayCycles.remove(id);
retired.add(id);
setDirty();
}
void restoreAfterFailedRetirement(Identifier id, long seed, PortBiomePlan biomePlan,
PortAgeTerrain ageTerrain, PortRockPlan rockPlan) {
PortAgeTerrain ageTerrain, PortRockPlan rockPlan,
PortVegetationPlan vegetationPlan, int dungeonCount,
PortLakePlan lakePlan, PortTerrainFeaturePlan terrainFeaturePlan, PortLighting light,
PortSkyStyle skyStyle, PortAgeColors colorProfile, PortDayCycle dayCycle) {
if (!retired.remove(id)) throw new IllegalStateException("Age is not retired: " + id);
ages.put(id, seed);
if (biomePlan != null) biomePlans.put(id, biomePlan);
if (ageTerrain != PortAgeTerrain.STANDARD) terrain.put(id, ageTerrain);
if (rockPlan != null) rocks.put(id, rockPlan);
if (vegetationPlan != null) vegetation.put(id, vegetationPlan);
if (dungeonCount > 0) dungeons.put(id, dungeonCount);
if (lakePlan != null) lakes.put(id, lakePlan);
if (terrainFeaturePlan != null) terrainFeatures.put(id, terrainFeaturePlan);
if (light != PortLighting.NORMAL) lighting.put(id, light);
if (skyStyle != PortSkyStyle.OVERWORLD) sky.put(id, skyStyle);
if (!colorProfile.equals(PortAgeColors.NATIVE)) colors.put(id, colorProfile);
if (!dayCycle.equals(PortDayCycle.NATIVE)) dayCycles.put(id, dayCycle);
setDirty();
}
@@ -0,0 +1,101 @@
package net.dnorberg.rivencraft.age;
import com.mojang.serialization.Codec;
import com.mojang.serialization.codecs.RecordCodecBuilder;
import java.util.List;
import java.util.Map;
import java.util.Random;
import net.minecraft.network.RegistryFriendlyByteBuf;
import net.minecraft.network.codec.ByteBufCodecs;
import net.minecraft.network.codec.StreamCodec;
import net.minecraft.resources.Identifier;
import net.minecraft.world.level.Level;
import org.joml.Vector3f;
import org.joml.Vector3fc;
/** Frozen shared-color writing, adapted from 1.21.1 AgeSkyProfile. -1 means native. */
public record PortAgeColors(int sky, int ambient) {
public static final PortAgeColors NATIVE = new PortAgeColors(-1, -1);
public static final Codec<PortAgeColors> CODEC = RecordCodecBuilder.create(instance -> instance.group(
Codec.intRange(-1, 0xffffff).fieldOf("sky").forGetter(PortAgeColors::sky),
Codec.intRange(-1, 0xffffff).fieldOf("ambient").forGetter(PortAgeColors::ambient)
).apply(instance, PortAgeColors::new));
public static final StreamCodec<RegistryFriendlyByteBuf, PortAgeColors> STREAM_CODEC = StreamCodec.composite(
ByteBufCodecs.INT, PortAgeColors::sky, ByteBufCodecs.INT, PortAgeColors::ambient, PortAgeColors::new);
public static final Identifier AMBIENT = Identifier.fromNamespaceAndPath("rivencraft", "lighting/ambient_color");
private static volatile Map<Identifier, PortAgeColors> clientColors = Map.of();
public PortAgeColors {
if (sky < -1 || sky > 0xffffff || ambient < -1 || ambient > 0xffffff)
throw new IllegalArgumentException("Invalid Age color");
}
public static void updateClient(Map<Identifier, PortAgeColors> colors) { clientColors = Map.copyOf(colors); }
public static PortAgeColors forLevel(Level level) {
if (level instanceof PortAgeLevel age) return age.colors();
return level.isClientSide() ? clientColors.getOrDefault(level.dimension().identifier(), NATIVE) : NATIVE;
}
public static boolean isColor(Identifier page) {
return page.getNamespace().equals("rivencraft") && switch (page.getPath()) {
case "color/red", "color/green", "color/blue", "color/white", "color/black" -> true;
default -> false;
};
}
public static int number(Identifier page) {
if (!page.getNamespace().equals("rivencraft") || !page.getPath().startsWith("number/")) return -1;
try {
int value = Integer.parseInt(page.getPath().substring(7));
return value >= 0 && value <= 24 ? value : -1;
} catch (NumberFormatException exception) { return -1; }
}
public static boolean isModifier(Identifier page) { return isColor(page) || number(page) >= 0 || page.equals(AMBIENT); }
public static boolean validNumbers(List<Identifier> pages) {
for (int i = 0; i < pages.size(); i++)
if (number(pages.get(i)) >= 0 && (i + 1 == pages.size()
|| !isColor(pages.get(i + 1)) && !PortDayCycle.isPeriod(pages.get(i + 1))
&& !PortDayCycle.isPhase(pages.get(i + 1)) && !PortDayCycle.isAngle(pages.get(i + 1))
&& !PortDayCycle.isSize(pages.get(i + 1)))) return false;
return true;
}
public static PortAgeColors compile(List<Identifier> pages, long seed) {
if (!validNumbers(pages)) throw new IllegalArgumentException("Number pages must precede a supported Color, Length, Phase, Angle, or Size modifier");
int red = 0, green = 0, blue = 0, sky = -1, ambient = -1;
boolean written = false;
for (int i = 0; i < pages.size(); i++) {
Identifier page = pages.get(i);
if (isColor(page)) {
int amount = i > 0 ? number(pages.get(i - 1)) : -1;
if (amount < 0) amount = new Random(seed ^ (0x9E3779B97F4A7C15L * (i + 1L))).nextInt(25);
amount *= 10;
written = true;
switch (page.getPath()) {
case "color/red" -> red += amount;
case "color/green" -> green += amount;
case "color/blue" -> blue += amount;
case "color/white" -> { red += amount; green += amount; blue += amount; }
case "color/black" -> { red -= amount; green -= amount; blue -= amount; }
}
} else if (PortSkyStyle.forPage(page) != null || page.equals(AMBIENT)) {
if (written) {
int rgb = clamp(red) << 16 | clamp(green) << 8 | clamp(blue);
if (page.equals(AMBIENT)) ambient = rgb; else sky = rgb;
}
red = green = blue = 0;
written = false;
}
}
if (written) sky = clamp(red) << 16 | clamp(green) << 8 | clamp(blue);
return new PortAgeColors(sky, ambient);
}
private static int clamp(int value) { return Math.max(0, Math.min(255, value)); }
public static Vector3fc vector(int rgb) {
return new Vector3f((rgb >> 16 & 255) / 255f, (rgb >> 8 & 255) / 255f, (rgb & 255) / 255f);
}
}
@@ -18,6 +18,8 @@ import net.minecraft.server.level.ServerPlayer;
import net.minecraft.util.Prediction;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.level.levelgen.Heightmap;
import net.minecraft.tags.BlockTags;
import net.minecraft.world.level.block.Blocks;
import net.neoforged.neoforge.event.RegisterCommandsEvent;
import net.neoforged.fml.ModList;
import java.util.Map;
@@ -66,6 +68,93 @@ public final class PortAgeCommands {
return 1;
})))
.then(Commands.literal("age")
.then(Commands.literal("create_size")
.then(Commands.argument("name", StringArgumentType.word())
.then(Commands.argument("seed", LongArgumentType.longArg())
.then(Commands.argument("size", StringArgumentType.word())
.executes(context -> createSizeAge(context, -1))
.then(Commands.argument("number", IntegerArgumentType.integer(0, 24))
.executes(context -> createSizeAge(context,
IntegerArgumentType.getInteger(context, "number"))))))))
.then(Commands.literal("create_angle")
.then(Commands.argument("name", StringArgumentType.word())
.then(Commands.argument("seed", LongArgumentType.longArg())
.then(Commands.argument("bearing", StringArgumentType.word())
.executes(context -> createAngleAge(context, -1))
.then(Commands.argument("number", IntegerArgumentType.integer(0, 24))
.executes(context -> createAngleAge(context,
IntegerArgumentType.getInteger(context, "number"))))))))
.then(Commands.literal("create_phase")
.then(Commands.argument("name", StringArgumentType.word())
.then(Commands.argument("seed", LongArgumentType.longArg())
.then(Commands.argument("length", StringArgumentType.word())
.then(Commands.argument("phase", StringArgumentType.word())
.then(Commands.argument("direction", StringArgumentType.word())
.executes(context -> createPhaseAge(context, -1))
.then(Commands.argument("number", IntegerArgumentType.integer(0, 24))
.executes(context -> createPhaseAge(context,
IntegerArgumentType.getInteger(context, "number"))))))))))
.then(Commands.literal("create_cycle")
.then(Commands.argument("name", StringArgumentType.word())
.then(Commands.argument("seed", LongArgumentType.longArg())
.then(Commands.argument("length", StringArgumentType.word())
.executes(context -> createDayCycleAge(context, -1))
.then(Commands.argument("number", IntegerArgumentType.integer(0, 24))
.executes(context -> createDayCycleAge(context,
IntegerArgumentType.getInteger(context, "number"))))))))
.then(Commands.literal("cycle_probe")
.then(Commands.argument("name", StringArgumentType.word())
.executes(PortAgeCommands::probeDayCycle)))
.then(Commands.literal("create_colors")
.then(Commands.argument("name", StringArgumentType.word())
.then(Commands.argument("seed", LongArgumentType.longArg())
.then(Commands.argument("sky", StringArgumentType.word())
.executes(PortAgeCommands::createColorAge)))))
.then(Commands.literal("color_probe")
.then(Commands.argument("name", StringArgumentType.word())
.executes(PortAgeCommands::probeColors)))
.then(Commands.literal("create_sky")
.then(Commands.argument("name", StringArgumentType.word())
.then(Commands.argument("seed", LongArgumentType.longArg())
.then(Commands.argument("sky", StringArgumentType.word())
.then(Commands.argument("lighting", StringArgumentType.word())
.then(Commands.argument("terrain", StringArgumentType.word())
.executes(PortAgeCommands::createSkyAge)))))))
.then(Commands.literal("sky_probe")
.then(Commands.argument("name", StringArgumentType.word())
.executes(PortAgeCommands::probeSky)))
.then(Commands.literal("environment_probe")
.then(Commands.argument("seed", LongArgumentType.longArg())
.then(Commands.argument("terrain", StringArgumentType.word())
.executes(PortAgeCommands::probeEnvironment))))
.then(Commands.literal("create_lighting")
.then(Commands.argument("name", StringArgumentType.word())
.then(Commands.argument("seed", LongArgumentType.longArg())
.then(Commands.argument("mode", StringArgumentType.word())
.suggests((context, builder) -> net.minecraft.commands.SharedSuggestionProvider.suggest(
java.util.Arrays.stream(PortLighting.values())
.map(mode -> mode.name().toLowerCase(Locale.ROOT)), builder))
.then(Commands.argument("terrain", StringArgumentType.word())
.executes(PortAgeCommands::createLightingAge))))))
.then(Commands.literal("lighting_probe")
.then(Commands.argument("name", StringArgumentType.word())
.executes(PortAgeCommands::probeLighting)))
.then(Commands.literal("surface_probe")
.then(Commands.argument("name", StringArgumentType.word())
.then(Commands.argument("x", IntegerArgumentType.integer())
.then(Commands.argument("z", IntegerArgumentType.integer())
.executes(PortAgeCommands::probeSurface)))))
.then(Commands.literal("create_weather")
.then(Commands.argument("name", StringArgumentType.word())
.then(Commands.argument("seed", LongArgumentType.longArg())
.then(Commands.argument("mode", StringArgumentType.word())
.suggests((context, builder) -> net.minecraft.commands.SharedSuggestionProvider.suggest(
java.util.Arrays.stream(PortWeatherMode.values())
.map(mode -> mode.name().toLowerCase(Locale.ROOT)), builder))
.executes(PortAgeCommands::createWeatherAge)))))
.then(Commands.literal("weather_probe")
.then(Commands.argument("name", StringArgumentType.word())
.executes(PortAgeCommands::probeWeather)))
.then(Commands.literal("create")
.then(Commands.argument("name", StringArgumentType.word())
.then(Commands.argument("seed", LongArgumentType.longArg())
@@ -119,10 +208,13 @@ public final class PortAgeCommands {
long seed = LongArgumentType.getLong(context, "seed");
PortRandomAgeSelection.Selection selected = PortRandomAgeSelection.select(
context.getSource().getServer(), seed);
int created = createAge(context, selected.terrain(), selected.biomes());
var environment = PortEnvironmentSelection.resolve(java.util.List.of(), seed, selected.terrain());
int created = createAge(context, selected.terrain(), selected.biomes(),
null, null, 0, null, null, environment);
if (created != 0) context.getSource().sendSuccess(() -> Component.literal(
"Random Age plan: terrain=" + selected.terrain()
+ " biomes=" + selected.biomes()), false);
+ " biomes=" + selected.biomes()
+ " environment=" + environment), false);
return created;
}))))
.then(Commands.literal("create_rocks")
@@ -130,6 +222,51 @@ public final class PortAgeCommands {
.then(Commands.argument("seed", LongArgumentType.longArg())
.executes(context -> createAge(context, PortAgeTerrain.FLAT,
null, new PortRockPlan(Identifier.withDefaultNamespace("gold_block")))))))
.then(Commands.literal("create_rocks_multi")
.then(Commands.argument("name", StringArgumentType.word())
.then(Commands.argument("seed", LongArgumentType.longArg())
.executes(context -> createAge(context, PortAgeTerrain.FLAT, null,
fourGoldRocks(LongArgumentType.getLong(context, "seed")))))))
.then(Commands.literal("create_rocks_random")
.then(Commands.argument("name", StringArgumentType.word())
.then(Commands.argument("seed", LongArgumentType.longArg())
.executes(context -> createAge(context, PortAgeTerrain.FLAT, null,
net.dnorberg.rivencraft.writing.PortAuthoredWriting.rockPlan(
java.util.List.of(Identifier.fromNamespaceAndPath(
Rivencraft.MOD_ID, "feature/rocks")),
LongArgumentType.getLong(context, "seed")))))))
.then(Commands.literal("create_vegetation")
.then(Commands.argument("name", StringArgumentType.word())
.then(Commands.argument("seed", LongArgumentType.longArg())
.executes(context -> createAge(context, PortAgeTerrain.FLAT, null,
null, testVegetation())))))
.then(Commands.literal("create_dungeons")
.then(Commands.argument("name", StringArgumentType.word())
.then(Commands.argument("seed", LongArgumentType.longArg())
.executes(context -> createAge(context, PortAgeTerrain.STANDARD,
null, null, null, testDungeons())))))
.then(Commands.literal("create_lakes")
.then(Commands.argument("name", StringArgumentType.word())
.then(Commands.argument("seed", LongArgumentType.longArg())
.executes(context -> createAge(context, PortAgeTerrain.STANDARD,
null, null, null, 0, testLakes(
LongArgumentType.getLong(context, "seed"), false))))))
.then(Commands.literal("create_lakes_random")
.then(Commands.argument("name", StringArgumentType.word())
.then(Commands.argument("seed", LongArgumentType.longArg())
.executes(context -> createAge(context, PortAgeTerrain.STANDARD,
null, null, null, 0, testLakes(
LongArgumentType.getLong(context, "seed"), true))))))
.then(Commands.literal("create_islands")
.then(Commands.argument("name", StringArgumentType.word())
.then(Commands.argument("seed", LongArgumentType.longArg())
.then(Commands.argument("terrain", StringArgumentType.word())
.executes(context -> createIslandAge(context, false))))))
.then(Commands.literal("create_islands_random")
.then(Commands.argument("name", StringArgumentType.word())
.then(Commands.argument("seed", LongArgumentType.longArg())
.then(Commands.argument("terrain", StringArgumentType.word())
.executes(context -> createIslandAge(context, true))))))
.then(Commands.literal("list")
.executes(context -> {
var ages = RuntimeAgeBackend.savedAges(context.getSource().getServer());
@@ -236,6 +373,18 @@ public final class PortAgeCommands {
.then(Commands.literal("rocks_probe")
.then(Commands.argument("name", StringArgumentType.word())
.executes(PortAgeCommands::probeRocks)))
.then(Commands.literal("vegetation_probe")
.then(Commands.argument("name", StringArgumentType.word())
.executes(PortAgeCommands::probeVegetation)))
.then(Commands.literal("dungeons_probe")
.then(Commands.argument("name", StringArgumentType.word())
.executes(PortAgeCommands::probeDungeons)))
.then(Commands.literal("lakes_probe")
.then(Commands.argument("name", StringArgumentType.word())
.executes(PortAgeCommands::probeLakes)))
.then(Commands.literal("islands_probe")
.then(Commands.argument("name", StringArgumentType.word())
.executes(PortAgeCommands::probeIslands)))
.then(Commands.literal("biome")
.then(Commands.argument("name", StringArgumentType.word())
.then(Commands.argument("x", IntegerArgumentType.integer())
@@ -243,6 +392,469 @@ public final class PortAgeCommands {
.executes(PortAgeCommands::sampleBiome)))))));
}
private static int createSizeAge(CommandContext<CommandSourceStack> context, int number) {
try {
java.util.function.Function<String, Identifier> symbol = path -> Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, path);
var pages = new java.util.ArrayList<Identifier>();
pages.add(symbol.apply("period/zero"));
pages.add(symbol.apply("phase/zenith"));
if (number >= 0) pages.add(symbol.apply("number/" + number));
pages.add(symbol.apply("size/" + StringArgumentType.getString(context, "size")));
pages.add(PortDayCycle.NORMAL_TIME);
pages.addAll(java.util.stream.Stream.of("lighting/normal", "weather/off", "terrain/flat").map(symbol).toList());
var server = context.getSource().getServer();
long seed = LongArgumentType.getLong(context, "seed");
long origin = PortDayCycle.nativeTicks(server.overworld());
var cycle = PortDayCycle.compile(pages, seed, origin);
if (!net.dnorberg.rivencraft.writing.PortAuthoredWriting.supports(pages))
throw new IllegalArgumentException("Unsupported size fixture writing");
for (var size : java.util.Map.of("small", 0.5F, "normal", 1F, "large", 2F).entrySet()) {
var selected = PortDayCycle.compile(java.util.List.of(symbol.apply("size/" + size.getKey()),
PortDayCycle.NORMAL_TIME), seed, origin);
if (selected.sunSize() != size.getValue() || selected.ticks(origin) != origin)
throw new IllegalStateException("Size mapping changed cycle timing");
}
for (int n = 0; n <= 24; n++) {
var selected = PortDayCycle.compile(java.util.List.of(symbol.apply("number/" + n),
symbol.apply("size/large"), PortDayCycle.NORMAL_TIME), seed, origin);
float expected = (float) (0.25D + n * (2.75D / 24D));
if (selected.sunSize() != expected || n == 0 && expected != 0.25F
|| n == 12 && expected != 1.625F || n == 24 && expected != 3F)
throw new IllegalStateException("Numeric size override failed");
}
var older = PortDayCycle.CODEC.parse(com.mojang.serialization.JsonOps.INSTANCE,
com.google.gson.JsonParser.parseString("{\"authored\":true,\"period\":1,\"stopped_time\":0,"
+ "\"origin_tick\":100,\"phase_tick\":6000,\"clockwise\":false,\"bearing\":90}")).getOrThrow();
if (!older.equals(new PortDayCycle(true, 1, 0, 100, 6000, false, 90)))
throw new IllegalStateException("Old cycle size fallback failed");
var biomes = net.dnorberg.rivencraft.writing.PortAuthoredWriting.biomePlan(pages, server.registryAccess());
var environment = PortEnvironmentSelection.resolve(pages, seed, PortAgeTerrain.FLAT);
if (environment.sky() != PortSkyStyle.OVERWORLD)
throw new IllegalStateException("Sized celestial selected an incompatible sky");
RuntimeAgeBackend.create(server, ageId(context), seed, biomes, PortAgeTerrain.FLAT,
null, null, 0, null, null, environment.weather(), environment.lighting(), environment.sky(),
PortAgeColors.compile(pages, seed), cycle);
return probeDayCycle(context);
} catch (IllegalArgumentException | IllegalStateException exception) {
context.getSource().sendFailure(Component.literal(exception.getMessage()));
return 0;
}
}
private static int createAngleAge(CommandContext<CommandSourceStack> context, int number) {
try {
java.util.function.Function<String, Identifier> symbol = path -> Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, path);
var pages = new java.util.ArrayList<Identifier>();
pages.add(symbol.apply("period/zero"));
pages.add(symbol.apply("phase/rising"));
if (number >= 0) pages.add(symbol.apply("number/" + number));
pages.add(symbol.apply("angle/" + StringArgumentType.getString(context, "bearing")));
pages.add(PortDayCycle.NORMAL_TIME);
pages.addAll(java.util.stream.Stream.of("sky/overworld", "lighting/normal", "weather/off", "terrain/flat")
.map(symbol).toList());
var server = context.getSource().getServer();
long seed = LongArgumentType.getLong(context, "seed");
long origin = PortDayCycle.nativeTicks(server.overworld());
var cycle = PortDayCycle.compile(pages, seed, origin);
if (!net.dnorberg.rivencraft.writing.PortAuthoredWriting.supports(pages))
throw new IllegalArgumentException("Unsupported angle fixture writing");
for (var bearing : java.util.Map.of("north", 0, "east", 90, "south", 180, "west", 270).entrySet()) {
var selected = PortDayCycle.compile(java.util.List.of(symbol.apply("angle/" + bearing.getKey()),
PortDayCycle.NORMAL_TIME), seed, origin);
if (selected.bearing() != bearing.getValue() || selected.ticks(origin) != origin)
throw new IllegalStateException("Angle mapping changed cycle timing");
}
for (int n = 0; n <= 24; n++) {
var selected = PortDayCycle.compile(java.util.List.of(symbol.apply("number/" + n),
symbol.apply("angle/west"), PortDayCycle.NORMAL_TIME), seed, origin);
if (selected.bearing() != n * 15 % 360)
throw new IllegalStateException("Numeric angle override failed");
}
var older = PortDayCycle.CODEC.parse(com.mojang.serialization.JsonOps.INSTANCE,
com.google.gson.JsonParser.parseString("{\"authored\":true,\"period\":1,\"stopped_time\":0,"
+ "\"origin_tick\":100,\"phase_tick\":6000,\"clockwise\":false}")).getOrThrow();
if (!older.equals(new PortDayCycle(true, 1, 0, 100, 6000, false)))
throw new IllegalStateException("Old cycle bearing fallback failed");
var biomes = net.dnorberg.rivencraft.writing.PortAuthoredWriting.biomePlan(pages, server.registryAccess());
RuntimeAgeBackend.create(server, ageId(context), seed, biomes, PortAgeTerrain.FLAT,
null, null, 0, null, null, PortWeatherMode.OFF, PortLighting.NORMAL, PortSkyStyle.OVERWORLD,
PortAgeColors.compile(pages, seed), cycle);
return probeDayCycle(context);
} catch (IllegalArgumentException | IllegalStateException exception) {
context.getSource().sendFailure(Component.literal(exception.getMessage()));
return 0;
}
}
private static int createPhaseAge(CommandContext<CommandSourceStack> context, int number) {
try {
java.util.function.Function<String, Identifier> symbol = path -> Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, path);
var pages = new java.util.ArrayList<Identifier>();
pages.add(symbol.apply("period/" + StringArgumentType.getString(context, "length")));
if (number >= 0) pages.add(symbol.apply("number/" + number));
pages.add(symbol.apply("phase/" + StringArgumentType.getString(context, "phase")));
pages.add(symbol.apply("direction/" + StringArgumentType.getString(context, "direction")));
pages.add(PortDayCycle.NORMAL_TIME);
pages.addAll(java.util.stream.Stream.of("sky/overworld", "lighting/normal", "weather/off", "terrain/flat")
.map(symbol).toList());
var server = context.getSource().getServer();
long seed = LongArgumentType.getLong(context, "seed");
long origin = PortDayCycle.nativeTicks(server.overworld());
var cycle = PortDayCycle.compile(pages, seed, origin);
if (!net.dnorberg.rivencraft.writing.PortAuthoredWriting.supports(pages))
throw new IllegalArgumentException("Unsupported phase fixture writing");
for (var phase : java.util.Map.of("rising", 0, "zenith", 6000, "setting", 12000, "nadir", 18000).entrySet()) {
var forward = PortDayCycle.compile(java.util.List.of(symbol.apply("phase/" + phase.getKey()),
PortDayCycle.NORMAL_TIME), seed, origin);
var reverse = PortDayCycle.compile(java.util.List.of(symbol.apply("phase/" + phase.getKey()),
symbol.apply("direction/counterclockwise"), PortDayCycle.NORMAL_TIME), seed, origin);
var stopped = PortDayCycle.compile(java.util.List.of(symbol.apply("period/zero"),
symbol.apply("phase/" + phase.getKey()), PortDayCycle.NORMAL_TIME), seed, origin);
if (forward.ticks(origin) != phase.getValue() || reverse.ticks(origin) != phase.getValue()
|| forward.ticks(origin + 1000) != phase.getValue() + 1000
|| reverse.ticks(origin + 1000) != phase.getValue() - 1000
|| stopped.ticks(origin + 123456) != phase.getValue())
throw new IllegalStateException("Phase/direction fixture failed");
}
var legacy = PortDayCycle.CODEC.parse(com.mojang.serialization.JsonOps.INSTANCE,
com.google.gson.JsonParser.parseString("{\"authored\":true,\"period\":0,\"stopped_time\":321}"))
.getOrThrow();
if (!legacy.equals(new PortDayCycle(true, 0, 321)))
throw new IllegalStateException("Old day-cycle defaults changed");
var biomes = net.dnorberg.rivencraft.writing.PortAuthoredWriting.biomePlan(pages, server.registryAccess());
RuntimeAgeBackend.create(server, ageId(context), seed, biomes, PortAgeTerrain.FLAT,
null, null, 0, null, null, PortWeatherMode.OFF, PortLighting.NORMAL, PortSkyStyle.OVERWORLD,
PortAgeColors.compile(pages, seed), cycle);
return probeDayCycle(context);
} catch (IllegalArgumentException | IllegalStateException exception) {
context.getSource().sendFailure(Component.literal(exception.getMessage()));
return 0;
}
}
private static int createDayCycleAge(CommandContext<CommandSourceStack> context, int number) {
try {
java.util.function.Function<String, Identifier> symbol = path -> Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, path);
var pages = new java.util.ArrayList<Identifier>();
if (number >= 0) pages.add(symbol.apply("number/" + number));
pages.add(symbol.apply("period/" + StringArgumentType.getString(context, "length")));
pages.add(PortDayCycle.NORMAL_TIME);
pages.addAll(java.util.stream.Stream.of("lighting/normal", "weather/off", "terrain/flat")
.map(symbol).toList());
long seed = LongArgumentType.getLong(context, "seed");
var cycle = PortDayCycle.compile(pages, seed);
if (!net.dnorberg.rivencraft.writing.PortAuthoredWriting.supports(pages)
|| !cycle.equals(PortDayCycle.compile(pages, seed))
|| new PortDayCycle(true, 0.5, 0).ticks(12000) != 24000
|| new PortDayCycle(true, 2, 0).ticks(12000) != 6000
|| new PortDayCycle(true, 0, 321).ticks(-123456) != 321
|| new PortDayCycle(true, 0, 321).ticks(123456) != 321
|| PortDayCycle.validWriting(java.util.List.of(symbol.apply("period/half")))
|| PortDayCycle.validWriting(java.util.List.of(PortDayCycle.NORMAL_TIME, symbol.apply("period/half"))))
throw new IllegalStateException("Day cycle fixture failed");
var server = context.getSource().getServer();
var biomes = net.dnorberg.rivencraft.writing.PortAuthoredWriting.biomePlan(pages, server.registryAccess());
var environment = PortEnvironmentSelection.resolve(pages, seed, PortAgeTerrain.FLAT);
if (environment.sky() != PortSkyStyle.OVERWORLD)
throw new IllegalStateException("Celestial-only writing selected an incompatible sky");
RuntimeAgeBackend.create(server, ageId(context), seed, biomes, PortAgeTerrain.FLAT,
null, null, 0, null, null, environment.weather(), environment.lighting(), environment.sky(),
PortAgeColors.compile(pages, seed), cycle);
return probeDayCycle(context);
} catch (IllegalArgumentException | IllegalStateException exception) {
context.getSource().sendFailure(Component.literal(exception.getMessage()));
return 0;
}
}
private static int probeDayCycle(CommandContext<CommandSourceStack> context) {
var server = context.getSource().getServer();
var level = server.getLevel(ResourceKey.create(Registries.DIMENSION, ageId(context)));
if (level == null) {
context.getSource().sendFailure(Component.literal("Age not loaded"));
return 0;
}
verifyCatalogRoundTrip(server);
var definition = server.registryAccess().lookupOrThrow(Registries.WORLD_CLOCK)
.getOrThrow(net.minecraft.world.clock.WorldClocks.OVERWORLD);
var source = level.clockManager().getInstance(definition);
var sample = PortDayCycle.timelines(level, level.clockManager()).getInstance(definition);
var cycle = PortDayCycle.forLevel(level);
if (cycle.ticks(source.totalTicks()) != sample.totalTicks()
|| cycle.authored() && cycle.period() == 0 && (sample.partialTick() != 0 || sample.rate() != 0))
throw new IllegalStateException("Day cycle clock invariant failed");
var pos = new net.minecraft.core.BlockPos(0, 80, 0);
var sun = level.environmentAttributes().getValue(net.minecraft.world.attribute.EnvironmentAttributes.SUN_ANGLE, pos);
var light = level.environmentAttributes().getDimensionValue(net.minecraft.world.attribute.EnvironmentAttributes.SKY_LIGHT_LEVEL);
context.getSource().sendSuccess(() -> Component.literal("Cycle probe " + ageId(context)
+ " saved=" + cycle + " nativeTicks=" + source.totalTicks() + " ageTicks=" + sample.totalTicks()
+ " sunAngle=" + sun + " skyLight=" + light), false);
return 1;
}
private static int createColorAge(CommandContext<CommandSourceStack> context) {
try {
String sky = StringArgumentType.getString(context, "sky").toLowerCase(Locale.ROOT);
PortSkyStyle style = PortSkyStyle.valueOf(sky.toUpperCase(Locale.ROOT));
var pages = java.util.stream.Stream.of("number/10", "color/red", "number/5", "color/green",
"number/15", "color/blue", "sky/" + sky, "number/20", "color/white",
"number/10", "color/black", "number/5", "color/green", "number/10", "color/blue",
"lighting/ambient_color", "lighting/bright", "weather/off", "terrain/flat")
.map(path -> Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, path)).toList();
long seed = LongArgumentType.getLong(context, "seed");
var colors = PortAgeColors.compile(pages, seed);
java.util.function.Function<String, Identifier> symbol = path -> Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, path);
var trailing = PortAgeColors.compile(java.util.List.of(symbol.apply("number/24"), symbol.apply("color/red")), seed);
var zero = PortAgeColors.compile(java.util.List.of(symbol.apply("number/0"), symbol.apply("color/blue")), seed);
var clamped = PortAgeColors.compile(java.util.List.of(symbol.apply("number/24"), symbol.apply("color/white"),
symbol.apply("number/24"), symbol.apply("color/red")), seed);
var unnumbered = java.util.List.of(symbol.apply("color/blue"));
if (!net.dnorberg.rivencraft.writing.PortAuthoredWriting.supports(pages)
|| colors.sky() != 0x643296 || colors.ambient() != 0x6496c8
|| !colors.equals(PortAgeColors.compile(pages, seed))
|| trailing.sky() != 0xf00000 || zero.sky() != 0 || clamped.sky() != 0xfff0f0
|| !PortAgeColors.compile(unnumbered, seed).equals(PortAgeColors.compile(unnumbered, seed))
|| PortAgeColors.validNumbers(java.util.List.of(symbol.apply("number/5")))
|| !PortAgeColors.compile(java.util.List.of(), seed).equals(PortAgeColors.NATIVE))
throw new IllegalStateException("Authored color fixture failed");
var server = context.getSource().getServer();
var biomes = net.dnorberg.rivencraft.writing.PortAuthoredWriting.biomePlan(pages, server.registryAccess());
RuntimeAgeBackend.create(server, ageId(context), seed, biomes, PortAgeTerrain.FLAT,
null, null, 0, null, null, PortWeatherMode.OFF, PortLighting.BRIGHT, style, colors);
return probeColors(context);
} catch (IllegalArgumentException | IllegalStateException exception) {
context.getSource().sendFailure(Component.literal(exception.getMessage()));
return 0;
}
}
private static int probeColors(CommandContext<CommandSourceStack> context) {
var server = context.getSource().getServer();
var level = server.getLevel(ResourceKey.create(Registries.DIMENSION, ageId(context)));
if (level == null) {
context.getSource().sendFailure(Component.literal("Age not loaded"));
return 0;
}
var colors = PortAgeColors.forLevel(level);
verifyCatalogRoundTrip(server);
var pos = new net.minecraft.core.BlockPos(0, 80, 0);
var sky = level.environmentAttributes().getValue(net.minecraft.world.attribute.EnvironmentAttributes.SKY_COLOR, pos);
var fog = level.environmentAttributes().getValue(net.minecraft.world.attribute.EnvironmentAttributes.FOG_COLOR, pos);
var ambient = level.environmentAttributes().getValue(net.minecraft.world.attribute.EnvironmentAttributes.AMBIENT_LIGHT_COLOR, pos);
if (colors.sky() >= 0 && (!sky.equals(PortAgeColors.vector(colors.sky())) || !fog.equals(sky))
|| colors.ambient() >= 0 && !ambient.equals(PortAgeColors.vector(colors.ambient()))) {
context.getSource().sendFailure(Component.literal("Color attribute mismatch"));
return 0;
}
context.getSource().sendSuccess(() -> Component.literal("Color probe " + ageId(context)
+ " saved=" + colors + " sky=" + sky + " fog=" + fog + " ambient=" + ambient), false);
return 1;
}
private static void verifyCatalogRoundTrip(net.minecraft.server.MinecraftServer server) {
var payload = new AgeCatalogPayload(1, server.levelKeys().stream().map(key -> key.identifier()).toList(),
RuntimeAgeBackend.savedColors(server), RuntimeAgeBackend.savedDayCycles(server));
var buffer = new net.minecraft.network.RegistryFriendlyByteBuf(io.netty.buffer.Unpooled.buffer(),
server.registryAccess(), net.neoforged.neoforge.network.connection.ConnectionType.NEOFORGE);
try {
AgeCatalogPayload.STREAM_CODEC.encode(buffer, payload);
if (!payload.equals(AgeCatalogPayload.STREAM_CODEC.decode(buffer)))
throw new IllegalStateException("Age catalog round-trip failed");
} finally { buffer.release(); }
}
private static int createSkyAge(CommandContext<CommandSourceStack> context) {
try {
PortSkyStyle sky = PortSkyStyle.valueOf(StringArgumentType.getString(context, "sky").toUpperCase(Locale.ROOT));
PortLighting lighting = PortLighting.valueOf(StringArgumentType.getString(context, "lighting").toUpperCase(Locale.ROOT));
PortAgeTerrain terrain = PortAgeTerrain.valueOf(StringArgumentType.getString(context, "terrain").toUpperCase(Locale.ROOT));
var pages = java.util.List.of(
Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "sky/" + sky.name().toLowerCase(Locale.ROOT)),
Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "lighting/" + lighting.name().toLowerCase(Locale.ROOT)),
Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "terrain/" + terrain.name().toLowerCase(Locale.ROOT)),
Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "weather/off"));
var server = context.getSource().getServer();
var biomes = net.dnorberg.rivencraft.writing.PortAuthoredWriting.biomePlan(pages, server.registryAccess());
RuntimeAgeBackend.create(server, ageId(context), LongArgumentType.getLong(context, "seed"),
biomes, terrain, null, null, 0, null, null, PortWeatherMode.OFF, lighting, sky);
return probeSky(context);
} catch (IllegalArgumentException | IllegalStateException exception) {
context.getSource().sendFailure(Component.literal(exception.getMessage()));
return 0;
}
}
private static int probeSky(CommandContext<CommandSourceStack> context) {
Identifier id = ageId(context);
var server = context.getSource().getServer();
var level = server.getLevel(ResourceKey.create(Registries.DIMENSION, id));
if (level == null) {
context.getSource().sendFailure(Component.literal("Age not loaded: " + id));
return 0;
}
var type = level.dimensionType();
context.getSource().sendSuccess(() -> Component.literal("Sky probe " + id
+ " type=" + level.dimensionTypeRegistration().unwrapKey().orElseThrow().identifier()
+ " skybox=" + type.skybox() + " skylight=" + type.hasSkyLight() + " ambient=" + type.ambientLight()
+ " fog=" + level.environmentAttributes().getValue(net.minecraft.world.attribute.EnvironmentAttributes.FOG_COLOR,
new net.minecraft.core.BlockPos(0, 80, 0))
+ " ceiling=" + type.hasCeiling() + " scale=" + type.coordinateScale()
+ " minY=" + type.minY() + " height=" + type.height() + " dragon=" + type.hasEnderDragonFight()), false);
return 1;
}
private static int probeEnvironment(CommandContext<CommandSourceStack> context) {
try {
long seed = LongArgumentType.getLong(context, "seed");
PortAgeTerrain terrain = PortAgeTerrain.valueOf(
StringArgumentType.getString(context, "terrain").toUpperCase(Locale.ROOT));
var selected = PortEnvironmentSelection.resolve(java.util.List.of(), seed, terrain);
var explicitPages = java.util.List.of(
Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "weather/storm"),
Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "lighting/bright"));
var explicit = PortEnvironmentSelection.resolve(explicitPages, seed, terrain);
var weatherOnly = PortEnvironmentSelection.resolve(java.util.List.of(explicitPages.get(0)), seed, terrain);
var lightingOnly = PortEnvironmentSelection.resolve(java.util.List.of(explicitPages.get(1)), seed, terrain);
if (!selected.equals(PortEnvironmentSelection.resolve(java.util.List.of(), seed, terrain))
|| explicit.lighting() != PortLighting.BRIGHT
|| explicit.weather() != (terrain == PortAgeTerrain.CAVE ? PortWeatherMode.OFF : PortWeatherMode.STORM)
|| weatherOnly.lighting() != selected.lighting() || lightingOnly.weather() != selected.weather()
|| explicit.sky() != selected.sky() || weatherOnly.sky() != selected.sky()
|| lightingOnly.sky() != selected.sky())
throw new IllegalStateException("Environment selection invariant failed");
for (PortSkyStyle sky : PortSkyStyle.values()) {
var page = Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID,
"sky/" + sky.name().toLowerCase(Locale.ROOT));
var skyOnly = PortEnvironmentSelection.resolve(java.util.List.of(page), seed, terrain);
var allExplicit = PortEnvironmentSelection.resolve(java.util.List.of(
page, explicitPages.get(0), explicitPages.get(1)), seed, terrain);
if (skyOnly.sky() != sky || allExplicit.sky() != sky
|| skyOnly.weather() != selected.weather() || skyOnly.lighting() != selected.lighting()
|| allExplicit.weather() != explicit.weather() || allExplicit.lighting() != explicit.lighting())
throw new IllegalStateException("Sky override/independence invariant failed: " + sky);
}
for (String celestial : java.util.List.of("normal_time", "sun", "moon")) {
var page = Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "celestial/" + celestial);
var automatic = PortEnvironmentSelection.resolve(java.util.List.of(page), seed, terrain);
if (automatic.sky() != PortSkyStyle.OVERWORLD || automatic.weather() != selected.weather()
|| automatic.lighting() != selected.lighting())
throw new IllegalStateException("Celestial-compatible sky fallback failed: " + celestial);
for (PortSkyStyle sky : PortSkyStyle.values()) {
var skyPage = Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID,
"sky/" + sky.name().toLowerCase(Locale.ROOT));
if (PortSkyStyle.resolve(java.util.List.of(page, skyPage), seed) != sky
|| PortSkyStyle.resolve(java.util.List.of(skyPage, page), seed) != sky)
throw new IllegalStateException("Explicit sky lost precedence for " + celestial);
}
}
context.getSource().sendSuccess(() -> Component.literal("Environment probe seed=" + seed
+ " terrain=" + terrain + " selected=" + selected + " explicit=" + explicit
+ " deterministic=true skyOverrides=true celestialFallback=true"), false);
return 1;
} catch (IllegalArgumentException | IllegalStateException exception) {
context.getSource().sendFailure(Component.literal(exception.getMessage()));
return 0;
}
}
private static int createLightingAge(CommandContext<CommandSourceStack> context) {
try {
PortLighting mode = PortLighting.valueOf(
StringArgumentType.getString(context, "mode").toUpperCase(Locale.ROOT));
PortAgeTerrain terrain = PortAgeTerrain.valueOf(
StringArgumentType.getString(context, "terrain").toUpperCase(Locale.ROOT));
var pages = java.util.List.of(
Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "lighting/" + mode.name().toLowerCase(Locale.ROOT)),
Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "terrain/" + terrain.name().toLowerCase(Locale.ROOT)));
var server = context.getSource().getServer();
var biomePlan = net.dnorberg.rivencraft.writing.PortAuthoredWriting.biomePlan(pages, server.registryAccess());
RuntimeAgeBackend.create(server, ageId(context), LongArgumentType.getLong(context, "seed"),
biomePlan, terrain, null, null, 0, null, null, PortWeatherMode.NORMAL, mode);
return probeLighting(context);
} catch (IllegalArgumentException | IllegalStateException exception) {
context.getSource().sendFailure(Component.literal(exception.getMessage()));
return 0;
}
}
private static int probeLighting(CommandContext<CommandSourceStack> context) {
Identifier id = ageId(context);
var server = context.getSource().getServer();
ServerLevel level = server.getLevel(ResourceKey.create(Registries.DIMENSION, id));
if (level == null) {
context.getSource().sendFailure(Component.literal("Age not loaded: " + id));
return 0;
}
context.getSource().sendSuccess(() -> Component.literal("Lighting probe " + id
+ " type=" + level.dimensionTypeRegistration().unwrapKey().orElseThrow().identifier()
+ " skylight=" + level.dimensionType().hasSkyLight()
+ " ambient=" + level.dimensionType().ambientLight()
+ " undergroundBrightness=" + level.getMaxLocalRawBrightness(
new net.minecraft.core.BlockPos(0, level.getMinY() + 2, 0))
+ " vanillaDarkEnough=" + net.minecraft.world.entity.monster.Monster.isDarkEnoughToSpawn(
level, new net.minecraft.core.BlockPos(0, level.getMinY() + 2, 0),
net.minecraft.util.RandomSource.create(0))
+ " overworldSkylight=" + server.overworld().dimensionType().hasSkyLight()
+ " overworldAmbient=" + server.overworld().dimensionType().ambientLight()), false);
return 1;
}
private static int probeSurface(CommandContext<CommandSourceStack> context) {
Identifier id = ageId(context);
ServerLevel level = context.getSource().getServer().getLevel(ResourceKey.create(Registries.DIMENSION, id));
if (level == null) {
context.getSource().sendFailure(Component.literal("Age not loaded: " + id));
return 0;
}
int x = IntegerArgumentType.getInteger(context, "x");
int z = IntegerArgumentType.getInteger(context, "z");
var obstruction = PortAgeTravel.highestObstruction(level, x, z);
var landing = PortAgeTravel.findSurfaceLanding(level, x, z);
context.getSource().sendSuccess(() -> Component.literal("Surface probe " + id
+ " highest=" + obstruction + " landing=" + landing), false);
return landing == null ? 0 : 1;
}
private static int createWeatherAge(CommandContext<CommandSourceStack> context) {
try {
PortWeatherMode mode = PortWeatherMode.valueOf(
StringArgumentType.getString(context, "mode").toUpperCase(Locale.ROOT));
Identifier id = ageId(context);
var server = context.getSource().getServer();
// Exercise the Binder's page compiler too, not just direct backend creation.
var pages = java.util.List.of(Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID,
"weather/" + mode.name().toLowerCase(Locale.ROOT)));
var biomePlan = net.dnorberg.rivencraft.writing.PortAuthoredWriting.biomePlan(
pages, server.registryAccess());
RuntimeAgeBackend.create(server, id,
LongArgumentType.getLong(context, "seed"), biomePlan, PortAgeTerrain.FLAT,
null, null, 0, null, null, mode);
return probeWeather(context);
} catch (IllegalArgumentException | IllegalStateException exception) {
context.getSource().sendFailure(Component.literal(exception.getMessage()));
return 0;
}
}
private static int probeWeather(CommandContext<CommandSourceStack> context) {
Identifier id = ageId(context);
var server = context.getSource().getServer();
ServerLevel loaded = server.getLevel(ResourceKey.create(Registries.DIMENSION, id));
if (!(loaded instanceof PortAgeLevel level)) {
context.getSource().sendFailure(Component.literal("Age not loaded: " + id));
return 0;
}
var weather = level.getWeatherData();
context.getSource().sendSuccess(() -> Component.literal("Weather probe " + id
+ " mode=" + level.weatherMode() + " raining=" + weather.isRaining()
+ " thundering=" + weather.isThundering() + " rainTicks=" + weather.getRainTime()
+ " thunderTicks=" + weather.getThunderTime()
+ " independent=" + (weather != server.getWeatherData())
+ " overworldRaining=" + server.getWeatherData().isRaining()
+ " overworldThundering=" + server.getWeatherData().isThundering()), false);
return 1;
}
private static int createAge(CommandContext<CommandSourceStack> context, PortAgeTerrain terrain) {
return createAge(context, terrain, null);
}
@@ -255,30 +867,239 @@ public final class PortAgeCommands {
private static int probeRocks(CommandContext<CommandSourceStack> context) {
Identifier id = ageId(context);
ServerLevel level = context.getSource().getServer().getLevel(ResourceKey.create(Registries.DIMENSION, id));
if (level == null || !(level.getChunkSource().getGenerator() instanceof PortRockChunkGenerator rocks)) {
if (level == null || !(level.getChunkSource().getGenerator() instanceof PortFeatureChunkGenerator features)
|| features.rocks() == null) {
context.getSource().sendFailure(Component.literal("Age has no loaded Rocks generator: " + id));
return 0;
}
var material = net.minecraft.core.registries.BuiltInRegistries.BLOCK.getValue(rocks.rocks().material());
var materials = features.rocks().materials().stream()
.map(net.minecraft.core.registries.BuiltInRegistries.BLOCK::getValue).toList();
int count = 0;
for (int x = 0; x < 64; x++) for (int z = 0; z < 64; z++) {
int y = level.getHeight(Heightmap.Types.MOTION_BLOCKING, x, z) - 1;
if (level.getBlockState(new net.minecraft.core.BlockPos(x, y, z)).is(material)) count++;
var state = level.getBlockState(new net.minecraft.core.BlockPos(x, y, z));
if (materials.stream().anyMatch(state::is)) count++;
}
int placed = count;
context.getSource().sendSuccess(() -> Component.literal(
"Rocks probe " + id + " material=" + rocks.rocks().material() + " count=" + placed), false);
"Rocks probe " + id + " materials=" + features.rocks().materials() + " count=" + placed), false);
return placed;
}
private static int probeVegetation(CommandContext<CommandSourceStack> context) {
Identifier id = ageId(context);
ServerLevel level = context.getSource().getServer().getLevel(ResourceKey.create(Registries.DIMENSION, id));
if (level == null) {
context.getSource().sendFailure(Component.literal("Age not loaded: " + id));
return 0;
}
int flowers = 0;
int logs = 0;
for (int x = 0; x < 64; x++) for (int z = 0; z < 64; z++) {
for (int y = 4; y < 32; y++) {
var state = level.getBlockState(new net.minecraft.core.BlockPos(x, y, z));
if (state.is(BlockTags.FLOWERS)) flowers++;
if (state.is(BlockTags.LOGS)) logs++;
}
}
int flowerCount = flowers;
int logCount = logs;
context.getSource().sendSuccess(() -> Component.literal(
"Vegetation probe " + id + " flowers=" + flowerCount + " logs=" + logCount), false);
return flowerCount + logCount;
}
private static PortVegetationPlan testVegetation() {
java.util.List<Identifier> pages = new java.util.ArrayList<>();
for (int i = 0; i < 10; i++) {
pages.add(Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "feature/flowers"));
pages.add(Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "feature/trees"));
pages.add(Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "feature/huge_trees"));
}
return net.dnorberg.rivencraft.writing.PortAuthoredWriting.vegetationPlan(pages);
}
private static int testDungeons() {
Identifier page = Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "feature/dungeons");
return net.dnorberg.rivencraft.writing.PortAuthoredWriting.dungeonCount(
java.util.Collections.nCopies(8, page));
}
private static int probeDungeons(CommandContext<CommandSourceStack> context) {
Identifier id = ageId(context);
ServerLevel level = context.getSource().getServer().getLevel(ResourceKey.create(Registries.DIMENSION, id));
if (level == null || !(level.getChunkSource().getGenerator() instanceof PortFeatureChunkGenerator features)) {
context.getSource().sendFailure(Component.literal("Age has no loaded feature generator: " + id));
return 0;
}
int count = features.dungeons();
int spawners = 0;
for (int chunkX = 0; chunkX < 4; chunkX++) for (int chunkZ = 0; chunkZ < 4; chunkZ++) {
var chunk = level.getChunk(chunkX, chunkZ);
for (var pos : chunk.getBlockEntitiesPos()) {
if (chunk.getBlockState(pos).is(Blocks.SPAWNER)) spawners++;
}
}
int placed = spawners;
context.getSource().sendSuccess(() -> Component.literal(
"Dungeons probe " + id + " pages=" + count + " spawners=" + placed), false);
return placed;
}
private static PortLakePlan testLakes(long seed, boolean randomFluids) {
java.util.List<Identifier> pages = new java.util.ArrayList<>();
for (int i = 0; i < 8; i++) {
for (String kind : java.util.List.of("surface_lakes", "deep_lakes", "lava_lakes")) {
if (!randomFluids) pages.add(net.dnorberg.rivencraft.writing.PortRegistryPages.blockPageId(
Identifier.withDefaultNamespace("water")));
pages.add(Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "feature/" + kind));
}
}
return net.dnorberg.rivencraft.writing.PortAuthoredWriting.lakePlan(pages, seed);
}
private static int probeLakes(CommandContext<CommandSourceStack> context) {
Identifier id = ageId(context);
ServerLevel level = context.getSource().getServer().getLevel(ResourceKey.create(Registries.DIMENSION, id));
if (level == null) {
context.getSource().sendFailure(Component.literal("Age not loaded: " + id));
return 0;
}
PortLakePlan lakes = level.getChunkSource().getGenerator() instanceof PortFeatureChunkGenerator features
? features.lakes() : null;
// Finish neighboring features before counting, since lake bowls can
// cross a chunk boundary. Include underground lakes in the probe.
for (int x = -1; x <= 4; x++) for (int z = -1; z <= 4; z++) level.getChunk(x, z);
int[] fluids = new int[2];
for (int x = 0; x < 4; x++) for (int z = 0; z < 4; z++) {
level.getChunk(x, z).findBlocks(state -> state.is(Blocks.WATER) || state.is(Blocks.LAVA),
(pos, state) -> fluids[state.is(Blocks.WATER) ? 0 : 1]++);
}
int waterCount = fluids[0];
int lavaCount = fluids[1];
context.getSource().sendSuccess(() -> Component.literal(
"Lakes probe " + id + " pages=" + (lakes == null ? 0 : lakes.choices().size())
+ " fluids=" + (lakes == null ? java.util.List.of() : lakes.choices().stream()
.map(PortLakePlan.Choice::fluid).distinct().toList())
+ " plan=" + (lakes == null ? 0 : lakes.choices().hashCode())
+ " water=" + waterCount + " lava=" + lavaCount), false);
return waterCount + lavaCount;
}
private static int createIslandAge(CommandContext<CommandSourceStack> context, boolean randomMaterials) {
PortAgeTerrain terrain;
try {
terrain = PortAgeTerrain.valueOf(StringArgumentType.getString(context, "terrain").toUpperCase(Locale.ROOT));
} catch (IllegalArgumentException exception) {
context.getSource().sendFailure(Component.literal("Unknown island test terrain"));
return 0;
}
java.util.List<Identifier> pages = new java.util.ArrayList<>();
pages.add(Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "terrain/" + terrain.name().toLowerCase(Locale.ROOT)));
pages.add(Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "biome/forest"));
for (int i = 0; i < 4; i++) {
if (!randomMaterials) pages.add(net.dnorberg.rivencraft.writing.PortRegistryPages.blockPageId(
Identifier.withDefaultNamespace("gold_block")));
pages.add(PortTerrainFeaturePlan.FLOATING_ISLANDS);
}
try {
long seed = LongArgumentType.getLong(context, "seed");
var biomes = net.dnorberg.rivencraft.writing.PortAuthoredWriting.biomePlan(
pages, context.getSource().getServer().registryAccess());
var features = net.dnorberg.rivencraft.writing.PortAuthoredWriting.terrainFeaturePlan(pages, seed);
return createAge(context, terrain, biomes, null, null, 0, null, features);
} catch (IllegalArgumentException exception) {
context.getSource().sendFailure(Component.literal(exception.getMessage()));
return 0;
}
}
private static int probeIslands(CommandContext<CommandSourceStack> context) {
Identifier id = ageId(context);
ServerLevel level = context.getSource().getServer().getLevel(ResourceKey.create(Registries.DIMENSION, id));
if (level == null || !(level.getChunkSource().getGenerator() instanceof PortFeatureChunkGenerator features)
|| features.terrainFeatures() == null) {
context.getSource().sendFailure(Component.literal("Age has no loaded island generator: " + id));
return 0;
}
var choices = features.terrainFeatures().choices();
var materials = choices.stream().map(PortTerrainFeaturePlan.Choice::material).distinct().toList();
var blocks = materials.stream().map(net.minecraft.core.registries.BuiltInRegistries.BLOCK::getValue).toList();
for (int x = -1; x <= 4; x++) for (int z = -1; z <= 4; z++) level.getChunk(x, z);
int[] count = {0};
long[] fingerprint = {0};
for (int x = 0; x < 4; x++) for (int z = 0; z < 4; z++) {
level.getChunk(x, z).findBlocks(state -> !state.isAir(), (pos, state) -> {
if (pos.getY() < 128 || pos.getY() >= 240) return;
if (blocks.stream().anyMatch(state::is)) count[0]++;
fingerprint[0] += pos.asLong() * 0x9E3779B97F4A7C15L ^ state.toString().hashCode();
});
}
context.getSource().sendSuccess(() -> Component.literal(
"Islands probe " + id + " pages=" + choices.size() + " materials=" + materials
+ " count=" + count[0] + " fingerprint=" + fingerprint[0]), false);
return count[0];
}
/** Exercise the actual ordered writing parser, not only the generator codec. */
private static PortRockPlan fourGoldRocks(long seed) {
Identifier block = net.dnorberg.rivencraft.writing.PortRegistryPages.blockPageId(
Identifier.withDefaultNamespace("gold_block"));
Identifier feature = Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "feature/rocks");
java.util.List<Identifier> pages = new java.util.ArrayList<>();
for (int i = 0; i < 4; i++) {
pages.add(block);
pages.add(feature);
}
return net.dnorberg.rivencraft.writing.PortAuthoredWriting.rockPlan(pages, seed);
}
private static int createAge(CommandContext<CommandSourceStack> context,
PortAgeTerrain terrain, PortBiomePlan biomePlan, PortRockPlan rockPlan) {
return createAge(context, terrain, biomePlan, rockPlan, null);
}
private static int createAge(CommandContext<CommandSourceStack> context,
PortAgeTerrain terrain, PortBiomePlan biomePlan,
PortRockPlan rockPlan, PortVegetationPlan vegetationPlan) {
return createAge(context, terrain, biomePlan, rockPlan, vegetationPlan, 0);
}
private static int createAge(CommandContext<CommandSourceStack> context,
PortAgeTerrain terrain, PortBiomePlan biomePlan,
PortRockPlan rockPlan, PortVegetationPlan vegetationPlan,
int dungeons) {
return createAge(context, terrain, biomePlan, rockPlan, vegetationPlan, dungeons, null);
}
private static int createAge(CommandContext<CommandSourceStack> context,
PortAgeTerrain terrain, PortBiomePlan biomePlan,
PortRockPlan rockPlan, PortVegetationPlan vegetationPlan,
int dungeons, PortLakePlan lakes) {
return createAge(context, terrain, biomePlan, rockPlan, vegetationPlan, dungeons, lakes, null);
}
private static int createAge(CommandContext<CommandSourceStack> context,
PortAgeTerrain terrain, PortBiomePlan biomePlan,
PortRockPlan rockPlan, PortVegetationPlan vegetationPlan,
int dungeons, PortLakePlan lakes, PortTerrainFeaturePlan terrainFeatures) {
return createAge(context, terrain, biomePlan, rockPlan, vegetationPlan, dungeons, lakes, terrainFeatures,
new PortEnvironmentSelection(PortWeatherMode.NORMAL, PortLighting.NORMAL, PortSkyStyle.OVERWORLD));
}
private static int createAge(CommandContext<CommandSourceStack> context,
PortAgeTerrain terrain, PortBiomePlan biomePlan,
PortRockPlan rockPlan, PortVegetationPlan vegetationPlan,
int dungeons, PortLakePlan lakes, PortTerrainFeaturePlan terrainFeatures,
PortEnvironmentSelection environment) {
String name = StringArgumentType.getString(context, "name");
Identifier id = Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "age/" + name);
long seed = LongArgumentType.getLong(context, "seed");
ServerLevel level;
try {
level = RuntimeAgeBackend.create(context.getSource().getServer(), id, seed, biomePlan, terrain, rockPlan);
level = RuntimeAgeBackend.create(context.getSource().getServer(), id, seed, biomePlan,
terrain, rockPlan, vegetationPlan, dungeons, lakes, terrainFeatures,
environment.weather(), environment.lighting(), environment.sky());
} catch (IllegalArgumentException | IllegalStateException exception) {
context.getSource().sendFailure(Component.literal(exception.getMessage()));
return 0;
@@ -0,0 +1,63 @@
package net.dnorberg.rivencraft.age;
import java.util.List;
import net.minecraft.resources.ResourceKey;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.level.Level;
import net.minecraft.world.level.dimension.LevelStem;
import net.minecraft.world.level.saveddata.WeatherData;
import net.minecraft.world.level.storage.DerivedLevelData;
/** Runtime Age with independent native weather, rather than 26.3's shared server cycle. */
final class PortAgeLevel extends ServerLevel {
private final WeatherData ageWeather;
private final PortWeatherSettings weatherSettings;
private final boolean cave;
private PortAgeColors colors = PortAgeColors.NATIVE;
private PortDayCycle dayCycle = PortDayCycle.NATIVE;
PortDayCycle dayCycle() { return dayCycle == null ? PortDayCycle.NATIVE : dayCycle; }
void configureDayCycle(PortDayCycle cycle) {
this.dayCycle = cycle;
environmentAttributes().invalidateTickCache();
updateSkyBrightness();
}
PortAgeColors colors() { return colors == null ? PortAgeColors.NATIVE : colors; }
void configureColors(PortAgeColors colors) {
this.colors = colors;
environmentAttributes().invalidateTickCache();
}
PortAgeLevel(MinecraftServer server, ResourceKey<Level> key, LevelStem stem,
long biomeZoomSeed, PortAgeTerrain terrain) {
super(server, server.executor, server.storageSource,
new DerivedLevelData(server.getWorldData(), server.getWorldData().overworldData()),
key, stem, server.getWorldData().isDebugWorld(), biomeZoomSeed, List.of(), false);
ageWeather = getDataStorage().computeIfAbsent(WeatherData.TYPE);
weatherSettings = getDataStorage().computeIfAbsent(PortWeatherSettings.TYPE);
cave = terrain == PortAgeTerrain.CAVE;
PortAgeWeather.apply(this);
// The base constructor initializes visual levels from server weather.
// Replace them before publishing this Age or sending its join packets.
setRainLevel(ageWeather.isRaining() ? 1.0F : 0.0F);
setThunderLevel(ageWeather.isThundering() ? 1.0F : 0.0F);
}
@Override
public WeatherData getWeatherData() {
// The base constructor may query weather before our fields are initialized.
return ageWeather == null ? super.getWeatherData() : ageWeather;
}
PortWeatherMode weatherMode() { return cave ? PortWeatherMode.OFF : weatherSettings.mode(); }
void configureWeather(PortWeatherMode mode) {
weatherSettings.setMode(mode);
PortAgeWeather.apply(this);
setRainLevel(ageWeather.isRaining() ? 1.0F : 0.0F);
setThunderLevel(ageWeather.isThundering() ? 1.0F : 0.0F);
getDataStorage().saveAndJoin();
}
}
@@ -11,8 +11,6 @@ import net.minecraft.server.level.ServerLevel;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.levelgen.Heightmap;
import net.minecraft.world.level.levelgen.FlatLevelSource;
import net.minecraft.world.level.levelgen.flat.FlatLevelGeneratorSettings;
/** Shared, server-authoritative landing for the 26.3 book and operator probe. */
public final class PortAgeTravel {
@@ -25,21 +23,22 @@ public final class PortAgeTravel {
if (!AgeCatalogSync.ready(player, id)) {
return "Age catalog not acknowledged by client; remaining in source dimension";
}
BlockPos landing = findLanding(target);
boolean fissure = target.getChunkSource().getGenerator() instanceof PortFeatureChunkGenerator features
&& features.terrainFeatures() != null && features.terrainFeatures().starFissures();
BlockPos landing = fissure ? PortStarFissureFeature.savedSpawn(target) : null;
if (landing != null) landing = isCaveTerrain(target)
? findFootedDestination(target, landing).orElse(null)
: findSurfaceLanding(target, landing.getX(), landing.getZ());
if (landing == null) landing = findLanding(target);
if (landing == null) return "No safe test landing available; remaining in source dimension";
if (fissure) PortStarFissureFeature.generateOnce(target, landing, isCaveTerrain(target));
boolean moved = player.teleportTo(target, landing.getX() + 0.5, landing.getY(), landing.getZ() + 0.5,
Set.of(), player.getYRot(), player.getXRot(), true);
return moved ? null : "Teleportation failed; remaining in source dimension";
}
private static BlockPos findLanding(ServerLevel target) {
net.minecraft.world.level.chunk.ChunkGenerator generator = target.getChunkSource().getGenerator();
if (generator instanceof PortRockChunkGenerator rocks) generator = rocks.base();
if (generator instanceof net.minecraft.world.level.levelgen.NoiseBasedChunkGenerator noise
&& noise.generatorSettings().value().noiseSettings().equals(target.registryAccess()
.lookupOrThrow(Registries.NOISE_SETTINGS)
.getOrThrow(net.minecraft.world.level.levelgen.NoiseGeneratorSettings.CAVES)
.value().noiseSettings())) {
if (isCaveTerrain(target)) {
// The cave roof is not a valid arrival surface. Search down from
// Legacy's Y=64 start, then construct the normal safety platform
// at that height only when no footed opening can be found.
@@ -56,36 +55,50 @@ public final class PortAgeTravel {
}
return makePlatform(target, new BlockPos(0, 64, 0));
}
// A Flat Age has a known surface. Use it directly before a heightmap
// fallback can place a platform in the air above a low superflat world.
FlatLevelGeneratorSettings flatSettings = switch (generator) {
case FlatLevelSource flat -> flat.settings();
case PortFlatChunkGenerator flat -> flat.settings();
default -> null;
};
if (flatSettings != null && !flatSettings.getLayersInfo().isEmpty()) {
int surfaceY = target.getMinY() + flatSettings.getLayersInfo().stream()
.mapToInt(layer -> layer.getHeight()).sum();
BlockPos surface = new BlockPos(0, surfaceY, 0);
if (hasFooting(target, surface)) return surface;
}
BlockPos surface = findSurfaceLanding(target, 0, 0);
if (surface != null) return surface;
// Only build above the actual highest obstruction, never in a pocket
// beneath it. Void Ages retain the usual platform floor at Y=63.
BlockPos obstruction = highestObstruction(target, 0, 0);
int y = obstruction == null ? 64 : Math.max(64, obstruction.getY() + 3);
return makePlatform(target, new BlockPos(0, y, 0));
}
static BlockPos findSurfaceLanding(ServerLevel target, int originX, int originZ) {
for (int radius = 0; radius <= 16; radius++) {
for (int x = -radius; x <= radius; x++) {
for (int z = -radius; z <= radius; z++) {
if (Math.max(Math.abs(x), Math.abs(z)) != radius) continue;
int y = target.getHeight(Heightmap.Types.MOTION_BLOCKING, x, z);
BlockPos foot = new BlockPos(x, y, z);
if (hasFooting(target, foot)) {
return foot;
}
BlockPos floor = highestObstruction(target, originX + x, originZ + z);
if (floor != null && hasFooting(target, floor.above())) return floor.above();
}
}
}
// The operator/book probe needs a deterministic landing even when
// origin is ocean or foliage. Final book landing rules are ported later.
int y = Math.max(64, target.getHeight(Heightmap.Types.MOTION_BLOCKING, 0, 0) + 2);
if (y + 2 >= target.getMaxY()) return null;
return makePlatform(target, new BlockPos(0, y, 0));
return null;
}
/** First obstructing surface seen from the sky; never continue through a roof or liquid. */
static BlockPos highestObstruction(ServerLevel level, int x, int z) {
var chunk = level.getChunk(x >> 4, z >> 4);
BlockPos.MutableBlockPos pos = new BlockPos.MutableBlockPos(x, level.getMaxY() - 1, z);
for (int y = level.getMaxY() - 1; y >= level.getMinY(); y--) {
pos.setY(y);
var state = chunk.getBlockState(pos);
if (!state.isAir() && (!state.getFluidState().isEmpty()
|| state.is(net.minecraft.tags.BlockTags.LEAVES)
|| !state.getCollisionShape(level, pos).isEmpty())) return pos.immutable();
}
return null;
}
private static boolean isCaveTerrain(ServerLevel target) {
var generator = target.getChunkSource().getGenerator();
if (generator instanceof PortFeatureChunkGenerator features) generator = features.base();
return generator instanceof net.minecraft.world.level.levelgen.NoiseBasedChunkGenerator noise
&& noise.generatorSettings().value().noiseSettings().equals(target.registryAccess()
.lookupOrThrow(Registries.NOISE_SETTINGS)
.getOrThrow(net.minecraft.world.level.levelgen.NoiseGeneratorSettings.CAVES)
.value().noiseSettings());
}
private static BlockPos makePlatform(ServerLevel target, BlockPos foot) {
@@ -108,11 +121,45 @@ public final class PortAgeTravel {
}
private static boolean hasFooting(ServerLevel target, BlockPos foot) {
if (foot.getY() <= target.getMinY() || foot.getY() + 1 >= target.getMaxY()
|| !target.getWorldBorder().isWithinBounds(foot)) return false;
BlockPos floor = foot.below();
return target.getBlockState(floor).isFaceSturdy(target, floor, Direction.UP)
var floorState = target.getBlockState(floor);
return (floorState.isFaceSturdy(target, floor, Direction.UP)
|| floorState.is(net.minecraft.tags.BlockTags.LEAVES))
&& floorState.getFluidState().isEmpty()
&& target.getFluidState(foot).isEmpty()
&& target.getFluidState(foot.above()).isEmpty()
&& target.getBlockState(foot).getCollisionShape(target, foot).isEmpty()
&& target.getBlockState(foot.above()).getCollisionShape(target, foot.above()).isEmpty();
&& passable(target, foot) && passable(target, foot.above());
}
private static boolean passable(ServerLevel level, BlockPos pos) {
var state = level.getBlockState(pos);
return state.getCollisionShape(level, pos).isEmpty() || state.is(net.minecraft.tags.BlockTags.LEAVES);
}
/** Same local-then-surface safe-return search used by 1.21.1 fissures. */
public static java.util.Optional<BlockPos> findFootedDestination(ServerLevel level, BlockPos origin) {
var local = BlockPos.betweenClosedStream(origin.offset(-4, -2, -4), origin.offset(4, 2, 4))
.map(BlockPos::immutable).sorted(java.util.Comparator.comparingDouble(pos -> pos.distSqr(origin)))
.filter(pos -> level.isInWorldBounds(pos) && level.getWorldBorder().isWithinBounds(pos))
.filter(pos -> hasFooting(level, pos)).findFirst();
if (local.isPresent()) return local;
for (int radius = 1; radius <= 64; radius++) {
final int ring = radius;
var found = BlockPos.betweenClosedStream(origin.offset(-ring, 0, -ring), origin.offset(ring, 0, ring))
.filter(pos -> Math.max(Math.abs(pos.getX() - origin.getX()), Math.abs(pos.getZ() - origin.getZ())) == ring)
.map(BlockPos::immutable)
.flatMap(pos -> {
BlockPos surface = new BlockPos(pos.getX(),
level.getHeight(Heightmap.Types.MOTION_BLOCKING, pos.getX(), pos.getZ()), pos.getZ());
return java.util.stream.Stream.of(surface, surface.above(), surface.below(),
surface.above(2), surface.below(2));
})
.filter(pos -> level.isInWorldBounds(pos) && level.getWorldBorder().isWithinBounds(pos))
.filter(pos -> hasFooting(level, pos)).findFirst();
if (found.isPresent()) return found;
}
return java.util.Optional.empty();
}
}
@@ -0,0 +1,52 @@
package net.dnorberg.rivencraft.age;
import java.util.Map;
import java.util.WeakHashMap;
import net.minecraft.world.level.saveddata.WeatherData;
import net.neoforged.neoforge.event.tick.LevelTickEvent;
/** 1.21.1 weather policies, applied before the independent native Age cycle advances. */
public final class PortAgeWeather {
private static final Map<PortAgeLevel, Integer> STATES = new WeakHashMap<>();
private PortAgeWeather() {}
public static void onLevelTickPre(LevelTickEvent.Pre event) {
if (event.getLevel() instanceof PortAgeLevel level) apply(level);
}
static void apply(PortAgeLevel level) {
PortWeatherMode mode = level.weatherMode();
if (mode == PortWeatherMode.NORMAL) {
STATES.remove(level);
return;
}
WeatherData weather = level.getWeatherData();
if (mode == PortWeatherMode.FAST || mode == PortWeatherMode.SLOW) {
int state = (weather.isRaining() ? 1 : 0) | (weather.isThundering() ? 2 : 0);
Integer previous = STATES.put(level, state);
if (previous != null && previous == state) return;
// Keep persisted timers on reload; rescale only when a cycle changes.
if (previous == null && weather.getRainTime() > 0) return;
int scale = mode == PortWeatherMode.FAST ? 1 : 4;
int duration = 6000 * scale + level.getRandom().nextInt(
(weather.isRaining() ? 6000 : 84000) * scale);
set(weather, duration, weather.isRaining(), weather.isThundering());
} else {
STATES.remove(level);
boolean rain = mode != PortWeatherMode.OFF;
boolean thunder = mode == PortWeatherMode.STORM;
if (weather.isRaining() != rain || weather.isThundering() != thunder
|| weather.getRainTime() < 200 || weather.getThunderTime() < 200
|| weather.getClearWeatherTime() != 0) set(weather, 6000, rain, thunder);
}
}
private static void set(WeatherData data, int duration, boolean rain, boolean thunder) {
data.setClearWeatherTime(0);
data.setRainTime(duration);
data.setThunderTime(duration);
data.setRaining(rain);
data.setThundering(thunder);
}
}
@@ -0,0 +1,78 @@
package net.dnorberg.rivencraft.age;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import net.minecraft.core.HolderLookup;
import net.minecraft.core.QuartPos;
import net.minecraft.core.registries.Registries;
import net.minecraft.resources.Identifier;
import net.minecraft.resources.ResourceKey;
import net.minecraft.tags.BiomeTags;
import net.minecraft.world.level.ChunkPos;
import net.minecraft.world.level.chunk.CarvingMask;
import net.minecraft.world.level.chunk.ChunkAccess;
import net.minecraft.world.level.chunk.ChunkGenerator;
import net.minecraft.world.level.levelgen.LegacyRandomSource;
import net.minecraft.world.level.levelgen.RandomState;
import net.minecraft.world.level.levelgen.WorldGenerationContext;
import net.minecraft.world.level.levelgen.WorldgenRandom;
import net.minecraft.world.level.levelgen.carver.WorldCarver;
/** Authored Caves/Ravines, independent of the terrain page, with 1.21.1 source seeds. */
public final class PortCarverPages {
public static final Identifier CAVES = Identifier.fromNamespaceAndPath("rivencraft", "feature/caves");
public static final Identifier RAVINES = Identifier.fromNamespaceAndPath("rivencraft", "feature/ravines");
private static final long CAVE_SALT = 0x4341564553464541L;
private static final long RAVINE_SALT = 0x524156494E45534CL;
private PortCarverPages() {}
public static boolean supports(Identifier page) { return CAVES.equals(page) || RAVINES.equals(page); }
public static void validateCounts(List<Identifier> pages) {
Map<Identifier, Integer> counts = new HashMap<>();
for (Identifier page : pages) if (!supports(page) || counts.merge(page, 1, Integer::sum) > 32)
throw new IllegalArgumentException("Carver plan requires Caves/Ravines pages, at most 32 per type");
}
public static void validateRegistry(List<Identifier> pages, HolderLookup.Provider registries) {
if (pages.stream().noneMatch(PortCarverPages::supports)) return;
var carvers = registries.lookupOrThrow(Registries.CARVER);
for (String name : pages.contains(CAVES) ? List.of("cave", "nether_cave", "canyon") : List.of("canyon")) {
if (name.equals("canyon") && !pages.contains(RAVINES)) continue;
carvers.get(ResourceKey.create(Registries.CARVER, Identifier.withDefaultNamespace(name)))
.orElseThrow(() -> new IllegalArgumentException("Missing authored carver minecraft:" + name));
}
}
public static void carve(ChunkGenerator generator, ChunkAccess chunk, RandomState randomState,
HolderLookup.Provider registries, List<Identifier> pages, long seed, CarvingMask mask) {
if (pages.isEmpty()) return;
var registry = registries.lookupOrThrow(Registries.CARVER);
var biomes = generator.getBiomeSource().createUncachedResolver(randomState);
var context = new WorldGenerationContext(generator, chunk.getHeightAccessorForGeneration());
var random = new WorldgenRandom(new LegacyRandomSource(0L));
ChunkPos target = chunk.getPos();
// 1.21.1 repeats identical seeds for duplicate Caves/Ravines pages.
// Their union is identical; avoid replaying the same mask up to 32 times.
for (Identifier page : pages.stream().distinct().toList()) {
boolean ravine = page.equals(RAVINES);
WorldCarver overworld = registry.getOrThrow(ResourceKey.create(Registries.CARVER,
Identifier.withDefaultNamespace(ravine ? "canyon" : "cave"))).value();
WorldCarver nether = ravine ? null : registry.getOrThrow(ResourceKey.create(Registries.CARVER,
Identifier.withDefaultNamespace("nether_cave"))).value();
for (int dx = -8; dx <= 8; dx++) {
for (int dz = -8; dz <= 8; dz++) {
ChunkPos source = new ChunkPos(target.x() + dx, target.z() + dz);
boolean isNether = biomes.getNoiseBiome(QuartPos.fromBlock(source.getMinBlockX()), 0,
QuartPos.fromBlock(source.getMinBlockZ())).is(BiomeTags.IS_NETHER);
if (ravine && isNether) continue;
WorldCarver carver = isNether ? nether : overworld;
random.setLargeFeatureSeed(seed ^ (ravine ? RAVINE_SALT : CAVE_SALT), source.x(), source.z());
if (carver.isStartChunk(random)) carver.carve(context, random, target, source, mask);
}
}
}
}
}
@@ -0,0 +1,78 @@
package net.dnorberg.rivencraft.age;
import net.dnorberg.rivencraft.Rivencraft;
import net.minecraft.core.BlockPos;
import net.minecraft.core.Direction;
import net.minecraft.util.RandomSource;
import net.minecraft.world.level.ChunkPos;
import net.minecraft.world.level.WorldGenLevel;
import net.minecraft.world.level.block.AmethystClusterBlock;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.chunk.ChunkAccess;
import net.minecraft.world.level.levelgen.Heightmap;
/** 26.3 port of the 1.21.1 surface crystal mound with budding hearts and seeded buds. */
final class PortCrystalFormationsFeature {
private static final long SALT = 0x4352595354414C53L;
private static final int CHANCE = 15;
private PortCrystalFormationsFeature() {}
record Site(int x, int z, long seed) {}
static Site site(long ageSeed, ChunkPos chunk, int occurrence) {
long seed = mix(ageSeed ^ SALT ^ (long) chunk.x() * 341873128712L
^ (long) chunk.z() * 132897987541L ^ (long) occurrence * 0x9E3779B97F4A7C15L);
RandomSource random = RandomSource.create(seed);
if (random.nextInt(CHANCE) != 0) return null;
return new Site(chunk.getMinBlockX() + 5 + random.nextInt(6),
chunk.getMinBlockZ() + 5 + random.nextInt(6), seed);
}
static void generate(WorldGenLevel level, ChunkAccess chunk, long ageSeed, int occurrence) {
Site site = site(ageSeed, chunk.getPos(), occurrence);
if (site == null) return;
RandomSource random = RandomSource.create(site.seed() ^ 0x42554444494E474CL);
int y = level.getHeight(Heightmap.Types.WORLD_SURFACE_WG, site.x(), site.z());
if (y <= level.getMinY() + 1 || y + 4 >= level.getMaxY()) return;
BlockPos.MutableBlockPos pos = new BlockPos.MutableBlockPos();
for (int dx = -1; dx <= 1; dx++) {
for (int dz = -1; dz <= 1; dz++) {
int x = site.x() + dx;
int z = site.z() + dz;
if (Math.abs(level.getHeight(Heightmap.Types.WORLD_SURFACE_WG, x, z) - y) > 1) return;
BlockState floor = level.getBlockState(pos.set(x, y - 1, z));
if (!floor.isFaceSturdy(level, pos, Direction.UP) || !floor.getFluidState().isEmpty()
|| floor.hasBlockEntity()) return;
for (int h = 0; h < 4; h++) {
BlockState existing = level.getBlockState(pos.set(x, y + h, z));
if (!existing.isAir() && !existing.canBeReplaced()) return;
}
}
}
for (int dx = -1; dx <= 1; dx++) {
for (int dz = -1; dz <= 1; dz++) {
boolean center = dx == 0 && dz == 0;
int height = center ? 3 : random.nextInt(3) == 0 ? 2 : 1;
for (int h = 0; h < height; h++) {
boolean budding = h == height - 1 && (center || random.nextBoolean());
level.setBlock(pos.set(site.x() + dx, y + h, site.z() + dz),
budding ? Rivencraft.BUDDING_CRYSTAL.get().defaultBlockState()
: Rivencraft.CRYSTAL_BLOCK.get().defaultBlockState(), 2);
}
if (level.getBlockState(pos.set(site.x() + dx, y + height - 1, site.z() + dz))
.is(Rivencraft.BUDDING_CRYSTAL) && random.nextBoolean()) {
level.setBlock(pos.set(site.x() + dx, y + height, site.z() + dz),
Rivencraft.SMALL_CRYSTAL_BUD.get().defaultBlockState()
.setValue(AmethystClusterBlock.FACING, Direction.UP), 2);
}
}
}
}
private static long mix(long value) {
value = (value ^ value >>> 30) * 0xBF58476D1CE4E5B9L;
value = (value ^ value >>> 27) * 0x94D049BB133111EBL;
return value ^ value >>> 31;
}
}
@@ -0,0 +1,212 @@
package net.dnorberg.rivencraft.age;
import com.mojang.serialization.Codec;
import com.mojang.serialization.codecs.RecordCodecBuilder;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Random;
import net.minecraft.core.Holder;
import net.minecraft.network.RegistryFriendlyByteBuf;
import net.minecraft.network.codec.ByteBufCodecs;
import net.minecraft.network.codec.StreamCodec;
import net.minecraft.resources.Identifier;
import net.minecraft.world.clock.ClockInstance;
import net.minecraft.world.clock.ClockManager;
import net.minecraft.world.clock.WorldClock;
import net.minecraft.world.clock.WorldClocks;
import net.minecraft.world.level.Level;
/** Authored native celestial controls. Never mutates the shared clock. */
public record PortDayCycle(boolean authored, double period, int stoppedTime,
long originTick, int phaseTick, boolean clockwise, int bearing, float sunSize) {
public static final PortDayCycle NATIVE = new PortDayCycle(false, 1, 0);
public static final Identifier NORMAL_TIME = Identifier.fromNamespaceAndPath("rivencraft", "celestial/normal_time");
public static final Codec<PortDayCycle> CODEC = RecordCodecBuilder.create(instance -> instance.group(
Codec.BOOL.fieldOf("authored").forGetter(PortDayCycle::authored),
Codec.doubleRange(0, 2).fieldOf("period").forGetter(PortDayCycle::period),
Codec.intRange(0, 23999).fieldOf("stopped_time").forGetter(PortDayCycle::stoppedTime),
Codec.LONG.optionalFieldOf("origin_tick", 0L).forGetter(PortDayCycle::originTick),
Codec.intRange(0, 23999).optionalFieldOf("phase_tick", 0).forGetter(PortDayCycle::phaseTick),
Codec.BOOL.optionalFieldOf("clockwise", true).forGetter(PortDayCycle::clockwise),
Codec.intRange(-1, 359).optionalFieldOf("bearing", -1).forGetter(PortDayCycle::bearing),
Codec.floatRange(0.25F, 3F).optionalFieldOf("sun_size", 1F).forGetter(PortDayCycle::sunSize)
).apply(instance, PortDayCycle::new));
public static final StreamCodec<RegistryFriendlyByteBuf, PortDayCycle> STREAM_CODEC = StreamCodec.composite(
ByteBufCodecs.BOOL, PortDayCycle::authored, ByteBufCodecs.DOUBLE, PortDayCycle::period,
ByteBufCodecs.VAR_INT, PortDayCycle::stoppedTime, ByteBufCodecs.VAR_LONG, PortDayCycle::originTick,
ByteBufCodecs.VAR_INT, PortDayCycle::phaseTick, ByteBufCodecs.BOOL, PortDayCycle::clockwise,
ByteBufCodecs.INT, PortDayCycle::bearing, ByteBufCodecs.FLOAT, PortDayCycle::sunSize, PortDayCycle::new);
private static volatile Map<Identifier, PortDayCycle> clientCycles = Map.of();
public PortDayCycle {
if (!Double.isFinite(period) || period < 0 || period > 2 || stoppedTime < 0 || stoppedTime >= 24000
|| phaseTick < 0 || phaseTick >= 24000 || bearing < -1 || bearing >= 360
|| !Float.isFinite(sunSize) || sunSize < 0.25F || sunSize > 3F)
throw new IllegalArgumentException("Invalid day cycle");
}
public PortDayCycle(boolean authored, double period, int stoppedTime) {
this(authored, period, stoppedTime, 0, 0, true);
}
public PortDayCycle(boolean authored, double period, int stoppedTime,
long originTick, int phaseTick, boolean clockwise) {
this(authored, period, stoppedTime, originTick, phaseTick, clockwise, -1);
}
public PortDayCycle(boolean authored, double period, int stoppedTime,
long originTick, int phaseTick, boolean clockwise, int bearing) {
this(authored, period, stoppedTime, originTick, phaseTick, clockwise, bearing, 1F);
}
public static boolean isPeriod(Identifier page) {
return page.getNamespace().equals("rivencraft") && switch (page.getPath()) {
case "period/full", "period/half", "period/double", "period/zero" -> true;
default -> false;
};
}
public static boolean isPhase(Identifier page) {
return page.getNamespace().equals("rivencraft") && switch (page.getPath()) {
case "phase/rising", "phase/zenith", "phase/setting", "phase/nadir" -> true;
default -> false;
};
}
public static boolean isDirection(Identifier page) {
return page.getNamespace().equals("rivencraft") && (page.getPath().equals("direction/clockwise")
|| page.getPath().equals("direction/counterclockwise"));
}
public static boolean isAngle(Identifier page) {
return page.getNamespace().equals("rivencraft") && switch (page.getPath()) {
case "angle/north", "angle/east", "angle/south", "angle/west" -> true;
default -> false;
};
}
public static boolean supports(Identifier page) {
return page.equals(NORMAL_TIME) || isPeriod(page) || isPhase(page) || isDirection(page) || isAngle(page) || isSize(page);
}
public static boolean isSize(Identifier page) {
return page.getNamespace().equals("rivencraft") && switch (page.getPath()) {
case "size/small", "size/normal", "size/large" -> true;
default -> false;
};
}
public static boolean validWriting(List<Identifier> pages) {
int target = pages.indexOf(NORMAL_TIME), periods = 0, phases = 0, directions = 0, angles = 0, sizes = 0;
if (target != pages.lastIndexOf(NORMAL_TIME)) return false;
for (int i = 0; i < pages.size(); i++) {
if (isPeriod(pages.get(i)) && (++periods > 1 || i > target)) return false;
if (isPhase(pages.get(i)) && (++phases > 1 || i > target)) return false;
if (isDirection(pages.get(i)) && (++directions > 1 || i > target)) return false;
if (isAngle(pages.get(i)) && (++angles > 1 || i > target)) return false;
if (isSize(pages.get(i)) && (++sizes > 1 || i > target)) return false;
}
return true;
}
public static PortDayCycle compile(List<Identifier> pages, long seed) {
return compile(pages, seed, 0);
}
public static PortDayCycle compile(List<Identifier> pages, long seed, long nativeTicks) {
if (!validWriting(pages)) throw new IllegalArgumentException("Place at most one Length, Phase, Direction, Angle, and Size before Normal Time");
if (!pages.contains(NORMAL_TIME)) return NATIVE;
double period = 1;
Integer phase = null;
boolean clockwise = true;
int bearing = -1;
float size = 1F;
for (int i = 0; i < pages.size(); i++) {
Identifier page = pages.get(i);
int number = i > 0 ? PortAgeColors.number(pages.get(i - 1)) : -1;
if (isPhase(page)) {
int degrees = number >= 0 ? number * 15 % 360 : switch (page.getPath()) {
case "phase/rising" -> 90;
case "phase/zenith" -> 180;
case "phase/setting" -> 270;
default -> 0;
};
// Same conversion as 1.21.1 AgeDimensionService.applyInitialCelestialPhase:
// the written angle starts at Nadir, while native time zero is sunrise.
phase = (degrees * 24000 / 360 + 18000) % 24000;
} else if (isAngle(page)) {
bearing = number >= 0 ? number * 15 % 360 : switch (page.getPath()) {
case "angle/east" -> 90;
case "angle/south" -> 180;
case "angle/west" -> 270;
default -> 0;
};
} else if (isSize(page)) {
size = number >= 0 ? (float) (0.25D + number * (2.75D / 24D)) : switch (page.getPath()) {
case "size/small" -> 0.5F;
case "size/large" -> 2F;
default -> 1F;
};
} else if (isDirection(page)) {
clockwise = page.getPath().equals("direction/clockwise");
} else if (isPeriod(page)) period = number >= 0 ? number * 0.08 : switch (page.getPath()) {
case "period/half" -> 0.5;
case "period/double" -> 2;
case "period/zero" -> 0;
default -> 1;
};
}
int stopped = period == 0 ? phase != null ? phase
: new Random(seed ^ 0x4441594359434C45L).nextInt(24000) : 0;
return new PortDayCycle(true, period, stopped, phase != null ? nativeTicks : 0,
phase == null ? 0 : phase, clockwise, bearing, size);
}
public long ticks(long nativeTicks) {
if (!authored) return nativeTicks;
return (long) Math.floor(time(nativeTicks));
}
private double time(long nativeTicks) {
return !authored ? nativeTicks : period == 0 ? stoppedTime
: (clockwise ? 1 : -1) * ((double) nativeTicks - originTick) / period + phaseTick;
}
public static long nativeTicks(Level level) {
return level.clockManager().getInstance(level.registryAccess().lookupOrThrow(
net.minecraft.core.registries.Registries.WORLD_CLOCK).getOrThrow(WorldClocks.OVERWORLD)).totalTicks();
}
public static void updateClient(Map<Identifier, PortDayCycle> cycles) { clientCycles = Map.copyOf(cycles); }
public static PortDayCycle forLevel(Level level) {
if (level instanceof PortAgeLevel age) return age.dayCycle();
return level.isClientSide() ? clientCycles.getOrDefault(level.dimension().identifier(), NATIVE) : NATIVE;
}
/** Timelines keep this manager; profiles are read lazily after level construction/catalog sync. */
public static ClockManager timelines(Level level, ClockManager nativeManager) {
Map<Holder<WorldClock>, ClockInstance> wrappers = new HashMap<>();
return definition -> {
if (!definition.is(WorldClocks.OVERWORLD)) return nativeManager.getInstance(definition);
return wrappers.computeIfAbsent(definition, ignored -> new ClockInstance() {
private ClockInstance source() { return nativeManager.getInstance(definition); }
public long totalTicks() { return forLevel(level).ticks(source().totalTicks()); }
public float partialTick() {
PortDayCycle cycle = forLevel(level);
if (!cycle.authored) return source().partialTick();
if (cycle.period == 0) return 0;
double time = cycle.time(source().totalTicks());
return (float) (time - Math.floor(time)
+ (cycle.clockwise ? 1 : -1) * source().partialTick() / cycle.period);
}
public float rate() {
PortDayCycle cycle = forLevel(level);
return !cycle.authored ? source().rate() : cycle.period == 0 ? 0
: (float) ((cycle.clockwise ? 1 : -1) * source().rate() / cycle.period);
}
public boolean isPaused() { return source().isPaused() || forLevel(level).authored && forLevel(level).period == 0; }
});
};
}
}
@@ -0,0 +1,90 @@
package net.dnorberg.rivencraft.age;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
import java.util.TreeMap;
import net.dnorberg.rivencraft.Rivencraft;
import net.minecraft.core.BlockPos;
import net.minecraft.core.Holder;
import net.minecraft.core.HolderSet;
import net.minecraft.core.registries.BuiltInRegistries;
import net.minecraft.core.registries.Registries;
import net.minecraft.resources.Identifier;
import net.minecraft.tags.TagKey;
import net.minecraft.util.RandomSource;
import net.minecraft.world.level.ChunkPos;
import net.minecraft.world.level.WorldGenLevel;
import net.minecraft.world.level.biome.Biome;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.chunk.ChunkAccess;
import net.minecraft.world.level.chunk.ChunkGenerator;
import net.minecraft.world.level.levelgen.feature.AbstractOreFeature;
import net.minecraft.world.level.levelgen.placement.FeaturePlacer;
import net.minecraft.world.level.levelgen.placement.PlacedFeature;
import net.neoforged.neoforge.common.Tags;
/** Replays each eligible biome ore placement once per page, matching Rivencraft 1.21.1. */
public final class PortDenseOres {
public static final TagKey<PlacedFeature> EXTRA_ORES = TagKey.create(Registries.PLACED_FEATURE,
Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "dense_ores"));
private PortDenseOres() {}
public static boolean hasOres(Holder<Biome> biome) {
return oreFeatures(biome).anyMatch(PortDenseOres::isOre);
}
/** Modern ore features carry their targets directly rather than an OreConfiguration. */
public static boolean isOre(Holder<PlacedFeature> holder) {
if (holder.is(EXTRA_ORES)) return true;
if (!(holder.value().feature().value() instanceof AbstractOreFeature ore)) return false;
return ore.targetStates().stream().anyMatch(target -> isOreBlock(target.state()));
}
private static boolean isOreBlock(BlockState state) {
if (state.is(Tags.BlockItems.ORES.block())) return true;
String path = BuiltInRegistries.BLOCK.getKey(state.getBlock()).getPath();
return path.endsWith("_ore") || path.contains("_ore_");
}
private static java.util.stream.Stream<Holder<PlacedFeature>> oreFeatures(Holder<Biome> biome) {
return biome.value().getGenerationSettings().features().stream().flatMap(HolderSet::stream);
}
public static void place(WorldGenLevel level, ChunkAccess chunk, ChunkGenerator generator,
long ageSeed, int count) {
if (count == 0) return;
Set<Holder<Biome>> biomes = new HashSet<>();
ChunkPos.rangeClosed(chunk.getPos(), 1).forEach(pos -> {
for (var section : level.getChunk(pos.x(), pos.z()).getSections())
section.getBiomes().getAll(biomes::add);
});
biomes.retainAll(generator.getBiomeSource().possibleBiomes());
// Collect once per chunk, not once per repeated page. Stable registry-ID
// order and per-occurrence seeds retain the 1.21.1 placement sequence.
Map<Identifier, PlacedFeature> ores = new TreeMap<>();
for (Holder<Biome> biome : biomes) {
oreFeatures(biome).filter(PortDenseOres::isOre).forEach(feature ->
feature.unwrapKey().ifPresent(key -> ores.putIfAbsent(key.identifier(), feature.value())));
}
BlockPos origin = new BlockPos(chunk.getPos().getMinBlockX(), level.getMinY(),
chunk.getPos().getMinBlockZ());
FeaturePlacer placer = new FeaturePlacer(level, generator);
for (int occurrence = 0; occurrence < count; occurrence++) {
for (var entry : ores.entrySet()) {
long seed = mix(ageSeed ^ (long) origin.getX() * 341873128712L
^ (long) origin.getZ() * 132897987541L
^ (long) entry.getKey().hashCode() * 0x9E3779B97F4A7C15L
^ (long) occurrence * 0xD1B54A32D192ED03L);
placer.placeWithBiomeCheck(entry.getValue(), RandomSource.create(seed), origin);
}
}
}
private static long mix(long value) {
value = (value ^ value >>> 30) * 0xBF58476D1CE4E5B9L;
value = (value ^ value >>> 27) * 0x94D049BB133111EBL;
return value ^ value >>> 31;
}
}
@@ -0,0 +1,28 @@
package net.dnorberg.rivencraft.age;
import java.util.Arrays;
import java.util.List;
import java.util.Random;
import net.minecraft.resources.Identifier;
/** Seed-completes missing environment controllers once, before an Age is saved. */
public record PortEnvironmentSelection(PortWeatherMode weather, PortLighting lighting, PortSkyStyle sky) {
public static PortEnvironmentSelection resolve(List<Identifier> pages, long seed, PortAgeTerrain terrain) {
PortLighting lighting = pages.stream().map(PortLighting::forPage).filter(java.util.Objects::nonNull)
.findFirst().orElseGet(() -> rankedDefault(new Random(seed ^ 0x4C49474854494E47L), PortLighting.values()));
PortWeatherMode weather = terrain == PortAgeTerrain.CAVE ? PortWeatherMode.OFF
: pages.stream().map(PortWeatherMode::forPage).filter(java.util.Objects::nonNull)
.findFirst().orElseGet(() -> rankedDefault(new Random(seed ^ 0x574541544845525FL), PortWeatherMode.values()));
return new PortEnvironmentSelection(weather, lighting, PortSkyStyle.resolve(pages, seed));
}
private static <T> T rankedDefault(Random random, T[] choices) {
// LegacyRankChoice: the first (Normal) option is rank 1, the rest rank 2.
// Its rank-1 weight is the complete rare group plus one. Derive this
// from the supported pool, rather than selecting unported weather.
int[] weights = new int[choices.length];
Arrays.fill(weights, 1);
weights[0] = choices.length;
return PortRandomAgeSelection.weighted(random, List.of(choices), weights);
}
}
@@ -0,0 +1,290 @@
package net.dnorberg.rivencraft.age;
import com.mojang.serialization.MapCodec;
import com.mojang.serialization.codecs.RecordCodecBuilder;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.CompletableFuture;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.writing.PortRegistryPages;
import net.minecraft.core.BlockPos;
import net.minecraft.core.Holder;
import net.minecraft.core.HolderLookup;
import net.minecraft.core.RegistryAccess;
import net.minecraft.core.registries.BuiltInRegistries;
import net.minecraft.core.registries.Registries;
import net.minecraft.resources.Identifier;
import net.minecraft.resources.ResourceKey;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.server.level.WorldGenRegion;
import net.minecraft.tags.TagKey;
import net.minecraft.tags.BiomeTags;
import net.minecraft.util.RandomSource;
import net.minecraft.world.level.ChunkPos;
import net.minecraft.world.level.LevelHeightAccessor;
import net.minecraft.world.level.NoiseColumn;
import net.minecraft.world.level.StructureManager;
import net.minecraft.world.level.WorldGenLevel;
import net.minecraft.world.level.biome.Biome;
import net.minecraft.world.level.biome.BiomeManager;
import net.minecraft.world.level.chunk.ChunkAccess;
import net.minecraft.world.level.chunk.ChunkGenerator;
import net.minecraft.world.level.chunk.ChunkGeneratorStructureState;
import net.minecraft.world.level.levelgen.Heightmap;
import net.minecraft.world.level.levelgen.RandomState;
import net.minecraft.world.level.levelgen.blending.Blender;
import net.minecraft.world.level.levelgen.densityfunction.SamplerContext;
import net.minecraft.world.level.levelgen.blockpredicates.BlockPredicate;
import net.minecraft.world.level.levelgen.feature.BlockBlobFeature;
import net.minecraft.world.level.levelgen.feature.LakeFeature;
import net.minecraft.world.level.levelgen.feature.stateproviders.BlockStateProvider;
import net.minecraft.world.level.levelgen.placement.BiomeFilter;
import net.minecraft.world.level.levelgen.placement.PlacedFeature;
import net.minecraft.world.level.levelgen.placement.RarityFilter;
import net.minecraft.world.level.levelgen.structure.StructureSet;
import net.minecraft.world.level.levelgen.structure.Structure;
import net.minecraft.world.level.levelgen.structure.templatesystem.StructureTemplateManager;
import com.mojang.datafixers.util.Pair;
import net.minecraft.core.HolderSet;
import net.minecraft.world.level.Level;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.block.Block;
/** Delegates terrain generation and applies frozen Age-scoped features. */
public final class PortFeatureChunkGenerator extends ChunkGenerator {
private static final ResourceKey<PlacedFeature> FOREST_ROCK = ResourceKey.create(
Registries.PLACED_FEATURE, Identifier.withDefaultNamespace("forest_rock"));
private static final ResourceKey<PlacedFeature> DUNGEONS = ResourceKey.create(
Registries.PLACED_FEATURE, Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "dungeons"));
private static final TagKey<Block> ROCK_GROUND = TagKey.create(
Registries.BLOCK, Identifier.withDefaultNamespace("forest_rock_can_place_on"));
public static final MapCodec<PortFeatureChunkGenerator> CODEC = RecordCodecBuilder.mapCodec(instance -> instance.group(
ChunkGenerator.CODEC.fieldOf("base").forGetter(generator -> generator.base),
PortRockPlan.CODEC.optionalFieldOf("rocks").forGetter(generator -> Optional.ofNullable(generator.rocks)),
PortVegetationPlan.CODEC.optionalFieldOf("vegetation")
.forGetter(generator -> Optional.ofNullable(generator.vegetation)),
com.mojang.serialization.Codec.intRange(0, 32).optionalFieldOf("dungeons", 0)
.forGetter(generator -> generator.dungeons),
PortLakePlan.CODEC.optionalFieldOf("lakes")
.forGetter(generator -> Optional.ofNullable(generator.lakes)),
PortTerrainFeaturePlan.CODEC.optionalFieldOf("terrain_features")
.forGetter(generator -> Optional.ofNullable(generator.terrainFeatures)),
com.mojang.serialization.Codec.LONG.fieldOf("age_seed").forGetter(generator -> generator.ageSeed)
).apply(instance, (base, rocks, vegetation, dungeons, lakes, terrainFeatures, seed) ->
new PortFeatureChunkGenerator(base, rocks.orElse(null), vegetation.orElse(null),
dungeons, lakes.orElse(null), terrainFeatures.orElse(null), seed)));
private final ChunkGenerator base;
private final PortRockPlan rocks;
private final PortVegetationPlan vegetation;
private final int dungeons;
private final PortLakePlan lakes;
private final PortTerrainFeaturePlan terrainFeatures;
private final long ageSeed;
public PortFeatureChunkGenerator(ChunkGenerator base, PortRockPlan rocks,
PortVegetationPlan vegetation, int dungeons, PortLakePlan lakes,
PortTerrainFeaturePlan terrainFeatures, long ageSeed) {
super(base.getBiomeSource(), base::getBiomeGenerationSettings);
this.base = base;
this.rocks = rocks;
this.vegetation = vegetation;
this.dungeons = dungeons;
this.lakes = lakes;
this.terrainFeatures = terrainFeatures;
this.ageSeed = ageSeed;
}
public ChunkGenerator base() { return base; }
public PortRockPlan rocks() { return rocks; }
public PortVegetationPlan vegetation() { return vegetation; }
public int dungeons() { return dungeons; }
public PortLakePlan lakes() { return lakes; }
public PortTerrainFeaturePlan terrainFeatures() { return terrainFeatures; }
@Override protected MapCodec<? extends ChunkGenerator> codec() { return CODEC; }
@Override public void validate() { base.validate(); }
@Override public ChunkPos getOrigin(RandomState random) { return base.getOrigin(random); }
@Override public java.util.Optional<Identifier> getTypeNameForDataFixer() { return base.getTypeNameForDataFixer(); }
@Override public CompletableFuture<ChunkAccess> createBiomes(RandomState random, Blender blender,
StructureManager structures, ChunkAccess chunk) {
return base.createBiomes(random, blender, structures, chunk);
}
@Override public ChunkGeneratorStructureState createState(HolderLookup<StructureSet> sets,
RandomState random, long seed) {
if (hasStructurePages()) {
var lookup = PortStructurePages.filtered(sets, terrainFeatures.structures(), getBiomeSource());
var planning = PortStructurePages.planningBiomes(getBiomeSource(), sets, terrainFeatures.structures());
if (base instanceof PortFlatChunkGenerator || base instanceof net.minecraft.world.level.levelgen.FlatLevelSource)
return ChunkGeneratorStructureState.createForFlat(random, seed, getOrigin(random), planning,
lookup.listElements().map(holder -> (Holder<StructureSet>) holder));
return ChunkGeneratorStructureState.createForNormal(random, seed, getOrigin(random), planning, lookup);
}
return base.createState(sets, random, seed);
}
private boolean hasStructurePages() {
return terrainFeatures != null && !terrainFeatures.structures().isEmpty();
}
@Override public void createStructures(RegistryAccess registries, ChunkGeneratorStructureState state,
StructureManager structures, ChunkAccess chunk,
StructureTemplateManager templates, ResourceKey<Level> dimension) {
if (hasStructurePages()) super.createStructures(registries, state, structures, chunk, templates, dimension);
else base.createStructures(registries, state, structures, chunk, templates, dimension);
}
@Override public void createReferences(WorldGenLevel level, StructureManager structures, ChunkAccess chunk) {
base.createReferences(level, structures, chunk);
}
@Override public Pair<BlockPos, Holder<Structure>> findNearestMapStructure(ServerLevel level,
HolderSet<Structure> targets, BlockPos origin, int radius, boolean skipKnown) {
return base.findNearestMapStructure(level, targets, origin, radius, skipKnown);
}
@Override public void spawnOriginalMobs(WorldGenRegion level) { base.spawnOriginalMobs(level); }
@Override public int getSpawnHeight(LevelHeightAccessor height) { return base.getSpawnHeight(height); }
@Override public int getGenDepth() { return base.getGenDepth(); }
@Override public int getSeaLevel() { return base.getSeaLevel(); }
@Override public int getMinY() { return base.getMinY(); }
@Override public CompletableFuture<ChunkAccess> buildTerrain(ChunkAccess chunk, Blender blender,
RandomState random, StructureManager structures, BiomeManager biomes,
WorldGenRegion carverBiomeRegion, Set<Holder<Biome>> possibleBiomes) {
CompletableFuture<ChunkAccess> built = base.buildTerrain(
chunk, blender, random, structures, biomes, carverBiomeRegion, possibleBiomes);
if (terrainFeatures == null || !terrainFeatures.skylands() && terrainFeatures.choices().isEmpty()) return built;
return built.thenApply(result -> {
// Match 1.21.1: filter the base terrain once, then add the authored
// solid volumes, irrespective of the Skylands page's written position.
if (terrainFeatures.skylands()) PortSkylandsFeature.filter(result, ageSeed);
java.util.Map<Identifier, Integer> occurrences = new java.util.HashMap<>();
for (PortTerrainFeaturePlan.Choice choice : terrainFeatures.choices()) {
if (PortTerrainFeaturePlan.isSurface(choice.page())) continue;
int occurrence = occurrences.merge(choice.page(), 1, Integer::sum) - 1;
long featureSeed = ageSeed ^ (long) occurrence * 0x9E3779B97F4A7C15L;
BlockState material = BuiltInRegistries.BLOCK.getValue(choice.material()).defaultBlockState();
if (choice.page().equals(PortTerrainFeaturePlan.FLOATING_ISLANDS))
PortFloatingIslandFeature.generate(result, featureSeed, material);
else if (choice.page().equals(PortTerrainFeaturePlan.TENDRILS))
PortTendrilFeature.generate(result, featureSeed, material);
else if (choice.page().equals(PortTerrainFeaturePlan.SPHERES))
PortSpheresFeature.generate(result, featureSeed, material);
else throw new IllegalStateException("Unsupported terrain feature: " + choice.page());
}
return result;
});
}
@Override public int getBaseHeight(int x, int z, Heightmap.Types type,
LevelHeightAccessor height, RandomState random) {
return base.getBaseHeight(x, z, type, height, random);
}
@Override public NoiseColumn getBaseColumn(int x, int z, LevelHeightAccessor height, RandomState random) {
return base.getBaseColumn(x, z, height, random);
}
@Override public void addDebugScreenInfo(List<String> info, RandomState random,
BlockPos pos, SamplerContext context) {
base.addDebugScreenInfo(info, random, pos, context);
}
@Override public void refreshFeaturesPerStep() {
base.refreshFeaturesPerStep();
super.refreshFeaturesPerStep();
}
@Override public void applyBiomeDecoration(WorldGenLevel level, ChunkAccess chunk,
StructureManager structures) {
base.applyBiomeDecoration(level, chunk, structures);
if (terrainFeatures != null) {
PortDenseOres.place(level, chunk, this, ageSeed, terrainFeatures.denseOres());
int spikeOccurrence = 0;
int obeliskOccurrence = 0;
int crystalOccurrence = 0;
for (PortTerrainFeaturePlan.Choice choice : terrainFeatures.choices()) {
if (!PortTerrainFeaturePlan.isSurface(choice.page())) continue;
BlockState material = BuiltInRegistries.BLOCK.getValue(choice.material()).defaultBlockState();
if (choice.page().equals(PortTerrainFeaturePlan.SPIKES))
PortSpikesFeature.generate(level, chunk, ageSeed, material, spikeOccurrence++);
else if (choice.page().equals(PortTerrainFeaturePlan.OBELISKS))
PortObelisksFeature.generate(level, chunk, ageSeed, material, obeliskOccurrence++);
else if (choice.page().equals(PortTerrainFeaturePlan.CRYSTAL_FORMATIONS))
PortCrystalFormationsFeature.generate(level, chunk, ageSeed, crystalOccurrence++);
else throw new IllegalStateException("Unsupported surface feature: " + choice.page());
}
}
ChunkPos chunkPos = chunk.getPos();
long chunkSeed = ageSeed ^ (long) chunkPos.x() * 341873128712L
^ (long) chunkPos.z() * 132897987541L;
BlockPos origin = new BlockPos(chunkPos.getMinBlockX(), level.getMinY(), chunkPos.getMinBlockZ());
if (rocks != null) {
PlacedFeature vanilla = level.registryAccess().lookupOrThrow(Registries.PLACED_FEATURE)
.getOrThrow(FOREST_ROCK).value();
var placement = vanilla.placement().stream()
.filter(modifier -> !(modifier instanceof BiomeFilter)).toList();
for (int occurrence = 0; occurrence < rocks.materials().size(); occurrence++) {
RandomSource random = RandomSource.create(chunkSeed
^ (long) occurrence * 0xD1B54A32D192ED03L);
BlockState material = BuiltInRegistries.BLOCK.getValue(
rocks.materials().get(occurrence)).defaultBlockState();
PlacedFeature authored = new PlacedFeature(Holder.direct(new BlockBlobFeature(
material, BlockPredicate.matchesTag(ROCK_GROUND))), placement);
authored.place(level, this, random, origin);
}
}
if (dungeons > 0) {
PlacedFeature authored = level.registryAccess().lookupOrThrow(Registries.PLACED_FEATURE)
.getOrThrow(DUNGEONS).value();
for (int occurrence = 0; occurrence < dungeons; occurrence++) {
RandomSource random = RandomSource.create(chunkSeed
^ 0x44554E47454F4E53L ^ (long) occurrence * 0xD1B54A32D192ED03L);
authored.place(level, this, random, origin);
}
}
if (lakes != null) {
var registry = level.registryAccess().lookupOrThrow(Registries.PLACED_FEATURE);
java.util.Map<PortLakePlan.Choice, Integer> occurrences = new java.util.HashMap<>();
for (PortLakePlan.Choice choice : lakes.choices()) {
PlacedFeature vanilla = registry.getOrThrow(ResourceKey.create(
Registries.PLACED_FEATURE, PortLakePlan.placedFeature(choice.page()))).value();
if (!(vanilla.feature().value() instanceof LakeFeature lake))
throw new IllegalStateException("Lake placement no longer references a LakeFeature: " + choice.page());
BlockState fluid = BuiltInRegistries.BLOCK.getValue(choice.fluid()).defaultBlockState();
LakeFeature feature = new LakeFeature(BlockStateProvider.holderOf(fluid), lake.barrier(),
lake.canPlaceFeature(), lake.canReplaceWithAirOrFluid(), lake.canReplaceWithBarrier());
PlacedFeature authored = new PlacedFeature(Holder.direct(feature), vanilla.placement().stream()
.filter(modifier -> !(modifier instanceof BiomeFilter))
.map(modifier -> !PortLakePlan.isDeep(choice.page()) && modifier instanceof RarityFilter
? RarityFilter.onAverageOnceEvery(12) : modifier)
.toList());
int occurrence = occurrences.merge(choice, 1, Integer::sum) - 1;
RandomSource random = RandomSource.create(chunkSeed
^ (long) choice.hashCode() * 0x9E3779B97F4A7C15L
^ (long) occurrence * 0xD1B54A32D192ED03L);
authored.place(level, this, random, origin);
}
}
if (vegetation != null) {
int surface = chunk.getHeight(Heightmap.Types.WORLD_SURFACE_WG, 8, 8);
Holder<Biome> biome = level.getBiome(new BlockPos(
chunkPos.getMinBlockX() + 8, surface, chunkPos.getMinBlockZ() + 8));
boolean surfaceBiome = !biome.is(BiomeTags.IS_END) && !PortRegistryPages.isCaveBiome(biome);
var registry = level.registryAccess().lookupOrThrow(Registries.PLACED_FEATURE);
List<BlockPos> mushroomHosts = null;
for (int occurrence = 0; occurrence < vegetation.pages().size(); occurrence++) {
Identifier page = vegetation.pages().get(occurrence);
RandomSource random = RandomSource.create(chunkSeed
^ (long) page.hashCode() * 0x9E3779B97F4A7C15L
^ (long) occurrence * 0xD1B54A32D192ED03L);
if (page.equals(PortVegetationPlan.SHELF_MUSHROOMS)) {
if (mushroomHosts == null) mushroomHosts = PortShelfMushrooms.findHosts(chunk);
PortShelfMushrooms.decorate(level, chunk, mushroomHosts, random);
continue;
}
if (!surfaceBiome) continue;
Identifier placedId = PortVegetationPlan.placedFeature(page);
PlacedFeature vanilla = registry.getOrThrow(
ResourceKey.create(Registries.PLACED_FEATURE, placedId)).value();
PlacedFeature authored = new PlacedFeature(vanilla.feature(), vanilla.placement().stream()
.filter(modifier -> !(modifier instanceof BiomeFilter)).toList());
authored.place(level, this, random, origin);
if (PortVegetationPlan.addsLogs(page)) mushroomHosts = null;
}
}
}
}
@@ -0,0 +1,83 @@
package net.dnorberg.rivencraft.age;
import net.minecraft.core.BlockPos;
import net.minecraft.core.QuartPos;
import net.minecraft.tags.BiomeTags;
import net.minecraft.util.RandomSource;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.chunk.ChunkAccess;
import net.minecraft.world.level.levelgen.Heightmap;
import net.dnorberg.rivencraft.writing.PortRegistryPages;
/** 1.21.1 floating-island shape adapted to the 26.3 chunk terrain stage. */
final class PortFloatingIslandFeature {
private static final long SALT = 0x464C4F4154494E47L;
private static final int SOURCE_RANGE = 3;
private static final int CHANCE = 128;
private PortFloatingIslandFeature() {}
static void generate(ChunkAccess chunk, long ageSeed, BlockState material) {
var target = chunk.getPos();
for (int sourceX = target.x() - SOURCE_RANGE; sourceX <= target.x() + SOURCE_RANGE; sourceX++) {
for (int sourceZ = target.z() - SOURCE_RANGE; sourceZ <= target.z() + SOURCE_RANGE; sourceZ++) {
RandomSource random = RandomSource.create(mix(ageSeed ^ SALT
^ (long) sourceX * 341873128712L ^ (long) sourceZ * 132897987541L));
if (random.nextInt(CHANCE) != 0) continue;
int centerX = sourceX * 16 + random.nextInt(16);
int centerZ = sourceZ * 16 + random.nextInt(16);
int centerY = Math.min(chunk.getMaxY() - 30, 168 + random.nextInt(48));
if (centerY < chunk.getMinY() + 24) continue;
int radiusX = 24 + random.nextInt(18);
int radiusZ = 24 + random.nextInt(18);
fill(chunk, ageSeed, material, centerX, centerY, centerZ, radiusX, radiusZ);
}
}
}
private static void fill(ChunkAccess chunk, long ageSeed, BlockState material,
int centerX, int centerY, int centerZ, int radiusX, int radiusZ) {
int originX = chunk.getPos().getMinBlockX();
int originZ = chunk.getPos().getMinBlockZ();
Heightmap surface = chunk.getOrCreateHeightmapUnprimed(Heightmap.Types.WORLD_SURFACE_WG);
Heightmap floor = chunk.getOrCreateHeightmapUnprimed(Heightmap.Types.OCEAN_FLOOR_WG);
BlockPos.MutableBlockPos pos = new BlockPos.MutableBlockPos();
for (int localX = 0; localX < 16; localX++) {
int worldX = originX + localX;
double dx = (worldX - centerX) / (double) radiusX;
for (int localZ = 0; localZ < 16; localZ++) {
int worldZ = originZ + localZ;
double dz = (worldZ - centerZ) / (double) radiusZ;
double distance = dx * dx + dz * dz;
if (distance >= 1.0) continue;
long detail = mix(ageSeed ^ (long) centerX * 341873128712L
^ (long) centerZ * 132897987541L ^ (long) worldX * 42317861L
^ (long) worldZ * 374761393L);
int top = centerY + (int) Math.round(3 * (1 - distance)) + Math.floorMod(detail, 3) - 1;
int depth = Math.max(1, (int) Math.round(3 + 17 * Math.pow(1 - distance, 1.5)));
// Biomes are already populated by createBiomes in 26.3, so
// sampling this chunk preserves its authored biome distribution.
var biome = chunk.getNoiseBiome(QuartPos.fromBlock(worldX), QuartPos.fromBlock(top),
QuartPos.fromBlock(worldZ));
BlockState soil = biome.is(BiomeTags.IS_END) || PortRegistryPages.isCaveBiome(biome)
? material : Blocks.DIRT.defaultBlockState();
BlockState cap = soil == material ? material : Blocks.GRASS_BLOCK.defaultBlockState();
for (int y = Math.max(top - depth, chunk.getMinY()); y <= top; y++) {
pos.set(worldX, y, worldZ);
if (!chunk.getBlockState(pos).isAir()) continue;
BlockState state = y == top ? cap : y >= top - 3 ? soil : material;
chunk.setBlockState(pos, state);
surface.update(localX, y, localZ, state);
floor.update(localX, y, localZ, state);
}
}
}
}
private static long mix(long value) {
value = (value ^ value >>> 30) * 0xBF58476D1CE4E5B9L;
value = (value ^ value >>> 27) * 0x94D049BB133111EBL;
return value ^ value >>> 31;
}
}
@@ -0,0 +1,46 @@
package net.dnorberg.rivencraft.age;
import com.mojang.serialization.Codec;
import com.mojang.serialization.codecs.RecordCodecBuilder;
import java.util.List;
import net.dnorberg.rivencraft.Rivencraft;
import net.minecraft.resources.Identifier;
/** Ordered lake pages with fluid choices frozen when the Age is bound. */
public record PortLakePlan(List<Choice> choices) {
public static final Codec<PortLakePlan> CODEC = RecordCodecBuilder.create(instance -> instance.group(
Choice.CODEC.listOf().fieldOf("choices").forGetter(PortLakePlan::choices)
).apply(instance, PortLakePlan::new));
public record Choice(Identifier page, Identifier fluid) {
public static final Codec<Choice> CODEC = RecordCodecBuilder.create(instance -> instance.group(
Identifier.CODEC.fieldOf("page").forGetter(Choice::page),
Identifier.CODEC.fieldOf("fluid").forGetter(Choice::fluid)
).apply(instance, Choice::new));
public Choice {
if (!isLake(page) || fluid == null) throw new IllegalArgumentException("Invalid lake choice");
}
}
public PortLakePlan {
choices = List.copyOf(choices);
if (choices.isEmpty() || choices.size() > 32)
throw new IllegalArgumentException("Lake plan must contain 1–32 choices");
}
public static boolean isLake(Identifier page) {
if (page == null || !page.getNamespace().equals(Rivencraft.MOD_ID)) return false;
return switch (page.getPath()) {
case "feature/surface_lakes", "feature/deep_lakes", "feature/lava_lakes" -> true;
default -> false;
};
}
public static boolean isDeep(Identifier page) { return page.getPath().equals("feature/deep_lakes"); }
public static Identifier placedFeature(Identifier page) {
if (!isLake(page)) throw new IllegalArgumentException("Unsupported lake page " + page);
return Identifier.withDefaultNamespace(isDeep(page) ? "lake_lava_underground" : "lake_lava_surface");
}
}
@@ -0,0 +1,52 @@
package net.dnorberg.rivencraft.age;
import com.mojang.serialization.Codec;
import java.util.Locale;
import net.dnorberg.rivencraft.Rivencraft;
import net.minecraft.core.Holder;
import net.minecraft.core.registries.Registries;
import net.minecraft.resources.Identifier;
import net.minecraft.resources.ResourceKey;
import net.minecraft.server.MinecraftServer;
import net.minecraft.world.level.dimension.DimensionType;
import net.minecraft.world.level.Level;
/** Authored lighting choices using statically registered, client-synchronized types. */
public enum PortLighting {
NORMAL, DARK, BRIGHT;
public static final Codec<PortLighting> CODEC = Codec.STRING.xmap(
name -> valueOf(name.toUpperCase(Locale.ROOT)), mode -> mode.name().toLowerCase(Locale.ROOT));
public static PortLighting forPage(Identifier page) {
if (!page.getNamespace().equals(Rivencraft.MOD_ID)) return null;
return switch (page.getPath()) {
case "lighting/normal" -> NORMAL;
case "lighting/dark" -> DARK;
case "lighting/bright" -> BRIGHT;
default -> null;
};
}
public static boolean isBright(Level level) {
return level.dimensionTypeRegistration().unwrapKey().map(key -> {
Identifier id = key.identifier();
return id.getNamespace().equals(Rivencraft.MOD_ID)
&& (id.getPath().equals("age_bright") || id.getPath().equals("age_cave_bright")
|| id.getPath().startsWith("age_bright_sky_")
|| id.getPath().startsWith("age_cave_bright_sky_"));
}).orElse(false);
}
Holder<DimensionType> dimensionType(MinecraftServer server, PortAgeTerrain terrain,
Holder<DimensionType> overworldType, PortSkyStyle sky) {
boolean cave = terrain == PortAgeTerrain.CAVE;
if (this == NORMAL && !cave && sky == PortSkyStyle.OVERWORLD) return overworldType;
String variant = this == BRIGHT ? (cave ? "age_cave_bright" : "age_bright")
: this == DARK || cave ? "age_dark" : "age_normal";
if (sky != PortSkyStyle.OVERWORLD) variant += "_sky_" + sky.name().toLowerCase(Locale.ROOT);
return server.registryAccess().lookupOrThrow(Registries.DIMENSION_TYPE).getOrThrow(
ResourceKey.create(Registries.DIMENSION_TYPE,
Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, variant)));
}
}
@@ -0,0 +1,91 @@
package net.dnorberg.rivencraft.age;
import net.minecraft.core.BlockPos;
import net.minecraft.core.Direction;
import net.minecraft.util.RandomSource;
import net.minecraft.world.level.ChunkPos;
import net.minecraft.world.level.WorldGenLevel;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.chunk.ChunkAccess;
import net.minecraft.world.level.levelgen.Heightmap;
/** 26.3 port of Rivencraft 1.21.1's Legacy-style 4×4 plinth and 2×12×2 shaft. */
final class PortObelisksFeature {
private static final long SALT = 0x4F42454C49534B53L;
private static final int CHANCE = 128;
private static final int BASE = 4;
private static final int HEIGHT = 12;
private PortObelisksFeature() {}
record Site(int x, int z) {}
static Site site(long ageSeed, ChunkPos chunk, int occurrence) {
RandomSource random = RandomSource.create(mix(ageSeed ^ SALT
^ (long) chunk.x() * 341873128712L ^ (long) chunk.z() * 132897987541L
^ (long) occurrence * 0x9E3779B97F4A7C15L));
if (random.nextInt(CHANCE) != 0) return null;
return new Site(chunk.getMinBlockX() + 4 + random.nextInt(8),
chunk.getMinBlockZ() + 4 + random.nextInt(8));
}
static void generate(WorldGenLevel level, ChunkAccess chunk, long ageSeed, BlockState material, int occurrence) {
Site site = site(ageSeed, chunk.getPos(), occurrence);
if (site == null) return;
BlockPos.MutableBlockPos pos = new BlockPos.MutableBlockPos();
int minSurface = Integer.MAX_VALUE;
int maxSurface = Integer.MIN_VALUE;
int[][] surface = new int[BASE][BASE];
for (int dx = 0; dx < BASE; dx++) {
for (int dz = 0; dz < BASE; dz++) {
int x = site.x() + dx;
int z = site.z() + dz;
int y = level.getHeight(Heightmap.Types.WORLD_SURFACE_WG, x, z);
if (y <= level.getMinY() + 1) return;
BlockState ground = level.getBlockState(pos.set(x, y - 1, z));
if (!ground.isFaceSturdy(level, pos, Direction.UP)
|| !ground.getFluidState().isEmpty() || ground.hasBlockEntity()) return;
surface[dx][dz] = y;
minSurface = Math.min(minSurface, y);
maxSurface = Math.max(maxSurface, y);
}
}
if (maxSurface - minSurface > 2 || maxSurface + HEIGHT >= level.getMaxY()) return;
for (int dx = 0; dx < BASE; dx++) {
for (int dz = 0; dz < BASE; dz++) {
for (int y = surface[dx][dz]; y <= maxSurface; y++) {
if (!open(level.getBlockState(pos.set(site.x() + dx, y, site.z() + dz)))) return;
}
}
}
for (int dx = 1; dx <= 2; dx++) {
for (int dz = 1; dz <= 2; dz++) {
for (int y = maxSurface + 1; y <= maxSurface + HEIGHT; y++) {
if (!open(level.getBlockState(pos.set(site.x() + dx, y, site.z() + dz)))) return;
}
}
}
for (int dx = 0; dx < BASE; dx++) {
for (int dz = 0; dz < BASE; dz++) {
for (int y = surface[dx][dz]; y <= maxSurface; y++)
level.setBlock(pos.set(site.x() + dx, y, site.z() + dz), material, 2);
}
}
for (int dx = 1; dx <= 2; dx++) {
for (int dz = 1; dz <= 2; dz++) {
for (int y = maxSurface + 1; y <= maxSurface + HEIGHT; y++)
level.setBlock(pos.set(site.x() + dx, y, site.z() + dz), material, 2);
}
}
}
private static boolean open(BlockState state) {
return state.getFluidState().isEmpty() && (state.isAir() || state.canBeReplaced());
}
private static long mix(long value) {
value = (value ^ value >>> 30) * 0xBF58476D1CE4E5B9L;
value = (value ^ value >>> 27) * 0x94D049BB133111EBL;
return value ^ value >>> 31;
}
}
@@ -68,7 +68,7 @@ public final class PortRandomAgeSelection {
return surface.isEmpty() ? List.of(Identifier.withDefaultNamespace("plains")) : surface;
}
private static <T> T weighted(Random random, List<T> choices, int[] weights) {
static <T> T weighted(Random random, List<T> choices, int[] weights) {
int total = 0;
for (int weight : weights) total += weight;
int ticket = random.nextInt(total);
@@ -1,134 +0,0 @@
package net.dnorberg.rivencraft.age;
import com.mojang.serialization.MapCodec;
import com.mojang.serialization.codecs.RecordCodecBuilder;
import java.util.List;
import java.util.Set;
import java.util.concurrent.CompletableFuture;
import net.minecraft.core.BlockPos;
import net.minecraft.core.Holder;
import net.minecraft.core.HolderLookup;
import net.minecraft.core.RegistryAccess;
import net.minecraft.core.registries.BuiltInRegistries;
import net.minecraft.resources.Identifier;
import net.minecraft.resources.ResourceKey;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.server.level.WorldGenRegion;
import net.minecraft.util.RandomSource;
import net.minecraft.world.level.ChunkPos;
import net.minecraft.world.level.LevelHeightAccessor;
import net.minecraft.world.level.NoiseColumn;
import net.minecraft.world.level.StructureManager;
import net.minecraft.world.level.WorldGenLevel;
import net.minecraft.world.level.biome.Biome;
import net.minecraft.world.level.biome.BiomeManager;
import net.minecraft.world.level.chunk.ChunkAccess;
import net.minecraft.world.level.chunk.ChunkGenerator;
import net.minecraft.world.level.chunk.ChunkGeneratorStructureState;
import net.minecraft.world.level.levelgen.Heightmap;
import net.minecraft.world.level.levelgen.RandomState;
import net.minecraft.world.level.levelgen.blending.Blender;
import net.minecraft.world.level.levelgen.densityfunction.SamplerContext;
import net.minecraft.world.level.levelgen.structure.StructureSet;
import net.minecraft.world.level.levelgen.structure.Structure;
import net.minecraft.world.level.levelgen.structure.templatesystem.StructureTemplateManager;
import com.mojang.datafixers.util.Pair;
import net.minecraft.core.HolderSet;
import net.minecraft.world.level.Level;
import net.minecraft.world.level.block.state.BlockState;
/** Delegates 26.3 terrain generation and adds one Age-scoped Rocks decoration. */
public final class PortRockChunkGenerator extends ChunkGenerator {
public static final MapCodec<PortRockChunkGenerator> CODEC = RecordCodecBuilder.mapCodec(instance -> instance.group(
ChunkGenerator.CODEC.fieldOf("base").forGetter(generator -> generator.base),
PortRockPlan.CODEC.fieldOf("rocks").forGetter(generator -> generator.rocks),
com.mojang.serialization.Codec.LONG.fieldOf("age_seed").forGetter(generator -> generator.ageSeed)
).apply(instance, PortRockChunkGenerator::new));
private final ChunkGenerator base;
private final PortRockPlan rocks;
private final long ageSeed;
public PortRockChunkGenerator(ChunkGenerator base, PortRockPlan rocks, long ageSeed) {
super(base.getBiomeSource(), base::getBiomeGenerationSettings);
this.base = base;
this.rocks = rocks;
this.ageSeed = ageSeed;
}
public ChunkGenerator base() { return base; }
public PortRockPlan rocks() { return rocks; }
@Override protected MapCodec<? extends ChunkGenerator> codec() { return CODEC; }
@Override public void validate() { base.validate(); }
@Override public ChunkPos getOrigin(RandomState random) { return base.getOrigin(random); }
@Override public java.util.Optional<Identifier> getTypeNameForDataFixer() { return base.getTypeNameForDataFixer(); }
@Override public CompletableFuture<ChunkAccess> createBiomes(RandomState random, Blender blender,
StructureManager structures, ChunkAccess chunk) {
return base.createBiomes(random, blender, structures, chunk);
}
@Override public ChunkGeneratorStructureState createState(HolderLookup<StructureSet> sets,
RandomState random, long seed) {
return base.createState(sets, random, seed);
}
@Override public void createStructures(RegistryAccess registries, ChunkGeneratorStructureState state,
StructureManager structures, ChunkAccess chunk,
StructureTemplateManager templates, ResourceKey<Level> dimension) {
base.createStructures(registries, state, structures, chunk, templates, dimension);
}
@Override public void createReferences(WorldGenLevel level, StructureManager structures, ChunkAccess chunk) {
base.createReferences(level, structures, chunk);
}
@Override public Pair<BlockPos, Holder<Structure>> findNearestMapStructure(ServerLevel level,
HolderSet<Structure> targets, BlockPos origin, int radius, boolean skipKnown) {
return base.findNearestMapStructure(level, targets, origin, radius, skipKnown);
}
@Override public void spawnOriginalMobs(WorldGenRegion level) { base.spawnOriginalMobs(level); }
@Override public int getSpawnHeight(LevelHeightAccessor height) { return base.getSpawnHeight(height); }
@Override public int getGenDepth() { return base.getGenDepth(); }
@Override public int getSeaLevel() { return base.getSeaLevel(); }
@Override public int getMinY() { return base.getMinY(); }
@Override public CompletableFuture<ChunkAccess> buildTerrain(ChunkAccess chunk, Blender blender,
RandomState random, StructureManager structures, BiomeManager biomes,
WorldGenRegion carverBiomeRegion, Set<Holder<Biome>> possibleBiomes) {
return base.buildTerrain(chunk, blender, random, structures, biomes, carverBiomeRegion, possibleBiomes);
}
@Override public int getBaseHeight(int x, int z, Heightmap.Types type,
LevelHeightAccessor height, RandomState random) {
return base.getBaseHeight(x, z, type, height, random);
}
@Override public NoiseColumn getBaseColumn(int x, int z, LevelHeightAccessor height, RandomState random) {
return base.getBaseColumn(x, z, height, random);
}
@Override public void addDebugScreenInfo(List<String> info, RandomState random,
BlockPos pos, SamplerContext context) {
base.addDebugScreenInfo(info, random, pos, context);
}
@Override public void refreshFeaturesPerStep() {
base.refreshFeaturesPerStep();
super.refreshFeaturesPerStep();
}
@Override public void applyBiomeDecoration(WorldGenLevel level, ChunkAccess chunk,
StructureManager structures) {
base.applyBiomeDecoration(level, chunk, structures);
ChunkPos chunkPos = chunk.getPos();
long seed = ageSeed ^ (long) chunkPos.x() * 341873128712L ^ (long) chunkPos.z() * 132897987541L;
RandomSource random = RandomSource.create(seed);
if (random.nextInt(5) != 0) return;
BlockState material = BuiltInRegistries.BLOCK.getValue(rocks.material()).defaultBlockState();
int centerX = chunkPos.getMinBlockX() + 2 + random.nextInt(12);
int centerZ = chunkPos.getMinBlockZ() + 2 + random.nextInt(12);
for (int dx = -1; dx <= 1; dx++) for (int dz = -1; dz <= 1; dz++) {
if (random.nextInt(4) == 0) continue;
int x = centerX + dx;
int z = centerZ + dz;
int y = level.getHeight(Heightmap.Types.WORLD_SURFACE_WG, x, z);
BlockPos place = new BlockPos(x, y, z);
if (level.getBlockState(place).isAir()
&& level.getFluidState(place).isEmpty()
&& level.getBlockState(place.below()).isFaceSturdy(level, place.below(), net.minecraft.core.Direction.UP))
level.setBlock(place, material, 2);
}
}
}
@@ -2,11 +2,23 @@ package net.dnorberg.rivencraft.age;
import com.mojang.serialization.Codec;
import com.mojang.serialization.codecs.RecordCodecBuilder;
import java.util.List;
import java.util.Optional;
import net.minecraft.resources.Identifier;
/** First frozen authored feature: one Rocks page with an explicit block material. */
public record PortRockPlan(Identifier material) {
/** Frozen, ordered Rocks materials. Old single-material beta Ages remain readable. */
public record PortRockPlan(List<Identifier> materials) {
public static final Codec<PortRockPlan> CODEC = RecordCodecBuilder.create(instance -> instance.group(
Identifier.CODEC.fieldOf("material").forGetter(PortRockPlan::material)
).apply(instance, PortRockPlan::new));
Identifier.CODEC.optionalFieldOf("material").forGetter(plan -> Optional.empty()),
Identifier.CODEC.listOf().optionalFieldOf("materials", List.of()).forGetter(PortRockPlan::materials)
).apply(instance, (old, current) -> new PortRockPlan(current.isEmpty()
? old.map(List::of).orElse(List.of()) : current)));
public PortRockPlan {
materials = List.copyOf(materials);
if (materials.isEmpty() || materials.size() > 32)
throw new IllegalArgumentException("Rocks plan must contain 1–32 materials");
}
public PortRockPlan(Identifier material) { this(List.of(material)); }
}
@@ -0,0 +1,70 @@
package net.dnorberg.rivencraft.age;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import net.minecraft.core.BlockPos;
import net.minecraft.core.Direction;
import net.minecraft.tags.BlockTags;
import net.minecraft.util.RandomSource;
import net.minecraft.world.level.WorldGenLevel;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.block.ShelfMushroomBlock;
import net.minecraft.world.level.chunk.ChunkAccess;
import net.minecraft.world.level.levelgen.feature.treedecorators.ShelfMushroomDecorator;
import net.minecraft.world.level.levelgen.feature.treedecorators.TreeDecorator;
/** Shelf mushrooms on exposed log faces, regardless of how the logs were generated. */
final class PortShelfMushrooms {
private PortShelfMushrooms() {}
static List<BlockPos> findHosts(ChunkAccess chunk) {
List<BlockPos> logs = new ArrayList<>();
var sections = chunk.getSections();
for (int index = 0; index < sections.length; index++) {
var section = sections[index];
if (!section.maybeHas(state -> state.is(BlockTags.LOGS))) continue;
int baseY = chunk.getSectionYFromSectionIndex(index) << 4;
for (int y = 0; y < 16; y++) for (int z = 0; z < 16; z++) for (int x = 0; x < 16; x++) {
if (section.getBlockState(x, y, z).is(BlockTags.LOGS))
logs.add(new BlockPos(chunk.getPos().getMinBlockX() + x, baseY + y,
chunk.getPos().getMinBlockZ() + z));
}
}
return logs;
}
static void decorate(WorldGenLevel level, ChunkAccess chunk, List<BlockPos> logs, RandomSource random) {
// Reuse vanilla replaceability/water checks, but not its tree-base height
// band: a log tendril or sphere has no meaningful tree base.
TreeDecorator.Context context = new TreeDecorator.Context(level,
(pos, state) -> level.setBlock(pos, state, 2), random, Set.of(), Set.of(), Set.of());
for (BlockPos log : logs) {
if (!level.getBlockState(log).is(BlockTags.LOGS)) continue;
Direction first = Direction.Plane.HORIZONTAL.getRandomDirection(random);
for (Direction facing : List.of(first, first.getClockWise())) {
if (random.nextFloat() > 0.25F) continue;
BlockPos pos = log.relative(facing);
if ((pos.getX() >> 4) != chunk.getPos().x() || (pos.getZ() >> 4) != chunk.getPos().z()) continue;
if (pos.getY() < level.getMinY() || pos.getY() >= level.getMaxY()) continue;
if (!level.getFluidState(pos).isEmpty()
|| !ShelfMushroomDecorator.isBlockReplaceableWithShelfMushroom(context, pos)
|| level.getBlockState(pos.below()).is(Blocks.SHELF_MUSHROOM)) continue;
boolean adjacent = false;
for (Direction side : Direction.Plane.HORIZONTAL) {
if (level.getBlockState(pos.relative(side)).is(Blocks.SHELF_MUSHROOM)) {
adjacent = true;
break;
}
}
if (adjacent) continue;
var mushroom = Blocks.SHELF_MUSHROOM.defaultBlockState()
.setValue(ShelfMushroomBlock.FACING, facing)
.setValue(ShelfMushroomBlock.AGE, random.nextInt(2));
if (!mushroom.canSurvive(level, pos)) continue;
context.setBlock(pos, mushroom);
break;
}
}
}
}
@@ -0,0 +1,57 @@
package net.dnorberg.rivencraft.age;
import com.mojang.serialization.Codec;
import java.util.List;
import java.util.Locale;
import java.util.Random;
import net.dnorberg.rivencraft.Rivencraft;
import net.minecraft.resources.Identifier;
/** Persisted sky styles, including the client-rendered Legacy static background. */
public enum PortSkyStyle {
OVERWORLD, NETHER, END, STATIC;
public static final Codec<PortSkyStyle> CODEC = Codec.STRING.xmap(
name -> valueOf(name.toUpperCase(Locale.ROOT)), sky -> sky.name().toLowerCase(Locale.ROOT));
public static PortSkyStyle forPage(Identifier page) {
if (!page.getNamespace().equals(Rivencraft.MOD_ID)) return null;
return switch (page.getPath()) {
case "sky/overworld" -> OVERWORLD;
case "sky/nether" -> NETHER;
case "sky/end" -> END;
case "sky/static" -> STATIC;
default -> null;
};
}
public static PortSkyStyle resolve(List<Identifier> pages, long seed) {
return pages.stream().map(PortSkyStyle::forPage).filter(java.util.Objects::nonNull)
.findFirst().orElseGet(() -> pages.stream().anyMatch(PortSkyStyle::isCelestial)
? OVERWORLD : random(seed));
}
private static boolean isCelestial(Identifier page) {
if (!page.getNamespace().equals(Rivencraft.MOD_ID)) return false;
return switch (page.getPath()) {
// Only Overworld currently draws bodies in this port. Recognize the
// Sun/Moon intentions too, without marking those pages implemented.
case "celestial/normal_time", "celestial/sun", "celestial/moon" -> true;
default -> false;
};
}
private static PortSkyStyle random(long seed) {
// Match the first draw in 1.21.1 AgeSkyProfile.random: its equally
// likely INHERITED and OVERWORLD slots currently share the native
// Overworld presentation here. Do not add unported colors/bodies.
int choice = new Random(seed ^ 0x534B5950524F464CL).nextInt(5);
return switch (choice) {
case 0, 1 -> OVERWORLD;
case 2 -> NETHER;
case 3 -> END;
case 4 -> STATIC;
default -> throw new IllegalStateException("Invalid random sky choice");
};
}
}
@@ -0,0 +1,87 @@
package net.dnorberg.rivencraft.age;
import net.minecraft.core.BlockPos;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.chunk.ChunkAccess;
import net.minecraft.world.level.levelgen.Heightmap;
/**
* 26.3 port of Rivencraft's subtractive Skylands filter, adapted from
* Mystcraft Legacy's SymbolSkylands (LGPL-3.0-or-later). Keeps upper solid
* terrain above a seven-octave cutoff near Y=76 and removes liquids.
*/
final class PortSkylandsFeature {
private static final long SALT = 0x534B594C414E4453L;
private static final int BASE_CUTOFF = 76;
private static final BlockState AIR = Blocks.AIR.defaultBlockState();
private PortSkylandsFeature() {}
static void filter(ChunkAccess chunk, long ageSeed) {
int originX = chunk.getPos().getMinBlockX();
int originZ = chunk.getPos().getMinBlockZ();
int minY = chunk.getMinY();
int maxY = chunk.getMaxY();
Heightmap surface = chunk.getOrCreateHeightmapUnprimed(Heightmap.Types.WORLD_SURFACE_WG);
Heightmap floor = chunk.getOrCreateHeightmapUnprimed(Heightmap.Types.OCEAN_FLOOR_WG);
BlockPos.MutableBlockPos pos = new BlockPos.MutableBlockPos();
for (int localX = 0; localX < 16; localX++) {
for (int localZ = 0; localZ < 16; localZ++) {
int cutoff = cutoff(ageSeed, originX + localX, originZ + localZ);
for (int y = minY; y < maxY; y++) {
pos.set(originX + localX, y, originZ + localZ);
BlockState state = chunk.getBlockState(pos);
if (!removes(state, y, cutoff)) continue;
chunk.setBlockState(pos, AIR);
surface.update(localX, y, localZ, AIR);
floor.update(localX, y, localZ, AIR);
}
}
}
}
static boolean removes(BlockState state, int y, int cutoff) {
return !state.isAir() && (y <= cutoff || state.liquid());
}
static int cutoff(long ageSeed, int x, int z) {
double variation = 0.0D;
int cellSize = 128;
double amplitude = 12.0D;
for (int octave = 0; octave < 7; octave++) {
variation += valueNoise(ageSeed ^ SALT ^ (long) octave * 0x9E3779B97F4A7C15L,
x, z, cellSize) * amplitude;
cellSize /= 2;
amplitude /= 2.0D;
}
return BASE_CUTOFF + (int) Math.round(variation);
}
private static double valueNoise(long seed, int x, int z, int cellSize) {
int cellX = Math.floorDiv(x, cellSize);
int cellZ = Math.floorDiv(z, cellSize);
double u = smooth((x - cellX * cellSize) / (double) cellSize);
double v = smooth((z - cellZ * cellSize) / (double) cellSize);
double north = lerp(value(seed, cellX, cellZ), value(seed, cellX + 1, cellZ), u);
double south = lerp(value(seed, cellX, cellZ + 1), value(seed, cellX + 1, cellZ + 1), u);
return lerp(north, south, v);
}
private static double value(long seed, int x, int z) {
long hash = mix(seed ^ (long) x * 0xD1B54A32D192ED03L ^ (long) z * 0xABC98388FB8FAC03L);
return (hash >>> 11) * 0x1.0p-52 - 1.0D;
}
private static double smooth(double value) { return value * value * (3.0D - 2.0D * value); }
private static double lerp(double first, double second, double amount) {
return first + (second - first) * amount;
}
private static long mix(long value) {
value = (value ^ value >>> 30) * 0xBF58476D1CE4E5B9L;
value = (value ^ value >>> 27) * 0x94D049BB133111EBL;
return value ^ value >>> 31;
}
}
@@ -0,0 +1,122 @@
package net.dnorberg.rivencraft.age;
import java.util.Random;
import net.minecraft.core.BlockPos;
import net.minecraft.util.Mth;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.chunk.ChunkAccess;
import net.minecraft.world.level.levelgen.Heightmap;
/**
* 26.3 adaptation of Rivencraft 1.21.1's solid sphere nodes, derived from
* Mystcraft Legacy's MapGenSpheresMyst (Mystcraft contributors,
* LGPL-3.0-or-later, commit 9bc8ddc). Writes stay within the target chunk.
*/
final class PortSpheresFeature {
private static final int SOURCE_RANGE = 8;
private static final long SALT = 0x53504845524553L;
private PortSpheresFeature() {}
static void generate(ChunkAccess chunk, long ageSeed, BlockState material) {
var target = chunk.getPos();
Random master = new Random(ageSeed ^ SALT);
long xSeed = master.nextLong();
long zSeed = master.nextLong();
for (int sx = target.x() - SOURCE_RANGE; sx <= target.x() + SOURCE_RANGE; sx++) {
for (int sz = target.z() - SOURCE_RANGE; sz <= target.z() + SOURCE_RANGE; sz++) {
Random source = new Random((long) sx * xSeed ^ (long) sz * zSeed ^ ageSeed ^ SALT);
if (source.nextFloat() > 0.05F) continue;
double x = sx * 16 + source.nextInt(16);
double y = source.nextInt(source.nextInt(192) + 1) + 32;
double z = sz * 16 + source.nextInt(16);
trace(chunk, material, source.nextLong(), x, y, z,
1.0F + source.nextFloat() * 4.0F, 0, 0, -1, -1);
}
}
}
private static void trace(ChunkAccess chunk, BlockState material, long seed,
double x, double y, double z, float radius, float yaw, float pitch,
int step, int length) {
Random random = new Random(seed);
if (length <= 0) {
int maximum = SOURCE_RANGE * 16 - 16;
length = maximum - random.nextInt(maximum / 4);
}
boolean singleNode = step == -1;
if (singleNode) step = length / 2;
int splitAt = random.nextInt(length / 2) + length / 4;
boolean slowPitch = random.nextInt(6) == 0;
float yawDrift = 0;
float pitchDrift = 0;
double middleX = chunk.getPos().getMinBlockX() + 8;
double middleZ = chunk.getPos().getMinBlockZ() + 8;
for (; step < length; step++) {
double nodeRadius = 1.5D + Math.sin(step * Math.PI / length) * radius;
float horizontal = Mth.cos(pitch);
x += Mth.cos(yaw) * horizontal;
y += Mth.sin(pitch);
z += Mth.sin(yaw) * horizontal;
pitch *= slowPitch ? 0.92F : 0.7F;
pitch += pitchDrift * 0.1F;
yaw += yawDrift * 0.1F;
pitchDrift = pitchDrift * 0.9F
+ (random.nextFloat() - random.nextFloat()) * random.nextFloat() * 2.0F;
yawDrift = yawDrift * 0.75F
+ (random.nextFloat() - random.nextFloat()) * random.nextFloat() * 4.0F;
if (!singleNode && step == splitAt && radius > 1.0F) {
trace(chunk, material, random.nextLong(), x, y, z,
random.nextFloat() * 0.5F + 0.5F, yaw - Mth.HALF_PI, pitch / 3, step, length);
trace(chunk, material, random.nextLong(), x, y, z,
random.nextFloat() * 0.5F + 0.5F, yaw + Mth.HALF_PI, pitch / 3, step, length);
return;
}
if (!singleNode && random.nextInt(4) == 0) continue;
double dx = x - middleX;
double dz = z - middleZ;
double remaining = length - step;
double reach = radius + 18;
if (dx * dx + dz * dz - remaining * remaining > reach * reach) return;
if (Math.abs(dx) <= 16 + nodeRadius * 2 && Math.abs(dz) <= 16 + nodeRadius * 2)
fillNode(chunk, material, x, y, z, nodeRadius);
if (singleNode) break;
}
}
private static void fillNode(ChunkAccess chunk, BlockState material,
double centerX, double centerY, double centerZ, double radius) {
int originX = chunk.getPos().getMinBlockX();
int originZ = chunk.getPos().getMinBlockZ();
int minX = Math.max(0, Mth.floor(centerX - radius) - originX - 1);
int maxX = Math.min(16, Mth.floor(centerX + radius) - originX + 2);
int minZ = Math.max(0, Mth.floor(centerZ - radius) - originZ - 1);
int maxZ = Math.min(16, Mth.floor(centerZ + radius) - originZ + 2);
int minY = Math.max(chunk.getMinY() + 1, Mth.floor(centerY - radius) - 1);
int maxY = Math.min(chunk.getMaxY(), Mth.floor(centerY + radius) + 2);
Heightmap surface = chunk.getOrCreateHeightmapUnprimed(Heightmap.Types.WORLD_SURFACE_WG);
Heightmap floor = chunk.getOrCreateHeightmapUnprimed(Heightmap.Types.OCEAN_FLOOR_WG);
BlockPos.MutableBlockPos pos = new BlockPos.MutableBlockPos();
for (int worldY = minY; worldY < maxY; worldY++) {
double dy = (worldY + 0.5D - centerY) / radius;
for (int localZ = minZ; localZ < maxZ; localZ++) {
double dz = (originZ + localZ + 0.5D - centerZ) / radius;
for (int localX = minX; localX < maxX; localX++) {
double dx = (originX + localX + 0.5D - centerX) / radius;
if (!insideSphere(dx, dy, dz)) continue;
pos.set(originX + localX, worldY, originZ + localZ);
BlockState previous = chunk.getBlockState(pos);
if (previous == material || previous.is(Blocks.BEDROCK)) continue;
chunk.setBlockState(pos, material);
surface.update(localX, worldY, localZ, material);
floor.update(localX, worldY, localZ, material);
}
}
}
}
static boolean insideSphere(double dx, double dy, double dz) {
return dx * dx + dy * dy + dz * dz < 1.0D;
}
}
@@ -0,0 +1,56 @@
package net.dnorberg.rivencraft.age;
import net.minecraft.core.BlockPos;
import net.minecraft.util.RandomSource;
import net.minecraft.world.level.WorldGenLevel;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.chunk.ChunkAccess;
import net.minecraft.world.level.levelgen.Heightmap;
/**
* 26.3 port of Rivencraft 1.21.1's rare surface spikes, adapted from
* Mystcraft Legacy's WorldGenSpikesAdv (Mystcraft contributors,
* LGPL-3.0-or-later, commit 9bc8ddc). The footprint stays within one chunk.
*/
final class PortSpikesFeature {
private static final long SALT = 0x5350494B4553L;
private PortSpikesFeature() {}
static void generate(WorldGenLevel level, ChunkAccess chunk, long ageSeed, BlockState material, int occurrence) {
int originX = chunk.getPos().getMinBlockX();
int originZ = chunk.getPos().getMinBlockZ();
RandomSource random = RandomSource.create(ageSeed ^ SALT
^ (long) originX * 341873128712L ^ (long) originZ * 132897987541L
^ (long) occurrence * 0x9E3779B97F4A7C15L);
if (random.nextInt(18) != 0) return;
int x = originX + 4 + random.nextInt(8);
int z = originZ + 4 + random.nextInt(8);
int y = level.getHeight(Heightmap.Types.WORLD_SURFACE_WG, x, z);
int height = 6 + random.nextInt(32);
int width = 1 + random.nextInt(4);
if (y < level.getMinY() + 1 || y + height >= level.getMaxY()) return;
BlockPos.MutableBlockPos pos = new BlockPos.MutableBlockPos();
if (!level.isEmptyBlock(pos.set(x, y, z))) return;
for (int px = x - width; px <= x + width; px++) {
for (int pz = z - width; pz <= z + width; pz++) {
if (!insideFootprint(px - x, pz - z, width)) continue;
if (!level.getBlockState(pos.set(px, y - 1, pz)).isSolid()) return;
}
}
for (int px = x - width; px <= x + width; px++) {
for (int pz = z - width; pz <= z + width; pz++) {
if (!insideFootprint(px - x, pz - z, width)) continue;
int top = y + 1 + random.nextInt(random.nextInt(height) + 1);
for (int py = y; py < top; py++) {
pos.set(px, py, pz);
if (level.isEmptyBlock(pos)) level.setBlock(pos, material, 2);
}
}
}
}
static boolean insideFootprint(int dx, int dz, int width) {
return dx * dx + dz * dz <= width * width + 1;
}
}
@@ -0,0 +1,107 @@
package net.dnorberg.rivencraft.age;
import com.mojang.serialization.Codec;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.Random;
import net.dnorberg.rivencraft.Rivencraft;
import net.minecraft.core.BlockPos;
import net.minecraft.resources.Identifier;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.levelgen.Heightmap;
import net.minecraft.world.level.saveddata.SavedData;
import net.minecraft.world.level.saveddata.SavedDataType;
/**
* Ports Rivencraft 1.21.1's Legacy fissure footprint/carving.
* Derived from Mystcraft's WorldGenMystStarFissure, LGPL-3.0,
* copyright XCompWiz and Mystcraft contributors.
*/
public final class PortStarFissureFeature {
private static final long SALT = 0x5354415246495353L;
private PortStarFissureFeature() {}
/** Per-Age data prevents revisits/restarts from carving the same fissure again. */
private static final class Arrival extends SavedData {
private static final Codec<Arrival> CODEC = BlockPos.CODEC.optionalFieldOf("spawn").codec()
.xmap(Arrival::new, data -> Optional.ofNullable(data.spawn));
private static final SavedDataType<Arrival> TYPE = new SavedDataType<>(
Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "star_fissure_arrival"),
() -> new Arrival(Optional.empty()), CODEC);
private BlockPos spawn;
private Arrival(Optional<BlockPos> spawn) { this.spawn = spawn.orElse(null); }
}
public static BlockPos savedSpawn(ServerLevel level) {
return level.getDataStorage().computeIfAbsent(Arrival.TYPE).spawn;
}
public static void generateOnce(ServerLevel level, BlockPos spawn, boolean caveTerrain) {
Arrival data = level.getDataStorage().computeIfAbsent(Arrival.TYPE);
if (data.spawn != null) return;
generate(level, level.getSeed(), spawn, caveTerrain);
data.spawn = spawn.immutable();
data.setDirty();
}
/** Legacy chooses 8–23 blocks after the spawn chunk, then grows along +Z. */
public static List<BlockPos> footprint(long ageSeed, BlockPos spawn) {
long chunkX = spawn.getX() >> 4;
long chunkZ = spawn.getZ() >> 4;
Random random = new Random(ageSeed ^ SALT ^ chunkX ^ (chunkZ << 32));
int originX = ((int) chunkX << 4) + random.nextInt(16) + 8;
int originZ = ((int) chunkZ << 4) + random.nextInt(16) + 8;
int length = random.nextInt(8) + 10;
int[][] intervals = new int[length][2];
List<BlockPos> columns = new ArrayList<>();
addRow(columns, originX, originZ, 0, 0, 0);
for (int row = 1; row < length; row++) {
int range = length + Math.min(row, length - row) + 1;
int right = (random.nextInt(range) - (range >> 1) + intervals[row - 1][1]) / 2;
int left = (random.nextInt(range) - (range >> 1) + intervals[row - 1][0]) / 2;
if (left > right) {
int swap = left;
left = right;
right = swap;
}
left = Math.min(left, intervals[row - 1][1]);
right = Math.max(right, intervals[row - 1][0]);
intervals[row][0] = left;
intervals[row][1] = right;
addRow(columns, originX, originZ, row, left, right);
}
return List.copyOf(columns);
}
private static void addRow(List<BlockPos> columns, int originX, int originZ, int row, int left, int right) {
for (int x = left; x <= right; x++) {
// Only X/Z are used; the real floor is the dimension's minimum Y.
columns.add(new BlockPos(originX + x, 0, originZ + row * 2));
columns.add(new BlockPos(originX + x, 0, originZ + row * 2 + 1));
}
}
private static void generate(ServerLevel level, long ageSeed, BlockPos spawn, boolean caveTerrain) {
int floorY = level.getMinY();
BlockPos.MutableBlockPos mutable = new BlockPos.MutableBlockPos();
for (BlockPos column : footprint(ageSeed, spawn)) {
int top = level.getHeight(Heightmap.Types.WORLD_SURFACE, column.getX(), column.getZ());
if (caveTerrain) {
for (int y = Math.max(spawn.getY(), floorY + 1); y < top; y++) {
if (level.getBlockState(mutable.set(column.getX(), y, column.getZ())).is(Blocks.BEDROCK)) {
top = y;
break;
}
}
}
for (int y = floorY + 1; y < top; y++)
level.setBlock(mutable.set(column.getX(), y, column.getZ()), Blocks.AIR.defaultBlockState(), 2);
level.setBlock(mutable.set(column.getX(), floorY, column.getZ()),
Rivencraft.STAR_FISSURE.get().defaultBlockState(), 2);
}
}
}
@@ -0,0 +1,180 @@
package net.dnorberg.rivencraft.age;
import com.mojang.serialization.MapCodec;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Stream;
import net.dnorberg.rivencraft.mixin.StructurePlacementAccessor;
import net.minecraft.core.Holder;
import net.minecraft.core.HolderLookup;
import net.minecraft.core.HolderOwner;
import net.minecraft.core.HolderSet;
import net.minecraft.core.registries.Registries;
import net.minecraft.resources.Identifier;
import net.minecraft.resources.ResourceKey;
import net.minecraft.tags.TagKey;
import net.minecraft.world.level.biome.Biome;
import net.minecraft.world.level.biome.BiomeSource;
import net.minecraft.world.level.biome.Climate;
import net.minecraft.world.level.levelgen.structure.Structure;
import net.minecraft.world.level.levelgen.structure.StructureSet;
import net.minecraft.world.level.levelgen.structure.placement.ConcentricRingsStructurePlacement;
import net.minecraft.world.level.levelgen.structure.placement.RandomSpreadStructurePlacement;
/** 1.21.1 structure planning/density rules adapted to modern placement classes. */
public final class PortStructurePages {
private static final Map<Identifier, Identifier> SETS = Map.ofEntries(
vanillaSet("villages", "villages"),
vanillaSet("mineshafts", "mineshafts"),
vanillaSet("strongholds", "strongholds"),
vanillaSet("trial_chambers", "trial_chambers"),
vanillaSet("buried_treasure", "buried_treasures"),
vanillaSet("shipwrecks", "shipwrecks"),
vanillaSet("ocean_ruins", "ocean_ruins"),
vanillaSet("pillager_outposts", "pillager_outposts"),
vanillaSet("ruined_portals", "ruined_portals"),
vanillaSet("nether_fossils", "nether_fossils"),
vanillaSet("ancient_cities", "ancient_cities"),
vanillaSet("trail_ruins", "trail_ruins"),
vanillaSet("abandoned_camps", "abandoned_camp"),
Map.entry(page("nether_fortresses"), Identifier.fromNamespaceAndPath("rivencraft", "fortresses")),
Map.entry(page("bastions"), Identifier.fromNamespaceAndPath("rivencraft", "bastions")));
// Suppress vanilla's combined Nether set so an authored Fortress page does
// not also introduce Bastions (or vice versa).
private static final Set<Identifier> CONTROLLED_SETS = Stream.concat(SETS.values().stream(),
Stream.of(Identifier.withDefaultNamespace("nether_complexes")))
.collect(java.util.stream.Collectors.toUnmodifiableSet());
private static final Set<Identifier> CAVE_ONLY = Set.of(
page("nether_fortresses"), page("bastions"), page("nether_fossils"));
private static final Set<Identifier> SURFACE_ONLY = Set.of(
page("villages"), page("strongholds"), page("buried_treasure"), page("shipwrecks"),
page("ocean_ruins"), page("pillager_outposts"), page("trail_ruins"), page("abandoned_camps"));
private static final Set<Identifier> FLAT_ALLOWED = Set.of(page("villages"), page("strongholds"));
private PortStructurePages() {}
private static Identifier page(String name) {
return Identifier.fromNamespaceAndPath("rivencraft", "feature/" + name);
}
private static Map.Entry<Identifier, Identifier> vanillaSet(String page, String set) {
return Map.entry(page(page), Identifier.withDefaultNamespace(set));
}
public static boolean supports(Identifier page) { return SETS.containsKey(page); }
public static void validateCounts(List<Identifier> pages) {
Map<Identifier, Integer> counts = new HashMap<>();
for (Identifier page : pages) {
if (!supports(page) || counts.merge(page, 1, Integer::sum) > 32)
throw new IllegalArgumentException("Structure plan requires supported pages, at most 32 per feature");
}
}
public static boolean supportsTerrain(Identifier page, PortAgeTerrain terrain) {
if (!supports(page) || terrain == PortAgeTerrain.VOID) return false;
if (terrain == PortAgeTerrain.FLAT && !FLAT_ALLOWED.contains(page)) return false;
if (CAVE_ONLY.contains(page)) return terrain == PortAgeTerrain.CAVE;
return terrain != PortAgeTerrain.CAVE || !SURFACE_ONLY.contains(page);
}
public static void validateRegistry(List<Identifier> pages, HolderLookup.Provider registries) {
var sets = registries.lookupOrThrow(Registries.STRUCTURE_SET);
pages.stream().filter(PortStructurePages::supports).distinct().forEach(page ->
sets.get(ResourceKey.create(Registries.STRUCTURE_SET, SETS.get(page)))
.orElseThrow(() -> new IllegalArgumentException("Missing structure set for " + page)));
}
private static Map<Identifier, Integer> enabled(List<Identifier> pages) {
Map<Identifier, Integer> result = new HashMap<>();
for (Identifier page : pages) result.merge(SETS.get(page), 1, Integer::sum);
return result;
}
public static boolean authored(Structure structure, HolderLookup.Provider registries, List<Identifier> pages) {
var sets = registries.lookupOrThrow(Registries.STRUCTURE_SET);
return enabled(pages).keySet().stream()
.map(id -> sets.getOrThrow(ResourceKey.create(Registries.STRUCTURE_SET, id)).value())
.flatMap(set -> set.structures().stream()).anyMatch(entry -> entry.structure().value() == structure);
}
/** Extra possible biomes affect planning only; terrain still uses its original source. */
public static BiomeSource planningBiomes(BiomeSource actual, HolderLookup<StructureSet> sets, List<Identifier> pages) {
List<Holder<Biome>> extra = enabled(pages).keySet().stream()
.map(id -> sets.getOrThrow(ResourceKey.create(Registries.STRUCTURE_SET, id)).value())
.flatMap(set -> set.structures().stream()).flatMap(entry -> entry.structure().value().biomes().stream())
.distinct().toList();
return new BiomeSource() {
@Override protected MapCodec<? extends BiomeSource> codec() {
throw new UnsupportedOperationException("Planning biome sources are never serialized");
}
@Override protected Stream<Holder<Biome>> collectPossibleBiomes() {
return Stream.concat(actual.possibleBiomes().stream(), extra.stream());
}
@Override public net.minecraft.world.level.biome.BiomeResolver createResolver(Climate.Sampler sampler) {
return actual.createResolver(sampler);
}
};
}
public static HolderLookup<StructureSet> filtered(HolderLookup<StructureSet> source, List<Identifier> pages,
BiomeSource actual) {
Map<Identifier, Integer> counts = enabled(pages);
Map<ResourceKey<StructureSet>, Holder.Reference<StructureSet>> copies = new HashMap<>();
return new HolderLookup<>() {
@Override public Optional<Holder.Reference<StructureSet>> get(ResourceKey<StructureSet> key) {
if (CONTROLLED_SETS.contains(key.identifier()) && !counts.containsKey(key.identifier())) return Optional.empty();
return source.get(key).filter(holder -> counts.containsKey(key.identifier()) || hasBiome(holder.value(), actual))
.map(holder -> copies.computeIfAbsent(key, ignored -> {
int count = counts.getOrDefault(key.identifier(), 1);
return count == 1 ? holder : new LocalReference(key, scaled(holder.value(), count));
}));
}
@Override public Optional<HolderSet.Named<StructureSet>> get(TagKey<StructureSet> key) { return source.get(key); }
@Override public Stream<Holder.Reference<StructureSet>> listElements() {
return source.listElements().flatMap(holder -> get(holder.key()).stream());
}
@Override public Stream<HolderSet.Named<StructureSet>> listTags() { return source.listTags(); }
};
}
private static boolean hasBiome(StructureSet set, BiomeSource source) {
Set<Holder<Biome>> possible = source.possibleBiomes();
return set.structures().stream().flatMap(entry -> entry.structure().value().biomes().stream()).anyMatch(possible::contains);
}
private static StructureSet scaled(StructureSet set, int count) {
var placement = set.placement();
if (!(placement instanceof RandomSpreadStructurePlacement)
&& !(placement instanceof ConcentricRingsStructurePlacement)) return set;
StructurePlacementAccessor settings = (StructurePlacementAccessor) placement;
if (placement instanceof RandomSpreadStructurePlacement random) {
float original = settings.rivencraft$frequency();
float frequency = Math.min(1.0F, original * count);
double frequencyFactor = original > 0 ? frequency / original : 1;
int spacing = Math.max(1, (int) Math.round(random.spacing() / Math.sqrt(count / frequencyFactor)));
int separation = spacing == 1 ? 0 : Math.min(spacing - 1,
(int) Math.round((double) random.separation() * spacing / random.spacing()));
return new StructureSet(set.structures(), new RandomSpreadStructurePlacement(placement.locateOffset(),
settings.rivencraft$frequencyReductionMethod(), frequency, settings.rivencraft$salt(),
settings.rivencraft$exclusionZone(), spacing, separation, random.spreadType()));
}
if (placement instanceof ConcentricRingsStructurePlacement rings) {
int total = (int) Math.min(4095, (long) rings.count() * count);
int spread = Math.min(total, (int) Math.min(1023, (long) rings.spread() * count));
return new StructureSet(set.structures(), new ConcentricRingsStructurePlacement(placement.locateOffset(),
settings.rivencraft$frequencyReductionMethod(), settings.rivencraft$frequency(),
settings.rivencraft$salt(), settings.rivencraft$exclusionZone(), rings.distance(), spread,
total, rings.preferredBiomes()));
}
return set;
}
private static final class LocalReference extends Holder.Reference<StructureSet> {
private static final HolderOwner<StructureSet> OWNER = new HolderOwner<>() {};
private LocalReference(ResourceKey<StructureSet> key, StructureSet value) { super(Type.STAND_ALONE, OWNER, key, value); }
}
}
@@ -0,0 +1,142 @@
package net.dnorberg.rivencraft.age;
import java.util.Random;
import net.minecraft.core.BlockPos;
import net.minecraft.util.Mth;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.chunk.ChunkAccess;
import net.minecraft.world.level.levelgen.Heightmap;
/**
* 26.3 adaptation of Rivencraft 1.21.1's winding solid volumes, originally
* adapted from Mystcraft Legacy's SymbolTendrils and MapGenCavesMyst (LGPL-3.0-or-later).
* Each chunk reconstructs the neighboring source paths to preserve continuous borders.
*/
final class PortTendrilFeature {
private static final long SALT = 0x54454E4452494C53L;
private static final int SOURCE_RANGE = 8;
private static final int SOURCE_RATE = 15;
private static final int SOURCE_SIZE = 18;
private PortTendrilFeature() {}
static void generate(ChunkAccess chunk, long ageSeed, BlockState material) {
var target = chunk.getPos();
Random master = new Random(ageSeed ^ SALT);
long xSeed = master.nextLong();
long zSeed = master.nextLong();
for (int sourceX = target.x() - SOURCE_RANGE; sourceX <= target.x() + SOURCE_RANGE; sourceX++) {
for (int sourceZ = target.z() - SOURCE_RANGE; sourceZ <= target.z() + SOURCE_RANGE; sourceZ++) {
Random source = new Random((long) sourceX * xSeed ^ (long) sourceZ * zSeed ^ ageSeed ^ SALT);
int nodes = source.nextInt(source.nextInt(source.nextInt(SOURCE_SIZE) + 1) + 1);
if (source.nextInt(SOURCE_RATE) != 0) continue;
for (int node = 0; node < nodes; node++) {
double x = sourceX * 16 + source.nextInt(16);
double y = source.nextInt(source.nextInt(120) + 8);
double z = sourceZ * 16 + source.nextInt(16);
int branches = 1;
if (source.nextInt(4) == 0) {
trace(chunk, material, source.nextLong(), x, y, z,
1.0F + source.nextFloat() * 6.0F, 0.0F, 0.0F, -1, -1, 0.5D);
branches += source.nextInt(4);
}
for (int branch = 0; branch < branches; branch++) {
float yaw = source.nextFloat() * Mth.TWO_PI;
float pitch = (source.nextFloat() - 0.5F) / 4.0F;
float radius = source.nextFloat() * 2.0F + source.nextFloat();
if (source.nextInt(10) == 0)
radius *= source.nextFloat() * source.nextFloat() * 3.0F + 1.0F;
trace(chunk, material, source.nextLong(), x, y, z,
radius, yaw, pitch, 0, 0, 1.0D);
}
}
}
}
}
private static void trace(ChunkAccess chunk, BlockState material, long seed,
double x, double y, double z, float radius, float yaw, float pitch,
int step, int length, double verticalScale) {
Random random = new Random(seed);
if (length <= 0) {
int maximum = SOURCE_RANGE * 16 - 16;
length = maximum - random.nextInt(maximum / 4);
}
boolean singleNode = step == -1;
if (singleNode) step = length / 2;
int splitAt = random.nextInt(length / 2) + length / 4;
boolean slowPitch = random.nextInt(6) == 0;
float yawDrift = 0.0F;
float pitchDrift = 0.0F;
double middleX = chunk.getPos().getMinBlockX() + 8;
double middleZ = chunk.getPos().getMinBlockZ() + 8;
for (; step < length; step++) {
double horizontalRadius = 1.5D + Math.sin(step * Math.PI / length) * radius;
double verticalRadius = horizontalRadius * verticalScale;
float horizontalMotion = Mth.cos(pitch);
x += Mth.cos(yaw) * horizontalMotion;
y += Mth.sin(pitch);
z += Mth.sin(yaw) * horizontalMotion;
pitch *= slowPitch ? 0.92F : 0.7F;
pitch += pitchDrift * 0.1F;
yaw += yawDrift * 0.1F;
pitchDrift = pitchDrift * 0.9F
+ (random.nextFloat() - random.nextFloat()) * random.nextFloat() * 2.0F;
yawDrift = yawDrift * 0.75F
+ (random.nextFloat() - random.nextFloat()) * random.nextFloat() * 4.0F;
if (!singleNode && step == splitAt && radius > 1.0F) {
trace(chunk, material, random.nextLong(), x, y, z, random.nextFloat() * 0.5F + 0.5F,
yaw - Mth.HALF_PI, pitch / 3.0F, step, length, 1.0D);
trace(chunk, material, random.nextLong(), x, y, z, random.nextFloat() * 0.5F + 0.5F,
yaw + Mth.HALF_PI, pitch / 3.0F, step, length, 1.0D);
return;
}
if (!singleNode && random.nextInt(4) == 0) continue;
double distanceX = x - middleX;
double distanceZ = z - middleZ;
double remaining = length - step;
double reach = radius + 18.0D;
if (distanceX * distanceX + distanceZ * distanceZ - remaining * remaining > reach * reach) return;
if (x >= middleX - 16.0D - horizontalRadius * 2.0D
&& x <= middleX + 16.0D + horizontalRadius * 2.0D
&& z >= middleZ - 16.0D - horizontalRadius * 2.0D
&& z <= middleZ + 16.0D + horizontalRadius * 2.0D) {
fillNode(chunk, material, x, y, z, horizontalRadius, verticalRadius);
}
if (singleNode) break;
}
}
private static void fillNode(ChunkAccess chunk, BlockState material, double centerX, double centerY,
double centerZ, double horizontalRadius, double verticalRadius) {
int originX = chunk.getPos().getMinBlockX();
int originZ = chunk.getPos().getMinBlockZ();
int minX = Math.max(0, Mth.floor(centerX - horizontalRadius) - originX - 1);
int maxX = Math.min(16, Mth.floor(centerX + horizontalRadius) - originX + 2);
int minZ = Math.max(0, Mth.floor(centerZ - horizontalRadius) - originZ - 1);
int maxZ = Math.min(16, Mth.floor(centerZ + horizontalRadius) - originZ + 2);
int minY = Math.max(chunk.getMinY() + 1, Mth.floor(centerY - verticalRadius) - 1);
int maxY = Math.min(chunk.getMaxY(), Mth.floor(centerY + verticalRadius) + 2);
Heightmap surface = chunk.getOrCreateHeightmapUnprimed(Heightmap.Types.WORLD_SURFACE_WG);
Heightmap floor = chunk.getOrCreateHeightmapUnprimed(Heightmap.Types.OCEAN_FLOOR_WG);
BlockPos.MutableBlockPos pos = new BlockPos.MutableBlockPos();
for (int worldY = minY; worldY < maxY; worldY++) {
double dy = (worldY + 0.5D - centerY) / verticalRadius;
if (dy <= -0.7D || dy * dy >= 1.0D) continue;
for (int localZ = minZ; localZ < maxZ; localZ++) {
double dz = (originZ + localZ + 0.5D - centerZ) / horizontalRadius;
for (int localX = minX; localX < maxX; localX++) {
double dx = (originX + localX + 0.5D - centerX) / horizontalRadius;
if (dx * dx + dy * dy + dz * dz >= 1.0D) continue;
pos.set(originX + localX, worldY, originZ + localZ);
BlockState previous = chunk.getBlockState(pos);
if (previous == material || previous.is(Blocks.BEDROCK)) continue;
chunk.setBlockState(pos, material);
surface.update(localX, worldY, localZ, material);
floor.update(localX, worldY, localZ, material);
}
}
}
}
}
@@ -0,0 +1,95 @@
package net.dnorberg.rivencraft.age;
import com.mojang.serialization.Codec;
import com.mojang.serialization.codecs.RecordCodecBuilder;
import java.util.List;
import java.util.HashMap;
import net.dnorberg.rivencraft.Rivencraft;
import net.minecraft.resources.Identifier;
/** Frozen terrain filters and ordered solid/surface features for an Age. */
public record PortTerrainFeaturePlan(List<Choice> choices, boolean skylands, int denseOres,
boolean starFissures, List<Identifier> structures, List<Identifier> carvers) {
public static final Identifier FLOATING_ISLANDS = Identifier.fromNamespaceAndPath(
Rivencraft.MOD_ID, "feature/floating_islands");
public static final Identifier TENDRILS = Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "feature/tendrils");
public static final Identifier SKYLANDS = Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "feature/skylands");
public static final Identifier SPHERES = Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "feature/spheres");
public static final Identifier SPIKES = Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "feature/spikes");
public static final Identifier OBELISKS = Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "feature/obelisks");
public static final Identifier CRYSTAL_FORMATIONS = Identifier.fromNamespaceAndPath(
Rivencraft.MOD_ID, "feature/crystal_formations");
public static final Identifier CRYSTAL_MATERIAL = Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "crystal_block");
public static final Identifier DENSE_ORES = Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "feature/dense_ores");
public static final Identifier STAR_FISSURES = Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "feature/star_fissures");
public static final int MAX_PER_FEATURE = 32;
public static final Codec<PortTerrainFeaturePlan> CODEC = RecordCodecBuilder.create(instance -> instance.group(
Choice.CODEC.listOf().fieldOf("choices").forGetter(PortTerrainFeaturePlan::choices),
Codec.BOOL.optionalFieldOf("skylands", false).forGetter(PortTerrainFeaturePlan::skylands),
Codec.intRange(0, MAX_PER_FEATURE).optionalFieldOf("dense_ores", 0)
.forGetter(PortTerrainFeaturePlan::denseOres),
Codec.BOOL.optionalFieldOf("star_fissures", false).forGetter(PortTerrainFeaturePlan::starFissures),
Identifier.CODEC.listOf().optionalFieldOf("structures", List.of()).forGetter(PortTerrainFeaturePlan::structures),
Identifier.CODEC.listOf().optionalFieldOf("carvers", List.of()).forGetter(PortTerrainFeaturePlan::carvers)
).apply(instance, PortTerrainFeaturePlan::new));
public record Choice(Identifier page, Identifier material) {
public static final Codec<Choice> CODEC = RecordCodecBuilder.create(instance -> instance.group(
Identifier.CODEC.fieldOf("page").forGetter(Choice::page),
Identifier.CODEC.fieldOf("material").forGetter(Choice::material)
).apply(instance, Choice::new));
public Choice {
if (!isSupported(page) || material == null)
throw new IllegalArgumentException("Invalid terrain feature choice");
if (CRYSTAL_FORMATIONS.equals(page) && !CRYSTAL_MATERIAL.equals(material))
throw new IllegalArgumentException("Crystal formations use Rivencraft crystal, not a Block modifier");
}
}
public PortTerrainFeaturePlan {
choices = List.copyOf(choices);
structures = List.copyOf(structures);
carvers = List.copyOf(carvers);
PortStructurePages.validateCounts(structures);
PortCarverPages.validateCounts(carvers);
if (denseOres < 0 || denseOres > MAX_PER_FEATURE)
throw new IllegalArgumentException("Dense Ores permits at most 32 occurrences");
if (choices.isEmpty() && !skylands && denseOres == 0 && !starFissures && structures.isEmpty() && carvers.isEmpty())
throw new IllegalArgumentException("Terrain feature plan must not be empty");
var counts = new HashMap<Identifier, Integer>();
for (Choice choice : choices) {
if (counts.merge(choice.page(), 1, Integer::sum) > MAX_PER_FEATURE)
throw new IllegalArgumentException("Terrain feature plan permits at most 32 occurrences per feature");
}
}
public PortTerrainFeaturePlan(List<Choice> choices, boolean skylands, int denseOres,
boolean starFissures, List<Identifier> structures) {
this(choices, skylands, denseOres, starFissures, structures, List.of());
}
public PortTerrainFeaturePlan(List<Choice> choices, boolean skylands, int denseOres, boolean starFissures) {
this(choices, skylands, denseOres, starFissures, List.of());
}
public PortTerrainFeaturePlan(List<Choice> choices, boolean skylands, int denseOres) {
this(choices, skylands, denseOres, false);
}
public PortTerrainFeaturePlan(List<Choice> choices, boolean skylands) { this(choices, skylands, 0, false); }
public PortTerrainFeaturePlan(List<Choice> choices) { this(choices, false, 0, false); }
public static boolean isSupported(Identifier page) {
return FLOATING_ISLANDS.equals(page) || TENDRILS.equals(page) || SPHERES.equals(page) || isSurface(page);
}
public static boolean acceptsMaterial(Identifier page) {
return isSupported(page) && !CRYSTAL_FORMATIONS.equals(page);
}
public static boolean isSurface(Identifier page) {
return SPIKES.equals(page) || OBELISKS.equals(page) || CRYSTAL_FORMATIONS.equals(page);
}
}
@@ -0,0 +1,56 @@
package net.dnorberg.rivencraft.age;
import com.mojang.serialization.Codec;
import com.mojang.serialization.codecs.RecordCodecBuilder;
import java.util.List;
import net.dnorberg.rivencraft.Rivencraft;
import net.minecraft.resources.Identifier;
/** Ordered, frozen plants and log decorations; not all require surface terrain. */
public record PortVegetationPlan(List<Identifier> pages) {
public static final Identifier FLOWERS = page("flowers");
public static final Identifier TREES = page("trees");
public static final Identifier HUGE_TREES = page("huge_trees");
public static final Identifier POPLAR_TREES = page("poplar_trees");
public static final Identifier FALLEN_POPLAR_TREES = page("fallen_poplar_trees");
public static final Identifier RED_SHRUBS = page("red_shrubs");
public static final Identifier SHELF_MUSHROOMS = page("shelf_mushrooms");
public static final Codec<PortVegetationPlan> CODEC = RecordCodecBuilder.create(instance -> instance.group(
Identifier.CODEC.listOf().fieldOf("pages").forGetter(PortVegetationPlan::pages)
).apply(instance, PortVegetationPlan::new));
public PortVegetationPlan {
pages = List.copyOf(pages);
if (pages.isEmpty() || pages.size() > 32 || pages.stream().anyMatch(page -> !isVegetation(page)))
throw new IllegalArgumentException("Vegetation plan requires 1–32 supported pages");
}
public static boolean isVegetation(Identifier page) {
return page.equals(FLOWERS) || page.equals(TREES) || page.equals(HUGE_TREES)
|| page.equals(POPLAR_TREES) || page.equals(FALLEN_POPLAR_TREES)
|| page.equals(RED_SHRUBS) || page.equals(SHELF_MUSHROOMS);
}
public static Identifier placedFeature(Identifier page) {
if (page.equals(FLOWERS)) return Identifier.withDefaultNamespace("flower_default");
if (page.equals(TREES)) return Identifier.withDefaultNamespace("trees_plains");
if (page.equals(HUGE_TREES)) return Identifier.withDefaultNamespace("trees_old_growth_spruce_taiga");
if (page.equals(POPLAR_TREES)) return Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "poplar_trees");
if (page.equals(FALLEN_POPLAR_TREES)) return Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "fallen_poplar_trees");
if (page.equals(RED_SHRUBS)) return Identifier.withDefaultNamespace("patch_red_shrub");
throw new IllegalArgumentException("Unsupported vegetation page " + page);
}
public static boolean addsLogs(Identifier page) {
return page.equals(TREES) || page.equals(HUGE_TREES)
|| page.equals(POPLAR_TREES) || page.equals(FALLEN_POPLAR_TREES);
}
public static boolean requiresSurface(Identifier page) {
return isVegetation(page) && !page.equals(SHELF_MUSHROOMS);
}
private static Identifier page(String name) {
return Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "feature/" + name);
}
}
@@ -0,0 +1,28 @@
package net.dnorberg.rivencraft.age;
import com.mojang.serialization.Codec;
import net.dnorberg.rivencraft.Rivencraft;
import net.minecraft.resources.Identifier;
/** The six active weather pages in the 1.21.1 writing system. */
public enum PortWeatherMode {
NORMAL, FAST, SLOW, OFF, ALWAYS, STORM;
public static final Codec<PortWeatherMode> CODEC = Codec.STRING.xmap(
name -> valueOf(name.toUpperCase(java.util.Locale.ROOT)),
mode -> mode.name().toLowerCase(java.util.Locale.ROOT));
public static PortWeatherMode forPage(Identifier page) {
if (!page.getNamespace().equals(Rivencraft.MOD_ID)) return null;
return switch (page.getPath()) {
case "weather/normal" -> NORMAL;
case "weather/fast" -> FAST;
case "weather/slow" -> SLOW;
case "weather/off" -> OFF;
case "weather/always" -> ALWAYS;
case "weather/storm" -> STORM;
default -> null;
};
}
}
@@ -0,0 +1,24 @@
package net.dnorberg.rivencraft.age;
import com.mojang.serialization.Codec;
import net.minecraft.resources.Identifier;
import net.minecraft.world.level.saveddata.SavedData;
import net.minecraft.world.level.saveddata.SavedDataType;
/** Age-local policy; native WeatherData separately persists its live cycle. */
final class PortWeatherSettings extends SavedData {
private static final Codec<PortWeatherSettings> CODEC = PortWeatherMode.CODEC
.fieldOf("mode").codec().xmap(PortWeatherSettings::new, settings -> settings.mode);
static final SavedDataType<PortWeatherSettings> TYPE = new SavedDataType<>(
Identifier.fromNamespaceAndPath("rivencraft", "weather_policy"),
() -> new PortWeatherSettings(PortWeatherMode.NORMAL), CODEC);
private PortWeatherMode mode;
private PortWeatherSettings(PortWeatherMode mode) { this.mode = mode; }
PortWeatherMode mode() { return mode; }
void setMode(PortWeatherMode mode) {
this.mode = mode;
setDirty();
}
}
@@ -34,7 +34,6 @@ import net.minecraft.world.level.levelgen.NoiseGeneratorSettings;
import net.minecraft.world.level.levelgen.flat.FlatLevelGeneratorSettings;
import net.minecraft.world.level.levelgen.flat.FlatLayerInfo;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.storage.DerivedLevelData;
import net.minecraft.world.level.storage.LevelResource;
import net.neoforged.neoforge.common.NeoForge;
import net.neoforged.neoforge.common.world.chunk.ForcedChunkManager;
@@ -57,7 +56,8 @@ public final class RuntimeAgeBackend {
for (Map.Entry<Identifier, Long> age : index.entries().entrySet()) {
try {
load(server, age.getKey(), age.getValue(), index.biomePlan(age.getKey()),
index.terrain(age.getKey()), index.rocks(age.getKey()));
index.terrain(age.getKey()), index.rocks(age.getKey()), index.vegetation(age.getKey()),
index.dungeons(age.getKey()), index.lakes(age.getKey()), index.terrainFeatures(age.getKey()));
} catch (RuntimeException exception) {
Rivencraft.LOGGER.error("Could not restore Age {}. Its saved index remains intact.", age.getKey(), exception);
}
@@ -80,12 +80,83 @@ public final class RuntimeAgeBackend {
public static ServerLevel create(MinecraftServer server, Identifier id, long seed,
PortBiomePlan biomePlan, PortAgeTerrain terrain, PortRockPlan rocks) {
return create(server, id, seed, biomePlan, terrain, rocks, null);
}
public static ServerLevel create(MinecraftServer server, Identifier id, long seed,
PortBiomePlan biomePlan, PortAgeTerrain terrain, PortRockPlan rocks,
PortVegetationPlan vegetation) {
return create(server, id, seed, biomePlan, terrain, rocks, vegetation, 0);
}
public static ServerLevel create(MinecraftServer server, Identifier id, long seed,
PortBiomePlan biomePlan, PortAgeTerrain terrain, PortRockPlan rocks,
PortVegetationPlan vegetation, int dungeons) {
return create(server, id, seed, biomePlan, terrain, rocks, vegetation, dungeons, null);
}
public static ServerLevel create(MinecraftServer server, Identifier id, long seed,
PortBiomePlan biomePlan, PortAgeTerrain terrain, PortRockPlan rocks,
PortVegetationPlan vegetation, int dungeons, PortLakePlan lakes) {
return create(server, id, seed, biomePlan, terrain, rocks, vegetation, dungeons, lakes, null);
}
public static ServerLevel create(MinecraftServer server, Identifier id, long seed,
PortBiomePlan biomePlan, PortAgeTerrain terrain, PortRockPlan rocks,
PortVegetationPlan vegetation, int dungeons, PortLakePlan lakes,
PortTerrainFeaturePlan terrainFeatures) {
return create(server, id, seed, biomePlan, terrain, rocks, vegetation, dungeons, lakes,
terrainFeatures, PortWeatherMode.NORMAL);
}
public static ServerLevel create(MinecraftServer server, Identifier id, long seed,
PortBiomePlan biomePlan, PortAgeTerrain terrain, PortRockPlan rocks,
PortVegetationPlan vegetation, int dungeons, PortLakePlan lakes,
PortTerrainFeaturePlan terrainFeatures, PortWeatherMode weather) {
return create(server, id, seed, biomePlan, terrain, rocks, vegetation, dungeons, lakes,
terrainFeatures, weather, PortLighting.NORMAL);
}
public static ServerLevel create(MinecraftServer server, Identifier id, long seed,
PortBiomePlan biomePlan, PortAgeTerrain terrain, PortRockPlan rocks,
PortVegetationPlan vegetation, int dungeons, PortLakePlan lakes,
PortTerrainFeaturePlan terrainFeatures, PortWeatherMode weather,
PortLighting lighting) {
return create(server, id, seed, biomePlan, terrain, rocks, vegetation, dungeons, lakes,
terrainFeatures, weather, lighting, PortSkyStyle.OVERWORLD);
}
public static ServerLevel create(MinecraftServer server, Identifier id, long seed,
PortBiomePlan biomePlan, PortAgeTerrain terrain, PortRockPlan rocks,
PortVegetationPlan vegetation, int dungeons, PortLakePlan lakes,
PortTerrainFeaturePlan terrainFeatures, PortWeatherMode weather,
PortLighting lighting, PortSkyStyle sky) {
return create(server, id, seed, biomePlan, terrain, rocks, vegetation, dungeons, lakes,
terrainFeatures, weather, lighting, sky, PortAgeColors.NATIVE);
}
public static ServerLevel create(MinecraftServer server, Identifier id, long seed,
PortBiomePlan biomePlan, PortAgeTerrain terrain, PortRockPlan rocks,
PortVegetationPlan vegetation, int dungeons, PortLakePlan lakes,
PortTerrainFeaturePlan terrainFeatures, PortWeatherMode weather,
PortLighting lighting, PortSkyStyle sky, PortAgeColors colors) {
return create(server, id, seed, biomePlan, terrain, rocks, vegetation, dungeons, lakes,
terrainFeatures, weather, lighting, sky, colors, PortDayCycle.NATIVE);
}
public static ServerLevel create(MinecraftServer server, Identifier id, long seed,
PortBiomePlan biomePlan, PortAgeTerrain terrain, PortRockPlan rocks,
PortVegetationPlan vegetation, int dungeons, PortLakePlan lakes,
PortTerrainFeaturePlan terrainFeatures, PortWeatherMode weather,
PortLighting lighting, PortSkyStyle sky, PortAgeColors colors, PortDayCycle dayCycle) {
checkServerThread(server);
ageName(id);
AgeLevelIndex index = index(server);
if (index.contains(id)) throw new IllegalArgumentException("Age already exists: " + id);
ServerLevel level = load(server, id, seed, biomePlan, terrain, rocks);
index.add(id, seed, biomePlan, terrain, rocks);
ServerLevel level = load(server, id, seed, biomePlan, terrain, rocks, vegetation, dungeons, lakes,
terrainFeatures, lighting, sky, colors, dayCycle);
((PortAgeLevel) level).configureWeather(weather);
index.add(id, seed, biomePlan, terrain, rocks, vegetation, dungeons, lakes, terrainFeatures, lighting, sky, colors, dayCycle);
// A newly bound book may be used immediately. Persist its Age identity
// before publishing the catalog so a later crash cannot leave that
// book pointing at a world omitted from the on-disk index.
@@ -106,7 +177,8 @@ public final class RuntimeAgeBackend {
checkServerThread(server);
long seed = requireSeed(server, id);
ServerLevel level = load(server, id, seed, index(server).biomePlan(id), index(server).terrain(id),
index(server).rocks(id));
index(server).rocks(id), index(server).vegetation(id), index(server).dungeons(id), index(server).lakes(id),
index(server).terrainFeatures(id));
AgeCatalogSync.onCatalogChanged(server);
return level;
}
@@ -119,18 +191,22 @@ public final class RuntimeAgeBackend {
PortBiomePlan biomePlan = index.biomePlan(id);
PortAgeTerrain terrain = index.terrain(id);
PortRockPlan rocks = index.rocks(id);
PortVegetationPlan vegetation = index.vegetation(id);
int dungeons = index.dungeons(id);
PortLakePlan lakes = index.lakes(id);
PortTerrainFeaturePlan terrainFeatures = index.terrainFeatures(id);
if (previousSeed == seed) return;
boolean loaded = server.getLevel(dimensionKey(id)) != null;
if (loaded) unloadLoaded(server, id);
try {
if (loaded) load(server, id, seed, biomePlan, terrain, rocks);
if (loaded) load(server, id, seed, biomePlan, terrain, rocks, vegetation, dungeons, lakes, terrainFeatures);
index.reseed(id, seed);
server.overworld().getDataStorage().saveAndJoin();
if (loaded) AgeCatalogSync.onCatalogChanged(server);
} catch (RuntimeException exception) {
if (loaded) {
try {
load(server, id, previousSeed, biomePlan, terrain, rocks);
load(server, id, previousSeed, biomePlan, terrain, rocks, vegetation, dungeons, lakes, terrainFeatures);
AgeCatalogSync.onCatalogChanged(server);
} catch (RuntimeException rollbackFailure) {
exception.addSuppressed(rollbackFailure);
@@ -149,8 +225,16 @@ public final class RuntimeAgeBackend {
PortBiomePlan biomePlan = index.biomePlan(id);
PortAgeTerrain terrain = index.terrain(id);
PortRockPlan rocks = index.rocks(id);
PortVegetationPlan vegetation = index.vegetation(id);
int dungeons = index.dungeons(id);
PortLakePlan lakes = index.lakes(id);
PortTerrainFeaturePlan terrainFeatures = index.terrainFeatures(id);
boolean loaded = server.getLevel(dimensionKey(id)) != null;
ArchivePaths paths = archivePaths(server, name);
PortLighting lighting = index.lighting(id);
PortSkyStyle sky = index.sky(id);
PortAgeColors colors = index.colors(id);
PortDayCycle dayCycle = index.dayCycle(id);
if (loaded) unloadLoaded(server, id);
// Persist the tombstone before moving files: a crash mid-archive must
// not let an old book silently regenerate this identity.
@@ -167,14 +251,15 @@ public final class RuntimeAgeBackend {
}
} catch (IOException | RuntimeException exception) {
try {
index.restoreAfterFailedRetirement(id, seed, biomePlan, terrain, rocks);
index.restoreAfterFailedRetirement(id, seed, biomePlan, terrain, rocks, vegetation, dungeons, lakes,
terrainFeatures, lighting, sky, colors, dayCycle);
server.overworld().getDataStorage().saveAndJoin();
} catch (RuntimeException rollbackFailure) {
exception.addSuppressed(rollbackFailure);
}
if (loaded) {
try {
load(server, id, seed, biomePlan, terrain, rocks);
load(server, id, seed, biomePlan, terrain, rocks, vegetation, dungeons, lakes, terrainFeatures);
} catch (RuntimeException rollbackFailure) {
exception.addSuppressed(rollbackFailure);
}
@@ -190,6 +275,16 @@ public final class RuntimeAgeBackend {
return index(server).entries();
}
public static Map<Identifier, PortAgeColors> savedColors(MinecraftServer server) {
checkServerThread(server);
return index(server).colors();
}
public static Map<Identifier, PortDayCycle> savedDayCycles(MinecraftServer server) {
checkServerThread(server);
return index(server).dayCycles();
}
/** Includes retired IDs, which must never be silently reused by a new book. */
public static boolean isNameTaken(MinecraftServer server, Identifier id) {
checkServerThread(server);
@@ -290,7 +385,18 @@ public final class RuntimeAgeBackend {
}
private static ServerLevel load(MinecraftServer server, Identifier id, long seed,
PortBiomePlan biomePlan, PortAgeTerrain terrain, PortRockPlan rocks) {
PortBiomePlan biomePlan, PortAgeTerrain terrain, PortRockPlan rocks,
PortVegetationPlan vegetation, int dungeons, PortLakePlan lakes,
PortTerrainFeaturePlan terrainFeatures) {
return load(server, id, seed, biomePlan, terrain, rocks, vegetation, dungeons, lakes,
terrainFeatures, index(server).lighting(id), index(server).sky(id), index(server).colors(id), index(server).dayCycle(id));
}
private static ServerLevel load(MinecraftServer server, Identifier id, long seed,
PortBiomePlan biomePlan, PortAgeTerrain terrain, PortRockPlan rocks,
PortVegetationPlan vegetation, int dungeons, PortLakePlan lakes,
PortTerrainFeaturePlan terrainFeatures, PortLighting lighting, PortSkyStyle sky,
PortAgeColors colors, PortDayCycle dayCycle) {
ResourceKey<Level> key = ResourceKey.create(Registries.DIMENSION, id);
if (server.getLevel(key) != null) throw new IllegalArgumentException("Level already loaded: " + id);
@@ -361,12 +467,14 @@ public final class RuntimeAgeBackend {
};
generator = new NoiseBasedChunkGenerator(source, settings);
}
if (rocks != null) generator = new PortRockChunkGenerator(generator, rocks, seed);
LevelStem stem = new LevelStem(overworldStem.type(), generator, OptionalLong.of(seed));
if (rocks != null || vegetation != null || dungeons > 0 || lakes != null || terrainFeatures != null)
generator = new PortFeatureChunkGenerator(generator, rocks, vegetation, dungeons, lakes, terrainFeatures, seed);
LevelStem stem = new LevelStem(lighting.dimensionType(server, terrain, overworldStem.type(), sky),
generator, OptionalLong.of(seed));
long biomeZoomSeed = BiomeManager.obfuscateSeed(server.getWorldGenSettings().options().seed());
ServerLevel level = new ServerLevel(server, server.executor, server.storageSource,
new DerivedLevelData(server.getWorldData(), server.getWorldData().overworldData()),
key, stem, server.getWorldData().isDebugWorld(), biomeZoomSeed, List.of(), false);
ServerLevel level = new PortAgeLevel(server, key, stem, biomeZoomSeed, terrain);
((PortAgeLevel) level).configureColors(colors);
((PortAgeLevel) level).configureDayCycle(dayCycle);
// Publish only after the constructor succeeds. If the load event fails,
// roll back the map and close the newly opened chunk/entity stores.
@@ -0,0 +1,66 @@
package net.dnorberg.rivencraft.block;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.block.entity.StarFissureBlockEntity;
import net.minecraft.core.BlockPos;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.sounds.SoundSource;
import net.minecraft.world.entity.Entity;
import net.minecraft.world.entity.InsideBlockEffectApplier;
import net.minecraft.world.level.BlockGetter;
import net.minecraft.world.level.Level;
import net.minecraft.world.level.block.Block;
import net.minecraft.world.level.block.EndPortalBlock;
import net.minecraft.world.level.block.entity.BlockEntity;
import net.minecraft.world.level.block.state.BlockBehaviour;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.portal.TeleportTransition;
import net.minecraft.world.phys.Vec3;
import net.minecraft.world.phys.shapes.CollisionContext;
import net.minecraft.world.phys.shapes.VoxelShape;
/** Non-obtainable starfield portal returning entities to the shared Overworld spawn. */
public final class StarFissureBlock extends EndPortalBlock {
private static final VoxelShape SHAPE = Block.box(0, 0, 0, 16, 1.6, 16);
public StarFissureBlock(BlockBehaviour.Properties properties) { super(properties); }
@Override
public BlockEntity newBlockEntity(BlockPos pos, BlockState state) {
return new StarFissureBlockEntity(pos, state);
}
@Override
public void animateTick(BlockState state, Level level, BlockPos pos, net.minecraft.util.RandomSource random) {
// Legacy fissures have a starfield, not vanilla End portal smoke.
}
@Override
protected VoxelShape getShape(BlockState state, BlockGetter level, BlockPos pos, CollisionContext context) {
return SHAPE;
}
@Override
protected void entityInside(BlockState state, Level level, BlockPos pos, Entity entity,
InsideBlockEffectApplier effects, boolean precise) {
// Do not inherit End credits or End-dimension travel from the vanilla block.
if (entity.canUsePortal(false)) entity.setAsInsidePortal(this, pos);
}
@Override
public TeleportTransition getPortalDestination(ServerLevel level, Entity entity, BlockPos pos) {
if (level.dimension() == Level.OVERWORLD) return null;
ServerLevel overworld = level.getServer().overworld();
if (overworld == null) return null;
BlockPos spawn = overworld.getRespawnData().pos();
BlockPos landing = net.dnorberg.rivencraft.age.PortAgeTravel.findFootedDestination(overworld, spawn).orElse(spawn);
level.playSound(null, pos, Rivencraft.LINK_FISSURE.get(), SoundSource.BLOCKS, 1.0F, 1.0F);
return new TeleportTransition(overworld, Vec3.atBottomCenterOf(landing), entity.getDeltaMovement(),
entity.getYRot(), entity.getXRot(), TeleportTransition.PLACE_PORTAL_TICKET.then(arrived -> {
// Departure audio can be discarded when the client switches levels.
// Play after arrival too so the traveler hears the successful return.
arrived.level().playSound(null, arrived.blockPosition(), Rivencraft.LINK_FISSURE.get(),
SoundSource.BLOCKS, 1.0F, 1.0F);
}));
}
}
@@ -0,0 +1,12 @@
package net.dnorberg.rivencraft.block.entity;
import net.dnorberg.rivencraft.Rivencraft;
import net.minecraft.core.BlockPos;
import net.minecraft.world.level.block.entity.TheEndPortalBlockEntity;
import net.minecraft.world.level.block.state.BlockState;
public final class StarFissureBlockEntity extends TheEndPortalBlockEntity {
public StarFissureBlockEntity(BlockPos pos, BlockState state) {
super(Rivencraft.STAR_FISSURE_BLOCK_ENTITY.get(), pos, state);
}
}
@@ -18,10 +18,22 @@ public final class AgeCatalogClient {
private AgeCatalogClient() {
}
@SubscribeEvent
public static void logout(net.neoforged.neoforge.client.event.ClientPlayerNetworkEvent.LoggingOut event) {
net.dnorberg.rivencraft.age.PortAgeColors.updateClient(java.util.Map.of());
net.dnorberg.rivencraft.age.PortDayCycle.updateClient(java.util.Map.of());
}
@SubscribeEvent
public static void register(RegisterClientPayloadHandlersEvent event) {
event.register(AgeCatalogPayload.TYPE, (payload, context) -> {
if (!(context.listener() instanceof ClientPacketListener listener)) return;
net.dnorberg.rivencraft.age.PortAgeColors.updateClient(payload.colors());
net.dnorberg.rivencraft.age.PortDayCycle.updateClient(payload.dayCycles());
if (listener.getLevel() != null) {
listener.getLevel().environmentAttributes().invalidateTickCache();
listener.getLevel().updateSkyBrightness();
}
listener.levels = payload.dimensions().stream()
.map(id -> ResourceKey.<Level>create(Registries.DIMENSION, id))
.collect(Collectors.toUnmodifiableSet());
@@ -14,6 +14,12 @@ import net.minecraft.server.packs.resources.ResourceManagerReloadListener;
public final class PortBookBinderClientEvents {
private PortBookBinderClientEvents() {}
@SubscribeEvent
public static void registerBlockEntityRenderers(
net.neoforged.neoforge.client.event.EntityRenderersEvent.RegisterRenderers event) {
event.registerBlockEntityRenderer(Rivencraft.STAR_FISSURE_BLOCK_ENTITY.get(), context -> new StarFissureRenderer());
}
@SubscribeEvent
public static void registerScreens(RegisterMenuScreensEvent event) {
event.register(Rivencraft.BOOK_BINDER_MENU.get(), PortBookBinderScreen::new);
@@ -94,6 +94,8 @@ public final class PortBookBinderScreen extends AbstractContainerScreen<PortBook
} else if (PageItem.isLinkPanel(page)) {
graphics.fill(x + 5, y + 6, x + 22, y + 17, 0xFF000000);
} else {
if (!PortAuthoredWriting.isPageImplemented(page.get(Rivencraft.SYMBOL_PAGE_ID.get())))
graphics.fill(x, y, x + PAGE_WIDTH, y + PAGE_HEIGHT, 0x88FF3333);
// Render the full-size transparent glyph through the working
// item pipeline, without its small parchment-backed icon.
ItemStack glyphOnly = page.copy();
@@ -0,0 +1,93 @@
package net.dnorberg.rivencraft.client;
import com.mojang.blaze3d.pipeline.RenderTarget;
import com.mojang.blaze3d.systems.RenderSystem;
import com.mojang.blaze3d.vertex.BufferBuilder;
import com.mojang.blaze3d.vertex.ByteBufferBuilder;
import com.mojang.blaze3d.vertex.DefaultVertexFormat;
import com.mojang.blaze3d.vertex.MeshData;
import com.mojang.renderpearl.api.buffers.GpuBuffer;
import com.mojang.renderpearl.api.buffers.GpuBufferSlice;
import com.mojang.renderpearl.api.pipeline.PrimitiveTopology;
import java.util.Optional;
import java.util.OptionalDouble;
import net.minecraft.client.renderer.RenderPipelines;
import net.minecraft.client.renderer.texture.DynamicTexture;
import net.minecraft.util.RandomSource;
import org.joml.Matrix4f;
/** Legacy's six 48x48 noise faces, uploaded once per animation frame rather than per cell. */
public final class PortStaticSky implements AutoCloseable {
private static final int GRID = 48;
private final DynamicTexture texture = new DynamicTexture("Rivencraft static sky", GRID * 6, GRID, false);
private final GpuBuffer cube = buildCube();
private long uploadedFrame = Long.MIN_VALUE;
private int uploadedTint = -1;
public void render(RenderTarget target, GpuBufferSlice fog, long frame, int tint) {
if (tint < 0) tint = 0x929292;
if (uploadedFrame != frame || uploadedTint != tint) {
RandomSource random = RandomSource.create(frame);
var image = texture.getPixels();
for (int face = 0; face < 6; face++) {
for (int y = 0; y < GRID; y++) {
for (int x = 0; x < GRID; x++) {
int noise = 65 + random.nextInt(160);
int red = (tint >> 16 & 255) * noise / 255;
int green = (tint >> 8 & 255) * noise / 255;
int blue = (tint & 255) * noise / 255;
image.setPixel(face * GRID + x, y, 0xff000000 | red << 16 | green << 8 | blue);
}
}
}
texture.upload();
uploadedFrame = frame;
uploadedTint = tint;
}
RenderSystem.setShaderFog(fog);
var indices = RenderSystem.getSequentialBuffer(PrimitiveTopology.QUADS);
var indexBuffer = indices.getBuffer(36);
var transform = RenderSystem.getDynamicUniforms().writeTransform(RenderSystem.getModelViewMatrixCopy());
try (var pass = RenderSystem.getDevice().createCommandEncoder().createRenderPass(
() -> "Rivencraft static sky", target.getColorTextureView(), Optional.empty(),
target.getDepthTextureView(), OptionalDouble.empty())) {
pass.setPipeline(RenderSystem.getCompiledPipeline(RenderPipelines.END_SKY));
RenderSystem.bindDefaultUniforms(pass);
pass.setUniform("DynamicTransforms", transform);
pass.setUniform("Sampler0", texture.getTextureView(), texture.getSampler());
pass.setVertexBuffer(0, cube.slice());
pass.setIndexBuffer(indexBuffer, indices.type());
pass.drawIndexed(36, 1, 0, 0, 0);
}
}
private static GpuBuffer buildCube() {
var format = DefaultVertexFormat.POSITION_TEX_COLOR;
try (var bytes = ByteBufferBuilder.exactlySized(24 * format.getVertexSize())) {
var builder = new BufferBuilder(bytes, PrimitiveTopology.QUADS, format);
for (int face = 0; face < 6; face++) {
var pose = new Matrix4f();
switch (face) {
case 1 -> pose.rotationX((float) Math.PI / 2);
case 2 -> pose.rotationX((float) -Math.PI / 2);
case 3 -> pose.rotationX((float) Math.PI);
case 4 -> pose.rotationZ((float) Math.PI / 2);
case 5 -> pose.rotationZ((float) -Math.PI / 2);
}
float u0 = face / 6f, u1 = (face + 1) / 6f;
builder.addVertex(pose, -100, -100, -100).setUv(u0, 0).setColor(-1);
builder.addVertex(pose, -100, -100, 100).setUv(u0, 1).setColor(-1);
builder.addVertex(pose, 100, -100, 100).setUv(u1, 1).setColor(-1);
builder.addVertex(pose, 100, -100, -100).setUv(u1, 0).setColor(-1);
}
try (MeshData mesh = builder.buildOrThrow()) {
return RenderSystem.getDevice().createBuffer(() -> "Rivencraft static sky cube", 40, mesh.vertexBuffer());
}
}
}
@Override public void close() {
cube.close();
texture.close();
}
}
@@ -5,6 +5,7 @@ import com.mojang.blaze3d.vertex.VertexConsumer;
import com.mojang.serialization.MapCodec;
import java.util.function.Consumer;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.writing.PortAuthoredWriting;
import net.minecraft.client.renderer.SubmitNodeCollector;
import net.minecraft.client.renderer.rendertype.RenderTypes;
import net.minecraft.client.renderer.special.SpecialModelRenderer;
@@ -18,7 +19,8 @@ public final class PortSymbolPageRenderer implements SpecialModelRenderer<PortSy
public PageArt extractArgument(ItemStack stack) {
Identifier symbol = stack.get(Rivencraft.SYMBOL_PAGE_ID.get());
return symbol == null ? null : new PageArt(symbol,
stack.getOrDefault(Rivencraft.PAGE_GLYPH_ONLY.get(), false));
stack.getOrDefault(Rivencraft.PAGE_GLYPH_ONLY.get(), false),
PortAuthoredWriting.isPageImplemented(symbol) ? 0xFFFFFFFF : 0xFFFF6666);
}
@Override
@@ -30,10 +32,10 @@ public final class PortSymbolPageRenderer implements SpecialModelRenderer<PortSy
: PortSymbolPageTextures.forItem(art.symbol());
collector.submitCustomGeometry(pose, RenderTypes.entityCutout(texture),
(matrix, vertices) -> {
vertex(vertices, matrix, 0, 0, 0, 1, light, overlay);
vertex(vertices, matrix, 1, 0, 1, 1, light, overlay);
vertex(vertices, matrix, 1, 1, 1, 0, light, overlay);
vertex(vertices, matrix, 0, 1, 0, 0, light, overlay);
vertex(vertices, matrix, 0, 0, 0, 1, light, overlay, art.color());
vertex(vertices, matrix, 1, 0, 1, 1, light, overlay, art.color());
vertex(vertices, matrix, 1, 1, 1, 0, light, overlay, art.color());
vertex(vertices, matrix, 0, 1, 0, 0, light, overlay, art.color());
});
}
@@ -44,12 +46,12 @@ public final class PortSymbolPageRenderer implements SpecialModelRenderer<PortSy
}
private static void vertex(VertexConsumer vertices, PoseStack.Pose pose,
float x, float y, float u, float v, int light, int overlay) {
vertices.addVertex(pose, x, y, 0.5F).setColor(255, 255, 255, 255)
float x, float y, float u, float v, int light, int overlay, int color) {
vertices.addVertex(pose, x, y, 0.5F).setColor(color)
.setUv(u, v).setOverlay(overlay).setLight(light).setNormal(pose, 0, 0, 1);
}
public record PageArt(Identifier symbol, boolean glyphOnly) {}
public record PageArt(Identifier symbol, boolean glyphOnly, int color) {}
public record Unbaked() implements SpecialModelRenderer.Unbaked<PageArt> {
public static final Unbaked INSTANCE = new Unbaked();
@@ -0,0 +1,34 @@
package net.dnorberg.rivencraft.client;
import com.mojang.blaze3d.vertex.PoseStack;
import java.util.List;
import net.dnorberg.rivencraft.block.entity.StarFissureBlockEntity;
import net.minecraft.client.renderer.SubmitNodeCollector;
import net.minecraft.client.renderer.blockentity.AbstractEndPortalRenderer;
import net.minecraft.client.renderer.blockentity.state.EndPortalRenderState;
import net.minecraft.client.renderer.rendertype.RenderTypes;
import net.minecraft.client.renderer.state.level.CameraRenderState;
import net.minecraft.core.Direction;
/** Retains 1.21.1's UP-at-zero/DOWN-at-one planes with the modern starfield renderer. */
public final class StarFissureRenderer extends AbstractEndPortalRenderer<StarFissureBlockEntity, EndPortalRenderState> {
@Override
public EndPortalRenderState createRenderState() { return new EndPortalRenderState(); }
@Override
public void submit(EndPortalRenderState state, PoseStack pose, SubmitNodeCollector collector,
CameraRenderState camera) {
if (state.facesToShow.contains(Direction.UP)) {
pose.pushPose();
pose.translate(0, -1, 0);
submitCube(List.of(Direction.UP), RenderTypes.endPortal(), pose, collector);
pose.popPose();
}
if (state.facesToShow.contains(Direction.DOWN)) {
pose.pushPose();
pose.translate(0, 1, 0);
submitCube(List.of(Direction.DOWN), RenderTypes.endPortal(), pose, collector);
pose.popPose();
}
}
}
@@ -0,0 +1,13 @@
package net.dnorberg.rivencraft.client;
/** Carries extracted sky presentation values without live-world reads during drawing. */
public interface StaticSkyState {
long rivencraft$staticFrame();
void rivencraft$staticFrame(long frame);
int rivencraft$skyTint();
void rivencraft$skyTint(int color);
int rivencraft$orbitBearing();
void rivencraft$orbitBearing(int bearing);
float rivencraft$sunSize();
void rivencraft$sunSize(float size);
}
@@ -0,0 +1,35 @@
package net.dnorberg.rivencraft.item;
import net.dnorberg.rivencraft.Rivencraft;
import net.minecraft.core.particles.ParticleTypes;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.sounds.SoundEvent;
import net.minecraft.sounds.SoundSource;
import net.minecraft.world.phys.Vec3;
/** Same successful-link feedback and sound precedence as the 1.21.1 target. */
public final class LinkVisualEffects {
private LinkVisualEffects() {}
public static SoundEvent sound(LinkProperties properties, boolean portal) {
if (properties.has(LinkProperties.DISARM)) return Rivencraft.LINK_DISARM.get();
if (portal) return Rivencraft.LINK_PORTAL.get();
if (properties.has(LinkProperties.INTRA_LINKING)) return Rivencraft.LINK_INTRA.get();
return Rivencraft.LINK.get();
}
public static void play(ServerLevel source, Vec3 departure, ServerPlayer player,
LinkProperties properties, boolean portal) {
SoundEvent event = sound(properties, portal);
burst(source, departure, event);
burst(player.level(), player.position(), event);
}
private static void burst(ServerLevel level, Vec3 position, SoundEvent event) {
level.playSound(null, position.x, position.y, position.z, event,
SoundSource.PLAYERS, 0.8F, level.getRandom().nextFloat() * 0.2F + 0.9F);
level.sendParticles(ParticleTypes.CLOUD, position.x, position.y, position.z,
50, 0.5, 1.0, 0.5, 0.005);
}
}
@@ -78,6 +78,7 @@ public final class LinkingBookItem extends Item {
/** Link Panel effects are shared by Linking and Descriptive Books. */
public static void applySuccessfulLink(ServerPlayer player, LinkProperties properties,
ServerLevel source, Vec3 departure, Vec3 incoming, float destinationYaw) {
LinkVisualEffects.play(source, departure, player, properties, false);
if (properties.has(LinkProperties.MAINTAIN_MOMENTUM)) {
double speed = Math.hypot(incoming.x, incoming.z);
double yaw = Math.toRadians(destinationYaw);
@@ -3,6 +3,7 @@ package net.dnorberg.rivencraft.item;
import java.util.function.Consumer;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.writing.PortSymbolPages;
import net.dnorberg.rivencraft.writing.PortAuthoredWriting;
import net.minecraft.ChatFormatting;
import net.minecraft.core.component.DataComponents;
import net.minecraft.network.chat.Component;
@@ -12,7 +13,7 @@ import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.TooltipFlag;
import net.minecraft.world.item.component.TooltipDisplay;
/** One Page item with a persistent Link Panel variant; symbol writing is not ported yet. */
/** Shared Page item with persistent Link Panel and authored symbol variants. */
public final class PageItem extends Item {
public PageItem(Properties properties) {
super(properties);
@@ -72,8 +73,10 @@ public final class PageItem extends Item {
? description.copy().withStyle(ChatFormatting.GRAY, ChatFormatting.OBFUSCATED)
: description.copy().withStyle(ChatFormatting.GRAY));
}
tooltip.accept(Component.translatable("item.rivencraft.page.symbol.port_status")
.withStyle(ChatFormatting.DARK_GRAY));
boolean implemented = PortAuthoredWriting.isPageImplemented(symbolId);
tooltip.accept(Component.translatable(implemented
? "item.rivencraft.page.symbol.implemented" : "item.rivencraft.page.symbol.unimplemented")
.withStyle(implemented ? ChatFormatting.DARK_GRAY : ChatFormatting.RED));
return;
}
LinkProperties properties = stack.getOrDefault(Rivencraft.LINK_PROPERTIES.get(), LinkProperties.NONE);
@@ -0,0 +1,23 @@
package net.dnorberg.rivencraft.mixin;
import com.llamalad7.mixinextras.injector.ModifyReturnValue;
import net.dnorberg.rivencraft.age.PortLighting;
import net.minecraft.world.level.Level;
import net.minecraft.world.level.LevelHeightAccessor;
import net.minecraft.world.level.lighting.LevelLightEngine;
import org.spongepowered.asm.mixin.Final;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.Shadow;
import org.spongepowered.asm.mixin.injection.At;
/** Ambient light contributes to effective brightness, not fake block/sky propagation. */
@Mixin(LevelLightEngine.class)
public abstract class AgeAmbientLightMixin {
@Shadow @Final protected LevelHeightAccessor levelHeightAccessor;
@ModifyReturnValue(method = "getRawBrightness", at = @At("RETURN"))
private int rivencraft$ambientBrightness(int brightness) {
return levelHeightAccessor instanceof Level level && PortLighting.isBright(level)
? Math.max(brightness, 15) : brightness;
}
}
@@ -0,0 +1,36 @@
package net.dnorberg.rivencraft.mixin;
import com.llamalad7.mixinextras.sugar.Local;
import net.dnorberg.rivencraft.age.PortCarverPages;
import net.dnorberg.rivencraft.age.PortFeatureChunkGenerator;
import net.minecraft.server.level.WorldGenRegion;
import net.minecraft.world.level.biome.BiomeManager;
import net.minecraft.world.level.chunk.CarvingMask;
import net.minecraft.world.level.chunk.ChunkAccess;
import net.minecraft.world.level.levelgen.NoiseBasedChunkGenerator;
import net.minecraft.world.level.levelgen.NoiseChunk;
import net.minecraft.world.level.levelgen.RandomState;
import net.minecraft.world.level.levelgen.blending.Blender;
import net.minecraft.world.level.levelgen.material.rule.MaterialRule;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Inject;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
/** Merge authored geometry before vanilla consumes its mask with aquifer/surface rules. */
@Mixin(NoiseBasedChunkGenerator.class)
public abstract class AgeCarversMixin {
@Inject(method = "generateCarvers", at = @At(value = "INVOKE",
target = "Lnet/minecraft/world/level/chunk/CarvingMask;isEmpty()Z"))
private void rivencraft$authoredCarvers(ChunkAccess chunk, Blender blender, NoiseChunk noise,
RandomState random, BiomeManager biomes, WorldGenRegion region, MaterialRule material,
CallbackInfo ci, @Local CarvingMask mask) {
if (region == null) return;
var level = region.getLevel();
if (level.getChunkSource().getGenerator() instanceof PortFeatureChunkGenerator generator
&& generator.base() == (Object) this && generator.terrainFeatures() != null) {
PortCarverPages.carve((NoiseBasedChunkGenerator) (Object) this, chunk, random,
region.registryAccess(), generator.terrainFeatures().carvers(), level.getSeed(), mask);
}
}
}
@@ -0,0 +1,32 @@
package net.dnorberg.rivencraft.mixin;
import net.dnorberg.rivencraft.age.PortAgeColors;
import net.minecraft.world.attribute.EnvironmentAttributeSystem;
import net.minecraft.world.attribute.EnvironmentAttributes;
import net.minecraft.world.level.Level;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Inject;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable;
@Mixin(EnvironmentAttributeSystem.Builder.class)
public class AgeColorAttributesMixin {
@Inject(method = "addDefaultLayers", at = @At("RETURN"))
private void rivencraft$colors(Level level, CallbackInfoReturnable<EnvironmentAttributeSystem.Builder> cir) {
var id = level.dimension().identifier();
if (!id.getNamespace().equals("rivencraft") || !id.getPath().startsWith("age/")) return;
var builder = cir.getReturnValue();
builder.addTimeBasedLayer(EnvironmentAttributes.SKY_COLOR, (base, tick) -> {
int rgb = PortAgeColors.forLevel(level).sky();
return rgb < 0 ? base : PortAgeColors.vector(rgb);
});
builder.addTimeBasedLayer(EnvironmentAttributes.FOG_COLOR, (base, tick) -> {
int rgb = PortAgeColors.forLevel(level).sky();
return rgb < 0 ? base : PortAgeColors.vector(rgb);
});
builder.addTimeBasedLayer(EnvironmentAttributes.AMBIENT_LIGHT_COLOR, (base, tick) -> {
int rgb = PortAgeColors.forLevel(level).ambient();
return rgb < 0 ? base : PortAgeColors.vector(rgb);
});
}
}
@@ -0,0 +1,24 @@
package net.dnorberg.rivencraft.mixin;
import net.dnorberg.rivencraft.age.PortDayCycle;
import net.minecraft.world.attribute.EnvironmentAttributeSystem;
import net.minecraft.world.attribute.WeatherAttributes;
import net.minecraft.world.level.Level;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Inject;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable;
@Mixin(EnvironmentAttributeSystem.Builder.class)
public class AgeDayTimelineMixin {
@Inject(method = "addDynamicLayers", at = @At("HEAD"), cancellable = true)
private void rivencraft$dayCycle(Level level, CallbackInfoReturnable<EnvironmentAttributeSystem.Builder> cir) {
var id = level.dimension().identifier();
if (!id.getNamespace().equals("rivencraft") || !id.getPath().startsWith("age/")) return;
var builder = (EnvironmentAttributeSystem.Builder) (Object) this;
var clocks = PortDayCycle.timelines(level, level.clockManager());
level.dimensionType().timelines().forEach(timeline -> builder.addTimelineLayer(timeline, clocks));
if (level.canHaveWeather()) WeatherAttributes.addBuiltinLayers(builder, WeatherAttributes.WeatherAccess.from(level));
cir.setReturnValue(builder);
}
}
@@ -0,0 +1,42 @@
package net.dnorberg.rivencraft.mixin;
import java.util.function.Predicate;
import net.dnorberg.rivencraft.age.PortFeatureChunkGenerator;
import net.dnorberg.rivencraft.age.PortStructurePages;
import net.minecraft.core.Holder;
import net.minecraft.core.RegistryAccess;
import net.minecraft.world.level.ChunkPos;
import net.minecraft.world.level.LevelHeightAccessor;
import net.minecraft.world.level.biome.Biome;
import net.minecraft.world.level.biome.BiomeSource;
import net.minecraft.world.level.biome.Climate;
import net.minecraft.world.level.chunk.ChunkGenerator;
import net.minecraft.world.level.levelgen.RandomState;
import net.minecraft.world.level.levelgen.structure.Structure;
import net.minecraft.world.level.levelgen.structure.templatesystem.StructureTemplateManager;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.ModifyArg;
/** Matches 1.21.1's explicit-page biome override, scoped only to authored Age structures. */
@Mixin(Structure.class)
public abstract class AgeStructureBiomeMixin {
// 26.3 jigsaw placement checks biome columns inside findGenerationPoint,
// before the final isValidBiome test. Set the predicate when constructing
// the context so both checks see the explicit-page override.
@ModifyArg(method = "generate", at = @At(value = "INVOKE",
target = "Lnet/minecraft/world/level/levelgen/structure/Structure$GenerationContext;<init>(Lnet/minecraft/core/RegistryAccess;Lnet/minecraft/world/level/chunk/ChunkGenerator;Lnet/minecraft/world/level/biome/BiomeSource;Lnet/minecraft/world/level/biome/Climate$Sampler;Lnet/minecraft/world/level/levelgen/RandomState;Lnet/minecraft/world/level/levelgen/structure/templatesystem/StructureTemplateManager;JLnet/minecraft/world/level/ChunkPos;Lnet/minecraft/world/level/LevelHeightAccessor;Ljava/util/function/Predicate;)V"),
index = 9)
private Predicate<Holder<Biome>> rivencraft$authoredBiome(
RegistryAccess registries, ChunkGenerator chunkGenerator, BiomeSource biomes,
Climate.Sampler sampler, RandomState random, StructureTemplateManager templates,
long seed, ChunkPos chunk, LevelHeightAccessor height, Predicate<Holder<Biome>> original) {
if (chunkGenerator instanceof PortFeatureChunkGenerator generator
&& generator.terrainFeatures() != null
&& PortStructurePages.authored((Structure) (Object) this, registries,
generator.terrainFeatures().structures())) {
return biome -> true;
}
return original;
}
}
@@ -0,0 +1,102 @@
package net.dnorberg.rivencraft.mixin;
import com.mojang.blaze3d.pipeline.RenderTarget;
import com.mojang.renderpearl.api.buffers.GpuBufferSlice;
import net.dnorberg.rivencraft.client.PortStaticSky;
import net.dnorberg.rivencraft.client.StaticSkyState;
import net.minecraft.client.Camera;
import net.minecraft.client.multiplayer.ClientLevel;
import net.minecraft.client.renderer.SkyRenderer;
import net.minecraft.client.renderer.state.level.SkyRenderState;
import net.minecraft.world.level.dimension.DimensionType;
import org.spongepowered.asm.mixin.Final;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.Shadow;
import org.spongepowered.asm.mixin.Unique;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Inject;
import org.spongepowered.asm.mixin.injection.Redirect;
import org.spongepowered.asm.mixin.injection.ModifyArg;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
@Mixin(SkyRenderer.class)
public class StaticSkyRendererMixin {
@Shadow @Final private RenderTarget renderTarget;
@Unique private PortStaticSky rivencraft$staticSky;
@Unique private int rivencraft$endTint = -1;
@Unique private int rivencraft$orbitBearing = -1;
@Unique private float rivencraft$sunSize = 1F;
@Inject(method = "extractRenderState", at = @At("TAIL"))
private void rivencraft$extract(ClientLevel level, float partialTicks, Camera camera,
SkyRenderState state, CallbackInfo ci) {
boolean enabled = level.dimensionTypeRegistration().unwrapKey().map(key ->
key.identifier().getNamespace().equals("rivencraft")
&& key.identifier().getPath().endsWith("_sky_static")).orElse(false);
((StaticSkyState) state).rivencraft$staticFrame(enabled ? level.getGameTime() / 2 : Long.MIN_VALUE);
((StaticSkyState) state).rivencraft$skyTint(net.dnorberg.rivencraft.age.PortAgeColors.forLevel(level).sky());
var cycle = net.dnorberg.rivencraft.age.PortDayCycle.forLevel(level);
((StaticSkyState) state).rivencraft$orbitBearing(cycle.authored() ? cycle.bearing() : -1);
((StaticSkyState) state).rivencraft$sunSize(cycle.authored() ? cycle.sunSize() : 1F);
// Only the extracted presentation uses END to schedule the sky pass. The actual
// dimension has NONE, so it never acquires End flashes or End gameplay behavior.
if (enabled) state.skybox = DimensionType.Skybox.END;
}
@Inject(method = "render", at = @At("HEAD"), cancellable = true)
private void rivencraft$render(GpuBufferSlice fog, SkyRenderState state, CallbackInfo ci) {
long frame = ((StaticSkyState) state).rivencraft$staticFrame();
rivencraft$endTint = ((StaticSkyState) state).rivencraft$skyTint();
rivencraft$orbitBearing = ((StaticSkyState) state).rivencraft$orbitBearing();
rivencraft$sunSize = ((StaticSkyState) state).rivencraft$sunSize();
if (frame == Long.MIN_VALUE) return;
if (rivencraft$staticSky == null) rivencraft$staticSky = new PortStaticSky();
rivencraft$staticSky.render(renderTarget, fog, frame, rivencraft$endTint);
ci.cancel();
}
@ModifyArg(method = "renderSunMoonAndStars", at = @At(value = "INVOKE", target =
"Lcom/mojang/blaze3d/vertex/PoseStack;rotateDegrees(Lcom/mojang/math/Axis;F)V", ordinal = 0), index = 1)
private float rivencraft$orbitAngle(float nativeBearing) {
return rivencraft$orbitBearing < 0 ? nativeBearing : rivencraft$orbitBearing;
}
@ModifyArg(method = "renderSun", at = @At(value = "INVOKE", target =
"Lnet/minecraft/client/renderer/SkyRenderer;applyCelestialBodyTransform(Lcom/mojang/blaze3d/vertex/PoseStack;FF)Lorg/joml/Matrix4f;"), index = 2)
private float rivencraft$scaleSun(float nativeSize) {
// 1.21.1 Normal Time applies Size to its sun; its paired moon remains 1x.
return nativeSize * rivencraft$sunSize;
}
@Inject(method = "renderSunriseAndSunset", at = @At("HEAD"))
private void rivencraft$rotateHorizon(com.mojang.renderpearl.api.commands.RenderPass pass,
com.mojang.blaze3d.vertex.PoseStack pose, float angle,
org.joml.Vector4fc color, CallbackInfo ci) {
if (rivencraft$orbitBearing < 0) return;
pose.pushPose();
pose.rotateDegrees(com.mojang.math.Axis.YP, rivencraft$orbitBearing + 90);
}
@Inject(method = "renderSunriseAndSunset", at = @At("TAIL"))
private void rivencraft$restoreHorizon(com.mojang.renderpearl.api.commands.RenderPass pass,
com.mojang.blaze3d.vertex.PoseStack pose, float angle,
org.joml.Vector4fc color, CallbackInfo ci) {
if (rivencraft$orbitBearing >= 0) pose.popPose();
}
@Redirect(method = "renderEndSky", at = @At(value = "INVOKE", target =
"Lnet/minecraft/client/renderer/DynamicGpuData;writeTransform(Lorg/joml/Matrix4f;)Lcom/mojang/renderpearl/api/buffers/GpuBufferSlice;"))
private GpuBufferSlice rivencraft$endColor(net.minecraft.client.renderer.DynamicGpuData uniforms,
org.joml.Matrix4f matrix) {
return rivencraft$endTint < 0 ? uniforms.writeTransform(matrix) : uniforms.writeTransform(matrix,
new org.joml.Vector4f(net.dnorberg.rivencraft.age.PortAgeColors.vector(rivencraft$endTint), 1));
}
@Inject(method = "close", at = @At("HEAD"))
private void rivencraft$close(CallbackInfo ci) {
if (rivencraft$staticSky != null) {
rivencraft$staticSky.close();
rivencraft$staticSky = null;
}
}
}
@@ -0,0 +1,33 @@
package net.dnorberg.rivencraft.mixin;
import net.dnorberg.rivencraft.client.StaticSkyState;
import net.minecraft.client.renderer.state.level.SkyRenderState;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.Unique;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Inject;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
@Mixin(SkyRenderState.class)
public class StaticSkyStateMixin implements StaticSkyState {
@Unique private long rivencraft$frame = Long.MIN_VALUE;
@Unique private int rivencraft$tint = -1;
@Unique private int rivencraft$bearing = -1;
@Unique private float rivencraft$sunSize = 1F;
public long rivencraft$staticFrame() { return rivencraft$frame; }
public void rivencraft$staticFrame(long frame) { rivencraft$frame = frame; }
public int rivencraft$skyTint() { return rivencraft$tint; }
public void rivencraft$skyTint(int color) { rivencraft$tint = color; }
public int rivencraft$orbitBearing() { return rivencraft$bearing; }
public void rivencraft$orbitBearing(int bearing) { rivencraft$bearing = bearing; }
public float rivencraft$sunSize() { return rivencraft$sunSize; }
public void rivencraft$sunSize(float size) { rivencraft$sunSize = size; }
@Inject(method = "reset", at = @At("TAIL"))
private void rivencraft$reset(CallbackInfo ci) {
rivencraft$frame = Long.MIN_VALUE;
rivencraft$tint = -1;
rivencraft$bearing = -1;
rivencraft$sunSize = 1F;
}
}
@@ -0,0 +1,17 @@
package net.dnorberg.rivencraft.mixin;
import java.util.Optional;
import net.minecraft.world.level.levelgen.structure.placement.AbstractSpreadingStructurePlacement;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.gen.Accessor;
/** Read-only placement settings for Age-local copies, never edits global placements. */
@Mixin(AbstractSpreadingStructurePlacement.class)
public interface StructurePlacementAccessor {
@Accessor("frequencyReductionMethod")
AbstractSpreadingStructurePlacement.FrequencyReductionMethod rivencraft$frequencyReductionMethod();
@Accessor("frequency") float rivencraft$frequency();
@Accessor("salt") int rivencraft$salt();
@Accessor("exclusionZone")
Optional<AbstractSpreadingStructurePlacement.ExclusionZone> rivencraft$exclusionZone();
}
@@ -134,8 +134,17 @@ public final class PortBookNetworking {
terrain = random.terrain();
biomePlan = random.biomes();
}
var environment = net.dnorberg.rivencraft.age.PortEnvironmentSelection.resolve(pages, seed, terrain);
RuntimeAgeBackend.create(server, id, seed, biomePlan, terrain,
PortAuthoredWriting.rockPlan(pages));
PortAuthoredWriting.rockPlan(pages, seed),
PortAuthoredWriting.vegetationPlan(pages),
PortAuthoredWriting.dungeonCount(pages),
PortAuthoredWriting.lakePlan(pages, seed),
PortAuthoredWriting.terrainFeaturePlan(pages, seed),
environment.weather(), environment.lighting(), environment.sky(),
net.dnorberg.rivencraft.age.PortAgeColors.compile(pages, seed),
net.dnorberg.rivencraft.age.PortDayCycle.compile(pages, seed,
net.dnorberg.rivencraft.age.PortDayCycle.nativeTicks(server.overworld())));
} catch (IllegalArgumentException | IllegalStateException exception) {
player.sendOverlayMessage(Component.literal("Age creation failed: " + exception.getMessage()));
return null;
@@ -3,7 +3,7 @@ package net.dnorberg.rivencraft.registry;
import com.mojang.serialization.MapCodec;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.age.PortFlatChunkGenerator;
import net.dnorberg.rivencraft.age.PortRockChunkGenerator;
import net.dnorberg.rivencraft.age.PortFeatureChunkGenerator;
import net.minecraft.core.registries.Registries;
import net.minecraft.world.level.chunk.ChunkGenerator;
import net.neoforged.bus.api.IEventBus;
@@ -16,7 +16,8 @@ public final class PortChunkGenerators {
static {
GENERATORS.register("age_flat", () -> PortFlatChunkGenerator.CODEC);
GENERATORS.register("age_rocks", () -> PortRockChunkGenerator.CODEC);
// Keep the beta codec ID so previously saved Rocks Ages remain readable.
GENERATORS.register("age_rocks", () -> PortFeatureChunkGenerator.CODEC);
}
private PortChunkGenerators() {}
@@ -2,18 +2,35 @@ package net.dnorberg.rivencraft.writing;
import java.util.List;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Random;
import java.util.HashMap;
import java.util.Map;
import net.dnorberg.rivencraft.age.PortBiomePlan;
import net.dnorberg.rivencraft.age.PortAgeTerrain;
import net.dnorberg.rivencraft.age.PortRockPlan;
import net.dnorberg.rivencraft.age.PortVegetationPlan;
import net.dnorberg.rivencraft.age.PortLakePlan;
import net.dnorberg.rivencraft.age.PortTerrainFeaturePlan;
import net.dnorberg.rivencraft.age.PortStructurePages;
import net.dnorberg.rivencraft.age.PortCarverPages;
import net.dnorberg.rivencraft.age.PortWeatherMode;
import net.dnorberg.rivencraft.age.PortLighting;
import net.dnorberg.rivencraft.age.PortSkyStyle;
import net.dnorberg.rivencraft.Rivencraft;
import net.minecraft.core.HolderLookup;
import net.minecraft.core.registries.Registries;
import net.minecraft.resources.Identifier;
import net.minecraft.resources.ResourceKey;
import net.minecraft.tags.BiomeTags;
/** Authored noise terrain and biome distribution supported by the 26.3 backend. */
public final class PortAuthoredWriting {
private static final int MAX_AUTHORED_BIOMES = 252;
private static final int MAX_ROCKS = 32;
private static final int MAX_VEGETATION = 32;
private static final int MAX_DUNGEONS = 32;
private static final int MAX_LAKES = 32;
private static final Identifier STANDARD_TERRAIN = Identifier.fromNamespaceAndPath(
Rivencraft.MOD_ID, "terrain/standard");
private static final Identifier AMPLIFIED_TERRAIN = Identifier.fromNamespaceAndPath(
@@ -28,27 +45,103 @@ public final class PortAuthoredWriting {
Rivencraft.MOD_ID, "terrain/cave");
private static final Identifier ROCKS = Identifier.fromNamespaceAndPath(
Rivencraft.MOD_ID, "feature/rocks");
private static final Identifier DUNGEONS = Identifier.fromNamespaceAndPath(
Rivencraft.MOD_ID, "feature/dungeons");
private PortAuthoredWriting() {}
/** Port availability only, not validity of a particular ordered book. */
public static boolean isPageImplemented(Identifier page) {
if (page == null || !page.getNamespace().equals(Rivencraft.MOD_ID)) return false;
var entry = PortSymbolPages.find(page);
if (entry == null) return false;
if (net.dnorberg.rivencraft.age.PortAgeColors.isModifier(page)
|| net.dnorberg.rivencraft.age.PortDayCycle.supports(page)) return true;
if (isTerrain(page) || controller(page) != null || PortWeatherMode.forPage(page) != null
|| PortLighting.forPage(page) != null || PortSkyStyle.forPage(page) != null) return true;
if (entry.category().equals("rivencraft:biome") && PortRegistryPages.biomeId(page) != null) return true;
if (isBlockPage(page) && (entry.category().equals("rivencraft:block")
|| entry.category().equals("rivencraft:fluid"))) return true;
return page.equals(ROCKS) || page.equals(DUNGEONS) || PortLakePlan.isLake(page)
|| PortVegetationPlan.isVegetation(page) || PortTerrainFeaturePlan.isSupported(page)
|| page.equals(PortTerrainFeaturePlan.SKYLANDS)
|| page.equals(PortTerrainFeaturePlan.DENSE_ORES)
|| page.equals(PortTerrainFeaturePlan.STAR_FISSURES)
|| PortStructurePages.supports(page) || PortCarverPages.supports(page);
}
/** A page never disappears into an Age unless this port can apply its effect. */
public static boolean supports(List<Identifier> pages) {
if (!net.dnorberg.rivencraft.age.PortAgeColors.validNumbers(pages)) return false;
if (!net.dnorberg.rivencraft.age.PortDayCycle.validWriting(pages)) return false;
boolean terrain = false;
int biomes = 0;
int controllers = 0;
boolean material = false;
boolean rocks = false;
String pendingMaterial = null;
int rocks = 0;
int vegetation = 0;
int dungeons = 0;
int lakes = 0;
int skylands = 0;
int denseOres = 0;
int starFissures = 0;
int weather = 0;
int lighting = 0;
int sky = 0;
Map<Identifier, Integer> terrainFeatures = new HashMap<>();
Map<Identifier, Integer> structures = new HashMap<>();
Map<Identifier, Integer> carvers = new HashMap<>();
for (Identifier page : pages) {
if (isTerrain(page) && !terrain) {
if (pendingMaterial != null && !(pendingMaterial.equals("rivencraft:block")
&& (page.equals(ROCKS) || PortTerrainFeaturePlan.acceptsMaterial(page))
|| pendingMaterial.equals("rivencraft:fluid") && PortLakePlan.isLake(page))) return false;
if (net.dnorberg.rivencraft.age.PortAgeColors.isModifier(page)
|| net.dnorberg.rivencraft.age.PortDayCycle.supports(page)) {
// Shared color groups can precede a sky/ambient target or tint the sky at the end.
} else if (isTerrain(page) && !terrain) {
terrain = true;
} else if (PortSkyStyle.forPage(page) != null && sky++ == 0) {
// Sky presentation, independent of terrain and illumination.
} else if (PortLighting.forPage(page) != null && lighting++ == 0) {
// A single lighting controller; omitted choices resolve from the Age seed.
} else if (PortWeatherMode.forPage(page) != null && weather++ == 0) {
// A single independent weather policy for this Age.
} else if (controller(page) != null && controllers++ == 0) {
// An explicit controller needs an authored pool.
} else if (isBlockPage(page) && !material && !rocks
} else if (isBlockPage(page) && pendingMaterial == null
&& PortSymbolPages.find(page) != null
&& PortSymbolPages.find(page).category().equals("rivencraft:block")) {
material = true;
} else if (page.equals(ROCKS) && material && !rocks) {
rocks = true;
&& (PortSymbolPages.find(page).category().equals("rivencraft:block")
|| PortSymbolPages.find(page).category().equals("rivencraft:fluid"))) {
pendingMaterial = PortSymbolPages.find(page).category();
} else if (page.equals(ROCKS) && rocks < MAX_ROCKS) {
pendingMaterial = null;
rocks++;
} else if (PortLakePlan.isLake(page) && lakes < MAX_LAKES) {
pendingMaterial = null;
lakes++;
} else if (PortTerrainFeaturePlan.isSupported(page)
&& terrainFeatures.getOrDefault(page, 0) < PortTerrainFeaturePlan.MAX_PER_FEATURE) {
pendingMaterial = null;
terrainFeatures.merge(page, 1, Integer::sum);
} else if (page.equals(PortTerrainFeaturePlan.SKYLANDS)
&& skylands < PortTerrainFeaturePlan.MAX_PER_FEATURE) {
skylands++;
} else if (page.equals(PortTerrainFeaturePlan.DENSE_ORES)
&& denseOres < PortTerrainFeaturePlan.MAX_PER_FEATURE) {
denseOres++;
} else if (page.equals(PortTerrainFeaturePlan.STAR_FISSURES)
&& starFissures < PortTerrainFeaturePlan.MAX_PER_FEATURE) {
starFissures++;
} else if (PortCarverPages.supports(page)
&& carvers.getOrDefault(page, 0) < PortTerrainFeaturePlan.MAX_PER_FEATURE) {
carvers.merge(page, 1, Integer::sum);
} else if (PortStructurePages.supports(page)
&& structures.getOrDefault(page, 0) < PortTerrainFeaturePlan.MAX_PER_FEATURE) {
structures.merge(page, 1, Integer::sum);
} else if (PortVegetationPlan.isVegetation(page) && vegetation < MAX_VEGETATION) {
vegetation++;
} else if (page.equals(DUNGEONS) && dungeons < MAX_DUNGEONS) {
dungeons++;
} else if (page.getNamespace().equals(Rivencraft.MOD_ID)
&& page.getPath().startsWith("biome/") && biomes < MAX_AUTHORED_BIOMES
&& PortSymbolPages.find(page) != null
@@ -57,7 +150,20 @@ public final class PortAuthoredWriting {
biomes++;
} else return false;
}
if (material != rocks || rocks && (pages.contains(CAVE_TERRAIN) || pages.contains(VOID_TERRAIN))) return false;
if (pendingMaterial != null || (rocks > 0 || pages.stream().anyMatch(PortVegetationPlan::requiresSurface))
&& (pages.contains(CAVE_TERRAIN) || pages.contains(VOID_TERRAIN))) return false;
if ((dungeons > 0 || lakes > 0) && (pages.contains(FLAT_TERRAIN) || pages.contains(VOID_TERRAIN)
|| pages.contains(CAVE_TERRAIN))) return false;
if (denseOres > 0 && (pages.contains(FLAT_TERRAIN) || pages.contains(VOID_TERRAIN))) return false;
if (!carvers.isEmpty() && (pages.contains(FLAT_TERRAIN) || pages.contains(VOID_TERRAIN))) return false;
PortAgeTerrain structureTerrain = pages.contains(VOID_TERRAIN) ? PortAgeTerrain.VOID
: pages.contains(FLAT_TERRAIN) ? PortAgeTerrain.FLAT
: pages.contains(CAVE_TERRAIN) ? PortAgeTerrain.CAVE : PortAgeTerrain.STANDARD;
if (structures.keySet().stream().anyMatch(page -> !PortStructurePages.supportsTerrain(page, structureTerrain)))
return false;
if (pages.contains(PortTerrainFeaturePlan.FLOATING_ISLANDS) && pages.contains(CAVE_TERRAIN)) return false;
if (pages.stream().anyMatch(PortTerrainFeaturePlan::isSurface)
&& (pages.contains(CAVE_TERRAIN) || pages.contains(VOID_TERRAIN))) return false;
if (controllers != 0) {
PortBiomePlan.Distribution distribution = pages.stream().map(PortAuthoredWriting::controller)
.filter(value -> value != null).findFirst().orElseThrow();
@@ -107,11 +213,96 @@ public final class PortAuthoredWriting {
return PortAgeTerrain.STANDARD;
}
public static PortRockPlan rockPlan(List<Identifier> pages) {
public static PortRockPlan rockPlan(List<Identifier> pages, long seed) {
if (!supports(pages)) throw new IllegalArgumentException("Unsupported authored Age pages");
return pages.contains(ROCKS) ? new PortRockPlan(pages.stream()
.filter(PortAuthoredWriting::isBlockPage).map(PortRegistryPages::blockId)
.findFirst().orElseThrow()) : null;
List<Identifier> materials = solidMaterials(pages, seed, ROCKS);
return materials.isEmpty() ? null : new PortRockPlan(materials);
}
public static PortTerrainFeaturePlan terrainFeaturePlan(List<Identifier> pages, long seed) {
if (!supports(pages)) throw new IllegalArgumentException("Unsupported authored Age pages");
Map<Identifier, List<Identifier>> materials = new HashMap<>();
Map<Identifier, Integer> occurrences = new HashMap<>();
List<PortTerrainFeaturePlan.Choice> choices = new ArrayList<>();
for (Identifier page : pages) {
if (!PortTerrainFeaturePlan.isSupported(page)) continue;
if (page.equals(PortTerrainFeaturePlan.CRYSTAL_FORMATIONS)) {
choices.add(new PortTerrainFeaturePlan.Choice(page, PortTerrainFeaturePlan.CRYSTAL_MATERIAL));
continue;
}
List<Identifier> selected = materials.computeIfAbsent(page, feature -> solidMaterials(pages, seed, feature));
int occurrence = occurrences.merge(page, 1, Integer::sum) - 1;
choices.add(new PortTerrainFeaturePlan.Choice(page, selected.get(occurrence)));
}
boolean skylands = pages.contains(PortTerrainFeaturePlan.SKYLANDS);
int denseOres = (int) pages.stream().filter(PortTerrainFeaturePlan.DENSE_ORES::equals).count();
boolean starFissures = pages.contains(PortTerrainFeaturePlan.STAR_FISSURES);
List<Identifier> structures = pages.stream().filter(PortStructurePages::supports).toList();
List<Identifier> carvers = pages.stream().filter(PortCarverPages::supports).toList();
return choices.isEmpty() && !skylands && denseOres == 0 && !starFissures && structures.isEmpty() && carvers.isEmpty() ? null
: new PortTerrainFeaturePlan(choices, skylands, denseOres, starFissures, structures, carvers);
}
private static List<Identifier> solidMaterials(List<Identifier> pages, long seed, Identifier feature) {
if (!pages.contains(feature)) return List.of();
List<Identifier> randomPool = new ArrayList<>(PortRegistryPages.solidMaterialIds());
if (randomPool.isEmpty()) throw new IllegalStateException("No full-block materials are registered");
Collections.shuffle(randomPool, new Random(mix(seed ^ (long) feature.hashCode() * 0x9E3779B97F4A7C15L)));
List<Identifier> materials = new ArrayList<>();
Identifier pending = null;
int randomIndex = 0;
for (Identifier page : pages) {
if (isBlockPage(page)) pending = PortRegistryPages.blockId(page);
else if (page.equals(feature)) {
materials.add(pending != null ? pending : randomPool.get(randomIndex++ % randomPool.size()));
pending = null;
} else pending = null;
}
return List.copyOf(materials);
}
public static PortLakePlan lakePlan(List<Identifier> pages, long seed) {
if (!supports(pages)) throw new IllegalArgumentException("Unsupported authored Age pages");
if (pages.stream().noneMatch(PortLakePlan::isLake)) return null;
List<Identifier> fluidPool = PortRegistryPages.fluidMaterialIds();
if (fluidPool.isEmpty()) throw new IllegalStateException("No source-fluid materials are registered");
Map<Identifier, List<Identifier>> orders = new HashMap<>();
Map<Identifier, Integer> occurrences = new HashMap<>();
List<PortLakePlan.Choice> choices = new ArrayList<>();
Identifier pending = null;
for (Identifier page : pages) {
if (isBlockPage(page)) pending = PortRegistryPages.blockId(page);
else if (PortLakePlan.isLake(page)) {
int occurrence = occurrences.merge(page, 1, Integer::sum) - 1;
List<Identifier> fluids = orders.computeIfAbsent(page, id -> {
List<Identifier> shuffled = new ArrayList<>(fluidPool);
Collections.shuffle(shuffled, new Random(mix(seed ^ (long) id.hashCode()
* 0x9E3779B97F4A7C15L)));
return shuffled;
});
choices.add(new PortLakePlan.Choice(page,
pending != null ? pending : fluids.get(occurrence % fluids.size())));
pending = null;
} else pending = null;
}
return new PortLakePlan(choices);
}
public static PortVegetationPlan vegetationPlan(List<Identifier> pages) {
if (!supports(pages)) throw new IllegalArgumentException("Unsupported authored Age pages");
List<Identifier> vegetation = pages.stream().filter(PortVegetationPlan::isVegetation).toList();
return vegetation.isEmpty() ? null : new PortVegetationPlan(vegetation);
}
public static int dungeonCount(List<Identifier> pages) {
if (!supports(pages)) throw new IllegalArgumentException("Unsupported authored Age pages");
return (int) pages.stream().filter(DUNGEONS::equals).count();
}
private static long mix(long value) {
value = (value ^ value >>> 30) * 0xBF58476D1CE4E5B9L;
value = (value ^ value >>> 27) * 0x94D049BB133111EBL;
return value ^ value >>> 31;
}
/** Resolves all authored biomes from the active registry before creating a level. */
@@ -119,20 +310,35 @@ public final class PortAuthoredWriting {
if (!supports(pages)) throw new IllegalArgumentException(
"Unsupported terrain or biome-page combination in this port slice");
List<Identifier> biomes = new ArrayList<>();
PortStructurePages.validateRegistry(pages, registries);
PortCarverPages.validateRegistry(pages, registries);
PortBiomePlan.Distribution distribution = null;
boolean caveTerrain = pages.contains(CAVE_TERRAIN);
boolean hasSurfaceBiome = false;
boolean hasOreBiome = false;
for (Identifier page : pages) {
if (isTerrain(page) || page.equals(ROCKS) || isBlockPage(page)) continue;
PortBiomePlan.Distribution selected = controller(page);
if (selected != null) { distribution = selected; continue; }
// supports() has already checked the whole book. Resolve only biome
// pages here, not every page absent from a growing exclusion list.
var entry = PortSymbolPages.find(page);
if (entry == null || !entry.category().equals("rivencraft:biome")) continue;
Identifier biome = PortRegistryPages.biomeId(page);
var registry = registries.lookupOrThrow(Registries.BIOME);
var holder = registry.get(ResourceKey.create(Registries.BIOME, biome))
.orElseThrow(() -> new IllegalArgumentException("Unknown authored biome: " + biome));
if (caveTerrain && !PortRegistryPages.isCaveBiome(holder))
throw new IllegalArgumentException("Cave terrain requires a Cave or Nether biome: " + biome);
if (!PortRegistryPages.isCaveBiome(holder) && !holder.is(BiomeTags.IS_END))
hasSurfaceBiome = true;
if (net.dnorberg.rivencraft.age.PortDenseOres.hasOres(holder)) hasOreBiome = true;
biomes.add(biome);
}
if (pages.stream().anyMatch(PortVegetationPlan::requiresSurface)
&& !biomes.isEmpty() && !hasSurfaceBiome)
throw new IllegalArgumentException("Vegetation pages need at least one surface Overworld biome");
if (pages.contains(PortTerrainFeaturePlan.DENSE_ORES) && !biomes.isEmpty() && !hasOreBiome)
throw new IllegalArgumentException("Dense Ores needs at least one biome with registered ore placements");
if (distribution == PortBiomePlan.Distribution.NATIVE)
return new PortBiomePlan(distribution, List.of());
if (biomes.isEmpty()) return null;
@@ -76,13 +76,27 @@ public final class PortRegistryPages {
public static List<ItemStack> materialPages(boolean fluids) {
String category = fluids ? "rivencraft:fluid" : "rivencraft:block";
return BuiltInRegistries.BLOCK.keySet().stream().sorted()
.filter(id -> category.equals(materialCategory(BuiltInRegistries.BLOCK.getValue(id))))
return materialIds(category).stream()
.map(id -> PageItem.createSymbolPage(blockPageId(id))).toList();
}
public static boolean isCaveBiome(net.minecraft.core.Holder.Reference<net.minecraft.world.level.biome.Biome> holder) {
String path = holder.key().identifier().getPath();
/** The same full-block pool used by Creative pages, in stable registry-ID order. */
public static List<Identifier> solidMaterialIds() {
return materialIds("rivencraft:block");
}
public static List<Identifier> fluidMaterialIds() {
return materialIds("rivencraft:fluid");
}
private static List<Identifier> materialIds(String category) {
return BuiltInRegistries.BLOCK.keySet().stream().sorted()
.filter(id -> category.equals(materialCategory(BuiltInRegistries.BLOCK.getValue(id))))
.toList();
}
public static boolean isCaveBiome(net.minecraft.core.Holder<net.minecraft.world.level.biome.Biome> holder) {
String path = holder.unwrapKey().map(key -> key.identifier().getPath()).orElse("");
return holder.is(CAVE_BIOMES) || holder.is(BiomeTags.IS_NETHER)
|| path.equals("deep_dark") || path.endsWith("_caves");
}
@@ -0,0 +1 @@
{"schema_version":2,"name":"symbol.rivencraft.lighting.ambient_color","description":"symbol.rivencraft.lighting.ambient_color.description","category":"rivencraft:lighting","poem":"Ethereal Red Sustain Balance"}
@@ -0,0 +1,8 @@
{
"type": "minecraft:weighted_random_selector",
"features": [
{"data": "minecraft:red_poplar_leaf_litter", "weight": 200},
{"data": "minecraft:orange_poplar_leaf_litter", "weight": 240},
{"data": "minecraft:yellow_poplar_leaf_litter", "weight": 90}
]
}
@@ -0,0 +1,15 @@
{
"feature": "minecraft:monster_room",
"placement": [
{ "type": "minecraft:count", "count": 8 },
{ "type": "minecraft:in_square" },
{
"type": "minecraft:height_range",
"height": {
"type": "minecraft:uniform",
"min_inclusive": { "above_bottom": 1 },
"max_inclusive": { "below_top": 1 }
}
}
]
}
@@ -0,0 +1,12 @@
{
"feature": "minecraft:fallen_poplar_tree",
"placement": [
{"type": "minecraft:in_square"},
{"type": "minecraft:surface_water_depth_filter", "max_water_depth": 0},
{"type": "minecraft:heightmap", "heightmap": "OCEAN_FLOOR"},
{
"type": "minecraft:block_predicate_filter",
"predicate": {"type": "minecraft:would_survive", "state": "minecraft:poplar_sapling"}
}
]
}
@@ -0,0 +1,9 @@
{
"feature": "rivencraft:poplar_trees",
"placement": [
{"type": "minecraft:count", "count": 6},
{"type": "minecraft:in_square"},
{"type": "minecraft:surface_water_depth_filter", "max_water_depth": 0},
{"type": "minecraft:heightmap", "heightmap": "OCEAN_FLOOR"}
]
}
@@ -0,0 +1,9 @@
{
"required": true,
"minVersion": "0.8",
"package": "net.dnorberg.rivencraft.mixin",
"compatibilityLevel": "JAVA_25",
"mixins": ["StructurePlacementAccessor", "AgeStructureBiomeMixin", "AgeCarversMixin", "AgeAmbientLightMixin", "AgeColorAttributesMixin", "AgeDayTimelineMixin"],
"client": ["StaticSkyStateMixin", "StaticSkyRendererMixin"],
"injectors": {"defaultRequire": 1}
}
@@ -4,15 +4,18 @@ issueTrackerURL="https://git.paddedcell.org/dnorberg/rivencraft/issues"
[[mods]]
modId="${mod_id}"
version="${mod_version}"
displayName="${mod_name} 26.3 Alpha"
displayName="${mod_name} 26.3 Beta"
displayURL="https://git.paddedcell.org/dnorberg/rivencraft"
authors="dnorberg and contributors"
description='''
Experimental Minecraft 26.3 alpha. Provides a runtime Age prototype and basic
Linking Book travel. Most Rivencraft gameplay has not yet been ported. Use a
test world and keep backups; this alpha is not compatible with 1.21.1 worlds.
Experimental Minecraft 26.3 beta with books, writing stations and selected
authored Age generation. Full 1.21.1 gameplay parity is still in progress.
Use a test world and keep backups; 1.21.1 worlds are not compatible.
'''
[[mixins]]
config="rivencraft.mixins.json"
[[dependencies.${mod_id}]]
modId="neoforge"
type="required"
+142 -5
View File
@@ -77,6 +77,18 @@ send_and_wait 'rivencraft_port age create_multi smoke_void_multi 13580 void' 'Cr
send_and_wait 'rivencraft_port age create_multi smoke_cave_multi 112234 cave' 'Created rivencraft:age/smoke_cave_multi with seed 112234 terrain=CAVE'
send_and_wait 'rivencraft_port age create_random smoke_random 889900' 'Random Age plan: terrain='
send_and_wait 'rivencraft_port age create_rocks smoke_rocks 445566' 'Created rivencraft:age/smoke_rocks with seed 445566 terrain=FLAT'
send_and_wait 'rivencraft_port age create_rocks_multi smoke_rocks_multi 445566' 'Created rivencraft:age/smoke_rocks_multi with seed 445566 terrain=FLAT'
send_and_wait 'rivencraft_port age create_rocks_random smoke_rocks_random 445566' 'Created rivencraft:age/smoke_rocks_random with seed 445566 terrain=FLAT'
send_and_wait 'rivencraft_port age create_vegetation smoke_vegetation 24680' 'Created rivencraft:age/smoke_vegetation with seed 24680 terrain=FLAT'
send_and_wait 'rivencraft_port age create_dungeons smoke_dungeons 135791' 'Created rivencraft:age/smoke_dungeons with seed 135791 terrain=STANDARD'
send_and_wait 'rivencraft_port age create smoke_lake_control 667788' 'Created rivencraft:age/smoke_lake_control with seed 667788 terrain=STANDARD'
send_and_wait 'rivencraft_port age create_lakes smoke_lakes 667788' 'Created rivencraft:age/smoke_lakes with seed 667788 terrain=STANDARD'
send_and_wait 'rivencraft_port age create_lakes_random smoke_lakes_random 667788' 'Created rivencraft:age/smoke_lakes_random with seed 667788 terrain=STANDARD'
# This fixture includes a rare island candidate near the origin at normal rarity.
send_and_wait 'rivencraft_port age create_islands smoke_islands 887767 void' 'Created rivencraft:age/smoke_islands with seed 887767 terrain=VOID'
send_and_wait 'rivencraft_port age create_islands smoke_islands_flat 887767 flat' 'Created rivencraft:age/smoke_islands_flat with seed 887767 terrain=FLAT'
send_and_wait 'rivencraft_port age create_islands smoke_islands_noise 887767 standard' 'Created rivencraft:age/smoke_islands_noise with seed 887767 terrain=STANDARD'
send_and_wait 'rivencraft_port age create_islands_random smoke_islands_random 887767 void' 'Created rivencraft:age/smoke_islands_random with seed 887767 terrain=VOID'
# The index must reach disk while the server is still running. A graceful
# shutdown would hide the interrupted-save regression this checks.
index_file="$server_dir/world/dimensions/minecraft/overworld/data/rivencraft/runtime_ages.dat"
@@ -94,6 +106,15 @@ gzip -dc "$index_file" | strings | grep -Fq 'rivencraft:age/smoke_void_multi'
gzip -dc "$index_file" | strings | grep -Fq 'rivencraft:age/smoke_cave_multi'
gzip -dc "$index_file" | strings | grep -Fq 'rivencraft:age/smoke_random'
gzip -dc "$index_file" | strings | grep -Fq 'rivencraft:age/smoke_rocks'
gzip -dc "$index_file" | strings | grep -Fq 'rivencraft:age/smoke_rocks_multi'
gzip -dc "$index_file" | strings | grep -Fq 'rivencraft:age/smoke_rocks_random'
gzip -dc "$index_file" | strings | grep -Fq 'rivencraft:age/smoke_vegetation'
gzip -dc "$index_file" | strings | grep -Fq 'rivencraft:age/smoke_dungeons'
gzip -dc "$index_file" | strings | grep -Fq 'rivencraft:age/smoke_lakes'
gzip -dc "$index_file" | strings | grep -Fq 'rivencraft:age/smoke_lakes_random'
for island_age in smoke_islands smoke_islands_flat smoke_islands_noise smoke_islands_random; do
gzip -dc "$index_file" | strings | grep -Fq "rivencraft:age/$island_age"
done
send_and_wait 'rivencraft_port age sample smoke_alpha' 'rivencraft:age/smoke_alpha seed=123456789 chunkOffset=0 terrain='
send_and_wait 'rivencraft_port age sample smoke_beta' 'rivencraft:age/smoke_beta seed=987654321 chunkOffset=0 terrain='
send_and_wait 'rivencraft_port age sample smoke_flat' 'rivencraft:age/smoke_flat seed=24680 chunkOffset=0 terrain='
@@ -104,6 +125,52 @@ send_and_wait 'rivencraft_port age sample smoke_void_multi' 'rivencraft:age/smok
send_and_wait 'rivencraft_port age sample smoke_cave_multi' 'rivencraft:age/smoke_cave_multi seed=112234 chunkOffset=0 terrain='
send_and_wait 'rivencraft_port age sample smoke_random' 'rivencraft:age/smoke_random seed=889900 chunkOffset=0 terrain='
send_and_wait 'rivencraft_port age sample smoke_rocks' 'rivencraft:age/smoke_rocks seed=445566 chunkOffset=0 terrain='
send_and_wait 'rivencraft_port age sample smoke_rocks_multi' 'rivencraft:age/smoke_rocks_multi seed=445566 chunkOffset=0 terrain='
send_and_wait 'rivencraft_port age sample smoke_rocks_random' 'rivencraft:age/smoke_rocks_random seed=445566 chunkOffset=0 terrain='
send_and_wait 'rivencraft_port age sample smoke_vegetation' 'rivencraft:age/smoke_vegetation seed=24680 chunkOffset=0 terrain='
send_and_wait 'rivencraft_port age sample smoke_dungeons' 'rivencraft:age/smoke_dungeons seed=135791 chunkOffset=0 terrain='
send_and_wait 'rivencraft_port age dungeons_probe smoke_dungeons' 'Dungeons probe rivencraft:age/smoke_dungeons pages=8 spawners='
dungeons_before=$(grep -F 'Dungeons probe rivencraft:age/smoke_dungeons pages=8 spawners=' "$log_file" | tail -1 | sed 's/.*spawners=//')
[[ "$dungeons_before" =~ ^[1-9][0-9]*$ ]] || {
printf 'Authored Dungeons placed no monster spawners: %s\n' "$dungeons_before" >&2
exit 1
}
send_and_wait 'rivencraft_port age lakes_probe smoke_lake_control' 'Lakes probe rivencraft:age/smoke_lake_control pages=0'
lake_control=$(grep -F 'Lakes probe rivencraft:age/smoke_lake_control pages=0' "$log_file" | tail -1 | sed 's/.* water=//; s/ lava=.*//')
send_and_wait 'rivencraft_port age lakes_probe smoke_lakes' 'Lakes probe rivencraft:age/smoke_lakes pages=24 fluids=[minecraft:water]'
lakes_before=$(grep -F 'Lakes probe rivencraft:age/smoke_lakes pages=24' "$log_file" | tail -1 | sed 's/.*Lakes probe /Lakes probe /')
lake_water=$(printf '%s' "$lakes_before" | sed 's/.* water=//; s/ lava=.*//')
[[ "$lake_water" -gt "$lake_control" ]] || {
printf 'Water lake pages did not add water: %s -> %s\n%s\n' "$lake_control" "$lake_water" "$lakes_before" >&2
exit 1
}
send_and_wait 'rivencraft_port age lakes_probe smoke_lakes_random' 'Lakes probe rivencraft:age/smoke_lakes_random pages=24'
lakes_random_before=$(grep -F 'Lakes probe rivencraft:age/smoke_lakes_random pages=24' "$log_file" | tail -1 | sed 's/.*Lakes probe /Lakes probe /')
[[ "$lakes_random_before" == *minecraft:water* && "$lakes_random_before" == *minecraft:lava* ]] || {
printf 'Unmodified lake pages did not resolve both vanilla fluids: %s\n' "$lakes_random_before" >&2
exit 1
}
declare -A islands_before
for island_age in smoke_islands smoke_islands_flat smoke_islands_noise smoke_islands_random; do
send_and_wait "rivencraft_port age islands_probe $island_age" "Islands probe rivencraft:age/$island_age pages=4"
island_result=$(grep -F "Islands probe rivencraft:age/$island_age pages=4" "$log_file" | tail -1 | sed 's/.*Islands probe /Islands probe /')
island_blocks=$(printf '%s' "$island_result" | sed 's/.* count=//; s/ fingerprint=.*//')
[[ "$island_blocks" =~ ^[1-9][0-9]*$ ]] || {
printf 'Floating Islands placed no selected material: %s\n' "$island_result" >&2
exit 1
}
islands_before[$island_age]=$island_result
done
island_materials=${islands_before[smoke_islands_random]#*materials=[}
island_materials=${island_materials%%]*}
IFS=',' read -r -a island_material_list <<< "$island_materials"
[[ ${#island_material_list[@]} -eq 4 ]] || {
printf 'Repeated unmodified islands did not choose independent materials: %s\n' "$island_materials" >&2
exit 1
}
send_and_wait 'execute in rivencraft:age/smoke_islands if block 0 0 0 minecraft:air run say Island Void remains empty below' 'Island Void remains empty below'
send_and_wait 'execute in rivencraft:age/smoke_islands_flat if block 0 0 0 minecraft:bedrock run say Island Flat retains its floor' 'Island Flat retains its floor'
send_and_wait 'rivencraft_port age biome smoke_islands 128 128' 'rivencraft:age/smoke_islands biome at 128,128=minecraft:forest'
send_and_wait 'execute in rivencraft:age/smoke_flat run forceload add 0 0' 'Marked chunk [0, 0] in rivencraft:age/smoke_flat to be force loaded'
send_and_wait 'execute in rivencraft:age/smoke_void run forceload add 0 0' 'Marked chunk [0, 0] in rivencraft:age/smoke_void to be force loaded'
send_and_wait 'execute in rivencraft:age/smoke_flat_multi run forceload add 0 0' 'Marked chunk [0, 0] in rivencraft:age/smoke_flat_multi to be force loaded'
@@ -111,12 +178,40 @@ send_and_wait 'execute in rivencraft:age/smoke_void_multi run forceload add 0 0'
send_and_wait 'execute in rivencraft:age/smoke_cave run forceload add 0 0' 'Marked chunk [0, 0] in rivencraft:age/smoke_cave to be force loaded'
send_and_wait 'execute in rivencraft:age/smoke_cave_multi run forceload add 0 0' 'Marked chunk [0, 0] in rivencraft:age/smoke_cave_multi to be force loaded'
send_and_wait 'execute in rivencraft:age/smoke_rocks run forceload add 0 0' 'Marked chunk [0, 0] in rivencraft:age/smoke_rocks to be force loaded'
send_and_wait 'rivencraft_port age rocks_probe smoke_rocks' 'Rocks probe rivencraft:age/smoke_rocks material=minecraft:gold_block count='
rocks_before=$(grep -F 'Rocks probe rivencraft:age/smoke_rocks material=minecraft:gold_block count=' "$log_file" | tail -1 | sed 's/.*count=//')
send_and_wait 'execute in rivencraft:age/smoke_rocks_multi run forceload add 0 0' 'Marked chunk [0, 0] in rivencraft:age/smoke_rocks_multi to be force loaded'
send_and_wait 'execute in rivencraft:age/smoke_rocks_random run forceload add 0 0' 'Marked chunk [0, 0] in rivencraft:age/smoke_rocks_random to be force loaded'
send_and_wait 'execute in rivencraft:age/smoke_vegetation run forceload add 0 0' 'Marked chunk [0, 0] in rivencraft:age/smoke_vegetation to be force loaded'
send_and_wait 'rivencraft_port age vegetation_probe smoke_flat' 'Vegetation probe rivencraft:age/smoke_flat flowers='
flat_vegetation=$(grep -F 'Vegetation probe rivencraft:age/smoke_flat flowers=' "$log_file" | tail -1 | sed 's/.*flowers=//')
send_and_wait 'rivencraft_port age vegetation_probe smoke_vegetation' 'Vegetation probe rivencraft:age/smoke_vegetation flowers='
authored_vegetation=$(grep -F 'Vegetation probe rivencraft:age/smoke_vegetation flowers=' "$log_file" | tail -1 | sed 's/.*flowers=//')
flat_flowers=${flat_vegetation%% logs=*}
flat_logs=${flat_vegetation##* logs=}
authored_flowers=${authored_vegetation%% logs=*}
authored_logs=${authored_vegetation##* logs=}
[[ "$authored_flowers" -gt "$flat_flowers" && "$authored_logs" -gt "$flat_logs" ]] || {
printf 'Authored vegetation did not increase flowers and logs: %s -> %s\n' \
"$flat_vegetation" "$authored_vegetation" >&2
exit 1
}
send_and_wait 'rivencraft_port age rocks_probe smoke_rocks' 'Rocks probe rivencraft:age/smoke_rocks materials=[minecraft:gold_block] count='
rocks_before=$(grep -F 'Rocks probe rivencraft:age/smoke_rocks materials=[minecraft:gold_block] count=' "$log_file" | tail -1 | sed 's/.*count=//')
[[ "$rocks_before" =~ ^[1-9][0-9]*$ ]] || {
printf 'Authored Rocks placed no gold blocks: %s\n' "$rocks_before" >&2
exit 1
}
send_and_wait 'rivencraft_port age rocks_probe smoke_rocks_multi' 'Rocks probe rivencraft:age/smoke_rocks_multi materials=[minecraft:gold_block, minecraft:gold_block, minecraft:gold_block, minecraft:gold_block] count='
rocks_multi_before=$(grep -F 'Rocks probe rivencraft:age/smoke_rocks_multi materials=' "$log_file" | tail -1 | sed 's/.*count=//')
[[ "$rocks_multi_before" =~ ^[1-9][0-9]*$ && "$rocks_multi_before" -gt "$rocks_before" ]] || {
printf 'Repeated Rocks pages did not increase placement: %s -> %s\n' "$rocks_before" "$rocks_multi_before" >&2
exit 1
}
send_and_wait 'rivencraft_port age rocks_probe smoke_rocks_random' 'Rocks probe rivencraft:age/smoke_rocks_random materials=['
rocks_random_before=$(grep -F 'Rocks probe rivencraft:age/smoke_rocks_random materials=' "$log_file" | tail -1 | sed 's/.*Rocks probe /Rocks probe /')
[[ "$rocks_random_before" =~ count=[1-9][0-9]*$ ]] || {
printf 'Unmodified Rocks page placed no material: %s\n' "$rocks_random_before" >&2
exit 1
}
send_and_wait 'execute in rivencraft:age/smoke_flat if block 0 0 0 minecraft:bedrock run say Flat bedrock starts at Y0' 'Flat bedrock starts at Y0'
send_and_wait 'execute in rivencraft:age/smoke_flat if block 0 3 0 minecraft:grass_block run say Flat surface is Y3' 'Flat surface is Y3'
send_and_wait 'execute in rivencraft:age/smoke_void if block 0 0 0 minecraft:air run say Void remains empty' 'Void remains empty'
@@ -155,8 +250,32 @@ send_and_wait 'rivencraft_port age sample smoke_void_multi' 'rivencraft:age/smok
send_and_wait 'rivencraft_port age sample smoke_cave_multi' 'rivencraft:age/smoke_cave_multi seed=112234 chunkOffset=0 terrain='
send_and_wait 'rivencraft_port age sample smoke_random' 'rivencraft:age/smoke_random seed=889900 chunkOffset=0 terrain='
send_and_wait 'rivencraft_port age sample smoke_rocks' 'rivencraft:age/smoke_rocks seed=445566 chunkOffset=0 terrain='
send_and_wait 'rivencraft_port age rocks_probe smoke_rocks' 'Rocks probe rivencraft:age/smoke_rocks material=minecraft:gold_block count='
rocks_after=$(grep -F 'Rocks probe rivencraft:age/smoke_rocks material=minecraft:gold_block count=' "$log_file" | tail -1 | sed 's/.*count=//')
send_and_wait 'rivencraft_port age sample smoke_rocks_multi' 'rivencraft:age/smoke_rocks_multi seed=445566 chunkOffset=0 terrain='
send_and_wait 'rivencraft_port age sample smoke_rocks_random' 'rivencraft:age/smoke_rocks_random seed=445566 chunkOffset=0 terrain='
send_and_wait 'rivencraft_port age sample smoke_vegetation' 'rivencraft:age/smoke_vegetation seed=24680 chunkOffset=0 terrain='
send_and_wait 'rivencraft_port age sample smoke_dungeons' 'rivencraft:age/smoke_dungeons seed=135791 chunkOffset=0 terrain='
send_and_wait 'rivencraft_port age dungeons_probe smoke_dungeons' 'Dungeons probe rivencraft:age/smoke_dungeons pages=8 spawners='
dungeons_after=$(grep -F 'Dungeons probe rivencraft:age/smoke_dungeons pages=8 spawners=' "$log_file" | tail -1 | sed 's/.*spawners=//')
send_and_wait 'rivencraft_port age lakes_probe smoke_lakes' 'Lakes probe rivencraft:age/smoke_lakes pages=24 fluids=[minecraft:water]'
lakes_after=$(grep -F 'Lakes probe rivencraft:age/smoke_lakes pages=24' "$log_file" | tail -1 | sed 's/.*Lakes probe /Lakes probe /')
send_and_wait 'rivencraft_port age lakes_probe smoke_lakes_random' 'Lakes probe rivencraft:age/smoke_lakes_random pages=24'
lakes_random_after=$(grep -F 'Lakes probe rivencraft:age/smoke_lakes_random pages=24' "$log_file" | tail -1 | sed 's/.*Lakes probe /Lakes probe /')
for island_age in smoke_islands smoke_islands_flat smoke_islands_noise smoke_islands_random; do
send_and_wait "rivencraft_port age islands_probe $island_age" "Islands probe rivencraft:age/$island_age pages=4"
island_result=$(grep -F "Islands probe rivencraft:age/$island_age pages=4" "$log_file" | tail -1 | sed 's/.*Islands probe /Islands probe /')
[[ "${islands_before[$island_age]}" == "$island_result" ]] || {
printf 'Floating Islands changed across restart: %s\n%s\n' "${islands_before[$island_age]}" "$island_result" >&2
exit 1
}
done
send_and_wait 'rivencraft_port age rocks_probe smoke_rocks' 'Rocks probe rivencraft:age/smoke_rocks materials=[minecraft:gold_block] count='
rocks_after=$(grep -F 'Rocks probe rivencraft:age/smoke_rocks materials=[minecraft:gold_block] count=' "$log_file" | tail -1 | sed 's/.*count=//')
send_and_wait 'rivencraft_port age rocks_probe smoke_rocks_multi' 'Rocks probe rivencraft:age/smoke_rocks_multi materials=['
rocks_multi_after=$(grep -F 'Rocks probe rivencraft:age/smoke_rocks_multi materials=' "$log_file" | tail -1 | sed 's/.*count=//')
send_and_wait 'rivencraft_port age rocks_probe smoke_rocks_random' 'Rocks probe rivencraft:age/smoke_rocks_random materials=['
rocks_random_after=$(grep -F 'Rocks probe rivencraft:age/smoke_rocks_random materials=' "$log_file" | tail -1 | sed 's/.*Rocks probe /Rocks probe /')
send_and_wait 'rivencraft_port age vegetation_probe smoke_vegetation' 'Vegetation probe rivencraft:age/smoke_vegetation flowers='
authored_vegetation_after=$(grep -F 'Vegetation probe rivencraft:age/smoke_vegetation flowers=' "$log_file" | tail -1 | sed 's/.*flowers=//')
send_and_wait 'rivencraft_port age biome smoke_flat_multi 384 128' 'rivencraft:age/smoke_flat_multi biome at 384,128=minecraft:forest'
send_and_wait 'rivencraft_port age biome smoke_void_multi 384 128' 'rivencraft:age/smoke_void_multi biome at 384,128=minecraft:forest'
send_and_wait 'rivencraft_port age biome smoke_cave_multi 384 128' 'rivencraft:age/smoke_cave_multi biome at 384,128=minecraft:dripstone_caves'
@@ -185,5 +304,23 @@ random_after=$(grep -F 'rivencraft:age/smoke_random seed=889900 chunkOffset=0 te
"$rocks_before" "$rocks_after" >&2
exit 1
}
[[ "$rocks_multi_before" == "$rocks_multi_after" && "$rocks_random_before" == "$rocks_random_after" ]] || {
printf 'Repeated or unmodified Rocks changed across restart\n' >&2
exit 1
}
[[ "$authored_vegetation" == "$authored_vegetation_after" ]] || {
printf 'Authored vegetation changed across restart: %s -> %s\n' \
"$authored_vegetation" "$authored_vegetation_after" >&2
exit 1
}
[[ "$dungeons_before" == "$dungeons_after" ]] || {
printf 'Authored Dungeons changed across restart: %s -> %s\n' \
"$dungeons_before" "$dungeons_after" >&2
exit 1
}
[[ "$lakes_before" == "$lakes_after" && "$lakes_random_before" == "$lakes_random_after" ]] || {
printf 'Explicit or random lake plans/blocks changed across restart\n' >&2
exit 1
}
stop_server
printf '26.3 dedicated-server smoke passed: Standard, Flat, Void, Cave, random and authored Rocks Ages persisted across restart.\n'
printf '26.3 dedicated-server smoke passed: terrain, biomes, Rocks, vegetation, Dungeons, lakes, and Floating Islands persisted across restart.\n'