Compare commits

..
Author SHA1 Message Date
ChrisM-PekandCursor 8de569bbf1 Add access-code gate to Hello World creation (beta.144).
Build Sanctuary / build (pull_request) Canceled after 0s
Build Sanctuary / build (push) Canceled after 0s
Require a site-issued SANC code when the server has app URL and API token configured, with replay-safe ledger binding and FR/EN UI including galactic code display.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-22 03:03:48 +02:00
66 changed files with 681 additions and 2156 deletions
+7
View File
@@ -1,5 +1,12 @@
# Changelog
## beta.144 — Code d’accès à la création
- Hello World demande le code `SANC-XXXX-XXXX` quand le serveur a l’URL et le jeton du site.
- Le client ne contacte pas le site. Le serveur vérifie, puis consomme le code au moment de créer l’habitant.
- Le lien Discord est enregistré à part. Une création interrompue après `redeemed` peut se terminer sans nouveau code si le compte est encore connu.
- [Contrat](docs/access-code-beta144.md).
## beta.110 — Atelier d’argile inclus
- Une seule livraison réunit l’atelier d’argile, les statuaires et l’import GLB de beta.106 avec les œufs et la neige saisonnière de beta.107 à beta.109.
+2 -24
View File
@@ -1,22 +1,5 @@
# Sanctuary
## beta.146 — Reflets et transitions de l’eau (pack local)
Correction du masquage des personnages en F5 et sélection stricte des surfaces PBR.
PBR à 80 % et SSR à 20 % par défaut, distance commune PBR/SSR
(16/32/64/128 blocs), reflet solaire large et blanc avec léger bloom des matières.
Eau naturelle selon le type de monde et mélange 1/3/5/9/17 blocs.
[Contrat et vérifications](docs/reflections-water-beta146.md).
## beta.145 — PBR de l’eau et reflets pixélisés (livraison locale)
L’eau et le verre rejoignent le PBR natif. L’option expérimentale de reflets
alimente les texels avec le paysage visible selon leurs normales.
[Contrat, vérifications et limites](docs/pbr-ssr.md).
Cette branche intègre un instantané des sources d’eau beta.144 ; elle ne
publie pas le canal et ne synchronise pas Prism.
Sanctuary est une extension gratuite de Minecraft qui transforme le jeu en un
monde flottant d'exploration, de production et de progression collective.
Les joueurs commencent ensemble sur Sanctuary Island et construisent les
@@ -37,11 +20,6 @@ La [vision complète](docs/vision.md) conserve les intentions ; le
L’économie, les claims et les dimensions décrits dans la vision restent
à implémenter.
La version **beta.144** prépare une option d’**eau naturelle pixélisée** :
couleur du biome, trois profils selon le fond et visibilité adaptée en immersion.
Les océans gardent leur couleur native et le profil clair.
[Contrat et vérifications](docs/natural-water-beta144.md). Livraison en cours.
La version **beta.143** garde le sampler natif de précipitation pour la neige
saisonnière, mais ralentit son rythme : l'accumulation est espacée par un
facteur 12 et la fonte par le facteur temps réel 72. Les protections existantes
@@ -2336,7 +2314,7 @@ Depuis beta.090, la cible est **Minecraft 26.3 finale**, publiée le 15 septembr
| Fabric API | 0.160.5+26.3 |
| Fabric Loom | 1.17.20 |
| Gradle Wrapper | 9.5.1, distribution vérifiée par SHA-256 |
| Sanctuary / pack | beta.146 |
| Sanctuary / pack | beta.122 |
Java 25 et Python 3.11 ou plus récent sont nécessaires. Le script pack utilise
uniquement la bibliothèque standard et repère aussi une installation Python
@@ -2365,7 +2343,7 @@ décrits dans [Validation](docs/testing.md).
Résultats :
- `mods/sanctuary/build/libs/sanctuary-beta.146.jar` : mod à installer avec
- `mods/sanctuary/build/libs/sanctuary-beta.122.jar` : mod à installer avec
Fabric API sur la version Minecraft indiquée.
- `build/packwiz/` : pack de développement complet, avec le mod construit et
l'index vérifié. Voir [Installation packwiz](packwiz/README.md).
+57
View File
@@ -0,0 +1,57 @@
# beta.144 — Code d’accès à la création
Le site accepte la candidature, whitelist le pseudo et envoie un code `SANC-XXXX-XXXX`.
Hello World demande ce code avant de créer le personnage. Le client ne parle pas
au site. Seul le serveur appelle l’API, avec le jeton `SANCTUARY_API_TOKEN`.
Sans ces réglages, Hello World reste celui de beta.143 : biographie, couleur et
familier, sans code. Dès que l’URL et le jeton sont présents, un nouveau
personnage exige un code reconnu. Un habitant déjà enregistré entre sans écran.
## Réglage serveur
Variables d’environnement, ou fichier `config/sanctuary/access.json` si une
variable manque :
```json
{"appUrl":"https://exemple.sanctuary","token":"..."}
```
`appUrl` est l’origine du site, sans barre finale. Le serveur appelle
`POST {appUrl}/api/v1/access-codes/verify` puis `redeem`. Le jeton n’est pas
écrit dans les logs, le client ou le pack. Une URL ou un jeton illisible ferme
la création : aucun personnage n’est inventé hors ligne.
## Parcours
1. La whitelist laisse passer le pseudo. L’écran s’ouvre tant que l’UUID n’a pas
d’habitant.
2. Le joueur saisit le code. L’affichage du champ et de l’exemple utilise
l’alphabet galactique standard (`minecraft:alt`, la police de la table
d’enchantement). La valeur envoyée reste le texte tapé. Un format complet
déclenche `verify`, pas chaque frappe. « Code reconnu » n’écrit rien.
3. Confirmer envoie le code de la session, la biographie, la couleur et le
familier. Le pseudo envoyé au site est celui de la session.
4. `redeem` répond `redeemed` avec `discord_id` : le lien est enregistré, puis
l’habitant est créé. Les connexions suivantes sautent l’écran.
5. Erreur, site injoignable ou jeton refusé : le joueur reste sur Hello World.
Une déconnexion avant `redeemed` ne consomme pas le code. Si le site a répondu
`redeemed` et que l’écriture de l’habitant est interrompue, le lien Discord
reste dans `data/sanctuary-access.json` (`pending`, schéma 1, graine du monde).
La confirmation suivante termine le personnage sans rappeler `redeem`.
Si le code est déjà consommé et que le site ne renvoie plus `discord_id`, le
joueur reste bloqué avec un message de reprise. Le site doit alors renvoyer
`minecraft_username` et `discord_id` pour le même pseudo. Le mod accepte cette
réponse, que `valid` soit vrai ou faux, et finit la création.
Le registre des habitants ne change pas. Le Discord est un fichier à part.
Un fichier illisible est conservé et refuse l’accueil.
## Limites
Le gel dans le monde n’est pas ajouté : Hello World reste avant l’entrée, comme
aujourd’hui. Inventaire, commandes et dimensions ne sont pas accessibles tant
que l’habitant n’existe pas. La whitelist RCON reste celle du site. Un pseudo
ajouté à la main, sans code, voit l’écran sans pouvoir le valider.
-121
View File
@@ -1,121 +0,0 @@
# WATER-144 — Eau naturelle pixélisée
Branche `codex/water-beta144`, socle beta.143 publiée (`97ff2b5`) et suivi
`580b241`. Minecraft 26.3, Java 25, dépendances conservées.
## Contrat
**Options → Shaders → Eau naturelle** est activée par défaut avec Sanctuary.
L’intensité (0–100 %, défaut 100 %) agit sur la surface et l’immersion.
L’interrupteur ou 0 % retrouve le rendu précédent. Les préférences existantes
restent conservées ; un moteur externe suspend l’effet comme les autres effets
natifs Sanctuary.
La couleur native du biome reste la base, y compris les variantes d’océans.
Les océans (`minecraft:is_ocean`) imposent le profil clair.
Hors océan, le premier support situé au maximum huit blocs sous la cellule
observée choisit le profil :
| Profil | Fond | Mélange olive #788448 | Alpha natif | Distance de brume native |
| --- | --- | ---: | ---: | ---: |
| Claire | Pierre, matériaux construits, inconnus | 0 % | ×0,55 | ×1 |
| Légèrement teintée | Sable, sable rouge, gravier, argile | 10 % | ×0,80 | ×0,70 |
| Étang | Terre, terre stérile/enracinée, herbe, podzol, mycélium, boue, racines de palétuvier boueuses | 85 % | ×1 | ×0,40 |
L’eau et les plantes aquatiques sans collision sont traversées. Un support
immergé reste un fond ; absence de support, air ou données manquantes donnent
une eau claire. La référence visuelle fournie pendant l’implémentation demande un véritable
vert olive légèrement jaunâtre : le profil étang passe donc de 45 % à 85 %
de mélange, tout en laissant les eaux claires et océaniques natives.
Les surfaces profondes sans fond dans la limite restent donc
claires. Les profils se mélangent aux sommets sur un bloc, sans filtrer les
textures. L’eau sous un toit suit son fond, pas l’éclairage du bâtiment.
La ressource client `assets/sanctuary/water_materials.json` accepte deux listes
d’identifiants de blocs, `light` et `pond`, remplaçables par un resource pack.
Les blocs absents des listes sont clairs. Une ressource invalide est signalée
et reprend les familles embarquées. Le template personnel inclut cette ressource.
## Rendu et coût
Modification des seules couleurs/alpha des sommets de `FluidRenderer`, sans
passe écran ni duplication de maillage. Textures, UV, animation, éclairage
et tri des transparences restent natifs. Les calculs de profils sont différés
jusqu’à l’émission d’un sommet visible, puis mutualisés dans la région compilée.
Un test de palette élimine les sections sans eau avant toute copie de biomes.
Les états de blocs viennent des copies natives ; les appartenances océaniques
sont capturées avant l’envoi au worker. Aucun chargement de chunk n’est demandé.
Un changement de bloc invalide la zone dépendante, huit blocs au-dessus et
un bloc horizontalement. Les reconstructions natives recopient leurs données ;
l’effet ne déclenche aucune reconstruction pour une lumière mobile.
Un changement d’option reconstruit seulement les sections chargées contenant de
l’eau ; les caches des autres effets restent disponibles. Le cache temporaire
disparaît avec sa compilation, sans historique de monde.
Sous l’eau, le profil est échantillonné au maximum une fois par tick, avec
transition de 0,5 seconde. La couleur et les distances environnementales sont
adaptées dans le rendu natif du brouillard. La teinte finale est appliquée après
la normalisation de la vision aquatique, pour éviter de transformer l’olive en
jaune fluorescent ; la luminosité issue des modificateurs natifs de vision
et le bonus du compagnon calmar sont conservés. Lave et brume atmosphérique ne changent
pas. Il n’y a pas de simulation d’absorption volumétrique ni de reflet ajouté.
## Validation native
Les essais utilisent un monde plat jetable, graine 122, avec bassins sur quartz,
sable et boue, support du sable, plantes aquatiques, toit, frontière de sections
et changement de biome océan. Une copie personnelle de ressource remplace
réellement les familles de matériaux puis est désactivée.
Les suites graphiques OpenGL et Vulkan ont passé les contrôles de surface,
d’immersion, de fond modifié, d’océan, de ressources, de préférences et de
cascades pendant le développement. La dernière optimisation reconstruit
seulement les sections contenant de l’eau lors du changement d’option.
Sa série de validation finale reste à terminer avant publication.
### Protocole de performance
Apple M1, macOS, Java 25, 854×480, distance de rendu 6 chunks,
ombres 32 blocs / 16 pixels, PBR 50 %, autres effets activés.
VSync désactivée, limite 260 (illimitée), limitation AFK désactivée.
Pour chaque scène : OFF/ON/ON/OFF/ON/OFF/OFF/ON, dix secondes par passage,
avec stabilisation des sections puis quatre secondes de chauffe entre passages.
Le déplacement avance de 120 blocs et laisse les sections se compiler
naturellement, sans vidange artificielle des caches des autres effets.
Les moyennes des quatre médianes et P95 par état sont comparées avec les seuils
+5 % et +2 ms. Les temps de compilation de sections sont consignés également.
Les essais courts initiaux ont montré une forte variabilité. Une limitation AFK
à 30 FPS puis des clients Minecraft concurrents dans le chantier PBR/SSR ont
été identifiés. Ces essais restent dans les preuves de développement ; ils
ne valident pas la livraison. La série finale attend la fin du chantier
concurrent, conformément à la demande du créateur. Les résultats ne sont pas
présentés comme acquis, et aucun gain de performance universel n’est promis.
Pour reproduire, avec Java 25 et un environnement graphique disponible :
```sh
./gradlew :sanctuary:runClientGameTest -PsanctuaryClientTests=true \
-PsanctuaryPixelShadows122ClientTests=true -PsanctuaryWater144ClientTests=true \
-PsanctuaryClientNoVsync=true -PsanctuaryQuickTests=true \
-PsanctuaryClientGraphicsBackend=vulkan > build/water144-vulkan.log 2>&1
python3 scripts/check_water_performance.py build/water144-vulkan.log
```
Répéter avec `opengl`, sans autre client graphique simultané. Le vérificateur
refuse les séries incomplètes et les dépassements ; il écrit le reçu détaillé
près du journal. Le marqueur `WATER144_PASS` atteste les contrôles fonctionnels.
### Assemblage et publication
Le GameTest serveur dédié reste exclu conformément au refus antérieur de son
EULA. La commande de livraison est `./gradlew check build assemblePack
assembleTestPack -x :sanctuary:runGameTest` ; la suite complète non exclue
n’est pas déclarée réussie. Les contrôles natifs de l’eau utilisent le serveur
intégré d’un monde jetable. Aucun essai Windows n’est revendiqué.
La release, le canal et l’instance ne sont pas encore mis à jour.
Les preuves détaillées restent dans les dossiers ignorés :
`build/water144-*.log`, `build/water144-*-performance.json`,
`build/water144-*-isolation.json` et `build/water144-evidence/`.
-8
View File
@@ -14,14 +14,6 @@ JAR Sanctuary sont des pièces jointes de releases Gitea. Aucun JAR de mod ou
dépendance téléchargée n'entre dans l'historique Git. Le canal Beta est distinct
de l'ancien pack 26.2.
## beta.144 — Eau naturelle pixélisée (préparation)
Trois profils de couleur/transparence selon le fond, océans clairs et brume
aquatique adaptée. Le [ticket de livraison](natural-water-beta144.md) décrit
la teinte olive retenue, les contrôles et le protocole de performance.
La publication et la synchronisation attendent la validation finale isolée.
Les textures restent à beta.121 ; Minecraft 26.3 et les dépendances sont conservés.
## beta.143 — Neige saisonnière en temps réel
Accumulation espacée par un facteur 12 et fonte par le facteur temps réel 72,
-221
View File
@@ -1,221 +0,0 @@
# PBR-SSR — eau, verre et reflets pixélisés
Livraison locale **beta.145**, branche `codex/pbr-ssr`. Socle PBR beta.142 /
suivi beta.143 et copie figée des sources du chantier d’eau beta.144. Minecraft 26.3, API graphique native.
## Contrat
Les reflets colorent les texels de matière sur une grille 16 × 16 liée aux UV,
avec les normales procédurales existantes. Eau et verre sont diélectriques.
L’eau reprend les faces du tessellateur natif (niveaux, pentes, écoulements,
waterlogging), sans modification de géométrie ni simulation de vagues.
Le verre réfléchit aussi sur les zones sans pigment de sa texture. Les pixels
pigmentés conservent leur relief même avec un alpha faible ; l’alpha nul
utilise la normale géométrique.
Les images des normales d’eau sont préparées au chargement du matériau ; leur
séquence suit l’horloge effective des animations de l’atlas, avec interpolation.
Seules les petites tuiles de normales changent sur le GPU, pas le maillage.
Les normales des textures haute résolution sont rééchantillonnées à 16 × 16.
Au-delà de 1 024 images par sprite, un avertissement signale le repli vers
une normale fixe pour borner la mémoire.
L’option FR/EN « Reflets du paysage — expérimental » est OFF initialement,
intensité 50 %, bornée entre 0 et 100. Elle exige le PBR actif. L’eau naturelle
reste indépendante pour ses couleurs, sa transparence et sa brume.
## Pipeline et limites
Couleur et profondeur du paysage sont copiées après les opaques et avant les
transparences, dans les deux chemins natifs. Le chemin classique ferme la passe
opaque avant la copie, puis reprend la transparence native dans une nouvelle
passe. Aucune copie d’attachement Vulkan encore ouvert.
Une profondeur propre sélectionne le récepteur PBR le plus proche devant le
paysage opaque. Une vitre devant l’eau reçoit seule le SSR ; les couches
suivantes conservent leur rendu natif. La profondeur du fond n’est pas utilisée
comme profondeur de surface de l’eau.
8 à 32 pas déterministes adaptés à la longueur écran, puis quatre raffinements maximum, récepteurs jusqu’à
32 blocs et rayons jusqu’à 128 blocs. Pas de bruit aléatoire, historique,
réflexion récursive, réfraction nouvelle, lecture GPU vers CPU ou ajout de
lumière de gameplay. Les objets hors écran ou masqués ne sont pas retrouvés. Les détails fins entre
deux pas peuvent être manqués ; une interpolation finale affine le point
d’intersection sans augmenter le nombre de recherches.
Un échec revient progressivement au rendu existant ; les transparences et les
effets Sanctuary postérieurs à la capture ne figurent pas dans le reflet.
Les reflets sont composés après le SSGI, avant contours, bloom et rayons.
La main et le HUD restent en dehors du traitement. Le PBR conserve son
périmètre Overworld hors immersion et s’efface avec les moteurs externes.
Budget de maillages : 24 Mio de solides, 8 Mio supplémentaires de transparences.
Deux profondeurs plein écran servent aux surfaces PBR ; la couleur de paysage
SSR est allouée seulement à ON/intensité positive. La copie de composition PBR
existante est réutilisée. Aucun buffer de normales plein écran.
La grille visuelle ne signifie pas qu’un seul fragment GPU est exécuté par texel.
## Intégration
Le commit `38f4d6c` apporte le SSR et les récepteurs transparents. Le commit
`0cfd51e` importe une copie contrôlée des sources beta.144 depuis le chantier
`codex/water-beta144`, fondé sur `580b241`. Les empreintes des fichiers ont été
comparées avant et après la copie ; le reçu local est dans
`build/water144-snapshot/manifest.json`. Cette intégration ne prétend pas que
beta.144 a été publiée ou que son benchmark isolé est terminé. Le code de
production de cet instantané est identique à celui du commit
`7eef945` ensuite créé sur la branche d’eau. Les essais ci-dessous portent
sur les sources réunies dans cette branche.
Points d’entrée : `PbrMaterials` et `PbrAnimation` pour les normales animées,
`PbrFluidMesh` pour le tessellateur natif, `PbrTerrain` pour les deux budgets,
`ReflectionSceneMixin` pour la capture opaque, `PixelReflections` et
`pixel_reflections.fsh` pour la sélection et le rayon. Les horloges de sprites
sont référencées faiblement des deux côtés afin de ne pas retenir les anciens
atlas après rechargement.
## Vérifications
Machine : Apple M1, Minecraft 26.3, Java 25, macOS. Mondes plats de test neufs,
graine 122 ; aucune sauvegarde personnelle utilisée. Les captures conservent
le préfixe historique `sanctuary-beta142-shader` du banc de test.
- Client OpenGL 4.1 Metal 90.5, transparence classique : suite SSR réussie avec les sources d’eau
intégrées (`build/ssr145-opengl-final.log`).
- Client Vulkan/MoltenVK : suite SSR avec transparence améliorée réussie
(`build/ssr145-vulkan-final.log`).
- Régression du PBR historique et nouvelles assertions de profils, normales
transparentes, persistance, bornes et traductions SSR : réussies
(`build/ssr145-pbr-regression-retry.log`). Une première tentative interrompue
s’était arrêtée sur un timeout de sémaphore Vulkan ; elle reste un échec
consigné dans `build/ssr145-pbr-regression.log`.
- Suite fonctionnelle eau beta.144 : réussie, y compris profils, fond modifié,
brume, océan, rechargement, préférences, cascades et pack de matériaux
(`build/ssr145-water-functional.log`). Son long benchmark autonome a été
explicitement sauté ; le coût SSR est mesuré séparément ci-dessous.
- Le relief rouge puis bleu produit deux contributions de couleur distinctes
dans le bassin ; caché par un mur opaque, ce changement ne traverse pas le mur.
- Eau animée sans nouvelle analyse de texture, verre transparent devant l’eau,
métal, déplacement minime de caméra, rotation, OFF/zéro, immersion,
rechargement de ressources et redimensionnement sont exercés par assertions.
L’horloge d’eau reste active après rechargement ; les profils sable et étang
restent corrects avec PBR et SSR simultanément actifs.
- Des captures couvrent également les effets combinés, la lune, les angles
rasants, la translation de caméra, la sortie du paysage hors écran, la pierre
rugueuse OFF/ON, des vitres colorées superposées, un écoulement, une cascade
et une dalle gorgée d’eau. Elles ne remplacent pas une inspection
artistique de tous les angles et de tous les packs.
Captures locales OpenGL : [OFF](../build/ssr-evidence/beta145-opengl-final/0000_sanctuary-beta142-shader-ssr-water-off.png),
[ON](../build/ssr-evidence/beta145-opengl-final/0001_sanctuary-beta142-shader-ssr-water-on.png).
Le dossier `build/ssr-evidence/beta145-opengl-final/` contient les autres captures.
Les mêmes cas Vulkan sont conservés dans
`build/ssr-evidence/beta145-vulkan-final/`. Les comparaisons eau, métal et
verre empilé ont été inspectées visuellement. Ces pièces sont ignorées par Git.
## Coût mesuré
1920 × 1080, PBR à 80 %, SSR à 100 % pour accentuer le résultat, VSync OFF,
limite de FPS et réduction AFK désactivées. Chaque scène chauffe 30 ticks par
état avant 80 ticks de mesure, soit 324 à 483 images pour OpenGL et 317 à 477 pour Vulkan. Ordre OFF puis
ON ; il s’agit d’un diagnostic local court, pas d’une campagne statistique.
Le temps de frame mesure `Minecraft.renderFrame`, présentation comprise.
| OpenGL | Médiane OFF / ON (ms) | p95 OFF / ON (ms) | Écart médian |
| --- | --- | --- | --- |
| Petit étang | 7,57 / 9,42 | 10,35 / 12,50 | +1,85 ms |
| Eau dominante | 7,76 / 11,67 | 10,67 / 13,96 | +3,91 ms |
| Construction vitrée | 7,85 / 10,12 | 10,67 / 12,65 | +2,27 ms |
| Vulkan | Médiane OFF / ON (ms) | p95 OFF / ON (ms) | Écart médian |
| --- | --- | --- | --- |
| Petit étang | 7,97 / 10,36 | 8,97 / 11,49 | +2,40 ms |
| Eau dominante | 10,10 / 12,23 | 10,97 / 12,97 | +2,13 ms |
| Construction vitrée | 8,52 / 11,25 | 9,49 / 12,20 | +2,72 ms |
La mesure GPU Vulkan autour de `PixelReflections.render` donne, à ON,
**2,07 / 2,18 ms** (médiane / p95) pour l’étang, **2,56 / 2,63 ms** pour
l’eau dominante et **2,09 / 2,21 ms** pour le verre. À OFF, la sonde mesure
0,03 ms environ. Ce périmètre couvre la copie de composition et le dessin
SSR ; il exclut la capture opaque antérieure et la profondeur partagée PBR.
La sonde utilise les valeurs rendues par la requête native, pas une mesure
GPU du jeu complet.
La requête GPU native ne fournit pas de mesure exploitable sur ce pilote
OpenGL ; ce champ est indisponible, pas nul. Les premiers essais contaminés
par la réduction AFK à 30 FPS sont exclus des résultats retenus.
**L’objectif de surcoût médian inférieur à 2 ms n’est pas respecté dans l’ensemble des
scènes.** L’option reste expérimentale et désactivée par défaut.
À 1080p : profondeurs partagées par le PBR = 15,82 Mio ; copie couleur SSR
supplémentaire = 7,91 Mio, libérée à OFF/zéro. Total de ces cibles à ON :
23,73 Mio. Les maillages observés occupent environ 0,51–0,55 Mio de solides et
1,09–1,21 Mio de transparences, en dessous des plafonds 24 / 8 Mio vérifiés
par assertion. Ces compteurs n’incluent ni la mémoire du moteur, ni la copie
PBR existante, ni les atlas de matériaux de 6,25 Mio chacun ; ce ne sont pas
des mesures de mémoire totale du processus.
## Construction et contrôles généraux
`./gradlew check build` a été exécuté jusqu’au bout : **229 GameTests serveur
réussis sur 252, 23 échecs**, en 8 min 45 s pour la commande complète.
Les échecs concernent les tombes/familiers, le catalogue, deux invariants de
génération, les placements de blocs/progression/construction et les menus
natifs. Les tests concernés et le code de gameplay serveur ne sont pas
modifiés par ce lot. Aucun contre-test de toute la branche de départ n’est
revendiqué. Le journal complet est `build/ssr145-check-build.log` ; le relevé
exhaustif est `build/ssr145-server-failures.json`.
L’assemblage est repris avec `./gradlew check build assemblePack assembleTestPack
-x :sanctuary:runGameTest`, en excluant uniquement cette tâche déjà exécutée.
Cette commande réussit en 2 min 56 s (126 tâches) ; vérification du pack,
compilation, contrôles hors GameTests serveur et assemblages passent. Les
exports MRpack sont produits depuis leurs dossiers packwiz de staging.
L’intégrité ZIP, les métadonnées, les traductions et mixins SSR ainsi que
l’identité du JAR embarqué dans les deux packs sont contrôlées.
Cette exclusion ne transforme pas le contrôle complet en succès.
## Reproduire les essais client
Base de chaque commande :
```sh
./gradlew :sanctuary:runClientGameTest \
-PsanctuaryClientTests=true -PsanctuaryQuickTests=true \
-PsanctuaryPbr134ClientTests=true -PsanctuaryClientNoVsync=true
```
- SSR Vulkan : ajouter `-PsanctuarySsrClientTests=true
-PsanctuaryClientGraphicsBackend=vulkan -PsanctuaryClientImprovedTransparency=true`.
- SSR OpenGL : ajouter `-PsanctuarySsrClientTests=true
-PsanctuaryClientGraphicsBackend=opengl -PsanctuaryClientImprovedTransparency=false`.
- Régression PBR : ajouter uniquement `-PsanctuaryClientGraphicsBackend=vulkan`.
- Profils et rendu de l’eau : ajouter `-PsanctuaryWater144ClientTests=true
-PsanctuaryWater144FunctionalOnly=true -PsanctuaryClientGraphicsBackend=vulkan`.
Le mode fonctionnel saute uniquement le long benchmark autonome beta.144.
Les sondes de temps de frame/GPU et lectures d’images appartiennent au mod
de test ; elles ne sont pas embarquées dans le JAR de production.
## Artefacts locaux
Les archives finales sont rafraîchies depuis le JAR testé, puis revérifiées
(`build/ssr145-artifacts.json`). Aucun tag ni canal n’est publié.
| Artefact | Octets | SHA-256 |
| --- | ---: | --- |
| [sanctuary-beta.145.jar](../mods/sanctuary/build/libs/sanctuary-beta.145.jar) | 11369411 | `55341e2f5c8dcc352190fe96d15e86cd1ee6bb4ea2908441b70491d48f86f608` |
| [Sanctuary-beta.145.mrpack](../build/Sanctuary-beta.145.mrpack) | 10508045 | `8bebe6227cbfe629c714a7c7547640ab5ccfbefaaf211484a93c042cfb42b5c5` |
| [Sanctuary-Test-beta.145.mrpack](../build/Sanctuary-Test-beta.145.mrpack) | 10526966 | `54402ff8414ed1ae0d12b3b97af70c547c44dde2f3aa183712ae3769f99efbe7` |
| [Sanctuary-Template-beta.145.zip](../build/Sanctuary-Template-beta.145.zip) | 4358316 | `226d92362a5ce922b8757b36f30c312081aca5cf73a299b82a221a12f384a95b` |
## Limites de validation
Le banc utilise un relief construit reproductible, pas une montagne générée
représentative de tous les biomes. La validation artistique sur terrain généré,
les textures personnalisées avec ordre/durées/interpolation atypiques et la
stabilité sur un long trajet restent à compléter. Les surfaces peuvent perdre les petits détails entre
les pas SSR et les zones hors écran ne sont jamais reconstruites. Windows,
autres GPU et moteurs graphiques externes ne sont pas validés ici.
Aucune publication de canal ni synchronisation Prism dans cette livraison.
-112
View File
@@ -1,112 +0,0 @@
# RENDER-146 — Reflets, portée commune et transitions de l’eau
Branche `codex/reflections-water-beta146`, socle beta.145 `ed2d088`.
Minecraft 26.3, Java 25 et dépendances inchangés. Préparation locale uniquement.
## Contrat
- Les passes PBR et SSR vérifient la profondeur finale du rendu natif pour
masquer les personnages, notamment en F5. La profondeur du paysage capturée
avant les transparences reste celle utilisée pour rechercher le reflet.
- Le PBR utilise le test matériel EQUAL contre la profondeur du récepteur
sélectionné. L’ancienne tolérance de 3,5 cm pouvait accepter des faces
concurrentes près des coins ; elle est supprimée. La comparaison avec la
profondeur native conserve seulement une marge de 5 mm pour les différences
d’ordre de calcul des matrices, sans relâcher la sélection stricte du récepteur.
La position du vertex est déclarée invariante entre les passes.
- PBR à 80 % et SSR à 20 % par défaut, pour toutes les matières, eau comprise.
Deux intensités d’effets distinctes, sans réglage séparé solide/liquide.
Les préférences explicites déjà enregistrées restent conservées.
- Distance PBR/SSR commune : 16, 32 (défaut), 64, 128 blocs. Maillages,
sélection des récepteurs et fondu final emploient ce réglage. Le rayon SSR
conserve sa recherche maximale de 128 blocs dans le paysage visible.
Budgets de maillages inchangés : solides 24 Mio, transparences 8 Mio.
Une forte densité à grande portée peut donc limiter les surfaces disponibles.
- Le reflet du soleil adopte un lobe plus large sur l’eau, avec une irradiance
renforcée : grande empreinte blanche vue du dessus et traînée étendue aux
angles rasants. Les lampes conservent leur lobe local.
Les éclats spéculaires ne sont plus plafonnés au gris moyen. Leur compression
conserve une valeur finie pouvant approcher le blanc. Une contribution douce
des seules passes PBR/SSR alimente le bloom existant, sans faire briller tous
les blocs blancs. Le réglage Bloom et son intensité gardent autorité.
- Eau naturelle OFF par défaut dans une dimension ordinaire, ON pour une
dimension dont le générateur est Sanctuary / ExpansionChunkGenerator.
Le serveur transmet ce contexte à la connexion, au changement de dimension
et à la réapparition, y compris en multijoueur. Le défaut automatique nécessite
ce contexte envoyé par un serveur beta.146 ; sans lui, le client utilise le
défaut ordinaire OFF, toujours réglable. Aucune donnée de monde modifiée.
Les choix ON/OFF sont mémorisés séparément pour ces deux catégories.
Un ancien OFF explicite est conservé dans les deux ; un ancien ON global
adopte les nouveaux défauts par contexte.
- Mélange des profils sur 1×1, 3×3, 5×5, 9×9 (défaut) ou 17×17 blocs, puis
interpolation aux sommets. Océans toujours clairs. La texture demeure native.
Surface et immersion utilisent la même étendue. Les changements du fond
invalident toute la largeur dépendante ; changer le réglage reconstruit les
sections chargées contenant de l’eau.
Le filtre de surface est calculé dans la copie de région native, avec sommes
préfixées par hauteur pour borner le coût des sommets. Aucun accès au monde
vivant depuis le worker, chargement de chunk ou modification de sauvegarde.
Les textes des nouvelles options sont fournis en français et en anglais.
## Vérifications
Machine : Apple M1, macOS, Java 25, Minecraft 26.3. Mondes plats jetables,
graine 122 ; aucune sauvegarde personnelle utilisée.
- Suite SSR/PBR Vulkan avec transparence améliorée : réussie
(`build/beta146-final-settings-vulkan.log`). F5 avec personnage réel en créatif,
portée 16/32/64/128, couleur du paysage, eau/verre/métal, OFF/zéro,
immersion, rechargement de ressources et redimensionnement.
- Suite SSR/PBR OpenGL finale réussie également
(`build/beta146-final-settings-opengl.log`), avec 22 450 pixels quasi blancs
dans la scène solaire et 99 / 518 400 pixels modifiés dans le contrôle
des intersections. Captures dans `build/evidence/final-settings-opengl/`.
- F5 : les 1 900 pixels du torse contrôlés sont strictement identiques entre
SSR OFF et ON, tandis que le reflet reste présent autour du personnage.
- Soleil : 22 111 pixels quasi blancs sur 854 × 480 à PBR 80 % / SSR 20 %,
sans bloom, sur la scène de contrôle Vulkan. Les captures vues du dessus et
à soleil bas montrent une empreinte large et une traînée allongée.
Bloom OFF libère sa cible.
- Mur, sol et escaliers en contact : 86 pixels sur 518 400 changent de plus
de 12 niveaux cumulés lors d’une rotation de 0,00002 degré ; seuil du test
0,1 %. Le cadre exclut l’eau animée, qui ne constitue pas une référence
de stabilité pour les blocs statiques.
- Eau : suites fonctionnelles Vulkan et OpenGL réussies
(`build/beta146-water-vulkan.log`, `build/beta146-water-opengl.log`).
Profils, mélange 5/9/17, changement de contexte transmis par le serveur,
préférences séparées, immersion, fond modifié, océans, eau courante,
waterlogging et rechargement. OpenGL contrôle aussi l’égalité des couleurs
aux frontières des copies de région et avec l’échantillonnage de brume.
- `./gradlew check build` exécuté dans un instantané isolé des sources :
252 tests serveur, 229 réussis et 23 échecs. Les identifiants des échecs
correspondent exactement au reçu archivé beta.145 ; aucun nouvel identifiant.
Il ne s’agit pas d’une nouvelle exécution comparative de beta.145.
Journal : `build/beta146-check-build-isolated.log` ; comparaison :
`build/beta146-server-failures.json`. Les derniers changements après cet
instantané concernent le rendu client, ses réglages et ses tests.
Les preuves Vulkan finales sont dans `build/evidence/final-settings-vulkan/`.
La suite de régression PBR et de préférences finale réussit
(`build/beta146-final-pbr-preferences.log`) : PBR 80 %, SSR 20 %, persistance,
migration des anciennes préférences, FR/EN, OFF/zéro et rechargement.
La commande finale `./gradlew check build assemblePack -x :sanctuary:runGameTest`
réussit (125 tâches), sans répéter la suite serveur isolée déjà exécutée.
Journal : `build/beta146-final-build-pack.log`. Cette exclusion ne transforme
pas les 23 échecs serveur en validation globale réussie.
## Artefact local
Le pack [Sanctuary-beta.146.mrpack](../build/Sanctuary-beta.146.mrpack) est exporté
et copié dans le dossier `sanctuary-beta/build/`, à côté des autres versions.
Intégrité ZIP, version beta.146, Minecraft 26.3 et identité du JAR embarqué
avec le JAR construit contrôlées ; shaders finaux présents et identiques aux sources.
Taille : 10 519 634 octets. SHA-256 :
`9f3f7a840652d401741cbe3c85bbb5626326542179754cb599479b01a6b4c955`.
Reçu : `build/beta146-artifacts.json`.
Aucun benchmark isolé de performance de beta.146 n’est revendiqué : des
contrôles serveur peuvent tourner simultanément. Les limites SSR hors écran,
de maillage à grande portée et de compatibilité avec d’autres GPU subsistent.
Aucune publication ou synchronisation Prism.
-6
View File
@@ -107,9 +107,3 @@ Les contrôles ne revendiquent pas un essai avec Iris ou un autre moteur externe
Les ombres portées pixélisées sont livrées dans [beta.127](shader-beta127.md),
intégrées sur la beta.126. Leur préparation locale portait un numéro beta.122
non publié ; la release beta.122 existante n'est pas remplacée.
## beta.144 — Eau naturelle
Le [ticket WATER-144](natural-water-beta144.md) ajoute les profils clair,
légèrement teinté et étang au rendu natif des fluides et à leur brouillard.
La couleur du biome et les textures pixélisées restent la base.
+2 -2
View File
@@ -9,8 +9,8 @@ loom_version=1.17.20
fabric_api_version=0.160.5+26.3
# Release counter: beta.001, .002, .003, ... (see docs/versioning.md).
mod_version=beta.146
pack_version=beta.146
mod_version=beta.144
pack_version=beta.144
resource_pack_version=beta.121
maven_group=fr.koka.sanctuary
jei_version=30.32.0-sanctuary.3
+8 -5
View File
@@ -34,11 +34,6 @@ if (clientRenderTests) {
if (improvedTransparency.isPresent()) {
systemProperty('sanctuary.test.improvedTransparency', improvedTransparency.get())
}
systemProperty('sanctuary.test.ssr', providers.gradleProperty('sanctuarySsrClientTests').getOrElse('false'))
systemProperty('sanctuary.test.water144', providers.gradleProperty('sanctuaryWater144ClientTests').getOrElse('false'))
systemProperty('sanctuary.test.water144ExtrasOnly', providers.gradleProperty('sanctuaryWater144ExtrasOnly').getOrElse('false'))
systemProperty('sanctuary.test.water144FunctionalOnly', providers.gradleProperty('sanctuaryWater144FunctionalOnly').getOrElse('false'))
systemProperty('sanctuary.test.water144PerfOnly', providers.gradleProperty('sanctuaryWater144PerfOnly').getOrElse('false'))
systemProperty('sanctuary.test.pbr142', providers.gradleProperty('sanctuaryPbr142ClientTests').getOrElse('false'))
systemProperty('sanctuary.test.directional141Only', providers.gradleProperty('sanctuaryDirectional141Only').getOrElse('false'))
systemProperty('sanctuary.test.directional141', providers.gradleProperty('sanctuaryDirectional141ClientTests').getOrElse('false'))
@@ -1142,6 +1137,14 @@ tasks.register('intro031Smoke', JavaExec) {
}
tasks.named('check') { dependsOn('intro031Smoke') }
tasks.register('access144Smoke', JavaExec) {
group = 'verification'
dependsOn('testClasses')
classpath = sourceSets.test.runtimeClasspath
mainClass = 'fr.koka.sanctuary.progression.AccessCodeSmoke'
}
tasks.named('check') { dependsOn('access144Smoke') }
if (providers.gradleProperty('sanctuaryQuickTests').map { it.toBoolean() }.getOrElse(false)) {
dependencies { gametestRuntimeOnly project(':sanctuary-test') }
}
@@ -30,9 +30,9 @@ public final class Pbr134ClientChecks implements FabricClientGameTest {
c.runOnClient(m->VanillaLight.setPbrIntensity(20));frames(c);var low=difference(off,capture(c,"pbr-20"));
check(delta.total>low.total*1.3,"PBR strength affects actual GPU output");
c.runOnClient(m->VanillaLight.setPbrIntensity(100));frames(c);var high=difference(off,capture(c,"pbr-100"));check(high.total>delta.total*1.2,"Strong PBR visibly increases relief");
c.runOnClient(m->{VanillaLight.setPbrIntensity(0);});c.waitTicks(8);unchanged(off,capture(c,null),"PBR zero restores native image");
c.runOnClient(m->VanillaLight.setPbrIntensity(0));c.waitTicks(8);unchanged(off,capture(c,null),"PBR zero restores native image");
c.runOnClient(m->check(!ProceduralPbr.allocated()&&ProceduralPbr.materialCount()==0,"Zero releases targets and material cache"));
c.runOnClient(m->{VanillaLight.setPbrIntensity(50);});frames(c);
c.runOnClient(m->VanillaLight.setPbrIntensity(50));frames(c);
server.runOnServer(s->command(s,"time set 11000"));settle(c);enable(c,false);var eveningOff=capture(c,null);enable(c,true);var eveningOn=capture(c,"pbr-evening");
int directions=0;for(int i=0;i<on.pixels().length;i++){int am=(on.pixels()[i]>>16&255)-(off.pixels()[i]>>16&255),pm=(eveningOn.pixels()[i]>>16&255)-(eveningOff.pixels()[i]>>16&255);if(Math.abs(am-pm)>5)directions++;}
check(directions>500,"Relief follows the moving sun: "+directions);
@@ -57,36 +57,12 @@ public final class Pbr134ClientChecks implements FabricClientGameTest {
for(int p:PbrMaterials.generate(flat,4,4,profile))check((p>>8&0xFFFF)==0x8080,"Flat source has neutral normal");
flat[5]=0xFFFFFFFF;var slope=PbrMaterials.generate(flat,4,4,profile);check((slope[4]>>16&255)<128&&(slope[6]>>16&255)>128,"Height gradients produce opposite normal slopes");
check(PbrMaterials.profile("iron_block").metal()>.8&&PbrMaterials.profile("iron_ore").metal()==0,"Metal block profile does not turn ore host rock into metal");
check(PbrMaterials.family(Blocks.WATER.defaultBlockState())==2&&PbrMaterials.family(Blocks.GLASS.defaultBlockState())==1&&PbrMaterials.family(Blocks.GLASS_PANE.defaultBlockState())==1,"Water and glass receiver families");
check(PbrMaterials.profile("water").metal()==0&&PbrMaterials.profile("glass").metal()==0,"Water and glass stay dielectric");
int[] glass={0x00FFFFFF,0x00000000,0x00000000,0x00FFFFFF};
for(int p:PbrMaterials.generate(glass,2,2,PbrMaterials.profile("glass")))check((p>>8&0xFFFF)==0x8080,"Transparent glass texels fall back to geometric normal");
check(PbrMaterials.profile("oak_planks").roughness()>PbrMaterials.profile("polished_andesite").roughness(),"Wood remains rougher than polished stone");
}
private static void preferences(){try{
var p=FabricLoader.getInstance().getConfigDir().resolve("sanctuary-shaders.json");Files.writeString(p,"{\"ssgi\":true,\"ssgiIntensity\":35,\"bloom\":false}");forgetPreferences();
check(VanillaLight.pbr()&&VanillaLight.pbrIntensity()==80&&VanillaLight.ssgi()&&!VanillaLight.bloom(),"Missing PBR setting defaults ON while preserving explicit old choices");
for(int n:new int[]{-1,0,50,100,101}){VanillaLight.setPbr(true);VanillaLight.setPbrIntensity(n);forgetPreferences();check(VanillaLight.pbr()&&VanillaLight.pbrIntensity()==Math.clamp(n,0,100),"PBR preference persistence/clamp");}
check(VanillaLight.pbrDistance()==32,"Shared distance default");
check(!VanillaLight.naturalWater(false)&&VanillaLight.naturalWater(true),"World-specific water defaults");
for(int n:new int[]{16,32,64,128}){VanillaLight.setPbrDistance(n);forgetPreferences();check(VanillaLight.pbrDistance()==n,"Shared distance persistence");}
for(int n:new int[]{0,1,2,4,8}){VanillaLight.setWaterBlendRadius(n);forgetPreferences();check(VanillaLight.waterBlendRadius()==n,"Water blend persistence");}
VanillaLight.setPbrDistance(32);VanillaLight.setWaterBlendRadius(4);
check(!VanillaLight.ssr()&&VanillaLight.ssrIntensity()==20,"Missing SSR defaults OFF at 20 percent");
for(int n:new int[]{-1,0,50,100,101}){VanillaLight.setSsr(true);VanillaLight.setSsrIntensity(n);forgetPreferences();check(VanillaLight.ssr()&&VanillaLight.ssrIntensity()==Math.clamp(n,0,100),"SSR preference persistence/clamp");}
VanillaLight.setSsr(false);VanillaLight.setSsrIntensity(20);forgetPreferences();check(!VanillaLight.ssr(),"SSR OFF persists");
var manager=net.minecraft.client.Minecraft.getInstance().getLanguageManager();var oldLanguage=manager.getSelected();
for(String locale:new String[]{"fr_fr","en_us"}){
manager.setSelected(locale);manager.onResourceManagerReload(net.minecraft.client.Minecraft.getInstance().getResourceManager());
for(String key:new String[]{"pbr_distance","pbr_distance_help","pbr_distance_value","water_blend","water_blend_help","water_blend_value","ssr","ssr_help","ssr_intensity","ssr_intensity_help","ssr_intensity_value"}){
String full="sanctuary.shaders."+key;check(!net.minecraft.network.chat.Component.translatable(full,50).getString().equals(full),"SSR FR/EN "+full);
}
}
manager.setSelected(oldLanguage);manager.onResourceManagerReload(net.minecraft.client.Minecraft.getInstance().getResourceManager());
Files.writeString(p,"{\"naturalWater\":false,\"pbrIntensity\":37}");forgetPreferences();
check(!VanillaLight.naturalWater(false)&&!VanillaLight.naturalWater(true)&&VanillaLight.pbrIntensity()==37,"Legacy water OFF and personal PBR intensity are preserved");
Files.writeString(p,"{\"naturalWater\":true}");forgetPreferences();
check(!VanillaLight.naturalWater(false)&&VanillaLight.naturalWater(true),"Legacy global ON adopts contextual defaults");
VanillaLight.setPbrIntensity(50);
}catch(Exception e){throw new AssertionError(e);}}
private record Delta(int changed,int brighter,int darker,long total){}
@@ -1,208 +0,0 @@
package fr.koka.sanctuary.client.shader;
import static fr.koka.sanctuary.client.shader.PixelShadows122ClientChecks.*;
import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext;
import net.fabricmc.fabric.api.client.gametest.v1.context.TestServerContext;
import net.minecraft.core.BlockPos;
import net.minecraft.world.level.block.Blocks;
/** Native screenshots on a new development world; source-colour and transparent receiver checks. */
final class PixelReflectionsClientChecks {
static void run(ClientGameTestContext c,TestServerContext server){
c.runOnClient(m->{VanillaLight.setEnabled(true);VanillaLight.setSaturation(100);VanillaLight.setPbr(true);VanillaLight.setPbrIntensity(80);
VanillaLight.setNaturalWater(true);VanillaLight.setWaterIntensity(100);VanillaLight.setWaterBlendRadius(0);VanillaLight.setSsr(false);VanillaLight.setSsrIntensity(100);VanillaLight.setColoredLights(false);VanillaLight.setSsgi(false);
VanillaLight.setSunshafts(false);VanillaLight.setLensFlare(false);VanillaLight.setBloom(false);VanillaLight.setOreEmission(false);
VanillaLight.setEdgeHighlights(false);VanillaLight.setPixelShadows(false);});
server.runOnServer(s->{
command(s,"time set 6000");
for(int x=-18;x<=18;x++)for(int z=-4;z<=24;z++){
for(int y=65;y<=85;y++)s.overworld().setBlockAndUpdate(new BlockPos(x,y,z),Blocks.AIR.defaultBlockState());
s.overworld().setBlockAndUpdate(new BlockPos(x,64,z),Blocks.STONE.defaultBlockState());
if(z>=3&&z<=18)s.overworld().setBlockAndUpdate(new BlockPos(x,65,z),Blocks.WATER.defaultBlockState());
}
for(int x=-12;x<=12;x++)for(int y=65;y<=77-Math.abs(x)/2;y++)
s.overworld().setBlockAndUpdate(new BlockPos(x,y,19),Blocks.CONCRETE.red().defaultBlockState());
});
camera(c,server,.5,68,0,70);look(c,server,0,12);settle(c);
int analysis=c.computeOnClient(m->ProceduralPbr.waterAnalyses()),updates=c.computeOnClient(m->ProceduralPbr.waterUpdates());
c.waitTicks(20);c.runOnClient(m->check(ProceduralPbr.waterAnalyses()==analysis&&ProceduralPbr.waterUpdates()>updates,"Water normals follow native animation without reanalysis"));
var off=capture(c,"ssr-water-off");
c.runOnClient(m->VanillaLight.setSsr(true));frames(c);var on=capture(c,"ssr-water-on");
dumpLandscape(c);
long changed=changed(off,on);System.out.println("SSR_WATER_DIFF "+changed);
check(changed>100,"SSR changes water receiver pixels: "+changed);
double redSignal=colourSignal(off,on);
server.runOnServer(s->{command(s,"gamemode creative @a");var player=s.getPlayerList().getPlayers().getFirst();player.getAbilities().flying=true;player.onUpdateAbilities();});
c.waitTicks(10);
c.runOnClient(m->m.options.setCameraType(net.minecraft.client.CameraType.THIRD_PERSON_BACK));c.waitTicks(20);
c.runOnClient(m->VanillaLight.setSsr(false));c.waitTicks(8);var f5Off=capture(c,"ssr146-f5-off");
c.runOnClient(m->VanillaLight.setSsr(true));frames(c);var f5On=capture(c,"ssr146-f5-on");
check(changed(f5Off,f5On)>100,"SSR remains visible around third-person player");
int torsoChange=0,torsoPixels=0;
for(int y=f5Off.height()*55/100;y<f5Off.height()*71/100;y++)for(int x=f5Off.width()*485/1000;x<f5Off.width()*515/1000;x++){
int index=y*f5Off.width()+x;torsoPixels++;if(f5Off.pixels()[index]!=f5On.pixels()[index])torsoChange++;
}
check(torsoChange<torsoPixels*.01,"SSR does not draw through the visible F5 torso: "+torsoChange+" / "+torsoPixels);
c.runOnClient(m->m.options.setCameraType(net.minecraft.client.CameraType.FIRST_PERSON));c.waitTicks(20);
server.runOnServer(s->command(s,"gamemode spectator @a"));c.waitTicks(10);
for(int distance:new int[]{16,64,128,32}){
c.runOnClient(m->VanillaLight.setPbrDistance(distance));settle(c);frames(c);capture(c,"ssr146-distance-"+distance);
c.runOnClient(m->check(ProceduralPbr.applied()&&PixelReflections.applied(),"Both material passes share distance "+distance));
}
c.runOnClient(m->{VanillaLight.setPbrIntensity(80);VanillaLight.setSsrIntensity(20);});
camera(c,server,.5,68,10,70);settle(c);frames(c);
var solar=capture(c,"ssr146-solar-water");
int white=0;for(int pixel:solar.pixels())if((pixel>>16&255)>235&&(pixel>>8&255)>235&&(pixel&255)>235)white++;
check(white>300,"Solar reflection has a broad near-white footprint at PBR 80 / SSR 20 percent: "+white);
System.out.println("SSR146_SOLAR_WHITE pixels="+white+" width="+solar.width()+" height="+solar.height());
c.runOnClient(m->VanillaLight.setBloom(true));frames(c);capture(c,"ssr146-solar-water-bloom");
c.runOnClient(m->check(MaterialBloom.contribution()!=null,"Material light reaches bloom"));
c.runOnClient(m->VanillaLight.setBloom(false));frames(c);
c.runOnClient(m->check(MaterialBloom.contribution()==null,"Bloom OFF releases material halo"));
server.runOnServer(s->command(s,"time set 1000"));c.waitTicks(10);
var sunLook=c.computeOnClient(m->{var sun=PixelShadows.sunDirection(m.gameRenderer.gameRenderState().levelRenderState.skyRenderState.sunAngle);return new float[]{(float)Math.toDegrees(Math.atan2(-sun.x,sun.z)),(float)Math.toDegrees(Math.asin(sun.y))};});
look(c,server,sunLook[0],sunLook[1]);frames(c);capture(c,"ssr146-solar-path-low-sun");
c.runOnClient(m->VanillaLight.setBloom(true));frames(c);capture(c,"ssr146-solar-path-low-sun-bloom");
c.runOnClient(m->{VanillaLight.setBloom(false);VanillaLight.setSsrIntensity(100);});
server.runOnServer(s->command(s,"time set 6000"));c.waitTicks(10);
camera(c,server,.5,68,0,70);look(c,server,0,12);settle(c);
server.runOnServer(s->{for(int x=-12;x<=12;x++)for(int y=65;y<=77-Math.abs(x)/2;y++)s.overworld().setBlockAndUpdate(new BlockPos(x,y,19),Blocks.CONCRETE.blue().defaultBlockState());});
settle(c);c.runOnClient(m->VanillaLight.setSsr(false));c.waitTicks(8);var blueOff=capture(c,"ssr-blue-off");
c.runOnClient(m->VanillaLight.setSsr(true));frames(c);var blueOn=capture(c,"ssr-blue-on");double blueSignal=colourSignal(blueOff,blueOn);
System.out.println("SSR_SOURCE_COLOUR red="+redSignal+" blue="+blueSignal);
check(redSignal>blueSignal+3,"Water reflection follows source colour rather than animation: "+redSignal+" / "+blueSignal);
server.runOnServer(s->{for(int x=-12;x<=12;x++)for(int y=65;y<=77-Math.abs(x)/2;y++)s.overworld().setBlockAndUpdate(new BlockPos(x,y,19),Blocks.CONCRETE.red().defaultBlockState());});settle(c);
c.runOnClient(m->VanillaLight.setSsrIntensity(0));c.waitTicks(8);
check(!c.computeOnClient(m->PixelReflections.applied()||PixelReflections.allocated()),"Zero removes SSR contribution and colour allocation");
c.runOnClient(m->VanillaLight.setSsrIntensity(100));frames(c);
for(var bed:new net.minecraft.world.level.block.Block[]{Blocks.SAND,Blocks.MUD}){
server.runOnServer(s->{for(int x=-18;x<=18;x++)for(int z=3;z<=18;z++){s.overworld().setBlockAndUpdate(new BlockPos(x,63,z),Blocks.STONE.defaultBlockState());s.overworld().setBlockAndUpdate(new BlockPos(x,64,z),bed.defaultBlockState());}});
settle(c);frames(c);
c.runOnClient(m->check(NaturalWater.sample(m.level,.5,65.5,8.5).equals(bed==Blocks.SAND?NaturalWater.LIGHT:NaturalWater.POND),"Natural water profile remains independent of PBR and SSR: "+NaturalWater.sample(m.level,.5,65.5,8.5)+" above "+m.level.getBlockState(new BlockPos(0,64,8))));
capture(c,bed==Blocks.SAND?"ssr-sand-profile":"ssr-pond-profile");
}
server.runOnServer(s->{for(int x=-18;x<=18;x++)for(int z=3;z<=18;z++)s.overworld().setBlockAndUpdate(new BlockPos(x,64,z),Blocks.STONE.defaultBlockState());});settle(c);frames(c);
for(float yaw:new float[]{-25,0,25}){look(c,server,yaw,12);frames(c);capture(c,"ssr-water-angle-"+(int)yaw);}
look(c,server,0,2);frames(c);capture(c,"ssr-grazing");
look(c,server,75,12);frames(c);capture(c,"ssr-landscape-offscreen");
camera(c,server,2.5,68,0,70);look(c,server,0,12);settle(c);frames(c);capture(c,"ssr-translated");
camera(c,server,.5,68,0,70);look(c,server,0,12);settle(c);
// A hidden landscape must not leak through an opaque foreground wall.
server.runOnServer(s->{for(int x=-12;x<=12;x++)for(int y=65;y<=78;y++)s.overworld().setBlockAndUpdate(new BlockPos(x,y,18),Blocks.CONCRETE.white().defaultBlockState());});
settle(c);c.runOnClient(m->VanillaLight.setSsr(false));c.waitTicks(8);var coveredOff=capture(c,"ssr-covered-off");
c.runOnClient(m->VanillaLight.setSsr(true));frames(c);var coveredOn=capture(c,"ssr-covered-on");
double coveredSignal=colourSignal(coveredOff,coveredOn);
server.runOnServer(s->{for(int x=-12;x<=12;x++)for(int y=65;y<=77-Math.abs(x)/2;y++)s.overworld().setBlockAndUpdate(new BlockPos(x,y,19),Blocks.CONCRETE.blue().defaultBlockState());});settle(c);
c.runOnClient(m->VanillaLight.setSsr(false));c.waitTicks(8);var hiddenBlueOff=capture(c,"ssr-hidden-blue-off");
c.runOnClient(m->VanillaLight.setSsr(true));frames(c);var hiddenBlueOn=capture(c,"ssr-hidden-blue-on");
double hiddenBlueSignal=colourSignal(hiddenBlueOff,hiddenBlueOn);
check(Math.abs(coveredSignal-hiddenBlueSignal)<1.5,"Hidden landscape colour cannot leak through opaque wall: "+coveredSignal+" / "+hiddenBlueSignal);
server.runOnServer(s->{for(int x=-12;x<=12;x++){
for(int y=65;y<=78;y++)s.overworld().setBlockAndUpdate(new BlockPos(x,y,18),Blocks.AIR.defaultBlockState());
for(int y=65;y<=77-Math.abs(x)/2;y++)s.overworld().setBlockAndUpdate(new BlockPos(x,y,19),Blocks.CONCRETE.red().defaultBlockState());
}});settle(c);frames(c);
benchmark(c,"pond");
look(c,server,0,35);frames(c);benchmark(c,"water-fullscreen");look(c,server,0,12);frames(c);
// The nearest glass layer must replace the water receiver, including alpha-zero glass texels.
server.runOnServer(s->{for(int x=-7;x<=7;x++)for(int z=4;z<=14;z++)s.overworld().setBlockAndUpdate(new BlockPos(x,66,z),Blocks.GLASS.defaultBlockState());});
settle(c);capture(c,"ssr-glass-over-water");
c.runOnClient(m->VanillaLight.setSsr(false));c.waitTicks(8);var glassOff=capture(c,"ssr-glass-off");
c.runOnClient(m->VanillaLight.setSsr(true));frames(c);var glassOn=capture(c,"ssr-glass-on");
check(changed(glassOff,glassOn)>100,"Transparent glass reflects landscape");
benchmark(c,"glass");
server.runOnServer(s->{for(int x=-7;x<=7;x++)for(int z=4;z<=14;z++)s.overworld().setBlockAndUpdate(new BlockPos(x,66,z),Blocks.IRON_BLOCK.defaultBlockState());});
settle(c);c.runOnClient(m->VanillaLight.setSsr(false));c.waitTicks(8);var metalOff=capture(c,"ssr-metal-off");
c.runOnClient(m->VanillaLight.setSsr(true));frames(c);var metalOn=capture(c,"ssr-metal-on");
check(changed(metalOff,metalOn)>100,"Solid PBR reflects landscape");
look(c,server,.00002f,12.00002f);frames(c);var tiny=capture(c,"ssr-metal-tiny-motion");
check(stableMetal(metalOn,tiny),"Tiny camera movement keeps material texels stable");look(c,server,0,12);frames(c);
server.runOnServer(s->{
command(s,"fill -7 67 9 7 70 9 iron_block");
for(int x=-6;x<=6;x+=2)command(s,"setblock "+x+" 67 8 polished_andesite_stairs[facing=south]");
});settle(c);frames(c);var intersections=capture(c,"ssr146-intersections");
look(c,server,.00002f,12.00002f);frames(c);var shifted=capture(c,"ssr146-intersections-tiny-motion");
check(stableIntersections(intersections,shifted),"Block intersections remain stable during subpixel motion");
look(c,server,0,12);frames(c);
c.runOnClient(m->{VanillaLight.setPixelShadows(true);VanillaLight.setSsgi(true);VanillaLight.setColoredLights(true);VanillaLight.setBloom(true);VanillaLight.setSunshafts(true);});
frames(c);capture(c,"ssr-combined");
server.runOnServer(s->command(s,"time set 18000"));c.waitTicks(15);frames(c);capture(c,"ssr-moon");
server.runOnServer(s->command(s,"time set 6000"));c.waitTicks(15);frames(c);
c.runOnClient(m->{VanillaLight.setPixelShadows(false);VanillaLight.setSsgi(false);VanillaLight.setColoredLights(false);VanillaLight.setBloom(false);VanillaLight.setSunshafts(false);});
server.runOnServer(s->{for(int x=-7;x<=7;x++)for(int z=4;z<=14;z++)s.overworld().setBlockAndUpdate(new BlockPos(x,66,z),Blocks.STONE.defaultBlockState());});
settle(c);c.runOnClient(m->VanillaLight.setSsr(false));c.waitTicks(8);capture(c,"ssr-rough-stone-off");
c.runOnClient(m->VanillaLight.setSsr(true));frames(c);capture(c,"ssr-rough-stone-on");
// Native panes, sloped water, cascade sides and a waterlogged slab share the receiver pass.
server.runOnServer(s->{
for(int y=66;y<=69;y++)s.overworld().setBlockAndUpdate(new BlockPos(-8,y,8),Blocks.GLASS_PANE.defaultBlockState());
s.overworld().setBlockAndUpdate(new BlockPos(8,69,8),Blocks.WATER.defaultBlockState());
s.overworld().setBlockAndUpdate(new BlockPos(-8,65,10),Blocks.OAK_SLAB.defaultBlockState().setValue(net.minecraft.world.level.block.state.properties.BlockStateProperties.WATERLOGGED,true));
});settle(c);frames(c);capture(c,"ssr-flow-pane-waterlogged");
server.runOnServer(s->{command(s,"fill -3 66 6 3 69 6 glass");command(s,"fill -3 66 8 3 69 8 light_blue_stained_glass");
command(s,"setblock -4 67 6 blue_stained_glass_pane");});
settle(c);c.runOnClient(m->VanillaLight.setSsr(false));c.waitTicks(8);capture(c,"ssr-stacked-coloured-glass-off");
c.runOnClient(m->VanillaLight.setSsr(true));frames(c);capture(c,"ssr-stacked-coloured-glass-on");
camera(c,server,12,64,8,70);look(c,server,0,0);c.waitTicks(15);
c.runOnClient(m->check(!PixelReflections.allocated(),"Immersion suspends and releases SSR"));
camera(c,server,.5,68,0,70);look(c,server,0,12);settle(c);frames(c);
var reload=c.computeOnClient(net.minecraft.client.Minecraft::reloadResourcePacks);c.waitFor(m->reload.isDone()&&m.gui.overlay()==null,1200);reload.join();frames(c);capture(c,"ssr-reloaded");
int reloadedUpdates=c.computeOnClient(m->ProceduralPbr.waterUpdates());c.waitTicks(20);
c.runOnClient(m->check(ProceduralPbr.waterUpdates()>reloadedUpdates,"Reloaded atlas supplies a live native water clock"));
int w=c.computeOnClient(m->m.getWindow().getScreenWidth()),h=c.computeOnClient(m->m.getWindow().getScreenHeight());
c.runOnClient(m->m.getWindow().setWindowed(961,541));c.waitTicks(15);frames(c);capture(c,"ssr-resized");
c.runOnClient(m->m.getWindow().setWindowed(w,h));c.waitTicks(15);frames(c);
c.runOnClient(m->VanillaLight.setPbr(false));c.waitTicks(8);c.runOnClient(m->check(!PixelReflections.allocated(),"PBR OFF releases reflection resources"));
System.out.println("SSR_PASS water; transparent glass; solids; OFF/zero; motion; reload; resize");
}
private static boolean stableIntersections(Frame a,Frame b){
// The surrounding animated water is not a stability oracle for static blocks.
int changed=0,count=0;
for(int y=a.height()*30/100;y<a.height()*80/100;y++)for(int x=a.width()/4;x<a.width()*3/4;x++){
int i=y*a.width()+x,aa=a.pixels()[i],bb=b.pixels()[i];count++;
if(Math.abs((aa>>16&255)-(bb>>16&255))+Math.abs((aa>>8&255)-(bb>>8&255))+Math.abs((aa&255)-(bb&255))>12)changed++;
}
System.out.println("SSR146_INTERSECTIONS changed="+changed+" samples="+count);
return changed<count*.001;
}
private static boolean stableMetal(Frame a,Frame b){int changes=0,count=0;
for(int y=a.height()*58/100;y<a.height()*68/100;y++)for(int x=a.width()*3/10;x<a.width()*7/10;x++){
int i=y*a.width()+x,aa=a.pixels()[i],bb=b.pixels()[i];count++;
if(Math.abs((aa>>16&255)-(bb>>16&255))+Math.abs((aa>>8&255)-(bb>>8&255))+Math.abs((aa&255)-(bb&255))>12)changes++;
}return changes<count*.02;
}
private static double colourSignal(Frame a,Frame b){long total=0;int count=0;
for(int y=a.height()/2;y<a.height();y++)for(int x=a.width()/4;x<a.width()*3/4;x++){
int i=y*a.width()+x,aa=a.pixels()[i],bb=b.pixels()[i];total+=(bb>>16&255)-(aa>>16&255)-(bb&255)+(aa&255);count++;
}return total/(double)count;
}
private static void benchmark(ClientGameTestContext c,String name){
c.runOnClient(m->{m.options.enableVsync().set(false);m.options.framerateLimit().set(net.minecraft.client.Options.UNLIMITED_FRAMERATE_CUTOFF);
m.options.inactivityFpsLimit().set(net.minecraft.client.InactivityFpsLimit.MINIMIZED);m.getWindow().setWindowed(1920,1080);});
c.waitTicks(20);frames(c);
c.runOnClient(m->check(m.gameRenderer.mainRenderTarget().width==1920&&m.gameRenderer.mainRenderTarget().height==1080,"Benchmark uses 1080p"));
for(boolean on:new boolean[]{false,true}){
c.runOnClient(m->VanillaLight.setSsr(on));c.waitTicks(30);
c.runOnClient(m->fr.koka.sanctuary.gametest.ReflectionMetrics.start());c.waitTicks(80);
var result=c.computeOnClient(m->fr.koka.sanctuary.gametest.ReflectionMetrics.stop());
c.runOnClient(m->{check(ProceduralPbr.solidBytes()<=24L*1024*1024&&ProceduralPbr.transparentBytes()<=8L*1024*1024,"Mesh memory budgets");
System.out.println("SSR_MEMORY "+name+" enabled="+on+" solid="+ProceduralPbr.solidBytes()+" transparent="+ProceduralPbr.transparentBytes()+" targets="+PixelReflections.targetBytes());});
check(result[0]>=30,"Benchmark has enough frame samples");
System.out.println("SSR_BENCH "+name+" enabled="+on+" count="+result[0]+" medianMs="+result[1]+" p95Ms="+result[2]+" gpuCount="+result[3]+" gpuMedianMs="+result[4]+" gpuP95Ms="+result[5]);
}
}
private static void dumpLandscape(ClientGameTestContext c){
var done=new java.util.concurrent.CompletableFuture<Void>();
c.runOnClient(m->{
var src=PixelReflections.landscape();
var target=new com.mojang.blaze3d.pipeline.TextureTarget("SSR test source",src.getWidth(0),src.getHeight(0),com.mojang.renderpearl.api.GpuFormat.RGBA8_UNORM,null);
com.mojang.blaze3d.systems.RenderSystem.getDevice().createCommandEncoder().copyTextureToTexture(src,target.getColorTexture(),0,0,0,0,0,target.width,target.height);
net.minecraft.client.Screenshot.takeScreenshot(target,image->{try(image){image.writeToFile(java.nio.file.Path.of("screenshots/ssr-opaque-source.png"));done.complete(null);}catch(Exception e){done.completeExceptionally(e);}finally{target.destroyBuffers();}});
});c.waitFor(m->done.isDone(),200);done.join();
}
private static long changed(Frame a,Frame b){long n=0;for(int y=a.height()/2;y<a.height();y++)for(int x=0;x<a.width();x++){
int i=y*a.width()+x,aa=a.pixels()[i],bb=b.pixels()[i];
if(Math.abs((aa>>16&255)-(bb>>16&255))+Math.abs((aa>>8&255)-(bb>>8&255))+Math.abs((aa&255)-(bb&255))>8)n++;
}return n;}
private static void frames(ClientGameTestContext c){int start=c.computeOnClient(m->PixelReflections.frames());c.waitFor(m->PixelReflections.applied()&&ProceduralPbr.ready()&&PixelReflections.frames()>start+8,1200);}
private static void settle(ClientGameTestContext c){c.waitTicks(45);c.waitFor(m->m.levelRenderer.hasRenderedAllSections()&&ProceduralPbr.ready(),1200);c.waitTicks(10);}
}
@@ -151,11 +151,9 @@ public final class PixelShadows122ClientChecks implements FabricClientGameTest {
c.runOnClient(m -> check(VanillaLight.active(), "Native Sanctuary shader is active without an external engine"));
float morning = c.computeOnClient(m -> m.gameRenderer.gameRenderState().levelRenderState.skyRenderState.sunAngle);
if(Boolean.getBoolean("sanctuary.test.water144")){Water144ClientChecks.run(c,server);return;}
if(coloredOnly){if(Boolean.getBoolean("sanctuary.test.directional141Only")){DirectionalLights141ClientChecks.run(c,server);return;}ColoredLights139ClientChecks.run(c,server);if(Boolean.getBoolean("sanctuary.test.colored140"))ColoredLights140ClientChecks.run(c,server);if(Boolean.getBoolean("sanctuary.test.directional141"))DirectionalLights141ClientChecks.run(c,server);return;}
if(sunshaftOnly){Sunshafts137ClientChecks.run(c,server);return;}
if(closeupOnly){SsgiCloseup135ClientChecks.run(c,server);Pbr134ClientChecks.run(c,server);return;}
if(Boolean.getBoolean("sanctuary.test.ssr")){PixelReflectionsClientChecks.run(c,server);return;}
if(pbrOnly){Pbr134ClientChecks.run(c,server);return;}
if(ssgiOnly){Ssgi132ClientChecks.run(c,server);return;}
if(bloomOnly){Bloom130ClientChecks.run(c,server);return;}
@@ -454,7 +452,7 @@ public final class PixelShadows122ClientChecks implements FabricClientGameTest {
var path=FabricLoader.getInstance().getConfigDir().resolve("sanctuary-shaders.json");
Files.deleteIfExists(path);forgetPreferences();
check(VanillaLight.bloom()&&VanillaLight.bloomIntensity()==20&&VanillaLight.pbr()&&VanillaLight.ssgi(),"Fresh defaults: bloom20, PBR and SSGI ON");
check(VanillaLight.pbrIntensity()==80&&VanillaLight.ssrIntensity()==20&&VanillaLight.ssgiIntensity()==35,"PBR 80 and SSR 20 defaults, SSGI strength retained");
check(VanillaLight.pbrIntensity()==80&&VanillaLight.ssgiIntensity()==35,"PBR default 80, SSGI strength retained");
Files.writeString(path,"{\"pbr\":false,\"ssgi\":false,\"bloomIntensity\":73}");forgetPreferences();
check(!VanillaLight.pbr()&&!VanillaLight.ssgi()&&VanillaLight.bloomIntensity()==73,"Explicit personal toggles and intensity survive changed defaults");
Files.writeString(path,"{\"bloom\":false}");forgetPreferences();
@@ -570,7 +568,7 @@ public final class PixelShadows122ClientChecks implements FabricClientGameTest {
}
static Frame capture(ClientGameTestContext c, String name) {
if (name != null) c.takeScreenshot((Boolean.getBoolean("sanctuary.test.water144")?"sanctuary-beta144-water-":"sanctuary-beta142-shader-") + name);
if (name != null) c.takeScreenshot("sanctuary-beta142-shader-" + name);
var result = new CompletableFuture<Frame>();
c.runOnClient(m -> Screenshot.takeScreenshot(m.gameRenderer.mainRenderTarget(), image -> {
try (image) {
@@ -1,180 +0,0 @@
package fr.koka.sanctuary.client.shader;
import static fr.koka.sanctuary.client.shader.PixelShadows122ClientChecks.*;
import fr.koka.sanctuary.gametest.Water144Metrics;
import net.fabricmc.fabric.api.client.gametest.v1.context.*;
import net.minecraft.core.BlockPos;
import net.minecraft.network.chat.Component;
import net.minecraft.world.level.block.Blocks;
import java.nio.file.*;
import java.util.*;
final class Water144ClientChecks {
static void run(ClientGameTestContext c,TestServerContext server){
c.runOnClient(m->{
VanillaLight.setEnabled(true);VanillaLight.setNaturalWater(true);VanillaLight.setWaterIntensity(100);VanillaLight.setWaterBlendRadius(0);
VanillaLight.setPbr(false);VanillaLight.setSsgi(false);VanillaLight.setBloom(false);VanillaLight.setOreEmission(false);
VanillaLight.setPixelShadows(false);VanillaLight.setColoredLights(false);VanillaLight.setSunshafts(false);VanillaLight.setLensFlare(false);VanillaLight.setEdgeHighlights(false);
});
server.runOnServer(s->{
command(s,"fill 47 94 -1 96 95 33 quartz_block");
command(s,"fill 47 96 -1 47 100 33 glass");command(s,"fill 96 96 -1 96 100 33 glass");
command(s,"fill 47 96 -1 96 100 -1 glass");command(s,"fill 47 96 33 96 100 33 glass");
command(s,"fill 64 95 0 79 95 32 sand");command(s,"fill 80 95 0 95 95 32 mud");
command(s,"fill 48 96 0 95 99 32 water");
command(s,"setblock 84 96 8 seagrass");
for(int x=50;x<95;x+=4)command(s,"setblock "+x+" 96 20 gold_block");
});
camera(c,server,72,125,16,70);c.waitTicks(50);settle(c);
if(Boolean.getBoolean("sanctuary.test.water144ExtrasOnly")){
camera(c,server,88.5,96.2,8.5,70);look(c,server,0,0);c.waitTicks(20);capture(c,"pond-final-fog");
var green=c.computeOnClient(m->new org.joml.Vector4f(m.gameRenderer.gameRenderState().levelRenderState.cameraRenderState.fogData.color));
enable(c,false);var blue=c.computeOnClient(m->new org.joml.Vector4f(m.gameRenderer.gameRenderState().levelRenderState.cameraRenderState.fogData.color));capture(c,"native-final-fog");
check(green.y>green.z&&green.y<Math.max(blue.x,Math.max(blue.y,blue.z))*.8,"Olive fog stays muted after native vision adaptation");
enable(c,true);extras(c,server);return;
}
if(!Boolean.getBoolean("sanctuary.test.water144PerfOnly")){
c.runOnClient(m->{
check(NaturalWater.sample(m.level,56.5,99.1,8.5).equals(NaturalWater.CLEAR),"Constructed bed clear");
check(NaturalWater.sample(m.level,72.5,99.1,8.5).equals(NaturalWater.LIGHT),"Sand lightly tinted");
check(NaturalWater.sample(m.level,88.5,99.1,8.5).equals(NaturalWater.POND),"Mud pond");
check(NaturalWater.sample(m.level,84.5,99.1,8.5).equals(NaturalWater.POND),"Seagrass is not a floor");
check(NaturalWater.sample(m.level,88.5,110.1,8.5).equals(NaturalWater.CLEAR),"No bed in bounded search uses clear");
var boundary=NaturalWater.sample(m.level,80,99.1,8.5);check(boundary.tint()>.1&&boundary.tint()<.85,"Section boundary mixes profiles");
});
c.runOnClient(m->check(!NaturalWater.sanctuaryWorld(),"Server identifies ordinary test dimension"));
server.runOnServer(s->net.fabricmc.fabric.api.networking.v1.ServerPlayNetworking.send(s.getPlayerList().getPlayers().getFirst(),new fr.koka.sanctuary.water.WaterWorldSettings.Context(true)));
c.waitTicks(5);c.runOnClient(m->{check(NaturalWater.sanctuaryWorld()&&VanillaLight.naturalWater(),"Sanctuary context defaults ON");VanillaLight.setNaturalWater(false);});
server.runOnServer(s->net.fabricmc.fabric.api.networking.v1.ServerPlayNetworking.send(s.getPlayerList().getPlayers().getFirst(),new fr.koka.sanctuary.water.WaterWorldSettings.Context(false)));
c.waitTicks(5);c.runOnClient(m->check(VanillaLight.naturalWater(),"Ordinary explicit ON is independent of Sanctuary OFF"));
server.runOnServer(s->net.fabricmc.fabric.api.networking.v1.ServerPlayNetworking.send(s.getPlayerList().getPlayers().getFirst(),new fr.koka.sanctuary.water.WaterWorldSettings.Context(true)));
c.waitTicks(5);c.runOnClient(m->{check(!VanillaLight.naturalWater(),"Sanctuary explicit OFF survives context round trip");VanillaLight.setNaturalWater(true);});
server.runOnServer(s->net.fabricmc.fabric.api.networking.v1.ServerPlayNetworking.send(s.getPlayerList().getPlayers().getFirst(),new fr.koka.sanctuary.water.WaterWorldSettings.Context(false)));
c.waitTicks(5);
for(int radius:new int[]{2,4,8}){
c.runOnClient(m->VanillaLight.setWaterBlendRadius(radius));settle(c);
c.runOnClient(m->{
var left=NaturalWater.sample(m.level,79.5,99.1,8.5);
var right=NaturalWater.sample(m.level,80.5,99.1,8.5);
check(left.tint()>.1&&right.tint()<.85&&right.tint()>left.tint(),"Wider transition smooths both sides of section seam");
var west=new NaturalWater.Region(m.level,3,5,-1).corner(m.level,new BlockPos(79,99,8),1,0);
var east=new NaturalWater.Region(m.level,4,5,-1).corner(m.level,new BlockPos(80,99,8),0,0);
var expected=NaturalWater.sample(m.level,80,99,8);
check(Math.abs(west.tint()-east.tint())<.0002&&Math.abs(west.alpha()-east.alpha())<.0002,"Adjacent compiled sections share continuous vertex tint and alpha");
check(Math.abs(west.tint()-expected.tint())<.0002&&Math.abs(west.alpha()-expected.alpha())<.0002,"Compiled surface blend matches immersion sampling");
});
capture(c,"blend146-"+(radius*2+1));
}
c.runOnClient(m->VanillaLight.setWaterBlendRadius(0));settle(c);
enable(c,false);var off=capture(c,"surface-off");enable(c,true);var on=capture(c,"surface-on");
check(difference(off,on)>100000,"Visible surface colour and opacity change");
c.runOnClient(m->VanillaLight.setWaterIntensity(0));settle(c);var zero=capture(c,"surface-zero");
check(difference(off,zero)<difference(off,on)/4,"Zero restores native water (animated texture tolerance)");
c.runOnClient(m->VanillaLight.setWaterIntensity(100));settle(c);
camera(c,server,88.5,103,4.5,70);look(c,server,0,28);capture(c,"pond-closeup");
camera(c,server,72,125,16,70);
server.runOnServer(s->command(s,"fill 80 95 0 95 95 32 quartz_block"));c.waitTicks(30);settle(c);var changed=capture(c,"bed-changed");
check(difference(changed,on)>100000,"Changing bed four blocks below surface refreshes water");
server.runOnServer(s->command(s,"fill 80 95 0 95 95 32 mud"));c.waitTicks(25);settle(c);
server.runOnServer(s->command(s,"fill 80 103 0 95 103 12 stone"));c.waitTicks(25);settle(c);
c.runOnClient(m->check(NaturalWater.sample(m.level,88.5,99.1,8.5).equals(NaturalWater.POND),"Roof does not replace floor rule"));capture(c,"covered-pond");
server.runOnServer(s->command(s,"fill 80 103 0 95 103 12 air"));c.waitTicks(20);
for(int x:new int[]{56,72,88}){
camera(c,server,x+.5,96.2,8.5,70);look(c,server,0,0);c.waitTicks(20);capture(c,"immersed-"+x);
c.runOnClient(m->{check(m.gameRenderer.mainCamera().getFluidInCamera()==net.minecraft.world.level.material.FogType.WATER,"Camera immersed");
float expected=x==56?1:x==72?.7f:.4f;check(Math.abs(NaturalWater.fogProfile().fog()-expected)<.001,"Immersion uses corresponding profile");});
}
var pondColor=c.computeOnClient(m->new org.joml.Vector4f(m.gameRenderer.gameRenderState().levelRenderState.cameraRenderState.fogData.color));
check(pondColor.y>pondColor.z&&pondColor.y<.8f,"Olive fog avoids native neon normalization");
float pondFog=c.computeOnClient(m->m.gameRenderer.gameRenderState().levelRenderState.cameraRenderState.fogData.environmentalEnd);
enable(c,false);float nativeFog=c.computeOnClient(m->m.gameRenderer.gameRenderState().levelRenderState.cameraRenderState.fogData.environmentalEnd);
check(Math.abs(pondFog/nativeFog-.4)<.01,"Rendered underwater fog distance matches pond profile");capture(c,"immersed-native");enable(c,true);
camera(c,server,72,125,16,70);
server.runOnServer(s->command(s,"fillbiome 80 88 0 95 104 32 minecraft:ocean"));c.waitTicks(35);settle(c);
c.runOnClient(m->check(NaturalWater.sample(m.level,88.5,99.1,8.5).equals(NaturalWater.CLEAR),"Ocean overrides muddy bed"));capture(c,"ocean-over-mud");
server.runOnServer(s->command(s,"fillbiome 80 88 0 95 104 32 minecraft:plains"));c.waitTicks(30);
var reload=c.computeOnClient(net.minecraft.client.Minecraft::reloadResourcePacks);c.waitFor(m->reload.isDone()&&m.gui.overlay()==null,1200);reload.join();settle(c);capture(c,"resources-reloaded");
resourceOverride(c);
camera(c,server,400,120,16,70);c.waitTicks(30);camera(c,server,72,125,16,70);settle(c);
c.runOnClient(m->{check(NaturalWater.sample(m.level,88.5,99.1,8.5).equals(NaturalWater.POND),"Reloaded chunks keep correct profile");
VanillaLight.setNaturalWater(false);VanillaLight.setWaterIntensity(37);forgetPreferences();check(!VanillaLight.naturalWater()&&VanillaLight.waterIntensity()==37,"Water preferences persist");
VanillaLight.setNaturalWater(true);VanillaLight.setWaterIntensity(100);
for(String locale:new String[]{"fr_fr","en_us"}){m.getLanguageManager().setSelected(locale);m.getLanguageManager().onResourceManagerReload(m.getResourceManager());
for(String key:new String[]{"natural_water","natural_water_help","water_intensity","water_intensity_help","water_intensity_value"}){String full="sanctuary.shaders."+key;check(!Component.translatable(full,100).getString().equals(full),"FR/EN "+full);}}
});settle(c);
}
// All effects enabled; fixed inherited test quality (shadows 32 blocks, PBR 50%) for A/B comparison.
c.runOnClient(m->{VanillaLight.setPbr(true);VanillaLight.setSsgi(true);VanillaLight.setBloom(true);VanillaLight.setOreEmission(true);
VanillaLight.setPixelShadows(true);VanillaLight.setColoredLights(true);VanillaLight.setSunshafts(true);VanillaLight.setLensFlare(true);VanillaLight.setEdgeHighlights(true);});
c.waitTicks(80);capture(c,"combined");
if(!Boolean.getBoolean("sanctuary.test.water144FunctionalOnly"))benchmark(c,server);
extras(c,server);
System.out.println("WATER144_PASS surface, immersion, beds, ocean, section seam, roof, plants, invalidation, reload, preferences, FR/EN and combined rendering");
}
private static void enable(ClientGameTestContext c,boolean value){c.runOnClient(m->VanillaLight.setNaturalWater(value));settle(c);}
private static void settle(ClientGameTestContext c){c.waitTicks(8);c.waitFor(m->m.levelRenderer.hasRenderedAllSections(),1200);c.waitTicks(20);}
private static long difference(Frame a,Frame b){long value=0;for(int i=0;i<a.pixels().length;i++)value+=channelDifference(a.pixels()[i],b.pixels()[i]);return value;}
private static void extras(ClientGameTestContext c,TestServerContext server){
server.runOnServer(s->{
for(int x:new int[]{100,108}){
command(s,"fill "+x+" 95 0 "+(x+4)+" 104 16 glass");
command(s,"fill "+(x+1)+" 96 1 "+(x+3)+" 103 15 air");
command(s,"fill "+(x+1)+" 95 1 "+(x+3)+" 95 15 mud");
}
command(s,"setblock 102 102 2 water");command(s,"setblock 110 102 2 lava");
command(s,"setblock 56 99 8 oak_slab[waterlogged=true]");
});
c.waitTicks(80);camera(c,server,106,103,25,70);look(c,server,179,20);settle(c);
c.runOnClient(m->{
var flow=m.level.getFluidState(new BlockPos(102,101,2));check(flow.is(net.minecraft.tags.FluidTags.WATER)&&!flow.isSource(),"Real falling water is rendered");
check(m.level.getFluidState(new BlockPos(110,101,2)).is(net.minecraft.tags.FluidTags.LAVA),"Lava cascade remains lava");
check(m.level.getBlockState(new BlockPos(56,99,8)).getValue(net.minecraft.world.level.block.state.properties.BlockStateProperties.WATERLOGGED),"Waterlogged slab preserved");
});
enable(c,false);var off=capture(c,"cascades-off");enable(c,true);var on=capture(c,"cascades-on");
check(difference(off,on)>1000,"Waterfall responds to water option");
int lava=0,stable=0;for(int i=0;i<off.pixels().length;i++){int a=off.pixels()[i];int r=a>>16&255,g=a>>8&255,b=a&255;
if(r>160&&r>g*1.3&&b<90){lava++;if(channelDifference(a,on.pixels()[i])<70)stable++;}}
check(lava>100&&stable>lava*.8,"Lava colours remain native (animation tolerance)");
camera(c,server,56.5,102,8.5,70);capture(c,"waterlogged-slab");
System.out.println("WATER144_EXTRAS_PASS falling water, unchanged lava and waterlogged slab");
}
private static void resourceOverride(ClientGameTestContext c){
var directory=c.computeOnClient(m->m.getResourcePackDirectory().resolve("water144-fixture"));
try{
Files.createDirectories(directory.resolve("assets/sanctuary"));
var mod=net.fabricmc.loader.api.FabricLoader.getInstance().getModContainer("sanctuary").orElseThrow();
Files.copy(mod.findPath("resourcepacks/template/pack.mcmeta").orElseThrow(),directory.resolve("pack.mcmeta"),StandardCopyOption.REPLACE_EXISTING);
Files.writeString(directory.resolve("assets/sanctuary/water_materials.json"),"{\"light\":[],\"pond\":[\"minecraft:quartz_block\"]}");
}catch(Exception e){throw new AssertionError(e);}
c.runOnClient(m->{var repo=m.getResourcePackRepository();repo.reload();check(repo.addPack("file/water144-fixture"),"Custom materials pack discovered");});
var future=c.computeOnClient(net.minecraft.client.Minecraft::reloadResourcePacks);c.waitFor(m->future.isDone()&&m.gui.overlay()==null,1200);future.join();settle(c);
c.runOnClient(m->{check(NaturalWater.sample(m.level,56.5,99.1,8.5).equals(NaturalWater.POND),"Resource pack replaces material family");
check(NaturalWater.sample(m.level,88.5,99.1,8.5).equals(NaturalWater.CLEAR),"Replacement removes bundled assignment");});
capture(c,"custom-materials");
c.runOnClient(m->m.getResourcePackRepository().removePack("file/water144-fixture"));
var restore=c.computeOnClient(net.minecraft.client.Minecraft::reloadResourcePacks);c.waitFor(m->restore.isDone()&&m.gui.overlay()==null,1200);restore.join();settle(c);
c.runOnClient(m->check(NaturalWater.sample(m.level,88.5,99.1,8.5).equals(NaturalWater.POND),"Disabling custom pack restores defaults"));
}
private static void benchmark(ClientGameTestContext c,TestServerContext s){
int oldLimit=c.computeOnClient(m->m.options.framerateLimit().get());
var oldInactivity=c.computeOnClient(m->m.options.inactivityFpsLimit().get());
c.runOnClient(m->{m.options.framerateLimit().set(net.minecraft.client.Options.UNLIMITED_FRAMERATE_CUTOFF);m.options.inactivityFpsLimit().set(net.minecraft.client.InactivityFpsLimit.MINIMIZED);});
var results=new LinkedHashMap<String,Object>();
try {
for(String scene:new String[]{"mixed","large","moving"}){
if(scene.equals("large")){s.runOnServer(server->{command(server,"fill 31 94 -17 112 100 -17 glass");command(server,"fill 31 94 48 112 100 48 glass");
command(server,"fill 31 94 -17 31 100 48 glass");command(server,"fill 112 94 -17 112 100 48 glass");
command(server,"fill 32 93 -16 111 93 47 quartz_block");command(server,"fill 32 94 -16 111 94 47 sand");command(server,"fill 32 95 -16 111 99 47 water");});c.waitTicks(50);}
camera(c,s,72,125,16,70);settle(c);
for(boolean enabled:new boolean[]{false,true,true,false,true,false,false,true}){
camera(c,s,72,125,16,70);enable(c,enabled);c.waitTicks(80);c.runOnClient(m->Water144Metrics.start());
if(scene.equals("moving")){
for(int i=0;i<200;i++){final int step=i;c.runOnClient(m->{m.player.setPos(48+step*.6,125,16);m.player.setDeltaMovement(net.minecraft.world.phys.Vec3.ZERO);});c.waitTicks(1);}
}else c.waitTicks(200);
var values=c.computeOnClient(m->Water144Metrics.stop());String key=scene+"-"+enabled+"-"+results.size();results.put(key,values);
System.out.println("WATER144_PERF "+key+" "+Arrays.toString(values));
}
}
try{Files.writeString(Path.of("water144-performance.json"),new com.google.gson.GsonBuilder().setPrettyPrinting().create().toJson(results));}catch(Exception e){throw new AssertionError(e);}
}finally{c.runOnClient(m->{m.options.framerateLimit().set(oldLimit);m.options.inactivityFpsLimit().set(oldInactivity);});}
}
}
@@ -1,13 +0,0 @@
package fr.koka.sanctuary.gametest;
import java.util.ArrayList;
public final class ReflectionMetrics {
private static final ArrayList<Long> samples=new ArrayList<>(),gpu=new ArrayList<>();
public static volatile boolean recording;
public static synchronized void add(long time){if(recording)samples.add(time);}
public static synchronized void gpu(long ns){if(recording)gpu.add(ns);}
public static synchronized void start(){samples.clear();gpu.clear();recording=true;}
public static synchronized double[] stop(){recording=false;var a=samples.stream().mapToLong(Long::longValue).sorted().toArray();
if(a.length==0)throw new AssertionError("No frame timing samples");
var g=gpu.stream().mapToLong(Long::longValue).sorted().toArray();
return new double[]{a.length,a[a.length/2]/1e6,a[Math.min(a.length-1,(int)(a.length*.95))]/1e6,g.length,g.length==0?-1:g[g.length/2]/1e6,g.length==0?-1:g[Math.min(g.length-1,(int)(g.length*.95))]/1e6};}
}
@@ -1,11 +0,0 @@
package fr.koka.sanctuary.gametest;
import java.util.*;
public final class Water144Metrics {
public static volatile boolean recording;
private static final List<Long> frames=new ArrayList<>(),sections=new ArrayList<>();
public static synchronized void frame(long ns){if(recording)frames.add(ns);}
public static synchronized void section(long ns){if(recording)sections.add(ns);}
public static synchronized void start(){frames.clear();sections.clear();recording=true;}
public static synchronized double[] stop(){recording=false;return new double[]{frames.size(),quantile(frames,.5),quantile(frames,.95),sections.size(),quantile(sections,.5),quantile(sections,.95)};}
private static double quantile(List<Long> values,double q){if(values.isEmpty())return 0;var sorted=values.stream().mapToLong(Long::longValue).sorted().toArray();return sorted[Math.min(sorted.length-1,(int)(sorted.length*q))]/1e6;}
}
@@ -1,14 +0,0 @@
package fr.koka.sanctuary.gametest.mixin;
import fr.koka.sanctuary.gametest.ReflectionMetrics;
import net.minecraft.client.Minecraft;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.Unique;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Inject;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
@Mixin(Minecraft.class)
abstract class ReflectionFrameProbeMixin {
@Unique private long sanctuary$reflectionStart;
@Inject(method="renderFrame",at=@At("HEAD")) private void begin(boolean tick,CallbackInfo ci){if(ReflectionMetrics.recording)sanctuary$reflectionStart=System.nanoTime();}
@Inject(method="renderFrame",at=@At("RETURN")) private void end(boolean tick,CallbackInfo ci){if(ReflectionMetrics.recording)ReflectionMetrics.add(System.nanoTime()-sanctuary$reflectionStart);}
}
@@ -1,24 +0,0 @@
package fr.koka.sanctuary.gametest.mixin;
import com.mojang.blaze3d.systems.TimerQuery;
import fr.koka.sanctuary.client.shader.PixelReflections;
import fr.koka.sanctuary.gametest.ReflectionMetrics;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.Unique;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Inject;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
@Mixin(value=PixelReflections.class,remap=false)
abstract class ReflectionGpuProbeMixin {
@Unique private static TimerQuery sanctuary$query;
@Unique private static boolean sanctuary$started;
@Inject(method="render",at=@At("HEAD")) private static void begin(CallbackInfo ci){
sanctuary$started=false;
if(!ReflectionMetrics.recording){if(sanctuary$query!=null){sanctuary$query.close();sanctuary$query=null;}return;}
if(sanctuary$query==null)sanctuary$query=new TimerQuery();
if(sanctuary$query.getStatus()==TimerQuery.Status.NOT_RECORDING){
long previous=sanctuary$query.get();if(previous>0)ReflectionMetrics.gpu(previous);
sanctuary$query.beginProfile();sanctuary$started=true;
}
}
@Inject(method="render",at=@At("RETURN")) private static void end(CallbackInfo ci){if(sanctuary$started)sanctuary$query.endProfile();}
}
@@ -1,12 +0,0 @@
package fr.koka.sanctuary.gametest.mixin;
import fr.koka.sanctuary.gametest.Water144Metrics;
import net.minecraft.client.Minecraft;
import org.spongepowered.asm.mixin.*;
import org.spongepowered.asm.mixin.injection.*;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
@Mixin(Minecraft.class)
abstract class Water144FrameProbeMixin {
@Unique private long sanctuary$start;
@Inject(method="renderFrame",at=@At("HEAD")) private void start(boolean tick,CallbackInfo ci){if(Water144Metrics.recording)sanctuary$start=System.nanoTime();}
@Inject(method="renderFrame",at=@At("RETURN")) private void end(boolean tick,CallbackInfo ci){if(Water144Metrics.recording)Water144Metrics.frame(System.nanoTime()-sanctuary$start);}
}
@@ -1,17 +0,0 @@
package fr.koka.sanctuary.gametest.mixin;
import fr.koka.sanctuary.gametest.Water144Metrics;
import com.llamalad7.mixinextras.injector.wrapmethod.WrapMethod;
import com.llamalad7.mixinextras.injector.wrapoperation.Operation;
import net.minecraft.client.renderer.chunk.*;
import net.minecraft.client.renderer.SectionBufferBuilderPack;
import net.minecraft.core.SectionPos;
import com.mojang.blaze3d.vertex.VertexSorting;
import org.spongepowered.asm.mixin.Mixin;
@Mixin(SectionCompiler.class)
abstract class Water144SectionProbeMixin {
@WrapMethod(method="compile")
private SectionCompiler.Results compile(SectionPos pos,RenderSectionRegion region,VertexSorting sorting,SectionBufferBuilderPack buffers,Operation<SectionCompiler.Results> original){
boolean record=Water144Metrics.recording;long start=record?System.nanoTime():0;
try{return original.call(pos,region,sorting,buffers);}finally{if(record)Water144Metrics.section(System.nanoTime()-start);}
}
}
@@ -10,8 +10,6 @@
"defaultRequire": 1
},
"client": [
"ReflectionFrameProbeMixin",
"ReflectionGpuProbeMixin",
"Colored139LightmapProbeMixin",
"Fourneau094ParticleProbeMixin",
"Tooltip093FontProbeMixin",
@@ -21,8 +19,6 @@
"Atlas026TextureProbeMixin",
"Inventory013MerchantAccess",
"Dynamic030TerrainProbeMixin",
"Star040RenderProbeMixin",
"Water144FrameProbeMixin",
"Water144SectionProbeMixin"
"Star040RenderProbeMixin"
]
}
@@ -71,7 +71,6 @@ public final class SanctuaryMod implements ModInitializer {
fr.koka.sanctuary.notifications.DiscoveryNotifications.register();
fr.koka.sanctuary.realtime.RealtimeService.register();
fr.koka.sanctuary.weather.WeatherService.register();
fr.koka.sanctuary.water.WaterWorldSettings.register();
fr.koka.sanctuary.plans.PlanService.register();
fr.koka.sanctuary.plans.Statuary.register();
fr.koka.sanctuary.food.FoodSync.register();
@@ -12,13 +12,20 @@ import net.minecraft.client.gui.screens.Screen;
import net.minecraft.client.renderer.RenderPipelines;
import net.minecraft.resources.Identifier;
import net.minecraft.network.chat.Component;
import net.minecraft.network.chat.FontDescription;
import net.minecraft.network.chat.Style;
import net.minecraft.util.FormattedCharSequence;
import net.minecraft.ChatFormatting;
/** Configuration-stage identity and starter slots. Closing cancels connection, never the saved offer. */
public final class HelloWorldScreen extends Screen {
private static final FontDescription GALACTIC = new FontDescription.Resource(Identifier.withDefaultNamespace("alt"));
private static final Style GALACTIC_STYLE = Style.EMPTY.withFont(GALACTIC);
private ProgressionPayloads.Hello offer;private final Runnable disconnect;
private EditBox bio,familiarName;private int color=-1;private Button submit;private boolean waiting,identityLocked;
private String draft="",starter="",nameDraft="";private List<StarterCompanions.Choice> starterChoices=List.of();
private String draft="",starter="",nameDraft="",codeDraft="",codeStatus="",lastChecked="";
private boolean accessRequired,accessPending,admitting;private EditBox codeBox;
private List<StarterCompanions.Choice> starterChoices=List.of();
private final List<Button> colors=new ArrayList<>(),starters=new ArrayList<>();
private int panelX,panelY,panelWidth,bioLabelY,colorLabelY,starterLabelY,nameLabelY;private boolean compact;
public HelloWorldScreen(ProgressionPayloads.Hello offer,Runnable disconnect){super(ProgressionService.label("hello_title"));this.offer=offer;this.disconnect=disconnect;}
@@ -29,18 +36,29 @@ public final class HelloWorldScreen extends Screen {
if(identityLocked){draft=welcome.bio();color=offer.colors().getFirst();}
if(starterChoices.stream().noneMatch(c->c.mob().equals(starter)))starter="";
}
public void accept(StarterPayloads.Welcome welcome){if(bio!=null)draft=bio.getValue();load(welcome);waiting=false;if(!offer.colors().contains(color))color=-1;rebuildWidgets();}
public void accept(ProgressionPayloads.Hello offer){this.offer=offer;waiting=false;if(bio!=null)draft=bio.getValue();if(!offer.colors().contains(color))color=-1;rebuildWidgets();}
public void prepareAccess(boolean pending){accessRequired=true;accessPending=pending;if(pending)codeStatus="code_recognized";}
public void requireAccess(boolean pending){boolean changed=!accessRequired||accessPending!=pending;prepareAccess(pending);if(changed&&minecraft!=null&&minecraft.gui.screen()==this){keepDrafts();rebuildWidgets();}}
public void acceptCode(String code,String reason){if(admitting)return;if(!AccessCodes.normalize(code).equals(AccessCodes.normalize(codeDraft))){if(AccessCodes.validFormat(AccessCodes.normalize(codeDraft)))sendCheck(AccessCodes.normalize(codeDraft));return;}codeStatus=reason;waiting=false;updateSelection();}
public void accept(StarterPayloads.Welcome welcome){keepDrafts();load(welcome);waiting=false;admitting=false;noteCodeError();if(!offer.colors().contains(color))color=-1;rebuildWidgets();}
public void accept(ProgressionPayloads.Hello offer){keepDrafts();this.offer=offer;waiting=false;admitting=false;noteCodeError();if(!offer.colors().contains(color))color=-1;rebuildWidgets();}
private void keepDrafts(){if(bio!=null)draft=bio.getValue();if(codeBox!=null)codeDraft=codeBox.getValue();if(familiarName!=null)nameDraft=familiarName.getValue();}
private void noteCodeError(){if(offer.error().startsWith("code_"))codeStatus=offer.error();}
@Override protected void init(){
boolean naming=!starterChoices.isEmpty();compact=naming&&height<278;
panelWidth=Math.min(520,width-20);panelX=(width-panelWidth)/2;panelY=Math.max(0,(height-(naming&&!compact?270:238))/2);colors.clear();starters.clear();familiarName=null;
bioLabelY=panelY+(compact?37:44);colorLabelY=panelY+(compact?71:81);starterLabelY=panelY+(compact?145:177);nameLabelY=starterLabelY+36;
boolean naming=!starterChoices.isEmpty();int shift=accessRequired?36:0;compact=naming&&height<278+shift;
panelWidth=Math.min(520,width-20);panelX=(width-panelWidth)/2;panelY=Math.max(0,(height-((naming&&!compact?270:238)+shift))/2);colors.clear();starters.clear();familiarName=null;codeBox=null;
bioLabelY=panelY+(compact?37:44)+shift;colorLabelY=panelY+(compact?71:81)+shift;starterLabelY=panelY+(compact?145:177)+shift;nameLabelY=starterLabelY+36;
var skin=addRenderableWidget(new PlayerSkinWidget(64,145,minecraft.getEntityModels(),minecraft.getSkinManager().createLookup(minecraft.getGameProfile(),false)));skin.setPosition(panelX,panelY+35);
int right=panelX+72,w=panelWidth-72;
bio=addRenderableWidget(new EditBox(font,right,panelY+(compact?48:57),w,18,ProgressionService.label("bio")));bio.setMaxLength(256);bio.setHint(ProgressionService.label("bio_hint"));bio.setValue(draft);bio.setResponder(value->draft=value);
if(accessRequired){
codeBox=addRenderableWidget(new EditBox(font,right,panelY+(compact?48:57),w,18,ProgressionService.label("code")));
codeBox.setMaxLength(32);codeBox.setHint(Component.translatable("sanctuary.progression.code_hint").withStyle(style -> style.withFont(GALACTIC)));codeBox.setValue(codeDraft);
codeBox.addFormatter((text,offset) -> FormattedCharSequence.forward(text, GALACTIC_STYLE));
codeBox.setResponder(value->{codeDraft=value;if(accessPending){updateSelection();return;}String normalized=AccessCodes.normalize(value);if(!AccessCodes.validFormat(normalized)){codeStatus=normalized.isEmpty()?"":"code_malformed";lastChecked="";updateSelection();return;}if(!normalized.equals(lastChecked))sendCheck(normalized);else updateSelection();});
}
bio=addRenderableWidget(new EditBox(font,right,panelY+(compact?48:57)+shift,w,18,ProgressionService.label("bio")));bio.setMaxLength(256);bio.setHint(ProgressionService.label("bio_hint"));bio.setValue(draft);bio.setResponder(value->draft=value);
int cell=w/4;
for(int i=0;i<offer.colors().size();i++){
final int rgb=offer.colors().get(i);int rowHeight=compact?12:16;Button button=addRenderableWidget(new ColorButton(right+(i%4)*cell,panelY+(compact?82:94)+(i/4)*rowHeight,cell-2,rowHeight,rgb,i+1,b->{color=rgb;updateSelection();}));
final int rgb=offer.colors().get(i);int rowHeight=compact?12:16;Button button=addRenderableWidget(new ColorButton(right+(i%4)*cell,panelY+(compact?82:94)+shift+(i/4)*rowHeight,cell-2,rowHeight,rgb,i+1,b->{color=rgb;updateSelection();}));
button.setTooltip(Tooltip.create(InhabitantColorNames.label(rgb)));colors.add(button);
}
for(int i=0;i<starterChoices.size();i++)starters.add(addRenderableWidget(new StarterSlot(right+i*28,starterLabelY+11,starterChoices.get(i))));
@@ -49,10 +67,16 @@ public final class HelloWorldScreen extends Screen {
familiarName.setMaxLength(StarterCompanions.NAME_LIMIT);familiarName.setHint(Component.translatable("sanctuary.starter.name_hint"));familiarName.setValue(nameDraft);
familiarName.setResponder(value->{nameDraft=value;updateSelection();});
}
int actionsY=panelY+(naming&&!compact?248:216);
int actionsY=panelY+(naming&&!compact?248:216)+shift;
addRenderableWidget(Button.builder(ProgressionService.label("cancel"),b->onClose()).bounds(panelX,actionsY,90,20).build());
submit=addRenderableWidget(Button.builder(ProgressionService.label("enter"),b->{
if(waiting||color<0||(!starterChoices.isEmpty()&&(starter.isEmpty()||!StarterCompanions.validName(nameDraft))))return;
if(accessRequired){
if(!(accessPending||"code_recognized".equals(codeStatus)))return;
admitting=true;waiting=true;updateSelection();
ClientConfigurationNetworking.send(new AccessPayloads.Admit(bio.getValue(),color,starterChoices.isEmpty()?"":starter,nameDraft,accessPending||codeBox==null?"":codeBox.getValue()));
return;
}
if(!starterChoices.isEmpty()&&!nameDraft.isBlank()&&!ClientConfigurationNetworking.canSend(StarterPayloads.NamedCreate.TYPE)){
offer=new ProgressionPayloads.Hello(offer.name(),offer.colors(),"starter_name_version_required");return;
}
@@ -62,9 +86,12 @@ public final class HelloWorldScreen extends Screen {
else ClientConfigurationNetworking.send(new StarterPayloads.Create(bio.getValue(),color,starter));
}).bounds(panelX+96,actionsY,panelWidth-96,20).build());updateSelection();
}
private void sendCheck(String normalized){lastChecked=normalized;codeStatus="code_checking";updateSelection();if(ClientConfigurationNetworking.canSend(AccessPayloads.Check.TYPE))ClientConfigurationNetworking.send(new AccessPayloads.Check(normalized));}
private void updateSelection(){
if(submit!=null)submit.active=!waiting&&color>=0&&(starterChoices.isEmpty()||(!starter.isEmpty()&&StarterCompanions.validName(nameDraft)));
boolean codeOk=!accessRequired||accessPending||"code_recognized".equals(codeStatus);
if(submit!=null)submit.active=!waiting&&color>=0&&(starterChoices.isEmpty()||(!starter.isEmpty()&&StarterCompanions.validName(nameDraft)))&&codeOk;
if(familiarName!=null)familiarName.setEditable(!waiting);
if(codeBox!=null)codeBox.setEditable(!waiting&&!accessPending);
if(bio!=null)bio.setEditable(!waiting&&!identityLocked);colors.forEach(b->b.active=!waiting&&!identityLocked);starters.forEach(b->b.active=!waiting);
}
@Override public void extractBackground(GuiGraphicsExtractor g,int mx,int my,float a){
@@ -73,13 +100,19 @@ public final class HelloWorldScreen extends Screen {
@Override public void extractRenderState(GuiGraphicsExtractor g,int mx,int my,float a){
super.extractRenderState(g,mx,my,a);g.centeredText(font,title,width/2,panelY+7,0xFFFFFFFF);
g.text(font,Component.literal(offer.name()).withStyle(s->color<0?s:s.withColor(color)),panelX+72,panelY+(compact?24:29),0xFFFFFFFF);
if(accessRequired){
String codeKey=codeStatus.isEmpty()?"code":codeStatus;
int codeColor="code_recognized".equals(codeStatus)?0xFF88CC88:codeStatus.isEmpty()?0xFFBBBBBB:0xFFFFFFAA;
g.text(font,ProgressionService.label(codeKey),panelX+72,panelY+(compact?37:44),codeColor);
}
g.text(font,ProgressionService.label("bio"),panelX+72,bioLabelY,0xFFBBBBBB);g.text(font,ProgressionService.label("choose_color"),panelX+72,colorLabelY,0xFFBBBBBB);
String status=waiting?"joining":offer.error();
String shown=offer.error().startsWith("code_")?"":offer.error();
String status=waiting?"joining":shown;
if(!starterChoices.isEmpty()){
g.text(font,status.isEmpty()?Component.translatable("sanctuary.starter.slots"):ProgressionService.label(status),panelX+72,starterLabelY,status.isEmpty()?0xFFBBBBBB:0xFFFFFFAA);
g.text(font,Component.translatable("sanctuary.starter.name"),panelX+72,nameLabelY,0xFFBBBBBB);
}
else if(!status.isEmpty())g.centeredText(font,ProgressionService.label(status),width/2,panelY+204,0xFFFFFFAA);
else if(!status.isEmpty())g.centeredText(font,ProgressionService.label(status),width/2,panelY+204+(accessRequired?36:0),0xFFFFFFAA);
}
private final class ColorButton extends Button.Plain {
ColorButton(int x,int y,int w,int h,int rgb,int number,OnPress press){super(x,y,w,h,InhabitantColorNames.label(rgb).copy().withStyle(s->s.withColor(rgb)),press,ignored->ProgressionService.label("color_number",number).copy().append(": ").append(InhabitantColorNames.label(rgb)));setOverrideRenderHighlightedSprite(()->color==rgb||isHoveredOrFocused());}
@@ -11,6 +11,7 @@ import net.minecraft.client.gui.screens.Screen;
public final class ProgressionClient {
public static ProgressRecord progress;
private static boolean accessGate,accessPending;
public static boolean operator;
public static InhabitantStore.Identity identity;
public static ProgressionConfig config=ProgressionConfig.DEFAULT;
@@ -38,14 +39,21 @@ public final class ProgressionClient {
CycleClient.register();
EntityRendererRegistry.register(SanctuarySitting.SEAT,NoopRenderer::new);
ClientConfigurationNetworking.registerGlobalReceiver(ProgressionPayloads.InventoryLayout.TYPE,(payload,context) -> {});
ClientConfigurationNetworking.registerGlobalReceiver(AccessPayloads.Gate.TYPE,(payload,context)->context.client().execute(()->{
accessGate=payload.required();accessPending=payload.pending();
if(payload.required()&&context.client().gui.screen() instanceof HelloWorldScreen screen)screen.requireAccess(payload.pending());
}));
ClientConfigurationNetworking.registerGlobalReceiver(AccessPayloads.Status.TYPE,(payload,context)->context.client().execute(()->{
if(context.client().gui.screen() instanceof HelloWorldScreen screen)screen.acceptCode(payload.code(),payload.reason());
}));
ClientConfigurationNetworking.registerGlobalReceiver(StarterPayloads.Welcome.TYPE,(payload,context)->context.client().execute(()->{
if(context.client().gui.screen() instanceof HelloWorldScreen screen)screen.accept(payload);
else context.client().gui.setScreen(new HelloWorldScreen(payload,()->context.responseSender().disconnect(ProgressionService.label("cancelled"))));
else showHello(context.client(),new HelloWorldScreen(payload,()->context.responseSender().disconnect(ProgressionService.label("cancelled"))));
}));
ClientConfigurationNetworking.registerGlobalReceiver(ProgressionPayloads.Hello.TYPE,(payload,context) ->
context.client().execute(() -> {
if (context.client().gui.screen() instanceof HelloWorldScreen screen) screen.accept(payload);
else context.client().gui.setScreen(new HelloWorldScreen(payload,
else showHello(context.client(),new HelloWorldScreen(payload,
() -> context.responseSender().disconnect(ProgressionService.label("cancelled"))));
}));
ClientPlayNetworking.registerGlobalReceiver(ProgressionPayloads.Snapshot.TYPE,(payload,context) ->
@@ -91,9 +99,11 @@ public final class ProgressionClient {
if (active() && ClientPlayNetworking.canSend(ProgressionPayloads.Action.TYPE))
ClientPlayNetworking.send(new ProgressionPayloads.Action(target,progress.revision()));
}
private static void showHello(Minecraft client,HelloWorldScreen screen){if(accessGate)screen.prepareAccess(accessPending);client.gui.setScreen(screen);}
private static void reset(Minecraft client) {
if (client.player != null) ProgressEffects.clear(client.player);
identity=null;progress=null;operator=false;config=ProgressionConfig.DEFAULT;names=MobNamesConfig.DEFAULT;
accessGate=false;accessPending=false;
wasSneaking=false;pendingSit=false;lastSneak=-100;
}
}
@@ -18,7 +18,6 @@ public final class SanctuaryKnowledgeClient implements ClientModInitializer {
public static void registerClient() {
SanctuaryResourcePack.register();
fr.koka.sanctuary.client.shader.NaturalWater.register();
VortexCloudRenderer.register();
fr.koka.sanctuary.client.shader.PixelShadows.register();
fr.koka.sanctuary.client.shader.EdgeHighlights.register();
@@ -42,9 +42,8 @@ public final class Bloom {
var builder=RenderPipeline.builder().withLocation(id("pipeline/"+name)).withVertexShader("core/screenquad").withFragmentShader(id("core/"+name)).withBindGroupLayout(layout.build());
if(scene)builder.withBindGroupLayout(SCENE);
return RenderPipelines.register(builder.withPrimitiveTopology(PrimitiveTopology.TRIANGLES).withCull(false)
.withColorTargetState(new ColorTargetState(Optional.ofNullable(blend),name.equals("bloom_material_add")||name.equals("bloom_blur")||name.equals("bloom_downsample")||name.equals("lens_flare_source")?GpuFormat.RGBA16_FLOAT:GpuFormat.RGBA8_UNORM,blend==null?ColorTargetState.WRITE_ALL:ColorTargetState.WRITE_COLOR)).withDepthStencilState(Optional.empty()).build());
.withColorTargetState(new ColorTargetState(Optional.ofNullable(blend),name.equals("bloom_blur")||name.equals("bloom_downsample")||name.equals("lens_flare_source")?GpuFormat.RGBA16_FLOAT:GpuFormat.RGBA8_UNORM,blend==null?ColorTargetState.WRITE_ALL:ColorTargetState.WRITE_COLOR)).withDepthStencilState(Optional.empty()).build());
}
private static final RenderPipeline MATERIAL=screen("bloom_material_add",new BlendFunction(BlendFactor.ONE,BlendFactor.ONE,BlendFactor.ZERO,BlendFactor.ONE),false,false);
private static final RenderPipeline SKY=screen("bloom_sky",null,true,false);
private static final RenderPipeline DOWNSAMPLE=screen("bloom_downsample",null,false,false);
private static final RenderPipeline BLUR=screen("bloom_blur",null,false,true);
@@ -122,7 +121,6 @@ public final class Bloom {
}
if(halo){
screen(DOWNSAMPLE,small.getColorTextureView(),emission.getColorTextureView(),false,null);
if(MaterialBloom.contribution()!=null)screen(MATERIAL,small.getColorTextureView(),MaterialBloom.contribution(),false,null);
screen(BLUR,ping.getColorTextureView(),small.getColorTextureView(),false,horizontal);
screen(BLUR,small.getColorTextureView(),ping.getColorTextureView(),false,vertical);
}
@@ -1,42 +0,0 @@
package fr.koka.sanctuary.client.shader;
import com.mojang.blaze3d.pipeline.TextureTarget;
import com.mojang.blaze3d.systems.RenderSystem;
import com.mojang.renderpearl.api.GpuFormat;
import com.mojang.renderpearl.api.pipeline.*;
import com.mojang.renderpearl.api.textures.*;
import net.minecraft.client.Minecraft;
import net.minecraft.client.renderer.RenderPipelines;
import net.minecraft.resources.Identifier;
import org.joml.Vector4f;
import java.util.Optional;
/** Extract only the positive contribution of each material pass, before other effects. */
final class MaterialBloom {
private static final RenderPipeline EXTRACT=RenderPipelines.register(RenderPipeline.builder()
.withLocation(Identifier.parse("sanctuary:pipeline/material_bloom"))
.withVertexShader("core/screenquad").withFragmentShader(Identifier.parse("sanctuary:core/material_bloom"))
.withBindGroupLayout(BindGroupLayout.builder().withUniform("Before",UniformType.COMBINED_IMAGE_SAMPLER)
.withUniform("After",UniformType.COMBINED_IMAGE_SAMPLER).build())
.withPrimitiveTopology(PrimitiveTopology.TRIANGLES).withCull(false)
.withColorTargetState(new ColorTargetState(Optional.of(new BlendFunction(BlendFactor.ONE,BlendFactor.ONE,BlendFactor.ZERO,BlendFactor.ONE)),GpuFormat.RGBA16_FLOAT,ColorTargetState.WRITE_COLOR))
.withDepthStencilState(Optional.empty()).build());
private static TextureTarget contribution;
private static boolean ready;
private MaterialBloom(){}
static void beginFrame(){ready=false;if(!VanillaLight.bloom()||VanillaLight.bloomIntensity()==0)release();}
static void accumulate(GpuTextureView before){
if(!VanillaLight.bloom()||VanillaLight.bloomIntensity()==0)return;
var main=Minecraft.getInstance().gameRenderer.mainRenderTarget();
int w=Math.max(1,main.width/8),h=Math.max(1,main.height/8);
if(contribution==null||contribution.width!=w||contribution.height!=h){release();contribution=new TextureTarget("Sanctuary material glow",w,h,GpuFormat.RGBA16_FLOAT,null);}
try(var pass=RenderSystem.getDevice().createCommandEncoder().createRenderPass(()->"Sanctuary material glow",contribution.getColorTextureView(),ready?Optional.empty():Optional.of(new Vector4f()))){
pass.setPipeline(RenderSystem.getCompiledPipeline(EXTRACT));
var nearest=RenderSystem.getSamplerCache().getClampToEdge(FilterMode.NEAREST);
pass.setUniform("Before",before,nearest);pass.setUniform("After",main.getColorTextureView(),nearest);pass.draw(3,1,0,0);
}
ready=true;
}
static GpuTextureView contribution(){return ready?contribution.getColorTextureView():null;}
static void release(){if(contribution!=null){contribution.destroyBuffers();contribution=null;}ready=false;}
}
@@ -1,198 +0,0 @@
package fr.koka.sanctuary.client.shader;
import com.google.gson.*;
import fr.koka.sanctuary.SanctuaryMod;
import java.util.*;
import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents;
import net.fabricmc.fabric.api.resource.v1.ResourceLoader;
import net.minecraft.client.Minecraft;
import net.minecraft.client.multiplayer.ClientLevel;
import net.minecraft.client.renderer.block.BlockAndTintGetter;
import net.minecraft.core.BlockPos;
import net.minecraft.core.registries.BuiltInRegistries;
import net.minecraft.resources.Identifier;
import net.minecraft.server.packs.PackType;
import net.minecraft.server.packs.resources.ResourceManagerReloadListener;
import net.minecraft.tags.BiomeTags;
import net.minecraft.tags.FluidTags;
import net.minecraft.world.level.block.Block;
import net.minecraft.world.level.block.state.BlockState;
/** Client-only water materials; no world writes, per-frame terrain scan or additional render pass. */
public final class NaturalWater {
public static final int DEPTH=8;
public record Profile(float tint,float alpha,float fog) {
public Profile mix(Profile b,float t){if(this==b||t==0)return this;if(t==1)return b;return new Profile(tint+(b.tint-tint)*t,alpha+(b.alpha-alpha)*t,fog+(b.fog-fog)*t);}
}
public static final Profile CLEAR=new Profile(0,.55f,1),LIGHT=new Profile(.10f,.80f,.70f),POND=new Profile(.85f,1,.40f);
private static final Profile[] PROFILES={CLEAR,LIGHT,POND};
private static volatile Map<Block,Integer> materials=Map.of();
private static volatile float strength;
private static ClientLevel previousLevel;
private static boolean sanctuaryWorld;
private static volatile int blendRadius=4;
public static boolean sanctuaryWorld(){return sanctuaryWorld;}
private static Profile fogFrom=CLEAR,fogTarget=CLEAR;
private static long fogChanged;
private static boolean wasUnderwater;
private NaturalWater(){}
public static void register(){
net.fabricmc.fabric.api.client.networking.v1.ClientPlayNetworking.registerGlobalReceiver(
fr.koka.sanctuary.water.WaterWorldSettings.Context.TYPE,(payload,context)->context.client().execute(()->sanctuaryWorld=payload.sanctuary()));
net.fabricmc.fabric.api.client.networking.v1.ClientPlayConnectionEvents.DISCONNECT.register((listener,client)->sanctuaryWorld=false);
net.fabricmc.fabric.api.client.networking.v1.ClientConfigurationConnectionEvents.INIT.register((listener,client)->sanctuaryWorld=false);
ResourceLoader.get(PackType.CLIENT_RESOURCES).registerReloadListener(Identifier.parse("sanctuary:water_materials"),
(ResourceManagerReloadListener) manager->{
try(var reader=manager.getResourceOrThrow(Identifier.parse("sanctuary:water_materials.json")).openAsReader()){
var root=JsonParser.parseReader(reader).getAsJsonObject();var next=new HashMap<Block,Integer>();
for(var entry:Map.of("light",1,"pond",2).entrySet()) for(var id:root.getAsJsonArray(entry.getKey())){
var key=Identifier.parse(id.getAsString());
if(!BuiltInRegistries.BLOCK.containsKey(key))throw new IllegalArgumentException("Unknown water material "+key);
if(next.put(BuiltInRegistries.BLOCK.getValue(key),entry.getValue())!=null)throw new IllegalArgumentException("Duplicate water material "+key);
}
materials=Map.copyOf(next);
}catch(Exception e){SanctuaryMod.LOGGER.warn("Invalid water materials; using bundled defaults",e);materials=defaults();}
});
ClientTickEvents.END_CLIENT_TICK.register(client->{
float next=VanillaLight.active()&&VanillaLight.naturalWater()?VanillaLight.waterIntensity()/100f:0;
if(previousLevel!=client.level||strength!=next||blendRadius!=VanillaLight.waterBlendRadius()){previousLevel=client.level;strength=next;blendRadius=VanillaLight.waterBlendRadius();wasUnderwater=false;
if(client.level!=null)invalidateWaterSections(client);}
if(client.level==null||next==0){wasUnderwater=false;return;}
var camera=client.gameRenderer.mainCamera();
if(camera.getFluidInCamera()!=net.minecraft.world.level.material.FogType.WATER){wasUnderwater=false;return;}
var pos=camera.position();var target=sample(client.level,pos.x,pos.y,pos.z);
if(!wasUnderwater){fogFrom=fogTarget=target;fogChanged=System.nanoTime();wasUnderwater=true;}
else if(!target.equals(fogTarget)){fogFrom=fogProfile();fogTarget=target;fogChanged=System.nanoTime();}
});
}
/** Preference changes only rebuild loaded water meshes, retaining unrelated shader caches. */
private static void invalidateWaterSections(Minecraft client){
var level=client.level;
var center=BlockPos.containing(client.gameRenderer.mainCamera().position());
int radius=client.options.getEffectiveRenderDistance()+1;
for(int z=(center.getZ()>>4)-radius;z<=(center.getZ()>>4)+radius;z++)
for(int x=(center.getX()>>4)-radius;x<=(center.getX()>>4)+radius;x++){
var chunk=level.getChunkSource().getChunk(x,z,net.minecraft.world.level.chunk.status.ChunkStatus.FULL,false);
if(chunk==null)continue;
var sections=chunk.getSections();
for(int i=0;i<sections.length;i++)if(!sections[i].hasOnlyAir()
&&sections[i].getStates().maybeHas(state->state.getFluidState().is(FluidTags.WATER)))
client.levelExtractor.setSectionDirty(x,level.getSectionYFromSectionIndex(i),z);
}
}
private static Map<Block,Integer> defaults(){
var result=new HashMap<Block,Integer>();
for(String name:new String[]{"sand","red_sand","gravel","clay"})result.put(BuiltInRegistries.BLOCK.getValue(Identifier.withDefaultNamespace(name)),1);
for(String name:new String[]{"dirt","coarse_dirt","rooted_dirt","grass_block","podzol","mycelium","mud","muddy_mangrove_roots"})result.put(BuiltInRegistries.BLOCK.getValue(Identifier.withDefaultNamespace(name)),2);
return Map.copyOf(result);
}
public static float strength(){return strength;}
public static Profile fogProfile(){return fogFrom.mix(fogTarget,Math.clamp((System.nanoTime()-fogChanged)/500_000_000f,0,1));}
public static boolean ocean(ClientLevel level,BlockPos pos){return level.hasChunk(pos.getX()>>4,pos.getZ()>>4)&&level.getBiome(pos).is(BiomeTags.IS_OCEAN);}
private static int classify(java.util.function.Function<BlockPos,BlockState> read,BlockPos pos,Map<Block,Integer> table){
var cursor=pos.mutable();
for(int i=1;i<=DEPTH;i++){
cursor.set(pos.getX(),pos.getY()-i,pos.getZ());var state=read.apply(cursor);
if(state==null||state.isAir())return 0;
if(state.isSolid()||!state.getCollisionShape(net.minecraft.world.level.EmptyBlockGetter.INSTANCE,cursor).isEmpty()){
// Plants have no collision; a waterlogged solid remains a basin floor.
return table.getOrDefault(state.getBlock(),0);
}
if(!state.getFluidState().is(FluidTags.WATER))return 0;
}
return 0;
}
public static Profile sample(ClientLevel level,double x,double y,double z){
var pos=BlockPos.containing(x,y,z);if(ocean(level,pos))return CLEAR;
int bx=(int)Math.floor(x-.5),bz=(int)Math.floor(z-.5),by=pos.getY();
float dx=(float)(x-.5-bx),dz=(float)(z-.5-bz);
return live(level,bx,by,bz).mix(live(level,bx+1,by,bz),dx).mix(live(level,bx,by,bz+1).mix(live(level,bx+1,by,bz+1),dx),dz);
}
private static Profile live(ClientLevel l,int x,int y,int z){
var p=new BlockPos(x,y,z);if(!l.hasChunk(x>>4,z>>4)||ocean(l,p))return CLEAR;
float tint=0,alpha=0,fog=0;int count=0;
for(int dz=-blendRadius;dz<=blendRadius;dz++)for(int dx=-blendRadius;dx<=blendRadius;dx++){
var q=p.offset(dx,0,dz);
Profile v=!l.hasChunk(q.getX()>>4,q.getZ()>>4)||ocean(l,q)?CLEAR:PROFILES[classify(l::getBlockState,q,materials)];
tint+=v.tint;alpha+=v.alpha;fog+=v.fog;count++;
}
return new Profile(tint/count,alpha/count,fog/count);
}
/** Owned by a single native section compilation, with ocean data captured before worker submission. */
public static final class Region {
private static final int PAD=9,SIDE=34;
private final int radius=blendRadius;
private final float[][][] sums=new float[16][][];
private final int ox,oy,oz;
private final net.minecraft.world.level.biome.BiomeManager biomes;
private byte[] oceanKinds;
private byte[] profiles;
private final Map<Block,Integer> table=materials;
public final float intensity=strength;
public Region(ClientLevel level,int minX,int minY,int minZ){
ox=(minX+1)*16;oy=(minY+1)*16;oz=(minZ+1)*16;
// Copy quart holders, then keep the native seeded biome zoom on the immutable copy.
// Sampling only quart centres would misclassify the jagged edges of ocean biomes.
var copied=new java.util.ArrayList<net.minecraft.core.Holder<net.minecraft.world.level.biome.Biome>>(600);
int qx=(ox>>2)-3,qy=(oy>>2)-1,qz=(oz>>2)-3;
for(int y=0;y<6;y++)for(int z=0;z<10;z++)for(int x=0;x<10;x++)
copied.add(level.hasChunk((qx+x)>>2,(qz+z)>>2)?level.getBiomeManager().getNoiseBiomeAtQuart(qx+x,qy+y,qz+z):null);
biomes=level.getBiomeManager().withDifferentSource((x,y,z)->copied.get(((y-qy)*10+z-qz)*10+x-qx));
}
public boolean ocean(int x,int y,int z){
if(oceanKinds==null)oceanKinds=new byte[SIDE*16*SIDE];
int key=((y-oy)*SIDE+z-oz+PAD)*SIDE+x-ox+PAD;
int code=oceanKinds[key];if(code==0){var biome=biomes.getBiome(x,y,z);code=biome!=null&&biome.is(BiomeTags.IS_OCEAN)?2:1;oceanKinds[key]=(byte)code;}
return code==2;
}
private Profile profile(BlockAndTintGetter region,int x,int y,int z){
if(x<ox-PAD||x>=ox-PAD+SIDE||y<oy||y>oy+15||z<oz-PAD||z>=oz-PAD+SIDE)return CLEAR;
if(ocean(x,y,z))return CLEAR;
int key=((y-oy)*SIDE+z-oz+PAD)*SIDE+x-ox+PAD;
if(profiles==null)profiles=new byte[SIDE*16*SIDE];
int code=profiles[key];if(code==0){code=1+classify(region::getBlockState,new BlockPos(x,y,z),table);profiles[key]=(byte)code;}
return PROFILES[code-1];
}
private Profile blended(BlockAndTintGetter region,int x,int y,int z){
if(radius==0)return profile(region,x,y,z);
int layer=y-oy,stride=SIDE+1;
if(sums[layer]==null){
var sum=new float[3][stride*stride];
for(int iz=0;iz<SIDE;iz++)for(int ix=0;ix<SIDE;ix++){
var p=profile(region,ox-PAD+ix,y,oz-PAD+iz);int at=(iz+1)*stride+ix+1;
float[] values={p.tint,p.alpha,p.fog};
for(int c=0;c<3;c++)sum[c][at]=values[c]+sum[c][at-1]+sum[c][at-stride]-sum[c][at-stride-1];
}
sums[layer]=sum;
}
int left=x-ox+PAD-radius,top=z-oz+PAD-radius,right=left+radius*2+1,bottom=top+radius*2+1;
float[] values=new float[3];float area=(radius*2+1)*(radius*2+1);
for(int c=0;c<3;c++){var a=sums[layer][c];values[c]=(a[bottom*stride+right]-a[top*stride+right]-a[bottom*stride+left]+a[top*stride+left])/area;}
return new Profile(values[0],values[1],values[2]);
}
public Profile corner(BlockAndTintGetter region,BlockPos p,int dx,int dz){
if(ocean(p.getX(),p.getY(),p.getZ()))return CLEAR;
int x=p.getX()+dx-1,y=p.getY(),z=p.getZ()+dz-1;
return blended(region,x,y,z).mix(blended(region,x+1,y,z),.5f).mix(blended(region,x,y,z+1).mix(blended(region,x+1,y,z+1),.5f),.5f);
}
}
public interface RegionAccess { Region sanctuary$waterRegion(); }
public static int color(int color,int base,Profile profile,float intensity){
float tint=profile.tint*intensity;
int r=color>>16&255,g=color>>8&255,b=color&255;
// Restore directional face shading after tinting, including custom biome/resource-pack colours.
float shade=(r+g+b)/(float)Math.max(1,(base>>16&255)+(base>>8&255)+(base&255));
int rr=Math.clamp(Math.round(r*(1-tint)+120*shade*tint),0,255);
int gg=Math.clamp(Math.round(g*(1-tint)+132*shade*tint),0,255);
int bb=Math.clamp(Math.round(b*(1-tint)+72*shade*tint),0,255);
int a=Math.round((color>>>24)*(1+(profile.alpha-1)*intensity));
return a<<24|rr<<16|gg<<8|bb;
}
public static void dirty(BlockPos pos){
if(strength==0)return;
var renderer=Minecraft.getInstance().levelExtractor;
int margin=blendRadius+1;
renderer.setSectionRangeDirty((pos.getX()-margin)>>4,pos.getY()>>4,(pos.getZ()-margin)>>4,
(pos.getX()+margin)>>4,(pos.getY()+DEPTH)>>4,(pos.getZ()+margin)>>4);
}
}
@@ -1,18 +0,0 @@
package fr.koka.sanctuary.client.shader;
import java.lang.ref.WeakReference;
import java.util.Map;
import java.util.WeakHashMap;
import net.minecraft.client.renderer.texture.SpriteContents;
/** References the actual atlas clock, so pause, reload and custom frame durations stay aligned. */
public final class PbrAnimation {
public interface Clock { int sanctuary$frame(); int sanctuary$subFrame(); }
// AnimationState retains its owning sprite; both sides must be weak across atlas reloads.
private static final Map<SpriteContents,WeakReference<Clock>> clocks=new WeakHashMap<>();
private PbrAnimation(){}
public static void register(SpriteContents sprite,SpriteContents.AnimationState state){
if(state!=null)clocks.put(sprite,new WeakReference<>((Clock)state));
}
static Clock clock(SpriteContents sprite){var clock=clocks.get(sprite);return clock==null?null:clock.get();}
}
@@ -1,52 +0,0 @@
package fr.koka.sanctuary.client.shader;
import com.mojang.blaze3d.vertex.VertexConsumer;
import net.minecraft.client.Minecraft;
import net.minecraft.client.renderer.block.FluidRenderer;
import net.minecraft.client.renderer.texture.TextureAtlasSprite;
import net.minecraft.core.BlockPos;
import net.minecraft.world.level.block.state.BlockState;
/** Replays the native fluid tessellator, including slope, flow UVs and waterlogged faces. */
final class PbrFluidMesh implements VertexConsumer {
private final VertexConsumer target;
private final PbrMaterials materials;
private final TextureAtlasSprite[] sprites;
private final float[][] vertices=new float[4][5];
private final int sky;
private int cursor,quads;
private PbrFluidMesh(VertexConsumer target,PbrMaterials materials,TextureAtlasSprite[] sprites,int sky){
this.target=target;this.materials=materials;this.sprites=sprites;this.sky=sky;
}
static int emit(VertexConsumer target,PbrMaterials materials,net.minecraft.client.multiplayer.ClientLevel level,BlockPos pos,BlockState state){
var models=Minecraft.getInstance().getModelManager().getFluidStateModelSet();
var model=models.get(state.getFluidState());
var sink=new PbrFluidMesh(target,materials,new TextureAtlasSprite[]{model.stillMaterial().sprite(),model.flowingMaterial().sprite(),model.overlayMaterial()==null?null:model.overlayMaterial().sprite()},
level.getBrightness(net.minecraft.world.level.LightLayer.SKY,pos.above()));
new FluidRenderer(models).tesselate(level,pos,layer->sink,state,state.getFluidState());
return sink.quads;
}
@Override public void addVertex(float x,float y,float z,int color,float u,float v,int overlay,int light,float nx,float ny,float nz){
vertices[cursor++]=new float[]{x,y,z,u,v};
if(cursor!=4)return;
cursor=0;float cu=0,cv=0;for(var p:vertices){cu+=p[3]*.25f;cv+=p[4]*.25f;}
TextureAtlasSprite sprite=null;
for(var candidate:sprites)if(candidate!=null&&cu>=candidate.getU0()&&cu<=candidate.getU1()&&cv>=candidate.getV0()&&cv<=candidate.getV1()){sprite=candidate;break;}
if(sprite==null)return;
var slot=materials.slot(sprite,net.minecraft.world.level.block.Blocks.WATER.defaultBlockState());
if(slot.empty())return;
for(var p:vertices)target.addVertex(p[0],p[1],p[2]).setColor((Math.round(sky/15f*255)<<24)|(color&0xFFFFFF))
.setUv(p[3],p[4]).setUv3(slot.u((p[3]-sprite.getU0())/(sprite.getU1()-sprite.getU0())),slot.v((p[4]-sprite.getV0())/(sprite.getV1()-sprite.getV0()))+4);
quads++;
}
// FluidRenderer uses the packed overload above. Fail visibly if that native contract changes.
public VertexConsumer addVertex(float x,float y,float z){throw new UnsupportedOperationException("Unpacked fluid vertex");}
public VertexConsumer setColor(int r,int g,int b,int a){return this;}
public VertexConsumer setColor(int c){return this;}
public VertexConsumer setUv(float u,float v){return this;}
public VertexConsumer setUv1(int u,int v){return this;}
public VertexConsumer setUv2(int u,int v){return this;}
public VertexConsumer setUv3(float u,float v){return this;}
public VertexConsumer setNormal(float x,float y,float z){return this;}
public VertexConsumer setLineWidth(float w){return this;}
}
@@ -17,18 +17,10 @@ final class PbrMaterials implements AutoCloseable {
}
private final Map<String,Slot> slots=new HashMap<>();
private final Map<BlockState,Profile> profiles=new IdentityHashMap<>();
private final List<WaterAnimation> animations=new ArrayList<>();
private DynamicTexture atlas;private boolean dirty;private int analyses;
static int family(BlockState state){
String name=BuiltInRegistries.BLOCK.getKey(state.getBlock()).getPath();
return state.getFluidState().is(net.minecraft.tags.FluidTags.WATER)&&state.liquid()?2:
(name.equals("glass")||name.endsWith("_glass")||name.equals("glass_pane")||name.endsWith("_glass_pane"))?1:0;
}
boolean candidate(BlockState state){return !state.isAir();}
boolean candidate(BlockState state){return !state.isAir()&&!state.liquid();}
Profile profile(BlockState state){return profiles.computeIfAbsent(state,s->profile(BuiltInRegistries.BLOCK.getKey(s.getBlock()).getPath()));}
static Profile profile(String name){
if(name.equals("water"))return new Profile(.12f,0);
if(name.contains("glass"))return new Profile(.16f,0);
if(name.equals("iron_block")||name.equals("gold_block")||name.equals("netherite_block")||name.contains("copper")&&!name.contains("ore")&&!name.contains("raw"))
return new Profile(name.contains("oxidized")?.65f:.32f,name.contains("oxidized")?.35f:.85f);
if(name.contains("polished")||name.contains("glazed")||name.equals("diamond_block")||name.equals("emerald_block")||name.equals("ruby_block")||name.equals("sapphire_block"))return new Profile(.42f,0);
@@ -47,13 +39,7 @@ final class PbrMaterials implements AutoCloseable {
int fw=Math.min(image.getWidth(),sprite.contents().width()),fh=Math.min(image.getHeight(),sprite.contents().height());
int[] colors=new int[INNER*INNER];
for(int py=0;py<INNER;py++)for(int px=0;px<INNER;px++)colors[py*INNER+px]=image.getPixel(px*fw/INNER,py*fh/INNER);
int family=family(state);
// Pigmented glass can have low alpha and still carry real texture relief.
// Fully transparent glass texels keep the geometric-normal fallback.
if(family==2||family==1)for(int j=0;j<colors.length;j++)
if(family==2||(colors[j]>>>24)>0)colors[j]|=0xFF000000;
int[] pixels=generate(colors,INNER,INNER,profile);analyses++;
if(family(state)==2)animations.add(new WaterAnimation(sprite,image,profile,x,y));
for(int py=0;py<TILE;py++)for(int px=0;px<TILE;px++)atlas.getPixels().setPixel(x+px,y+py,pixels[Math.clamp(py-2,0,INNER-1)*INNER+Math.clamp(px-2,0,INNER-1)]);
}catch(java.io.IOException e){empty=true;fr.koka.sanctuary.SanctuaryMod.LOGGER.warn("Cannot analyse PBR texture {}",sprite.contents().name(),e);}
var result=new Slot(x,y,empty);slots.put(key,result);dirty=true;return result;
@@ -73,52 +59,7 @@ final class PbrMaterials implements AutoCloseable {
}
private static float height(int color,float fallback){return (color>>>24)<128?fallback:luma(color);}
private static float luma(int c){return ((c>>16&255)*.2126f+(c>>8&255)*.7152f+(c&255)*.0722f)/255;}
DynamicTexture atlas(){
if(dirty){atlas.upload();dirty=false;for(var animation:animations)animation.lastFrame=-1;}
if(atlas!=null)for(var animation:animations)animation.update(atlas);
return atlas;
}
private static final class WaterAnimation {
final TextureAtlasSprite sprite;final int x,y;final int[][] normals;final int[] frames,times;final boolean interpolate;
final NativeImage tile=new NativeImage(TILE,TILE,false);int lastFrame=-1,lastSub=-1,updates;
WaterAnimation(TextureAtlasSprite sprite,NativeImage image,Profile profile,int x,int y)throws java.io.IOException{
this.sprite=sprite;this.x=x;this.y=y;
int w=sprite.contents().width(),h=sprite.contents().height(),columns=image.getWidth()/w,count=columns*(image.getHeight()/h);
// Bound custom resource-pack memory; unsupported huge animations keep their initial normal.
boolean capped=count>1024;
if(capped){fr.koka.sanctuary.SanctuaryMod.LOGGER.warn("PBR water animation {} exceeds 1024 frames; using a fixed normal",sprite.contents().name());count=1;}
normals=new int[count][];
for(int f=0;f<count;f++){
int[] colors=new int[INNER*INNER];
for(int py=0;py<INNER;py++)for(int px=0;px<INNER;px++)colors[py*INNER+px]=image.getPixel((f%columns)*w+px*w/INNER,(f/columns)*h+py*h/INNER)|0xFF000000;
normals[f]=generate(colors,INNER,INNER,profile);
}
var metadata=net.minecraft.client.Minecraft.getInstance().getResourceManager().getResourceOrThrow(BloomMaterials.texture(sprite.contents().name()))
.metadata().getSection(net.minecraft.client.resources.metadata.animation.AnimationMetadataSection.TYPE);
var sequence=new ArrayList<Integer>();var durations=new ArrayList<Integer>();
if(metadata.isPresent()&&!capped){
var m=metadata.get();interpolate=m.interpolatedFrames();
if(m.frames().isPresent())for(var frame:m.frames().get()){
if(frame.index()>=0&&frame.index()<count&&frame.timeOr(m.defaultFrameTime())>0){sequence.add(frame.index());durations.add(frame.timeOr(m.defaultFrameTime()));}
}else for(int f=0;f<count;f++){sequence.add(f);durations.add(m.defaultFrameTime());}
}else interpolate=false;
if(sequence.isEmpty()){sequence.add(0);durations.add(1);}
frames=sequence.stream().mapToInt(Integer::intValue).toArray();times=durations.stream().mapToInt(Integer::intValue).toArray();
}
void update(DynamicTexture atlas){
var clock=PbrAnimation.clock(sprite.contents());int f=clock==null?0:Math.floorMod(clock.sanctuary$frame(),frames.length),sub=clock==null||!interpolate?0:clock.sanctuary$subFrame();
if(f==lastFrame&&sub==lastSub)return;lastFrame=f;lastSub=sub;updates++;
float t=interpolate?Math.clamp(sub/(float)times[f],0,1):0;
int[] a=normals[frames[f]],b=normals[frames[(f+1)%frames.length]];
for(int py=0;py<TILE;py++)for(int px=0;px<TILE;px++){
int i=Math.clamp(py-2,0,INNER-1)*INNER+Math.clamp(px-2,0,INNER-1);
int r=Math.round((a[i]>>16&255)*(1-t)+(b[i]>>16&255)*t),g=Math.round((a[i]>>8&255)*(1-t)+(b[i]>>8&255)*t);
tile.setPixel(px,py,(a[i]&0xFF0000FF)|(r<<16)|(g<<8));
}
com.mojang.blaze3d.systems.RenderSystem.getDevice().createCommandEncoder().writeToTexture(atlas.getTexture(),tile,0,0,x,y);
}
}
int animationUpdates(){return animations.stream().mapToInt(a->a.updates).sum();}
DynamicTexture atlas(){if(dirty){atlas.upload();dirty=false;}return atlas;}
int count(){return slots.size();}int analyses(){return analyses;}
public void close(){if(atlas!=null){atlas.close();atlas=null;}for(var animation:animations)animation.tile.close();animations.clear();slots.clear();profiles.clear();dirty=false;analyses=0;}
public void close(){if(atlas!=null){atlas.close();atlas=null;}slots.clear();profiles.clear();dirty=false;analyses=0;}
}
@@ -25,14 +25,7 @@ final class PbrTerrain implements AutoCloseable {
static final VertexFormat FORMAT=VertexFormat.builder(0).addAttribute("Position",GpuFormat.RGB32_FLOAT)
.addAttribute("Color",GpuFormat.RGBA8_UNORM).addAttribute("UV0",GpuFormat.RG32_FLOAT)
.addAttribute("UV3",GpuFormat.RG32_FLOAT).build();
private final boolean transparent;
private final long maxBytes;
PbrTerrain(){this(false);}
PbrTerrain(boolean transparent){this.transparent=transparent;maxBytes=(transparent?8L:24L)*1024*1024;}
private boolean candidate(net.minecraft.world.level.block.state.BlockState state){
return transparent ? PbrMaterials.family(state)==1||state.getFluidState().is(net.minecraft.tags.FluidTags.WATER)
: materials.candidate(state)&&!state.liquid()&&PbrMaterials.family(state)==0;
}
private static final long MAX_BYTES=24L*1024*1024;
private final Map<Long,Section> sections=new HashMap<>();
final PbrMaterials materials=new PbrMaterials();
private Build building;
@@ -48,7 +41,7 @@ final class PbrTerrain implements AutoCloseable {
long frame=mc.level.getGameTime();var active=new ArrayList<Section>();
for(var render:mc.levelRenderer.visibleSections()){
long key=render.getSectionNode();var pos=render.getRenderOrigin();
if(pos.offset(8,8,8).distToCenterSqr(camera)>Math.pow(VanillaLight.pbrDistance()+16,2))continue;
if(pos.offset(8,8,8).distToCenterSqr(camera)>48*48)continue;
var chunk=mc.level.getChunkSource().getChunk(pos.getX()>>4,pos.getZ()>>4,ChunkStatus.FULL,false);
if(chunk==null)continue;
var storage=chunk.getSection(mc.level.getSectionIndex(pos.getY()));
@@ -68,7 +61,7 @@ final class PbrTerrain implements AutoCloseable {
if(started++>=4)break;
Section next=active.stream().filter(s->s.dirty).min(Comparator.comparingDouble(s->s.origin.distToCenterSqr(camera))).orElse(null);
if(next==null)break;
if(!next.storage.maybeHas(this::candidate)){releaseEntry(next);next.dirty=false;continue;}
if(!next.storage.maybeHas(materials::candidate)){releaseEntry(next);next.dirty=false;continue;}
building=new Build(next);
}
if(!advance(building,deadline))break;
@@ -80,7 +73,7 @@ final class PbrTerrain implements AutoCloseable {
try(var mesh=done.out.buildOrThrow()){
long size=mesh.vertexBuffer().remaining();
long replaced=done.section.entry==null?0:done.section.entry.vertices.size();
if(bytes-replaced+size<=maxBytes){
if(bytes-replaced+size<=MAX_BYTES){
var buffer=RenderSystem.getDevice().createBuffer(()->"Sanctuary PBR surfaces",GpuBuffer.USAGE_VERTEX|GpuBuffer.USAGE_COPY_DST,mesh.vertexBuffer());
int count=mesh.drawState().indexCount();RenderSystem.getSequentialBuffer(PrimitiveTopology.QUADS).getBuffer(count);
replacement=new Entry(done.section.origin,buffer,count);
@@ -101,13 +94,7 @@ final class PbrTerrain implements AutoCloseable {
var parts=new ArrayList<BlockStateModelPart>();var random=RandomSource.create();
while(build.cursor<4096){
int i=build.cursor++,x=i&15,z=i>>4&15,y=i>>8;var state=build.section.storage.getBlockState(x,y,z);
if(candidate(state)){
if(transparent&&state.getFluidState().is(net.minecraft.tags.FluidTags.WATER)){
int emitted=PbrFluidMesh.emit(build.out,materials,mc.level,build.section.origin.offset(x,y,z),state);
build.quads+=emitted;
if(build.quads>=16384){build.overflow=true;return true;}
}
if(state.liquid() || (transparent&&PbrMaterials.family(state)!=1)){if(System.nanoTime()>=deadline)return build.cursor==4096;continue;}
if(materials.candidate(state)){
var pos=build.section.origin.offset(x,y,z);
parts.clear();random.setSeed(state.getSeed(pos));if(!state.liquid())models.get(state).collectParts(random,parts);
var tintSources=mc.getBlockColors().getTintSources(state);
@@ -129,7 +116,7 @@ final class PbrTerrain implements AutoCloseable {
var p=quad.position(v);float u=UVPair.unpackU(quad.packedUV(v)),tv=UVPair.unpackV(quad.packedUV(v));
float localU=(u-sprite.getU0())/(sprite.getU1()-sprite.getU0()),localV=(tv-sprite.getV0())/(sprite.getV1()-sprite.getV0());
build.out.addVertex(x+p.x()+(float)offset.x,y+p.y()+(float)offset.y,z+p.z()+(float)offset.z)
.setColor(color).setUv(u,tv).setUv3(slot.u(localU),slot.v(localV)+2*PbrMaterials.family(state));
.setColor(color).setUv(u,tv).setUv3(slot.u(localU),slot.v(localV));
}
build.quads++;
}
@@ -146,7 +133,6 @@ final class PbrTerrain implements AutoCloseable {
private void dirty(Section section){if(section!=null){section.revision++;section.dirty=true;}}
private void releaseEntry(Section section){if(section.entry!=null){bytes-=section.entry.vertices.size();section.entry.vertices.close();section.entry=null;}}
private void remove(Section section){if(building!=null&&building.section==section){building.close();building=null;}releaseEntry(section);}
long allocatedBytes(){return bytes;}
int entryCount(){return (int)visible.stream().filter(s->s.entry!=null).count();}
int completed(){return completed;}
boolean building(){return building!=null;}
@@ -1,151 +0,0 @@
package fr.koka.sanctuary.client.shader;
import com.mojang.blaze3d.buffers.Std140Builder;
import com.mojang.blaze3d.systems.RenderSystem;
import com.mojang.renderpearl.api.GpuFormat;
import com.mojang.renderpearl.api.buffers.GpuBuffer;
import com.mojang.renderpearl.api.commands.RenderPassDescriptor;
import com.mojang.renderpearl.api.pipeline.*;
import com.mojang.renderpearl.api.textures.*;
import fr.koka.sanctuary.client.panorama.PanoramaCapture;
import net.minecraft.client.Minecraft;
import net.minecraft.client.renderer.BindGroupLayouts;
import net.minecraft.client.renderer.MappableRingBuffer;
import net.minecraft.client.renderer.RenderPipelines;
import net.minecraft.client.renderer.texture.DynamicTexture;
import net.minecraft.client.renderer.texture.TextureAtlas;
import net.minecraft.resources.Identifier;
import net.minecraft.world.level.material.FogType;
import org.joml.Matrix4f;
import org.joml.Vector3f;
import org.joml.Vector4f;
import java.util.*;
/** Bounded single-bounce landscape reflection. Never samples the target being written. */
public final class PixelReflections {
private static final BindGroupLayout LAYOUT=BindGroupLayout.builder()
.withUniform("ReflectionScene",UniformType.UNIFORM_BUFFER)
.withUniform("OpaqueDepth",UniformType.COMBINED_IMAGE_SAMPLER)
.withUniform("OpaqueColor",UniformType.COMBINED_IMAGE_SAMPLER)
.withUniform("ReceiverDepth",UniformType.COMBINED_IMAGE_SAMPLER)
.withUniform("SceneColor",UniformType.COMBINED_IMAGE_SAMPLER)
.withUniform("BlockAtlas",UniformType.COMBINED_IMAGE_SAMPLER)
.withUniform("MaterialAtlas",UniformType.COMBINED_IMAGE_SAMPLER).build();
private static RenderPipeline pipeline(String name,boolean depth){
var b=RenderPipeline.builder().withLocation(Identifier.parse("sanctuary:pipeline/"+name))
.withVertexShader(Identifier.parse("sanctuary:core/pbr"))
.withFragmentShader(Identifier.parse("sanctuary:core/"+name))
.withBindGroupLayout(BindGroupLayouts.DYNAMIC_TRANSFORMS).withBindGroupLayout(LAYOUT)
.withVertexBinding(0,PbrTerrain.FORMAT).withPrimitiveTopology(PrimitiveTopology.QUADS).withCull(true);
if(depth)b.withDepthStencilState(new DepthStencilState(CompareOp.GREATER_THAN,true));
else b.withColorTargetState(new ColorTargetState(Optional.empty(),GpuFormat.RGBA8_UNORM,ColorTargetState.WRITE_COLOR)).withDepthStencilState(new DepthStencilState(CompareOp.EQUAL,false));
return RenderPipelines.register(b.build());
}
private static final RenderPipeline DEPTH=pipeline("pbr_receiver",true),REFLECT=pipeline("pixel_reflections",false);
private static GpuTexture opaqueDepth,opaqueColor;
private static GpuTextureView opaqueDepthView,opaqueColorView;
private static int width,height;
private static GpuTexture receiver;
private static GpuTextureView receiverView;
private static MappableRingBuffer uniform;
private static Matrix4f vp=new Matrix4f();
private static List<PbrTerrain.Entry> solid=List.of(),transparent=List.of();
private static DynamicTexture solidAtlas,transparentAtlas;
private static boolean captured,ready,applied;
private static int frames;
private PixelReflections(){}
public static boolean eligible(){
var mc=Minecraft.getInstance();
return mc.level!=null&&VanillaLight.active()&&VanillaLight.pbr()&&VanillaLight.pbrIntensity()>0&&!PanoramaCapture.rendering()
&&mc.gameRenderer.gameRenderState().levelRenderState.cameraRenderState.fogType==FogType.NONE
&&mc.gameRenderer.gameRenderState().levelRenderState.skyRenderState.skybox==net.minecraft.world.level.dimension.DimensionType.Skybox.OVERWORLD;
}
public static void beginFrame(){MaterialBloom.beginFrame();captured=ready=applied=false;if(!eligible())release();}
/** Called only with the opaque render pass closed, on both native transparency paths. */
public static void captureOpaque(){
if(!eligible())return;
var mc=Minecraft.getInstance();var main=mc.gameRenderer.mainRenderTarget();
var device=RenderSystem.getDevice();
int usage=GpuTexture.USAGE_RENDER_ATTACHMENT|GpuTexture.USAGE_TEXTURE_BINDING|GpuTexture.USAGE_COPY_DST|GpuTexture.USAGE_COPY_SRC;
if(opaqueDepth==null||width!=main.width||height!=main.height){
release();width=main.width;height=main.height;
opaqueDepth=device.createTexture(()->"Sanctuary opaque depth",usage,main.getDepthTexture().getFormat(),width,height,1,1);
opaqueDepthView=device.createTextureView(opaqueDepth);
receiver=device.createTexture(()->"Sanctuary nearest PBR surface",usage,main.getDepthTexture().getFormat(),width,height,1,1);
receiverView=device.createTextureView(receiver);
}
boolean enabled=VanillaLight.ssr()&&VanillaLight.ssrIntensity()>0;
if(enabled&&opaqueColor==null){
opaqueColor=device.createTexture(()->"Sanctuary reflection landscape",usage,GpuFormat.RGBA8_UNORM,width,height,1,1);
opaqueColorView=device.createTextureView(opaqueColor);
}else if(!enabled&&opaqueColor!=null){opaqueColorView.close();opaqueColor.close();opaqueColorView=null;opaqueColor=null;}
var encoder=device.createCommandEncoder();
encoder.copyTextureToTexture(main.getDepthTexture(),opaqueDepth,0,0,0,0,0,width,height);
if(enabled)encoder.copyTextureToTexture(main.getColorTexture(),opaqueColor,0,0,0,0,0,width,height);
captured=true;
}
static void prepare(Matrix4f matrix,List<PbrTerrain.Entry> solids,DynamicTexture sa,List<PbrTerrain.Entry> liquids,DynamicTexture ta){
if(!captured)return;
vp.set(matrix);solid=solids;transparent=liquids;solidAtlas=sa;transparentAtlas=ta;
if(uniform==null)uniform=new MappableRingBuffer(()->"Sanctuary reflection parameters",GpuBuffer.USAGE_UNIFORM|GpuBuffer.USAGE_MAP_WRITE,160);
uniform.rotate();try(var map=uniform.currentBuffer().map(false,true)){
Std140Builder.intoBuffer(map.data()).putMat4f(vp).putMat4f(new Matrix4f(vp).invert())
.putVec4(RenderSystem.getDevice().getDeviceInfo().isZZeroToOne()?1:0,VanillaLight.ssrIntensity()/100f,VanillaLight.pbrIntensity()/100f,VanillaLight.pbrDistance())
.putVec4(128,32,0,0);
}
int max=java.util.stream.Stream.concat(solid.stream(),transparent.stream()).mapToInt(PbrTerrain.Entry::indices).max().orElse(0);
RenderSystem.getSequentialBuffer(PrimitiveTopology.QUADS).requestIndexCount(max);
var encoder=RenderSystem.getDevice().createCommandEncoder();
try(var pass=encoder.createRenderPass(RenderPassDescriptor.builder(()->"Sanctuary nearest material")
.withDepthAttachment(receiverView,OptionalDouble.of(0)).build())){
pass.setPipeline(RenderSystem.getCompiledPipeline(DEPTH));
bind(pass,Minecraft.getInstance().gameRenderer.mainRenderTarget().getColorTextureView(),false);
draw(pass,solid,solidAtlas);draw(pass,transparent,transparentAtlas);
}
ready=true;
}
static GpuTextureView depth(){return ready?receiverView:null;}
private static void bind(com.mojang.renderpearl.api.commands.RenderPass pass,GpuTextureView scene,boolean reflection){
var nearest=RenderSystem.getSamplerCache().getClampToEdge(FilterMode.NEAREST);
pass.setUniform("ReflectionScene",uniform.currentBuffer());
pass.setUniform("OpaqueDepth",opaqueDepthView,nearest);
pass.setUniform("OpaqueColor",opaqueColorView!=null?opaqueColorView:scene,nearest);
// Never bind the receiver as a sampled texture while it is attached for depth writes.
pass.setUniform("ReceiverDepth",reflection?Minecraft.getInstance().gameRenderer.mainRenderTarget().getDepthTextureView():opaqueDepthView,nearest);
pass.setUniform("SceneColor",scene,nearest);
pass.setUniform("BlockAtlas",Minecraft.getInstance().getTextureManager().getTexture(TextureAtlas.LOCATION_BLOCKS).getTextureView(),nearest);
}
private static void draw(com.mojang.renderpearl.api.commands.RenderPass pass,List<PbrTerrain.Entry> entries,DynamicTexture atlas){
if(atlas==null||entries.isEmpty())return;
pass.setUniform("MaterialAtlas",atlas.getTextureView(),RenderSystem.getSamplerCache().getClampToEdge(FilterMode.NEAREST));
var indices=RenderSystem.getSequentialBuffer(PrimitiveTopology.QUADS);
var camera=Minecraft.getInstance().gameRenderer.gameRenderState().levelRenderState.cameraRenderState;
for(var e:entries){
pass.setIndexBuffer(indices.getBuffer(),indices.type());
var p=e.origin();var offset=new Vector3f((float)(p.getX()-camera.pos.x),(float)(p.getY()-camera.pos.y),(float)(p.getZ()-camera.pos.z));
pass.setUniform("DynamicTransforms",RenderSystem.getDynamicUniforms().writeTransform(new Matrix4f(vp).translate(offset),new Vector4f(1,1,1,1),offset,new Matrix4f()));
pass.setVertexBuffer(0,e.vertices().slice());pass.drawIndexed(e.indices(),1,0,0,0);
}
}
public static void render(){
if(!ready||!VanillaLight.ssr()||VanillaLight.ssrIntensity()==0)return;
var main=Minecraft.getInstance().gameRenderer.mainRenderTarget();
var scene=ProceduralPbr.copyScene();
try(var pass=RenderSystem.getDevice().createCommandEncoder().createRenderPass(()->"Sanctuary pixel reflections",main.getColorTextureView(),Optional.empty(),receiverView,OptionalDouble.empty())){
pass.setPipeline(RenderSystem.getCompiledPipeline(REFLECT));bind(pass,scene,true);
draw(pass,solid,solidAtlas);draw(pass,transparent,transparentAtlas);
}
MaterialBloom.accumulate(scene);
applied=true;frames++;
}
public static void release(){
if(opaqueColorView!=null){opaqueColorView.close();opaqueColorView=null;}if(opaqueColor!=null){opaqueColor.close();opaqueColor=null;}
if(opaqueDepthView!=null){opaqueDepthView.close();opaqueDepthView=null;}if(opaqueDepth!=null){opaqueDepth.close();opaqueDepth=null;}if(receiverView!=null){receiverView.close();receiverView=null;}
if(receiver!=null){receiver.close();receiver=null;}if(uniform!=null){uniform.close();uniform=null;}
solid=transparent=List.of();solidAtlas=transparentAtlas=null;captured=ready=applied=false;
}
static long targetBytes(){return (long)width*height*((opaqueDepth!=null?4:0)+(receiver!=null?4:0)+(opaqueColor!=null?4:0));}
static GpuTexture landscape(){return opaqueColor;}
static boolean applied(){return applied;}static int frames(){return frames;}
static boolean allocated(){return opaqueColor!=null;}
}
@@ -33,20 +33,19 @@ public final class ProceduralPbr {
private static final RenderPipeline PIPELINE=RenderPipelines.register(RenderPipeline.builder()
.withLocation(Identifier.parse("sanctuary:pipeline/pbr")).withVertexShader(Identifier.parse("sanctuary:core/pbr"))
.withFragmentShader(Identifier.parse("sanctuary:core/pbr")).withBindGroupLayout(BindGroupLayouts.DYNAMIC_TRANSFORMS).withBindGroupLayout(SCENE)
.withVertexBinding(0,PbrTerrain.FORMAT).withPrimitiveTopology(PrimitiveTopology.QUADS).withCull(true)
.withVertexBinding(0,PbrTerrain.FORMAT).withPrimitiveTopology(PrimitiveTopology.QUADS).withCull(false)
.withColorTargetState(new ColorTargetState(Optional.empty(),GpuFormat.RGBA8_UNORM,ColorTargetState.WRITE_COLOR))
.withDepthStencilState(new DepthStencilState(CompareOp.EQUAL,false)).build());
.withDepthStencilState(Optional.empty()).build());
private static final PbrTerrain TERRAIN=new PbrTerrain();
private static final PbrTerrain TRANSPARENT=new PbrTerrain(true);
private static final Matrix4f projection=new Matrix4f();
private static TextureTarget source;
private static MappableRingBuffer scene;
private static boolean applied;private static int frames;
private ProceduralPbr(){}
public static void captureProjection(Matrix4f matrix){projection.set(matrix);}
public static void dirtyBlock(BlockPos pos){TERRAIN.dirty(pos);TRANSPARENT.dirty(pos);}
public static void dirtyChunk(int x,int z){TERRAIN.dirtyChunk(x,z);TRANSPARENT.dirtyChunk(x,z);}
public static void dirtySection(int x,int y,int z){if(!fr.koka.sanctuary.client.DynamicLightClient.invalidatingSections()){TERRAIN.dirtySection(x,y,z);TRANSPARENT.dirtySection(x,y,z);}}
public static void dirtyBlock(BlockPos pos){TERRAIN.dirty(pos);}
public static void dirtyChunk(int x,int z){TERRAIN.dirtyChunk(x,z);}
public static void dirtySection(int x,int y,int z){if(!fr.koka.sanctuary.client.DynamicLightClient.invalidatingSections())TERRAIN.dirtySection(x,y,z);}
public static void render(){
applied=false;var mc=Minecraft.getInstance();
if(!VanillaLight.active()||!VanillaLight.pbr()||VanillaLight.pbrIntensity()==0||mc.level==null){release();return;}
@@ -56,12 +55,8 @@ public final class ProceduralPbr {
if(source==null||source.width!=main.width||source.height!=main.height){if(source!=null)source.destroyBuffers();source=new TextureTarget("Sanctuary PBR source",main.width,main.height,GpuFormat.RGBA8_UNORM,null);}
// Padding supplies valid inactive PixelShadows and local-light uniform bindings.
if(scene==null)scene=new MappableRingBuffer(()->"Sanctuary PBR scene",GpuBuffer.USAGE_UNIFORM|GpuBuffer.USAGE_MAP_WRITE,176+ColoredDynamicLights.LIMIT*32);
var solidEntries=TERRAIN.update();var atlas=TERRAIN.materials.atlas();
var transparentEntries=TRANSPARENT.update();var transparentAtlas=TRANSPARENT.materials.atlas();
var entries=new ArrayList<PbrTerrain.Entry>(solidEntries);entries.addAll(transparentEntries);
var entries=TERRAIN.update();var atlas=TERRAIN.materials.atlas();
var vp=new Matrix4f(projection).mul(camera.viewRotationMatrix);var fog=camera.fogData;
PixelReflections.prepare(vp,solidEntries,atlas,transparentEntries,transparentAtlas);
if(PixelReflections.depth()==null)return;
var sun=PixelShadows.sunDirection(sky.sunAngle);boolean lunar=sun.y<=.08f;
if(lunar)sun=PixelShadows.sunDirection(sky.moonAngle);
float moon=sky.moonPhase==null?1:(float)(.5+.5*Math.cos(sky.moonPhase.index()*Math.PI/4));
@@ -70,7 +65,7 @@ public final class ProceduralPbr {
scene.rotate();try(var mapped=scene.currentBuffer().map(false,true)){
Std140Builder.intoBuffer(mapped.data()).putMat4f(new Matrix4f(vp).invert())
.putVec4(sun.x,sun.y,sun.z,strength)
.putVec4(VanillaLight.pbrIntensity()/100f,RenderSystem.getDevice().getDeviceInfo().isZZeroToOne()?1:0,shadow?1:0,VanillaLight.pbrDistance())
.putVec4(VanillaLight.pbrIntensity()/100f,RenderSystem.getDevice().getDeviceInfo().isZZeroToOne()?1:0,shadow?1:0,32)
.putVec4(fog.renderDistanceStart,fog.renderDistanceEnd,fog.environmentalStart,fog.environmentalEnd).putVec4(fog.color.w,ColoredLights.directionalReady()?1:0,0,0)
.putVec4(0,0,0,0).putVec4(0,0,0,0).putVec4(0,0,0,0).putVec4(0,0,0,0);
}
@@ -81,33 +76,22 @@ public final class ProceduralPbr {
var encoder=RenderSystem.getDevice().createCommandEncoder();
encoder.copyTextureToTexture(main.getColorTexture(),source.getColorTexture(),0,0,0,0,0,main.width,main.height);
var nearest=RenderSystem.getSamplerCache().getClampToEdge(FilterMode.NEAREST);
if((atlas!=null||transparentAtlas!=null)&&!entries.isEmpty())try(var pass=encoder.createRenderPass(()->"Sanctuary PBR materials",main.getColorTextureView(),Optional.empty(),PixelReflections.depth(),OptionalDouble.empty())){
if(atlas!=null&&!entries.isEmpty())try(var pass=encoder.createRenderPass(()->"Sanctuary PBR materials",main.getColorTextureView(),Optional.empty())){
pass.setPipeline(RenderSystem.getCompiledPipeline(PIPELINE));pass.setUniform("PbrScene",scene.currentBuffer());
pass.setUniform("PixelShadows",shadow?PixelShadows.materialUniforms():scene.currentBuffer());
pass.setUniform("SunDepth",shadow?PixelShadows.materialDepth():main.getDepthTextureView(),nearest);
pass.setUniform("WorldDepth",main.getDepthTextureView(),nearest);pass.setUniform("SceneColor",source.getColorTextureView(),nearest);
pass.setUniform("BlockAtlas",mc.getTextureManager().getTexture(TextureAtlas.LOCATION_BLOCKS).getTextureView(),nearest);
pass.setUniform("MaterialAtlas",(atlas!=null?atlas:transparentAtlas).getTextureView(),nearest);
pass.setUniform("MaterialAtlas",atlas.getTextureView(),nearest);
if(ColoredLights.directionalReady())ColoredLights.bindDirections(pass);
else{pass.setUniform("ColoredLightScene",scene.currentBuffer());pass.setUniform("LightDirection",source.getColorTextureView(),nearest);pass.setUniform("PreviousDirection",source.getColorTextureView(),nearest);}
pass.setIndexBuffer(indices.getBuffer(),indices.type());
for(int i=0;i<entries.size();i++){var entry=entries.get(i);
pass.setUniform("MaterialAtlas",(i<solidEntries.size()?atlas:transparentAtlas).getTextureView(),nearest);pass.setUniform("DynamicTransforms",transforms.get(i));pass.setVertexBuffer(0,entry.vertices().slice());pass.drawIndexed(entry.indices(),1,0,0,0);}
for(int i=0;i<entries.size();i++){var entry=entries.get(i);pass.setUniform("DynamicTransforms",transforms.get(i));pass.setVertexBuffer(0,entry.vertices().slice());pass.drawIndexed(entry.indices(),1,0,0,0);}
}
MaterialBloom.accumulate(source.getColorTextureView());
applied=true;frames++;
}
static com.mojang.renderpearl.api.textures.GpuTextureView copyScene(){
var main=Minecraft.getInstance().gameRenderer.mainRenderTarget();
RenderSystem.getDevice().createCommandEncoder().copyTextureToTexture(main.getColorTexture(),source.getColorTexture(),0,0,0,0,0,main.width,main.height);
return source.getColorTextureView();
}
public static void release(){MaterialBloom.release();TERRAIN.close();TRANSPARENT.close();PixelReflections.release();if(source!=null){source.destroyBuffers();source=null;}if(scene!=null){scene.close();scene=null;}applied=false;}
static long solidBytes(){return TERRAIN.allocatedBytes();}
static long transparentBytes(){return TRANSPARENT.allocatedBytes();}
static int waterUpdates(){return TRANSPARENT.materials.animationUpdates();}
static int waterAnalyses(){return TRANSPARENT.materials.analyses();}
static boolean applied(){return applied;}static boolean ready(){return TERRAIN.settled()&&TRANSPARENT.settled();}
public static void release(){TERRAIN.close();if(source!=null){source.destroyBuffers();source=null;}if(scene!=null){scene.close();scene=null;}applied=false;}
static boolean applied(){return applied;}static boolean ready(){return TERRAIN.settled();}
static boolean allocated(){return source!=null;}static int renderedFrames(){return frames;}
static int terrainEntries(){return TERRAIN.entryCount();}
static int terrainBuilds(){return TERRAIN.completed();}
@@ -14,12 +14,6 @@ public final class ShaderOptionsScreen extends OptionsSubScreen {
list.addBig(OptionInstance.createBoolean("sanctuary.shaders.vanilla_light",OptionInstance.cachedConstantTooltip(
Component.translatable(VanillaLight.externalEngine()?"sanctuary.shaders.external":"sanctuary.shaders.help")),
VanillaLight.enabled(),VanillaLight::setEnabled));
list.addBig(OptionInstance.createBoolean("sanctuary.shaders.natural_water",OptionInstance.cachedConstantTooltip(
Component.translatable("sanctuary.shaders.natural_water_help")),VanillaLight.naturalWater(),VanillaLight::setNaturalWater));
slider("water_intensity",VanillaLight.waterIntensity(),VanillaLight::setWaterIntensity);
list.addBig(new OptionInstance<Integer>("sanctuary.shaders.water_blend",OptionInstance.cachedConstantTooltip(
Component.translatable("sanctuary.shaders.water_blend_help")),(caption,value)->Component.translatable("sanctuary.shaders.water_blend_value",value*2+1,value*2+1),
new OptionInstance.Enum<>(List.of(0,1,2,4,8),Codec.INT),VanillaLight.waterBlendRadius(),VanillaLight::setWaterBlendRadius));
list.addBig(new OptionInstance<Integer>("sanctuary.shaders.saturation",OptionInstance.cachedConstantTooltip(
Component.translatable("sanctuary.shaders.saturation_help")),(caption,value)->Component.translatable("sanctuary.shaders.saturation_value",value),
new OptionInstance.IntRange(0,200),VanillaLight.saturation(),VanillaLight::setSaturation));
@@ -29,12 +23,6 @@ public final class ShaderOptionsScreen extends OptionsSubScreen {
list.addBig(OptionInstance.createBoolean("sanctuary.shaders.pbr",OptionInstance.cachedConstantTooltip(
Component.translatable("sanctuary.shaders.pbr_help")),VanillaLight.pbr(),VanillaLight::setPbr));
slider("pbr_intensity",VanillaLight.pbrIntensity(),VanillaLight::setPbrIntensity);
list.addBig(OptionInstance.createBoolean("sanctuary.shaders.ssr",OptionInstance.cachedConstantTooltip(
Component.translatable("sanctuary.shaders.ssr_help")),VanillaLight.ssr(),VanillaLight::setSsr));
slider("ssr_intensity",VanillaLight.ssrIntensity(),VanillaLight::setSsrIntensity);
list.addBig(new OptionInstance<Integer>("sanctuary.shaders.pbr_distance",OptionInstance.cachedConstantTooltip(
Component.translatable("sanctuary.shaders.pbr_distance_help")),(caption,value)->Component.translatable("sanctuary.shaders.pbr_distance_value",value),
new OptionInstance.Enum<>(List.of(16,32,64,128),Codec.INT),VanillaLight.pbrDistance(),VanillaLight::setPbrDistance));
list.addBig(OptionInstance.createBoolean("sanctuary.shaders.colored_lights",OptionInstance.cachedConstantTooltip(
Component.translatable("sanctuary.shaders.colored_lights_help")),VanillaLight.coloredLights(),VanillaLight::setColoredLights));
list.addBig(OptionInstance.createBoolean("sanctuary.shaders.warm_torches",OptionInstance.cachedConstantTooltip(
@@ -16,11 +16,6 @@ public final class VanillaLight {
private VanillaLight() { }
private static final class Preferences {
boolean enabled=true;
Boolean naturalWater; // Legacy OFF is an explicit opt-out in both world types.
Boolean sanctuaryNaturalWater;
Boolean ordinaryNaturalWater;
int waterBlendRadius=4;
int waterIntensity=100;
int saturation=142;
boolean pixelShadows=true;
boolean bloom=true;
@@ -30,9 +25,6 @@ public final class VanillaLight {
int lensFlareIntensity=30;
boolean pbr=true;
int pbrIntensity=80;
int pbrDistance=32;
boolean ssr=false;
int ssrIntensity=20;
boolean ssgi=true;
boolean coloredLights=true;
boolean warmTorches=false;
@@ -55,15 +47,8 @@ public final class VanillaLight {
try{if(Files.isRegularFile(path()))preferences=new GsonBuilder().create().fromJson(Files.readString(path()),Preferences.class);}
catch(Exception e){SanctuaryMod.LOGGER.warn("Cannot read Sanctuary shader preference",e);}
if(preferences==null)preferences=new Preferences();
if(Boolean.FALSE.equals(preferences.naturalWater)){
if(preferences.sanctuaryNaturalWater==null)preferences.sanctuaryNaturalWater=false;
if(preferences.ordinaryNaturalWater==null)preferences.ordinaryNaturalWater=false;
}
preferences.naturalWater=null;
preferences.sunshaftIntensity=Math.clamp(preferences.sunshaftIntensity,0,100);
preferences.lensFlareIntensity=Math.clamp(preferences.lensFlareIntensity,0,100);
preferences.ssrIntensity=Math.clamp(preferences.ssrIntensity,0,100);
preferences.pbrDistance=normalizePbrDistance(preferences.pbrDistance);
preferences.pbrIntensity=Math.clamp(preferences.pbrIntensity,0,100);
preferences.coloredLightIntensity=Math.clamp(preferences.coloredLightIntensity,0,100);
preferences.coloredLightDistance=normalizeColoredLightDistance(preferences.coloredLightDistance);
@@ -73,8 +58,6 @@ public final class VanillaLight {
preferences.oreIntensity=Math.clamp(preferences.oreIntensity,0,100);
preferences.edgeIntensity=Math.clamp(preferences.edgeIntensity,0,100);
preferences.edgeDistance=normalizeShadowDistance(preferences.edgeDistance);
preferences.waterBlendRadius=normalizeWaterBlendRadius(preferences.waterBlendRadius);
preferences.waterIntensity=Math.clamp(preferences.waterIntensity,0,100);
preferences.saturation=Math.clamp(preferences.saturation,0,200);
preferences.shadowPixels=normalizePrecision(preferences.shadowPixels);
preferences.shadowDistance=normalizeShadowDistance(preferences.shadowDistance);
@@ -83,14 +66,6 @@ public final class VanillaLight {
}
public static boolean externalEngine(){return EXTERNAL_ENGINE;}
public static boolean active(){return ProgressionClient.active()&&!EXTERNAL_ENGINE&&enabled();}
public static boolean naturalWater(){return naturalWater(NaturalWater.sanctuaryWorld());}
static boolean naturalWater(boolean sanctuary){enabled();Boolean value=sanctuary?preferences.sanctuaryNaturalWater:preferences.ordinaryNaturalWater;return value!=null?value:sanctuary;}
public static int waterBlendRadius(){enabled();return preferences.waterBlendRadius;}
public static void setWaterBlendRadius(int value){enabled();preferences.waterBlendRadius=normalizeWaterBlendRadius(value);save();}
static int normalizeWaterBlendRadius(int value){return value<1?0:value<2?1:value<3?2:value<6?4:8;}
public static int waterIntensity(){enabled();return preferences.waterIntensity;}
public static void setNaturalWater(boolean value){enabled();if(NaturalWater.sanctuaryWorld())preferences.sanctuaryNaturalWater=value;else preferences.ordinaryNaturalWater=value;save();}
public static void setWaterIntensity(int value){enabled();preferences.waterIntensity=Math.clamp(value,0,100);save();}
public static int saturation(){enabled();return preferences.saturation;}
public static boolean edgeHighlights(){enabled();return preferences.edgeHighlights;}
public static int edgeDistance(){enabled();return preferences.edgeDistance;}
@@ -106,13 +81,6 @@ public final class VanillaLight {
public static int lensFlareIntensity(){enabled();return preferences.lensFlareIntensity;}
public static void setLensFlare(boolean value){enabled();preferences.lensFlare=value;save();}
public static void setLensFlareIntensity(int value){enabled();preferences.lensFlareIntensity=Math.clamp(value,0,100);save();}
public static boolean ssr(){enabled();return preferences.ssr;}
public static int ssrIntensity(){enabled();return preferences.ssrIntensity;}
public static void setSsr(boolean value){enabled();preferences.ssr=value;save();}
public static void setSsrIntensity(int value){enabled();preferences.ssrIntensity=Math.clamp(value,0,100);save();}
public static int pbrDistance(){enabled();return preferences.pbrDistance;}
public static void setPbrDistance(int value){enabled();preferences.pbrDistance=normalizePbrDistance(value);save();}
static int normalizePbrDistance(int value){return value<24?16:value<48?32:value<96?64:128;}
public static boolean pbr(){enabled();return preferences.pbr;}
public static int pbrIntensity(){enabled();return preferences.pbrIntensity;}
public static void setPbr(boolean value){enabled();preferences.pbr=value;save();}
@@ -1,48 +0,0 @@
package fr.koka.sanctuary.mixin.client;
import com.llamalad7.mixinextras.injector.wrapmethod.WrapMethod;
import com.llamalad7.mixinextras.injector.wrapoperation.*;
import com.mojang.blaze3d.vertex.VertexConsumer;
import fr.koka.sanctuary.client.shader.NaturalWater;
import net.minecraft.client.color.block.BlockTintSource;
import net.minecraft.client.renderer.block.*;
import net.minecraft.core.BlockPos;
import net.minecraft.tags.FluidTags;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.material.FluidState;
import org.spongepowered.asm.mixin.*;
import org.spongepowered.asm.mixin.injection.At;
/** Native renderer instances belong to one section compiler. Context is restored even on failure. */
@Mixin(FluidRenderer.class)
abstract class NaturalWaterFluidMixin {
@Unique private NaturalWater.Profile[] sanctuary$corners;
@Unique private NaturalWater.Region sanctuary$snapshot;
@Unique private BlockAndTintGetter sanctuary$region;
@Unique private BlockPos sanctuary$pos;
@Unique private float sanctuary$strength;
@Unique private int sanctuary$x,sanctuary$z,sanctuary$base;
@WrapMethod(method="tesselate")
private void sanctuary$water(BlockAndTintGetter region,BlockPos pos,FluidRenderer.Output output,BlockState state,FluidState fluid,Operation<Void> original){
var previous=sanctuary$snapshot;
try{
sanctuary$corners=null;sanctuary$snapshot=null;sanctuary$base=-1;
if(fluid.is(FluidTags.WATER)&&region instanceof NaturalWater.RegionAccess access){
var snapshot=access.sanctuary$waterRegion();
if(snapshot!=null){
sanctuary$strength=snapshot.intensity;sanctuary$x=pos.getX()&15;sanctuary$z=pos.getZ()&15;
sanctuary$snapshot=snapshot;sanctuary$region=region;sanctuary$pos=pos;
}
}
original.call(region,pos,output,state,fluid);
}finally{sanctuary$snapshot=previous;sanctuary$corners=null;}
}
@WrapOperation(method="tesselate",at=@At(value="INVOKE",target="Lnet/minecraft/client/color/block/BlockTintSource;colorInWorld(Lnet/minecraft/world/level/block/state/BlockState;Lnet/minecraft/client/renderer/block/BlockAndTintGetter;Lnet/minecraft/core/BlockPos;)I"))
private int sanctuary$base(BlockTintSource source,BlockState state,BlockAndTintGetter region,BlockPos pos,Operation<Integer> original){return sanctuary$base=original.call(source,state,region,pos);}
@WrapOperation(method="vertex",at=@At(value="INVOKE",target="Lcom/mojang/blaze3d/vertex/VertexConsumer;addVertex(FFFIFFIIFFF)V"))
private void sanctuary$vertex(VertexConsumer consumer,float x,float y,float z,int color,float u,float v,int overlay,int light,float nx,float ny,float nz,Operation<Void> original){
if(sanctuary$snapshot!=null){
if(sanctuary$corners==null){var s=sanctuary$snapshot;var r=sanctuary$region;var p=sanctuary$pos;
sanctuary$corners=new NaturalWater.Profile[]{s.corner(r,p,0,0),s.corner(r,p,1,0),s.corner(r,p,0,1),s.corner(r,p,1,1)};}
int corner=(x-sanctuary$x>.5f?1:0)+(z-sanctuary$z>.5f?2:0);color=NaturalWater.color(color,sanctuary$base,sanctuary$corners[corner],sanctuary$strength);}
original.call(consumer,x,y,z,color,u,v,overlay,light,nx,ny,nz);
}
}
@@ -1,24 +0,0 @@
package fr.koka.sanctuary.mixin.client;
import fr.koka.sanctuary.client.shader.NaturalWater;
import net.minecraft.client.Camera;
import net.minecraft.client.multiplayer.ClientLevel;
import net.minecraft.client.renderer.fog.FogRenderer;
import net.minecraft.world.level.material.FogType;
import org.joml.Vector4f;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.injection.*;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
@Mixin(FogRenderer.class)
abstract class NaturalWaterFogColorMixin {
@Inject(method="computeFogColor",at=@At("RETURN"))
private void sanctuary$waterColor(Camera camera,float ticks,ClientLevel level,int distance,float darken,Vector4f color,CallbackInfo ci){
if(NaturalWater.strength()==0||camera.getFluidInCamera()!=FogType.WATER)return;
float amount=NaturalWater.strength()*NaturalWater.fogProfile().tint();if(amount==0)return;
// Apply after native water-vision normalization: normalizing olive to max=1 turns it neon yellow.
// Retain native darkness/vision brightness, including a fully black blinded environment.
float brightness=Math.max(color.x,Math.max(color.y,color.z));
color.x+=(120/255f*brightness-color.x)*amount;
color.y+=(132/255f*brightness-color.y)*amount;
color.z+=(72/255f*brightness-color.z)*amount;
}
}
@@ -1,19 +0,0 @@
package fr.koka.sanctuary.mixin.client;
import fr.koka.sanctuary.client.shader.NaturalWater;
import net.minecraft.client.Camera;
import net.minecraft.client.DeltaTracker;
import net.minecraft.client.multiplayer.ClientLevel;
import net.minecraft.client.renderer.fog.FogData;
import net.minecraft.client.renderer.fog.environment.WaterFogEnvironment;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.injection.*;
import org.spongepowered.asm.mixin.injection.callback.*;
@Mixin(WaterFogEnvironment.class)
abstract class NaturalWaterFogMixin {
@Inject(method="setupFog",at=@At("TAIL"))
private void sanctuary$fog(FogData fog,Camera camera,ClientLevel level,float distance,DeltaTracker delta,CallbackInfo ci){
float amount=NaturalWater.strength();if(amount==0)return;
float factor=1+(NaturalWater.fogProfile().fog()-1)*amount;
fog.environmentalStart*=factor;fog.environmentalEnd*=factor;fog.skyEnd*=factor;fog.cloudEnd*=factor;
}
}
@@ -1,23 +0,0 @@
package fr.koka.sanctuary.mixin.client;
import fr.koka.sanctuary.client.shader.NaturalWater;
import net.minecraft.client.multiplayer.ClientLevel;
import net.minecraft.client.renderer.chunk.RenderSectionRegion;
import org.spongepowered.asm.mixin.*;
import org.spongepowered.asm.mixin.injection.*;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
@Mixin(RenderSectionRegion.class)
abstract class NaturalWaterRegionMixin implements NaturalWater.RegionAccess {
@Shadow @Final private ClientLevel level;
@Shadow @Final private net.minecraft.client.renderer.chunk.SectionCopy[] sections;
@Shadow @Final private int minSectionX,minSectionY,minSectionZ;
@Unique private NaturalWater.Region sanctuary$water;
@Inject(method="<init>",at=@At("RETURN"))
private void sanctuary$snapshot(CallbackInfo ci){
if(NaturalWater.strength()==0)return;
// Native 3×3×3 region, centre index 13. Palette rejection avoids 216 biome reads for dry terrain.
var states=((NaturalWaterSectionCopyAccess)sections[13]).sanctuary$states();
if(states!=null&&states.maybeHas(state->state.getFluidState().is(net.minecraft.tags.FluidTags.WATER)))
sanctuary$water=new NaturalWater.Region(level,minSectionX,minSectionY,minSectionZ);
}
public NaturalWater.Region sanctuary$waterRegion(){return sanctuary$water;}
}
@@ -1,10 +0,0 @@
package fr.koka.sanctuary.mixin.client;
import net.minecraft.client.renderer.chunk.SectionCopy;
import net.minecraft.world.level.chunk.PalettedContainer;
import net.minecraft.world.level.block.state.BlockState;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.gen.Accessor;
@Mixin(SectionCopy.class)
interface NaturalWaterSectionCopyAccess {
@Accessor("section") PalettedContainer<BlockState> sanctuary$states();
}
@@ -1,10 +0,0 @@
package fr.koka.sanctuary.mixin.client;
import fr.koka.sanctuary.client.shader.PbrAnimation;
import net.minecraft.client.renderer.texture.SpriteContents;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.gen.Accessor;
@Mixin(SpriteContents.AnimationState.class)
public interface PbrAnimationClockMixin extends PbrAnimation.Clock {
@Accessor("frame") int sanctuary$frame();
@Accessor("subFrame") int sanctuary$subFrame();
}
@@ -1,14 +0,0 @@
package fr.koka.sanctuary.mixin.client;
import fr.koka.sanctuary.client.shader.PbrAnimation;
import net.minecraft.client.renderer.texture.SpriteContents;
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(SpriteContents.class)
abstract class PbrAnimationSpriteMixin {
@Inject(method="createAnimationState",at=@At("RETURN"))
private void sanctuary$clock(CallbackInfoReturnable<SpriteContents.AnimationState> ci){
PbrAnimation.register((SpriteContents)(Object)this,ci.getReturnValue());
}
}
@@ -17,7 +17,6 @@ import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
abstract class PixelShadowDirtyMixin {
@Inject(method="blockChanged", at=@At("HEAD"))
private void sanctuary$dirty(BlockPos pos, int flags, CallbackInfo ci) {
fr.koka.sanctuary.client.shader.NaturalWater.dirty(pos);
PixelShadows.dirtyBlock(pos); ProceduralPbr.dirtyBlock(pos); Bloom.dirtyBlock(pos); ColoredLights.dirtyBlock(pos);
}
@Inject(method="allChanged", at=@At("HEAD"))
@@ -31,7 +31,7 @@ abstract class PixelShadowRendererMixin {
return original.call(buffer, matrix);
}
@Inject(method="renderLevel", at=@At(value="INVOKE", target="Lnet/minecraft/client/renderer/GameRenderer;render3dHud(Lnet/minecraft/client/renderer/state/level/CameraRenderState;Lnet/minecraft/client/renderer/state/level/PlayerRenderState;Lnet/minecraft/client/renderer/state/OptionsRenderState;Z)V"))
private void sanctuary$shadows(CallbackInfo ci) { ColoredLights.prepare(); PixelShadows.render(); ProceduralPbr.render(); ColoredLights.render(); ScreenSpaceGI.render(); fr.koka.sanctuary.client.shader.PixelReflections.render(); EdgeHighlights.render(); Bloom.render(); Sunshafts.render(); }
private void sanctuary$shadows(CallbackInfo ci) { ColoredLights.prepare(); PixelShadows.render(); ProceduralPbr.render(); ColoredLights.render(); ScreenSpaceGI.render(); EdgeHighlights.render(); Bloom.render(); Sunshafts.render(); }
@Inject(method={"close", "setLevel"}, at=@At("HEAD"))
private void sanctuary$close(CallbackInfo ci) { PixelShadows.release(); ProceduralPbr.release(); EdgeHighlights.release(); Bloom.release(); ColoredLights.release(); ScreenSpaceGI.release(); Sunshafts.release(); }
}
@@ -1,41 +0,0 @@
package fr.koka.sanctuary.mixin.client;
import com.llamalad7.mixinextras.injector.wrapoperation.Operation;
import com.llamalad7.mixinextras.injector.wrapoperation.WrapOperation;
import com.mojang.blaze3d.systems.RenderSystem;
import com.mojang.renderpearl.api.commands.RenderPass;
import fr.koka.sanctuary.client.shader.PixelReflections;
import net.minecraft.client.Minecraft;
import net.minecraft.client.renderer.LevelRenderer;
import net.minecraft.client.renderer.chunk.ChunkSectionsToRender;
import net.minecraft.client.renderer.feature.FeatureRenderDispatcher;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.Unique;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Inject;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
import java.util.Optional;
import java.util.OptionalDouble;
/** Splits only the classic pass; copying an active Vulkan attachment is not legal. */
@Mixin(LevelRenderer.class)
abstract class ReflectionSceneMixin {
@Unique private java.util.function.Consumer<RenderPass> sanctuary$transparency;
@Inject(method="render",at=@At("HEAD"))
private void sanctuary$begin(CallbackInfo ci){sanctuary$transparency=null;PixelReflections.beginFrame();}
@WrapOperation(method="lambda$addMainPass$0",at=@At(value="INVOKE",target="Lnet/minecraft/client/renderer/LevelRenderer;executeClassicTransparency(Lnet/minecraft/client/renderer/chunk/ChunkSectionsToRender;Lnet/minecraft/client/renderer/feature/FeatureRenderDispatcher$PreparedFrame;Lcom/mojang/renderpearl/api/commands/RenderPass;)V"))
private void sanctuary$defer(LevelRenderer self,ChunkSectionsToRender chunks,FeatureRenderDispatcher.PreparedFrame features,RenderPass pass,Operation<Void> original){
if(!PixelReflections.eligible()){original.call(self,chunks,features,pass);return;}
sanctuary$transparency=next->original.call(self,chunks,features,next);
}
@Inject(method="executeOit",at=@At("HEAD"))
private void sanctuary$oit(CallbackInfo ci){PixelReflections.captureOpaque();}
@Inject(method="executeOutline",at=@At("HEAD"))
private void sanctuary$classic(CallbackInfo ci){
var action=sanctuary$transparency;sanctuary$transparency=null;if(action==null)return;
PixelReflections.captureOpaque();var main=Minecraft.getInstance().gameRenderer.mainRenderTarget();
try(var pass=RenderSystem.getDevice().createCommandEncoder().createRenderPass(()->"Sanctuary native transparency",main.getColorTextureView(),Optional.empty(),main.getDepthTextureView(),OptionalDouble.empty())){
RenderSystem.bindDefaultUniforms(pass);action.accept(pass);
}
}
}
@@ -0,0 +1,77 @@
package fr.koka.sanctuary.progression;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import java.util.Locale;
import java.util.regex.Pattern;
/** Site access-code contract. The client never calls the site; only these decisions run on the server. */
public final class AccessCodes {
private static final Pattern CODE = Pattern.compile("SANC-[A-Z0-9]{4}-[A-Z0-9]{4}");
private static final Pattern USER = Pattern.compile("[A-Za-z0-9_]{3,16}");
private static final Pattern DISCORD = Pattern.compile("[0-9]{1,20}");
private AccessCodes() { }
public record Result(boolean valid, String reason, String minecraftUsername, String discordId) {
public static Result of(boolean valid, String reason, String username, String discordId) {
return new Result(valid, reason == null ? "" : reason, username == null ? "" : username, discordId == null ? "" : discordId);
}
public static Result unreachable() { return of(false, "unreachable", "", ""); }
public static Result unauthorized() { return of(false, "unauthorized", "", ""); }
public static Result unconfigured() { return of(false, "unconfigured", "", ""); }
}
public record Redeem(boolean accepted, String reason, String minecraftUsername, String discordId) {
public static Redeem accept(String username, String discordId) { return new Redeem(true, "code_recognized", username, discordId); }
public static Redeem reject(String reason) { return new Redeem(false, reason, "", ""); }
}
public static String normalize(String code) { return code == null ? "" : code.trim().toUpperCase(Locale.ROOT); }
public static boolean validFormat(String normalized) { return normalized != null && CODE.matcher(normalized).matches(); }
public static boolean validUsername(String name) { return name != null && USER.matcher(name).matches(); }
public static boolean validDiscord(String id) { return id != null && DISCORD.matcher(id).matches(); }
public static boolean sameUser(String returned, String session) {
return validUsername(session) && returned != null && returned.equalsIgnoreCase(session) && validUsername(returned);
}
public static Result parse(int status, String body) {
if (status == 401) return Result.unauthorized();
if (status == 429 || status < 200 || status >= 500) return Result.unreachable();
try {
var object = JsonParser.parseString(body).getAsJsonObject();
if (!object.has("valid") || !object.has("reason")) return Result.unreachable();
return Result.of(object.get("valid").getAsBoolean(), text(object, "reason"), text(object, "minecraft_username"), text(object, "discord_id"));
} catch (RuntimeException error) { return Result.unreachable(); }
}
public static String verifyKey(Result result, String username) {
if (result == null) return "code_unreachable";
return switch (result.reason()) {
case "unconfigured" -> "code_unconfigured";
case "unauthorized" -> "code_unauthorized";
case "unreachable" -> "code_unreachable";
case "unused" -> result.valid() && sameUser(result.minecraftUsername(), username) && validDiscord(result.discordId()) ? "code_recognized" : "code_invalid";
case "invalid" -> "code_invalid";
case "already_used" -> "code_used";
default -> "code_invalid";
};
}
/** Redeem commits the code. An already used code is accepted only when the site still returns the same account. */
public static Redeem redeem(Result result, String username) {
if (result == null) return Redeem.reject("code_unreachable");
return switch (result.reason()) {
case "unconfigured" -> Redeem.reject("code_unconfigured");
case "unauthorized" -> Redeem.reject("code_unauthorized");
case "unreachable" -> Redeem.reject("code_unreachable");
case "redeemed", "already_used" -> account(result, username);
case "invalid" -> Redeem.reject("code_invalid");
default -> Redeem.reject("code_invalid");
};
}
private static Redeem account(Result result, String username) {
if (!result.minecraftUsername().isEmpty() && !result.minecraftUsername().equalsIgnoreCase(username))
return Redeem.reject("code_mismatch");
if (!sameUser(result.minecraftUsername(), username) || !validDiscord(result.discordId()))
return Redeem.reject("code_replay");
return Redeem.accept(result.minecraftUsername(), result.discordId());
}
private static String text(JsonObject object, String key) {
if (!object.has(key) || object.get(key).isJsonNull() || !object.get(key).isJsonPrimitive()) return "";
return object.get(key).getAsString();
}
}
@@ -0,0 +1,107 @@
package fr.koka.sanctuary.progression;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/** Server-only site access. The token stays in the environment or the server config, never in the client. */
public final class AccessConfig {
private static final Logger LOG = LoggerFactory.getLogger("Sanctuary/Access");
private static final HttpClient HTTP = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5)).followRedirects(HttpClient.Redirect.NEVER).build();
public interface Directory {
AccessCodes.Result verify(String username, String code);
AccessCodes.Result redeem(String username, String code);
}
private static volatile boolean enabled;
private static volatile Directory directory = new Disabled();
private AccessConfig() { }
public static boolean enabled() { return enabled; }
public static Directory directory() { return directory; }
public static void load(Path configDir) {
String url = env("SANCTUARY_APP_URL");
String token = env("SANCTUARY_API_TOKEN");
if (url.isEmpty() || token.isEmpty()) {
var file = readFile(configDir.resolve("access.json"));
if (url.isEmpty()) url = file.url;
if (token.isEmpty()) token = file.token;
}
if (url.isEmpty() && token.isEmpty()) {
enabled = false; directory = new Disabled();
LOG.info("Sanctuary access codes are off. Set SANCTUARY_APP_URL and SANCTUARY_API_TOKEN to require a code.");
return;
}
URI uri;
try { uri = URI.create(url.endsWith("/") ? url.substring(0, url.length() - 1) : url); }
catch (IllegalArgumentException error) { failClosed("Sanctuary access URL is invalid. Character creation stays closed."); return; }
if (uri.getUserInfo() != null || uri.getRawQuery() != null || uri.getHost() == null) {
failClosed("Sanctuary access URL must be a bare https origin. Character creation stays closed."); return;
}
String scheme = uri.getScheme();
if (!"https".equalsIgnoreCase(scheme) && !"http".equalsIgnoreCase(scheme)) {
failClosed("Sanctuary access URL must use https. Character creation stays closed."); return;
}
if (token.isEmpty()) { failClosed("Sanctuary access token is missing. Character creation stays closed."); return; }
if ("http".equalsIgnoreCase(scheme)) LOG.warn("Sanctuary access URL is not HTTPS. Use HTTPS outside a local site.");
enabled = true;
directory = new HttpDirectory(uri, token);
LOG.info("Sanctuary access codes are required before character creation.");
}
private static void failClosed(String message) {
enabled = true; directory = new Disabled(); LOG.error(message);
}
private static String env(String key) {
String value = System.getenv(key);
return value == null ? "" : value.trim();
}
private record FileConfig(String url, String token) { }
private static FileConfig readFile(Path file) {
if (!Files.isRegularFile(file)) return new FileConfig("", "");
try {
var object = JsonParser.parseString(Files.readString(file, StandardCharsets.UTF_8)).getAsJsonObject();
return new FileConfig(text(object, "appUrl"), text(object, "token"));
} catch (Exception error) {
LOG.error("Sanctuary access config cannot be read. Character creation stays closed.");
return new FileConfig("http://invalid", "");
}
}
private static String text(JsonObject object, String key) {
if (!object.has(key) || object.get(key).isJsonNull()) return "";
return object.get(key).getAsString().trim();
}
private static final class Disabled implements Directory {
public AccessCodes.Result verify(String username, String code) { return AccessCodes.Result.unconfigured(); }
public AccessCodes.Result redeem(String username, String code) { return AccessCodes.Result.unconfigured(); }
}
private static final class HttpDirectory implements Directory {
private final URI origin;
private final String token;
HttpDirectory(URI origin, String token) { this.origin = origin; this.token = token; }
public AccessCodes.Result verify(String username, String code) { return post("/api/v1/access-codes/verify", username, code); }
public AccessCodes.Result redeem(String username, String code) { return post("/api/v1/access-codes/redeem", username, code); }
private AccessCodes.Result post(String path, String username, String code) {
var body = new JsonObject();
body.addProperty("code", code);
body.addProperty("minecraft_username", username);
var request = HttpRequest.newBuilder(URI.create(origin.toString() + path)).timeout(Duration.ofSeconds(10))
.header("Authorization", "Bearer " + token).header("Content-Type", "application/json").header("Accept", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(body.toString())).build();
try {
var response = HTTP.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
if (response.statusCode() == 401) LOG.warn("Sanctuary access token was rejected");
return AccessCodes.parse(response.statusCode(), response.body());
} catch (Exception error) {
LOG.warn("Sanctuary access endpoint is unreachable ({})", error.getClass().getSimpleName());
return AccessCodes.Result.unreachable();
}
}
}
}
@@ -0,0 +1,83 @@
package fr.koka.sanctuary.progression;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonObject;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.util.TreeMap;
import java.util.UUID;
/** Discord binding for an inhabitant. Pending entries finish a creation interrupted after the site consumed the code. */
public final class AccessLedger {
private static final int LIMIT = 1024 * 1024;
private final Path file;
private final long seed;
private byte[] previous;
private final TreeMap<UUID, Binding> pending = new TreeMap<>();
private final TreeMap<UUID, Binding> bound = new TreeMap<>();
private boolean failed;
public record Binding(String name, String discordId) { }
public AccessLedger(Path file, long seed) throws IOException {
this.file = file; this.seed = seed;
previous = AtomicJson.read(file, LIMIT);
if (previous == null) return;
var root = AtomicJson.parse(new String(previous, StandardCharsets.UTF_8)).getAsJsonObject();
AtomicJson.fields(root, "schema", "seed", "pending", "bound");
if (root.get("schema").getAsInt() != 1 || root.get("seed").getAsLong() != seed)
throw new IllegalArgumentException("Invalid access ledger");
read(root.getAsJsonObject("pending"), pending);
read(root.getAsJsonObject("bound"), bound);
}
public Binding known(UUID id) {
var open = pending.get(id);
return open != null ? open : bound.get(id);
}
public void remember(UUID id, String name, String discordId) throws IOException {
if (failed) throw new IOException("Access ledger failed earlier");
if (!AccessCodes.validUsername(name) || !AccessCodes.validDiscord(discordId))
throw new IllegalArgumentException("Invalid access binding");
if (bound.containsKey(id)) return;
pending.put(id, new Binding(name, discordId));
save();
}
public void commit(UUID id) throws IOException {
if (failed) throw new IOException("Access ledger failed earlier");
var binding = pending.remove(id);
if (binding == null) return;
bound.put(id, binding);
save();
}
private static void read(JsonObject object, TreeMap<UUID, Binding> into) {
for (var entry : object.entrySet()) {
AtomicJson.fields(entry.getValue(), "name", "discordId");
UUID id = UUID.fromString(entry.getKey());
var value = entry.getValue().getAsJsonObject();
String name = value.get("name").getAsString();
String discordId = value.get("discordId").getAsString();
if (!AccessCodes.validUsername(name) || !AccessCodes.validDiscord(discordId))
throw new IllegalArgumentException("Invalid access binding");
if (into.put(id, new Binding(name, discordId)) != null) throw new IllegalArgumentException("Duplicate access binding");
}
}
private void save() throws IOException {
var root = new JsonObject();
root.addProperty("schema", 1);
root.addProperty("seed", seed);
root.add("pending", write(pending));
root.add("bound", write(bound));
try {
previous = AtomicJson.write(file, previous, new GsonBuilder().setPrettyPrinting().create().toJson(root), LIMIT);
} catch (IOException | RuntimeException error) { failed = true; throw error; }
}
private static JsonObject write(TreeMap<UUID, Binding> map) {
var object = new JsonObject();
map.forEach((id, binding) -> {
var value = new JsonObject();
value.addProperty("name", binding.name);
value.addProperty("discordId", binding.discordId);
object.add(id.toString(), value);
});
return object;
}
}
@@ -0,0 +1,38 @@
package fr.koka.sanctuary.progression;
import fr.koka.sanctuary.SanctuaryMod;
import net.minecraft.network.FriendlyByteBuf;
import net.minecraft.network.codec.StreamCodec;
import net.minecraft.network.protocol.common.custom.CustomPacketPayload;
/** Additive Hello World handshake. Existing create packets stay unchanged when access codes are off. */
public final class AccessPayloads {
private AccessPayloads() { }
public record Gate(boolean required, boolean pending) implements CustomPacketPayload {
public static final Type<Gate> TYPE = new Type<>(SanctuaryMod.id("access_gate_v1"));
public static final StreamCodec<FriendlyByteBuf, Gate> CODEC = CustomPacketPayload.codec(
(p, b) -> { b.writeBoolean(p.required); b.writeBoolean(p.pending); },
b -> new Gate(b.readBoolean(), b.readBoolean()));
public Type<Gate> type() { return TYPE; }
}
public record Check(String code) implements CustomPacketPayload {
public static final Type<Check> TYPE = new Type<>(SanctuaryMod.id("access_check_v1"));
public static final StreamCodec<FriendlyByteBuf, Check> CODEC = CustomPacketPayload.codec(
(p, b) -> b.writeUtf(p.code, 32), b -> new Check(b.readUtf(32)));
public Type<Check> type() { return TYPE; }
}
public record Status(String code, String reason) implements CustomPacketPayload {
public static final Type<Status> TYPE = new Type<>(SanctuaryMod.id("access_status_v1"));
public static final StreamCodec<FriendlyByteBuf, Status> CODEC = CustomPacketPayload.codec(
(p, b) -> { b.writeUtf(p.code, 32); b.writeUtf(p.reason, 64); },
b -> new Status(b.readUtf(32), b.readUtf(64)));
public Type<Status> type() { return TYPE; }
}
public record Admit(String bio, int color, String mob, String name, String code) implements CustomPacketPayload {
public static final Type<Admit> TYPE = new Type<>(SanctuaryMod.id("access_admit_v1"));
public static final StreamCodec<FriendlyByteBuf, Admit> CODEC = CustomPacketPayload.codec(
(p, b) -> { b.writeUtf(p.bio, 256); b.writeInt(p.color); b.writeUtf(p.mob, 64); b.writeUtf(p.name, StarterCompanions.NAME_LIMIT); b.writeUtf(p.code, 32); },
b -> new Admit(b.readUtf(256), b.readInt(), b.readUtf(64), b.readUtf(StarterCompanions.NAME_LIMIT), b.readUtf(32)));
public Type<Admit> type() { return TYPE; }
}
}
@@ -50,6 +50,7 @@ public final class ProgressionService {
public StarterCompanions.Store starters;
public CycleConfig cycleConfig=CycleConfig.DEFAULT;
public TutorialConfig tutorialConfig=TutorialConfig.DEFAULT;
public AccessLedger access;
Session(InhabitantStore inhabitants, ProgressionConfig config, MobNamesConfig names) {
this.inhabitants=inhabitants; this.config=config; this.names=names;
}
@@ -61,6 +62,10 @@ public final class ProgressionService {
PayloadTypeRegistry.clientboundConfiguration().register(StarterPayloads.Welcome.TYPE,StarterPayloads.Welcome.CODEC);
PayloadTypeRegistry.serverboundConfiguration().register(StarterPayloads.Create.TYPE,StarterPayloads.Create.CODEC);
PayloadTypeRegistry.serverboundConfiguration().register(StarterPayloads.NamedCreate.TYPE,StarterPayloads.NamedCreate.CODEC);
PayloadTypeRegistry.clientboundConfiguration().register(AccessPayloads.Gate.TYPE,AccessPayloads.Gate.CODEC);
PayloadTypeRegistry.clientboundConfiguration().register(AccessPayloads.Status.TYPE,AccessPayloads.Status.CODEC);
PayloadTypeRegistry.serverboundConfiguration().register(AccessPayloads.Check.TYPE,AccessPayloads.Check.CODEC);
PayloadTypeRegistry.serverboundConfiguration().register(AccessPayloads.Admit.TYPE,AccessPayloads.Admit.CODEC);
SanctuarySitting.register();
fr.koka.sanctuary.inventory.HotbarService.register();
fr.koka.sanctuary.inventory.InventorySortService.register();
@@ -87,6 +92,9 @@ public final class ProgressionService {
});
ServerConfigurationConnectionEvents.CONFIGURE.register((handler,server) -> {
if (session(server) == null) return;
if (session(server).access != null && !ServerConfigurationNetworking.canSend(handler,AccessPayloads.Gate.TYPE)) {
handler.disconnect(label("access_version_required")); return;
}
if (!ServerConfigurationNetworking.canSend(handler,ProgressionPayloads.Hello.TYPE)) {
handler.disconnect(label("client_required")); return;
}
@@ -147,6 +155,12 @@ public final class ProgressionService {
ServerConfigurationNetworking.registerGlobalReceiver(StarterPayloads.NamedCreate.TYPE,(payload,context)->context.server().execute(()->{
HelloTask task=PENDING.get(context.packetListener());if(task!=null)task.createWithStarter(new StarterPayloads.Create(payload.bio(),payload.color(),payload.mob()),payload.name());
}));
ServerConfigurationNetworking.registerGlobalReceiver(AccessPayloads.Check.TYPE,(payload,context)->context.server().execute(()->{
HelloTask task=PENDING.get(context.packetListener());if(task!=null)task.check(payload.code());
}));
ServerConfigurationNetworking.registerGlobalReceiver(AccessPayloads.Admit.TYPE,(payload,context)->context.server().execute(()->{
HelloTask task=PENDING.get(context.packetListener());if(task!=null)task.admit(payload);
}));
ServerConfigurationNetworking.registerGlobalReceiver(ProgressionPayloads.IntroFinished.TYPE, (payload,context) ->
context.server().execute(() -> {
HelloTask task = PENDING.get(context.packetListener());
@@ -194,6 +208,7 @@ public final class ProgressionService {
private static void start(MinecraftServer server) {
if (!SanctuaryGameplay.supported(server)) return;
try {
AccessConfig.load(CONFIG);
var config=ProgressionConfig.load(CONFIG.resolve("progression.json"));
var names=MobNamesConfig.load(CONFIG.resolve("mob-names.json"));
Path world=server.getWorldPath(LevelResource.ROOT);
@@ -210,6 +225,7 @@ public final class ProgressionService {
session.cycleConfig=CycleConfig.load(CONFIG.resolve("cycle-factions.json"));
session.tutorialConfig=TutorialConfig.load(CONFIG.resolve("tutorial.json"));
session.cycles=new CycleStore(world.resolve("data/sanctuary-cycle-factions.json"),server.overworld().getSeed());
if (AccessConfig.enabled()) session.access=new AccessLedger(world.resolve("data/sanctuary-access.json"),server.overworld().getSeed());
for (var event:session.cycles.events()) {
if (inhabitants.get(event.actor())==null || (!event.target().isEmpty() && inhabitants.get(UUID.fromString(event.target()))==null))
throw new java.io.IOException("Cycle journal references a missing inhabitant");
@@ -233,7 +249,10 @@ public final class ProgressionService {
private static final Type TYPE=new Type("sanctuary:hello_world");
private final ServerConfigurationPacketListenerImpl handler;
private final MinecraftServer server;
private boolean awaitingIntro,awaitingStarter;
private boolean awaitingIntro,awaitingStarter,admitted,busy;
private String lastChecked="",queuedCheck;
private AccessPayloads.Admit queuedAdmit;
private long lastCheckAt;
HelloTask(ServerConfigurationPacketListenerImpl handler,MinecraftServer server) { this.handler=handler;this.server=server; }
public Type type() { return TYPE; }
public void start(Consumer<Packet<?>> sender) {
@@ -255,6 +274,8 @@ public final class ProgressionService {
void offer(String error) {
var profile=((ProgressionConfigurationAccess)handler).sanctuary$profile();
var session=session(server);var inhabitant=session.inhabitants.get(profile.id());var starters=session.starters.get(profile.id());
if (session.access != null && ServerConfigurationNetworking.canSend(handler,AccessPayloads.Gate.TYPE))
ServerConfigurationNetworking.send(handler,new AccessPayloads.Gate(true,session.access.known(profile.id())!=null));
if(starters!=null){
var identity=new ProgressionPayloads.Hello(profile.name(),inhabitant==null?session.inhabitants.available():java.util.List.of(inhabitant.color()),error);
ServerConfigurationNetworking.send(handler,new StarterPayloads.Welcome(identity,inhabitant==null?"":inhabitant.bio(),inhabitant!=null,starters.choices()));
@@ -263,6 +284,7 @@ public final class ProgressionService {
void create(ProgressionPayloads.Create payload) {
if (awaitingIntro || awaitingStarter) return;
if (!handler.isAcceptingMessages()) { PENDING.remove(handler); return; }
if (AccessConfig.enabled() && !admitted) { offer("code_required"); return; }
Session session=session(server);
if (session == null || !session.healthy) { handler.disconnect(label("storage_invalid")); return; }
try {
@@ -288,6 +310,7 @@ public final class ProgressionService {
}
void createWithStarter(StarterPayloads.Create payload,String name) {
if(awaitingIntro||!handler.isAcceptingMessages())return;
if (AccessConfig.enabled() && !admitted) { offer("code_required"); return; }
var session=session(server);if(session==null||!session.healthy){handler.disconnect(label("storage_invalid"));return;}
try {
var profile=((ProgressionConfigurationAccess)handler).sanctuary$profile();var uuid=profile.id();
@@ -303,6 +326,94 @@ public final class ProgressionService {
awaitingStarter=false;beginIntro(uuid);
}catch(Exception error){fail(error);}
}
void check(String code) {
if (awaitingIntro || !handler.isAcceptingMessages()) return;
var session=session(server);
if (session == null || session.access == null) return;
var profile=((ProgressionConfigurationAccess)handler).sanctuary$profile();
String normalized=AccessCodes.normalize(code);
if (session.access.known(profile.id()) != null) { status(normalized,"code_recognized"); return; }
if (!AccessCodes.validFormat(normalized)) { status(normalized,"code_malformed"); return; }
if (!AccessCodes.validUsername(profile.name())) { status(normalized,"code_username"); return; }
if (busy) { queuedCheck=normalized; queuedAdmit=null; return; }
if (normalized.equals(lastChecked) && System.nanoTime()-lastCheckAt < 1_000_000_000L) return;
verify(profile.name(),normalized);
}
void verify(String username,String code) {
busy=true; lastChecked=code; lastCheckAt=System.nanoTime(); queuedCheck=null;
var directory=AccessConfig.directory();
java.util.concurrent.CompletableFuture.supplyAsync(() -> directory.verify(username,code)).whenComplete((result,error) -> server.execute(() -> {
busy=false;
if (PENDING.get(handler) != this || !handler.isAcceptingMessages()) return;
status(code, error != null || result == null ? "code_unreachable" : AccessCodes.verifyKey(result,username));
if (queuedAdmit != null) { var next=queuedAdmit; queuedAdmit=null; admit(next); return; }
if (queuedCheck != null && !queuedCheck.equals(code) && AccessCodes.validFormat(queuedCheck)) {
String next=queuedCheck; queuedCheck=null; verify(username,next);
}
}));
}
void status(String code,String reason) {
if (handler.isAcceptingMessages() && ServerConfigurationNetworking.canSend(handler,AccessPayloads.Status.TYPE))
ServerConfigurationNetworking.send(handler,new AccessPayloads.Status(code,reason));
}
void admit(AccessPayloads.Admit payload) {
if (awaitingIntro || !handler.isAcceptingMessages()) return;
String normalized=AccessCodes.normalize(payload.code());
if (busy) { queuedAdmit=payload; return; }
var session=session(server);
if (session == null || !session.healthy) { handler.disconnect(label("storage_invalid")); return; }
if (session.access == null) { offer("code_unconfigured"); return; }
var profile=((ProgressionConfigurationAccess)handler).sanctuary$profile();
var uuid=profile.id();
if (!ready(session,uuid,payload)) return;
if (session.access.known(uuid) != null) { finishAdmit(session,uuid,payload); drain(); return; }
if (!AccessCodes.validFormat(normalized)) { offer("code_malformed"); return; }
if (!AccessCodes.validUsername(profile.name())) { offer("code_username"); return; }
busy=true;
String username=profile.name();
var directory=AccessConfig.directory();
java.util.concurrent.CompletableFuture.supplyAsync(() -> directory.redeem(username,normalized)).whenComplete((result,error) -> server.execute(() -> {
busy=false;
if (PENDING.get(handler) != this || !handler.isAcceptingMessages()) return;
if (error != null || result == null) { offer("code_unreachable"); drain(); return; }
var decision=AccessCodes.redeem(result,username);
if (!decision.accepted()) { offer(decision.reason()); drain(); return; }
try {
session.access.remember(uuid,decision.minecraftUsername(),decision.discordId());
finishAdmit(session,uuid,payload);
} catch (Exception failure) { fail(failure); }
drain();
}));
}
boolean ready(Session session,UUID uuid,AccessPayloads.Admit payload) {
var starters=session.starters.get(uuid);
if (starters != null) {
if (starters.choices().stream().noneMatch(choice -> choice.mob().equals(payload.mob()))) { offer("starter_required"); return false; }
if (!StarterCompanions.validName(payload.name())) { offer("starter_name_invalid"); return false; }
} else if (!payload.mob().isEmpty()) { offer("starter_required"); return false; }
if (session.inhabitants.get(uuid) == null) {
if (!InhabitantStore.validBio(payload.bio())) { offer("bio_invalid"); return false; }
if (!session.inhabitants.available().contains(payload.color())) { offer("color_taken"); return false; }
}
return true;
}
void drain() {
if (!handler.isAcceptingMessages() || PENDING.get(handler) != this) return;
if (queuedAdmit != null) { var next=queuedAdmit; queuedAdmit=null; admit(next); }
else if (queuedCheck != null && AccessCodes.validFormat(queuedCheck)) {
String next=queuedCheck; queuedCheck=null;
verify(((ProgressionConfigurationAccess)handler).sanctuary$profile().name(),next);
}
}
void finishAdmit(Session session,UUID uuid,AccessPayloads.Admit payload) {
admitted=true;
try {
if (payload.mob().isEmpty()) create(new ProgressionPayloads.Create(payload.bio(),payload.color()));
else createWithStarter(new StarterPayloads.Create(payload.bio(),payload.color(),payload.mob()),payload.name());
} finally { admitted=false; }
try { if (session.inhabitants.get(uuid) != null) session.access.commit(uuid); }
catch (Exception error) { fail(error); }
}
void fail(Exception error){
session(server).healthy=false;LOG.error("Could not persist Hello World starter",error);handler.disconnect(label("storage_invalid"));
}
@@ -325,6 +436,7 @@ public final class ProgressionService {
if (!restore(player)) return;
sendNames(player,session);
try {
if (session.access != null) session.access.commit(player.getUUID());
StarterCompanions.deliver(player,session.starters);
if (session.inhabitants.arrive(player.getUUID())) player.level().getServer().getPlayerList()
.broadcastSystemMessage(Component.literal("helloworld ").append(player.getDisplayName()),false);
@@ -1,32 +0,0 @@
package fr.koka.sanctuary.water;
import fr.koka.sanctuary.SanctuaryMod;
import fr.koka.sanctuary.expansion.ExpansionChunkGenerator;
import net.fabricmc.fabric.api.networking.v1.*;
import net.fabricmc.fabric.api.entity.event.v1.ServerEntityLevelChangeEvents;
import net.fabricmc.fabric.api.entity.event.v1.ServerPlayerEvents;
import net.minecraft.network.RegistryFriendlyByteBuf;
import net.minecraft.network.codec.StreamCodec;
import net.minecraft.network.protocol.common.custom.CustomPacketPayload;
import net.minecraft.server.level.ServerPlayer;
/** Read-only dimension context; no persistent world or generation changes. */
public final class WaterWorldSettings {
private WaterWorldSettings() {}
public record Context(boolean sanctuary) implements CustomPacketPayload {
public static final Type<Context> TYPE=new Type<>(SanctuaryMod.id("water_world_v1"));
public static final StreamCodec<RegistryFriendlyByteBuf,Context> CODEC=CustomPacketPayload.codec(
(p,b)->b.writeBoolean(p.sanctuary),b->new Context(b.readBoolean()));
public Type<Context> type(){return TYPE;}
}
public static void register(){
PayloadTypeRegistry.clientboundPlay().register(Context.TYPE,Context.CODEC);
ServerPlayConnectionEvents.JOIN.register((handler,sender,server)->send(handler.player));
ServerEntityLevelChangeEvents.AFTER_PLAYER_CHANGE_LEVEL.register((player,origin,destination)->send(player));
ServerPlayerEvents.AFTER_RESPAWN.register((oldPlayer,player,alive)->send(player));
}
private static void send(ServerPlayer player){
if(ServerPlayNetworking.canSend(player,Context.TYPE))
ServerPlayNetworking.send(player,new Context(player.level().getChunkSource().getGenerator() instanceof ExpansionChunkGenerator));
}
}
@@ -1,9 +1,4 @@
{
"sanctuary.shaders.ssr_intensity_value": "Reflection strength: %s%%",
"sanctuary.shaders.ssr_intensity_help": "Strength of visible landscape reflections on solids and water. Default: 20%.",
"sanctuary.shaders.ssr": "Landscape reflections — experimental",
"sanctuary.shaders.ssr_help": "Colours material texels with the visible landscape. Requires PBR; off-screen objects cannot be reflected.",
"sanctuary.shaders.ssr_intensity": "Reflection strength",
"sanctuary.graphics.default_vulkan": "Vulkan (default)",
"sanctuary.statuary.clay_ready": "Clay: original model colors.",
"sanctuary.weather.selected": "Real Time weather: %s for %s (%s). The daily forecast resumes at midnight.",
@@ -272,6 +267,21 @@
"sanctuary.progression.choose_color": "Choose your colour",
"sanctuary.progression.cancel": "Cancel",
"sanctuary.progression.enter": "Enter Sanctuary",
"sanctuary.progression.code": "Access code",
"sanctuary.progression.code_hint": "SANC-XXXX-XXXX",
"sanctuary.progression.code_recognized": "Code recognised.",
"sanctuary.progression.code_checking": "Checking the code…",
"sanctuary.progression.code_malformed": "The code should look like SANC-XXXX-XXXX.",
"sanctuary.progression.code_invalid": "This code does not match this name.",
"sanctuary.progression.code_used": "This code has already been used.",
"sanctuary.progression.code_replay": "This code is already used and Sanctuary cannot recover the account. Try again later.",
"sanctuary.progression.code_unreachable": "Sanctuary is unreachable, try again.",
"sanctuary.progression.code_unauthorized": "The server cannot reach Sanctuary.",
"sanctuary.progression.code_unconfigured": "The server has no Sanctuary token.",
"sanctuary.progression.code_required": "Enter the code sent in a private message.",
"sanctuary.progression.code_username": "This name cannot receive a code.",
"sanctuary.progression.code_mismatch": "The code does not match this session.",
"sanctuary.progression.access_version_required": "This server requires the Sanctuary access code. Update the client.",
"sanctuary.progression.joining": "Character saved, joining…",
"sanctuary.progression.bio_invalid": "Use plain text for your bio (256 characters).",
"sanctuary.progression.color_taken": "Colour just taken. Choose another one.",
@@ -2302,9 +2312,9 @@
"sanctuary.shaders.ssgi_intensity_value": "SSGI: %s%%",
"sanctuary.shaders.pbr": "PBR · material relief",
"sanctuary.shaders.pbr_help": "Experimental: procedural block normals and roughness, sun, moon and local light highlights. Enable Colored lights for local direction. Range 32 blocks. ON at 80% by default.",
"sanctuary.shaders.pbr_intensity": "PBR intensity",
"sanctuary.shaders.pbr_intensity_help": "Material relief and highlights, including water. Default: 80%.",
"sanctuary.shaders.pbr_intensity_value": "PBR intensity: %s %%",
"sanctuary.shaders.pbr_intensity": "PBR strength",
"sanctuary.shaders.pbr_intensity_help": "Material relief and highlights. Zero stops rendering and frees the cache. Original textures remain unchanged.",
"sanctuary.shaders.pbr_intensity_value": "PBR: %s%%",
"sanctuary.shaders.lens_flare": "Lens flare · soft rectangles",
"sanctuary.shaders.lens_flare_help": "Soft rectangular sun and moon reflections. Obstacles hide the sources. Independent of the bloom halo.",
"sanctuary.shaders.lens_flare_intensity": "Lens flare intensity",
@@ -2324,16 +2334,5 @@
"sanctuary.shaders.colored_light_distance_value": "Colored lights: %s blocks",
"sanctuary.shaders.colored_light_distance_help": "View distance: 8 to 64 blocks. Longer distances use more memory and computation. Smooth range fade; each source keeps its native reach.",
"sanctuary.shaders.warm_torches": "Warm torches",
"sanctuary.shaders.warm_torches_help": "Off: ordinary torches keep the native color temperature. On: orange tint. Other sources retain their own colors.",
"sanctuary.shaders.natural_water": "Natural water",
"sanctuary.shaders.natural_water_help": "Bed-dependent tint, transparency and fog. Defaults OFF in ordinary worlds and ON on Sanctuary islands. Preferences are stored separately for each world type.",
"sanctuary.shaders.water_intensity": "Water intensity",
"sanctuary.shaders.water_intensity_help": "At 0%, keeps native rendering. At 100%, applies all three profiles.",
"sanctuary.shaders.water_intensity_value": "Water intensity: %s%%",
"sanctuary.shaders.pbr_distance": "PBR and SSR distance",
"sanctuary.shaders.pbr_distance_value": "PBR and SSR distance: %s blocks",
"sanctuary.shaders.pbr_distance_help": "Shared range for relief and reflecting surfaces. 16, 32, 64 or 128 blocks. Longer distances cost more; mesh memory is bounded.",
"sanctuary.shaders.water_blend": "Water blending",
"sanctuary.shaders.water_blend_value": "Water blending: %s × %s blocks",
"sanctuary.shaders.water_blend_help": "Transition width between bed-dependent water colours: 1, 3, 5, 9 or 17 blocks. Default: 9 × 9."
"sanctuary.shaders.warm_torches_help": "Off: ordinary torches keep the native color temperature. On: orange tint. Other sources retain their own colors."
}
@@ -1,9 +1,4 @@
{
"sanctuary.shaders.ssr_intensity_value": "Intensité des reflets : %s %%",
"sanctuary.shaders.ssr_intensity_help": "Intensité des reflets du paysage visible, sur les solides et l’eau. Défaut : 20 %.",
"sanctuary.shaders.ssr": "Reflets du paysage — expérimental",
"sanctuary.shaders.ssr_help": "Colore les pixels de matière avec le paysage visible. PBR requis ; les objets hors écran ne sont pas réfléchis.",
"sanctuary.shaders.ssr_intensity": "Intensité des reflets",
"sanctuary.graphics.default_vulkan": "Vulkan (par défaut)",
"sanctuary.statuary.clay_ready": "Argile : couleurs originales du modèle.",
"sanctuary.weather.selected": "Météo Real Time : %s pour la journée du %s (%s). Le tirage quotidien reprend à minuit.",
@@ -272,6 +267,21 @@
"sanctuary.progression.choose_color": "Choisis ta couleur",
"sanctuary.progression.cancel": "Annuler",
"sanctuary.progression.enter": "Entrer dans Sanctuary",
"sanctuary.progression.code": "Code d’accès",
"sanctuary.progression.code_hint": "SANC-XXXX-XXXX",
"sanctuary.progression.code_recognized": "Code reconnu.",
"sanctuary.progression.code_checking": "Vérification du code…",
"sanctuary.progression.code_malformed": "Le code doit ressembler à SANC-XXXX-XXXX.",
"sanctuary.progression.code_invalid": "Ce code ne correspond pas à ce pseudo.",
"sanctuary.progression.code_used": "Ce code a déjà été utilisé.",
"sanctuary.progression.code_replay": "Ce code est déjà consommé et Sanctuary ne peut pas retrouver le compte. Réessaie plus tard.",
"sanctuary.progression.code_unreachable": "Sanctuary est injoignable, réessaie.",
"sanctuary.progression.code_unauthorized": "Le serveur ne peut pas joindre Sanctuary.",
"sanctuary.progression.code_unconfigured": "Le serveur n’a pas de jeton Sanctuary.",
"sanctuary.progression.code_required": "Entre le code reçu en message privé.",
"sanctuary.progression.code_username": "Ce pseudo ne peut pas recevoir de code.",
"sanctuary.progression.code_mismatch": "Le code ne correspond pas à cette session.",
"sanctuary.progression.access_version_required": "Ce serveur exige le code d’accès Sanctuary. Mets le client à jour.",
"sanctuary.progression.joining": "Création enregistrée, arrivée en cours…",
"sanctuary.progression.bio_invalid": "La bio doit contenir du texte simple (256 caractères).",
"sanctuary.progression.color_taken": "Couleur déjà prise. Choisis-en une autre.",
@@ -2302,9 +2312,9 @@
"sanctuary.shaders.ssgi_intensity_value": "SSGI : %s %%",
"sanctuary.shaders.pbr": "PBR · relief des matériaux",
"sanctuary.shaders.pbr_help": "Expérimental : normales procédurales et rugosité des blocs, reflets du soleil, de la lune et des lumières locales. Activer Lumières colorées pour la direction locale. Portée 32 blocs. Activé par défaut à 80 %.",
"sanctuary.shaders.pbr_intensity": "Intensité PBR",
"sanctuary.shaders.pbr_intensity_help": "Relief et éclats des matières, eau comprise. Défaut : 80 %.",
"sanctuary.shaders.pbr_intensity_value": "Intensité PBR : %s %%",
"sanctuary.shaders.pbr_intensity": "Intensité du PBR",
"sanctuary.shaders.pbr_intensity_help": "Relief et reflets des matériaux. Zéro coupe le calcul et libère le cache. Ne modifie pas les textures originales.",
"sanctuary.shaders.pbr_intensity_value": "PBR : %s %%",
"sanctuary.shaders.lens_flare": "Lens flare · rectangles doux",
"sanctuary.shaders.lens_flare_help": "Reflets rectangulaires du soleil et de la lune, aux bords dégradés. Les obstacles masquent les sources. Indépendant du halo de bloom.",
"sanctuary.shaders.lens_flare_intensity": "Intensité du lens flare",
@@ -2324,16 +2334,5 @@
"sanctuary.shaders.colored_light_distance_value": "Lumières colorées : %s blocs",
"sanctuary.shaders.colored_light_distance_help": "Distance d'affichage : 8 à 64 blocs. Les grandes distances utilisent plus de mémoire et de calcul. Fondu aux limites ; la portée de chaque source reste native.",
"sanctuary.shaders.warm_torches": "Torches chaudes",
"sanctuary.shaders.warm_torches_help": "Désactivé : les torches ordinaires conservent la température de couleur native. Activé : teinte orangée. Les autres sources gardent leurs propres couleurs.",
"sanctuary.shaders.natural_water": "Eau naturelle",
"sanctuary.shaders.natural_water_help": "Teintes, transparence et brume selon le fond. OFF par défaut dans les mondes ordinaires, ON sur les îles Sanctuary. Choix mémorisé séparément pour chaque type de monde.",
"sanctuary.shaders.water_intensity": "Intensité de l’eau",
"sanctuary.shaders.water_intensity_help": "À 0 %, conserve le rendu natif. À 100 %, applique les trois profils.",
"sanctuary.shaders.water_intensity_value": "Intensité de l’eau: %s%%",
"sanctuary.shaders.pbr_distance": "Distance PBR et SSR",
"sanctuary.shaders.pbr_distance_value": "Distance PBR et SSR : %s blocs",
"sanctuary.shaders.pbr_distance_help": "Portée commune du relief et des surfaces réfléchissantes. 16, 32, 64 ou 128 blocs. Les distances élevées coûtent davantage ; budget de maillage limité.",
"sanctuary.shaders.water_blend": "Mélange des eaux",
"sanctuary.shaders.water_blend_value": "Mélange des eaux : %s × %s blocs",
"sanctuary.shaders.water_blend_help": "Étendue de la transition des teintes selon le fond : 1, 3, 5, 9 ou 17 blocs. Défaut : 9 × 9."
"sanctuary.shaders.warm_torches_help": "Désactivé : les torches ordinaires conservent la température de couleur native. Activé : teinte orangée. Les autres sources gardent leurs propres couleurs."
}
@@ -1,6 +0,0 @@
#version 330
#extension GL_ARB_separate_shader_objects : require
uniform sampler2D InputTexture;
layout(location=0) in vec2 texCoord;
layout(location=0) out vec4 fragColor;
void main(){fragColor=vec4(texture(InputTexture,texCoord).rgb,0);}
@@ -1,16 +0,0 @@
#version 330
#extension GL_ARB_separate_shader_objects : require
uniform sampler2D Before;
uniform sampler2D After;
layout(location=0) in vec2 texCoord;
layout(location=0) out vec4 fragColor;
void main(){
vec2 pixel=1.0/vec2(textureSize(After,0));vec3 glow=vec3(0);
for(int y=0;y<8;y++)for(int x=0;x<8;x++){
vec2 uv=texCoord+(vec2(x,y)-3.5)*pixel;
vec3 a=texture(After,uv).rgb,b=texture(Before,uv).rgb;
// A restrained halo for material light, stronger for near-white solar glints.
glow+=max(a*a-b*b,vec3(0))*(.12+.28*smoothstep(.75,1.0,max(a.r,max(a.g,a.b))));
}
fragColor=vec4(glow/64.0,0);
}
@@ -89,13 +89,13 @@ vec3 localShine(vec3 n,vec3 v,vec3 l,vec4 material,vec3 albedo){
vec3 halfVector=l+v;float size=dot(halfVector,halfVector);if(size<.00001)return vec3(0);
vec3 h=halfVector*inversesqrt(size);
float nl=clamp(dot(n,l),0.0,1.0),nv=clamp(dot(n,v),.001,1.0),nh=clamp(dot(n,h),0.0,1.0),vh=clamp(dot(v,h),0.0,1.0);
float roughness=max(material.b,.16),alpha=roughness*roughness,a2=alpha*alpha;
float denom=nh*nh*(a2-1.0)+1.0,distribution=a2/max(3.14159265*denom*denom,.00000001);
float roughness=max(material.b,.25),alpha=roughness*roughness,a2=alpha*alpha;
float denom=nh*nh*(a2-1.0)+1.0,distribution=a2/max(3.14159265*denom*denom,.00001);
float k=(roughness+1.0)*(roughness+1.0)/8.0;
float geometry=nv/(nv*(1.0-k)+k)*nl/max(nl*(1.0-k)+k,.00001);
// Explicit integer power stays defined even at round-off limits on OpenGL drivers.
float grazing=1.0-vh,grazing2=grazing*grazing;
vec3 f0=mix(vec3(maskUv.y>=4.0?.02:.04),albedo,material.a),fresnel=f0+(1.0-f0)*(grazing2*grazing2*grazing);
vec3 f0=mix(vec3(.04),albedo,material.a),fresnel=f0+(1.0-f0)*(grazing2*grazing2*grazing);
return distribution*geometry*fresnel/max(4.0*nv*nl,.0001)*nl;
}
void main(){
@@ -104,33 +104,23 @@ void main(){
vec2 tx=dFdx(blockUv),ty=dFdy(blockUv);
float det=tx.x*ty.y-tx.y*ty.x;
vec3 baseNormal=normalize(cross(dx,dy));if(dot(baseNormal,-relativePosition)<0.0)baseNormal=-baseNormal;
// Exact receiver depth is tested by the GPU; final scene depth also masks entities.
float visibleDepth=texelFetch(WorldDepth,ivec2(gl_FragCoord.xy),0).r;
vec2 screenUv=gl_FragCoord.xy/vec2(textureSize(WorldDepth,0));
vec4 visiblePoint=PbrInverseVP*vec4(screenUv*2.0-1.0,PbrParams.y>.5?visibleDepth:visibleDepth*2.0-1.0,1);
// Native terrain and material matrices have different evaluation order. Use a
// bounded 5 mm tolerance only for native occlusion; receiver selection stays exact.
if((visibleDepth>0.0&&length(visiblePoint.xyz/visiblePoint.w)+.005<length(relativePosition))||length(relativePosition)>=PbrParams.w||abs(det)<1e-12)discard;
vec2 uv=gl_FragCoord.xy/vec2(textureSize(WorldDepth,0));float depth=texelFetch(WorldDepth,ivec2(gl_FragCoord.xy),0).r;
vec4 projected=PbrInverseVP*vec4(uv*2.0-1.0,PbrParams.y>0.5?depth:depth*2.0-1.0,1.0);
vec3 visible=projected.xyz/projected.w;
if(depth<=0.0||abs(length(visible)-length(relativePosition))>0.035||length(relativePosition)>=PbrParams.w||abs(det)<1e-12)discard;
// Keep atlas derivatives valid after per-fragment depth/alpha rejection.
float family=floor(maskUv.y*.5);
vec4 texel=textureGrad(BlockAtlas,blockUv,tx,ty);if(family<.5&&texel.a<0.99)discard;
vec4 texel=textureGrad(BlockAtlas,blockUv,tx,ty);if(texel.a<0.99)discard;
vec3 tangent=normalize((dx*ty.y-dy*tx.y)/det),bitangent=normalize((dy*tx.x-dx*ty.x)/det);
vec4 material=textureLod(MaterialAtlas,vec2(maskUv.x,maskUv.y-family*2.0),0.0);
vec4 material=textureLod(MaterialAtlas,maskUv,0.0);
// RG stores signed height derivatives; B roughness, A metalness, not transparency.
float intensity=PbrParams.x;
vec2 slope=(material.rg*255.0-128.0)/127.0*intensity*(family>1.5?.24:2.0);
vec2 slope=(material.rg*255.0-128.0)/127.0*PbrParams.x*2.0;
vec3 n=normalize(baseNormal+tangent*slope.x+bitangent*slope.y);
// Reject degenerate/interpolated geometry without replacing the native scene with NaNs.
if(any(isnan(n))||any(isinf(n))||dot(relativePosition,relativePosition)<.000001)discard;
vec3 l=normalize(PbrSun.xyz),v=normalize(-relativePosition);
float nl=clamp(dot(n,l),0.0,1.0);
vec3 albedo=texel.rgb*tint.rgb;albedo*=albedo;
// A broad solar lobe gives water an extended glitter path at grazing angles.
// Local lamps retain the material's original narrow lobe.
vec4 solarMaterial=material;
solarMaterial.b=max(material.b,family>1.5?.34:.24);
vec3 specular=localShine(n,v,l,solarMaterial,albedo);
float solarRadiance=family>1.5?20.0:4.0;
vec3 specular=localShine(n,v,l,material,albedo);
float fog=max(clamp((max(length(relativePosition.xz),abs(relativePosition.y))-PbrFog.x)/max(PbrFog.y-PbrFog.x,.001),0.0,1.0),
clamp((length(relativePosition)-PbrFog.z)/max(PbrFog.w-PbrFog.z,.001),0.0,1.0));
float amount=PbrSun.w*pow(tint.a,8.0)*visibility(relativePosition,baseNormal)*(1.0-fog*PbrEnvironment.x)
@@ -140,14 +130,13 @@ void main(){
if(amount<=0.0001&&localAmount<=0.0001)discard;
vec3 scene=texelFetch(SceneColor,ivec2(gl_FragCoord.xy),0).rgb;vec3 linear=scene*scene;
// Native diffuse light already exists. Apply only the perturbed-normal difference, then add GGX.
float relief=family<.5?(nl-max(dot(baseNormal,l),0.0))*.7:0.0;
float relief=(nl-max(dot(baseNormal,l),0.0))*.7;
vec3 lit=clamp(linear*(1.0+relief*amount),0.0,1.0);
if(localAmount>.0001){
float localRelief=family<.5?(max(dot(n,arrival.xyz),0.0)-max(dot(baseNormal,arrival.xyz),0.0))*.7:0.0;
float localRelief=(max(dot(n,arrival.xyz),0.0)-max(dot(baseNormal,arrival.xyz),0.0))*.7;
lit=clamp(lit*(1.0+localRelief*localAmount),0.0,1.0);
}
// Preserve bright solar highlights instead of capping them at a medium grey.
vec3 shine=min((specular*amount*solarRadiance+localShine(n,v,arrival.xyz,material,albedo)*localAmount)*intensity*5.0,vec3(8.0));
vec3 shine=min((specular*amount+localShine(n,v,arrival.xyz,material,albedo)*localAmount)*PbrParams.x*3.0,vec3(0.5));
lit+=(1.0-lit)*(1.0-exp(-shine));
if(any(isnan(lit))||any(isinf(lit)))discard;
fragColor=vec4(sqrt(clamp(lit,0.0,1.0)),1.0);
@@ -1,8 +1,6 @@
#version 330
#extension GL_ARB_separate_shader_objects : require
#include <minecraft:dynamictransforms.glsl>
// Receiver and colour passes must produce bit-identical depth on every backend.
invariant gl_Position;
layout(location=0) in vec3 Position;
layout(location=1) in vec4 Color;
layout(location=2) in vec2 UV0;
@@ -1,17 +0,0 @@
#version 330
#extension GL_ARB_separate_shader_objects : require
uniform sampler2D OpaqueDepth;
layout(std140) uniform ReflectionScene { mat4 ReflectionVP; mat4 ReflectionInverseVP; vec4 ReflectionParams; vec4 ReflectionLimits; };
uniform sampler2D BlockAtlas;
layout(location=0) in vec3 relativePosition;
layout(location=1) in vec2 blockUv;
layout(location=2) in vec2 maskUv;
layout(location=3) in vec4 tint;
void main(){
vec2 dx=dFdx(blockUv),dy=dFdy(blockUv);
if(length(relativePosition)>=ReflectionParams.w)discard;
// Minecraft's world projection uses reverse depth: zero is the far plane.
float opaque=texelFetch(OpaqueDepth,ivec2(gl_FragCoord.xy),0).r;
if(gl_FragCoord.z+0.0000001<opaque)discard;
if(maskUv.y<2.0&&textureGrad(BlockAtlas,blockUv,dx,dy).a<.99)discard;
}
@@ -1,131 +0,0 @@
#version 330
#extension GL_ARB_separate_shader_objects : require
uniform sampler2D OpaqueDepth;
uniform sampler2D OpaqueColor;
uniform sampler2D ReceiverDepth;
uniform sampler2D SceneColor;
uniform sampler2D BlockAtlas;
uniform sampler2D MaterialAtlas;
layout(std140) uniform ReflectionScene {
mat4 ReflectionVP;
mat4 ReflectionInverseVP;
vec4 ReflectionParams; // zero-to-one, strength, normal strength, receiver distance
vec4 ReflectionLimits; // ray distance, steps, reserved
};
layout(location=0) in vec3 relativePosition;
layout(location=1) in vec2 blockUv;
layout(location=2) in vec2 maskUv;
layout(location=3) in vec4 tint;
layout(location=0) out vec4 fragColor;
vec3 reconstruct(vec2 uv,float d){
vec4 p=ReflectionInverseVP*vec4(uv*2.0-1.0,ReflectionParams.x>.5?d:d*2.0-1.0,1);
return p.xyz/p.w;
}
bool sampleRay(vec3 origin,vec3 direction,float t,out vec2 uv,out float gap){
vec3 p=origin+direction*t;vec4 clip=ReflectionVP*vec4(p,1);
if(clip.w<=.001)return false;
uv=clip.xy/clip.w*.5+.5;
if(any(lessThanEqual(uv,vec2(0)))||any(greaterThanEqual(uv,vec2(1))))return false;
float depth=textureLod(OpaqueDepth,uv,0).r;
if(depth<=0.0){gap=-1e6;return true;}
float rayDepth=ReflectionParams.x>.5?clip.z/clip.w:clip.z/clip.w*.5+.5;
gap=depth-rayDepth;return true;
}
vec4 trace(vec3 p,vec3 n,vec3 geometricNormal){
vec3 direction=reflect(normalize(p),n),origin=p+n*.035;
// A perturbed normal must not send the ray back through its own geometric face.
if(dot(direction,geometricNormal)<=.0001)return vec4(0);
vec4 a=ReflectionVP*vec4(origin,1),b=ReflectionVP*vec4(origin+direction*ReflectionLimits.x,1);
if(a.w<=.001)return vec4(0);
float distance=ReflectionLimits.x;
if(b.w<=.01){distance*=clamp((a.w-.01)/(a.w-b.w),0.0,1.0);b=ReflectionVP*vec4(origin+direction*distance,1);}
if(distance<.1)return vec4(0);
// Perspective-correct interpolation spaces the bounded march in screen coordinates.
vec2 start=a.xy/a.w*.5+.5,end=b.xy/b.w*.5+.5,delta=end-start;
float screenLimit=1.0;
if(delta.x>0.0)screenLimit=min(screenLimit,(.9999-start.x)/delta.x);
if(delta.x<0.0)screenLimit=min(screenLimit,(.0001-start.x)/delta.x);
if(delta.y>0.0)screenLimit=min(screenLimit,(.9999-start.y)/delta.y);
if(delta.y<0.0)screenLimit=min(screenLimit,(.0001-start.y)/delta.y);
if(screenLimit<=0.0)return vec4(0);
// Adaptive coarse march: large landscape silhouettes need fewer probes than screen pixels.
// The four refinements and final intersection interpolation preserve a sharp hit colour.
int steps=int(clamp(ceil(length(delta*screenLimit*vec2(textureSize(OpaqueDepth,0)))/128.0),8.0,32.0));
float previous=0.0,oldGap=-1e6;vec2 hit=vec2(0);float gap;
for(int i=1;i<=32;i++){
if(i>steps)break;
float f=float(i)/float(steps)*screenLimit;
float t=(f*distance/b.w)/mix(1.0/a.w,1.0/b.w,f);
vec2 uv;if(!sampleRay(origin,direction,t,uv,gap))return vec4(0);
if(gap>=0.0&&oldGap<0.0){
float lo=previous,hi=t,lowGap=oldGap,highGap=gap;
for(int j=0;j<4;j++){
float mid=(lo+hi)*.5;vec2 q;float delta;
if(!sampleRay(origin,direction,mid,q,delta))return vec4(0);
if(delta>=0.0){hi=mid;highGap=delta;}else{lo=mid;lowGap=delta;}
}
hi=mix(lo,hi,clamp(-lowGap/max(highGap-lowGap,1e-12),0.0,1.0));
if(!sampleRay(origin,direction,hi,hit,gap))return vec4(0);
// A bounded thickness rejects a silhouette jump into unrelated distant geometry.
float thickness=.15+.002*length(origin+direction*hi);
gap=length(origin+direction*hi)-length(reconstruct(hit,textureLod(OpaqueDepth,hit,0).r));
if(abs(gap)>thickness||hi<.08)return vec4(0);
float edge=min(min(hit.x,hit.y),min(1.0-hit.x,1.0-hit.y));
float confidence=smoothstep(0.0,.04,edge)*(1.0-smoothstep(distance*.85,distance,hi))
*(1.0-smoothstep(thickness*.65,thickness,abs(gap)))*smoothstep(.08,.2,hi);
return vec4(hit,hi,confidence);
}
previous=t;oldGap=gap;
}
return vec4(0);
}
void main(){
vec3 dx=dFdx(relativePosition),dy=dFdy(relativePosition);
vec2 mx=dFdx(maskUv),my=dFdy(maskUv),tx=dFdx(blockUv),ty=dFdy(blockUv);
float det=mx.x*my.y-mx.y*my.x;
float family=floor(maskUv.y*.5);
vec2 materialUv=vec2(maskUv.x,maskUv.y-family*2.0);
vec3 base=normalize(cross(dx,dy));if(dot(base,-relativePosition)<0.0)base=-base;
if(abs(det)<1e-14||length(relativePosition)>=ReflectionParams.w)discard;
// The final native depth includes entities rendered after the landscape capture.
float finalDepth=texelFetch(ReceiverDepth,ivec2(gl_FragCoord.xy),0).r;
vec2 screenUv=gl_FragCoord.xy/vec2(textureSize(ReceiverDepth,0));
if(finalDepth>0.0&&length(reconstruct(screenUv,finalDepth))+.005<length(relativePosition))discard;
float intensity=ReflectionParams.z;
if(intensity<=0.0)discard;
vec4 texel=textureGrad(BlockAtlas,blockUv,tx,ty);if(family<.5&&texel.a<.99)discard;
vec2 center=(floor(materialUv*1280.0)+.5)/1280.0;
vec3 u=(dx*my.y-dy*mx.y)/det,v=(dy*mx.x-dx*my.x)/det;
vec3 point=relativePosition+u*(center.x-materialUv.x)+v*(center.y-materialUv.y);
vec4 material=textureLod(MaterialAtlas,center,0);
vec2 slope=(material.rg*255.0-128.0)/127.0*intensity*(family>1.5?.24:2.0);
vec3 normal=normalize(base+normalize(u)*slope.x+normalize(v)*slope.y);
if(any(isnan(normal))||any(isinf(normal)))discard;
vec4 hit=trace(point,normal,base);if(hit.w<=0.0)discard;
vec3 reflected=textureLod(OpaqueColor,hit.xy,0).rgb;
if(material.b>.3){
vec2 stepUv=vec2(material.b*3.0)/vec2(textureSize(OpaqueColor,0));
float reference=length(reconstruct(hit.xy,textureLod(OpaqueDepth,hit.xy,0).r));
vec3 sum=vec3(0);float weight=0;
for(int i=0;i<4;i++){
vec2 uv=hit.xy+vec2(i%2==0?-1:1,i<2?-1:1)*stepUv;
float d=textureLod(OpaqueDepth,uv,0).r;
if(d>0.0&&abs(length(reconstruct(uv,d))-reference)<.25+reference*.002){sum+=textureLod(OpaqueColor,uv,0).rgb;weight+=1.0;}
}
if(weight>0.0)reflected=sum/weight;
}
float nv=clamp(dot(normal,normalize(-point)),0.0,1.0),grazing=1.0-nv,g2=grazing*grazing;
// Metal tint is sampled at the same texel centre, never at the individual screen fragment.
vec2 localDelta=center-materialUv;
vec2 centerBlock=blockUv+(tx*my.y-ty*mx.y)/det*localDelta.x+(ty*mx.x-tx*my.x)/det*localDelta.y;
vec3 albedo=textureLod(BlockAtlas,centerBlock,0).rgb*tint.rgb;
vec3 f0=mix(vec3(family>1.5?.02:.04),albedo*albedo,material.a);
vec3 fresnel=f0+(1.0-f0)*(g2*g2*grazing);
float fade=(1.0-smoothstep(ReflectionParams.w*.75,ReflectionParams.w,length(point)))*hit.w;
vec3 weight=clamp(fresnel*ReflectionParams.y*(1.0-material.b*material.b)*fade,0.0,.9);
vec3 scene=texelFetch(SceneColor,ivec2(gl_FragCoord.xy),0).rgb;
vec3 result=mix(scene*scene,reflected*reflected,weight);
if(any(isnan(result))||any(isinf(result)))discard;
fragColor=vec4(sqrt(clamp(result,0.0,1.0)),1);
}
@@ -1,4 +0,0 @@
{
"light": ["minecraft:sand", "minecraft:red_sand", "minecraft:gravel", "minecraft:clay"],
"pond": ["minecraft:dirt", "minecraft:coarse_dirt", "minecraft:rooted_dirt", "minecraft:grass_block", "minecraft:podzol", "minecraft:mycelium", "minecraft:mud", "minecraft:muddy_mangrove_roots"]
}
@@ -184,9 +184,6 @@
"NaturalSnowEditMixin"
],
"client": [
"client.ReflectionSceneMixin",
"client.PbrAnimationClockMixin",
"client.PbrAnimationSpriteMixin",
"client.PackSelectionScreenMixin",
"client.FourneauFireMixin",
"client.BoatPassengerPickMixin",
@@ -281,12 +278,7 @@
"client.PixelShadowRendererMixin",
"client.PixelShadowLevelMixin",
"client.PixelShadowDirtyMixin",
"client.PreferredGraphicsApiMixin",
"client.NaturalWaterRegionMixin",
"client.NaturalWaterFluidMixin",
"client.NaturalWaterFogMixin",
"client.NaturalWaterFogColorMixin",
"client.NaturalWaterSectionCopyAccess"
"client.PreferredGraphicsApiMixin"
],
"injectors": {
"defaultRequire": 1
@@ -0,0 +1,48 @@
package fr.koka.sanctuary.progression;
import java.nio.file.Files;
import java.util.UUID;
public final class AccessCodeSmoke {
public static void main(String[] args) throws Exception {
require(AccessCodes.normalize(" sanc-ab3d-k7pq ").equals("SANC-AB3D-K7PQ"), "Codes are trimmed and uppercased");
require(AccessCodes.validFormat("SANC-AB3D-K7PQ"), "Documented code shape is accepted");
require(!AccessCodes.validFormat("SANC-AB3D-K7P") && !AccessCodes.validFormat("SANC-AB3D-K7PQX"), "Partial codes are refused");
require(AccessCodes.validUsername("Steve") && !AccessCodes.validUsername("A") && !AccessCodes.validUsername("bad name"), "Session names stay Minecraft names");
var unused = AccessCodes.parse(200, "{\"valid\":true,\"reason\":\"unused\",\"minecraft_username\":\"Steve\",\"discord_id\":\"123456789012345678\"}");
require(AccessCodes.verifyKey(unused, "steve").equals("code_recognized"), "Verify matches the session name without consuming the code");
require(AccessCodes.verifyKey(AccessCodes.parse(200, "{\"valid\":false,\"reason\":\"invalid\"}"), "Steve").equals("code_invalid"), "Unknown code stays on the screen");
require(AccessCodes.verifyKey(AccessCodes.parse(200, "{\"valid\":false,\"reason\":\"already_used\"}"), "Steve").equals("code_used"), "Used code is not a fresh verification");
require(AccessCodes.parse(401, "").reason().equals("unauthorized"), "A bad token is not a character");
require(AccessCodes.parse(503, "nope").reason().equals("unreachable"), "An outage does not invent a character");
var redeemed = AccessCodes.parse(200, "{\"valid\":true,\"reason\":\"redeemed\",\"minecraft_username\":\"Steve\",\"discord_id\":\"42\"}");
var decision = AccessCodes.redeem(redeemed, "Steve");
require(decision.accepted() && decision.discordId().equals("42"), "Redeem is the commitment");
require(!AccessCodes.redeem(redeemed, "Alex").accepted(), "Another username cannot use the code");
var replay = AccessCodes.parse(200, "{\"valid\":false,\"reason\":\"already_used\",\"minecraft_username\":\"Steve\",\"discord_id\":\"42\"}");
require(AccessCodes.redeem(replay, "Steve").accepted(), "A consumed code can finish an interrupted creation when the account comes back");
var stuck = AccessCodes.parse(200, "{\"valid\":false,\"reason\":\"already_used\"}");
require(AccessCodes.redeem(stuck, "Steve").reason().equals("code_replay"), "A consumed code without an account does not create anyone");
var path = Files.createTempDirectory("sanctuary-access-").resolve("access.json");
UUID id = UUID.fromString("01234567-89ab-cdef-fedc-ba9876543210");
var ledger = new AccessLedger(path, 42L);
ledger.remember(id, "Steve", "42");
var reopened = new AccessLedger(path, 42L);
require(reopened.known(id).discordId().equals("42"), "A crash after redeem keeps the Discord account");
reopened.commit(id);
var committed = new AccessLedger(path, 42L);
require(committed.known(id).name().equals("Steve"), "The binding survives the character write");
committed.commit(id);
committed.remember(id, "Steve", "42");
String saved = Files.readString(path);
require(saved.indexOf(id.toString()) == saved.lastIndexOf(id.toString()), "An existing character is not returned to pending");
byte[] broken = "{".getBytes();
Files.write(path, broken);
boolean refused = false;
try { new AccessLedger(path, 42L); } catch (IllegalArgumentException error) { refused = true; }
require(refused && Files.readAllBytes(path).length == 1, "A corrupt ledger is preserved and refused");
System.out.println("ACCESS-01: code contract, replay and ledger passed");
}
private static void require(boolean value, String message) { if (!value) throw new AssertionError(message); }
}
+1 -1
View File
@@ -1,6 +1,6 @@
name = "Sanctuary"
author = "KOKA99CAB"
version = "beta.146"
version = "beta.144"
pack-format = "packwiz:1.1.0"
[index]
-27
View File
@@ -1,27 +0,0 @@
#!/usr/bin/env python3
"""Check the eight alternating water benchmark passages per scene.
Usage: python3 scripts/check_water_performance.py build/water144-vulkan.log
Writes the detailed receipt beside the log, and rejects incomplete/over-budget runs.
The caller must also verify that no other graphics test ran during measurement.
"""
import json,re,sys
from pathlib import Path
log_path=Path(sys.argv[1])
backend=log_path.stem
log=log_path.read_text()
rows={key:json.loads(data) for key,data in re.findall(r"WATER144_PERF (\S+) (\[[^\n]+\])",log)}
assert len(rows)==24, ("Incomplete measurements",len(rows))
result={"backend":backend,"samples":rows,"scenes":{}}
for scene in ("mixed","large","moving"):
off=[v for k,v in rows.items() if k.startswith(scene+"-false-")]
on=[v for k,v in rows.items() if k.startswith(scene+"-true-")]
assert len(off)==len(on)==4 and all(v[0]>50 for v in off+on)
median_off=sum(v[1] for v in off)/len(off);median_on=sum(v[1] for v in on)/len(on)
p95_off=sum(v[2] for v in off)/len(off);p95_on=sum(v[2] for v in on)/len(on)
value={"offMedianMs":median_off,"onMedianMs":median_on,"medianIncreasePercent":100*(median_on/median_off-1),"offP95Ms":p95_off,"onP95Ms":p95_on,"p95IncreaseMs":p95_on-p95_off}
value["passed"]=value["medianIncreasePercent"]<=5 and value["p95IncreaseMs"]<=2
result["scenes"][scene]=value
log_path.with_name(log_path.stem+"-performance.json").write_text(json.dumps(result,indent=2)+"\n")
print(json.dumps(result["scenes"],indent=2))
assert all(v["passed"] for v in result["scenes"].values()), "Performance acceptance not met"
-7
View File
@@ -8,10 +8,3 @@ ajoute les ombres portées pixélisées optionnelles, configurables depuis
Ce dossier reste réservé aux futurs packs externes. Leur ajout au manifeste
packwiz nécessitera version, URL, hash, licence et compatibilité vérifiée pour
la version exacte du jeu et du moteur Iris.
La beta.144 ajoute l’[eau naturelle](../docs/natural-water-beta144.md) dans
le rendu natif : profils selon le fond, océans clairs et brume sous-marine.
La livraison locale beta.145 ajoute le [PBR de l’eau et du verre ainsi que les
reflets pixélisés](../docs/pbr-ssr.md). SSR désactivé par défaut ; aucune
dépendance graphique externe.