Port seeded sky colors and Normal Time celestial controls to 26.3
Build / build (push) Canceled after 0s

This commit is contained in:
dnorberg committed 2026-11-03 16:56:07 -05:00
1 parent a7a73d3c17
commit 38e40ddba3
27 files changed
+1516 -35

No files matched your search

+3 -2
View File
@@ -14,11 +14,12 @@ 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, Overworld/Nether/End and animated Static skies,
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.71-beta.1.md).
[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
+238
View File
@@ -1065,6 +1065,244 @@ 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
+249
View File
@@ -698,6 +698,255 @@ 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,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).
+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.71-beta.1
mod_version=1.0.1.78-beta.1
+27 -1
View File
@@ -11,7 +11,33 @@
"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": "An animated gray static sky surrounds the Age.",
"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.",
@@ -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) {
@@ -38,7 +38,11 @@ final class AgeLevelIndex extends SavedData {
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)
.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<>(
@@ -59,6 +63,8 @@ final class AgeLevelIndex extends SavedData {
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<>();
@@ -73,6 +79,8 @@ final class AgeLevelIndex extends SavedData {
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,
@@ -85,7 +93,8 @@ final class AgeLevelIndex extends SavedData {
Map<Identifier, PortLakePlan> lakes,
Map<Identifier, PortTerrainFeaturePlan> terrainFeatures,
Map<Identifier, PortLighting> lighting,
Map<Identifier, PortSkyStyle> sky) {
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);
@@ -98,6 +107,8 @@ final class AgeLevelIndex extends SavedData {
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() {
@@ -130,10 +141,15 @@ final class AgeLevelIndex extends SavedData {
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, PortVegetationPlan vegetationPlan, int dungeonCount,
PortLakePlan lakePlan, PortTerrainFeaturePlan terrainFeaturePlan, PortLighting light, PortSkyStyle skyStyle) {
PortLakePlan lakePlan, PortTerrainFeaturePlan terrainFeaturePlan, PortLighting light, PortSkyStyle skyStyle,
PortAgeColors colorProfile, PortDayCycle dayCycle) {
if (contains(id)) {
throw new IllegalArgumentException("Age already exists: " + id);
}
@@ -147,6 +163,8 @@ final class AgeLevelIndex extends SavedData {
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();
}
@@ -162,6 +180,8 @@ final class AgeLevelIndex extends SavedData {
terrainFeatures.remove(id);
lighting.remove(id);
sky.remove(id);
colors.remove(id);
dayCycles.remove(id);
retired.add(id);
setDirty();
}
@@ -170,7 +190,7 @@ final class AgeLevelIndex extends SavedData {
PortAgeTerrain ageTerrain, PortRockPlan rockPlan,
PortVegetationPlan vegetationPlan, int dungeonCount,
PortLakePlan lakePlan, PortTerrainFeaturePlan terrainFeaturePlan, PortLighting light,
PortSkyStyle skyStyle) {
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);
@@ -182,6 +202,8 @@ final class AgeLevelIndex extends SavedData {
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);
}
}
@@ -68,6 +68,51 @@ 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())
@@ -347,6 +392,277 @@ 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));
@@ -402,11 +718,38 @@ public final class PortAgeCommands {
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())
|| 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"), false);
+ " deterministic=true skyOverrides=true celestialFallback=true"), false);
return 1;
} catch (IllegalArgumentException | IllegalStateException exception) {
context.getSource().sendFailure(Component.literal(exception.getMessage()));
@@ -741,7 +1084,7 @@ public final class PortAgeCommands {
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));
new PortEnvironmentSelection(PortWeatherMode.NORMAL, PortLighting.NORMAL, PortSkyStyle.OVERWORLD));
}
private static int createAge(CommandContext<CommandSourceStack> context,
@@ -756,7 +1099,7 @@ public final class PortAgeCommands {
try {
level = RuntimeAgeBackend.create(context.getSource().getServer(), id, seed, biomePlan,
terrain, rockPlan, vegetationPlan, dungeons, lakes, terrainFeatures,
environment.weather(), environment.lighting());
environment.weather(), environment.lighting(), environment.sky());
} catch (IllegalArgumentException | IllegalStateException exception) {
context.getSource().sendFailure(Component.literal(exception.getMessage()));
return 0;
@@ -14,6 +14,21 @@ 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) {
@@ -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; }
});
};
}
}
@@ -6,14 +6,14 @@ 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) {
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);
return new PortEnvironmentSelection(weather, lighting, PortSkyStyle.resolve(pages, seed));
}
private static <T> T rankedDefault(Random random, T[] choices) {
@@ -3,6 +3,7 @@ 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;
@@ -24,8 +25,33 @@ public enum PortSkyStyle {
};
}
public static PortSkyStyle fromPages(List<Identifier> pages) {
public static PortSkyStyle resolve(List<Identifier> pages, long seed) {
return pages.stream().map(PortSkyStyle::forPage).filter(java.util.Objects::nonNull)
.findFirst().orElse(OVERWORLD);
.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");
};
}
}
@@ -131,14 +131,32 @@ public final class RuntimeAgeBackend {
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, vegetation, dungeons, lakes,
terrainFeatures, lighting, sky);
terrainFeatures, lighting, sky, colors, dayCycle);
((PortAgeLevel) level).configureWeather(weather);
index.add(id, seed, biomePlan, terrain, rocks, vegetation, dungeons, lakes, terrainFeatures, lighting, sky);
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.
@@ -215,6 +233,8 @@ public final class RuntimeAgeBackend {
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.
@@ -232,7 +252,7 @@ public final class RuntimeAgeBackend {
} catch (IOException | RuntimeException exception) {
try {
index.restoreAfterFailedRetirement(id, seed, biomePlan, terrain, rocks, vegetation, dungeons, lakes,
terrainFeatures, lighting, sky);
terrainFeatures, lighting, sky, colors, dayCycle);
server.overworld().getDataStorage().saveAndJoin();
} catch (RuntimeException rollbackFailure) {
exception.addSuppressed(rollbackFailure);
@@ -255,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);
@@ -359,13 +389,14 @@ public final class RuntimeAgeBackend {
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));
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) {
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);
@@ -442,6 +473,8 @@ public final class RuntimeAgeBackend {
generator, OptionalLong.of(seed));
long biomeZoomSeed = BiomeManager.obfuscateSeed(server.getWorldGenSettings().options().seed());
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.
@@ -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());
@@ -22,21 +22,27 @@ public final class PortStaticSky implements AutoCloseable {
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) {
if (uploadedFrame != frame) {
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 gray = 146 * (65 + random.nextInt(160)) / 255;
image.setPixel(face * GRID + x, y, 0xff000000 | gray << 16 | gray << 8 | gray);
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);
@@ -1,7 +1,13 @@
package net.dnorberg.rivencraft.client;
/** Carries the extracted animation frame without consulting the live world during drawing. */
/** 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,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);
}
}
@@ -15,12 +15,17 @@ 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,
@@ -29,6 +34,10 @@ public class StaticSkyRendererMixin {
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;
@@ -37,12 +46,52 @@ public class StaticSkyRendererMixin {
@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$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) {
@@ -11,9 +11,23 @@ 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; }
private void rivencraft$reset(CallbackInfo ci) {
rivencraft$frame = Long.MIN_VALUE;
rivencraft$tint = -1;
rivencraft$bearing = -1;
rivencraft$sunSize = 1F;
}
}
@@ -141,8 +141,10 @@ public final class PortBookNetworking {
PortAuthoredWriting.dungeonCount(pages),
PortAuthoredWriting.lakePlan(pages, seed),
PortAuthoredWriting.terrainFeaturePlan(pages, seed),
environment.weather(), environment.lighting(),
net.dnorberg.rivencraft.age.PortSkyStyle.fromPages(pages));
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;
@@ -55,6 +55,8 @@ public final class PortAuthoredWriting {
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;
@@ -70,6 +72,8 @@ public final class PortAuthoredWriting {
/** 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;
@@ -91,7 +95,10 @@ public final class PortAuthoredWriting {
if (pendingMaterial != null && !(pendingMaterial.equals("rivencraft:block")
&& (page.equals(ROCKS) || PortTerrainFeaturePlan.acceptsMaterial(page))
|| pendingMaterial.equals("rivencraft:fluid") && PortLakePlan.isLake(page))) return false;
if (isTerrain(page) && !terrain) {
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.
@@ -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"}
@@ -3,7 +3,7 @@
"minVersion": "0.8",
"package": "net.dnorberg.rivencraft.mixin",
"compatibilityLevel": "JAVA_25",
"mixins": ["StructurePlacementAccessor", "AgeStructureBiomeMixin", "AgeCarversMixin", "AgeAmbientLightMixin"],
"mixins": ["StructurePlacementAccessor", "AgeStructureBiomeMixin", "AgeCarversMixin", "AgeAmbientLightMixin", "AgeColorAttributesMixin", "AgeDayTimelineMixin"],
"client": ["StaticSkyStateMixin", "StaticSkyRendererMixin"],
"injectors": {"defaultRequire": 1}
}