Compare commits

...
Author SHA1 Message Date
koka 0eb1a381d4 beta.154: conversations communautaires et stockage partagé avec le site
Build Sanctuary / build (push) Waiting to run
2026-09-22 20:28:12 +02:00
koka b97ec6c9e8 Fix dimension inventory synchronization and opaque PBR sheen in beta.151
Build Sanctuary / build (push) Waiting to run
2026-09-18 22:11:32 +02:00
koka 448e2febff Keep soil matte and align solar wave glints with native sunset in beta.150
Build Sanctuary / build (push) Waiting to run
2026-09-18 21:47:33 +02:00
koka 1c23d0c17e Separate PBR and SSR ranges and remove foliage sheen in beta.149
Build Sanctuary / build (push) Waiting to run
2026-09-18 20:40:47 +02:00
koka 6f871446bb Balance PBR at 50 percent and soften solar water highlights in beta.148
Build Sanctuary / build (push) Waiting to run
2026-09-18 20:12:08 +02:00
koka 96a36d99be Fix close PBR flicker, soften moonlight and extend range in beta.147
Build Sanctuary / build (push) Waiting to run
2026-09-18 19:56:03 +02:00
koka cf9a670f44 Prepare beta.146 reflections and water blending
Build Sanctuary / build (push) Waiting to run
2026-09-18 19:40:25 +02:00
koka ed2d088272 Validate and package experimental material reflections as beta.145
Build Sanctuary / build (push) Waiting to run
2026-09-18 18:04:21 +02:00
koka 0cfd51eb4f Integrate frozen beta.144 natural water sources for combined SSR validation 2026-09-18 14:41:53 +02:00
koka 38f4d6c9a9 Implement native texel reflections and transparent PBR surfaces 2026-09-18 14:39:26 +02:00
koka 580b2414da Document beta.143 publication and Prism synchronization
Build Sanctuary / build (push) Canceled after 0s
2026-09-18 12:44:49 +02:00
koka 97ff2b54cb Slow seasonal snow pacing in beta.143
Build Sanctuary / build (push) Canceled after 0s
2026-09-18 12:41:27 +02:00
koka 59346d7f0a Document verified beta.142 publication and Prism synchronization
Build Sanctuary / build (push) Canceled after 0s
2026-09-18 05:08:09 +02:00
koka ea151dd5df Prefer native Vulkan and stabilize PBR lighting in beta.142
Build Sanctuary / build (push) Canceled after 0s
2026-09-18 05:05:49 +02:00
koka 7ce01c47f2 Document beta.141 publication and verified instance update
Build Sanctuary / build (push) Canceled after 0s
2026-09-18 04:13:00 +02:00
koka 93ed5574a4 Drive PBR normals with cached local light directions in beta.141
Build Sanctuary / build (push) Canceled after 0s
2026-09-18 04:11:03 +02:00
koka c9f5c56e68 Document verified beta.140 publication and Prism synchronization
Build Sanctuary / build (push) Canceled after 0s
2026-09-18 03:15:07 +02:00
koka 6e1b3b7108 Add colored dynamic lights and smooth hue-preserving fades for beta.140
Build Sanctuary / build (push) Canceled after 0s
2026-09-18 03:13:43 +02:00
koka c05c338e6b Record beta.139 release and preserved Prism state
Build Sanctuary / build (push) Canceled after 0s
2026-09-18 02:27:19 +02:00
koka dc33ffa546 Add optional local colored lights for beta.139
Build Sanctuary / build (push) Canceled after 0s
2026-09-18 02:25:48 +02:00
koka 9d84a02ed9 Verify Sanctuary shaders on Vulkan with explicit backend tests
Build Sanctuary / build (push) Canceled after 0s
2026-09-18 01:59:13 +02:00
koka 91181865d5 Record beta.138 release and preserved Prism state
Build Sanctuary / build (push) Canceled after 0s
2026-09-18 01:25:31 +02:00
koka daf36fdeac Adapt solar volume density to weather for beta.138
Build Sanctuary / build (push) Canceled after 0s
2026-09-18 01:24:13 +02:00
koka 7f576d14d9 Record beta.137 release and verified Prism update
Build Sanctuary / build (push) Canceled after 0s
2026-09-18 01:01:59 +02:00
koka 2d7072b8a8 Add native solar volume rendering for beta.137
Build Sanctuary / build (push) Canceled after 0s
2026-09-18 01:00:20 +02:00
koka 0781cbd964 Record beta.136 publication and preserved Prism state
Build Sanctuary / build (push) Canceled after 0s
2026-09-18 00:28:20 +02:00
koka 71296a9cfb Add soft celestial lens flare for beta.136
Build Sanctuary / build (push) Canceled after 0s
2026-09-18 00:27:14 +02:00
koka 07d09c8508 docs: record beta.135 release and verified Prism update
Build Sanctuary / build (push) Canceled after 0s
2026-09-17 23:48:14 +02:00
koka 7fada570ef fix: stabilize close-range SSGI and update shader defaults for beta.135
Build Sanctuary / build (push) Canceled after 0s
2026-09-17 23:46:29 +02:00
koka b7cf4bc294 docs: record beta.134 release and Prism synchronization
Build Sanctuary / build (push) Canceled after 0s
2026-09-17 22:56:48 +02:00
koka fc4a0cb68e feat: add optional PBR and filter SSGI in beta.134
Build Sanctuary / build (push) Canceled after 0s
2026-09-17 22:54:38 +02:00
koka 17172fd18b docs: record beta.133 release and Prism synchronization
Build Sanctuary / build (push) Canceled after 0s
2026-09-17 22:19:32 +02:00
koka 216d3f75b5 feat: make SSGI color bounce visible in beta.133
Build Sanctuary / build (push) Canceled after 0s
2026-09-17 22:18:12 +02:00
koka e48fc656c7 docs: record beta.132 release and Prism synchronization
Build Sanctuary / build (push) Canceled after 0s
2026-09-17 21:58:39 +02:00
koka 7e21781a9a feat(shader): experimental local diffuse SSGI in beta.132
Build Sanctuary / build (push) Canceled after 0s
2026-09-17 21:57:04 +02:00
koka d941b91265 docs: record beta.131 release and Prism synchronization
Build Sanctuary / build (push) Canceled after 0s
2026-09-17 21:34:00 +02:00
koka 078d3e6833 feat(shader): independent emissive cores and precision options in beta.131
Build Sanctuary / build (push) Canceled after 0s
2026-09-17 21:32:28 +02:00
139 changed files with 9225 additions and 125 deletions
+1
View File
@@ -11,6 +11,7 @@ La vision est dans `docs/vision.md` ; elle décrit aussi des fonctionnalités fu
- Ne pas modifier un monde existant, régénérer des chunks, changer un format de sauvegarde ou activer une expansion sans contrat de migration explicite.
- Garder les identifiants `sanctuary:*` stables et documenter toute évolution de la génération avec graine et version.
- Ajouter les libellés FR/EN des nouvelles interfaces. Vérifier les dépendances pour la version Minecraft exacte ; aucune compatibilité supposée à partir du nom d'un mod.
- Les tests graphiques Sanctuary ciblent uniquement Vulkan. OpenGL est abandonné comme cible de validation depuis le 18 septembre 2026 : ne plus lancer de suite OpenGL ni revendiquer sa prise en charge à partir des essais historiques.
- Lancer `./gradlew check build` pour livrer du code, et `./gradlew assemblePack` si la distribution change. Ajouter seulement les tests utiles au comportement touché.
- Pour chaque nouvelle livraison du mod, incrémenter le compteur `beta.xxx` (départ `beta.001`) dans `mod_version` et `pack_version` de `gradle.properties`, synchroniser `packwiz/pack.toml` et documenter le changement. Le tag reprend cette version exacte, sans préfixe `v` ; voir `docs/versioning.md`. Une simple modification documentaire n'incrémente pas les binaires.
- Le pack Beta suit un canal packwiz stable et une seule instance Prism. Pour une mise à jour demandée, suivre `docs/packwiz.md` : publier un artefact vérifié et immuable, avancer le canal, puis synchroniser l'instance existante en conservant ses sauvegardes et réglages.
+173 -9
View File
@@ -1,5 +1,62 @@
# Sanctuary
## beta.154 — Gazette et tableau communautaire (pack local)
Panneaux gauche/droite du menu pause, intendance, publication, réponses,
modification et modération. Stockage fichier par défaut ; MariaDB optionnelle
commune avec le site. Shaders beta.151 conservés.
[Contrat et configuration](docs/community-contract-v1.md) ·
[Vérifications et limites](docs/community-beta154.md).
## beta.151 — Nether, reflets des blocs et SSR (pack local)
Synchronisation de l’inventaire au changement de dimension, retour de l’intensité
spéculaire des blocs solides à son niveau antérieur et bois mat avec relief.
SSR activé par défaut à 20 %, choix sauvegardés conservés.
[Contrat et vérifications](docs/nether-pbr-beta151.md).
## beta.150 — Terre mate et reflets du coucher de soleil (pack local)
Terre mate avec relief conservé. Le soleil et son reflet se colorent ensemble
en suivant la couleur et la transition du coucher de soleil natif de Minecraft.
Les accents lumineux suivent les normales animées des vaguelettes et leur
orientation vers le soleil et la caméra. [Contrat et vérifications](docs/matte-dirt-beta150.md).
## beta.149 — Distances PBR / SSR et feuillage (pack local)
Deux distances séparées, 64 blocs par défaut chacune, jusqu’à 256 blocs.
Option renommée « SSR » et suppression du voile spéculaire sur le feuillage.
[Contrat et vérifications](docs/independent-distances-beta149.md).
## beta.148 — PBR 50 % et soleil diffus (pack local)
PBR à 50 %, reflet solaire plus doux avec fusion par texel préservant
la texture de l’eau. SSR à 20 %, portée commune jusqu’à 256 blocs.
[Contrat et vérifications](docs/pbr-soft-sun-beta148.md).
## beta.147 — PBR proche, lune et portée 256 (pack local)
Correction du scintillement à très courte distance, reflet lunaire adouci
et distance commune PBR/SSR jusqu’à 256 blocs.
[Contrat et vérifications](docs/pbr-close-range-beta147.md).
## 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
@@ -20,13 +77,19 @@ 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.130** ajoute un bloom réglable, les inclusions émissives des
minerais et une **Précision** commune aux ombres et au highlight. Par défaut :
ombres activées à 128 blocs, highlight à 32 blocs, précision 16 pixels/bloc.
**Options → Shaders → Sanctuary · Vanilla Light**. Les réglages personnalisés
sont conservés. Le [ticket de livraison](docs/shader-beta130.md) précise le
résultat et les vérifications. Release publiée et même instance **Sanctuary Beta**
synchronisée, sauvegardes et réglages conservés.
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
restent inchangées : neige construite, biomes froids, hiver, profils neigeux
et météo Vanilla. Le [ticket](docs/realtime-snow-melt-beta143.md) documente le
contrat et les vérifications.
La beta.143 est publiée et synchronisée dans l’instance Sanctuary Beta ;
les 923 fichiers personnels suivis sont conservés.
La [livraison précédente](docs/clay-workshop-integration-beta110.md) réunit déjà
l’atelier, les œufs et la neige saisonnière dans l’instance **Sanctuary Beta**.
Les [synchronisations antérieures](docs/git-sync-beta092.md) restent documentées.
@@ -37,6 +100,107 @@ La [carte native beta.005](docs/atlas-beta005.md) prend le relais après
l’étude de Xaero et des alternatives libres.
## beta.143 — Neige saisonnière en temps réel
Accumulation ralentie par un facteur 12 et fonte saisonnière ralentie par le
facteur naturel 72, tout en conservant le sampler natif des colonnes de
précipitation. Deux blocs de neige fondent en environ 2,7 jours réels en été,
5,4 au printemps et 11 en automne.
[Contrat et vérifications](docs/realtime-snow-melt-beta143.md) ·
[Pack normal](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.143/Sanctuary-beta.143.mrpack) ·
[Pack de test](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.143/Sanctuary-Test-beta.143.mrpack).
## beta.142 — Vulkan par défaut et stabilité PBR
Moteur natif Vulkan prioritaire, relief conservé pendant les reconstructions,
calculs de reflets bornés et intensité initiale 80 %. Choix personnels conservés.
[Contrat et vérifications](docs/pbr-refresh-beta142.md) ·
[Pack normal](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.142/Sanctuary-beta.142.mrpack) ·
[Pack de test](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.142/Sanctuary-Test-beta.142.mrpack).
## beta.141 — Direction locale et normales PBR
Relief et reflets orientés par les lumières locales. Cache compact, confiance
réduite pour les sources opposées, objets portés et fondus. Activer PBR et
Lumières colorées ; portée PBR 32 blocs.
[Contrat et vérifications](docs/directional-lights-beta141.md) ·
[Pack normal](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.141/Sanctuary-beta.141.mrpack) ·
[Pack de test](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.141/Sanctuary-Test-beta.141.mrpack).
## beta.140 — Lumières portées et fondus
Mains et tête, distances 8/16/32/64, fondus, cuivre vert, redstone rouge et
option pour la chaleur des torches. Lumières colorées activées à 30 % par défaut.
[Contrat et vérifications](docs/colored-dynamic-beta140.md) ·
[Pack normal](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.140/Sanctuary-beta.140.mrpack) ·
[Pack de test](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.140/Sanctuary-Test-beta.140.mrpack).
## beta.139 — Lumières colorées
Teintes chaudes, sources des âmes bleues, redstone rouge, froglights et matériaux lumineux.
Option désactivée initialement, intensité 35 % ; propagation locale avec obstacles.
[Contrat et vérifications](docs/colored-lights-beta139.md) ·
[Pack normal](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.139/Sanctuary-beta.139.mrpack) ·
[Pack de test](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.139/Sanctuary-Test-beta.139.mrpack).
## beta.138 — Rayons et brouillard
Densité automatique des rayons selon le brouillard et les précipitations.
[Contrat et vérifications](docs/shader-beta138.md) ·
[Pack normal](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.138/Sanctuary-beta.138.mrpack) ·
[Pack de test](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.138/Sanctuary-Test-beta.138.mrpack).
## beta.137 — Rayons solaires
Faisceaux volumétriques légers, intensité réglable et feuillage perméable.
[Contrat et vérifications](docs/shader-beta137.md) ·
[Pack normal](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.137/Sanctuary-beta.137.mrpack) ·
[Pack de test](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.137/Sanctuary-Test-beta.137.mrpack).
## beta.136 — Lens flare rectangulaire
Soleil et lune, reflets doux et intensité indépendante du bloom.
[Contrat et vérifications](docs/shader-beta136.md) ·
[Pack normal](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.136/Sanctuary-beta.136.mrpack) ·
[Pack de test](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.136/Sanctuary-Test-beta.136.mrpack).
## beta.135 — SSGI près des surfaces
Correction des coupures à courte distance, sans flouter les textures.
[Contrat et vérifications](docs/shader-beta135.md) ·
[Pack normal](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.135/Sanctuary-beta.135.mrpack) ·
[Pack de test](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.135/Sanctuary-Test-beta.135.mrpack).
## beta.134 — PBR optionnel et SSGI filtré
Relief et reflets des matériaux ; lissage de la lumière indirecte.
[Contrat et vérifications](docs/shader-beta134.md) ·
[Pack normal](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.134/Sanctuary-beta.134.mrpack) ·
[Pack de test](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.134/Sanctuary-Test-beta.134.mrpack).
## beta.133 — SSGI renforcé
Couleurs voisines plus visibles, même budget de rayons et de cibles GPU.
[Contrat et vérifications](docs/shader-beta133.md) ·
[Pack normal](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.133/Sanctuary-beta.133.mrpack) ·
[Pack de test](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.133/Sanctuary-Test-beta.133.mrpack).
## beta.132 — SSGI : diffusion locale des couleurs
Rebond diffus des couleurs visibles, intensité réglable et reconstruction
préservant les silhouettes. Expérimental, initialement désactivé.
[Contrat et vérifications](docs/shader-beta132.md) ·
[Pack normal](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.132/Sanctuary-beta.132.mrpack) ·
[Pack de test](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.132/Sanctuary-Test-beta.132.mrpack).
## beta.131 — Émissifs indépendants et précision étendue
Bloom, émissifs et highlight activés initialement ; halo facultatif et cinq
précisions communes de 8 à 128 pixels/bloc.
[Contrat et vérifications](docs/shader-beta131.md) ·
[Pack normal](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.131/Sanctuary-beta.131.mrpack) ·
[Pack de test](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.131/Sanctuary-Test-beta.131.mrpack).
## beta.130 — Bloom, minerais émissifs et précision commune
Halo réglable des sources lumineuses et inclusions des vingt textures de
@@ -2212,7 +2376,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.122 |
| Sanctuary / pack | beta.151 |
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
@@ -2241,7 +2405,7 @@ décrits dans [Validation](docs/testing.md).
Résultats :
- `mods/sanctuary/build/libs/sanctuary-beta.122.jar` : mod à installer avec
- `mods/sanctuary/build/libs/sanctuary-beta.151.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).
+7
View File
@@ -136,3 +136,10 @@ intégré et le template personnel (SHA-256
`a97570908db75caf8e6fc2f5ecabf12c54fd99bfea7be634df66c4310dca22af`).
Le template regroupe les ressources client déjà distribuées par Sanctuary ;
leurs crédits et conditions respectives restent applicables.
MariaDB Connector/J 3.5.10 (`org.mariadb.jdbc:mariadb-java-client`),
MariaDB Corporation et contributeurs : LGPL-2.1-or-later. Le JAR officiel est
embarqué comme dépendance imbriquée, sans modification, pour le stockage
communautaire optionnel. Sa licence est fournie dans `licenses/mariadb-connector-j-LGPL-2.1.txt` du JAR Sanctuary.
Sources correspondantes : https://github.com/mariadb-corporation/mariadb-connector-j/tree/3.5.10
(distribution Maven Central 3.5.10).
+7
View File
@@ -1,5 +1,12 @@
# Backlog Sanctuary
## COMM-154 — Communauté dans le menu pause et sur le site
Branches `codex/community-beta154` (mod) et `codex/community-contract` (site).
Résultat : Gazette, tableau et intendance avec lecture, publication, réponses,
droits serveur et comptes liés ; fichier autonome ou MariaDB partagée.
[Contrat](community-contract-v1.md), [livraison locale](community-beta154.md).
## STAT-03 — Atelier d’argile et hotbar — beta.112
Branche `codex/clay-workshop-hotbar-beta112`. [Contrat et vérifications](clay-workshop-hotbar-beta112.md).
+112
View File
@@ -0,0 +1,112 @@
# LIGHT-140 — Lumières colorées portées et distance
Socle beta.139 `c05c338`, branche `codex/colored-dynamic-beta140`.
Minecraft 26.3 / Java 25. Livraison beta.140.
## Contrat
Relier les couleurs aux vraies sources de lumière dynamique : mains, tête,
cosmétiques, objets au sol et feu, selon le système existant. Renforcer la
teinte et activer les lumières colorées par défaut à 30 %. Ajouter une distance
d'affichage de 8, 16, 32 ou 64 blocs (16 par défaut), indépendante de la portée
native de chaque source. Augmenter la distance coûte davantage en mémoire et
calcul ; aucun chargement forcé de chunk. Les choix sauvegardés sont conservés.
Sources portées actualisées avec le système dynamique, sans reconstruire tout
le champ statique à chaque mouvement. Aucun changement de monde ou de règle
serveur. Vérifications natives OpenGL et Vulkan, puis construction et archives.
## Couleurs et fondus
Torches ordinaires : température native par défaut ; option « Torches chaudes »
pour retrouver l'orangé. Glowstone jaune, lave jaune-rouge, redstone rouge saturé,
cuivre vert (posé, en main ou sur la tête). Le cuivre est reconnu avant les
familles génériques de torches et lanternes. Les palettes explicites restent
indépendantes des packs ; le repli des autres matériaux suit leurs textures.
Le rendu s'atténue sur le dernier quart de la distance choisie. Le champ précédent
reste disponible pendant un fondu de 300 ms vers le nouveau champ ; l'activation
et les sources dynamiques ont aussi un fondu. Le changement de distance est
interpolé. Les sources mobiles utilisent la même sélection bornée et les mêmes
intensités que la lumière dynamique existante, avec interpolation visuelle des
positions et couleurs. Elles héritent de sa propagation radiale, y compris de
ses limites d'occultation ; les blocs posés conservent leur propagation avec murs.
Le volume varie réellement selon la distance (56, 72, 104 ou 168 blocs de côté,
avec marge de propagation). Les grandes distances augmentent la mémoire et le
délai de reconstruction ; la construction reste répartie entre les images.
Deux champs coexistent brièvement pour le fondu. Seuls les chunks déjà chargés
sont lus. La portée lumineuse native des torches n'est pas allongée.
## Frontières de saturation
Le stockage beta.139 tronquait les canaux faibles séparément en fin de portée :
un bleu pouvait perdre son rouge avant son vert, puis devenir plus saturé sur
une frontière visible. Le stockage logarithmique conserve maintenant une marge
sous le niveau lumineux natif zéro. Le shader applique la coupure à l'énergie
globale en conservant les rapports RGB. La réponse devient linéaire près du noir,
sans amplification par racine quatrième des très faibles contributions.
La transition des champs interpole le résultat colorimétrique rendu, pas une
énergie ensuite amplifiée. Les sources mobiles ont une atténuation de fondu
compensée pour la même réponse. Le test contrôle les rapports rouge/bleu et
vert/bleu d'une torche des âmes de 1 à 9 blocs, jusqu'au bord de sa portée.
## Vérifications natives
Minecraft 26.3 / Java 25 sur Apple M1, mondes de test neufs de graine 122 :
- OpenGL 4.1 Metal : **2 min 40 s**, `build/colored140-opengl.log`.
- Vulkan / MoltenVK 1.4.2, profondeur 0–1 et transparence améliorée :
**2 min 26 s**, `build/colored140-vulkan.log`.
Les deux exécutions passent `COLORED139_PASS` et `COLORED140_PASS` :
propagation avec murs, sources hors champ, lampes allumées/éteintes, retrait,
rechargement des ressources, cache, déplacement/retour de caméra, OFF/zéro,
préférences et traductions FR/EN. Les nouveaux contrôles couvrent la teinte
bleue jusqu'à neuf blocs, la torche native inchangée, l'option chaude, le cuivre
vert et le rouge saturé, les deux mains et le cosmétique de tête, la position
en troisième personne, les quatre distances et un fondu sans dépassement.
Dans la même scène contrôlée, la somme d'écarts OFF/torche chaude à 30 % vaut
3 926 093 / 3 926 092 sur OpenGL/Vulkan, contre 2 025 577 en beta.139 à 35 %.
Cette mesure vérifie l'accentuation demandée, pas la performance. Le test fige
uniquement le scintillement de la lightmap native ; cette sonde et les tests
client ne sont pas distribués. Les captures finales sont conservées sous
`build/colored140-opengl-screenshots/` et `build/colored140-vulkan-screenshots/`.
Comparatif autonome : `build/Shader-beta.140-comparaison.html`.
Pas de validation des pilotes Windows de l'utilisateur revendiquée. Les
limites de beta.139 pour le rendu sous l'eau/lave et les panoramas restent
ouvertes. Les sources dynamiques gardent l'occultation du système existant.
Aucun monde personnel ouvert ni modifié.
## Construction et archives
`./gradlew check build assemblePack assembleTestPack -x :sanctuary:runGameTest`
réussit en **2 min 22 s**, 126 tâches. Le GameTest serveur dédié reste exclu
conformément au refus EULA antérieur ; les tests clients ci-dessus sont exécutés
séparément. Log : `build/shader140-build.log`.
La comparaison avec beta.139 trouve **18 entrées de production modifiées**,
limitées aux lumières dynamiques/colorées, options et aides FR/EN. Les sources
des JAR correspondent aux fichiers du dépôt. Les archives normale/Test contiennent
le même JAR Sanctuary ; la version, les dépendances exactes, les textures de
gemmes et de la clé de Steve, l'absence de classes de test et le template complet
sont vérifiés. Reçu : `build/shader140-artifact.json`.
SHA-256 du JAR : `853284c1a61c7fce12edd2f7763717321eef56977290eb0e7c5db5e30bff442f`.
- Sanctuary-beta.140.mrpack : `1b366746e4d6bebfbe0115c849524bdeb81c14eee89a2510f9dbe18808974d6a`.
- Sanctuary-Test-beta.140.mrpack : `50c3cdf759117bb5286d41b4535fde15d9d84deaac833b7f8f61c4a0d29b72e7`.
## Livraison
[Release beta.140](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.140)
publiée sur le commit source `6e1b3b71088437fe57cf2b9b979b6d62711628d3` ; canal stable
`2c360a0759d4a164f82c89ac2bef04ff69d50875` vérifié après publication.
Deux synchronisations isolées puis deux dans la même instance Sanctuary Beta
réussissent. Les **923 fichiers personnels** suivis gardent leurs hashes.
Sauvegarde préalable : `sanctuary-backups/before-beta.140/`. Aucun monde personnel
ouvert. Reçus : `build/shader140-isolated.json` et `build/shader140-prism.json`.
Archives normale/Test/template et comparaison copiées dans `sanctuary-beta/build/`.
+107
View File
@@ -0,0 +1,107 @@
# LIGHT-139 — Lumières colorées
Socle beta.138 + vérification Vulkan `9d84a02`, Minecraft 26.3 / Java 25.
Branche `codex/colored-lights-beta139`, livraison beta.139.
## Contrat
Option locale « Lumières colorées » avec intensité. Les vrais blocs émetteurs
teintent discrètement leurs environs, y compris hors champ. Torches/lanternes,
lave et feu chauds ; sources des âmes bleues ; redstone active rouge ;
froglights par variante ; sources Sanctuary selon leur texture/matière.
Les blocs éteints n'émettent pas. Le bloom et les inclusions émissives des
minerais restent indépendants ; aucune lumière de gameplay n'est ajoutée.
Propagation RGB dans un volume local de blocs chargés, obstacles et formes
natives, construction répartie entre les images, cache des couleurs de
matériaux. Aucun chargement forcé de chunk, accès aux mondes personnels,
changement de sauvegarde, réseau ou règles serveur.
## Réalisation
Dans les options du shader : « Lumières colorées », désactivées initialement,
et une intensité de 0 à 100 %, réglée à 35 %. Zéro ou OFF libère les ressources.
Les préférences existantes sont conservées. Libellés et aides FR/EN.
Les familles vanilla demandées ont une palette dédiée. Les autres émetteurs,
y compris ceux de Sanctuary, utilisent la couleur des pixels lumineux de leurs
textures de modèle, calculée puis mise en cache. Ce repli suit les packs de
ressources ; les palettes dédiées vanilla restent constantes. Une texture
illisible ou sans pixels exploitables utilise du blanc. Les blocs purement
émissifs du bloom, sans émission lumineuse native, ne deviennent pas des lampes.
Le champ couvre un cube de 64 blocs de côté autour de la caméra. Une marge de
14 blocs réserve la propagation aux sources présentes dans le volume ; le rendu
s'atténue progressivement sur 4 blocs à sa périphérie. C'est donc un effet de
proximité, pas une illumination de tous les chunks visibles. Les formes et
l'opacité natives règlent le passage de la lumière. Les canaux utilisent des
maxima indépendants, sans addition illimitée des sources superposées.
Le calcul CPU est réparti avec un budget visé de 2 ms par image, plus les
allocations et chargements ponctuels de matériaux ; aucun chiffre de FPS n'est
garanti. Un monde immobile réutilise son champ. Les changements rapides et les
déplacements peuvent présenter un bref retard, le temps de publier un champ
complet. Aucun chargement forcé de chunk. Une texture 512 × 512 contient le champ.
La passe GPU teinte les surfaces visibles, atténue l'effet au soleil et dans le
brouillard, puis laisse agir le SSGI et le bloom. Les sources tenues, entités,
particules et le rendu sous l'eau/lave ne sont pas couverts par cette option.
## Vérifications
Essais client natifs réussis dans des mondes neufs (graine 122) sur Apple M1 :
- OpenGL 4.1 Metal : **1 min 46 s**, `build/colored139-opengl.log`.
- Vulkan / MoltenVK 1.4.2, profondeur 0–1 et transparence améliorée :
**1 min 30 s**, `build/colored139-vulkan.log`.
Les deux journaux contiennent `COLORED139_PASS` et le backend réellement actif.
Torche et torche des âmes hors champ, trois froglights, pierre lumineuse dont
la couleur vient de sa texture, intensité 35/100, cache statique, déplacement
et retour de caméra, mur fermé puis ouvert, lampe redstone allumée puis éteinte,
retrait de source, rechargement des ressources et OFF/zéro vérifiés.
Les préférences persistantes et les libellés FR/EN sont contrôlés ; une dernière
capture active conjointement PBR, SSGI, bloom, ombres, highlights et rayons.
Les sommes d'écart entre OFF et 35 % sont presque identiques entre backends :
torche 2 025 577 / 2 025 576 ; âmes 964 200 / 964 201. Il s'agit de mesures
sur les pixels de cette scène, pas d'un indice de performance.
Comparatif autonome : `build/Shader-beta.139-comparaison.html`.
Pas de validation du pilote Vulkan Windows de l'utilisateur revendiquée.
Le test fige uniquement le scintillement vanilla des torches afin de comparer
les images ; cette sonde est exclue du JAR distribué. Aucun monde personnel ouvert.
## Diagnostic des essais
La première comparaison de stabilité détectait le scintillement natif de la
lightmap des torches, même avec un champ coloré inchangé. La sonde de test
stabilise ce seul facteur ; aucune désactivation de scintillement en production.
La pièce d'essai est désormais construite après le chargement de ses chunks,
et l'absence de lumière du ciel est vérifiée avant les captures.
## Construction et archives
`./gradlew check build assemblePack assembleTestPack -x :sanctuary:runGameTest`
réussit en **2 min 22 s**, 126 tâches. Le serveur GameTest dédié reste exclu
conformément au refus EULA antérieur. Log : `build/shader139-build.log`.
La comparaison à beta.138 trouve **13 entrées de production** modifiées,
limitées au shader, à son menu et aux aides FR/EN. Les sondes de test sont
absentes du JAR distribué. Sources, archives normale/Test et template complets
vérifiés ; textures des gemmes et clé de Steve conservées à l'identique.
Reçu local : `build/shader139-artifact.json`.
JAR SHA-256 : `7b958f6576adbabb2b1af589b866cb52b1a65600ea2fa505b05dd283f8d595df`.
- `Sanctuary-beta.139.mrpack` : 10450317 octets ; SHA-256 `13f3a283c260876d28ce0c6504615b4a8f4d575906badfbb2b39558f312e9fbb`.
- `Sanctuary-Test-beta.139.mrpack` : 10469244 octets ; SHA-256 `c1e0b684dc2650950a0925c4625ef5eb4b78fb5f1c5680f5e302550424fa6d81`.
## Publication et instance
[Release beta.139](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.139)
publiée depuis `dc33ffa54697f61cd8f579235e7cebdbf0765f7c`, tag exact `beta.139`.
Canal packwiz : `9f10167b6acfece9c045573580e186aa72bc91dc`.
Deux synchronisations isolées puis deux synchronisations de la même instance
Prism réussissent. Un seul JAR Sanctuary beta.139 actif, empreinte identique
à l'archive vérifiée ; les **923 fichiers personnels suivis** conservent leurs
empreintes. Sauvegarde des fichiers gérés dans
`sanctuary-backups/before-beta.139/`. Aucun monde personnel ouvert.
Reçus : `build/shader139-isolated.json`, `build/shader139-prism.json`.
+115
View File
@@ -0,0 +1,115 @@
# COMM-154 — Communauté et conversations, beta.154
Livraison locale préparée sur `codex/community-beta154`, depuis beta.151
`b97ec6c9e820bed7693e75f200a02b16af01cf89`. Le site est préparé sur
`codex/community-contract`, depuis `4af93d1e42ba6084ecd654967ad6d8f317a5d3ef`.
Contrat identique dans les deux dépôts : [communauté v1](community-contract-v1.md).
## Résultat
Gazette à gauche et tableau à droite du menu pause, intendance au-dessus ;
accès compacts lorsque la largeur GUI ne permet pas trois colonnes.
Lire, publier et répondre, modifier son texte, masquer et clôturer une annonce.
Modération autorisée côté serveur et comptes Web liés par code confirmé en jeu.
Les discussions montrent les visages Minecraft, le pseudo, la date et chaque
message dans un bloc de conversation. Les actions secondaires sont regroupées
sous « ⋯ » ; la réponse se saisit directement sous le fil. Même présentation
sur le site, avec rendu des visages par UUID via son prestataire existant.
Fichier JSON atomique par défaut ; MariaDB optionnelle partagée avec Laravel.
Le serveur exécute la persistance hors du thread de jeu. Brouillons conservés
en cas de refus ; aucun repli silencieux vers un fichier après une panne SQL.
Aucun changement des shaders, de leurs réglages, de la génération ou des
sauvegardes existantes. Le delta part exactement du shader beta.151.
## Vérifications
Environnement : macOS Apple M1, Java Temurin 25, Minecraft 26.3,
Fabric Loader 0.19.5, Fabric API 0.160.5+26.3, Vulkan/MoltenVK 1.4.2.
Site : PHP 8.5.10, Laravel 13.32.0, PHPUnit 12.5.35. MariaDB locale 12.3.3,
Connector/J 3.5.10 imbriqué dans le mod.
- Scénario de stockage commun réussi sur fichier et MariaDB : persistance,
redémarrage, exact retry, conflits de révision, auteurs, opérateurs,
fermeture/réouverture, masquage, pagination, texte Unicode et isolation.
Codes expirés, consommés et tentative de réattribution d’identité refusés.
`build/community-client-database-final.log`, marqueurs FILE_PASS et SQL_PASS.
- Aller-retour réel Java → HTTP Laravel → Java réussi : article, création d’un
code Web, liaison côté Java, modification Web et réponse relues par Java.
`build/community-interop-{seed,claim,verify}.log` ; deux phases PHPUnit,
3 puis 6 assertions. Migration Laravel sur les tables déjà créées par le SQL
du mod réussie ; création depuis Laravel également testée par les tests HTTP.
- Tests client Vulkan réussis en FR/EN, GUI 2/3 (panneaux et mode compact),
publication, réponse intégrée, clôture, actions contextuelles, visages,
brouillon refusé et réponse tardive après fermeture.
Fichier : `build/community-conversation-client.log`.
MariaDB : `build/community-conversation-database.log`, mode explicitement
vérifié par le client. Captures dans `mods/sanctuary/build/run/clientGameTest/screenshots/`.
- Panne SQL testée avec une adresse locale sans service : erreur visible,
brouillon conservé, aucune création de fichier de repli.
`build/community-client-outage.log`, marqueur COMMUNITY154_OUTAGE_PASS.
- Web : **12 tests / 60 assertions** communautaires réussis, comprenant
validation, identité non falsifiable, restrictions du staff, auteur,
XSS/texte échappé, formulaires, pagination, compte lié et rejet CSRF réel.
Pint réussi. Rendu des pages et visages vérifié dans le navigateur local,
navigation accueil → discussion et FR/EN. Aucune erreur console applicative ;
avertissement THREE.Clock hérité du panorama.
- Suite Web complète : **55 réussites, 1 échec, 1 test optionnel ignoré** sur
57. L’échec existant de PlayerModerationTest attend « Nommer admin », alors
que ViewUser affiche déjà « Nommer staff » dans le commit de départ.
- `./gradlew check build --continue` exécuté : **229/252 GameTests réussis**,
**23 échecs strictement identiques à beta.151** (comparaison des identifiants).
`build/community-check-build.log`, `build/community-server-failures.json`.
Ce passage a aussi rencontré un `ClassNotFoundException` temporaire dans
seasonal105Smoke pendant des compilations concurrentes de vérification.
Le contrôle final est relancé seul, avec la tâche GameTests exclue (les 252 cas ont été exécutés lors du premier passage).
Le contrôle complet reste rouge : cette livraison ne prétend pas réparer les
23 GameTests historiques ni l’ancienne assertion de libellé Web. Les parcours
communautaires sont validés indépendamment.
## Rejouer les tests ciblés
```sh
./gradlew :sanctuary:community154Smoke
./gradlew :sanctuary:runClientGameTest \
-PsanctuaryClientTests=true -PsanctuaryQuickTests=true \
-PsanctuaryCommunity154ClientTests=true -PsanctuaryClientGraphicsBackend=vulkan
```
Pour le même scénario en base, préparer une MariaDB de développement vide
avec `src/main/resources/community/schema-v1.sql`, puis définir
`SANCTUARY_COMMUNITY_TEST_JDBC` (URL JDBC),
`SANCTUARY_COMMUNITY_CLIENT_DATABASE=true` et
`SANCTUARY_COMMUNITY_DB_PASSWORD`. Ces fixtures de développement utilisent
`root` sans mot de passe, uniquement dans la MariaDB locale isolée de test.
Ne jamais les lancer sur une base personnelle ou de production.
L’aller-retour utilise une autre base isolée, également migrée par Laravel :
`community154Interop -PcommunityInteropPhase=seed`, puis CommunityInteropTest
avec `SANCTUARY_COMMUNITY_INTEROP_PHASE=link`, Java `claim`, PHP `write`, Java
`verify`. Les deux programmes reçoivent le même chemin absolu
`SANCTUARY_COMMUNITY_INTEROP_FILE` vers un JSON dans un dossier ignoré.
Le test PHP reçoit les paramètres DB_CONNECTION/DB_HOST/DB_PORT/DB_DATABASE
pointant exclusivement vers cette base. Sans cette variable, il est ignoré.
## Limites de livraison
Code et artefacts locaux ; aucun déploiement Web, serveur personnel, canal
packwiz ou instance Prism. Validation Windows et connexion Discord réelle sur
le domaine de production à faire lors du déploiement. Le compte Web utilisé
dans les essais est fictif, la liaison passe cependant par le vrai stockage.
Pas de pièces jointes, de notification poussée ou d’import fichier → base.
Les anciens JAR encore présents sont conservés.
## Artefacts vérifiés
Export packwiz réussi. ZIP, versions Minecraft/Fabric, JAR unique, pilote JDBC
imbriqué, licence, absence des tests et égalité octet pour octet des 29 fichiers
shader avec beta.151 vérifiés. Reçu : `build/community-artifacts.json`.
- `mods/sanctuary/build/libs/sanctuary-beta.154.jar` — SHA-256 `07e7419cb3e814d59a6b6f130a1b05b4f65c2407a0ffbd00b194ba97614923ad`.
- `build/Sanctuary-beta.154.mrpack` — SHA-256 `fbc007fe59777b0c52b30b6e461b09fe0636fb0b86d254b9e98952856b7f6c8a`.
Site : commit local `32d804a` sur `codex/community-contract`.
+160
View File
@@ -0,0 +1,160 @@
# Communauté Sanctuary — contrat v1
Ticket beta.154, branche `codex/community-beta154`, socle beta.151 `b97ec6c`.
Ce contrat décrit le comportement cible et les échanges implémentés par ce ticket.
La note de livraison indique séparément les vérifications effectivement réussies.
Les shaders de beta.151 sont conservés sans modification.
## Périmètre
Gazette (`article`), tableau (`notice`), intendance (`bulletin`) et réponses
(`reply`). Contenus texte brut : titre 120 caractères Unicode, corps 4 000,
réponse 1 000. Aucune interprétation HTML, Markdown ou commande Minecraft.
Catégories stables : `info`, `work`, `need`, `event`. Photos et pièces jointes
sont hors de cette première livraison.
La pause présente la Gazette à gauche, les actions natives au centre, le tableau
à droite et l'intendance au-dessus. À faible largeur GUI, accès compacts vers
les mêmes écrans. Publication et réponses disponibles dès cette version.
## Autorité et identité
Le client passe exclusivement par le serveur Minecraft. L'auteur vient de la
session serveur, jamais d'un UUID fourni par le client. Chaque installation
possède un `serverId` UUID stable, également configuré sur le site. Chaque
requête SQL inclut ce serveur. Les sauvegardes du monde restent indépendantes.
Tous les joueurs connectés peuvent publier et répondre. L'auteur peut modifier
ou masquer son contenu et clôturer/réouvrir son annonce. Un opérateur Minecraft
ou membre du staff Web disposant de `users.moderate` peut masquer des contenus, épingler les publications et
publier l'intendance ; il ne réécrit pas le texte d'un autre auteur.
Le site exige un compte connecté lié à l'UUID Minecraft : code aléatoire à usage
unique créé sur le site, valable 10 minutes, confirmé en jeu. Seul le hash SHA-256
est conservé. La confirmation nécessite un serveur authentifié (`online-mode`).
Une identité déjà liée ne peut être réattribuée automatiquement.
## Stockage
`config/sanctuary-community.json` sélectionne `file` (défaut) ou `database`.
Le fichier du monde `data/sanctuary-community.json` est remplacé atomiquement
après synchronisation disque. Une corruption ou modification externe est une
erreur : aucune réinitialisation silencieuse.
En mode base, MariaDB utilise les tables `sanctuary_community_*`, utf8mb4,
InnoDB. Les nouvelles tables sont additives. Le schéma SQL v1 livré peut être
installé par l'administrateur ; Laravel possède la migration équivalente.
Le mod vérifie la version et ne lance jamais de migration au démarrage.
Une seule application du schéma suffit avant de démarrer les deux applications.
Les évolutions futures auront une migration explicite commune.
Aucun basculement automatique base → fichier lors d'une panne. Les lectures en
cache restent signalées comme anciennes ; une écriture n'est confirmée qu'après
commit. Les brouillons restent côté client durant la session. Les requêtes SQL
s'exécutent hors du thread de jeu, avec délais et file d'attente bornés.
Le passage fichier ↔ base nécessite un export/import explicite ; changer le mode
ne migre ni ne fusionne les données. La v1 ne fournit pas cet importateur.
## Enregistrements et concurrence
Les entrées portent : serveur, UUID, parent éventuel, type, catégorie, UUID et nom
d'auteur, titre, corps, état (`open`, `closed`, `hidden`), épingle, révision,
date de création et date de modification (millisecondes Unix UTC).
Une réponse a un parent article/annonce du même serveur ; pas de réponses imbriquées.
Masquer un parent masque également sa discussion dans toutes les lectures.
Chaque création utilise un UUID d'opération stable. La répétition exacte rend le
même résultat ; une réutilisation avec d'autres données est un conflit. Chaque
modification exige la révision courante. La transaction verrouille le parent
avant de répondre, empêchant la course avec une clôture ou un masquage.
Les listes sont paginées (10 entrées), épingles puis date/id décroissants ; les
réponses sont ordonnées par date/id croissants. L'intendance montre le bulletin
visible le plus récent. Les textes et le nombre de résultats sont bornés.
## Vérification attendue
Même scénario sur fichier et vraie MariaDB : publication, réponses, redémarrage,
idempotence, conflits, droits, masquage, fermeture et isolation de deux serveurs.
Aller-retour Java → PHP → Java sur les mêmes tables. Essais client Vulkan FR/EN,
GUI 2/3, mode compact, publication/refus/brouillon et fermeture avant réponse.
Tests HTTP du site : identité, CSRF (middleware), validation, permissions,
échappement HTML, erreurs, pagination. `./gradlew check build` et `assemblePack`
sont consignés avec leurs éventuels échecs hérités.
## Mise en service
### Serveur autonome (fichier, défaut)
Installer le JAR ou le pack beta.154. La première ouverture d’un panneau crée
`config/sanctuary-community.json` et son `serverId`. La première publication
crée uniquement le fichier communautaire du monde. Aucun service externe requis.
Les joueurs utilisent Échap → Gazette/Tableau → Publier, puis Répondre dans
une discussion. Le mode fichier n’est pas partagé avec le site Web.
Conserver le `serverId` et sauvegarder le fichier communautaire avec son monde.
Ne pas éditer ce fichier pendant que le serveur tourne. La limite de la v1 est
64 Mio par fichier communautaire ; une écriture qui la dépasse est refusée.
### Même MariaDB pour Minecraft et Laravel
1. Sauvegarder la base existante. Déployer le code Web puis appliquer sa migration
`php artisan migrate --force`. Elle ajoute quatre tables sans modifier les
données de jeu. Un schéma déjà installé par `schema-v1.sql` est reconnu.
À l’inverse, ne pas rejouer le SQL brut sur des tables déjà créées par Laravel.
2. Dans le `.env` du site, garder ses paramètres MariaDB et définir
`SANCTUARY_COMMUNITY_SERVER_ID` avec le même UUID que le mod. Recharger
la configuration Laravel (`php artisan config:cache` en production).
3. Arrêter Minecraft et éditer sa configuration :
```json
{
"mode": "database",
"serverId": "11111111-1111-4111-8111-111111111111",
"jdbcUrl": "jdbc:mariadb://db.example.net:3306/sanctuary?sslMode=verify-full",
"user": "sanctuary_game",
"passwordEnv": "SANCTUARY_COMMUNITY_DB_PASSWORD"
}
```
L’UUID ci-dessus est un exemple : reprendre celui de votre installation.
Définir `SANCTUARY_COMMUNITY_DB_PASSWORD` dans l’environnement du service Minecraft,
puis le redémarrer. Utiliser un certificat TLS valide pour l’hôte de la base.
Le compte de jeu a besoin de SELECT sur `sanctuary_community_schema`, SELECT,
INSERT et UPDATE sur `sanctuary_community_entries`, SELECT et INSERT sur
`sanctuary_community_identities`, SELECT et DELETE sur `sanctuary_community_links`.
Il n’a besoin ni des tables de comptes Web, ni de droits de création de tables.
Le compte Laravel conserve ses permissions habituelles et celles des migrations.
4. Sur le site : se connecter avec Discord, ouvrir Gazette, demander un code de
liaison. En jeu : Gazette → Lier mon compte → saisir le code. La liaison
exige `online-mode=true`. Recharger le site après confirmation ; les boutons
Publier et Répondre deviennent accessibles.
5. Modération : opérateur Minecraft ou staff Web autorisé à `users.moderate`,
avec compte lié. Les fonctions de lecture seule du staff ne donnent aucun
droit de masquage, épinglage ou publication officielle.
L’accueil du site conserve sa version de téléchargement beta.151 tant que le
pack public n’est pas mis à jour. Ce ticket ne publie pas le site, le canal
packwiz ou une installation Prism.
### Synchronisation et limites de la v1
Le menu pause recharge ses aperçus toutes les 5 secondes. Les pages de lecture
ont un bouton Actualiser. Le site charge les publications à chaque navigation.
Les brouillons Minecraft restent en mémoire jusqu’à déconnexion ; le site garde
ses brouillons dans le stockage local du navigateur, par compte et serveur.
Aucune notification poussée ni import automatique d’archives ou de fichier vers
MariaDB. Les anciennes publications d’exemple du site étaient du texte statique,
pas des données à migrer. Les pièces jointes et la réattribution d’un compte lié
restent hors périmètre.
### Présentation des discussions et visages
Les réponses se lisent en conversation : visage Minecraft, pseudo et date,
texte dans un bloc distinct, actions compactes derrière « ⋯ ». Le formulaire
reste sous la discussion. Les listes et les aperçus du menu pause affichent
également les visages. En jeu, les profils sont résolus par les widgets natifs
Minecraft, avec leur texture de repli tant que le skin n’est pas disponible.
Sur le site, le rendu par UUID utilise le prestataire mc-api.io déjà employé
par la page de compte ; chargement différé, sans référent, initiale locale en
cas d’erreur. Aucune image ni URL arbitraire n’entre dans le contrat des posts.
Documentation du rendu : https://mc-api.io/docs (GET `/render/{uuid}?size=64`).
+127
View File
@@ -0,0 +1,127 @@
# LIGHT-141 — Direction locale pour les normales PBR
Socle beta.140 `c9f5c56`, branche `codex/directional-lights-beta141`.
Minecraft 26.3 / Java 25. Livraison beta.141.
## Contrat
Estimer une direction d'arrivée depuis les six voisins du champ lumineux,
avec intensité et confiance. Conserver cette estimation dans une texture
compacte reconstruite avec le champ, uniquement quand le PBR est actif.
Les sources portées utilisent leurs positions déjà disponibles. Brancher ces
directions sur les normales et reflets PBR existants, sans nouveau bouton.
Les sources opposées doivent réduire la directionnalité, sans bascule brutale.
Les champs précédent/courant gardent leur fondu et les obstacles restent pris
en compte pour les blocs posés. Aucun changement de lumière de gameplay,
de sauvegarde ou de génération ; aucune promesse de lancer de rayons exact.
## Programme de vérification
Contrôles natifs OpenGL/Vulkan : direction, sources opposées, murs, rendu de
relief en intérieur, déplacement des sources, transitions et PBR OFF.
Régression des lumières beta.139/140 puis check/build et assemblage. GameTest
serveur dédié exclu selon le refus EULA antérieur. Aucun monde personnel ouvert.
## Réalisation
Le champ RGB conserve une texture directionnelle RGBA8 supplémentaire :
les trois composantes codent la direction pondérée, l'alpha sa réponse lumineuse.
Les voisins plus lumineux et accessibles contribuent à l'estimation ; les
arrivées opposées s'annulent dans le vecteur, sans supprimer l'éclairage natif.
Le shader interpole les moments pondérés avant normalisation, y compris entre
les champs précédent et courant. La confiance réduit le relief quand aucune
direction ne domine.
Cette texture est préparée dans le budget de reconstruction existant, avec
un maximum de six voisins par cellule éclairée et une table d'énergie réutilisée.
Aucune recherche de sources ni rayons par pixel pour les blocs posés. La
lecture GPU interpole huit cellules par champ ; les deux champs sont utilisés
pendant le fondu. Les sources mobiles réutilisent les positions, intensités et
transitions déjà collectées, dans la limite existante de 128 sources.
Une texture directionnelle occupe environ 1,5 Mo à 16 blocs de distance et
19 Mo à 64 blocs, par copie CPU/GPU ; deux champs coexistent lors des transitions.
Le champ directionnel n'est construit que si PBR est actif avec une intensité
non nulle. Sa désactivation libère les directions après la transition.
Le coût est borné par ces réglages, sans garantie de FPS.
La passe PBR applique la différence entre normale de base et normale perturbée,
puis un reflet GGX local. Elle garde sa portée de 32 blocs et les réglages PBR
existants ; Lumières colorées doit être actif. Le soleil et la lune continuent
à fonctionner lorsque les lumières colorées sont coupées. Les aides FR/EN
expliquent cette dépendance.
C'est une direction dominante approximative : deux lumières colorées opposées
ne produisent pas deux reflets indépendants. Les sources mobiles gardent la
propagation radiale et les limites d'occultation existantes. Les normales PBR
concernent les surfaces de blocs prises en charge, pas tous les modèles de mobs.
Les exclusions existantes des panoramas et de l'immersion restent inchangées.
## Premier essai ciblé
L'essai natif OpenGL ciblé réussit en **1 min 12 s** : direction gauche/droite,
annulation des sources opposées avec énergie conservée, occultation par un mur,
relief sur la pierre en intérieur et avec une torche en main, stabilité au repos,
libération des directions avec PBR OFF et maintien du chemin solaire avec
Lumières colorées OFF. La somme d'écarts PBR OFF/ON sur la pierre vaut 878 114
au réglage PBR 100 % ; c'est une comparaison de pixels, pas une mesure de FPS.
Log : `build/directional141-focused.log`.
Les essais de préparation ont révélé un conflit de nom de matrice GLSL, corrigé,
puis des erreurs de fixture (angle 180° normalisé par Minecraft, suppression
d'entités alors que la sélection était vide, commande d'heure déjà à minuit),
corrigées sans changer le rendu.
## Validation complète OpenGL
La chaîne complète réussit en **3 min 1 s** sur OpenGL / Apple M1 :
`COLORED139_PASS`, `COLORED140_PASS` et `DIRECTIONAL141_PASS`.
Elle réunit les contrôles de couleurs, objets portés, caméra, occultation,
rechargement des ressources, préférences, distances/fondus et directions PBR.
Le test ciblé est reproductible avec `-PsanctuaryDirectional141Only=true` ;
la chaîne complète utilise `-PsanctuaryDirectional141ClientTests=true` avec
les deux suites de lumières existantes.
Log : `build/directional141-opengl.log`. Captures natives conservées dans
`build/directional141-opengl-screenshots/`. Comparaison autonome sans retraitement :
`build/Shader-beta.141-comparaison.html`.
## Validation complète Vulkan
La même chaîne réussit en **2 min 56 s** sur Vulkan / Apple M1, MoltenVK 1.4.2,
profondeur 0–1 et transparence améliorée. Les trois marqueurs de réussite sont
présents dans `build/directional141-vulkan.log`. L'écart PBR local OFF/ON vaut
878 107, contre 878 114 sous OpenGL dans cette scène. Les captures natives sont
conservées dans `build/directional141-vulkan-screenshots/`.
Les deux moteurs vérifient la compilation, les chemins actifs et désactivés,
le cache, les murs, les sources opposées et portées, ainsi que les régressions
beta.139/140. Pas de validation des pilotes Windows de l'utilisateur revendiquée.
## Construction et archives
`./gradlew check build assemblePack assembleTestPack -x :sanctuary:runGameTest`
réussit en **2 min 35 s**, 126 tâches. Le GameTest serveur dédié reste exclu
selon le refus EULA antérieur. Les suites clientes ci-dessus ont été exécutées
séparément. Log : `build/shader141-build.log`.
Comparaison à beta.140 : **8 entrées de production modifiées**, limitées au
champ lumineux, à sa préparation avant le PBR, au shader PBR et aux aides FR/EN.
Les archives normale/Test contiennent le même JAR Sanctuary, sans classes
de test. Version, dépendances exactes, sources des JAR, textures de gemmes et
de la clé de Steve, et template complet sont vérifiés.
Reçu : `build/shader141-artifact.json`.
SHA-256 du JAR : `660f29c3fa77fee4de6a8db5673c61619985804494f70e961998da36f396d0f1`.
- Sanctuary-beta.141.mrpack : `08df23dac9e98fbfa2935f747345c38244bddc3a3d9371c6416f251e85a7eb19`.
- Sanctuary-Test-beta.141.mrpack : `a300ef5bfff7b6990c9f5c2760e9c3a23d7a27aeb10b8f04d29db9a7409fa0e1`.
## Livraison
[Release beta.141](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.141)
publiée sur le commit source `93ed5574a4f77bd3a083b254498f4994efe31179` ; canal stable
`7e6c84400f80db24bf5204cd21d628194ae9f0f7` vérifié après publication.
Deux synchronisations isolées puis deux dans la même instance Sanctuary Beta
réussissent. Les **923 fichiers personnels** suivis gardent leurs hashes.
Sauvegarde préalable : `sanctuary-backups/before-beta.141/`. Aucun monde personnel
ouvert. Reçus : `build/shader141-isolated.json` et `build/shader141-prism.json`.
Archives normale/Test/template et comparaison copiées dans `sanctuary-beta/build/`.
+68
View File
@@ -0,0 +1,68 @@
# RENDER-149 — Distances PBR et SSR indépendantes
Branche `codex/separate-reflection-distances-beta149`, socle beta.148 `6f87144`.
- Deux réglages : Distance PBR et Distance SSR, chacun à 64 blocs par défaut.
Choix 16, 32, 64, 128, 256 blocs, enregistrés séparément.
- Le PBR conserve son propre fondu et sa propre limite de portée. Le SSR
utilise sa distance pour les surfaces réfléchissantes et sa recherche de
paysage. Le maillage et la sélection des surfaces couvrent le maximum des
deux portées actives, afin que réduire l’une ne coupe pas l’autre.
- Ancien défaut commun 32 migré une fois vers PBR 64 ; nouveau réglage SSR 64.
Les anciennes valeurs PBR différentes de 32 restent conservées. Après la
migration, un choix explicite 32 est conservé comme les autres valeurs.
- Option nommée exactement « SSR » en français et en anglais, sans la mention
« expérimental ». Les autres options expérimentales du jeu sont inchangées.
- Intensités PBR 50 % et SSR 20 %, rendu solaire diffus de beta.148 conservés.
SSR conserve sa dépendance au PBR et les limites du paysage visible/chargé
et du budget de maillage.
- Les feuilles (et fibres de laine, même profil entièrement mat) ne reçoivent
plus de large éclat spéculaire blanc. Leur relief normal reste présent.
La pierre et le bois retrouvent exactement leur poids spéculaire précédent,
comme les surfaces polies, les métaux, l’eau et le verre. La suppression
concerne les profils non métalliques de rugosité 0,98, avec transition 0,90–0,98.
## Vérifications
Contrôles de persistance indépendante, valeurs par défaut et libellés FR/EN.
Scènes graphiques avec PBR variable/SSR 256 puis PBR 256/SSR variable.
- Suite PBR/préférences Vulkan réussie : deux défauts 64 et persistance
indépendante sur 16/32/64/128/256 ; libellés FR/EN et nom SSR vérifiés.
Journal : `build/beta149-pbr-vulkan.log`.
- Suites SSR Vulkan et OpenGL réussies avec les portées dissociées, F5,
intersections, eau/verre/métal, soleil/lune, OFF/zéro et rechargement.
Journaux : `build/beta149-ssr-vulkan.log`, `build/beta149-ssr-opengl.log`.
Ces passes précèdent l’ajustement final du profil entièrement mat.
- Suite PBR Vulkan finale avec feuillage réussie : 170 172 pixels de feuilles
contrôlés, saturation relative 1,00007, relief visible sur 63 236 pixels.
Journal : `build/beta149-foliage-vulkan.log` ; captures correspondantes
dans `build/evidence/beta149-foliage-vulkan/`.
- Bois et briques après recentrage sur le feuillage : aucun pixel modifié
par rapport à la capture précédant l’ajustement, sur les zones de contrôle
de 26 304 et 40 896 pixels respectivement.
- `./gradlew check build` exécuté dans l’instantané isolé : 252 tests serveur,
229 réussis et 23 échecs, mêmes identifiants qu’en beta.148. Aucun nouvel échec.
Journal : `build/beta149-check-build-isolated.log` ; comparaison :
`build/beta149-server-failures.json`. L’ajustement final du feuillage concerne
seulement le shader et son test client. Le contrôle global reste en échec.
- Suite PBR OpenGL finale réussie également : 170 199 pixels de feuilles,
saturation relative 1,00001, relief visible sur 63 263 pixels.
Journal : `build/beta149-foliage-opengl.log` ; captures correspondantes
dans `build/evidence/beta149-foliage-opengl/`.
- Assemblage final réussi : `./gradlew build assemblePack -x :sanctuary:check`,
sans répéter les contrôles purs/serveur exécutés dans l’instantané.
Journal : `build/beta149-build-pack.log`. Cette exclusion ne transforme
pas le contrôle complet en réussite.
## Artefact
[Sanctuary-beta.149.mrpack](../build/Sanctuary-beta.149.mrpack), copié aussi dans
`sanctuary-beta/build/` avec les autres versions. Archive vérifiée, un seul JAR
Sanctuary beta.149 identique au JAR construit, shaders identiques aux sources,
libellés SSR et deux portées FR/EN vérifiés. Minecraft 26.3 / Loader 0.19.5.
Taille : 10 520 465 octets. SHA-256 :
`ef5519d9a0e2006b865b62371a6f8712db02ab138aa9698339d6fdb02fb4befa`.
Reçu : `build/beta149-artifacts.json`. Aucune installation Prism ni publication.
+96
View File
@@ -0,0 +1,96 @@
# RENDER-150 — Terre mate et reflets solaires colorés
Branche `codex/matte-dirt-beta150`, socle beta.149 `1c23d0c`.
La terre sèche utilise un profil entièrement mat (rugosité 0,98, métal 0),
comme le feuillage corrigé en beta.149. Cela enlève l’éclat blanc/gris diffus
sans supprimer les normales ni le contraste de relief. Le SSR de ces matières
est également atténué par leur rugosité, sans changer son réglage global.
Blocs concernés : dirt, coarse_dirt, rooted_dirt, grass_block, dirt_path,
farmland, podzol et mycelium. Les faces terreuses suivent ainsi le même profil.
Les autres profils de matière sont inchangés.
Le disque du soleil et son reflet utilisent la même couleur native
`SkyRenderState.sunriseAndSunsetColor`, issue de l’attribut environnemental
Minecraft `SUNRISE_SUNSET_COLOR`. Son alpha pilote la transition depuis le
blanc : aucune palette horaire ou altitude artificielle. Le soleil reste
neutre tant que le dégradé natif est absent et se colore avec le ciel.
La teinte solaire est convertie en lumière linéaire pour le PBR. Le mixin
modifie seulement la couleur du disque solaire, préserve l’alpha de pluie,
la texture native et la lune, et respecte la désactivation globale de Vanilla Light.
La fusion diffuse de l’eau et sa normale animée d’origine sont conservées.
Les accents suivent les pentes des vaguelettes qui réfléchissent le soleil
vers la caméra. Leur pulsation vient des normales animées de la texture,
sans oscillateur indépendant ni inversion artificielle du relief.
Une faible variation stable par texel évite des crêtes toutes identiques ;
elle module seulement l’intensité, sans produire des points isolés aléatoires.
Les détails s’atténuent lorsqu’ils deviennent plus petits qu’un pixel écran.
Les accents restent limités au reflet solaire et conservent sa couleur.
Les lampes et la lune gardent leurs
couleurs propres.
PBR 50 %, SSR 20 % et deux portées indépendantes de 64 blocs par défaut conservés.
Minecraft 26.3, Java 25 et dépendances inchangés ; aucune sauvegarde modifiée.
## Vérifications
Tests des profils de terre et scènes GPU comparant PBR OFF/ON sur terre et
feuillage : conservation de la couleur native et présence du relief.
- Suites PBR finales Vulkan/OpenGL réussies, dont profils des huit types de
sol, proximité, intensités, préférences et feuillage.
Journaux : `build/beta150-pbr-vulkan-final.log` et
`build/beta150-pbr-opengl-final.log`.
- Terre : 180 469 pixels contrôlés, saturation relative 0,99877 sur les deux
moteurs ; relief visible sur 145 415 pixels. Captures conservées dans
`build/evidence/beta150-pbr-vulkan-final/` et
`build/evidence/beta150-pbr-opengl-final/`.
Les tests comparent la contribution PBR au rendu natif et capturent aussi
le disque du soleil, avant puis pendant la transition native du ciel.
Le contrôle serveur `./gradlew check build`, exécuté dans une copie isolée
`build/server-validation` avant les derniers ajouts exclusivement client,
compte 229 réussites et 23 échecs sur 252 tests. Les 23 identifiants d’échec
sont identiques à beta.149 ; aucun nouvel identifiant. Le contrôle global
n’est donc pas vert. Journaux : `build/beta150-check-build-isolated.log` et
`build/beta150-server-failures.json`.
Les scènes solaires comparent le rendu PBR au natif, avant et pendant le
coucher de soleil. À 2 000 ticks, la teinte native est blanche ; à 11 500 ticks
sa transition commence légèrement (alpha 0,051) ; à 12 400 ticks l’alpha
natif atteint 0,831 et le disque devient chaud (centre RGB 255/255/135).
Les deux moteurs ont validé cette synchronisation native.
Un lancement sans sélection PBR a également rencontré l’assertion client
Blocodex « The full snapshot must become visible together », avant les scènes
GPU (`build/beta150-water-motion-vulkan.log`). Il ne constitue pas une
validation de la suite client générale.
La suite finale Vulkan des éclats liés aux vaguelettes passe
(`build/beta150-wave-glints-vulkan.log`) : animation native, masquage F5,
distances indépendantes, reflets solaires et lunaires, matériaux transparents,
rechargement des ressources et redimensionnement. Dans la scène solaire,
150 563 pixels sont éclaircis, sans pixel blanc saturé.
La suite OpenGL finale passe également
(`build/beta150-wave-glints-opengl.log`) : 148 180 pixels éclaircis, aucun
pixel blanc saturé. Les captures finales sont conservées dans
`build/evidence/beta150-wave-glints-vulkan/` et
`build/evidence/beta150-wave-glints-opengl/`.
Le ressenti des vaguelettes en mouvement reste à confirmer par le créateur
sur sa configuration ; les tests ne constituent pas une validation esthétique.
## Pack local
Construction et assemblage réussis avec
`./gradlew build assemblePack -x :sanctuary:check`, après le contrôle complet
ci-dessus. L’exclusion ne transforme pas ses échecs connus en réussite.
Export packwiz puis vérification ZIP, version Fabric, Minecraft 26.3,
Loader 0.19.5, JAR unique identique au construit, shader final et mixin solaire.
- Pack : `/Users/koka/Documents/sanctuary/sanctuary-beta/build/Sanctuary-beta.150.mrpack`
- Taille : 10522942 octets.
- SHA-256 pack : `65a4a44948dcdcf0325b5e24c076a03f8832184f25f5c1ff16f69bd3ee641b93`.
- SHA-256 JAR : `4962e6f8cafd6ef3e81e088bf9b0092a264441632cfa095d4fe7394b855f093d`.
- Reçu : `build/beta150-artifacts.json`, également copié à côté du pack.
Aucun canal publié, aucune instance Prism ni sauvegarde personnelle modifiée.
+121
View File
@@ -0,0 +1,121 @@
# 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/`.
+93
View File
@@ -0,0 +1,93 @@
# FIX-151 — Nether, PBR opaque et SSR
Branche `codex/nether-wood-beta151`, socle beta.150 `448e2fe`.
## Déconnexion Nether
Le rapport Windows beta.150 transmis le 18 septembre 2026 indique
`IndexOutOfBoundsException: Index 46 out of bounds for length 46` lors de
`ClientPacketListener.handleContainerContent`, paquet `container_set_content`.
Le passage au Nether dans le test client local reproduit exactement cette trace
(journal `build/beta151-client-first.log`). Ce défaut n’est pas un crash GPU.
Minecraft recrée le joueur client et son menu lors de `handleRespawn`, utilisé
aussi pour les changements de dimension. La progression n’était réattachée
qu’en fin de tick ; un paquet d’inventaire dans le même lot arrivait avant la
création des 18 cases supplémentaires et des 3 accessoires. Le correctif lie la
progression déjà reçue au nouveau joueur dès la fin du traitement du paquet de
respawn, avant le paquet suivant. Aucun paquet ni objet n’est ignoré et aucun
format de sauvegarde ne change. Sans progression Sanctuary active, le chemin
vanilla reste inchangé.
## Rendu
La beta.146 avait porté le gain solaire des surfaces opaques de 3 à 20
(4 × 5), soit environ 6,7 fois plus. Le renforcement prévu pour les reflets de
l’eau avait ainsi créé un voile sur les matières sombres, dont l’obsidienne.
Les surfaces opaques retrouvent le gain 3 et le budget spéculaire total 0,5,
partagé entre soleil et lampes. La teinte native du coucher de soleil, les
normales et le relief sont conservés. Eau et verre conservent leur traitement.
Le bois utilise le profil mat 0,98 : planches, bûches et variantes de construction,
bambou, tiges et hyphes du Nether. Ce classement ne transforme pas le nylium en bois.
SSR est activé par défaut à 20 %, avec portée 64 blocs. Un choix OFF déjà
sauvegardé reste OFF. PBR reste à 50 % par défaut.
## Cible graphique
Décision du créateur pendant ce ticket : **Vulkan uniquement** pour les nouvelles
validations. Le passage OpenGL de ce ticket s’était terminé avant cette demande ;
il reste une trace historique, pas une cible à conserver ni à relancer.
Cette décision est reportée dans `AGENTS.md` et `docs/testing.md`.
## Vérification
Les assertions GPU Vulkan et les quatre transferts successifs
Overworld → Nether → Overworld → Nether → Overworld passent sur un nouveau monde
plat de développement, graine 122. Après chaque transfert : menu de 67 cases,
pile de 7 diamants en inventaire natif, 3 émeraudes dans la première case
supplémentaire et 5 lingots d’or dans la dernière, toujours présents côté client.
Les scènes PBR conservent 99,878 % de la saturation relative du chêne et 99,968 %
de celle de l’épicéa écorcé sur 185 776 pixels chacune. Le relief affecte
respectivement 101 382 et 51 671 pixels. De face, l’obsidienne ne gagne en moyenne
que 5,114 niveaux RGB sur 255 (102 480 pixels), avec relief visible sur 95 225
pixels. Captures inspectées : bois et obsidienne PBR OFF/ON.
Les contrôles couvrent aussi les réglages SSR absents (ON/20 %), la persistance
d’un choix OFF, les profils bois, les petits déplacements de caméra, les
intensités, le rechargement des ressources et le redimensionnement.
Le premier passage Vulkan corrigé valide toutes les assertions, mais se bloque
ensuite à la fermeture dans les barrières de synchronisation du programme de
test Fabric ; son processus est arrêté après capture des piles de threads.
Premier journal : `build/beta151-client-vulkan-final.log` ; diagnostic :
`build/beta151-client-shutdown-threads.txt`. Le second passage Vulkan réussit entièrement, fermeture comprise, en **3 min 9 s**
(`build/beta151-client-vulkan-clean.log`). La suite OpenGL terminée avant l’abandon de
cette cible a réussi en 2 min 42 s ; aucun nouveau test OpenGL n’est prévu.
Le contrôle complet `./gradlew check build` dans `build/server-validation`
(3 068 fichiers de production identiques aux sources courantes) termine en
11 min 13 s : **229 réussites, 23 échecs sur 252**. Les 23 identifiants sont
strictement identiques à beta.150 : aucun nouveau, aucun résolu.
Le contrôle global reste rouge. Journaux et liste :
`build/beta151-check-build.log`, `build/beta151-server-failures.json`.
Ces échecs restent ouverts et ne constituent pas la validation du correctif
réseau ; celui-ci repose sur la reproduction puis le test client des transferts.
## Pack local
`./gradlew build assemblePack -x :sanctuary:check` réussit en 11 s,
indépendamment du contrôle complet en échec ci-dessus. Export packwiz réussi ;
ZIP, version Fabric, Minecraft 26.3, Loader 0.19.5, JAR Sanctuary unique,
mixin de respawn et shader exacts, classes conformes aux sorties compilées et
absence des tests dans le JAR vérifiés.
- Pack : `/Users/koka/Documents/sanctuary/sanctuary-beta/build/Sanctuary-beta.151.mrpack`.
- SHA-256 pack : `1d87804d75edda0a95c318ea3feaf47ecadeeb29d86ca55483a42741d7a5fcab`.
- SHA-256 JAR : `6676c67f8be4e650a9b6a8213c8b0fdf3cf7e62b030c3d1a6d4805d438a8e543`.
- Reçu : `build/beta151-artifacts.json`.
Les essais sont effectués sur Apple M1 ; la validation sur le PC Windows du
créateur reste à faire.
Aucune sauvegarde personnelle ouverte, aucun canal publié ni instance installée.
+151
View File
@@ -14,6 +14,157 @@ 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,
avec le sampler natif de précipitation conservé. Protections inchangées pour
la neige construite, les biomes froids, l'hiver, les profils neigeux et la
météo Vanilla. [Contrat et vérifications](realtime-snow-melt-beta143.md).
[Release beta.143](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.143)
publiée ; canal `bafff5e9fcdd95c5dd184953a34ada805e455797`. Deux synchronisations
isolées puis deux dans l'instance Sanctuary Beta réussies. Les 923 fichiers
personnels suivis gardent leurs hashes. Aucun monde personnel ouvert ;
sauvegarde préalable `sanctuary-backups/before-beta.143/`. Archives vérifiées
dans `sanctuary-beta/build/`.
## beta.142 — Vulkan par défaut et stabilité PBR
Vulkan natif prioritaire, rafraîchissements PBR stables et intensité initiale
80 %, calculs de reflets bornés. [Contrat et vérifications](pbr-refresh-beta142.md).
[Release beta.142](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.142)
publiée ; canal `d661260e2bbabb0326c86d6cc543daf986979596`. Deux synchronisations isolées
puis deux dans l'instance Sanctuary Beta réussies. Les 923 fichiers personnels
suivis gardent leurs hashes. Aucun monde personnel ouvert ; sauvegarde préalable
`sanctuary-backups/before-beta.142/`. Archives vérifiées dans `sanctuary-beta/build/`.
Un choix OpenGL explicite reste conservé : sélectionner Vulkan dans les réglages
vidéo et redémarrer pour changer ce choix. La capture Windows de bruit en
mouvement reste à reconfirmer sur son pilote ; les contrôles OpenGL/Vulkan Mac
et la correction du domaine numérique du reflet sont documentés dans le ticket.
## beta.141 — Direction des lumières pour les normales PBR
Directions locales mises en cache, sources portées, confiance réduite lorsque
les sources s'opposent et fondus. Activer PBR et Lumières colorées ; portée PBR
de 32 blocs. [Contrat et vérifications](directional-lights-beta141.md).
[Release beta.141](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.141)
publiée ; canal `7e6c84400f80db24bf5204cd21d628194ae9f0f7`. Deux synchronisations isolées
puis deux dans l'instance Sanctuary Beta réussies. Les 923 fichiers personnels
suivis gardent leurs hashes. Aucun monde personnel ouvert ; sauvegarde préalable
`sanctuary-backups/before-beta.141/`. Archives vérifiées dans `sanctuary-beta/build/`.
## beta.140 — Lumières portées et fondus
Couleurs des objets en main et sur la tête, distances 8/16/32/64 et fondus
préservant la teinte. Torches natives par défaut, option chaude, cuivre vert
et redstone rouge. [Contrat et vérifications](colored-dynamic-beta140.md).
[Release beta.140](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.140)
publiée ; canal `2c360a0759d4a164f82c89ac2bef04ff69d50875`. Deux synchronisations isolées
puis deux dans l'instance Sanctuary Beta réussies. Les 923 fichiers personnels
suivis gardent leurs hashes. Aucun monde personnel ouvert ; sauvegarde préalable
`sanctuary-backups/before-beta.140/`. Archives vérifiées dans `sanctuary-beta/build/`.
## beta.138 — Densité des rayons selon la météo
Brouillard plus dense et diffus, précipitations et atténuation de l'orage.
Réglage manuel conservé, aucune nouvelle option ni modification de sauvegarde.
[Contrat et vérifications](shader-beta138.md).
[Release beta.138](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.138)
publiée ; canal `c049423de1971079b9d61874d83adeb6f1ad49f7`. Deux synchronisations isolées,
puis deux dans la même instance Sanctuary Beta réussies. Les 923 fichiers
personnels suivis gardent leurs hashes. Aucun monde personnel ouvert ;
sauvegarde préalable `sanctuary-backups/before-beta.138/`.
Packs normal/Test/template vérifiés dans `sanctuary-beta/build/`.
## beta.137 — Rayons solaires volumétriques
Rayons à travers les ouvertures et le feuillage, activés à 35 %, indépendants
du bloom et de l'affichage des ombres de surface. Carte solaire partagée.
[Contrat et vérifications](shader-beta137.md).
[Release beta.137](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.137)
publiée ; canal `68cb39a5edbf5d34def02f670e2488ee852605b8`. Deux synchronisations isolées,
puis deux dans la même instance Sanctuary Beta réussies. Les 923 fichiers
personnels suivis gardent leurs hashes. Aucun monde personnel ouvert ;
sauvegarde préalable `sanctuary-backups/before-beta.137/`.
Packs normal/Test/template vérifiés dans `sanctuary-beta/build/`.
## beta.136 — Lens flare rectangulaire
Rectangles à dégradé doux pour le soleil et la lune, activés à 30 % et
réglables indépendamment du bloom. Occultation par les blocs et phases lunaires.
[Contrat et vérifications](shader-beta136.md).
[Release beta.136](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.136)
publiée ; canal `dd5b545a7cfbb957f2c70e9dc17034b44caef98f`. Deux synchronisations isolées,
puis deux dans la même instance Sanctuary Beta réussies. Les 923 fichiers
personnels suivis gardent leurs hashes. Aucun monde personnel ouvert ;
sauvegarde préalable `sanctuary-backups/before-beta.136/`.
Packs normal/Test/template vérifiés dans `sanctuary-beta/build/`.
## beta.135 — SSGI en proximité et nouveaux défauts
Normales stables près des surfaces, atténuation progressive du rebond au contact.
Bloom à 20 %, PBR et SSGI activés par défaut, choix personnels existants conservés.
[Contrat et vérifications](shader-beta135.md).
[Release beta.135](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.135)
publiée ; canal `5b0df577fcd036937c25be3a1f4580f9db3103a1`. Deux synchronisations isolées,
puis deux dans la même instance Sanctuary Beta réussies. Les 923 fichiers
personnels suivis gardent leurs hashes. Aucun monde personnel ouvert ;
sauvegarde préalable `sanctuary-backups/before-beta.135/`.
Packs normal/Test/template vérifiés dans `sanctuary-beta/build/`.
## beta.134 — PBR optionnel et SSGI filtré
Normales procédurales en cache, rugosité/métallicité par famille et reflets
directionnels. Filtrage SSGI et suppression de sa forte réinjection neutre.
[Contrat et vérifications](shader-beta134.md).
[Release beta.134](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.134)
publiée ; canal `35ec0f6e145002f6367944df5b75e2703958eaef`. Deux synchronisations isolées,
puis deux dans la même instance Sanctuary Beta réussies. Les 923 fichiers
personnels suivis gardent leurs hashes. Aucun monde personnel ouvert ;
sauvegarde préalable `sanctuary-backups/before-beta.134/`.
Packs normal/Test/template vérifiés dans `sanctuary-beta/build/`.
## beta.133 — SSGI renforcé
Diffusion des couleurs rendue visible au réglage 35, même budget de rayons
et cibles GPU. [Contrat et vérifications](shader-beta133.md).
[Release beta.133](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.133)
publiée ; canal `9ceb5a5353c9d1b8bb7834a20e818fd65096ff98`. Deux synchronisations isolées,
puis deux dans la même instance Sanctuary Beta réussies. Les 923 fichiers
personnels suivis gardent leurs hashes. Aucun monde personnel ouvert ;
sauvegarde préalable `sanctuary-backups/before-beta.133/`.
Packs normal/Test/template vérifiés dans `sanctuary-beta/build/`.
## beta.132 — SSGI expérimental
Diffusion locale des couleurs visibles, rayon 2,5 blocs, intensité réglable,
initialement OFF. [Contrat et vérifications](shader-beta132.md).
[Release beta.132](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.132)
publiée ; canal `9edaaedc0205c525ecce9de66b5ca7827c56c28f`. Deux synchronisations isolées,
puis deux dans la même instance Sanctuary Beta réussies. Les 923 fichiers
personnels suivis gardent leurs hashes. Aucun monde personnel ouvert ;
sauvegarde préalable `sanctuary-backups/before-beta.132/`.
Packs normal/Test/template vérifiés dans `sanctuary-beta/build/`.
## beta.131 — émissifs indépendants et précision étendue
Bloom, émissifs et highlight ON initialement. Couper le halo ou régler son
intensité à zéro garde les inclusions lumineuses. Précisions 64/128 ajoutées,
défaut 16 conservé. [Contrat et vérifications](shader-beta131.md).
[Release beta.131](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.131)
publiée ; canal `efb18c274d95d988a9566b8c52751469f002cdda`. Deux synchronisations isolées,
puis deux dans la même instance Sanctuary Beta réussies. Les 923 fichiers
personnels suivis sont conservés, préférences et sauvegardes comprises.
Aucun monde personnel ouvert ; sauvegarde `sanctuary-backups/before-beta.131/`.
Archives normal/Test/template vérifiées dans `sanctuary-beta/build/`.
## beta.130 — bloom et précision commune
Bloom réglable, masques sélectifs des vingt textures de minerais, précision
+82
View File
@@ -0,0 +1,82 @@
# RENDER-147 — PBR proche, lune et portée 256 blocs
Branche `codex/pbr-close-range-beta147`, socle beta.146 `cf9a670`.
Minecraft 26.3, Java 25 ; dépendances et formats de sauvegarde inchangés.
## Résultat
- Le prépassage des récepteurs PBR comparait encore les profondeurs natives
avec un epsilon fixe de 0,0000001. En profondeur inversée, cette marge
devenait trop stricte au voisinage de la caméra : de nombreux fragments
alternaient entre le rendu natif et le PBR lors de mouvements minuscules.
- La comparaison utilise maintenant une distance reconstruite avec la marge
bornée de 5 mm déjà employée par les passes couleur. La sélection de la
surface PBR la plus proche et son test de profondeur EQUAL restent stricts.
Aucun décalage du maillage ni fondu supprimant le PBR près de la caméra.
- La lune ne reçoit plus le renforcement spéculaire du soleil (×20 pour
l’eau, ×4 pour les autres matières). Son coefficient lunaire et ses phases
restent appliqués ; le grand reflet solaire est conservé.
- Distance commune PBR/SSR : 16, 32 (défaut), 64, 128 ou 256 blocs.
À 256, le rayon SSR peut aussi rechercher jusqu’à 256 blocs. Nombre de pas
et budgets de maillage inchangés ; les limites du terrain visible/chargé,
des reflets hors écran et du budget de maillage subsistent.
- PBR 80 %, SSR 20 %, mélange des eaux et préférences de beta.146 conservés.
Libellés FR/EN mis à jour.
## Vérifications
Mondes de test jetables, macOS Apple M1. Aucune sauvegarde personnelle utilisée.
- Reproduction avant correction sous Vulkan : à 12 cm du sol de briques,
59 442 / 262 272 pixels changent de plus de 12 niveaux RVB cumulés sous une
rotation de 0,00002 degré. Le test échoue. Journal :
`build/beta147-close-before-vulkan.log`.
- Même scène après correction : 1 / 262 272 pixels à 12 cm ; également testé
à 25 cm, 50 cm et 1 m. Suite PBR Vulkan réussie :
`build/beta147-close-after-vulkan.log`.
- Preuves avant/après dans `build/evidence/close-before-vulkan/` et
`build/evidence/close-after-vulkan/`.
- Suite PBR et préférences OpenGL finale réussie : mêmes scènes proches,
persistance des distances dont 256 blocs, FR/EN, OFF/zéro, intensités,
rechargement, redimensionnement et effets combinés.
Journal : `build/beta147-close-final-opengl.log` ; preuves :
`build/evidence/beta147-close-final-opengl/`.
- Suite SSR Vulkan finale réussie : F5, intersections, portées dont 256,
eau/verre/métal, OFF/zéro, rechargement et redimensionnement.
Journal : `build/beta147-ssr-final-vulkan.log` ; preuves :
`build/evidence/beta147-ssr-final-vulkan/`.
- À PBR 80 % / SSR 20 %, le soleil conserve 22 697 pixels quasi blancs sur
la scène aquatique 854 × 480. La lune seule en compte zéro, tout en modifiant
visiblement 40 808 pixels par rapport au PBR OFF. Capture du bloom lunaire
inspectée : reflet doux, sans plateau blanc.
- Suite SSR OpenGL finale réussie également : zéro pixel quasi blanc pour
la lune, 22 111 pour le soleil et 40 749 pixels de reflet lunaire visibles.
Intersections : 91 / 518 400 pixels changés sous rotation minuscule.
Journal : `build/beta147-ssr-final-opengl.log` ; captures correspondantes
dans `build/evidence/beta147-ssr-final-opengl/`.
- `./gradlew check build` dans un instantané isolé : 252 tests serveur,
229 réussis, 23 échecs, avec exactement les mêmes identifiants que beta.146.
Journal : `build/beta147-check-build-isolated.log` ; comparaison :
`build/beta147-server-failures.json`. La vérification globale reste en échec.
Les derniers ajouts lune/256 concernent exclusivement le rendu client,
ses réglages et ses tests ; ils sont validés par les suites finales ci-dessus.
- Assemblage final réussi : `./gradlew build assemblePack -x :sanctuary:check`,
après les contrôles purs et serveur exécutés dans l’instantané, sans les
répéter. Journal : `build/beta147-build-pack.log`. Cette exclusion ne
transforme pas le contrôle complet en réussite.
## Artefact
[Sanctuary-beta.147.mrpack](../build/Sanctuary-beta.147.mrpack), également copié
à côté de beta.146 dans `sanctuary-beta/build/`. Aucun ancien pack remplacé,
aucune installation Prism ou publication effectuée.
Archive ZIP vérifiée, un seul JAR Sanctuary beta.147, identique au JAR construit,
shaders identiques aux sources finales, options FR/EN 256 présentes,
Minecraft 26.3 / Fabric Loader 0.19.5 conservés. Taille : 10 519 851 octets.
SHA-256 : `b8044243dbe160d5171ea584052cf5732393e4d91352c29c33b727af80c0a5bc`.
Reçu : `build/beta147-artifacts.json`.
+173
View File
@@ -0,0 +1,173 @@
# PBR-142 — Stabilité du relief et Vulkan par défaut
Socle beta.141 `7ce01c4`, branche `codex/pbr-refresh-beta142`.
Minecraft 26.3 / Java 25. Livraison beta.142.
## Contrat
Corriger le clignotement lors de la reconstruction des sections PBR : conserver
le rendu précédent jusqu'à ce que le remplaçant soit disponible et éviter les
reconstructions dues seulement aux lumières dynamiques. Une invalidation pendant
une reconstruction doit être conservée, sans annuler indéfiniment le travail.
Le PBR passe à 80 % par défaut, valeur confirmée par l'utilisateur ; les choix
explicitement enregistrés restent conservés.
La capture `2026-09-18_04.19.34.png` montre des points très clairs, plus fins
que les pixels des textures, sur des parois et un plafond de grotte. L'utilisateur
confirme OpenGL et leur disparition quand seul le PBR est coupé. Examiner
les lectures de textures et les calculs aux angles rasants séparément du cache.
Tests natifs prévus : recalcul de section sous observation, invalidations
répétées, remplacement atomique, suppression réelle de blocs, lumière portée,
cache et préférences ; OpenGL et Vulkan. Pas de monde personnel ouvert.
## Précision du signalement et implémentation
L'utilisateur précise ensuite que le bruit apparaît **en mouvement**, et demande
le passage à Vulkan. Il ne faut donc pas attribuer les points de sa capture au
seul clignotement de reconstruction : ce sont deux vérifications distinctes.
- Les sections PBR publiées restent affichées jusqu'au remplacement de leur
maillage. Une nouvelle invalidation durant la construction est mémorisée ;
elle n'annule plus continuellement le travail. Le budget permanent reste
24 Mio, avec temporairement l'ancien et le nouveau maillage d'une section.
- Les invalidations de lumière dynamique ne régénèrent plus la géométrie PBR.
Les changements de blocs et de lumière du ciel restent pris en compte.
- Lectures explicites des texels de profondeur/scène et du niveau de matériau,
gradients de texture calculés avant les rejets de fragments. Les produits
scalaires des reflets sont bornés, la puissance entière de Fresnel est
explicite, les résultats non finis ne remplacent pas la scène native.
Aucun flou ajouté aux textures.
- Le choix graphique natif « Par défaut » devient « Vulkan (par défaut) »
(FR/EN) et essaie Vulkan en premier. Minecraft conserve son repli OpenGL si
Vulkan ne peut pas être initialisé. Un choix **OpenGL explicite** reste
disponible ; pour une installation ainsi réglée, sélectionner Vulkan dans
les réglages vidéo puis redémarrer. Aucun moteur externe ni VulkanMod ajouté.
- 80 % est la nouvelle intensité PBR initiale. Les valeurs déjà enregistrées
explicitement restent conservées.
Le code Minecraft 26.3 a été inspecté : `PreferredGraphicsApi.VULKAN` essaie
Vulkan puis OpenGL ; le choix natif `DEFAULT` essayait auparavant OpenGL d'abord.
Tous les effets Sanctuary utilisent déjà l'API graphique native. Le seul hook
OpenGL direct est le correctif spécifique de lecture des panoramas dans
`GlCommandEncoder` ; il ne remplace pas la voie Vulkan native.
Référence pour les lectures après contrôle divergent :
[documentation Khronos des samplers](https://wikis.khronos.org/opengl/Sampler_%28GLSL%29).
La correction rend ces lectures explicites ; elle ne constitue pas à elle seule
une reproduction du défaut sur le pilote Windows de l'utilisateur.
## Observations de diagnostic
Le détecteur de points blancs isolés (minimum RGB 170, au moins cinq voisins
sur huit sous 120) compte 669 points sur l'image utilisateur et zéro sur la
première grotte de contrôle. Il s'agit de scènes, résolutions et pilotes différents,
pas d'un avant/après reproduisant exactement le bug utilisateur. Les rendus
natifs OpenGL de contrôle étaient déjà exempts de ce grain avant les nouvelles
protections du shader ; le signalement Windows reste donc à reconfirmer là-bas.
Le test de rafraîchissement conserve les mêmes maillages publiés durant douze
invalidations consécutives, compare l'image et vérifie que des remplacements
aboutissent. Le test de torche alterne quatre fois les torches normales/des âmes
sans reconstruction des normales. Le contrôle de mouvement presse réellement
Avancer, maintient la torche et le PBR actifs et vérifie un déplacement d'au moins
un bloc avant de vérifier les 24 captures. Les angles -30°, 0° et 35° sont aussi
contrôlés, ainsi que les effets combinés et les préférences.
### Domaine invalide du reflet de la torche en main
Pour la lumière du joueur en vue subjective, la source est la caméra : le
vecteur lumière et le vecteur de vue sont parallèles. L'ancien
`pow(1 - max(dot(v,h),0),5)` n'avait aucune borne supérieure pour ce produit
scalaire. Un calcul reproductible Float32 (100 000 positions, graine 142)
donne 19 991 bases légèrement négatives, jusqu'à -2,3841858e-7, contre zéro
après clamp. C'est une reproduction numérique d'un **domaine de calcul invalide**,
pas une reproduction des pixels produits par le pilote utilisateur.
GLSL 3.30 ne définit pas `pow` pour une base négative, même avec un exposant
entier : [spécification Khronos, fonctions exponentielles](https://registry.khronos.org/OpenGL/specs/gl/GLSLangSpec.3.30.pdf).
Le correctif borne les cosinus à [0,1] et remplace cette puissance par des
multiplications. La torche alignée sur la caméra ne dépend donc plus du traitement
de cette entrée invalide par le pilote. Reçu numérique ignoré :
`build/pbr142-fresnel-domain.json`.
### Mise à jour des fixtures antérieures
La suite Vulkan complète s'est arrêtée au contrôle SSGI de coin : l'oracle
ne trouvait plus de pierre neutre (`rows=0`), car les lumières colorées ajoutées
depuis beta.139/140 teintaient la pierre avec la lave. Le test de proximité SSGI
et le test PBR solaire isolent désormais leur effet en coupant cette autre passe.
Aucun seuil n'a été diminué et aucun changement de rendu SSGI n'est livré.
Le sous-ensemble en échec est relancé séparément ; les étapes précédentes
(ombres, CTM, bloom, lens flare, déplacement SSGI) avaient réussi.
## Vérifications natives
Sur Mac Apple M1, Java 25 et Minecraft 26.3 :
- OpenGL : suite couleurs/directions/rafraîchissement en 3 min 36 s, puis
déplacement continu avec torche active en 1 min 36 s. Zéro point blanc isolé
sur les 24 images de mouvement et les trois angles de grotte.
- Vulkan/MoltenVK 1.4.2 : sélection automatique sans argument de moteur,
couleurs/directions/rafraîchissement en 3 min 47 s. Dernier test continu avec
torche active en 1 min 37 s, zéro point blanc isolé sur les 24 images.
- Ombrages lune/midi/feuilles, distances et précisions, stabilité temps réel,
CTM, bloom (minerais, torche, lampe, lave, soleil), émissifs indépendants et
lens flare validés sous Vulkan dans la suite d'effets. Le déplacement SSGI
sur douze blocs passe également. La suite s'arrête ensuite sur l'oracle
ancien de couleur de pierre décrit ci-dessus : ne pas présenter ce lancement
de 7 min 15 s comme une réussite complète.
- Reprise ciblée après isolation de cet oracle : SSGI près des surfaces puis
PBR extérieur réussis sous Vulkan en 2 min 55 s. Proximité 35/100, haute
résolution, léger mouvement, toit ajouté/retiré, reload, resize, zéro/OFF et
effets combinés. Gain SSGI au coin test : 6,01 sur 99 lignes à 35 %.
- Les nouvelles valeurs initiales et les choix enregistrés sont testés, dont
une intensité PBR explicitement enregistrée à 50 qui reste à 50.
Les premiers essais du nouveau contrôle de mouvement ont permis de corriger
son entrée clavier : `pressKey` ne maintient pas une touche enfoncée. Le test
final utilise `holdKey` puis `releaseKey`, vérifie le déplacement réel et refuse
la disparition de la lumière portée ou du PBR pendant les captures.
Les jeux de test utilisent uniquement des mondes neufs et ignorés. La capture
et le pilote Windows utilisateur n'ont pas été reproduits à l'identique ; leur
retest reste ouvert malgré les corrections numériques et les contrôles natifs.
## Construction et archives
`./gradlew check build assemblePack assembleTestPack -x :sanctuary:runGameTest`
réussit en **2 min 38 s**, 126 tâches. Le GameTest serveur dédié reste exclu
selon le refus EULA antérieur ; les suites clientes ci-dessus ont réellement
utilisé les deux moteurs natifs. `git diff --check` passe.
Les archives normale/Test embarquent le même JAR, leurs versions/index/hashes
sont vérifiés, leurs sources correspondent aux fichiers courants. Le template
contient exactement les ressources attendues. Les onze entrées de production
modifiées par rapport à beta.141 sont limitées au PBR, au choix graphique et
aux traductions ; les ressources de jeu antérieures sont conservées.
SHA-256 du JAR : `0bc2da9df2ef004e6dc78932cfde7b31b7717c63c83ceed789d151177c41bc1a`.
- `Sanctuary-beta.142.mrpack` : 10464746 octets ; SHA-256 `dba4fd8e1e3b20136faa1c1de3494df3cb250c87de199c04e07883c786ca48ee`.
- `Sanctuary-Test-beta.142.mrpack` : 10483672 octets ; SHA-256 `d56b81a4aea6649a40a3c49dedee904989943a518ad0ae7c520b808dd0f9b9a7`.
Reçu détaillé ignoré : `build/shader142-artifact.json`.
## Livraison réalisée
[Release beta.142](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.142)
publiée depuis `ea151dd5df8c7c5abd0107656f40c723efbf2ebe`. Canal stable :
`d661260e2bbabb0326c86d6cc543daf986979596`. Archives normale/Test, ZIP d'amorçage Prism et template
publiés avec vérification de leurs téléchargements ; tag et artefacts immuables.
Deux synchronisations isolées puis deux synchronisations de l'instance
**Sanctuary Beta** réussies. Les 923 fichiers personnels suivis ont conservé
leurs hashes, notamment mondes, réglages et packs personnels. Aucun monde
personnel ouvert. Sauvegarde préalable : `sanctuary-backups/before-beta.142/`.
L'instance locale conserve son réglage graphique natif « default », qui démarre
désormais avec Vulkan prioritaire. Un Windows explicitement réglé sur OpenGL
doit sélectionner Vulkan dans les réglages vidéo et redémarrer.
Archives locales également dans `sanctuary-beta/build/`. Le checkout de travail
historique modifié `sanctuary-beta` n'a pas été réinitialisé ; seules ces archives
ignorées y ont été déposées. Sources sur `codex/pbr-refresh-beta142`.
+68
View File
@@ -0,0 +1,68 @@
# RENDER-148 — PBR 50 % et reflet solaire diffus
Branche `codex/pbr-soft-sun-beta148`, socle beta.147 `96a36d9`.
Minecraft 26.3, Java 25, dépendances et sauvegardes inchangés.
## Résultat
- PBR à 50 % par défaut, SSR toujours à 20 % et portée commune jusqu’à 256.
- Les configurations anciennes contenant exactement PBR 80 % passent à 50 %.
Un marqueur de migration conserve ensuite tout choix explicite, y compris
un nouveau choix 80 %. Les autres anciennes intensités restent conservées.
- Le reflet solaire de l’eau avait un renforcement ×20 et une compression
exponentielle qui effaçait les détails dans une large zone blanche.
Le renforcement passe à ×6 et le lobe est plus diffus (rugosité minimale
de 0,42 au lieu de 0,34 pour le lobe céleste de l’eau).
- Pour le soleil sur l’eau, fusion par texel entre éclaircissement vers le
blanc et lumière douce fondée sur la couleur native. La pondération emploie
une compression rationnelle, sans atteindre brutalement un plateau blanc.
La texture et ses animations restent natives ; aucun bruit ajouté.
- La lune conserve son intensité atténuée, sans renforcement solaire.
Les lampes et matières solides conservent leur fusion spéculaire précédente.
Le bloom reste réglable et suit la lumière réellement ajoutée.
## Vérifications
Scènes plates jetables, Apple M1, Vulkan et OpenGL. Le test solaire contrôle
une lumière largement visible et limite les pixels quasi blancs à moins de
0,5 % de l’image, au lieu d’imposer une grande tache blanche.
Premier contrôle Vulkan à PBR 50 % / SSR 20 % : 149 908 pixels éclaircis,
zéro pixel quasi blanc sur 854 × 480. Captures vues du dessus et à soleil bas
inspectées : la texture reste visible dans la traînée lumineuse.
Preuves : `build/evidence/beta148-preview/`.
Rendu de la traînée solaire approuvé par le créateur après inspection de la capture.
- Suites SSR complètes Vulkan et OpenGL réussies : F5, intersections, portées,
eau/verre/métal, OFF/zéro, lune, bloom, rechargement et redimensionnement.
Journaux : `build/beta148-ssr-vulkan.log`, `build/beta148-ssr-opengl.log`.
Sous OpenGL, 146 680 pixels éclaircis et zéro pixel quasi blanc dans la scène
solaire. La lune reste visible sans saturation (35 865 pixels modifiés).
Preuves : `build/evidence/beta148-ssr-vulkan/` et
`build/evidence/beta148-ssr-opengl/`.
- Suites PBR/préférences Vulkan et OpenGL réussies : défaut 50 %, migration
de l’ancien 80 %, conservation d’un nouveau choix explicite 80 %, valeurs
personnalisées, FR/EN, proximité, OFF/zéro, intensités et rechargement.
Journaux : `build/beta148-pbr-vulkan.log`, `build/beta148-pbr-opengl.log`.
- `./gradlew check build` dans l’instantané isolé : 252 tests serveur,
229 réussis, 23 échecs. Exactement les mêmes identifiants que beta.147,
aucun nouvel échec. Journal : `build/beta148-check-build-isolated.log` ;
comparaison : `build/beta148-server-failures.json`. Le contrôle global
demeure donc en échec.
- Assemblage final réussi : `./gradlew build assemblePack -x :sanctuary:check`,
sans répéter les contrôles purs et serveur déjà exécutés dans l’instantané.
Journal : `build/beta148-build-pack.log`. L’exclusion ne constitue pas
une réussite du contrôle complet.
## Artefact
[Sanctuary-beta.148.mrpack](../build/Sanctuary-beta.148.mrpack), également copié
à côté des autres versions dans `sanctuary-beta/build/`. Archive ZIP vérifiée,
un seul JAR Sanctuary beta.148 identique au JAR construit, shaders finaux
identiques aux sources, textes FR/EN 50 % et dépendances exactes vérifiés.
Taille : 10 520 050 octets. SHA-256 :
`597cc2a96eabc3cf0ebf8ac59b4f94cf7cabf0c773e1dce13c10736ca6fcba38`.
Reçu : `build/beta148-artifacts.json`.
Aucune publication ni installation Prism effectuée.
+221
View File
@@ -0,0 +1,221 @@
# 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.
+68
View File
@@ -0,0 +1,68 @@
# SNOW-143 — Neige saisonnière en temps réel
Branche `codex/realtime-snow-melt-beta143`, socle beta.142. Minecraft 26.3 /
Java 25. Livraison beta.143.
## Comportement
L'accumulation et la fonte saisonnière conservent le sampler natif de
précipitation `ServerLevel.tickPrecipitation`. Sanctuary ne scanne donc toujours
pas les chunks et ne force aucun chargement de terrain. Le changement porte
seulement sur la probabilité des mutations de neige.
Pendant une chute de neige, Sanctuary ajoute désormais une couche une fois sur
12 échantillons natifs en moyenne. La neige reste visible pendant une session,
mais les colonnes ne montent plus instantanément. À `randomTickSpeed = 3`, une
couche apparaît en moyenne après environ 40 minutes de neige continue.
Pour la fonte, chaque tirage saisonnier est multiplié par `72`, le rapport
entre une journée réelle de 24 heures et une journée Minecraft de 20 minutes.
Une couche fond désormais en moyenne en 4 h 05 en été,
8 h 10 au printemps et 16 h 25 en automne. Avec le plafond actuel de deux blocs,
une colonne météo suivie met environ 2,7 jours réels à disparaître en été,
5,4 jours au printemps et 11 jours en automne. L'hiver ne fond toujours pas.
Les règles de protection restent celles de beta.109 : seule la neige déposée
par la météo Sanctuary depuis l'ajout du suivi `sanctuary:natural_snow_v1`
peut fondre. La neige construite, l'ancienne neige non suivie, les couches
manuelles, les biomes froids, les profils neigeux et la météo Vanilla restent
préservés. Aucun format de sauvegarde ne change.
## Vérifications
Vérifié le 18 septembre 2026 sur macOS, Java 25.
- `./gradlew :sanctuary:runClientGameTest -PsanctuaryClientTests=true
-PsanctuaryMelt109ClientTests=true -PsanctuaryClientNoVsync=true
-PsanctuaryQuickTests=true` : réussi en 57 s, avec le marqueur
`MELT109_NATIVE_PASS`. Le test couvre l'accumulation beta.108 ralentie,
la fonte couche par couche, la provenance persistante, les saisons, la
gamerule, la météo Vanilla, les profils neigeux, les biomes froids et le
rechargement de monde.
- `./gradlew check build assemblePack assembleTestPack -x :sanctuary:runGameTest` :
réussi en 2 min 31 s, 126 tâches. Le GameTest serveur dédié reste exclu
conformément au refus antérieur de son EULA ; les contrôles natifs utilisent
le serveur intégré.
- `python3 scripts/pack.py check` : versions, icône et index packwiz vérifiés.
- Exports MRpack produits avec `packwiz modrinth export`, intégrité ZIP vérifiée.
Les deux MRpack embarquent exactement le JAR Sanctuary construit.
| Artefact | Taille | SHA-256 |
| --- | ---: | --- |
| `sanctuary-beta.143.jar` | 11 323 665 octets | `fd4b663ac2b2a106624c42b09997db229cc14d6c39d44ae83079bc6aa9d39a08` |
| `Sanctuary-beta.143.mrpack` | 10 464 931 octets | `e55caaa41c5a3a2666ff5415846e18f44d504ac34b698f3d974091cfb52364c5` |
| `Sanctuary-Test-beta.143.mrpack` | 10 483 858 octets | `b14bca04c4a48771209d0f7e234ff9ef6b800eb236fb969a6f0dfcee5620dafc` |
| `Sanctuary-Template-beta.143.zip` | 4 354 153 octets | `071fd8da67d1d0224b89889a5fcbc4cd269673dddb7a52f89aa78802f154605a` |
## Publication
[Release beta.143](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.143)
publiée depuis le commit `97ff2b54cb63afe8fdd88bfa8f2ebb25dbe3b86d`.
Le canal packwiz stable pointe sur
`bafff5e9fcdd95c5dd184953a34ada805e455797`.
Deux synchronisations isolées, puis deux synchronisations dans l'instance
Sanctuary Beta ont réussi. Les 923 fichiers personnels suivis gardent leurs
hashes ; aucun monde personnel n'a été ouvert. Sauvegarde préalable :
`sanctuary-backups/before-beta.143/`.
+112
View File
@@ -0,0 +1,112 @@
# 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.
+91
View File
@@ -0,0 +1,91 @@
# SHADER-131 — émissifs indépendants et réglages initiaux
Demande : activer par défaut bloom, minerais émissifs et highlight ; garder
les émissifs avec halo coupé ou intensité du bloom à zéro, sans bouton ajouté ;
ajouter 64 et 128 pixels/bloc en options. Précision initiale 16, distances
highlight 32 et ombres 128 conservées. Préférences personnalisées préservées.
Branche `codex/emissive-beta131`, socle beta.130, Minecraft 26.3 / Java 25.
Le futur effet demandé est une légère teinte diffusée depuis les blocs
voisins, pas des reflets reconnaissables. Étude seulement pour ce ticket ;
aucun SSR ou éclairage indirect expérimental activé.
## Comportement et validation
Le curseur à zéro et l'interrupteur Bloom coupent uniquement le halo.
Le réglage existant des minerais contrôle leurs inclusions nettes séparément.
Sans halo, les cibles de diffusion GPU sont libérées ; l'émission garde
seulement les ressources nécessaires. Tout désactiver libère l'ensemble.
La grille commune propose 8, 16, 32, 64 et 128, sans augmenter le plafond
4096 de la carte d'ombres. Libellés FR/EN et essais clients natifs sur les
inclusions, l'absence de halo, les transitions et les nouvelles précisions.
Aucun changement de lumière serveur, de génération ou de sauvegardes.
Le cœur émissif et le halo utilisent maintenant deux chemins indépendants.
Les textures et leur analyse restent celles de beta.130. Avec le halo coupé,
la carte d'émission est effacée avant les faces, le cœur net est composé,
et les passes de ciel, réduction, diffusion et application du halo sont
omises. Le retour au bloom recrée ses cibles. Aucun bouton ajouté, aides
FR/EN adaptées et choix explicitement désactivés toujours conservés.
## Vérifications
Suite client native réussie en **3 min 46 s**, monde plat neuf de graine 122,
macOS / Apple M1 / OpenGL. Cinq précisions sauvegardées et chargées, bornes
et seuils de normalisation, carte d'ombres plafonnée et arêtes CTM rendues
à 64/128. Le gain mesuré des traits diminue de 0,02778 à précision 32 à
0,02094 à 64, puis 0,01013 à 128 dans la même scène.
Les inclusions restent nettes sans halo : le rendu Bloom OFF correspond
au rendu intensité zéro. Aucun gain mesuré hors du masque source, ressources
de diffusion absentes ; couper aussi les émissifs libère l'ensemble et
restitue l'image initiale. Réactiver le bloom restitue son image précédente.
Captures OFF/zéro inspectées. Valeurs initiales ON vérifiées ; un ancien
choix explicitement OFF reste OFF. Les essais beta.130 (vingt masques,
occultation, sources, rechargement), lune, feuillage et realtime passent.
Le premier essai rencontrait une assertion historique attendant le highlight
OFF lorsqu'une clé était absente. Elle attend désormais la nouvelle valeur
ON ; les cas de préférences explicitement désactivées restent testés.
Commande : `./gradlew :sanctuary:runClientGameTest -PsanctuaryClientTests=true
-PsanctuaryPixelShadows122ClientTests=true -PsanctuaryClientNoVsync=true
-PsanctuaryQuickTests=true`. Log : `build/shader131-client-full.log` ; captures
sous `mods/sanctuary/build/run/clientGameTest/screenshots/`.
`./gradlew check build assemblePack assembleTestPack -x :sanctuary:runGameTest`
réussit en **2 min 30 s**, 126 tâches. Les archives normal/Test, leurs sources
et le template sont vérifiés. Six entrées de production changent : moteur
Bloom, préférences, interface et libellés. Gameplay, shaders GLSL et textures
restent identiques à beta.130. Aucun résultat Windows/Vulkan
revendiqué ; le serveur GameTest dédié reste exclu conformément au refus
antérieur de son EULA.
## Piste suivante — diffusion des couleurs
Choix confirmé : légère teinte des surfaces voisines. Une estimation SSGI
utilise la couleur et la profondeur visibles pour des rebonds diffus ; voir
la [documentation primaire Unity](https://docs.unity3d.com/Packages/com.unity.render-pipelines.high-definition@17.0/manual/Override-Screen-Space-GI.html).
Proposition à expérimenter : faible intensité, courte portée autour des
surfaces, calcul réduit, rejet par profondeur pour ne pas teinter au travers
des murs. Vérifier les bords de l'écran, les blocs hors champ, la stabilité
en mouvement et les textures avant de fixer une qualité. Cette piste n'est
pas implémentée dans beta.131.
## Livraison
- `Sanctuary-beta.131.mrpack` : 10386312 octets ; SHA-256 `e02067e424bf4f54512d5cfb1b5cde0fe232aa6bdadc0d38981cd15817a6387a`.
- `Sanctuary-Test-beta.131.mrpack` : 10405238 octets ; SHA-256 `298387cce5a5b7cdc09b3a9d24859b674a736b25abcbafa7995b813757641265`.
JAR : `87210da23f7781efccdbdbf5d4e2d3c1ff1857a7f326027b93d6a301c7dfdb0d`.
Archives vérifiées recopiées dans `sanctuary-beta/build/`. [Release beta.131](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.131)
publiée depuis `078d3e6833daa7de7f6c6d0e1a6c17d5dbefe075`. Canal packwiz
`efb18c274d95d988a9566b8c52751469f002cdda`. Téléchargements publics vérifiés par hash.
Deux synchronisations isolées, puis deux dans la même instance **Sanctuary Beta**
réussissent. Un seul JAR Sanctuary beta.131 ; les **923 fichiers personnels
suivis** conservent leurs hashes, dont les préférences de shaders et les mondes.
Sauvegarde préalable `sanctuary-backups/before-beta.131/` ; aucun monde personnel
ouvert. Reçus ignorés : `build/shader131-artifact.json`,
`build/shader131-isolated.json`, `build/shader131-prism.json` et
`build/shader131-publication.log`. Le checkout original conserve ses changements.
+106
View File
@@ -0,0 +1,106 @@
# SHADER-132 — diffusion locale SSGI expérimentale
Demande : légère teinte diffusée des surfaces voisines, sans reflets miroir
ni shader d'eau. Branche `codex/ssgi-beta132`, socle beta.131, Minecraft 26.3 /
Java 25. Aucun changement de génération, sauvegarde ou lumière serveur.
## Résultat
**Options → Shaders → SSGI · couleurs diffusées** active un rebond diffus
local. L'effet expérimental est initialement OFF, avec une intensité préparée
à **35 %** ; le curseur propose 0–100. À zéro ou OFF, ses cibles et uniformes
GPU sont libérés. Les autres effets et leurs préférences sont conservés.
La couleur d'une surface visible peut teinter les surfaces proches qui lui
font face. Le calcul porte à **2,5 blocs** du récepteur et s'estompe entre
24 et 32 blocs de la caméra. Il utilise une copie de la couleur native après
les ombres, avec la profondeur et des normales reconstruites. Le premier
obstacle arrête chaque rayon, même si son épaisseur empêche de retenir une
intersection. Les plans coplanaires ne s'éclairent pas eux-mêmes.
Douze directions hémisphériques fixes, au maximum dix étapes par rayon,
sont évaluées à demi-résolution en largeur et hauteur. La reconstruction
plein écran est guidée par la profondeur pour conserver les silhouettes et
les détails des textures. Le cœur des émissifs, le highlight et le bloom
sont composés ensuite : pas de halo réinjecté dans le rebond ni de boucle
de rétroaction d'une image sur l'autre. Aucune lecture GPU vers CPU ni
recherche de chunks en production. Pas de bruit temporel ou d'historique.
Deux cibles, RGBA8 plein écran et RGBA16 flottant à demi-résolution, soit
environ six octets par pixel de l'écran, plus 176 octets d'uniformes par
segment du tampon natif. Redimensionnement, arrêt, déconnexion et
rechargement libèrent/recréent les ressources possédées. Ce budget n'est
pas une garantie de FPS ; le SSGI coûte davantage qu'un simple réglage de
colorimétrie.
## Limites de cette version
L'estimation utilise seulement la couleur et la profondeur visibles. Les
blocs hors champ ou cachés n'apportent pas leur couleur. Le résultat peut
changer lorsqu'ils entrent ou sortent de l'image ; une atténuation près
des bords limite la coupure. Les surfaces transparentes absentes de la
profondeur ne sont pas reconstruites. Petits objets et intersections plus
minces que l'échantillonnage peuvent manquer. Il s'agit d'une approximation
diffuse locale, pas d'une simulation complète d'éclairage global ni de
reflets spéculaires. Aucune modification de l'eau et aucune source hors
champ déduite ou générée.
## Vérifications
Test ciblé natif réussi en **1 min** sur macOS / Apple M1 / OpenGL, nouveau
monde plat de graine 122. À intensité 90, les échantillons du sol blanc
montrent un gain rouge cumulé de 8 968 contre 20 bleu devant le mur rouge,
puis 9 168 bleu contre 23 rouge devant le mur bleu. Le sol seul est inchangé.
OFF/zéro restituent l'image de base et libèrent les cibles ; intensité 20
réduit le gain. Changer la couleur du mur complètement occulté laisse
l'image inchangée. Rechargement, caméra fixe, mouvement infinitésimal et
retour à la caméra d'origine passent, sans accumulation de couleur.
Deux ajustements de fixture ont précédé ce résultat : inclure dans les
mesures le blanc légèrement bleuté par l'éclairage natif, et formater les
petits angles de `/tp` en décimal car la commande refuse la notation
scientifique. Aucun comportement du jeu modifié pour ces corrections.
Log ciblé : `build/shader132-client-ssgi.log`. La suite complète réussit en
**4 min 18 s** et vérifie aussi une fenêtre 961 × 541, le retour à sa taille
initiale, le SSGI avec ombres/highlight/bloom/émissifs actifs, ainsi que
l'arrêt global du shader. Les captures françaises et anglaises passent.
Les contrôles précédents des cinq précisions, ombres, lune, feuillage, CTM,
bloom et émissifs restent réussis. Log : `build/shader132-client-full.log`.
Commandes : `./gradlew :sanctuary:runClientGameTest -PsanctuaryClientTests=true
-PsanctuaryPixelShadows122ClientTests=true -PsanctuaryClientNoVsync=true
-PsanctuaryQuickTests=true`. Pour le test ciblé, remplacer la propriété
PixelShadows par `-PsanctuarySsgi132ClientTests=true`.
`./gradlew check build assemblePack assembleTestPack -x :sanctuary:runGameTest`
réussit en **2 min 25 s**, 126 tâches. Les packs normal/Test contiennent le
même JAR ; sources archivées et template de ressources correspondent aux
sources. Comparaison avec beta.131 : **11 entrées de production** modifiées,
limitées au SSGI, à ses options et à ses points d'appel. Gameplay et textures
des gemmes inchangés, aucun monde ni test embarqué. Aucun résultat
Windows/Vulkan revendiqué.
Le serveur GameTest dédié reste exclu conformément au refus antérieur de
son EULA. Les captures panoramiques et les milieux de brume sous l'eau/lave
ou neige poudreuse suspendent cette passe, comme les autres effets de
profondeur Sanctuary.
## Livraison
- `Sanctuary-beta.132.mrpack` : 10394026 octets, SHA-256 `a7c0823e00f36925919645e32e7792242ec1d01015b41bee5b4f6285b9fc533d`.
- `Sanctuary-Test-beta.132.mrpack` : 10412949 octets, SHA-256 `9bc30a7d6d5834f9323f15f0c39d3f49d200e1a97d739266ab373c9e1ba4a8fd`.
JAR : `83eb2d091a7acd85eaa8b3b6a00a40ea483f9eeb2a30c033bdf68b4d717c5142`.
Archives vérifiées recopiées dans `sanctuary-beta/build/`. [Release beta.132](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.132)
publiée depuis `7e21781a9a08c669c7d6b65653bd9d408887689c`. Canal packwiz
`9edaaedc0205c525ecce9de66b5ca7827c56c28f` ; téléchargements publics vérifiés par SHA-256.
Deux synchronisations isolées, puis deux dans la même instance **Sanctuary Beta**,
réussissent. Un seul JAR beta.132 actif ; les **923 fichiers personnels suivis**,
dont sauvegardes et préférences de shaders, gardent leurs hashes. Sauvegarde
préalable `sanctuary-backups/before-beta.132/` ; aucun monde personnel ouvert.
Le checkout original conserve ses changements préexistants.
Reçus locaux ignorés : `build/shader132-artifact.json`,
`build/shader132-isolated.json`, `build/shader132-prism.json`,
`build/shader132-publication.log`.
+106
View File
@@ -0,0 +1,106 @@
# SHADER-133 — SSGI visible au réglage courant
Demande : le rebond diffus de beta.132 est trop discret pour être distingué.
Branche `codex/ssgi-strength-beta133`, socle beta.132, Minecraft 26.3 / Java 25.
## Changement
La collecte conserve douze directions, dix étapes, une demi-résolution et
un rayon de 2,5 blocs. L'atténuation spatiale devient linéaire plutôt que
quadratique. Le mélange est reconstruit en lumière approximativement linéaire,
puis réencodé une seule fois. Un gain artistique plus fort rend la couleur
lisible et une courbe progressive évite de couper brutalement les canaux
à blanc. Les surfaces sans rebond restent intactes. La force demeure réglable
de 0 à 100, avec le même réglage initial de 35 ; aucune préférence enregistrée
n'est écrasée. SSGI reste optionnel, initialement désactivé.
Aucun rayon, cible GPU ou texture de matériau supplémentaire. La composition
ajoute une exponentielle et une racine carrée par canal ; le coût exact en FPS
reste à mesurer sur la machine du joueur. Les normales de géométrie orientent
déjà le rebond. Pas de PBR ni de normales procédurales de textures ajoutés.
## Piste matériaux
Pour une étape distincte, des normales de faible amplitude calculées au
chargement des textures, mises en cache, et une rugosité par famille de matériaux
permettraient d'étudier du relief et des reflets orientés. Une simple conversion
luminosité → hauteur ne connaît pas le relief réel : un motif peint peut devenir
une bosse artificielle. Commencer sur pierre, briques et bois avec une amplitude
réglable serait préférable à une génération aveugle pour tous les blocs.
La passe actuelle ne possède ni atlas de normales, ni identifiants de matériaux,
ni albédo non éclairé ; des images seules ne suffiraient pas à les exploiter.
## Limites conservées
Approximation diffuse à partir de la couleur déjà éclairée, pas un transport
physique complet. Seules les surfaces visibles contribuent, avec variations
possibles aux entrées/sorties d'écran. Premier obstacle bloquant, pas d'historique
ni de bruit temporel ; aucune modification d'eau, monde ou lumière serveur.
## Vérifications
Suite client native complète réussie en **4 min 29 s**, sur macOS / Apple M1 /
OpenGL, dans un monde de test plat (graine 122). Les comparaisons ON/OFF à 35 %
mesurent, sur les pixels neutres du sol :
| Mur | Pixels avec gain ≥ 8/255 | Pixels avec gain ≥ 16/255 | Gain cumulé du canal dominant |
| --- | ---: | ---: | ---: |
| Rouge | 16 487 | 7 311 | 305 221 |
| Bleu | 14 954 | 7 328 | 309 991 |
À 90 %, les gains dominants atteignent 654 683 et 655 867, contre 8 968 et
9 168 au même réglage dans la fixture beta.132. Ce rapport concerne uniquement
cette scène et ces pixels, pas une promesse de gain uniforme dans tous les mondes.
Les captures confirment une bande colorée au pied du mur. Un premier réglage
intermédiaire a été rejeté car aucun pixel du sol ne dépassait 8/255 à 35 % ;
un second a passé les contrôles ciblés, puis la force a encore été augmentée
après examen visuel. Les valeurs finales sont celles de la suite complète.
Le test impose maintenant plus de 5 000 pixels à 8/255 et plus de 500 à 16/255
au réglage 35, pour les deux couleurs. Intensité, OFF/zéro, absence de rebond
coplanaire, occultation d'un mur changeant de couleur, caméra fixe, déplacement
infinitésimal, retour de caméra, redimensionnement et rechargement passent.
Ombres, lune, feuillage, CTM, cinq précisions, bloom, émissifs indépendants et
interfaces FR/EN restent validés, y compris avec le SSGI actif.
Commande : `./gradlew :sanctuary:runClientGameTest -PsanctuaryClientTests=true
-PsanctuaryPixelShadows122ClientTests=true -PsanctuaryClientNoVsync=true
-PsanctuaryQuickTests=true`. Log : `build/shader133-client-full.log`.
Le nom de la propriété ciblée `sanctuarySsgi132ClientTests` est conservé.
Comparateur local autonome : `build/SSGI-beta.133-comparaison.html`, captures
natives OFF/35/90 des murs rouge/bleu, sans retouche. Pas de validation
Windows/Vulkan ni de mesure de coût FPS revendiquée.
`./gradlew check build assemblePack assembleTestPack -x :sanctuary:runGameTest`
réussit en **2 min 21 s**, 126 tâches. Le serveur GameTest dédié reste exclu
conformément au refus EULA antérieur. Log : `build/shader133-build.log`.
Les archives normal/Test contiennent le même JAR ; sources archivées et template
de ressources correspondent aux sources. Comparaison avec beta.132 : **5 entrées
de production** changées (classe du compositeur, deux shaders, libellés FR/EN).
Gameplay, textures, sauvegardes et dépendances inchangés.
## Artefacts
- `Sanctuary-beta.133.mrpack` : 10394282 octets, SHA-256 `0c90a070da7f969706abeeec1bdfb0830bb52244c20fccbca4fd876f3fbe0564`.
- `Sanctuary-Test-beta.133.mrpack` : 10413204 octets, SHA-256 `e01f0355bf2cce18156fe94a069e142bedc498624ba0b6e2729ae0f23ea73e80`.
JAR : `a01cae4a98606007b533ca09ec9f072f19a1db2e9b8de466f4ad075d5c1fc185`.
## Livraison
[Release beta.133](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.133)
publiée depuis `216d3f75b576bd2ed531653012f9cc6c4801d87a`. Canal packwiz
`9ceb5a5353c9d1b8bb7834a20e818fd65096ff98` ; téléchargements publics vérifiés par SHA-256.
Deux synchronisations isolées puis deux synchronisations de la même instance
**Sanctuary Beta** réussissent. Un seul JAR beta.133 actif ; les **923 fichiers
personnels suivis**, dont sauvegardes et préférences, gardent leurs hashes.
Sauvegarde préalable `sanctuary-backups/before-beta.133/`, aucun monde personnel
ouvert. Le checkout original conserve ses changements préexistants.
Les archives normal/Test/template et le comparateur natif sont également dans
`sanctuary-beta/build/`. Reçus locaux ignorés : `build/shader133-artifact.json`,
`build/shader133-isolated.json`, `build/shader133-prism.json` et
`build/shader133-publication.log`.
+109
View File
@@ -0,0 +1,109 @@
# SHADER-134 — PBR procédural optionnel et herbe SSGI
Demande : ajouter en option des normales calculées à partir des textures actives,
avec rugosité des matériaux et reflets orientés. Retour : le SSGI renforcé
éclaire artificiellement l'intérieur de l'herbe ; vérifier et corriger ce cas.
Branche `codex/pbr-beta134`, socle beta.133, Minecraft 26.3 / Java 25.
## Contrat
PBR initialement OFF, intensité 50 réglable 0–100. Cartes de normales à partir
des différences de luminance, amplitude limitée ; rugosité/métallicité par
famille de blocs, repli rugueux pour les types inconnus. Analyse des ressources
actives conservée en cache jusqu'à désactivation ou rechargement ; pas de fichiers
écrits dans le pack personnel, pas de GPU readback en production.
Portée 32 blocs, sections visibles construites progressivement. Budget de maillage
24 Mio, atlas de 4 096 matériaux en 16 × 16 avec bordures (1 280 × 1 280 RGBA8,
6,25 Mio), copie RGBA8 de l'image courante et uniformes. Les textures de plus haute
résolution sont échantillonnées à 16 × 16 ; première image des textures animées.
La couleur et l'animation restent celles de l'atlas natif. Pas de parallax ni
de géométrie ajoutée aux blocs ; les motifs clairs peuvent donner un faux relief.
La passe utilise une réponse spéculaire GGX (normales, rugosité, métallicité,
Fresnel) et module l'éclairage diffus déjà rendu. Elle ne remplace pas tout le
pipeline Minecraft par un moteur PBR physique. Soleil/lune, exposition au ciel et
carte d'ombres active limitent les reflets ; pas de reflets miroir, d'environnement
SSR, ni de direction locale déduite pour chaque torche. Aucun rendu PBR d'eau,
d'entités, d'items ou de modèles spéciaux hors maillage de terrain.
## SSGI
Les normales des faces presque axiales sont stabilisées sur leur axe. Deux passes
spatiales filtrent la lumière indirecte à demi-résolution, avec rejet des voisins
hors du plan du récepteur et interpolation de l'échantillonnage. Les textures de
l'image restent nettes. Un tampon RGBA16F supplémentaire à demi-résolution porte
le budget des cibles SSGI à environ 8 octets par pixel plein écran (contre 6).
Pas d'historique temporel ni de rémanence ajoutés.
Un parcours de 12 blocs a reproduit des bandes sur un mur et un éclaircissement
artificiel d'herbe à 35 et 100. Le filtrage seul ne suffisait pas : la collecte
réinjectait aussi trop fortement la lumière ambiante neutre. Elle conserve
maintenant la composante chromatique de la source, en soustrayant son canal
linéaire minimum. L'éclairage ambiant neutre reste celui de Minecraft. Ce réglage
vise le transfert de couleur voulu, pas une simulation physique complète du
rebond blanc. Les sources hors champ/cachées restent absentes du SSGI ; le résultat
peut encore varier lors d'un déplacement important. PBR et SSGI restent indépendants.
## Vérification
Test PBR ciblé réussi en 1 min 13 s : relief éclairé/ombré, intensités 20/50/100,
soleil matin/soir, cache stable, préférences, OFF/zéro, mouvement infinitésimal,
redimensionnement, rechargement et combinaison des effets. Le premier parcours
SSGI est conservé comme reproduction ; résultats finaux ci-dessous.
Le parcours corrigé a réussi en **2 min 47 s** avec 13 positions réparties sur
12 blocs, à 35 et 100, chaque fois comparées à OFF. Un mob immobile, des brins
d'herbe et un mur figurent dans la scène. Retour au point initial sans rémanence.
Sur les captures de ce parcours, le maximum du gain vert moyen mesuré dans
l'herbe passe sous 9,73 niveaux à 35 et 24,51 à 100. Le test impose ensuite
respectivement des limites de 12 et 30 à chaque position. L'essai reproduisant
le défaut atteignait notamment 23,68 et 49,20 à l'extrémité droite.
Ces mesures décrivent cette scène, pas une garantie d'absence d'artefacts
dans tous les mondes ni sur toutes les cartes graphiques.
La suite client complète réussit en **7 min** sur macOS / Apple M1 / OpenGL.
Elle inclut le parcours latéral et ses seuils de gain, les deux couleurs de rebond,
le PBR, les ombres, le feuillage, les cinq précisions, le CTM, les émissifs, le
bloom, les préférences et les écrans FR/EN. Log : `build/shader134-client-full.log`.
Le contrôle PBR final réussit en **1 min 19 s** : placement/retrait d'un toit,
invalidations de lumière/maillage et rendu combiné avec les ombres actives. Les
reflets utilisent maintenant la grille des ombres pour leur test de visibilité. Aucun résultat Windows/Vulkan ni
mesure de FPS revendiqués.
## Construction et artefacts
`./gradlew check build assemblePack assembleTestPack -x :sanctuary:runGameTest`
réussit en **2 min 22 s**, 126 tâches. Le serveur GameTest dédié reste exclu
conformément au refus EULA antérieur. Log : `build/shader134-build.log`.
Packs normal/Test : même JAR Sanctuary. Sources archivées et template identiques
aux sources. Comparaison à beta.133 : **23 entrées de production** changées,
limitées au rendu, à ses options et points d'appel. Gameplay et gemmes inchangés.
- `Sanctuary-beta.134.mrpack` : 10421971 octets, SHA-256 `d5bc79acb1d56e7dcf83c976156fdb2fcde80c98e443bde8a6ab312d56887b7f`.
- `Sanctuary-Test-beta.134.mrpack` : 10440898 octets, SHA-256 `04308ebb9adaf76b30a3cbf4fa32ab897e76842803fc8c8411cfa32952d1bdd5`.
JAR : `a6e69622a5e6629f70dd19b7494ad3b00916bc02169b73635f0e2d563929b7fa`.
Comparateur local : `build/Shader-beta.134-comparaison.html`. Il contient les
captures natives PBR OFF/50/100 et le parcours SSGI de 12 blocs OFF/35/100.
Le parcours est une succession de positions enregistrées, pas une mesure des FPS.
## Livraison
[Release beta.134](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.134)
publiée depuis `fc4a0cb68ed2d89163c8e74773976df5bab4f3f1`. Canal packwiz
`35ec0f6e145002f6367944df5b75e2703958eaef` ; téléchargements publics vérifiés par SHA-256.
Deux synchronisations isolées puis deux dans la même instance **Sanctuary Beta**
réussissent. Un seul JAR beta.134 actif ; les **923 fichiers personnels suivis**,
dont sauvegardes et préférences, gardent leurs hashes. Copie préalable dans
`sanctuary-backups/before-beta.134/`, aucun monde personnel ouvert.
Packs normal/Test/template et comparateur natif vérifiés également dans
`sanctuary-beta/build/`. Le checkout original garde ses changements préexistants.
Reçus locaux ignorés : `build/shader134-artifact.json`, `build/shader134-isolated.json`,
`build/shader134-prism.json`, `build/shader134-publication.log`.
+116
View File
@@ -0,0 +1,116 @@
# SHADER-135 — SSGI en contre-plongée et près des surfaces
Demande : supprimer les déchirures horizontales sous une surface, notamment
près de la lave, et atténuer proprement le rebond quand la caméra s'approche
d'une source ou d'un mur. Branche `codex/shader-closeup-beta135`, socle beta.134.
Minecraft 26.3 / Java 25 ; aucune modification de monde ou de gameplay.
## Diagnostic et correction
Le calcul des normales comparait l'aire du pixel en unités du monde à un seuil
fixe. À courte distance ou à haute résolution, cette aire devient naturellement
très petite : le rejet pouvait couper une surface continue. Les deux directions
sont maintenant normalisées avant leur produit vectoriel. Les texels de bord
sont bornés et les voisins sans profondeur ne créent plus de fausse surface.
La composition utilise le centre exact du texel de profondeur. Le rebond
s'atténue entre 0,4 et 0,06 bloc de distance au **plan** receveur, et lorsque la
vue devient presque tangente. Cette distance évite une découpe circulaire sur
un mur plat. L'image native reste nette ; aucune texture du décor n'est floutée.
Les sondes sortant du volume de projection sont rejetées. Leur contribution
s'atténue près du plan de caméra et de la limite d'épaisseur d'un impact, au lieu
d'une contribution pleine jusqu'à cette limite. Trois ressources GLSL modifiées ;
aucune passe, cible GPU, lecture CPU, histoire temporelle ou option ajoutée.
Les 12 rayons × 10 pas et les deux passes de filtrage spatial restent inchangés.
## Réglages initiaux
Demande ajoutée pendant la livraison : **bloom 20 %**, **PBR ON** et **SSGI ON**.
Les intensités PBR 50 et SSGI 35 restent inchangées. Les champs absents du fichier
de préférences prennent ces nouvelles valeurs ; les choix explicites du joueur
restent conservés. Aides françaises et anglaises mises à jour. Vérification
native supplémentaire des préférences vierges, partielles et personnalisées.
## Reproduction native
Deux scènes isolées : stone au-dessus de la lave, puis coin à 0,5 bloc d'une
paroi rouge. Caméra en contre-plongée, sept distances au plafond de 2 à 0,04 bloc,
comparaisons OFF/35/100 avant/après. Dans la première scène, à 0,1 bloc et
intensité 100, le saut maximal de gain rouge moyen entre lignes de pixels neutres
passe de **14,88 à 0,052** niveaux sur 255. Dans le coin, à 0,4 bloc et intensité
35, le rebond auparavant rejeté revient (gain moyen de **6,01** niveaux), puis
s'atténue à l'approche du contact. Ces chiffres concernent ces scènes uniquement.
Les deux essais ciblés corrigés réussissent en 1 min 18 s et 1 min 17 s sur
Apple M1 / OpenGL. Images avant/après conservées dans `build/closeup-*` ;
comparateur autonome `build/Shader-beta.135-comparaison.html`.
## Régression et livraison
Les contrôles natifs de la suite générale réussissent jusqu'au parcours SSGI :
ombres, lune, feuillage, temps réel, CTM, émissifs/bloom, couleurs de rebond,
occlusion, réglages, rechargement et déplacement latéral de 12 blocs à 35/100.
Log : `build/shader135-client-main.log`. La suite n'est pas présentée comme
entièrement réussie : elle s'est arrêtée sur une attente incorrecte du nouveau
test, qui demandait du rebond alors que sa source était sortie du champ.
Le scénario corrigé distingue la haute résolution avec source visible de la
vue très relevée sans source visible. Le bassin possède un fond pour isoler la
lave des essais suivants ; les particules du scénario précédent sont effacées
avant la comparaison PBR statique. Une première tentative avait aussi rencontré
le refus natif de remettre l'horloge à sa valeur actuelle ; la préparation de
l'heure est désormais répétable. Ces changements concernent uniquement les tests.
Le contrôle final ciblé **réussit en 2 min 52 s** : les deux scènes, 35/100,
sept distances, 1280 × 720 avec source visible, source hors champ, mouvement
minuscule, rendu combiné, puis contrôles PBR complets (cache, intensités,
matériaux, soleil, toit, OFF/zéro, redimensionnement et rechargement).
Log : `build/shader135-client-final.log` ; marqueurs `SSGI135_CLOSEUP_PASS` et
`PBR134_PASS`. Les seuils de proximité et PBR sont conservés.
Le contrôle supplémentaire des nouveaux défauts et du PBR **réussit en
1 min 16 s** : préférences vierges, champs absents, valeurs personnelles conservées,
puis rendu PBR et combinaison des effets. Log : `build/shader135-defaults-client.log`.
`./gradlew check build assemblePack assembleTestPack -x :sanctuary:runGameTest`
réussit en **2 min 18 s**, 126 tâches. Le serveur GameTest dédié demeure exclu
conformément au refus EULA antérieur. Les classes des scénarios clients compilent
aussi après leur isolation explicite des effets activés par défaut.
Log : `build/shader135-build.log` ; compilation : `build/shader135-test-compile.log`.
## Archives vérifiées
Comparaison à beta.134 : **6 entrées de production** modifiées, limitées aux
trois ressources SSGI, aux valeurs initiales et aux aides FR/EN. Gameplay et
textures des gemmes inchangés. Packs normal/Test : JAR Sanctuary identique ;
archives de sources et template conformes aux sources. Reçu local ignoré :
`build/shader135-artifact.json`.
## Limites
Le SSGI utilise seulement les surfaces visibles : les sources hors champ restent
absentes et un grand changement de vue peut modifier le rebond. L'atténuation
proche est volontaire ; ce n'est pas une simulation volumétrique. Le brouillard
natif de submersion continue de suspendre le SSGI, comme en beta.134. Aucun
résultat Windows/Vulkan ni mesure de FPS revendiqués.
- `Sanctuary-beta.135.mrpack` : 10422391 octets ; SHA-256 `36ac278f9f5ecd2cefd69da16ea7d153ff44c5fc8e6bf0b3a3d6b71f5d767af6`.
- `Sanctuary-Test-beta.135.mrpack` : 10441314 octets ; SHA-256 `e096374f7ea0856b20392e5d277e94f6c3d411723d7ffc10ceccdd9f0bf5c4d4`.
JAR : `a68ccd04a92a962c46a76b26a80cb44a4faf7a13a562094dde51010ade594c10`.
## Livraison
[Release beta.135](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.135)
publiée depuis `7fada570efb5ed579e6e4d7bc2b87079c7131b31`. Canal packwiz
`5b0df577fcd036937c25be3a1f4580f9db3103a1` ; téléchargements publics vérifiés par SHA-256.
Deux synchronisations isolées puis deux dans la même instance **Sanctuary Beta**
réussissent. Un seul JAR beta.135 actif ; les **923 fichiers personnels suivis**,
dont sauvegardes et préférences, gardent leurs hashes. Copie préalable dans
`sanctuary-backups/before-beta.135/`, aucun monde personnel ouvert.
Packs normal/Test/template et comparateur natif également vérifiés dans
`sanctuary-beta/build/`. Le checkout original garde ses changements préexistants.
Reçus locaux ignorés : `build/shader135-artifact.json`, `build/shader135-isolated.json`,
`build/shader135-prism.json`, `build/shader135-publication.log`.
+85
View File
@@ -0,0 +1,85 @@
# SHADER-136 — Lens flare rectangulaire doux
Demande : ajouter un lens flare avec des rectangles dégradés, exclusivement
pour le soleil et la lune. Socle beta.135, branche `codex/lens-flare-beta136`.
Minecraft 26.3 / Java 25. Aucun changement de gameplay ou de monde.
## Contrat
Effet activé initialement à **30 %**, intensité réglable 0–100, indépendant du halo de bloom.
Rectangles translucides aux bords doux, suivant la position de l'astre à l'écran.
La visibilité provient de l'image émissive céleste native, avant les blocs :
les murs masquent l'effet, les sources de terrain ne le déclenchent pas.
L'astre sortant du champ doit s'atténuer sans sauter sur le bord opposé.
Pas d'historique, ni lecture GPU vers le CPU, ni texture générée sur disque.
## Rendu et budget
Trois rectangles à dégradé doux par astre : un halo horizontal près de sa
position et deux reflets plus petits le long de l'axe passant par le centre de
l'écran. Teinte solaire chaude, lunaire légèrement bleue. Intégration de la
lumière réellement dessinée par le ciel natif ; phases de lune conservées.
Atténuation aux bords de l'écran et à l'horizon, sans projection derrière la caméra.
Le masque céleste est mesuré avant l'ajout des émissifs de terrain. Réduction
vers une texture 2 × 1 RGBA16F (512 échantillons au maximum au total), puis une
passe de composition avec deux lectures de cette texture et des dégradés analytiques.
La cible d'émission plein écran existante est réutilisée. Quand seul le flare
fonctionne, aucun maillage d'émissif de terrain ni tampon de flou n'est construit.
OFF et zéro libèrent la cible du flare ; les autres effets gardent leur autonomie.
## Vérification
Le scénario client natif réussit en **1 min 52 s** sur macOS / Apple M1 / OpenGL.
Soleil OFF/30/80, lune et nouvelle lune, mur occultant, source hors champ,
glowstone exclue, image statique, redimensionnement et rechargement des ressources.
Le flare reste actif sans bloom ; OFF/zéro libèrent ses ressources. Préférences
vierges, partielles, personnalisées, bornage et libellés FR/EN vérifiés.
Les contrôles existants du bloom et des minerais émissifs passent également.
Log : `build/shader136-client-final.log`, marqueurs `EMISSIVE131_PASS` et
`LENS_FLARE136_PASS`. Les captures natives restent dans le dossier de tests ; comparatif autonome
`build/Shader-beta.136-comparaison.html` (soleil OFF/30/80 et lune OFF/30).
Une première tentative s'est arrêtée sur l'attente d'un angle de caméra :
Minecraft normalise 180° en −180°. Le scénario utilise maintenant un angle
canonique ; ce changement concerne uniquement le test.
## Limites
Effet optique en espace écran : il s'atténue quand l'astre quitte le champ et
à l'horizon. Le masque natif du ciel contrôle l'occultation ; la lune reste
volontairement beaucoup plus discrète. Le brouillard de submersion suspend
l'effet comme le bloom existant. Aucun essai Windows/Vulkan ni mesure de FPS
revendiqués.
## Construction et archives
`./gradlew check build assemblePack assembleTestPack -x :sanctuary:runGameTest`
réussit en **2 min 29 s**, 126 tâches. Le serveur GameTest dédié reste exclu
conformément au refus EULA antérieur. Log : `build/shader136-build.log`.
Comparaison à beta.135 : **9 entrées de production** modifiées, uniquement
le rendu, ses préférences et ses libellés. Gameplay et textures des gemmes
strictement conservés. JAR normal/Test identique, sources et template vérifiés.
Reçu local : `build/shader136-artifact.json`.
- `Sanctuary-beta.136.mrpack` : 10424821 octets ; SHA-256 `808006bf2b0cb3fe63d3e16ae3a0003d27ad0b7e793a3a05f25b5d73ae88bc8d`.
- `Sanctuary-Test-beta.136.mrpack` : 10443744 octets ; SHA-256 `70f593ad16d1f4fd4305b276ce2727a63f87bcb84df45e55840c1427a314a1eb`.
JAR : `086afede22903f8988889f6085631451f65d6d55c2e4c81f249d2234698e68e5`.
## Livraison
[Release beta.136](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.136)
publiée depuis `71296a9cfb0f6b1a6e6f0c48145f3cc432c8afd0`. Canal packwiz
`dd5b545a7cfbb957f2c70e9dc17034b44caef98f` ; téléchargements publics vérifiés par SHA-256.
Deux synchronisations isolées puis deux dans la même instance **Sanctuary Beta**
réussissent. Un seul JAR beta.136 actif ; les **923 fichiers personnels suivis**,
dont sauvegardes et préférences, gardent leurs hashes. Copie préalable dans
`sanctuary-backups/before-beta.136/`, aucun monde personnel ouvert.
Packs normal/Test/template et comparatif natif également vérifiés dans
`sanctuary-beta/build/`. Le checkout original garde ses changements préexistants.
Reçus locaux ignorés : `build/shader136-artifact.json`, `build/shader136-isolated.json`,
`build/shader136-prism.json`, `build/shader136-publication.log`.
+96
View File
@@ -0,0 +1,96 @@
# SHADER-137 — Rayons solaires volumétriques
Demande validée : faisceaux légers découpés par les blocs, carte d'ombres partagée,
feuillage perméable, rendu natif préservé, option et intensité indépendantes du bloom.
Socle beta.136, branche `codex/sunshafts-beta137`, Minecraft 26.3 / Java 25.
Aucun changement de monde ni de gameplay.
## Contrat
Soleil uniquement ; activé initialement à 35 %, intensité 0–100.
Intégration de 16 segments par pixel à un quart de chaque dimension.
Reconstruction guidée par la profondeur, sans historique ni bruit animé.
Réutilisation du masque de feuillage déjà stable dans les ombres.
La carte solaire peut fonctionner sans afficher les ombres sur les surfaces.
Portée bornée à 64 blocs et à la moitié de la portée des ombres.
Atténuation aux limites de la carte, à l'horizon et avec la météo.
## Rendu et coût borné
Deux passes : intégration dans une cible RGBA16F de largeur et hauteur divisées
par quatre, puis reconstruction guidée par la profondeur. Seize segments
quadratiques privilégient les premiers mètres ; quatre lectures fixes de la
carte d'ombres par segment adoucissent seulement la lumière dans l'air.
La dernière portion du rayon rétrécit continûment à l'approche d'une surface.
La lumière utilise une teinte solaire chaude mélangée au brouillard natif.
La carte d'ombres et ses maillages sont partagés, y compris les trous de
transmission déterministes des feuilles. Aucune copie d'image plein écran,
aucune duplication du terrain, aucune lecture GPU côté CPU ni accumulation
entre images dans le rendu de production. OFF/zéro libèrent le volume ; la
carte d'ombres reste seulement si les ombres de surface sont activées.
Le soleil sous l'horizon et la submersion suspendent les rayons.
## Vérifications
Scénario natif réussi en **1 min 41 s** sur macOS / Apple M1 / OpenGL : pièce
entièrement fermée, toit ajouré OFF/35/100, feuilles puis ciel ouvert, déplacement
de quatre blocs et retour, caméra proche d'un mur à 0,5 puis 0,125 bloc,
soleil hors champ, nuit, OFF/zéro, carte d'ombres partagée, effet combiné,
redimensionnement à 961 × 541 et rechargement des ressources. Préférences
vierges/partielles/personnelles, bornage et libellés FR/EN également vérifiés.
Log : `build/shader137-client-final.log`, marqueur `SUNSHAFT137_PASS`.
Comparatif autonome : `build/Shader-beta.137-comparaison.html`.
La première tentative s'est arrêtée sur une comparaison de gain brut entre
feuillage et ciel ouvert. La composition dispose de moins de marge sur un mur
déjà clair : le test compare maintenant la contribution normalisée sur les mêmes
surfaces, et les feuilles du scénario sont persistantes. Aucun seuil du rendu
n'a été assoupli pour faire passer ce contrôle.
Les contrôles natifs existants du bloom, des vingt textures de minerais émissifs
et du lens flare réussissent également en **1 min 32 s**, avec leurs seuils
inchangés. Log : `build/shader137-regression.log`, marqueurs `EMISSIVE131_PASS`
et `LENS_FLARE136_PASS`. Les autres effets sont explicitement isolés dans les
scénarios qui nécessitent une image témoin sans rayons.
## Limites
Le volume utilise les formes présentes dans la carte d'ombres existante :
il n'ajoute pas de projection d'ombre des entités ou des nuages. Le feuillage
reste une approximation par transmission déterministe. Portée maximale de
64 blocs, atténuée à son extrémité. Le faible nombre de segments et la résolution
réduite peuvent lisser les ouvertures très fines. Aucun résultat Windows/Vulkan
ni mesure de FPS revendiqués.
## Construction et archives
`./gradlew check build assemblePack assembleTestPack -x :sanctuary:runGameTest`
réussit en **2 min 27 s**, 126 tâches. Le serveur GameTest dédié reste exclu
conformément au refus EULA antérieur. Log : `build/shader137-build.log`.
Comparaison à beta.136 : **11 entrées de production** modifiées, uniquement
le rendu, ses préférences et ses libellés. Gameplay et textures des gemmes
strictement conservés. JAR normal/Test identique, sources et template vérifiés.
Reçu local : `build/shader137-artifact.json`.
- `Sanctuary-beta.137.mrpack` : 10431723 octets ; SHA-256 `6684d1dbe48cd3031b373a03d4380519fdbf5d841e1c7cb5e290dec63b5ba9f6`.
- `Sanctuary-Test-beta.137.mrpack` : 10450650 octets ; SHA-256 `32b0cee60152b6a5c8f31b248391c79a5fdf47b649de74902b066457ad4785ef`.
JAR : `f1fbc106b4b048753f9a669af37e9c12f92168af486d4072f514e86920c81faa`.
## Livraison
[Release beta.137](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.137)
publiée depuis `2d7072b8a8febac53354e9d0063246ac1e843e37`. Canal packwiz
`68cb39a5edbf5d34def02f670e2488ee852605b8` ; téléchargements publics vérifiés par SHA-256.
Deux synchronisations isolées puis deux dans la même instance **Sanctuary Beta**
réussissent. Un seul JAR beta.137 actif ; les **923 fichiers personnels suivis**,
dont sauvegardes et préférences, gardent leurs hashes. Copie préalable dans
`sanctuary-backups/before-beta.137/`, aucun monde personnel ouvert.
Packs normal/Test/template et comparatif natif également vérifiés dans
`sanctuary-beta/build/`. Le checkout original garde ses changements préexistants.
Reçus locaux ignorés : `build/shader137-artifact.json`, `build/shader137-isolated.json`,
`build/shader137-prism.json`, `build/shader137-publication.log`.
+101
View File
@@ -0,0 +1,101 @@
# SHADER-138 — Épaisseur des rayons et météo
Demande : épaissir les sunshafts avec la météo, surtout le brouillard.
Socle beta.137, branche `codex/sunshaft-weather-beta138`, Minecraft 26.3 / Java 25.
Aucune modification des règles météo, des sauvegardes ou du gameplay.
## Contrat
Densité et diffusion liées au brouillard environnemental réellement rendu,
indépendamment de la limite de distance de rendu. Pluie/neige natives :
renforcement plus modéré ; orage : réduction supplémentaire du soleil.
Transitions continues, mêmes deux passes et seize segments. Le réglage
manuel conserve sa valeur, aucune nouvelle option.
## Rendu
Le brouillard utilise `FogData.environmentalEnd`, séparé de la distance de
rendu native. Sa contribution croît en douceur entre 192 et 80 blocs de
visibilité. La densité passe de ×1 à ×3,5 pour le brouillard seul ; la diffusion
angulaire s'élargit et la lumière lointaine s'éteint plus vite. Les coefficients
ne dépendent d'aucun bruit animé ni historique d'image.
Pluie et neige utilisent le niveau natif de précipitation : densité jusqu'à
×1,8, diffusion plus légère. La lumière disponible tombe progressivement à
25 % sous une pluie maximale et à 8,75 % sous un orage maximal. La carte solaire
reste disponible pour le volume même lorsque Minecraft cache entièrement
son disque solaire ; les ombres sur les surfaces conservent leur comportement.
Le brouillard et la météo continuent leurs transitions natives.
Même cible au quart de chaque dimension, deux passes, seize segments et
quatre lectures de la carte par segment. L'empreinte de l'ombre ne change pas :
la météo ne crée pas d'ouverture artificielle à travers un mur.
## Vérification
Scénario client natif réussi en **3 min 29 s**, macOS / Apple M1 / OpenGL.
Les contrôles des volumes beta.137 passent à nouveau : ouvertures, feuilles,
pièce fermée, mouvement/retour, proximité, soleil hors champ, intensité,
OFF/zéro/nuit, redimensionnement, rechargement, préférences et libellés FR/EN.
La suite utilise ensuite les vraies commandes serveur de météo : clair,
`weather fog`, pluie, orage et retour au clair, dans le même décor avec le
curseur maintenu à 35. Transition progressive du brouillard, rechargement
sous brouillard, absence d'éclairage d'une pièce fermée et restitution de
l'image claire initiale vérifiés. La contribution normalisée du volume dans
cette scène passe d'environ 0,0034 au clair à 0,0236 sous brouillard ; ce n'est
pas un multiplicateur universel de luminosité. L'orage reste sous la pluie.
Log : `build/shader138-client-final.log`, marqueurs `SUNSHAFT137_PASS` et
`SUNSHAFT138_WEATHER_PASS`. Comparatif natif autonome :
`build/Shader-beta.138-comparaison.html`.
## Diagnostic de la pluie
La première exécution native a validé le brouillard, puis s'est arrêtée en
attendant des rayons sous une pluie maximale. Minecraft 26.3 fournit alors
`rainBrightness = 1 - rainLevel`, soit zéro : beta.137 ne construisait plus la
carte solaire. Le volume reçoit désormais son atténuation météo propre et
conserve la carte nécessaire, sans réactiver les ombres de surface sous la
pluie maximale. Log de diagnostic : `build/shader138-client-first.log`.
## Limites
Coefficients artistiques bornés, sans simulation physique de gouttes ni de
nuages. Le brouillard environnemental est distinct de la simple distance
d'affichage ; la pluie et la neige partagent le niveau natif de précipitation.
Les limites des volumes beta.137 restent applicables : 64 blocs maximum,
formes de la carte d'ombres et feuillage approximé. Pas de mesure de FPS ni
de validation Windows/Vulkan revendiquée.
## Construction et archives
`./gradlew check build assemblePack assembleTestPack -x :sanctuary:runGameTest`
réussit en **2 min 20 s**, 126 tâches. Le serveur GameTest dédié reste exclu
conformément au refus EULA antérieur. Log : `build/shader138-build.log`.
Comparaison à beta.137 : **6 entrées de production** modifiées, uniquement
le rendu et les aides FR/EN. Gameplay et textures des gemmes strictement
conservés. JAR normal/Test identique, sources et template vérifiés.
Reçu local : `build/shader138-artifact.json`.
- `Sanctuary-beta.138.mrpack` : 10432860 octets ; SHA-256 `67093d3a53e3d90c31e8fb940d709ccf41ad730595e358346fa27ad744b44460`.
- `Sanctuary-Test-beta.138.mrpack` : 10451787 octets ; SHA-256 `1ff39158b47f44a831b6506c8c2636441b2bff02644d20b1698330097e5ff059`.
JAR : `44b6dd14f0e962fcb6f1efd61e0e7bb2c8dbb033cef81598582ec16c46a46518`.
## Livraison
[Release beta.138](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.138)
publiée depuis `daf36fdeacca707930092363589c89984af78f3b`. Canal packwiz
`c049423de1971079b9d61874d83adeb6f1ad49f7` ; téléchargements publics vérifiés par SHA-256.
Deux synchronisations isolées puis deux dans la même instance **Sanctuary Beta**
réussissent. Un seul JAR beta.138 actif ; les **923 fichiers personnels suivis**,
dont sauvegardes et préférences, gardent leurs hashes. Copie préalable dans
`sanctuary-backups/before-beta.138/`, aucun monde personnel ouvert.
Packs normal/Test/template et comparatif natif également vérifiés dans
`sanctuary-beta/build/`. Le checkout original garde ses changements préexistants.
Reçus locaux ignorés : `build/shader138-artifact.json`, `build/shader138-isolated.json`,
`build/shader138-prism.json`, `build/shader138-publication.log`.
+99
View File
@@ -0,0 +1,99 @@
# SHADER-139 — Compatibilité Vulkan du shader beta.138
Socle **beta.138**, Minecraft **26.3**, Java **25**, branche
`codex/vulkan-shaders-beta139`.
Le signalement initial concernait un bloom invisible même à 100 %. Le joueur
indique ensuite que le rendu fonctionne de nouveau sur Windows et demande
la compatibilité Vulkan. Les changements expérimentaux d'intensité ont été
retirés : les shaders et les valeurs par défaut restent ceux de beta.138.
## Contrat
Utiliser les mêmes pipelines RenderPearl sur OpenGL et Vulkan. Vérifier le
backend réellement utilisé, car Minecraft peut retomber sur OpenGL lorsque
Vulkan ne démarre pas. Tester les effets dans de nouveaux mondes ignorés,
sans ouvrir de sauvegarde personnelle ni modifier le backend de l'instance Prism.
Le lancement des tests accepte désormais `-PsanctuaryClientGraphicsBackend=vulkan`
ou `opengl`. Les tests de shaders refusent tout repli silencieux et consignent
le nom du backend, du GPU et du pilote. Cette option concerne uniquement
le client de développement.
## Reproduction
```sh
JAVA_HOME=/chemin/vers/jdk-25 ./gradlew :sanctuary:runClientGameTest \
-PsanctuaryClientTests=true -PsanctuaryPixelShadows122ClientTests=true \
-PsanctuaryClientGraphicsBackend=vulkan \
-PsanctuaryClientNoVsync=true -PsanctuaryQuickTests=true
```
Sur Windows, utiliser `gradlew.bat` avec les mêmes propriétés et Java 25.
Ajouter `-PsanctuaryClientImprovedTransparency=true` pour tester la transparence
améliorée ; le choix initial est restauré après le test.
Pour les rayons et la météo, remplacer `sanctuaryPixelShadows122ClientTests`
par `sanctuarySunshaft137ClientTests`. Pour isoler le bloom et le lens flare,
utiliser `sanctuaryBloom130ClientTests`.
## Diagnostic initial
Le client Vulkan démarre ici sur **Apple M1 / MoltenVK 1.4.2**, API 1.2.350.
Le test du bloom réussit en **2 min 19 s** : les masques des vingt minerais,
les contrôles OFF/zéro, intensité/diffusion, l'occlusion, le rechargement,
les torches, glowstone, lampes, lave, soleil et le lens flare sont vérifiés.
Les mesures de référence du bloom sont identiques à celles d'OpenGL :
14649 pixels de minerai, gain du halo faible 218131, fort 1970502,
gain solaire 7708. Log : `build/shader139-vulkan-first.log`.
Cela confirme cette combinaison précise ; ce n'est pas une validation des
pilotes NVIDIA/AMD/Intel sous Windows. En cas de retour du problème, le
`logs/latest.log` de la session Windows concernée est nécessaire pour relever
le backend effectif, le GPU, le pilote et les éventuelles erreurs de pipeline.
## Suite Vulkan
`sanctuaryPixelShadows122ClientTests` réussit en **8 min 50 s**. Les contrôles
couvrent ombres diurnes/nocturnes, feuillage, stabilité en temps réel,
highlights CTM, bloom et lens flare, SSGI en déplacement et près des surfaces,
PBR directionnel, rechargement, redimensionnement et interfaces FR/EN.
Log : `build/shader139-vulkan-suite2.log`.
Le bloom et le lens flare réussissent aussi avec
`sanctuaryClientImprovedTransparency=true`, en **1 min 45 s**. Le backend
est bien Vulkan, sa profondeur est en `[0, 1]` et l'option de transparence
est active. Les mesures du bloom restent identiques.
Log : `build/shader139-vulkan-transparency.log`.
La suite météo/rayons beta.138 n'a pas été rejouée sous Vulkan dans ce ticket ;
les résultats ci-dessus portent sur les effets explicitement listés.
Le joueur confirme que le retour à OpenGL rétablit l'effet sur Windows.
Ce défaut particulier reste à reproduire à partir d'un journal récent.
Un premier lancement utilisait un nom de propriété incomplet et a exécuté
la suite générale, échouant dans Blocodex avant les tests de shaders.
Ce lancement n'est pas compté comme une validation Vulkan des shaders.
La commande corrigée utilise `sanctuaryPixelShadows122ClientTests`.
## Construction
`./gradlew check build -x :sanctuary:runGameTest` réussit ; le serveur
GameTest dédié reste exclu conformément au refus EULA antérieur.
Log : `build/shader139-check-build.log`. Le JAR reconstruit est **strictement
identique, octet pour octet**, au JAR Sanctuary de la release beta.138.
La distribution ne change pas et ne nécessite donc pas de nouvel assemblage.
## Distribution
Ce ticket ajoute des options de lancement de tests et leur documentation.
Les shaders de production et les réglages de beta.138 ne sont pas changés.
Aucune nouvelle livraison binaire, aucun tag beta.139 ni mise à jour de
l'instance personnelle n'est effectué pour une vérification seule.
## Référence du moteur
Minecraft 26.3 compile les shaders OpenGL et Vulkan via ShaderC et expose
`RENDERPEARL_DEPTH_IS_ZERO_TO_ONE` pour la convention de profondeur.
[Notes officielles Minecraft 26.3](https://feedback.minecraft.net/hc/en-us/articles/48913133328013-Minecraft-Java-Edition-26-3).
Les effets Sanctuary transmettent déjà la convention du périphérique aux
reconstructions de position ; aucun remplacement spéculatif n'est appliqué.
+6
View File
@@ -107,3 +107,9 @@ 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.
+8
View File
@@ -1,5 +1,13 @@
# Vérifier Sanctuary
## Cible graphique — décision du 18 septembre 2026
Les nouvelles validations graphiques Sanctuary utilisent **Vulkan uniquement**.
OpenGL est abandonné comme cible de validation à la demande du créateur.
Les anciens résultats OpenGL ci-dessous sont historiques et ne constituent
pas un engagement de compatibilité. Ne plus programmer de suite OpenGL.
Utiliser Java 25 et les dépendances épinglées dans `gradle.properties`.
```sh
+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.130
pack_version=beta.130
mod_version=beta.154
pack_version=beta.154
resource_pack_version=beta.121
maven_group=fr.koka.sanctuary
jei_version=30.32.0-sanctuary.3
+56
View File
@@ -25,6 +25,24 @@ if (clientRenderTests) {
java.srcDir('src/clientGametest/java')
}
tasks.named('runClientGameTest') {
def graphicsBackend = providers.gradleProperty('sanctuaryClientGraphicsBackend')
if (graphicsBackend.isPresent()) {
args('--graphicsBackend', graphicsBackend.get())
systemProperty('sanctuary.test.graphicsBackend', graphicsBackend.get())
}
def improvedTransparency = providers.gradleProperty('sanctuaryClientImprovedTransparency')
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'))
systemProperty('sanctuary.test.colored140', providers.gradleProperty('sanctuaryColored140ClientTests').getOrElse('false'))
systemProperty('sanctuary.test.noVsync', providers.gradleProperty('sanctuaryClientNoVsync').getOrElse('false'))
systemProperty('sanctuary.test.quick', providers.gradleProperty('sanctuaryQuickTests').getOrElse('false'))
systemProperty('sanctuary.test.introVisualOnly', providers.gradleProperty('sanctuaryIntroVisualOnly').getOrElse('false'))
@@ -93,6 +111,23 @@ tasks.named('runGameTest') {
}
tasks.named('processGametestResources') {
def colored139Client = providers.gradleProperty('sanctuaryColored139ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('sanctuaryColored139ClientTests', colored139Client)
if (colored139Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.shader.ColoredLights139ClientChecks') }
def pbr134Client = providers.gradleProperty('sanctuaryPbr134ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('sanctuaryPbr134ClientTests', pbr134Client)
if (pbr134Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.shader.Pbr134ClientChecks') }
def ssgi132Client = providers.gradleProperty('sanctuarySsgi132ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('sanctuarySsgi132ClientTests', ssgi132Client)
if (ssgi132Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.shader.Ssgi132ClientChecks') }
def closeup135Client = providers.gradleProperty('sanctuarySsgiCloseup135ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('sanctuarySsgiCloseup135ClientTests', closeup135Client)
if (closeup135Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.shader.SsgiCloseup135ClientChecks') }
def sunshaft137Client = providers.gradleProperty('sanctuarySunshaft137ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('sanctuarySunshaft137ClientTests', sunshaft137Client)
if (sunshaft137Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.shader.Sunshafts137ClientChecks') }
def bloom130Client = providers.gradleProperty('sanctuaryBloom130ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('sanctuaryBloom130ClientTests', bloom130Client)
if (bloom130Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.shader.Bloom130ClientChecks') }
@@ -223,6 +258,9 @@ tasks.named('processGametestResources') {
def multiblocks084Client = providers.gradleProperty('sanctuaryMultiblocks084ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('sanctuaryMultiblocks084ClientTests', multiblocks084Client)
if (multiblocks084Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.Multiblocks084ClientChecks') }
def community154Client = providers.gradleProperty('sanctuaryCommunity154ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('sanctuaryCommunity154ClientTests', community154Client)
if (community154Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.Community154ClientChecks') }
def menus083Client = providers.gradleProperty('sanctuaryMenus083ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('sanctuaryMenus083ClientTests', menus083Client)
if (menus083Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.Menus083ClientChecks') }
@@ -452,6 +490,8 @@ tasks.named('processGametestResources') {
}
dependencies {
implementation 'org.mariadb.jdbc:mariadb-java-client:3.5.10'
include('org.mariadb.jdbc:mariadb-java-client:3.5.10')
implementation project(':jei')
include project(':jei')
implementation project(':demeure')
@@ -1176,3 +1216,19 @@ tasks.register('seasonal105Smoke', JavaExec) {
mainClass = 'fr.koka.sanctuary.weather.Seasonal105Smoke'
}
tasks.named('check') { dependsOn('seasonal105Smoke') }
tasks.register('community154Smoke', JavaExec) {
group = 'verification'
dependsOn('testClasses')
classpath = sourceSets.test.runtimeClasspath
mainClass = 'fr.koka.sanctuary.community.CommunitySmoke'
}
tasks.named('check') { dependsOn('community154Smoke') }
tasks.register('community154Interop', JavaExec) {
group = 'verification'
dependsOn('testClasses')
classpath = sourceSets.test.runtimeClasspath
mainClass = 'fr.koka.sanctuary.community.CommunityInterop'
args(providers.gradleProperty('communityInteropPhase').getOrElse('seed'))
}
@@ -0,0 +1,91 @@
package fr.koka.sanctuary.client;
import com.mojang.blaze3d.platform.InputConstants;
import java.util.*;
import fr.koka.sanctuary.gametest.Starter039TestSupport;
import java.util.concurrent.atomic.AtomicBoolean;
import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents;
import net.fabricmc.fabric.api.client.gametest.v1.FabricClientGameTest;
import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext;
import net.minecraft.client.gui.components.*;
import net.minecraft.client.gui.components.events.*;
import net.minecraft.client.gui.screens.*;
import net.minecraft.client.gui.screens.worldselection.WorldCreationUiState;
import net.minecraft.client.input.KeyEvent;
import net.minecraft.network.chat.contents.TranslatableContents;
/** Real screens and server packets on an isolated flat development world. */
public final class Community154ClientChecks implements FabricClientGameTest {
public void runTest(ClientGameTestContext c) {
boolean outage=Boolean.parseBoolean(System.getenv("SANCTUARY_COMMUNITY_CLIENT_OUTAGE"));
boolean database=Boolean.parseBoolean(System.getenv("SANCTUARY_COMMUNITY_CLIENT_DATABASE"));
if(database) try {
var path=net.fabricmc.loader.api.FabricLoader.getInstance().getConfigDir().resolve("sanctuary-community.json");
var config=new fr.koka.sanctuary.community.CommunityConfig("database",UUID.randomUUID().toString(),System.getenv("SANCTUARY_COMMUNITY_TEST_JDBC"),"root","SANCTUARY_COMMUNITY_DB_PASSWORD");
java.nio.file.Files.createDirectories(path.getParent());
java.nio.file.Files.writeString(path,new com.google.gson.Gson().toJson(config));
} catch(Exception error) { throw new AssertionError(error); }
var intro = new AtomicBoolean(true);
c.runOnClient(m -> { m.options.renderDistance().set(5); m.options.simulationDistance().set(5); m.options.pauseOnLostFocus=false; IntroClient.setQuickTestIntro(true); });
ClientTickEvents.END_CLIENT_TICK.register(m -> { if (intro.get() && m.gui.screen() instanceof HelloWorldScreen screen) {
Starter039TestSupport.selectFirst(screen);
screen.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b -> b.getClass().getSimpleName().equals("ColorButton")).findFirst().orElseThrow().onPress(enter());
press(screen,"sanctuary.progression.enter"); intro.set(false);
}});
try (var world = c.worldBuilder().adjustSettings(s -> { s.setName("Community154 "+UUID.randomUUID()); s.setSeed("154"); s.setAllowCommands(false); s.setGameMode(WorldCreationUiState.SelectedGameMode.SURVIVAL); s.setWorldType(s.getNormalPresetList().stream().filter(e -> e.preset()!=null && e.preset().getRegisteredName().equals("sanctuary_test:flat")).findFirst().orElseThrow()); }).create()) {
c.waitFor(m -> ProgressionClient.active() && m.player!=null && m.gui.screen()==null && !IntroSpawnFlash.active(),1200);
world.getServer().runOnServer(s -> s.getPlayerList().getPlayers().getFirst().getAbilities().invulnerable=true);
if(outage) {
c.runOnClient(m -> m.gui.setScreen(new PauseScreen(true)));
c.waitFor(m -> !CommunityClient.pending() && CommunityClient.overviewError.equals("unavailable"),300);
c.takeScreenshot("community154-database-unavailable");
c.runOnClient(m -> { CommunityClient.open(m.gui.screen(),"article","",true); edit(m.gui.screen()).setValue("Brouillon panne"); body(m.gui.screen()).setValue("Texte conservé"); press(m.gui.screen(),"sanctuary.community.publish"); });
c.waitFor(m -> !CommunityClient.pending() && has(m.gui.screen(),"sanctuary.community.unavailable"),300);
c.runOnClient(m -> { check(body(m.gui.screen()).getValue().equals("Texte conservé"),"Outage draft retained"); check(CommunityClient.overview.isEmpty(),"No false cached acknowledgement"); });
world.getServer().runOnServer(s -> check(!java.nio.file.Files.exists(s.getWorldPath(net.minecraft.world.level.storage.LevelResource.ROOT).resolve("data/sanctuary-community.json")),"No file fallback"));
System.out.println("COMMUNITY154_OUTAGE_PASS: visible error, draft preserved, no file fallback");
c.runOnClient(m -> m.gui.setScreen(null));
return;
}
for (String locale : List.of("fr_fr","en_us")) for (int scale : List.of(2,3)) {
c.runOnClient(m -> { m.getWindow().setWindowed(1280,800); m.options.guiScale().set(scale); m.resizeGui(); m.getLanguageManager().setSelected(locale); m.getLanguageManager().onResourceManagerReload(m.getResourceManager()); m.gui.setScreen(new PauseScreen(true)); });
c.waitFor(m -> !CommunityClient.pending() && !CommunityClient.overview.isEmpty(),300);
c.runOnClient(m -> { check(CommunityClient.overview.get("mode").getAsString().equals(database?"database":"file"),"Expected storage adapter"); press(m.gui.screen(),"sanctuary.community.article"); });
c.waitFor(m -> !CommunityClient.pending() && has(m.gui.screen(),"sanctuary.community.empty"),300);
c.takeScreenshot("community154-list-"+locale+"-"+scale);
c.runOnClient(m -> m.gui.setScreen(new PauseScreen(true))); c.waitTicks(5);
c.takeScreenshot("community154-pause-"+locale+"-"+scale);
}
c.runOnClient(m -> { m.options.guiScale().set(2); m.resizeGui(); m.getLanguageManager().setSelected("fr_fr"); m.getLanguageManager().onResourceManagerReload(m.getResourceManager()); CommunityClient.open(new PauseScreen(true),"notice","",true); });
c.waitFor(m -> !CommunityClient.pending(),250);
c.runOnClient(m -> { edit(m.gui.screen()).setValue("La passerelle 🌳"); body(m.gui.screen()).setValue("Du bois et des bras.\nQui vient construire ?"); press(m.gui.screen(),"sanctuary.community.publish"); });
c.waitFor(m -> !CommunityClient.pending() && has(m.gui.screen(),"sanctuary.community.reply"),300);
c.takeScreenshot("community154-published");
c.waitTicks(25);
c.runOnClient(m -> { body(m.gui.screen()).setValue("Je viens ce soir !"); press(m.gui.screen(),"sanctuary.community.reply"); });
c.waitFor(m -> !CommunityClient.pending() && text(m.gui.screen(),"Je viens ce soir !"),300);
c.takeScreenshot("community154-reply");
c.runOnClient(m -> check(widgets(m.gui.screen()).filter(PlayerFaceWidget.class::isInstance).count()==2,"Both conversation participants have faces"));
c.waitTicks(25);
c.runOnClient(m -> { press(m.gui.screen(),"sanctuary.community.actions"); press(m.gui.screen(),"sanctuary.community.close"); });
c.waitFor(m -> !CommunityClient.pending() && has(m.gui.screen(),"sanctuary.community.reopen"),300);
c.runOnClient(m -> { check(!has(m.gui.screen(),"sanctuary.community.reply"),"Closed announcement hides reply action"); CommunityClient.open(new PauseScreen(true),"article","",true); edit(m.gui.screen()).setValue("Brouillon"); body(m.gui.screen()).setValue("§Texte refusé"); press(m.gui.screen(),"sanctuary.community.publish"); });
c.waitFor(m -> !CommunityClient.pending() && (has(m.gui.screen(),"sanctuary.community.invalid") || has(m.gui.screen(),"sanctuary.community.busy")),300);
c.runOnClient(m -> { check(body(m.gui.screen()).getValue().equals("§Texte refusé"),"Refused draft retained"); m.gui.screen().onClose(); CommunityClient.open(m.gui.screen(),"article","",true); check(edit(m.gui.screen()).getValue().equals("Brouillon"),"Draft survives closing screen"); m.gui.screen().onClose(); });
c.runOnClient(m -> { CommunityClient.open(m.gui.screen(),"article","",false); }); c.waitTicks(4);
c.runOnClient(m -> m.gui.screen().onClose()); c.waitTicks(30);
c.runOnClient(m -> check(m.gui.screen() instanceof PauseScreen,"Late response does not reopen screen"));
System.out.println("COMMUNITY154_CLIENT_MODE="+(database?"database":"file"));
System.out.println("COMMUNITY154_CLIENT_PASS: pause FR/EN GUI 2/3, publication, reply, close, refused draft, late response");
c.runOnClient(m -> m.gui.setScreen(null));
} finally { intro.set(false); }
}
static java.util.stream.Stream<GuiEventListener> widgets(GuiEventListener e) { return java.util.stream.Stream.concat(java.util.stream.Stream.of(e), e instanceof ContainerEventHandler h ? h.children().stream().flatMap(Community154ClientChecks::widgets) : java.util.stream.Stream.empty()); }
static boolean has(Screen s,String key) { return s!=null && widgets(s).filter(AbstractWidget.class::isInstance).map(AbstractWidget.class::cast).anyMatch(w -> w.getMessage().getContents() instanceof TranslatableContents t && t.getKey().equals(key)); }
static boolean text(Screen s,String value) { return widgets(s).filter(AbstractWidget.class::isInstance).map(AbstractWidget.class::cast).anyMatch(w -> w.getMessage().getString().equals(value)); }
static void press(Screen s,String key) { widgets(s).filter(Button.class::isInstance).map(Button.class::cast).filter(b -> b.getMessage().getContents() instanceof TranslatableContents t && t.getKey().equals(key)).findFirst().orElseThrow(() -> new AssertionError("Missing "+key)).onPress(enter()); }
static EditBox edit(Screen s) { return widgets(s).filter(EditBox.class::isInstance).map(EditBox.class::cast).findFirst().orElseThrow(); }
static MultiLineEditBox body(Screen s) { return widgets(s).filter(MultiLineEditBox.class::isInstance).map(MultiLineEditBox.class::cast).findFirst().orElseThrow(); }
static KeyEvent enter() { return new KeyEvent(InputConstants.KEY_RETURN,0,0); }
static void check(boolean ok,String message) { if (!ok) throw new AssertionError(message); }
}
@@ -12,6 +12,7 @@ import java.time.Instant;
import java.time.ZoneOffset;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.BooleanSupplier;
import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents;
import net.fabricmc.fabric.api.client.gametest.v1.FabricClientGameTest;
import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext;
@@ -50,24 +51,29 @@ public final class Melt109ClientChecks implements FabricClientGameTest {
for(int x=-15;x<=15;x++)for(int z=-15;z<=15;z++)l.setBlockAndUpdate(new BlockPos(x,79,z),Blocks.STONE.defaultBlockState());
clock(l,"2026-07-10T12:00:00Z",SeasonalWeather.Season.SUMMER);
check(l.getGameRules().get(SeasonalSnowMelt.ENABLED),"Melt enabled by default");
check(SeasonalSnowMelt.REALTIME_SCALE==72,"Real-time snow melt scale is one real day per 72 native days");
check(SeasonalSnowMelt.chance(SeasonalWeather.Season.SUMMER)==72
&&SeasonalSnowMelt.chance(SeasonalWeather.Season.SPRING)==144
&&SeasonalSnowMelt.chance(SeasonalWeather.Season.AUTUMN)==288
&&SeasonalSnowMelt.chance(SeasonalWeather.Season.WINTER)==0,"Seasonal melt chances include the 72x real-time scale");
deposit(l,AT,16);check(l.getBlockState(AT.above()).is(Blocks.SNOW_BLOCK),"Default two-block accumulation retained");
clear(l);step(l,AT,1);
check(layers(l,AT.above())==7&&layers(l,AT)==8,"Summer melts only one top layer");
step(l,AT,7);check(l.getBlockState(AT.above()).isAir()&&layers(l,AT)==8,"Top block melts completely before the lower block");
step(l,AT,8);check(l.getBlockState(AT).isAir()&&!tracked(l,AT),"Natural column vanishes without water; receipt removed");
clear(l);
until(l,AT,5000,()->layers(l,AT.above())==7&&layers(l,AT)==8,"Summer melts only one top layer");
until(l,AT,20000,()->l.getBlockState(AT.above()).isAir()&&layers(l,AT)==8,"Top block melts completely before the lower block");
until(l,AT,20000,()->l.getBlockState(AT).isAir()&&!tracked(l,AT),"Natural column vanishes without water; receipt removed");
var built=new BlockPos(7,80,4);l.setBlockAndUpdate(built,Blocks.SNOW_BLOCK.defaultBlockState());step(l,built,64);check(layers(l,built)==8,"Untracked snow block preserved");
var old=new BlockPos(8,80,4);snow(l,old,5);step(l,old,64);check(layers(l,old)==5,"Untracked old layers preserved");
var base=new BlockPos(9,80,4);snow(l,base,3);deposit(l,base,5);clear(l);step(l,base,16);check(layers(l,base)==3&&!tracked(l,base),"Weather above manual layers melts only back to its base");
var atop=new BlockPos(9,80,6);l.setBlockAndUpdate(atop,Blocks.SNOW_BLOCK.defaultBlockState());deposit(l,atop,8);clear(l);step(l,atop,16);check(layers(l,atop)==8&&l.getBlockState(atop.above()).isAir(),"Weather on a snow construction melts; construction stays");
var base=new BlockPos(9,80,4);snow(l,base,3);deposit(l,base,5);clear(l);until(l,base,20000,()->layers(l,base)==3&&!tracked(l,base),"Weather above manual layers melts only back to its base");
var atop=new BlockPos(9,80,6);l.setBlockAndUpdate(atop,Blocks.SNOW_BLOCK.defaultBlockState());deposit(l,atop,8);clear(l);until(l,atop,20000,()->layers(l,atop)==8&&l.getBlockState(atop.above()).isAir(),"Weather on a snow construction melts; construction stays");
var replaced=new BlockPos(10,80,4);deposit(l,replaced,8);l.setBlockAndUpdate(replaced,Blocks.AIR.defaultBlockState());l.setBlockAndUpdate(replaced,Blocks.SNOW_BLOCK.defaultBlockState());clear(l);step(l,replaced,32);check(layers(l,replaced)==8&&!tracked(l,replaced),"Break and replacement cannot inherit melt permission");
var thickened=new BlockPos(10,80,6);deposit(l,thickened,2);snow(l,thickened,6);clear(l);step(l,thickened,32);check(layers(l,thickened)==6&&!tracked(l,thickened),"Manual layer edit relinquishes natural provenance");
var capped=new BlockPos(12,80,6);deposit(l,capped,8);snow(l,capped.above(),1);clear(l);step(l,capped,32);check(layers(l,capped)==8&&layers(l,capped.above())==1,"Manual thin snow cap protects its natural support");
var roof=new BlockPos(11,80,4);deposit(l,roof,8);l.setBlockAndUpdate(roof.above(3),Blocks.STONE.defaultBlockState());clear(l);step(l,roof,32);check(layers(l,roof)==8,"Covered snow remains untouched");l.setBlockAndUpdate(roof.above(3),Blocks.AIR.defaultBlockState());step(l,roof,1);check(layers(l,roof)==7,"Uncovered natural snow resumes melt");
var roof=new BlockPos(11,80,4);deposit(l,roof,8);l.setBlockAndUpdate(roof.above(3),Blocks.STONE.defaultBlockState());clear(l);step(l,roof,32);check(layers(l,roof)==8,"Covered snow remains untouched");l.setBlockAndUpdate(roof.above(3),Blocks.AIR.defaultBlockState());until(l,roof,10000,()->layers(l,roof)==7,"Uncovered natural snow resumes melt");
command(l,"gamerule sanctuary:seasonal_snow_melt false");step(l,roof,32);check(layers(l,roof)==7,"Operator disables melting");command(l,"gamerule sanctuary:seasonal_snow_melt true");
clock(l,"2026-01-10T12:00:00Z",SeasonalWeather.Season.WINTER);clear(l);step(l,roof,64);check(layers(l,roof)==7,"Winter never melts even with clear weather");
clock(l,"2026-04-10T12:00:00Z",SeasonalWeather.Season.SPRING);clear(l);step(l,roof,64);check(l.getBlockState(roof).isAir(),"Spring progressively melts");
deposit(l,roof,8);clock(l,"2026-10-10T12:00:00Z",SeasonalWeather.Season.AUTUMN);clear(l);step(l,roof,128);check(l.getBlockState(roof).isAir(),"Autumn progressively melts");
clock(l,"2026-04-10T12:00:00Z",SeasonalWeather.Season.SPRING);clear(l);until(l,roof,30000,()->l.getBlockState(roof).isAir(),"Spring progressively melts");
deposit(l,roof,8);clock(l,"2026-10-10T12:00:00Z",SeasonalWeather.Season.AUTUMN);clear(l);until(l,roof,50000,()->l.getBlockState(roof).isAir(),"Autumn progressively melts");
deposit(l,roof,8);clock(l,"2026-07-10T12:00:00Z",SeasonalWeather.Season.SUMMER);
command(l,"sanctuary weather snow");l.getWeatherData().setRaining(false);l.setRainLevel(0);step(l,roof,64);check(layers(l,roof)==8,"Forced summer snowfall blocks melting");
command(l,"sanctuary weather snow_showers");l.getWeatherData().setRaining(false);l.setRainLevel(0);step(l,roof,64);check(layers(l,roof)==8,"Pauses in snow showers also block melting");
@@ -84,7 +90,7 @@ public final class Melt109ClientChecks implements FabricClientGameTest {
var l=server.overworld();clock(l,"2026-07-10T12:00:00Z",SeasonalWeather.Season.SUMMER);clear(l);
check(!l.getGameRules().get(SeasonalSnowMelt.ENABLED),"Melt rule persists across restart");
check(layers(l,PERSIST)==8&&layers(l,PERSIST.above())==8&&tracked(l,PERSIST)&&tracked(l,PERSIST.above()),"Chunk attachment survives native save/reload");
command(l,"gamerule sanctuary:seasonal_snow_melt true");step(l,PERSIST,16);
command(l,"gamerule sanctuary:seasonal_snow_melt true");until(l,PERSIST,30000,()->l.getBlockState(PERSIST).isAir()&&l.getBlockState(PERSIST.above()).isAir()&&!tracked(l,PERSIST), "Loaded natural snow melts and clears provenance");
check(l.getBlockState(PERSIST).isAir()&&l.getBlockState(PERSIST.above()).isAir()&&!tracked(l,PERSIST),"Loaded natural snow melts and clears provenance: base="+l.getBlockState(PERSIST)+", top="+l.getBlockState(PERSIST.above())+", sky="+l.canSeeSky(PERSIST.above(2))+", active="+WeatherService.active(l)+", biome="+l.getBiome(PERSIST)+", season="+WeatherService.season(server));
check(layers(l,new BlockPos(7,80,4))==8&&layers(l,new BlockPos(9,80,4))==3,"Built and preexisting snow preserved across restart");
});
@@ -92,13 +98,15 @@ public final class Melt109ClientChecks implements FabricClientGameTest {
System.out.println("MELT109_NATIVE_PASS: accumulation regression, top-down melt, seasons, profiles, cold biomes, constructions, edit invalidation, gamerule, native chunk save/reload");
}finally{intro.set(false);}
}
private static void deposit(ServerLevel l,BlockPos pos,int count){l.getGameRules().set(GameRules.MAX_SNOW_ACCUMULATION_HEIGHT,1,l.getServer());command(l,"sanctuary weather snow");l.getWeatherData().setRaining(true);l.setRainLevel(1);step(l,pos,count);l.getGameRules().set(GameRules.MAX_SNOW_ACCUMULATION_HEIGHT,0,l.getServer());}
private static void deposit(ServerLevel l,BlockPos pos,int count){l.getGameRules().set(GameRules.MAX_SNOW_ACCUMULATION_HEIGHT,1,l.getServer());command(l,"sanctuary weather snow");l.getWeatherData().setRaining(true);l.setRainLevel(1);int target=depth(l,pos)+count;until(l,pos,Math.max(1000,count*200),()->depth(l,pos)>=target,"Weather snow reaches depth "+target+" at "+pos);l.getGameRules().set(GameRules.MAX_SNOW_ACCUMULATION_HEIGHT,0,l.getServer());}
private static void clear(ServerLevel l){command(l,"sanctuary weather clear");l.getWeatherData().setRaining(false);l.setRainLevel(0);}
private static void clock(ServerLevel l,String time,SeasonalWeather.Season expected){try{var field=RealtimeService.Session.class.getDeclaredField("clock");field.setAccessible(true);field.set(RealtimeService.session(l.getServer()),Clock.fixed(Instant.parse(time),ZoneOffset.UTC));check(WeatherService.season(l.getServer())==expected,"Test calendar: "+expected);}catch(ReflectiveOperationException e){throw new AssertionError(e);}}
private static boolean tracked(ServerLevel l,BlockPos p){var m=l.getChunkAt(p).getAttached(SeasonalSnowMelt.NATURAL);return m!=null&&m.containsKey(Long.toString(p.asLong()));}
private static void snow(ServerLevel l,BlockPos p,int layers){l.setBlockAndUpdate(p,Blocks.SNOW.defaultBlockState().setValue(SnowLayerBlock.LAYERS,layers));}
private static int layers(ServerLevel l,BlockPos p){return SeasonalSnowMelt.layers(l.getBlockState(p));}
private static int depth(ServerLevel l,BlockPos p){int total=0;for(int i=0;i<32;i++){int layers=layers(l,p.above(i));if(layers==0)break;total+=layers;}return total;}
private static void step(ServerLevel l,BlockPos p,int n){for(int i=0;i<n;i++)l.tickPrecipitation(p);}
private static void until(ServerLevel l,BlockPos p,int max,BooleanSupplier condition,String message){for(int i=0;i<max&&!condition.getAsBoolean();i++)l.tickPrecipitation(p);check(condition.getAsBoolean(),message);}
private static void command(ServerLevel l,String s){l.getServer().getCommands().performPrefixedCommand(l.getServer().createCommandSourceStack(),s);}
private static KeyEvent enter(){return new KeyEvent(InputConstants.KEY_RETURN,0,0);}
private static void check(boolean ok,String message){if(!ok)throw new AssertionError(message);}
@@ -6,6 +6,7 @@ import fr.koka.sanctuary.progression.SanctuaryGameplay;
import fr.koka.sanctuary.weather.SnowAccumulation;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.BooleanSupplier;
import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents;
import net.fabricmc.fabric.api.client.gametest.v1.FabricClientGameTest;
import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext;
@@ -45,42 +46,45 @@ public final class Snow108ClientChecks implements FabricClientGameTest {
if(enabled) {
command(l,"sanctuary weather snow");l.setRainLevel(1);
check(l.getGameRules().get(SnowAccumulation.MAX_HEIGHT)==2,"Default two blocks");
for(int i=1;i<=7;i++){l.tickPrecipitation(AT);check(layers(l,AT)==i,"One layer per precipitation event: "+i);}
check(SnowAccumulation.REALTIME_SCALE==12,"Snow accumulation uses the 12x real-time arrival scale");
for(int i=1;i<=7;i++){int expected=i;until(l,AT,1000,()->layers(l,AT)==expected,"Layer "+i+" arrives through the native sampler");}
var pig=EntityTypes.PIG.create(l,EntitySpawnReason.COMMAND);pig.setNoAi(true);pig.setNoGravity(true);pig.setPos(4.5,80.75,4.5);l.addFreshEntity(pig);
l.tickPrecipitation(AT);check(l.getBlockState(AT).is(Blocks.SNOW_BLOCK),"Eighth layer becomes solid snow block");
until(l,AT,1000,()->l.getBlockState(AT).is(Blocks.SNOW_BLOCK),"Eighth layer becomes solid snow block");
check(l.getBlockState(AT).getCollisionShape(l,AT,CollisionContext.empty()).bounds().maxY==1,"Full block collision");
check(pig.getY()>=81,"Growth lifts a standing entity");pig.discard();
l.tickPrecipitation(AT);check(layers(l,AT.above())==1,"New layer begins above solid snow");
for(int i=0;i<30;i++)l.tickPrecipitation(AT);
until(l,AT,1000,()->layers(l,AT.above())==1,"New layer begins above solid snow");
until(l,AT,2000,()->l.getBlockState(AT.above()).is(Blocks.SNOW_BLOCK),"Default cap fills a second block");
check(l.getBlockState(AT.above()).is(Blocks.SNOW_BLOCK)&&l.getBlockState(AT.above(2)).isAir(),"Default cap at two blocks");
command(l,"gamerule sanctuary:snow_accumulation_height 4");
for(int i=0;i<30;i++)l.tickPrecipitation(AT);
until(l,AT,5000,()->l.getBlockState(AT.above(3)).is(Blocks.SNOW_BLOCK),"Raised cap fills the fourth block");
check(l.getBlockState(AT.above(3)).is(Blocks.SNOW_BLOCK)&&l.getBlockState(AT.above(4)).isAir(),"Operator raises cap to four blocks");
command(l,"gamerule sanctuary:snow_accumulation_height -1");
for(int i=0;i<16;i++)l.tickPrecipitation(AT);
until(l,AT,3000,()->l.getBlockState(AT.above(5)).is(Blocks.SNOW_BLOCK),"Unlimited mode fills beyond the cap");
check(l.getBlockState(AT.above(5)).is(Blocks.SNOW_BLOCK),"Unlimited mode continues upward");
var fresh=new BlockPos(-5,80,-5);command(l,"gamerule sanctuary:snow_accumulation_height 0");l.tickPrecipitation(fresh);check(l.getBlockState(fresh).isAir(),"Sanctuary zero stops deposits");
command(l,"gamerule sanctuary:snow_accumulation_height 2");l.getGameRules().set(GameRules.MAX_SNOW_ACCUMULATION_HEIGHT,0,server);l.tickPrecipitation(fresh);check(l.getBlockState(fresh).isAir(),"Native zero remains authoritative");l.getGameRules().set(GameRules.MAX_SNOW_ACCUMULATION_HEIGHT,1,server);
l.setRainLevel(0);l.getWeatherData().setRaining(false);l.tickPrecipitation(fresh);check(l.getBlockState(fresh).isAir(),"No accumulation between showers");
command(l,"sanctuary weather snow_showers");l.setRainLevel(1);l.getWeatherData().setRaining(true);l.tickPrecipitation(fresh);check(layers(l,fresh)==1,"Snow showers accumulate while falling");
var flower=new BlockPos(-6,80,-6);l.setBlockAndUpdate(flower.below(),Blocks.GRASS_BLOCK.defaultBlockState());l.setBlockAndUpdate(flower,Blocks.DANDELION.defaultBlockState());l.tickPrecipitation(flower);check(l.getBlockState(flower).is(Blocks.DANDELION),"Plant preserved");
var water=new BlockPos(-7,79,-7);l.setBlockAndUpdate(water,Blocks.WATER.defaultBlockState());l.tickPrecipitation(water);check(l.getBlockState(water).is(Blocks.WATER)&&l.getBlockState(water.above()).isAir(),"Open water not filled with snow");
var roof=new BlockPos(7,83,-7);l.setBlockAndUpdate(roof,Blocks.STONE.defaultBlockState());l.tickPrecipitation(roof);check(layers(l,roof.above())==1&&l.getBlockState(roof.below()).isAir(),"Snow on roof, not indoors");
var fresh=new BlockPos(-5,80,-5);command(l,"gamerule sanctuary:snow_accumulation_height 0");step(l,fresh,200);check(l.getBlockState(fresh).isAir(),"Sanctuary zero stops deposits");
command(l,"gamerule sanctuary:snow_accumulation_height 2");l.getGameRules().set(GameRules.MAX_SNOW_ACCUMULATION_HEIGHT,0,server);step(l,fresh,200);check(l.getBlockState(fresh).isAir(),"Native zero remains authoritative");l.getGameRules().set(GameRules.MAX_SNOW_ACCUMULATION_HEIGHT,1,server);
l.setRainLevel(0);l.getWeatherData().setRaining(false);step(l,fresh,200);check(l.getBlockState(fresh).isAir(),"No accumulation between showers");
command(l,"sanctuary weather snow_showers");l.setRainLevel(1);l.getWeatherData().setRaining(true);until(l,fresh,1000,()->layers(l,fresh)==1,"Snow showers accumulate while falling");
var flower=new BlockPos(-6,80,-6);l.setBlockAndUpdate(flower.below(),Blocks.GRASS_BLOCK.defaultBlockState());l.setBlockAndUpdate(flower,Blocks.DANDELION.defaultBlockState());step(l,flower,200);check(l.getBlockState(flower).is(Blocks.DANDELION),"Plant preserved");
var water=new BlockPos(-7,79,-7);l.setBlockAndUpdate(water,Blocks.WATER.defaultBlockState());step(l,water,200);check(l.getBlockState(water).is(Blocks.WATER)&&l.getBlockState(water.above()).isAir(),"Open water not filled with snow");
var roof=new BlockPos(7,83,-7);l.setBlockAndUpdate(roof,Blocks.STONE.defaultBlockState());until(l,roof,1000,()->layers(l,roof.above())==1,"Snow on roof arrives");check(layers(l,roof.above())==1&&l.getBlockState(roof.below()).isAir(),"Snow on roof, not indoors");
var cauldron=new BlockPos(8,80,-8);l.setBlockAndUpdate(cauldron,Blocks.CAULDRON.defaultBlockState());for(int i=0;i<200;i++)l.tickPrecipitation(cauldron);check(l.getBlockState(cauldron).is(Blocks.POWDER_SNOW_CAULDRON),"Native cauldron snow filling preserved");
command(l,"sanctuary weather rain");l.setRainLevel(1);var rainy=new BlockPos(10,80,0);l.tickPrecipitation(rainy);check(l.getBlockState(rainy).isAir(),"Temperate rain is not snow");
command(l,"sanctuary weather vanilla");cold(l);for(int i=0;i<8;i++)l.tickPrecipitation(new BlockPos(0,80,0));check(l.getBlockState(new BlockPos(0,80,0)).is(Blocks.SNOW_BLOCK),"Cold biome also accumulates with vanilla weather");
command(l,"sanctuary weather vanilla");cold(l);until(l,new BlockPos(0,80,0),2000,()->l.getBlockState(new BlockPos(0,80,0)).is(Blocks.SNOW_BLOCK),"Cold biome also accumulates with vanilla weather");
var light=new BlockPos(11,80,11);l.setBlockAndUpdate(light.below(),Blocks.GLOWSTONE.defaultBlockState());command(l,"sanctuary weather snow");l.setRainLevel(1);
} else {
cold(l);for(int i=0;i<40;i++)l.tickPrecipitation(AT);check(layers(l,AT)==1&&l.getBlockState(AT.above()).isAir(),"Non-Sanctuary world keeps native one-layer limit");
}
});
if(enabled){c.waitTicks(8);world.getServer().runOnServer(s->{var l=s.overworld();var light=new BlockPos(11,80,11);check(l.getBrightness(LightLayer.BLOCK,light)>=10,"Test light propagated");for(int i=0;i<16;i++)l.tickPrecipitation(light);check(l.getBlockState(light).isAir(),"Bright surfaces do not accumulate");});}
if(enabled){c.waitTicks(8);world.getServer().runOnServer(s->{var l=s.overworld();var light=new BlockPos(11,80,11);check(l.getBrightness(LightLayer.BLOCK,light)>=10,"Test light propagated");step(l,light,400);check(l.getBlockState(light).isAir(),"Bright surfaces do not accumulate");});}
}
System.out.println("SNOW108_NATIVE_PASS: layers, solid blocks, vertical stacking, entity lift, caps and commands, weather, light, supports, cauldron and vanilla world");
}finally{intro.set(false);}
}
private static void cold(ServerLevel l){command(l,"fillbiome -8 76 -8 8 88 8 minecraft:snowy_plains");l.getWeatherData().setRaining(true);l.setRainLevel(1);}
private static int layers(ServerLevel l,BlockPos p){var s=l.getBlockState(p);return s.is(Blocks.SNOW)?s.getValue(SnowLayerBlock.LAYERS):0;}
private static void step(ServerLevel l,BlockPos p,int n){for(int i=0;i<n;i++)l.tickPrecipitation(p);}
private static void until(ServerLevel l,BlockPos p,int max,BooleanSupplier condition,String message){for(int i=0;i<max&&!condition.getAsBoolean();i++)l.tickPrecipitation(p);check(condition.getAsBoolean(),message);}
private static void command(ServerLevel l,String s){l.getServer().getCommands().performPrefixedCommand(l.getServer().createCommandSourceStack(),s);}
private static KeyEvent enter(){return new KeyEvent(InputConstants.KEY_RETURN,0,0);}
private static void check(boolean ok,String message){if(!ok)throw new AssertionError(message);}
@@ -23,7 +23,7 @@ public final class Bloom130ClientChecks implements FabricClientGameTest {
String ns=name.equals("ruby")||name.equals("sapphire")?"sanctuary:":"minecraft:";result.add(ns+name+"_ore");result.add(ns+"deepslate_"+name+"_ore");}
result.add("minecraft:nether_quartz_ore");result.add("minecraft:nether_gold_ore");return List.copyOf(result);}
static void run(ClientGameTestContext c,TestServerContext server){
c.runOnClient(m->{preferences();analyseTextures();VanillaLight.setSaturation(100);VanillaLight.setPixelShadows(false);VanillaLight.setEdgeHighlights(false);VanillaLight.setBloom(false);});
c.runOnClient(m->{preferences();VanillaLight.setSunshafts(false);VanillaLight.setLensFlare(false);VanillaLight.setPbr(false);VanillaLight.setSsgi(false);analyseTextures();VanillaLight.setSaturation(100);VanillaLight.setPixelShadows(false);VanillaLight.setEdgeHighlights(false);VanillaLight.setBloom(false);VanillaLight.setOreEmission(false);});
server.runOnServer(s->{command(s,"time set 18000");
for(int x=4;x<=20;x++)for(int z=4;z<=20;z++){
s.overworld().setBlockAndUpdate(new BlockPos(x,64,z),Blocks.CONCRETE.black().defaultBlockState());
@@ -40,6 +40,19 @@ public final class Bloom130ClientChecks implements FabricClientGameTest {
c.runOnClient(m->{VanillaLight.setOreEmission(true);VanillaLight.setOreIntensity(100);});frames(c);
var source=emission(c);int orePixels=luminous(source);check(orePixels>100,"Actual exposed ore inclusions enter the GPU mask: "+orePixels);
var on=capture(c,"bloom-ores-on");double oreGain=gain(off,on,null);check(oreGain>1000,"Ores visibly glow in the native night scene: "+oreGain);
c.runOnClient(m->VanillaLight.setBloom(false));frames(c);
var coreOnly=capture(c,"emissive-bloom-off");
check(gain(off,coreOnly,null)>1000,"Emissive cores survive bloom OFF");
check(gain(off,coreOnly,source)<1,"No halo outside source texels with bloom OFF");
c.runOnClient(m->check(!Bloom.haloAllocated(),"Bloom OFF frees blur targets and uniforms"));
c.runOnClient(m->{VanillaLight.setBloom(true);VanillaLight.setBloomIntensity(0);});frames(c);
unchanged(coreOnly,capture(c,"emissive-bloom-zero"),"Zero bloom matches OFF while retaining emissive cores");
c.runOnClient(m->check(!Bloom.haloAllocated(),"Zero bloom frees blur resources"));
c.runOnClient(m->VanillaLight.setOreEmission(false));c.waitTicks(8);
c.runOnClient(m->check(!Bloom.allocated()&&!Bloom.applied(),"Zero halo and disabled cores release everything"));
unchanged(off,capture(c,null),"Disabling both restores the original scene");
c.runOnClient(m->{VanillaLight.setOreEmission(true);VanillaLight.setBloomIntensity(35);});frames(c);
unchanged(on,capture(c,null),"Re-enabling bloom restores its original image");
c.runOnClient(m->VanillaLight.setBloomIntensity(10));frames(c);double low=gain(off,capture(c,null),source);
c.runOnClient(m->VanillaLight.setBloomIntensity(90));frames(c);double high=gain(off,capture(c,"bloom-strength-90"),source);
check(high>Math.max(30,low*2),"Bloom strength changes the halo outside source pixels: low="+low+" high="+high);
@@ -83,15 +96,16 @@ public final class Bloom130ClientChecks implements FabricClientGameTest {
c.runOnClient(m->{VanillaLight.setBloom(true);VanillaLight.setBloomIntensity(0);});c.waitTicks(6);c.runOnClient(m->check(!Bloom.applied()&&!Bloom.allocated(),"Zero bloom releases GPU resources"));
unchanged(coveredOff,capture(c,null),"Zero bloom leaves no residual glow");
c.runOnClient(m->{VanillaLight.setBloomIntensity(35);VanillaLight.setBloom(false);VanillaLight.setOreEmission(true);VanillaLight.setOreIntensity(50);});
System.out.println("BLOOM130_PASS masks for 20 ore textures; selective night glow; halo intensity/spread; no through-wall glow; live edits; torch/glowstone/lamp/lava/sun; resource reload; defaults/migration; orePixels="+orePixels+" oreGain="+oreGain+" haloLow="+low+" haloHigh="+high+" sunHalo="+sunlight);
System.out.println("EMISSIVE131_PASS independent cores with halo OFF/zero; blur resources released; defaults; masks for 20 ore textures; selective night glow; halo intensity/spread; no through-wall glow; live edits; torch/glowstone/lamp/lava/sun; resource reload; defaults/migration; orePixels="+orePixels+" oreGain="+oreGain+" haloLow="+low+" haloHigh="+high+" sunHalo="+sunlight);
LensFlare136ClientChecks.run(c,server);
}
private static BlockPos orePos(int i){return new BlockPos(8+i%5,65,8+i/5);}
private static void preferences(){try{
var path=FabricLoader.getInstance().getConfigDir().resolve("sanctuary-shaders.json");Files.deleteIfExists(path);forgetPreferences();
check(VanillaLight.pixelShadows()&&VanillaLight.shadowDistance()==128&&VanillaLight.shadowPixels()==16&&VanillaLight.edgeDistance()==32,"Fresh requested defaults: shadows ON, 128; highlights 32; shared precision 16");
check(!VanillaLight.bloom()&&VanillaLight.bloomIntensity()==35&&VanillaLight.bloomRadius()==50,"Bloom is opt-in with usable defaults");
Files.writeString(path,"{\"pixelShadows\":false,\"shadowDistance\":64,\"shadowPixels\":8,\"edgeDistance\":16}");forgetPreferences();
check(!VanillaLight.pixelShadows()&&VanillaLight.shadowDistance()==64&&VanillaLight.shadowPixels()==8&&VanillaLight.edgeDistance()==16,"Existing user choices are retained");
check(VanillaLight.bloom()&&VanillaLight.oreEmission()&&VanillaLight.edgeHighlights()&&VanillaLight.bloomIntensity()==20&&VanillaLight.bloomRadius()==50,"Bloom, ore emission and edge highlights default to ON");
Files.writeString(path,"{\"bloom\":false,\"oreEmission\":false,\"edgeHighlights\":false,\"pixelShadows\":false,\"shadowDistance\":64,\"shadowPixels\":8,\"edgeDistance\":16}");forgetPreferences();
check(!VanillaLight.bloom()&&!VanillaLight.oreEmission()&&!VanillaLight.edgeHighlights()&&!VanillaLight.pixelShadows()&&VanillaLight.shadowDistance()==64&&VanillaLight.shadowPixels()==8&&VanillaLight.edgeDistance()==16,"Existing user choices are retained");
for(int n:new int[]{-10,0,35,100,400}){VanillaLight.setBloomIntensity(n);VanillaLight.setBloomRadius(n);VanillaLight.setOreIntensity(n);forgetPreferences();check(VanillaLight.bloomIntensity()==Math.clamp(n,0,100)&&VanillaLight.bloomRadius()==Math.clamp(n,0,100)&&VanillaLight.oreIntensity()==Math.clamp(n,0,100),"Persisted bloom controls are bounded");}
VanillaLight.setBloomIntensity(35);VanillaLight.setBloomRadius(50);VanillaLight.setOreIntensity(50);VanillaLight.setShadowPixels(16);
}catch(java.io.IOException e){throw new AssertionError(e);}}
@@ -0,0 +1,86 @@
package fr.koka.sanctuary.client.shader;
import static fr.koka.sanctuary.client.shader.PixelShadows122ClientChecks.*;
import java.nio.file.Files;
import net.fabricmc.fabric.api.client.gametest.v1.FabricClientGameTest;
import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext;
import net.fabricmc.fabric.api.client.gametest.v1.context.TestServerContext;
import net.fabricmc.loader.api.FabricLoader;
import net.minecraft.core.BlockPos;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.block.Block;
import net.minecraft.network.chat.Component;
/** Fresh native world: offscreen sources, true voxel occlusion and real GPU tint. */
public final class ColoredLights139ClientChecks implements FabricClientGameTest {
private static final BlockPos SOURCE=new BlockPos(98,101,102),PROBE=new BlockPos(99,101,106);
public void runTest(ClientGameTestContext c){
System.setProperty("sanctuary.test.freezeBlockFlicker","true");
try{new PixelShadows122ClientChecks(false,false,false,false,false,true).runTest(c);}
finally{System.clearProperty("sanctuary.test.freezeBlockFlicker");}
}
static void run(ClientGameTestContext c,TestServerContext server){
c.runOnClient(m->{preferences();VanillaLight.setWarmTorches(true);m.options.particles().set(net.minecraft.server.level.ParticleStatus.MINIMAL);VanillaLight.setEnabled(true);VanillaLight.setSaturation(100);VanillaLight.setPbr(false);VanillaLight.setSsgi(false);VanillaLight.setBloom(false);VanillaLight.setOreEmission(false);VanillaLight.setLensFlare(false);VanillaLight.setEdgeHighlights(false);VanillaLight.setPixelShadows(false);VanillaLight.setSunshafts(false);VanillaLight.setColoredLights(false);VanillaLight.setColoredLightIntensity(35);});
camera(c,server,100.5,101,104.5,70);c.waitTicks(30);
server.runOnServer(s->{command(s,"time set 18000");
for(int x=88;x<=112;x++)for(int z=92;z<=116;z++)for(int y=100;y<=109;y++)
s.overworld().setBlockAndUpdate(new BlockPos(x,y,z),(y==100||y==109||x==88||x==112||z==92||z==116)?Blocks.CONCRETE.white().defaultBlockState():Blocks.AIR.defaultBlockState());
});
look(c,server,0,35);c.waitTicks(30);
c.waitFor(m->m.level.getBrightness(net.minecraft.world.level.LightLayer.SKY,PROBE)==0,1200);
long warm=0;
for(var block:new Block[]{Blocks.TORCH,Blocks.SOUL_TORCH,Blocks.PEARLESCENT_FROGLIGHT,Blocks.VERDANT_FROGLIGHT,Blocks.OCHRE_FROGLIGHT,Blocks.GLOWSTONE}){
source(c,server,block);enable(c,false);var off=capture(c,"colored-"+name(block)+"-off");enable(c,true);var on=capture(c,"colored-"+name(block)+"-35");
int color=c.computeOnClient(m->ColoredLights.sample(PROBE));long delta=difference(off,on);
check(delta>250,"Offscreen source visibly tints native surfaces: "+name(block)+" "+delta);
int r=color>>16&255,g=color>>8&255,b=color&255;
if(block==Blocks.TORCH){check(r>g&&g>b,"Torch is warm");warm=delta;}
if(block==Blocks.SOUL_TORCH)check(b>g&&g>r,"Soul source is blue");
if(block==Blocks.SOUL_TORCH)for(int step=1;step<=9;step++){
final int distance=step;int sample=c.computeOnClient(m->ColoredLights.sample(SOURCE.offset(distance,0,0)));
int sr=sample>>16&255,sg=sample>>8&255,sb=sample&255;
double rr=sr==0?0:Math.pow(.8,(sb-sr)/8.0),gg=sg==0?0:Math.pow(.8,(sb-sg)/8.0);
check(Math.abs(rr-85/255.)<.02&&Math.abs(gg-189/255.)<.02,"Blue hue remains consistent at native distance "+step+" "+rr+" / "+gg);
}
if(block==Blocks.VERDANT_FROGLIGHT)check(g>r&&g>b,"Verdant froglight is green");
if(block==Blocks.PEARLESCENT_FROGLIGHT)check(r>g&&b>g,"Pearlescent froglight is pink");
if(block==Blocks.OCHRE_FROGLIGHT)check(r>g&&g>b,"Ochre froglight is warm");
if(block==Blocks.GLOWSTONE)check(r>b,"Other emitters derive their colour from the native texture");
System.out.println("COLORED139_SOURCE "+name(block)+" sample="+Integer.toHexString(color)+" delta="+delta);
}
source(c,server,Blocks.TORCH);enable(c,false);var base=capture(c,null);enable(c,true);var low=capture(c,null);
c.runOnClient(m->VanillaLight.setColoredLightIntensity(100));settle(c);var strong=capture(c,"colored-torch-100");check(difference(base,strong)>difference(base,low)*1.5,"Intensity increases colour, without changing native light");
int builds=c.computeOnClient(m->ColoredLights.builds());c.waitTicks(25);check(c.computeOnClient(m->ColoredLights.builds())==builds,"Stationary volume is cached");unchanged(strong,capture(c,null),"Static light is stable");
camera(c,server,104.5,101,104.5,70);look(c,server,0,35);settle(c);capture(c,"colored-moved");
camera(c,server,100.5,101,104.5,70);look(c,server,0,35);settle(c);unchanged(strong,capture(c,"colored-returned"),"Moving and returning preserves world-anchored light");
// A complete divider closes the room; light may not take a path around its edges.
server.runOnServer(s->{for(int y=101;y<109;y++)for(int z=93;z<116;z++)s.overworld().setBlockAndUpdate(new BlockPos(99,y,z),Blocks.STONE.defaultBlockState());});c.waitTicks(35);settle(c);
check((c.computeOnClient(m->ColoredLights.sample(new BlockPos(100,101,106)))&0xffffff)==0,"Opaque wall blocks colour propagation");
enable(c,false);var sealedOff=capture(c,null);enable(c,true);unchanged(sealedOff,capture(c,"colored-sealed"),"No coloured light crosses a sealed wall");
server.runOnServer(s->{for(int y=101;y<109;y++)for(int z=93;z<116;z++)s.overworld().setBlockAndUpdate(new BlockPos(99,y,z),Blocks.AIR.defaultBlockState());});c.waitTicks(35);settle(c);
check((c.computeOnClient(m->ColoredLights.sample(PROBE))&0xffffff)!=0,"Opening the wall restores light");
server.runOnServer(s->{s.overworld().setBlockAndUpdate(SOURCE,Blocks.REDSTONE_LAMP.defaultBlockState());s.overworld().setBlockAndUpdate(SOURCE.below(),Blocks.REDSTONE_BLOCK.defaultBlockState());});c.waitTicks(35);settle(c);
int red=c.computeOnClient(m->ColoredLights.sample(PROBE));check((red>>16&255)>(red>>8&255)&& (red>>16&255)>(red&255),"Powered redstone source is red");capture(c,"colored-redstone-on");
server.runOnServer(s->s.overworld().setBlockAndUpdate(SOURCE.below(),Blocks.CONCRETE.white().defaultBlockState()));c.waitTicks(35);settle(c);
check((c.computeOnClient(m->ColoredLights.sample(PROBE))&0xffffff)==0,"Unpowered lamp removes cached coloured light");
source(c,server,Blocks.SOUL_TORCH);var beforeReload=capture(c,null);
var reload=c.computeOnClient(net.minecraft.client.Minecraft::reloadResourcePacks);c.waitFor(m->reload.isDone()&&m.gui.overlay()==null,1200);reload.join();settle(c);unchanged(beforeReload,capture(c,"colored-reloaded"),"Resource reload rebuilds materials and light field");
source(c,server,Blocks.AIR);check((c.computeOnClient(m->ColoredLights.sample(PROBE))&0xffffff)==0,"Removing the source clears its light");
source(c,server,Blocks.SOUL_TORCH);enable(c,false);var off=capture(c,null);c.runOnClient(m->{VanillaLight.setColoredLights(true);VanillaLight.setColoredLightIntensity(0);});c.waitTicks(10);
c.runOnClient(m->check(!ColoredLights.allocated(),"Zero releases volume and GPU resources"));unchanged(off,capture(c,null),"Zero is identical to OFF");
c.runOnClient(m->{for(String locale:new String[]{"fr_fr","en_us"}){m.getLanguageManager().setSelected(locale);m.getLanguageManager().onResourceManagerReload(m.getResourceManager());for(String key:new String[]{"colored_lights","colored_lights_help","colored_light_intensity","colored_light_intensity_help"}){String full="sanctuary.shaders."+key;check(!Component.translatable(full).getString().equals(full),"Translated "+locale+" "+full);}}});
c.runOnClient(m->{VanillaLight.setColoredLightIntensity(35);VanillaLight.setPbr(true);VanillaLight.setSsgi(true);VanillaLight.setBloom(true);VanillaLight.setBloomIntensity(20);VanillaLight.setEdgeHighlights(true);VanillaLight.setPixelShadows(true);VanillaLight.setSunshafts(true);VanillaLight.setLensFlare(true);VanillaLight.setOreEmission(true);});
settle(c);c.waitFor(m->ProceduralPbr.applied()&&Bloom.applied(),1200);c.waitTicks(30);capture(c,"colored-with-shader-effects");
System.out.println("COLORED139_PASS warm="+warm+" offscreen sources, hues, native textured materials, intensity, cache, camera, walls, lamp updates, removal, reload, zero/OFF and FR/EN");
}
private static String name(Block b){return net.minecraft.core.registries.BuiltInRegistries.BLOCK.getKey(b).getPath();}
private static void source(ClientGameTestContext c,TestServerContext s,Block b){s.runOnServer(server->server.overworld().setBlockAndUpdate(SOURCE,b.defaultBlockState()));c.waitTicks(35);if(c.computeOnClient(m->VanillaLight.coloredLights()))settle(c);}
private static void enable(ClientGameTestContext c,boolean value){c.runOnClient(m->VanillaLight.setColoredLights(value));if(value)settle(c);else c.waitTicks(8);}
private static void settle(ClientGameTestContext c){int before=c.computeOnClient(m->ColoredLights.renderedFrames());c.waitFor(m->ColoredLights.applied()&&ColoredLights.settled()&&ColoredLights.renderedFrames()>before+8,1600);c.waitTicks(8);}
private static long difference(Frame a,Frame b){long sum=0;for(int i=0;i<a.pixels().length;i++)sum+=channelDifference(a.pixels()[i],b.pixels()[i]);return sum;}
private static void preferences(){try{
var path=FabricLoader.getInstance().getConfigDir().resolve("sanctuary-shaders.json");Files.deleteIfExists(path);forgetPreferences();check(VanillaLight.coloredLights()&&VanillaLight.coloredLightIntensity()==30,"New defaults are enabled at 30 percent");
Files.writeString(path,"{\"bloomIntensity\":20,\"ssgi\":false}");forgetPreferences();check(VanillaLight.coloredLights()&&VanillaLight.coloredLightIntensity()==30&&!VanillaLight.ssgi()&&VanillaLight.bloomIntensity()==20,"Old preferences keep their choices");
for(int n:new int[]{-1,0,35,100,400}){VanillaLight.setColoredLights(true);VanillaLight.setColoredLightIntensity(n);forgetPreferences();check(VanillaLight.coloredLights()&&VanillaLight.coloredLightIntensity()==Math.clamp(n,0,100),"Bounded persistent controls");}
}catch(java.io.IOException e){throw new AssertionError(e);}}
}
@@ -0,0 +1,59 @@
package fr.koka.sanctuary.client.shader;
import static fr.koka.sanctuary.client.shader.PixelShadows122ClientChecks.*;
import fr.koka.sanctuary.client.DynamicLightClient;
import fr.koka.sanctuary.inventory.*;
import fr.koka.sanctuary.cosmetics.AccessoryService;
import net.fabricmc.fabric.api.client.gametest.v1.context.*;
import net.minecraft.core.BlockPos;
import net.minecraft.core.registries.BuiltInRegistries;
import net.minecraft.resources.Identifier;
import net.minecraft.world.entity.EquipmentSlot;
import net.minecraft.world.item.*;
import net.minecraft.world.level.block.*;
import net.minecraft.network.chat.Component;
final class ColoredLights140ClientChecks {
private static final BlockPos SOURCE=new BlockPos(98,101,102),PROBE=new BlockPos(99,101,106);
static void run(ClientGameTestContext c,TestServerContext server){
c.runOnClient(m->{VanillaLight.setPbr(false);VanillaLight.setSsgi(false);VanillaLight.setBloom(false);VanillaLight.setOreEmission(false);VanillaLight.setLensFlare(false);VanillaLight.setEdgeHighlights(false);VanillaLight.setPixelShadows(false);VanillaLight.setSunshafts(false);VanillaLight.setColoredLightIntensity(30);VanillaLight.setWarmTorches(false);VanillaLight.setColoredLightDistance(16);});
source(c,server,Blocks.TORCH);enable(c,false);var nativeTorch=capture(c,"torch-native-off");enable(c,true);unchanged(nativeTorch,capture(c,"torch-native-on"),"Normal torch preserves vanilla colour");
c.runOnClient(m->VanillaLight.setWarmTorches(true));settle(c);var warm=capture(c,"torch-warm-30");long accent=difference(nativeTorch,warm);check(accent>2_025_577,"30 percent exceeds beta139 warm tint at 35 percent: "+accent);
c.runOnClient(m->VanillaLight.setWarmTorches(false));settle(c);unchanged(nativeTorch,capture(c,null),"Warm torch option can restore native tint");
for(String name:new String[]{"copper_torch","copper_lantern"}){
var copper=BuiltInRegistries.BLOCK.getValue(Identifier.parse("minecraft:"+name));check(copper!=Blocks.AIR&&copper.defaultBlockState().getLightEmission()>0,"Native copper source exists "+name);
source(c,server,copper);int value=c.computeOnClient(m->ColoredLights.sample(PROBE));check((value>>8&255)>(value>>16&255)&&(value>>8&255)>(value&255),"Copper emits green");capture(c,name+"-green");
}
server.runOnServer(s->{s.overworld().setBlockAndUpdate(SOURCE,Blocks.REDSTONE_LAMP.defaultBlockState());s.overworld().setBlockAndUpdate(SOURCE.below(),Blocks.REDSTONE_BLOCK.defaultBlockState());});c.waitTicks(40);settle(c);
int red=c.computeOnClient(m->ColoredLights.sample(PROBE));check(Math.pow(.8,((red>>16&255)-(red>>8&255))/8.)<.04,"Redstone uses saturated red");capture(c,"redstone-red-30");
source(c,server,Blocks.AIR);server.runOnServer(s->s.overworld().setBlockAndUpdate(SOURCE.below(),Blocks.CONCRETE.white().defaultBlockState()));
// Actual carried sources, synchronized through native equipment and accessory packets.
server.runOnServer(s->{command(s,"gamemode creative @a");var p=s.getPlayerList().getPlayers().getFirst();p.setItemSlot(EquipmentSlot.MAINHAND,new ItemStack(Items.SOUL_TORCH));});
c.waitFor(m->DynamicLightClient.snapshot(m.level).size()==1,200);c.waitTicks(45);enable(c,false);var carriedOff=capture(c,"held-soul-off");enable(c,true);c.waitTicks(25);var carriedOn=capture(c,"held-soul-blue");check(difference(carriedOff,carriedOn)>1000&&c.computeOnClient(m->ColoredLights.dynamicCount())==1,"Held soul torch is visibly blue");
c.runOnClient(m->{var l=DynamicLightClient.snapshot(m.level).sources().iterator().next();check((l.color()&255)>(l.color()>>16&255),"Dynamic source has actual blue colour");});
c.runOnClient(m->m.options.setCameraType(net.minecraft.client.CameraType.THIRD_PERSON_BACK));c.waitTicks(25);
c.runOnClient(m->{var pos=ColoredLights.dynamicPosition(m.player.getId());check(pos!=null&&pos.distanceToSqr(m.player.getEyePosition())<1,"Third-person light follows player, not camera");});capture(c,"held-third-person");
c.runOnClient(m->m.options.setCameraType(net.minecraft.client.CameraType.FIRST_PERSON));c.waitTicks(20);settle(c);
int builds=c.computeOnClient(m->ColoredLights.builds());
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();p.setItemSlot(EquipmentSlot.MAINHAND,ItemStack.EMPTY);p.setItemSlot(EquipmentSlot.OFFHAND,new ItemStack(Items.SOUL_TORCH));});c.waitTicks(35);settle(c);check(c.computeOnClient(m->ColoredLights.builds())==builds,"Moving a light between hands does not rebuild static volume");capture(c,"offhand-soul-blue");
var copperItem=BuiltInRegistries.ITEM.getValue(Identifier.parse("minecraft:copper_torch"));
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();p.setItemSlot(EquipmentSlot.OFFHAND,ItemStack.EMPTY);AccessoryAccess.of(p).setItem(AccessoryContainer.HEAD,new ItemStack(copperItem));AccessoryService.publish(p);});c.waitTicks(45);settle(c);
c.runOnClient(m->{var l=DynamicLightClient.snapshot(m.level).sources().iterator().next();check((l.color()>>8&255)>(l.color()>>16&255)&&(l.color()>>8&255)>(l.color()&255),"Head cosmetic carries copper green");});capture(c,"head-copper-green");check(c.computeOnClient(m->ColoredLights.builds())==builds,"Dynamic colour updates preserve static cache");
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();AccessoryAccess.of(p).setItem(AccessoryContainer.HEAD,ItemStack.EMPTY);AccessoryService.publish(p);});c.waitFor(m->DynamicLightClient.snapshot(m.level).size()==0&&ColoredLights.dynamicCount()==0,200);c.waitTicks(25);
server.runOnServer(s->command(s,"gamemode spectator @a"));
camera(c,server,100.5,108,104.5,70);look(c,server,0,18);c.waitTicks(25);
server.runOnServer(s->{for(int x=88;x<=112;x++)for(int z=117;z<=155;z++)s.overworld().setBlockAndUpdate(new BlockPos(x,100,z),Blocks.CONCRETE.white().defaultBlockState());
for(int x=88;x<=112;x++)for(int y=101;y<=109;y++)s.overworld().setBlockAndUpdate(new BlockPos(x,y,116),Blocks.AIR.defaultBlockState());
s.overworld().setBlockAndUpdate(new BlockPos(100,101,140),Blocks.VERDANT_FROGLIGHT.defaultBlockState());});c.waitTicks(50);
c.runOnClient(m->VanillaLight.setColoredLightDistance(8));settle(c);var shortRange=capture(c,"distance-8");int small=c.computeOnClient(m->ColoredLights.volumeSide());check(c.computeOnClient(m->ColoredLights.sample(new BlockPos(100,101,141)))==0,"Far field omitted at short distance");
int before=c.computeOnClient(m->ColoredLights.builds());c.runOnClient(m->VanillaLight.setColoredLightDistance(64));c.waitFor(m->ColoredLights.builds()>before&&ColoredLights.blend()>0&&ColoredLights.blend()<1,1600);var transition=capture(c,"distance-fade");settle(c);var longRange=capture(c,"distance-64");
check(c.computeOnClient(m->ColoredLights.volumeSide())>small,"Distance changes actual field allocation");check((c.computeOnClient(m->ColoredLights.sample(new BlockPos(100,101,141)))&0xffffff)!=0,"Far light retained at long distance");check(difference(shortRange,longRange)>1000,"Far light is visibly rendered");check(difference(transition,longRange)>100&&difference(shortRange,transition)<difference(shortRange,longRange),"Transition is an intermediate rendered frame without overshoot");
for(int distance:new int[]{8,16,32,64}){c.runOnClient(m->{VanillaLight.setColoredLightDistance(distance);forgetPreferences();check(VanillaLight.coloredLightDistance()==distance,"Persisted distance "+distance);});settle(c);}
c.runOnClient(m->{for(String locale:new String[]{"fr_fr","en_us"}){m.getLanguageManager().setSelected(locale);m.getLanguageManager().onResourceManagerReload(m.getResourceManager());for(String key:new String[]{"warm_torches","warm_torches_help","colored_light_distance","colored_light_distance_help","colored_light_distance_value"}){String full="sanctuary.shaders."+key;check(!Component.translatable(full,16).getString().equals(full),"Translated "+locale+" "+key);}}});
System.out.println("COLORED140_PASS accent="+accent+" native/warm torches, copper green, saturated red, main/offhand/head, dynamic cache, 8/16/32/64 distance, fade and FR/EN");
}
private static void source(ClientGameTestContext c,TestServerContext s,Block b){s.runOnServer(server->server.overworld().setBlockAndUpdate(SOURCE,b.defaultBlockState()));c.waitTicks(35);settle(c);}
private static void enable(ClientGameTestContext c,boolean value){c.runOnClient(m->VanillaLight.setColoredLights(value));if(value)settle(c);else c.waitTicks(10);}
private static void settle(ClientGameTestContext c){c.waitFor(m->ColoredLights.applied()&&ColoredLights.settled(),2000);c.waitTicks(12);}
private static long difference(Frame a,Frame b){long total=0;for(int i=0;i<a.pixels().length;i++)total+=channelDifference(a.pixels()[i],b.pixels()[i]);return total;}
}
@@ -0,0 +1,51 @@
package fr.koka.sanctuary.client.shader;
import static fr.koka.sanctuary.client.shader.PixelShadows122ClientChecks.*;
import net.fabricmc.fabric.api.client.gametest.v1.context.*;
import net.minecraft.core.BlockPos;
import net.minecraft.world.level.block.Blocks;
/** Real enclosed stone surfaces: local normal response without sunlight. */
final class DirectionalLights141ClientChecks {
private static final BlockPos LEFT=new BlockPos(96,101,102),RIGHT=new BlockPos(104,101,102),PROBE=new BlockPos(100,101,102);
static void run(ClientGameTestContext c,TestServerContext server){
c.runOnClient(m->{VanillaLight.setEnabled(true);VanillaLight.setSaturation(100);VanillaLight.setSsgi(false);VanillaLight.setBloom(false);VanillaLight.setOreEmission(false);VanillaLight.setLensFlare(false);VanillaLight.setEdgeHighlights(false);VanillaLight.setPixelShadows(false);VanillaLight.setSunshafts(false);m.options.particles().set(net.minecraft.server.level.ParticleStatus.MINIMAL);VanillaLight.setColoredLights(true);VanillaLight.setColoredLightIntensity(30);VanillaLight.setColoredLightDistance(16);VanillaLight.setWarmTorches(false);VanillaLight.setPbr(false);VanillaLight.setPbrIntensity(100);});
camera(c,server,100.5,101,98.5,70);look(c,server,0,35);c.waitTicks(30);
server.runOnServer(s->{if(s.clockManager().getInstance(s.overworld().dimensionType().defaultClock().orElseThrow()).totalTicks()!=18000)command(s,"time set 18000");
for(int x=88;x<=112;x++)for(int z=92;z<=116;z++)for(int y=100;y<=109;y++)
s.overworld().setBlockAndUpdate(new BlockPos(x,y,z),(y==100)?Blocks.STONE.defaultBlockState():(y==109||x==88||x==112||z==92||z==116)?Blocks.CONCRETE.white().defaultBlockState():Blocks.AIR.defaultBlockState());
s.overworld().setBlockAndUpdate(LEFT,Blocks.TORCH.defaultBlockState());
});c.waitTicks(40);settle(c);var flat=capture(c,"direction-left-pbr-off");
c.runOnClient(m->VanillaLight.setPbr(true));c.waitFor(m->ColoredLights.directionSample(PROBE)!=0&&ProceduralPbr.ready(),2000);settle(c);
int left=c.computeOnClient(m->ColoredLights.directionSample(PROBE));check(dx(left)<-.9&&Math.abs(dy(left))<.02&&Math.abs(dz(left))<.02,"Cached arrival points toward the left source");
var relief=capture(c,"direction-left-pbr-on");long delta=difference(flat,relief);check(delta>1000,"Local light visibly affects stone normals without skylight: "+delta);
int builds=c.computeOnClient(m->ColoredLights.builds());c.waitTicks(25);check(c.computeOnClient(m->ColoredLights.builds())==builds,"Static directions reuse the cache");unchanged(relief,capture(c,null),"Static local PBR is stable");
if(Boolean.getBoolean("sanctuary.test.pbr142"))PbrRefresh142ClientChecks.refresh(c,server,relief);
server.runOnServer(s->{s.overworld().setBlockAndUpdate(LEFT,Blocks.AIR.defaultBlockState());s.overworld().setBlockAndUpdate(RIGHT,Blocks.TORCH.defaultBlockState());});c.waitTicks(40);settle(c);
int right=c.computeOnClient(m->ColoredLights.directionSample(PROBE));check(dx(right)>.9,"Moving the source reverses arrival direction");capture(c,"direction-right-pbr-on");
server.runOnServer(s->s.overworld().setBlockAndUpdate(LEFT,Blocks.TORCH.defaultBlockState()));c.waitTicks(40);settle(c);
int opposed=c.computeOnClient(m->ColoredLights.directionSample(PROBE));check(Math.abs(dx(opposed))<.02&&Math.abs(dy(opposed))<.02&&Math.abs(dz(opposed))<.02&&(opposed>>>24)>0,"Equal opposing lights cancel direction, retain illumination");capture(c,"direction-opposed");
// A sealed barrier must prevent both intensity and direction reaching the probe.
server.runOnServer(s->{s.overworld().setBlockAndUpdate(RIGHT,Blocks.AIR.defaultBlockState());for(int y=101;y<109;y++)for(int z=93;z<116;z++)s.overworld().setBlockAndUpdate(new BlockPos(99,y,z),Blocks.STONE.defaultBlockState());});c.waitTicks(40);settle(c);
check(c.computeOnClient(m->ColoredLights.directionSample(PROBE))==0,"Closed wall blocks local direction and energy");capture(c,"direction-blocked");
server.runOnServer(s->{s.overworld().setBlockAndUpdate(LEFT,Blocks.AIR.defaultBlockState());
for(int y=101;y<109;y++)for(int z=93;z<116;z++)s.overworld().setBlockAndUpdate(new BlockPos(99,y,z),Blocks.AIR.defaultBlockState());
command(s,"gamemode creative @a");s.getPlayerList().getPlayers().getFirst().setItemSlot(net.minecraft.world.entity.EquipmentSlot.MAINHAND,new net.minecraft.world.item.ItemStack(net.minecraft.world.item.Items.TORCH));});
c.waitTicks(45);settle(c);capture(c,"direction-held-pbr-on");
c.runOnClient(m->VanillaLight.setPbr(false));c.waitTicks(35);settle(c);var heldFlat=capture(c,"direction-held-pbr-off");
c.runOnClient(m->VanillaLight.setPbr(true));c.waitTicks(35);settle(c);var heldRelief=capture(c,"direction-held-pbr-on-final");
if(Boolean.getBoolean("sanctuary.test.pbr142"))PbrRefresh142ClientChecks.held(c,server);
long heldDelta=difference(heldFlat,heldRelief);check(heldDelta>1000&&c.computeOnClient(m->ColoredLights.dynamicCount())==1,"Carried light drives local PBR: "+heldDelta);
server.runOnServer(s->s.getPlayerList().getPlayers().getFirst().setItemSlot(net.minecraft.world.entity.EquipmentSlot.MAINHAND,net.minecraft.world.item.ItemStack.EMPTY));c.waitTicks(35);
c.runOnClient(m->VanillaLight.setPbr(false));c.waitTicks(35);settle(c);check(c.computeOnClient(m->ColoredLights.directionSample(LEFT))==0,"PBR OFF releases directional field");
c.runOnClient(m->{VanillaLight.setColoredLights(false);VanillaLight.setPbr(true);});c.waitTicks(25);capture(c,"direction-colored-off");
check(!c.computeOnClient(m->ColoredLights.allocated()),"Color OFF releases local field while PBR keeps its solar path");
if(Boolean.getBoolean("sanctuary.test.pbr142"))PbrRefresh142ClientChecks.cave(c,server);
System.out.println("DIRECTIONAL141_PASS localPbrDelta="+delta+" native normals, left/right, opposing sources, wall, held light, stable cache, OFF");
}
private static double dx(int c){return ((c>>16&255)-128)/127.;}
private static double dy(int c){return ((c>>8&255)-128)/127.;}
private static double dz(int c){return ((c&255)-128)/127.;}
private static void settle(ClientGameTestContext c){c.waitFor(m->ColoredLights.applied()&&ColoredLights.settled(),2400);c.waitTicks(14);}
private static long difference(Frame a,Frame b){long n=0;for(int i=0;i<a.pixels().length;i++)n+=channelDifference(a.pixels()[i],b.pixels()[i]);return n;}
}
@@ -41,6 +41,11 @@ final class EdgeHighlights129ClientChecks {
var fine=measure(off,capture(c,"edges-shared-precision-32"));
check(coarse.brighter()>low.brighter()*1.4 && fine.brighter()<low.brighter()*.8,
"One precision setting controls the visible highlight width: coarse="+coarse+" middle="+low+" fine="+fine);
for(int pixels:new int[]{64,128}){
c.runOnClient(m -> VanillaLight.setShadowPixels(pixels));frames(c);
var finer=measure(off,capture(c,"edges-shared-precision-"+pixels));
check(finer.meanGain()<fine.meanGain() && finer.darker()==0,"High precision refines CTM without darkening: "+pixels+" "+finer);
}
c.runOnClient(m -> VanillaLight.setShadowPixels(16));frames(c);
for (int distance : new int[]{16,32,64,128,256}) {
@@ -139,7 +144,7 @@ final class EdgeHighlights129ClientChecks {
// Real prior-version JSON, without either new field. Preserve its existing settings.
Files.writeString(path,"{\"enabled\":true,\"saturation\":100,\"pixelShadows\":true,\"shadowPixels\":16,\"shadowDistance\":32}");
forgetPreferences();
check(!VanillaLight.edgeHighlights() && VanillaLight.edgeIntensity()==30 && VanillaLight.edgeDistance()==32,"Old configuration defaults highlights OFF at intensity 30");
check(VanillaLight.edgeHighlights() && VanillaLight.edgeIntensity()==30 && VanillaLight.edgeDistance()==32,"Missing preference uses the new highlight ON default at intensity 30");
check(VanillaLight.pixelShadows() && VanillaLight.saturation()==100,"Migration retains existing shader choices");
for(int value:new int[]{-100,0,30,100,1000}) {
VanillaLight.setEdgeIntensity(value);forgetPreferences();
@@ -154,6 +159,9 @@ final class EdgeHighlights129ClientChecks {
VanillaLight.setEdgeHighlights(true);forgetPreferences();
check(VanillaLight.edgeHighlights(),"Toggle persists independently");
VanillaLight.setEdgeHighlights(false);
VanillaLight.setBloom(false);VanillaLight.setOreEmission(false);
VanillaLight.setSunshafts(false);VanillaLight.setLensFlare(false);
VanillaLight.setPbr(false);VanillaLight.setSsgi(false);
} catch(java.io.IOException e) { throw new AssertionError(e); }
}
@@ -0,0 +1,70 @@
package fr.koka.sanctuary.client.shader;
import static fr.koka.sanctuary.client.shader.PixelShadows122ClientChecks.*;
import java.nio.file.Files;
import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext;
import net.fabricmc.fabric.api.client.gametest.v1.context.TestServerContext;
import net.fabricmc.loader.api.FabricLoader;
import net.minecraft.core.BlockPos;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.network.chat.Component;
/** Native sky image, not a generated mockup; explicit independent effect controls. */
final class LensFlare136ClientChecks {
static void run(ClientGameTestContext c,TestServerContext server){
c.runOnClient(m->{preferences();VanillaLight.setEnabled(true);VanillaLight.setSaturation(100);VanillaLight.setPbr(false);VanillaLight.setSsgi(false);VanillaLight.setBloom(false);VanillaLight.setOreEmission(false);VanillaLight.setPixelShadows(false);VanillaLight.setEdgeHighlights(false);VanillaLight.setSunshafts(false);VanillaLight.setLensFlare(false);});
server.runOnServer(s->{command(s,"time add 1");command(s,"time set 3000");});
camera(c,server,100.5,130,100.5,70);aim(c,server,false);
var off=capture(c,"flare-sun-off");enable(c,true);var on=capture(c,"flare-sun-30");
long low=gain(off,on);check(low>1000,"Default sun flare is visible: "+low);
c.runOnClient(m->check(!Bloom.haloAllocated()&&Bloom.flareAllocated(),"Flare runs without bloom blur targets"));
unchanged(on,capture(c,null),"Static rectangular flare has no animated noise");
c.runOnClient(m->VanillaLight.setLensFlareIntensity(80));frames(c);var high=capture(c,"flare-sun-80");
check(gain(off,high)>low*1.5,"Flare intensity changes native pixels");
var reload=c.computeOnClient(net.minecraft.client.Minecraft::reloadResourcePacks);c.waitFor(m->reload.isDone()&&m.gui.overlay()==null,1200);reload.join();frames(c);
unchanged(high,capture(c,"flare-reloaded"),"Flare recovers after resource reload");
int width=c.computeOnClient(m->m.getWindow().getScreenWidth()),height=c.computeOnClient(m->m.getWindow().getScreenHeight());
c.runOnClient(m->m.getWindow().setWindowed(961,541));c.waitFor(m->m.gameRenderer.mainRenderTarget().width!=off.width(),200);frames(c);
enable(c,false);var smallOff=capture(c,null);enable(c,true);check(gain(smallOff,capture(c,"flare-resized"))>1000,"Flare survives odd-sized targets");
c.runOnClient(m->m.getWindow().setWindowed(width,height));c.waitFor(m->m.gameRenderer.mainRenderTarget().width==off.width(),200);frames(c);
var direction=c.computeOnClient(m->PixelShadows.sunDirection(m.gameRenderer.gameRenderState().levelRenderState.skyRenderState.sunAngle));
var eye=c.computeOnClient(m->m.player.getEyePosition());var center=BlockPos.containing(eye.x+direction.x*8,eye.y+direction.y*8,eye.z+direction.z*8);
cover(server,center,true);c.waitTicks(35);frames(c);var hiddenOn=capture(c,"flare-sun-covered");enable(c,false);
unchanged(hiddenOn,capture(c,null),"Occluded sun produces no rectangular flare through a wall");
cover(server,center,false);c.waitTicks(35);aim(c,server,false);
c.runOnClient(m->{VanillaLight.setBloom(true);VanillaLight.setBloomIntensity(20);VanillaLight.setLensFlareIntensity(30);});enable(c,true);capture(c,"flare-bloom-combined");
c.runOnClient(m->VanillaLight.setBloom(false));look(c,server,0,80);enable(c,false);var away=capture(c,null);enable(c,true);unchanged(away,capture(c,"flare-away"),"Behind-camera celestial sources produce no reflected screen edge");
server.runOnServer(s->s.overworld().setBlockAndUpdate(new BlockPos(100,129,104),Blocks.GLOWSTONE.defaultBlockState()));
look(c,server,0,35);c.waitTicks(25);enable(c,false);var lampOff=capture(c,null);enable(c,true);unchanged(lampOff,capture(c,"flare-glowstone-excluded"),"Visible glowstone does not create a celestial flare");
server.runOnServer(s->command(s,"time set 15000"));c.waitTicks(20);aim(c,server,true);
enable(c,false);var moonOff=capture(c,"flare-moon-off");enable(c,true);long moon=gain(moonOff,capture(c,"flare-moon-30"));
check(moon>200,"Native visible moon creates its own subtle flare: "+moon);
server.runOnServer(s->command(s,"time set 111000"));c.waitFor(m->m.gameRenderer.gameRenderState().levelRenderState.skyRenderState.moonPhase.index()==4,200);aim(c,server,true);
enable(c,false);var newOff=capture(c,null);enable(c,true);check(gain(newOff,capture(c,"flare-new-moon"))<moon*.1,"Dark new moon does not retain full moon flare");
c.runOnClient(m->VanillaLight.setLensFlareIntensity(0));c.waitTicks(8);c.runOnClient(m->check(!Bloom.flareAllocated()&&!Bloom.allocated(),"Zero frees all resources with other effects OFF"));
unchanged(newOff,capture(c,null),"Zero flare restores source image");
c.runOnClient(m->{VanillaLight.setLensFlareIntensity(30);VanillaLight.setSunshafts(false);VanillaLight.setLensFlare(false);});
for(String locale:new String[]{"fr_fr","en_us"})c.runOnClient(m->{
m.getLanguageManager().setSelected(locale);m.getLanguageManager().onResourceManagerReload(m.getResourceManager());
for(String suffix:new String[]{"lens_flare","lens_flare_help","lens_flare_intensity","lens_flare_intensity_help"}){
String key="sanctuary.shaders."+suffix;check(!Component.translatable(key).getString().equals(key),"Localized flare control: "+locale+" "+key);
}
});
System.out.println("LENS_FLARE136_PASS sunGain="+low+" moonGain="+moon+" visibility, resize, reload, strength, independent bloom, zero, preferences, FR/EN");
}
private static void cover(TestServerContext server,BlockPos center,boolean on){server.runOnServer(s->{for(int x=-4;x<=4;x++)for(int y=-4;y<=4;y++)for(int z=-4;z<=4;z++)s.overworld().setBlockAndUpdate(center.offset(x,y,z),on?Blocks.STONE.defaultBlockState():Blocks.AIR.defaultBlockState());});}
private static void aim(ClientGameTestContext c,TestServerContext server,boolean moon){
var direction=c.computeOnClient(m->PixelShadows.sunDirection(moon?m.gameRenderer.gameRenderState().levelRenderState.skyRenderState.moonAngle:m.gameRenderer.gameRenderState().levelRenderState.skyRenderState.sunAngle));
look(c,server,net.minecraft.util.Mth.wrapDegrees((float)Math.toDegrees(Math.atan2(-direction.x,direction.z))+12),-(float)Math.toDegrees(Math.asin(direction.y))+8);
}
private static void frames(ClientGameTestContext c){int before=c.computeOnClient(m->Bloom.renderedFrames());c.waitFor(m->Bloom.applied()&&Bloom.ready()&&Bloom.renderedFrames()>before+10,1200);c.waitTicks(8);}
private static void enable(ClientGameTestContext c,boolean value){c.runOnClient(m->VanillaLight.setLensFlare(value));if(value)frames(c);else c.waitTicks(8);}
private static long gain(Frame off,Frame on){long sum=0;for(int i=0;i<off.pixels().length;i++)for(int shift:new int[]{0,8,16})sum+=Math.max(0,(on.pixels()[i]>>shift&255)-(off.pixels()[i]>>shift&255));return sum;}
private static void preferences(){try{
var path=FabricLoader.getInstance().getConfigDir().resolve("sanctuary-shaders.json");Files.writeString(path,"{\"bloom\":false,\"bloomIntensity\":20}");forgetPreferences();
check(VanillaLight.lensFlare()&&VanillaLight.lensFlareIntensity()==30&&!VanillaLight.bloom()&&VanillaLight.bloomIntensity()==20,"Flare defaults preserve bloom choices");
VanillaLight.setSunshafts(false);VanillaLight.setLensFlare(false);VanillaLight.setLensFlareIntensity(73);forgetPreferences();check(!VanillaLight.lensFlare()&&VanillaLight.lensFlareIntensity()==73,"Saved custom flare retained");
for(int n:new int[]{-10,0,30,100,150}){VanillaLight.setLensFlareIntensity(n);forgetPreferences();check(VanillaLight.lensFlareIntensity()==Math.clamp(n,0,100),"Flare intensity persists and clamps");}
VanillaLight.setLensFlareIntensity(30);
}catch(java.io.IOException e){throw new AssertionError(e);}}
}
@@ -0,0 +1,199 @@
package fr.koka.sanctuary.client.shader;
import static fr.koka.sanctuary.client.shader.PixelShadows122ClientChecks.*;
import net.fabricmc.fabric.api.client.gametest.v1.FabricClientGameTest;
import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext;
import net.fabricmc.fabric.api.client.gametest.v1.context.TestServerContext;
import net.fabricmc.loader.api.FabricLoader;
import net.minecraft.core.BlockPos;
import net.minecraft.world.level.block.Blocks;
import java.nio.file.Files;
import java.util.Arrays;
public final class Pbr134ClientChecks implements FabricClientGameTest {
public void runTest(ClientGameTestContext c){new PixelShadows122ClientChecks(false,false,true).runTest(c);}
static void run(ClientGameTestContext c,TestServerContext server){
c.runOnClient(m->{preferences();VanillaLight.setSunshafts(false);VanillaLight.setLensFlare(false);materials();VanillaLight.setEnabled(true);VanillaLight.setSaturation(100);VanillaLight.setColoredLights(false);VanillaLight.setPbr(false);VanillaLight.setSsgi(false);VanillaLight.setBloom(false);VanillaLight.setOreEmission(false);VanillaLight.setEdgeHighlights(false);VanillaLight.setPixelShadows(false);});
server.runOnServer(s->{command(s,"time set 1000");for(int x=-5;x<=24;x++)for(int z=-5;z<=24;z++){
s.overworld().setBlockAndUpdate(new BlockPos(x,64,z),(x<10?Blocks.BRICKS:x<17?Blocks.OAK_PLANKS:Blocks.IRON_BLOCK).defaultBlockState());
for(int y=65;y<=102;y++)s.overworld().setBlockAndUpdate(new BlockPos(x,y,z),Blocks.AIR.defaultBlockState());
}});
camera(c,server,10.5,69,5.5,65);look(c,server,0,40);settle(c);
// The preceding lava/mob fixture may leave animated particles after its blocks are removed.
c.runOnClient(m->m.particleEngine.clearParticles());c.waitTicks(2);
var off=capture(c,"pbr-off");enable(c,true);var on=capture(c,"pbr-50");
var delta=difference(off,on);System.out.println("PBR134_DIFF "+delta);check(delta.changed>500&&delta.brighter>100&&delta.darker>100,"Texture relief has lit and shaded pixels: "+delta);
int analyses=c.computeOnClient(m->ProceduralPbr.analyses());c.waitTicks(20);
c.runOnClient(m->check(analyses==ProceduralPbr.analyses()&&ProceduralPbr.materialCount()>0,"Source textures are cached, not regenerated every frame"));
unchanged(on,capture(c,null),"Static PBR has no animated noise");
look(c,server,.00002f,40.00002f);frames(c);unchanged(on,capture(c,null),"PBR tiny camera motion");look(c,server,0,40);frames(c);
closeRange(c,server);
camera(c,server,10.5,69,5.5,65);look(c,server,0,40);settle(c);
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->check(!ProceduralPbr.allocated()&&ProceduralPbr.materialCount()==0,"Zero releases targets and material cache"));
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);
var reload=c.computeOnClient(net.minecraft.client.Minecraft::reloadResourcePacks);c.waitFor(m->reload.isDone()&&m.gui.overlay()==null,1200);reload.join();frames(c);unchanged(eveningOn,capture(c,"pbr-reloaded"),"Resource reload rebuilds consistent cached materials");
// Live geometry and light invalidation: an enclosing roof removes exposure to the sky.
server.runOnServer(s->{for(int x=-8;x<=28;x++)for(int z=-8;z<=28;z++)s.overworld().setBlockAndUpdate(new BlockPos(x,73,z),Blocks.STONE.defaultBlockState());});
settle(c);frames(c);var roofOn=capture(c,"pbr-roof-on");enable(c,false);var roofOff=capture(c,"pbr-roof-off");
unchanged(roofOff,roofOn,"Roof prevents outdoor highlights on enclosed floor");enable(c,true);
server.runOnServer(s->{for(int x=-8;x<=28;x++)for(int z=-8;z<=28;z++)s.overworld().setBlockAndUpdate(new BlockPos(x,73,z),Blocks.AIR.defaultBlockState());});
settle(c);frames(c);unchanged(eveningOn,capture(c,"pbr-roof-removed"),"Removing roof restores material light without stale meshes");
int oldWidth=c.computeOnClient(m->m.getWindow().getScreenWidth()),oldHeight=c.computeOnClient(m->m.getWindow().getScreenHeight());
c.runOnClient(m->m.getWindow().setWindowed(961,541));c.waitFor(m->m.gameRenderer.mainRenderTarget().width!=on.width(),200);frames(c);capture(c,"pbr-resized");
c.runOnClient(m->m.getWindow().setWindowed(oldWidth,oldHeight));c.waitFor(m->m.gameRenderer.mainRenderTarget().width==on.width(),200);frames(c);
c.runOnClient(m->{VanillaLight.setPixelShadows(true);VanillaLight.setSsgi(true);VanillaLight.setBloom(true);VanillaLight.setOreEmission(true);VanillaLight.setEdgeHighlights(true);});
c.waitFor(m->PixelShadows.applied()&&PixelShadows.ready()&&ScreenSpaceGI.applied()&&Bloom.applied()&&EdgeHighlights.applied()&&ProceduralPbr.applied(),1200);frames(c);capture(c,"pbr-combined");
matteGround(c,server,Blocks.OAK_LEAVES,"leaves",true);
matteGround(c,server,Blocks.DIRT,"dirt",false);
matteGround(c,server,Blocks.OAK_PLANKS,"oak-planks",false);
matteGround(c,server,Blocks.STRIPPED_SPRUCE_LOG,"stripped-spruce",false);
darkStone(c,server);
netherRoundTrip(c,server);
c.runOnClient(m->VanillaLight.setEnabled(false));c.waitTicks(8);c.runOnClient(m->check(!ProceduralPbr.allocated()&&ProceduralPbr.materialCount()==0,"Global OFF releases PBR"));
c.runOnClient(m->{VanillaLight.setEnabled(true);VanillaLight.setPbr(false);VanillaLight.setSsgi(false);});
System.out.println("PBR134_PASS normals; profiles; texture cache; directional lighting; intensity; OFF/zero; tiny camera; reload; resize; combined; default="+delta+" high="+high+" directional="+directions);
}
private static void darkStone(ClientGameTestContext c,TestServerContext server){
server.runOnServer(s->{for(int x=-5;x<=24;x++)for(int z=-5;z<=24;z++)
s.overworld().setBlockAndUpdate(new BlockPos(x,64,z),Blocks.OBSIDIAN.defaultBlockState());});
camera(c,server,10.5,69,5.5,65);look(c,server,0,90);settle(c);
enable(c,false);var off=capture(c,"pbr151-obsidian-face-off");enable(c,true);var on=capture(c,"pbr151-obsidian-face-on");
double gain=0;int samples=0,relief=0;
for(int y=off.height()/4;y<off.height()*3/4;y++)for(int x=off.width()/4;x<off.width()*3/4;x++){
int i=y*off.width()+x,a=off.pixels()[i],b=on.pixels()[i];
double before=((a>>16&255)+(a>>8&255)+(a&255))/3.0,after=((b>>16&255)+(b>>8&255)+(b&255))/3.0;
gain+=Math.max(0,after-before);samples++;if(Math.abs(after-before)>1)relief++;
}
System.out.println("PBR151_OBSIDIAN samples="+samples+" meanBrightening="+gain/samples+" relief="+relief);
check(samples>1000&&gain/samples<18,"Dark stone keeps its dark face without amplified white sheen");
check(relief>100,"Obsidian PBR remains visible");
look(c,server,0,20);settle(c);capture(c,"pbr151-obsidian-grazing");
}
private static void netherRoundTrip(ClientGameTestContext c,TestServerContext server){
c.runOnClient(m->{VanillaLight.setEnabled(true);VanillaLight.setPbr(true);VanillaLight.setSsr(true);VanillaLight.setColoredLights(true);VanillaLight.setPixelShadows(true);});
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();
p.getInventory().setItem(0,new net.minecraft.world.item.ItemStack(net.minecraft.world.item.Items.DIAMOND,7));
var overflow=fr.koka.sanctuary.inventory.SanctuaryOverflowAccess.of(p);
overflow.setItem(0,new net.minecraft.world.item.ItemStack(net.minecraft.world.item.Items.EMERALD,3));
overflow.setItem(17,new net.minecraft.world.item.ItemStack(net.minecraft.world.item.Items.GOLD_INGOT,5));
p.inventoryMenu.broadcastFullState();});
for(String dimension:new String[]{"the_nether","overworld","the_nether","overworld"}){
server.runOnServer(s->command(s,"execute in minecraft:"+dimension+" run tp @a 10.5 100 5.5"));
c.waitFor(m->m.level!=null&&m.player!=null&&m.level.dimension().identifier().getPath().equals(dimension)&&m.gui.screen()==null,2400);
c.waitTicks(40);
c.runOnClient(m->{
check(m.player.inventoryMenu.slots.size()==67,"Inventory has exactly 46 native, 18 extra and 3 accessory slots");
check(m.player.getInventory().getItem(0).is(net.minecraft.world.item.Items.DIAMOND)&&m.player.getInventory().getItem(0).getCount()==7,"Native stack survives transfer");
var overflow=fr.koka.sanctuary.inventory.SanctuaryOverflowAccess.of(m.player);
check(overflow.getItem(0).is(net.minecraft.world.item.Items.EMERALD)&&overflow.getItem(0).getCount()==3,"First overflow stack survives transfer");
check(overflow.getItem(17).is(net.minecraft.world.item.Items.GOLD_INGOT)&&overflow.getItem(17).getCount()==5,"Last overflow stack survives transfer");
});
capture(c,"nether151-"+dimension);
server.runOnServer(s->check(s.getPlayerList().getPlayers().size()==1,"Player stays connected after dimension transfer"));
System.out.println("NETHER151_TRANSFER_PASS "+dimension);
}
}
private static void matteGround(ClientGameTestContext c,TestServerContext server,net.minecraft.world.level.block.Block block,String name,boolean leaves){
c.runOnClient(m->{VanillaLight.setSsr(false);VanillaLight.setPbrIntensity(50);VanillaLight.setPixelShadows(false);VanillaLight.setSsgi(false);VanillaLight.setBloom(false);VanillaLight.setOreEmission(false);VanillaLight.setEdgeHighlights(false);});
server.runOnServer(s->{if(leaves)command(s,"time set 1000");for(int x=-5;x<=24;x++)for(int z=-5;z<=24;z++)
s.overworld().setBlockAndUpdate(new BlockPos(x,64,z),leaves?block.defaultBlockState().setValue(net.minecraft.world.level.block.LeavesBlock.PERSISTENT,true):block.defaultBlockState());});
camera(c,server,10.5,69,5.5,65);look(c,server,0,40);settle(c);
enable(c,false);var off=capture(c,"pbr150-"+name+"-off");enable(c,true);var on=capture(c,"pbr150-"+name+"-on");
double nativeChroma=0,pbrChroma=0;int samples=0,relief=0;
for(int y=off.height()/3;y<off.height()*9/10;y++)for(int x=off.width()/10;x<off.width()*9/10;x++){
int i=y*off.width()+x,a=off.pixels()[i],b=on.pixels()[i],r=a>>16&255,g=a>>8&255,blue=a&255;
if(leaves?(g<30||g<r*1.1||g<blue*1.1):(r<30||r<g*1.05||g<blue*1.1))continue;
nativeChroma+=chroma(a);pbrChroma+=chroma(b);samples++;
if(Math.abs((b>>16&255)-r)+Math.abs((b>>8&255)-g)+Math.abs((b&255)-blue)>6)relief++;
}
double retained=pbrChroma/nativeChroma;
System.out.println("PBR150_MATTE material="+name+" samples="+samples+" chromaRetained="+retained+" relief="+relief);
check(samples>1000&&retained>.985,name+" preserves native colour without specular veil: "+retained);
check(relief>100,name+" normal relief remains visible");
}
private static double chroma(int pixel){
int r=pixel>>16&255,g=pixel>>8&255,b=pixel&255,max=Math.max(r,Math.max(g,b)),min=Math.min(r,Math.min(g,b));
return (max-min)/(double)Math.max(1,max);
}
private static void closeRange(ClientGameTestContext c,TestServerContext server){
int failures=0;
c.runOnClient(m->VanillaLight.setPbrIntensity(80));
for(double distance:new double[]{.12,.25,.5,1.0}){
double eye=c.computeOnClient(m->(double)m.player.getEyeHeight());
camera(c,server,4.5,65-eye+distance,5.5,65);look(c,server,17,55);settle(c);frames(c);
var before=capture(c,"pbr147-close-"+distance);
look(c,server,17.00002f,55.00002f);frames(c);var after=capture(c,"pbr147-close-moved-"+distance);
int changed=0,count=0;
for(int y=before.height()/10;y<before.height()*9/10;y++)for(int x=before.width()/10;x<before.width()*9/10;x++){
int i=y*before.width()+x,a=before.pixels()[i],b=after.pixels()[i];count++;
if(Math.abs((a>>16&255)-(b>>16&255))+Math.abs((a>>8&255)-(b>>8&255))+Math.abs((a&255)-(b&255))>12)changed++;
}
System.out.println("PBR147_CLOSE distance="+distance+" changed="+changed+" samples="+count);
if(changed>count*.001)failures++;
}
c.runOnClient(m->VanillaLight.setPbrIntensity(50));
check(failures==0,"Close PBR stays stable under subpixel motion: failing distances="+failures);
}
private static void materials(){
int[] flat=new int[16];Arrays.fill(flat,0xFF808080);var profile=PbrMaterials.profile("bricks");
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");
for(String soil:new String[]{"dirt","coarse_dirt","rooted_dirt","grass_block","dirt_path","farmland","podzol","mycelium"})
check(PbrMaterials.profile(soil).roughness()>=.98f&&PbrMaterials.profile(soil).metal()==0,"Dry soil has a fully matte profile: "+soil);
for(String wood:new String[]{"oak_planks","spruce_stairs","dark_oak_slab","stripped_spruce_log","stripped_crimson_stem","warped_hyphae","bamboo_mosaic","pale_oak_trapdoor"})
check(PbrMaterials.profile(wood).roughness()>=.98f&&PbrMaterials.profile(wood).metal()==0,"Wood remains fully matte: "+wood);
check(PbrMaterials.profile("crimson_nylium").roughness()<.9f,"Nether ground is not classified as wood");
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()==50&&VanillaLight.ssgi()&&!VanillaLight.bloom(),"Missing PBR setting defaults ON while preserving explicit old choices");
Files.writeString(p,"{\"pbrIntensity\":80}");forgetPreferences();
check(VanillaLight.pbrIntensity()==50,"Previous 80 percent baseline migrates to 50 once");
VanillaLight.setPbrIntensity(80);forgetPreferences();check(VanillaLight.pbrIntensity()==80,"New explicit 80 percent choice persists after migration");
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()==64&&VanillaLight.ssrDistance()==64,"Independent distances default to 64");
check(!VanillaLight.naturalWater(false)&&VanillaLight.naturalWater(true),"World-specific water defaults");
for(int n:new int[]{16,32,64,128,256}){VanillaLight.setPbrDistance(n);forgetPreferences();check(VanillaLight.pbrDistance()==n&&VanillaLight.ssrDistance()==64,"PBR distance changes independently");}
for(int n:new int[]{16,32,64,128,256}){VanillaLight.setSsrDistance(n);forgetPreferences();check(VanillaLight.ssrDistance()==n&&VanillaLight.pbrDistance()==256,"SSR distance changes independently");}
VanillaLight.setSsrDistance(64);
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 ON 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[]{"ssr_distance","ssr_distance_help","ssr_distance_value","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);
}
}
check(net.minecraft.network.chat.Component.translatable("sanctuary.shaders.ssr").getString().equals("SSR"),"SSR label without experimental qualifier");
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){}
private static Delta difference(Frame off,Frame on){int changed=0,bright=0,dark=0;long total=0;for(int i=0;i<off.pixels().length;i++){
int a=off.pixels()[i],b=on.pixels()[i],delta=(b>>16&255)-(a>>16&255);total+=Math.abs(delta);if(Math.abs(delta)>3)changed++;if(delta>3)bright++;if(delta< -3)dark++;
}return new Delta(changed,bright,dark,total);}
private static void settle(ClientGameTestContext c){c.waitTicks(35);c.waitFor(m->m.levelRenderer.hasRenderedAllSections(),1200);c.waitTicks(10);}
private static void enable(ClientGameTestContext c,boolean on){c.runOnClient(m->VanillaLight.setPbr(on));if(on)frames(c);else c.waitTicks(8);}
private static void frames(ClientGameTestContext c){int start=c.computeOnClient(m->ProceduralPbr.renderedFrames());c.waitFor(m->ProceduralPbr.applied()&&ProceduralPbr.ready()&&ProceduralPbr.renderedFrames()>start+10,1200);c.waitTicks(8);}
}
@@ -0,0 +1,63 @@
package fr.koka.sanctuary.client.shader;
import static fr.koka.sanctuary.client.shader.PixelShadows122ClientChecks.*;
import net.fabricmc.fabric.api.client.gametest.v1.context.*;
import net.minecraft.core.BlockPos;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.item.*;
import net.minecraft.world.entity.EquipmentSlot;
final class PbrRefresh142ClientChecks {
static void refresh(ClientGameTestContext c,TestServerContext s,Frame reference){
int entries=c.computeOnClient(m->ProceduralPbr.terrainEntries()),builds=c.computeOnClient(m->ProceduralPbr.terrainBuilds());check(entries>0,"PBR fixture has visible sections");
for(int i=0;i<12;i++){
c.runOnClient(m->{for(var section:m.levelRenderer.visibleSections()){var p=section.getRenderOrigin();ProceduralPbr.dirtySection(p.getX()>>4,p.getY()>>4,p.getZ()>>4);}
check(ProceduralPbr.terrainEntries()==entries,"Invalidation retains every published PBR section");});
c.waitTicks(1);unchanged(reference,capture(c,null),"PBR remains visible throughout repeated refreshes");
}
ready(c);check(c.computeOnClient(m->ProceduralPbr.terrainBuilds())>builds,"Replacement meshes actually published");unchanged(reference,capture(c,"refresh-stable"),"Replacement preserves the rendered normals");
System.out.println("PBR142_REFRESH_PASS entries="+entries+" repeated invalidation, retained rendering, completed replacements");
}
static void held(ClientGameTestContext c,TestServerContext s){
ready(c);int builds=c.computeOnClient(m->ProceduralPbr.terrainBuilds());
for(var item:new Item[]{Items.SOUL_TORCH,Items.TORCH,Items.SOUL_TORCH,Items.TORCH}){
s.runOnServer(server->server.getPlayerList().getPlayers().getFirst().setItemSlot(EquipmentSlot.MAINHAND,new ItemStack(item)));c.waitTicks(25);
}
check(c.computeOnClient(m->ProceduralPbr.terrainBuilds())==builds,"Carried light updates do not rebuild the normal mesh");
System.out.println("PBR142_HELD_PASS native dynamic relighting preserves the PBR geometry cache");
}
static void cave(ClientGameTestContext c,TestServerContext s){
c.runOnClient(m->{VanillaLight.setColoredLights(true);VanillaLight.setPbr(false);VanillaLight.setPbrIntensity(80);VanillaLight.setColoredLightDistance(16);});
camera(c,s,0.5,65,2.5,70);look(c,s,0,-12);c.waitTicks(30);
s.runOnServer(server->{for(int x=-5;x<=5;x++)for(int z=-1;z<=18;z++)for(int y=64;y<=70;y++)
server.overworld().setBlockAndUpdate(new BlockPos(x,y,z),(x==-5||x==5||y==64||y==70||z==-1||z==18)?Blocks.DEEPSLATE.defaultBlockState():Blocks.AIR.defaultBlockState());
for(int y=65;y<=69;y++)server.overworld().setBlockAndUpdate(new BlockPos(-2,y,9),Blocks.DEEPSLATE.defaultBlockState());
for(int z=5;z<14;z++)server.overworld().setBlockAndUpdate(new BlockPos(3,67,z),Blocks.DEEPSLATE.defaultBlockState());
server.overworld().setBlockAndUpdate(new BlockPos(3,68,8),Blocks.WATER.defaultBlockState());
server.getPlayerList().getPlayers().getFirst().setItemSlot(EquipmentSlot.MAINHAND,new ItemStack(Items.TORCH));
});c.waitTicks(100);var off=capture(c,"cave-pbr-off");c.runOnClient(m->VanillaLight.setPbr(true));ready(c);var on=capture(c,"cave-pbr-80");
check(specks(on)<=specks(off)+4,"PBR does not introduce isolated white points in the cave");
System.out.println("PBR142_CAVE_PIXELS off="+specks(off)+" on="+specks(on));
// Capture while the camera is actually travelling, before settling caches.
s.runOnServer(server->{var player=server.getPlayerList().getPlayers().getFirst();player.getAbilities().flying=true;player.onUpdateAbilities();});c.waitTicks(10);
var start=c.computeOnClient(m->m.player.position());
c.getInput().holdKey(o->o.keyUp);
int maximumSpecks=0;
try {for(int frame=0;frame<24;frame++){
c.waitTicks(1);c.runOnClient(m->check(ColoredLights.dynamicCount()>0&&ProceduralPbr.applied(),"Carried light and PBR remain active during motion"));var moving=capture(c,frame%8==0?"cave-moving-"+frame:null);
maximumSpecks=Math.max(maximumSpecks,specks(moving));
}} finally {c.getInput().releaseKey(o->o.keyUp);}
check(c.computeOnClient(m->m.player.position().distanceToSqr(start))>1.0,"Camera really travels at least one block");
check(maximumSpecks<=4,"Moving camera has no isolated white grain: "+maximumSpecks);
System.out.println("PBR142_MOVEMENT_PASS frames=24 maxSpecks="+maximumSpecks);
camera(c,s,0.5,65,2.5,70);
for(float pitch:new float[]{-30,0,35}){look(c,s,0,pitch);ready(c);var frame=capture(c,"cave-angle-"+(int)pitch);check(specks(frame)<=4,"Grazing-angle PBR has no white grain");System.out.println("PBR142_CAVE_ANGLE "+pitch+" specks="+specks(frame));}
c.runOnClient(m->{VanillaLight.setSsgi(true);VanillaLight.setBloom(true);VanillaLight.setPixelShadows(true);VanillaLight.setEdgeHighlights(true);VanillaLight.setSunshafts(true);});c.waitTicks(40);ready(c);capture(c,"cave-combined");
System.out.println("PBR142_PASS refresh, dynamic cache, defaults, cave and grazing-angle captures");
}
private static int specks(Frame f){int count=0,w=f.width();for(int y=2;y<f.height()-2;y++)for(int x=2;x<w-2;x++){
int i=y*w+x,p=f.pixels()[i];int value=Math.min(p>>16&255,Math.min(p>>8&255,p&255));if(value<170)continue;
int dark=0;for(int dy=-1;dy<=1;dy++)for(int dx=-1;dx<=1;dx++){if(dx==0&&dy==0)continue;int q=f.pixels()[i+dy*w+dx];if(Math.max(q>>16&255,Math.max(q>>8&255,q&255))<120)dark++;}if(dark>=5)count++;
}return count;}
private static void ready(ClientGameTestContext c){c.waitTicks(14);c.waitFor(m->ProceduralPbr.ready(),2400);c.waitTicks(8);}
}
@@ -0,0 +1,265 @@
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,256,32}){
c.runOnClient(m->{VanillaLight.setPbrDistance(distance);VanillaLight.setSsrDistance(256);});settle(c);frames(c);capture(c,"ssr146-distance-"+distance);
c.runOnClient(m->check(ProceduralPbr.applied()&&PixelReflections.applied(),"Independent material distances render at PBR "+distance+" / SSR 256"));
}
for(int distance:new int[]{16,64,256}){
c.runOnClient(m->{VanillaLight.setPbrDistance(256);VanillaLight.setSsrDistance(distance);});settle(c);frames(c);
capture(c,"ssr149-independent-"+distance);
c.runOnClient(m->check(ProceduralPbr.applied()&&PixelReflections.applied(),"PBR 256 remains active with independent SSR "+distance));
}
c.runOnClient(m->VanillaLight.setPbrDistance(32));
c.runOnClient(m->VanillaLight.setSsrDistance(32));
c.runOnClient(m->{VanillaLight.setPbrIntensity(50);VanillaLight.setSsrIntensity(20);});
camera(c,server,.5,68,10,70);settle(c);frames(c);
c.runOnClient(m->VanillaLight.setPbr(false));c.waitTicks(8);var solarOff=capture(c,"ssr148-solar-water-off");
c.runOnClient(m->VanillaLight.setPbr(true));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++;
int brightened=0;for(int i=0;i<solar.pixels().length;i++){
int a=solarOff.pixels()[i],b=solar.pixels()[i];
if((b>>16&255)+(b>>8&255)+(b&255)>(a>>16&255)+(a>>8&255)+(a&255)+24)brightened++;
}
check(brightened>5000,"Soft solar reflection remains broadly visible: "+brightened);
check(white<solar.pixels().length*.005,"Soft solar reflection preserves texture instead of a white plateau: "+white);
System.out.println("PBR148_SOLAR brightened="+brightened+" white="+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 18000"));c.waitTicks(15);
c.runOnClient(m->VanillaLight.setSsr(false));c.waitTicks(8);
var moon=capture(c,"ssr147-moon-water");
int moonWhite=0;for(int pixel:moon.pixels())if((pixel>>16&255)>235&&(pixel>>8&255)>235&&(pixel&255)>235)moonWhite++;
check(moonWhite<Math.max(10,white*.05),"Moon reflection stays much softer than the sun: "+moonWhite+" / "+white);
c.runOnClient(m->VanillaLight.setPbr(false));c.waitTicks(8);var moonOff=capture(c,"ssr147-moon-water-off");
check(changed(moonOff,moon)>100,"Soft moon reflection remains visible");
System.out.println("PBR147_MOON white="+moonWhite+" sunWhite="+white+" changed="+changed(moonOff,moon));
c.runOnClient(m->{VanillaLight.setPbr(true);VanillaLight.setSsr(true);VanillaLight.setBloom(true);});frames(c);capture(c,"ssr147-moon-water-bloom");
c.runOnClient(m->VanillaLight.setBloom(false));
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");
for(int time:new int[]{2000,11500,12400}){
c.runOnClient(m->{VanillaLight.setBloom(false);VanillaLight.setSsr(false);});
server.runOnServer(s->command(s,"time set "+time));c.waitTicks(15);
var solarLook=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,solarLook[0],solarLook[1]);pbrFrames(c);
var warm=capture(c,"ssr150-solar-"+time);
c.runOnClient(m->VanillaLight.setPbr(false));c.waitTicks(8);var nativeWater=capture(c,"ssr150-solar-native-"+time);
long red=0,green=0,blue=0;int samples=0;
for(int y=warm.height()/3;y<warm.height()*9/10;y++)for(int x=warm.width()/3;x<warm.width()*2/3;x++){
int i=y*warm.width()+x,a=nativeWater.pixels()[i],b=warm.pixels()[i];
int dr=(b>>16&255)-(a>>16&255),dg=(b>>8&255)-(a>>8&255),db=(b&255)-(a&255);
if(dr>3){red+=dr;green+=dg;blue+=db;samples++;}
}
System.out.println("PBR150_SOLAR_COLOUR time="+time+" pitch="+solarLook[1]+" samples="+samples+" red="+red+" green="+green+" blue="+blue);
var tint=c.computeOnClient(m->SolarColour.tint(m.gameRenderer.gameRenderState().levelRenderState.skyRenderState));
float nativeFade=c.computeOnClient(m->m.gameRenderer.gameRenderState().levelRenderState.skyRenderState.sunriseAndSunsetColor.w());
System.out.println("PBR150_NATIVE_SUNSET time="+time+" fade="+nativeFade+" tint="+tint);
check(samples>500,"Solar reflection remains visible: "+time);
if(nativeFade>.1)check(red>green*1.1&&red>blue*1.1,"Reflection follows warm native horizon colour: "+time);
if(nativeFade==0)check(tint.equals(new org.joml.Vector3f(1)),"No early tint before native sunset");
c.runOnClient(m->{VanillaLight.setPbr(true);VanillaLight.setSsr(true);VanillaLight.setBloom(true);});frames(c);capture(c,"ssr150-solar-bloom-"+time);
c.runOnClient(m->VanillaLight.setBloom(false));
look(c,server,solarLook[0],-solarLook[1]);frames(c);var sunDisc=capture(c,"ssr150-sun-disc-"+time);
long sr=0,sg=0,sb=0;int sunSamples=0;
for(int y=sunDisc.height()/2-5;y<sunDisc.height()/2+5;y++)for(int x=sunDisc.width()/2-5;x<sunDisc.width()/2+5;x++){
int pixel=sunDisc.pixels()[y*sunDisc.width()+x];sr+=pixel>>16&255;sg+=pixel>>8&255;sb+=pixel&255;sunSamples++;
}
System.out.println("PBR150_SUN_DISC time="+time+" red="+sr/sunSamples+" green="+sg/sunSamples+" blue="+sb/sunSamples);
if(nativeFade>.1)check(sr>sb*1.05,"Sun disc follows native warm tint: "+time);
look(c,server,solarLook[0],solarLook[1]);
}
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 pbrFrames(ClientGameTestContext c){int start=c.computeOnClient(m->ProceduralPbr.renderedFrames());c.waitFor(m->ProceduralPbr.applied()&&ProceduralPbr.renderedFrames()>start+8,1200);}
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);}
}
@@ -36,9 +36,14 @@ import net.minecraft.world.phys.Vec3;
/** Real terrain/depth-map checks on a new flat seed 122 world; GPU readback is test-only. */
public final class PixelShadows122ClientChecks implements FabricClientGameTest {
private final boolean bloomOnly;
private final boolean bloomOnly,ssgiOnly,pbrOnly,closeupOnly,sunshaftOnly,coloredOnly;
public PixelShadows122ClientChecks(){this(false);}
PixelShadows122ClientChecks(boolean bloomOnly){this.bloomOnly=bloomOnly;}
PixelShadows122ClientChecks(boolean bloomOnly){this(bloomOnly,false);}
PixelShadows122ClientChecks(boolean bloomOnly,boolean ssgiOnly){this(bloomOnly,ssgiOnly,false);}
PixelShadows122ClientChecks(boolean bloomOnly,boolean ssgiOnly,boolean pbrOnly){this(bloomOnly,ssgiOnly,pbrOnly,false);}
PixelShadows122ClientChecks(boolean bloomOnly,boolean ssgiOnly,boolean pbrOnly,boolean closeupOnly){this(bloomOnly,ssgiOnly,pbrOnly,closeupOnly,false);}
PixelShadows122ClientChecks(boolean bloomOnly,boolean ssgiOnly,boolean pbrOnly,boolean closeupOnly,boolean sunshaftOnly){this(bloomOnly,ssgiOnly,pbrOnly,closeupOnly,sunshaftOnly,false);}
PixelShadows122ClientChecks(boolean bloomOnly,boolean ssgiOnly,boolean pbrOnly,boolean closeupOnly,boolean sunshaftOnly,boolean coloredOnly){this.coloredOnly=coloredOnly;this.sunshaftOnly=sunshaftOnly;this.bloomOnly=bloomOnly;this.ssgiOnly=ssgiOnly;this.pbrOnly=pbrOnly;this.closeupOnly=closeupOnly;}
private static final int FLOOR = 64;
private static final AABB PILLAR = new AABB(0, FLOOR + 1, 0, 2, FLOOR + 9, 2);
private static final BlockPos SLAB = new BlockPos(5, FLOOR + 3, -5);
@@ -47,6 +52,15 @@ public final class PixelShadows122ClientChecks implements FabricClientGameTest {
@Override
public void runTest(ClientGameTestContext c) {
c.runOnClient(m -> {
var info = com.mojang.blaze3d.systems.RenderSystem.getDevice().getDeviceInfo();
String expected = System.getProperty("sanctuary.test.graphicsBackend", "vulkan");
check(net.minecraft.client.PreferredGraphicsApi.DEFAULT.getBackendsToTry()[0] instanceof com.mojang.renderpearl.backend.vulkan.VulkanBackend, "Default tries native Vulkan first");
check(expected.isEmpty() || info.backendName().equalsIgnoreCase(expected),
"Requested graphics backend must not silently fall back: " + expected + " / " + info.backendName());
System.out.println("SHADER139_BACKEND " + info.backendName() + " / " + info.name()
+ " / " + info.driverInfo() + " / zeroToOne=" + info.isZZeroToOne());
});
var path = FabricLoader.getInstance().getConfigDir().resolve("sanctuary-shaders.json");
final byte[] originalPreferences;
try {
@@ -78,6 +92,9 @@ public final class PixelShadows122ClientChecks implements FabricClientGameTest {
m.options.bobView().set(false);
m.options.pauseOnLostFocus = false;
m.options.setCameraType(CameraType.FIRST_PERSON);
String transparency = System.getProperty("sanctuary.test.improvedTransparency");
if (transparency != null) m.options.improvedTransparency().set(Boolean.parseBoolean(transparency));
System.out.println("SHADER139_TRANSPARENCY " + m.options.improvedTransparency().get());
checkPreferences();
});
try (var world = c.worldBuilder().adjustSettings(s -> {
@@ -133,6 +150,13 @@ 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;}
// Keep VanillaLight enabled for both frames: changing its fog/grade would invalidate this oracle.
shadows(c, false);
@@ -149,7 +173,7 @@ public final class PixelShadows122ClientChecks implements FabricClientGameTest {
check(PixelShadows.shadowMap() != null, "Daylight allocates a real shadow target");
check(PixelShadows.shadowMap().width == PixelShadows.mapSize(32, 16), "Shadow map follows selected quality");
});
for (int pixels : new int[]{8, 32, 16}) {
for (int pixels : new int[]{8, 32, 64, 128, 16}) {
c.runOnClient(m -> VanillaLight.setShadowPixels(pixels));
activeFrames(c);
c.runOnClient(m -> check(PixelShadows.shadowMap().width == PixelShadows.mapSize(32, pixels),
@@ -241,12 +265,14 @@ public final class PixelShadows122ClientChecks implements FabricClientGameTest {
RealtimeShadows129ClientChecks.run(c, server);
EdgeHighlights129ClientChecks.run(c, server);
Bloom130ClientChecks.run(c, server);
Ssgi132ClientChecks.run(c, server);
Pbr134ClientChecks.run(c, server);
c.getInput().pressKey(InputConstants.KEY_F1);
for (String locale : new String[]{"fr_fr", "en_us"}) {
c.runOnClient(m -> {
m.getLanguageManager().setSelected(locale);
m.getLanguageManager().onResourceManagerReload(m.getResourceManager());
for (String key : new String[]{"pixel_shadows", "shadow_pixels", "shadow_distance", "edge_highlights", "edge_intensity", "edge_distance", "precision", "bloom", "bloom_radius", "ore_emission"}) {
for (String key : new String[]{"pixel_shadows", "shadow_pixels", "shadow_distance", "edge_highlights", "edge_intensity", "edge_distance", "precision", "ssgi", "ssgi_intensity", "bloom", "bloom_radius", "ore_emission"}) {
String fullKey = "sanctuary.shaders." + key;
check(!Component.translatable(fullKey).getString().equals(fullKey), "Translated " + locale + " " + key);
}
@@ -258,7 +284,7 @@ public final class PixelShadows122ClientChecks implements FabricClientGameTest {
});
});
c.waitTicks(3);
c.takeScreenshot("sanctuary-beta130-shader-options-" + locale);
c.takeScreenshot("sanctuary-beta142-shader-options-" + locale);
}
c.runOnClient(m -> m.gui.setScreen(null));
}
@@ -422,12 +448,32 @@ public final class PixelShadows122ClientChecks implements FabricClientGameTest {
? java.util.stream.Stream.concat(java.util.stream.Stream.of(widget), widgets(child)) : java.util.stream.Stream.of(widget));
}
private static void checkDefaults135() {
try {
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()==50&&VanillaLight.ssrIntensity()==20&&VanillaLight.ssgiIntensity()==35,"PBR 50 and SSR 20 defaults, 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();
check(!VanillaLight.bloom()&&VanillaLight.bloomIntensity()==20&&VanillaLight.pbr()&&VanillaLight.ssgi(),"Missing fields get new defaults without replacing explicit values");
Files.writeString(path,"{\"pbrIntensity\":50}");forgetPreferences();check(VanillaLight.pbrIntensity()==50,"Explicit PBR 50 is preserved");
System.out.println("SHADER135_DEFAULTS_PASS fresh, partial and personal preferences");
} catch(IOException e){throw new AssertionError(e);}
}
private static void checkPreferences() {
checkDefaults135();
VanillaLight.setSunshafts(false);VanillaLight.setLensFlare(false);
VanillaLight.setPbr(false);
VanillaLight.setSsgi(false);
VanillaLight.setBloom(false);
VanillaLight.setOreEmission(false);
VanillaLight.setEnabled(true);
VanillaLight.setEdgeHighlights(false);
VanillaLight.setSaturation(100);
for (int[] pair : new int[][]{{Integer.MIN_VALUE, 8}, {11, 8}, {12, 16}, {16, 16}, {23, 16}, {24, 32}, {Integer.MAX_VALUE, 32}}) {
for (int[] pair : new int[][]{{Integer.MIN_VALUE, 8}, {11, 8}, {12, 16}, {16, 16}, {23, 16}, {24, 32}, {47,32}, {48,64}, {64,64}, {95,64}, {96,128}, {128,128}, {Integer.MAX_VALUE,128}}) {
VanillaLight.setShadowPixels(pair[0]);
forgetPreferences();
check(VanillaLight.shadowPixels() == pair[1], "Persisted pixel density rounds/clamps " + pair[0]);
@@ -478,7 +524,7 @@ public final class PixelShadows122ClientChecks implements FabricClientGameTest {
static void look(ClientGameTestContext c, TestServerContext server, float yaw, float pitch) {
server.runOnServer(s -> {
var p = s.getPlayerList().getPlayers().getFirst();
command(s, "tp @a " + p.getX() + " " + p.getY() + " " + p.getZ() + " " + yaw + " " + pitch);
command(s, "tp @a " + p.getX() + " " + p.getY() + " " + p.getZ() + String.format(java.util.Locale.ROOT," %.8f %.8f",yaw,pitch));
});
c.waitFor(m -> Math.abs(m.player.getXRot() - pitch) < .00001
&& Math.abs(m.player.getYRot() - yaw) < .00001, 200);
@@ -523,7 +569,7 @@ public final class PixelShadows122ClientChecks implements FabricClientGameTest {
}
static Frame capture(ClientGameTestContext c, String name) {
if (name != null) c.takeScreenshot("sanctuary-beta130-shader-" + name);
if (name != null) c.takeScreenshot((Boolean.getBoolean("sanctuary.test.water144")?"sanctuary-beta144-water-":"sanctuary-beta142-shader-") + name);
var result = new CompletableFuture<Frame>();
c.runOnClient(m -> Screenshot.takeScreenshot(m.gameRenderer.mainRenderTarget(), image -> {
try (image) {
@@ -624,11 +670,11 @@ public final class PixelShadows122ClientChecks implements FabricClientGameTest {
}
private record Options(int distance, int simulationDistance, int fov, boolean bob,
boolean pause, CameraType camera, String language) {
boolean pause, CameraType camera, String language, boolean improvedTransparency) {
static Options capture(Minecraft m) {
return new Options(m.options.renderDistance().get(), m.options.simulationDistance().get(), m.options.fov().get(),
m.options.bobView().get(), m.options.pauseOnLostFocus,
m.options.getCameraType(), m.getLanguageManager().getSelected());
m.options.getCameraType(), m.getLanguageManager().getSelected(), m.options.improvedTransparency().get());
}
void restore(Minecraft m) {
@@ -638,6 +684,7 @@ public final class PixelShadows122ClientChecks implements FabricClientGameTest {
m.options.bobView().set(bob);
m.options.pauseOnLostFocus = pause;
m.options.setCameraType(camera);
m.options.improvedTransparency().set(improvedTransparency);
m.getLanguageManager().setSelected(language);
m.getLanguageManager().onResourceManagerReload(m.getResourceManager());
}
@@ -0,0 +1,126 @@
package fr.koka.sanctuary.client.shader;
import static fr.koka.sanctuary.client.shader.PixelShadows122ClientChecks.*;
import net.fabricmc.fabric.api.client.gametest.v1.FabricClientGameTest;
import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext;
import net.fabricmc.fabric.api.client.gametest.v1.context.TestServerContext;
import net.fabricmc.loader.api.FabricLoader;
import net.minecraft.core.BlockPos;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.block.state.BlockState;
import java.nio.file.Files;
public final class Ssgi132ClientChecks implements FabricClientGameTest {
public void runTest(ClientGameTestContext c){new PixelShadows122ClientChecks(false,true).runTest(c);}
static void run(ClientGameTestContext c,TestServerContext server){
c.runOnClient(m->{preferences();VanillaLight.setSunshafts(false);VanillaLight.setLensFlare(false);VanillaLight.setPbr(false);VanillaLight.setEnabled(true);VanillaLight.setSaturation(100);VanillaLight.setColoredLights(false);VanillaLight.setSsgi(false);VanillaLight.setBloom(false);VanillaLight.setOreEmission(false);VanillaLight.setPixelShadows(false);VanillaLight.setEdgeHighlights(false);});
server.runOnServer(s->{command(s,"time set 6000");for(int x=-5;x<=24;x++)for(int z=-5;z<=24;z++){
s.overworld().setBlockAndUpdate(new BlockPos(x,64,z),Blocks.CONCRETE.white().defaultBlockState());
for(int y=65;y<=102;y++)s.overworld().setBlockAndUpdate(new BlockPos(x,y,z),Blocks.AIR.defaultBlockState());
}});
camera(c,server,10.5,69,5.5,65);look(c,server,0,40);settle(c);
var flatOff=capture(c,"ssgi-flat-off");enable(c,true);unchanged(flatOff,capture(c,"ssgi-flat-on"),"Coplanar ground does not bounce light onto itself");
wall(server,12,Blocks.CONCRETE.red().defaultBlockState());settle(c);enable(c,false);var redOff=capture(c,"ssgi-red-off");
c.runOnClient(m->VanillaLight.setSsgiIntensity(35));
enable(c,true);var defaultRed=measure(redOff,capture(c,"ssgi-red-default-35"));
System.out.println("SSGI133_DEFAULT "+defaultRed);
check(defaultRed.visible>5000&&defaultRed.strong>500,"Default intensity produces 8/255 gain over 5000 pixels and 16/255 over 500 pixels: "+defaultRed);
c.runOnClient(m->VanillaLight.setSsgiIntensity(90));frames(c);
var redOn=capture(c,"ssgi-red-on");var red=measure(redOff,redOn);System.out.println("SSGI133_RED "+red);
check(red.changed>30&&red.red>red.blue*1.5+20,"Visible red wall tints neutral ground: "+red);
unchanged(redOn,capture(c,null),"Stationary SSGI has no animated noise or accumulating feedback");
look(c,server,0.00002f,40.00002f);frames(c);unchanged(redOn,capture(c,null),"Subpixel motion keeps actual red bounce stable");
look(c,server,5,40);frames(c);capture(c,"ssgi-camera-turned");
look(c,server,0,40);frames(c);unchanged(redOn,capture(c,null),"Returning the camera leaves no temporal color trails");
c.runOnClient(m->VanillaLight.setSsgiIntensity(20));frames(c);var low=measure(redOff,capture(c,"ssgi-strength-20"));
check(red.red>low.red*1.5,"Intensity adjusts native color bounce: "+red+" / "+low);
c.runOnClient(m->VanillaLight.setSsgiIntensity(0));c.waitTicks(6);unchanged(redOff,capture(c,null),"Zero intensity restores source image");
c.runOnClient(m->check(!ScreenSpaceGI.allocated()&&!ScreenSpaceGI.applied(),"Zero frees SSGI targets"));
c.runOnClient(m->VanillaLight.setSsgiIntensity(90));frames(c);
int oldWidth=c.computeOnClient(m->m.getWindow().getScreenWidth()),oldHeight=c.computeOnClient(m->m.getWindow().getScreenHeight());
c.runOnClient(m->m.getWindow().setWindowed(961,541));
c.waitFor(m->m.gameRenderer.mainRenderTarget().width!=redOn.width(),200);frames(c);
enable(c,false);var resizedOff=capture(c,null);enable(c,true);var resized=measure(resizedOff,capture(c,"ssgi-resized"));
check(resized.changed>30&&resized.red>resized.blue*1.5+20,"Resizing reallocates a working SSGI pass: "+resized);
c.runOnClient(m->m.getWindow().setWindowed(oldWidth,oldHeight));
c.waitFor(m->m.gameRenderer.mainRenderTarget().width==redOn.width(),200);frames(c);
wall(server,12,Blocks.CONCRETE.blue().defaultBlockState());settle(c);enable(c,false);var blueOff=capture(c,"ssgi-blue-off");enable(c,true);var blue=measure(blueOff,capture(c,"ssgi-blue-on"));
c.runOnClient(m->VanillaLight.setSsgiIntensity(35));frames(c);
var defaultBlue=measure(blueOff,capture(c,"ssgi-blue-default-35"));
check(defaultBlue.visible>5000&&defaultBlue.strong>500&&defaultBlue.blue>defaultBlue.red*1.5+20,"Default blue bounce is visible too: "+defaultBlue);
c.runOnClient(m->VanillaLight.setSsgiIntensity(90));frames(c);
System.out.println("SSGI133_BLUE "+blue+" default="+defaultBlue);check(blue.changed>30&&blue.blue>blue.red*1.5+20,"Live blue wall changes bounced hue: "+blue);
wall(server,11,Blocks.CONCRETE.white().defaultBlockState());settle(c);enable(c,false);var coveredOff=capture(c,null);enable(c,true);var covered=capture(c,"ssgi-covered-blue");
wall(server,12,Blocks.CONCRETE.red().defaultBlockState());settle(c);unchanged(covered,capture(c,"ssgi-covered-red"),"Hidden wall color cannot pass through foreground wall");
var reload=c.computeOnClient(net.minecraft.client.Minecraft::reloadResourcePacks);c.waitFor(m->reload.isDone()&&m.gui.overlay()==null,1200);reload.join();frames(c);unchanged(covered,capture(c,"ssgi-reloaded"),"Resource reload restores SSGI");
look(c,server,0.00002f,40.00002f);frames(c);unchanged(covered,capture(c,null),"Subpixel camera movement keeps bounce stable");
look(c,server,0,40);frames(c);enable(c,false);unchanged(coveredOff,capture(c,null),"OFF leaves no residual tint");
c.runOnClient(m->check(!ScreenSpaceGI.allocated(),"OFF frees SSGI resources"));
c.runOnClient(m->{VanillaLight.setSsgiIntensity(35);VanillaLight.setPixelShadows(true);VanillaLight.setEdgeHighlights(true);VanillaLight.setBloom(true);VanillaLight.setOreEmission(true);});
enable(c,true);c.waitFor(m->PixelShadows.applied()&&PixelShadows.ready()&&EdgeHighlights.applied()&&Bloom.applied(),1200);
capture(c,"ssgi-combined-shader");
c.runOnClient(m->VanillaLight.setEnabled(false));c.waitTicks(8);
c.runOnClient(m->check(!ScreenSpaceGI.allocated(),"Main shader OFF releases SSGI too"));
c.runOnClient(m->VanillaLight.setEnabled(true));frames(c);enable(c,false);
motion(c,server);
SsgiCloseup135ClientChecks.run(c,server);
System.out.println("SSGI133_PASS red/blue diffuse bounce; intensity; flat rejection; foreground occlusion; static stability; tiny camera motion; resize; reload; combined shader; OFF/zero; preferences; red="+red+" blue="+blue);
}
private static void motion(ClientGameTestContext c,TestServerContext server){
c.runOnClient(m->{VanillaLight.setPbr(false);VanillaLight.setSsgi(false);VanillaLight.setBloom(false);VanillaLight.setOreEmission(false);VanillaLight.setPixelShadows(false);VanillaLight.setEdgeHighlights(false);});
wall(server,11,Blocks.AIR.defaultBlockState());
server.runOnServer(s->{
command(s,"summon minecraft:cow 8.5 65 9.5 {NoAI:1b,Silent:1b,NoGravity:1b,Invulnerable:1b,Tags:[\"ssgi134_test\"]}");
for(int x=11;x<=13;x++)for(int z=8;z<=10;z++){
s.overworld().setBlockAndUpdate(new BlockPos(x,64,z),Blocks.GRASS_BLOCK.defaultBlockState());
s.overworld().setBlockAndUpdate(new BlockPos(x,65,z),Blocks.SHORT_GRASS.defaultBlockState());
}
});settle(c);
for(int intensity:new int[]{35,100}){
c.runOnClient(m->VanillaLight.setSsgiIntensity(intensity));
Frame start=null;double worstGrass=0;
for(int step=0;step<=12;step++){
double x=4.5+step;camera(c,server,x,66.5,5.5,65);
look(c,server,(float)Math.toDegrees(Math.atan2(x-10.5,6.5)),10);
enable(c,false);var base=capture(c,"ssgi-motion-"+intensity+"-"+step+"-off");
enable(c,true);var frame=capture(c,"ssgi-motion-"+intensity+"-"+step+"-on");
double grass=grassGain(base,frame);worstGrass=Math.max(worstGrass,grass);
check(grass<(intensity==35?12:30),"Lateral view avoids excessive grass brightening at "+intensity+" / "+step+": "+grass);
if(step==0)start=frame;
}
System.out.println("SSGI134_GRASS intensity="+intensity+" maxMeanGreenGain="+worstGrass);
camera(c,server,4.5,66.5,5.5,65);look(c,server,(float)Math.toDegrees(Math.atan2(-6,6.5)),10);frames(c);
unchanged(start,capture(c,null),"Twelve-block lateral movement returns without stale SSGI history at "+intensity);
}
enable(c,false);c.runOnClient(m->VanillaLight.setSsgiIntensity(35));
server.runOnServer(s->command(s,"kill @e[tag=ssgi134_test]"));
System.out.println("SSGI134_MOTION_PASS 12-block lateral sweep, grass/cow/blocks, OFF/ON at35 and100, return without history");
}
private static double grassGain(Frame off,Frame on){
int count=0;long gain=0;
for(int i=0;i<off.pixels().length;i++){
int a=off.pixels()[i],r=a>>16&255,g=a>>8&255,b=a&255;
if(g>r*1.15&&g>b*1.3&&g>45){count++;gain+=Math.max(0,(on.pixels()[i]>>8&255)-g);}
}
check(count>500,"Motion fixture includes visible vegetation");return gain/(double)count;
}
private static void wall(TestServerContext server,int z,BlockState state){server.runOnServer(s->{for(int x=6;x<=15;x++)for(int y=65;y<=(z==11?72:69);y++)s.overworld().setBlockAndUpdate(new BlockPos(x,y,z),state);});}
private static void settle(ClientGameTestContext c){c.waitTicks(30);c.waitFor(m->m.levelRenderer.hasRenderedAllSections(),1200);c.waitTicks(8);}
private static void enable(ClientGameTestContext c,boolean enabled){c.runOnClient(m->VanillaLight.setSsgi(enabled));if(enabled)frames(c);else c.waitTicks(8);}
private static void frames(ClientGameTestContext c){int start=c.computeOnClient(m->ScreenSpaceGI.renderedFrames());c.waitFor(m->ScreenSpaceGI.applied()&&ScreenSpaceGI.renderedFrames()>start+10,1200);c.waitTicks(6);}
private static void preferences(){try{
var p=FabricLoader.getInstance().getConfigDir().resolve("sanctuary-shaders.json");Files.writeString(p,"{\"bloom\":false,\"shadowPixels\":128}");forgetPreferences();
check(VanillaLight.ssgi()&&VanillaLight.ssgiIntensity()==35&&!VanillaLight.bloom()&&VanillaLight.shadowPixels()==128,"Legacy choices preserved; missing SSGI setting defaults ON at 35");
for(int i:new int[]{-10,0,35,90,140}){VanillaLight.setSsgiIntensity(i);VanillaLight.setSsgi(true);forgetPreferences();check(VanillaLight.ssgi()&&VanillaLight.ssgiIntensity()==Math.clamp(i,0,100),"SSGI persists and clamps");}
VanillaLight.setSsgiIntensity(90);
}catch(Exception e){throw new AssertionError(e);}}
private record Gain(int changed,int visible,int strong,double red,double green,double blue){}
private static Gain measure(Frame off,Frame on){int count=0,visible=0,strong=0;double r=0,g=0,b=0;for(int i=0;i<off.pixels().length;i++){
int a=off.pixels()[i],ar=a>>16&255,ag=a>>8&255,ab=a&255;
if(Math.max(ar,Math.max(ag,ab))-Math.min(ar,Math.min(ag,ab))>25||ar<60||ar>245)continue;
int z=on.pixels()[i];int dr=(z>>16&255)-ar,dg=(z>>8&255)-ag,db=(z&255)-ab;
if(Math.max(dr,Math.max(dg,db))>=16)strong++;
if(Math.max(dr,Math.max(dg,db))>=8)visible++;
if(Math.max(dr,Math.max(dg,db))>1)count++;r+=Math.max(0,dr);g+=Math.max(0,dg);b+=Math.max(0,db);
}return new Gain(count,visible,strong,r,g,b);}
}
@@ -0,0 +1,78 @@
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 low-angle and near-plane regression fixture; never opens a personal world. */
public final class SsgiCloseup135ClientChecks implements net.fabricmc.fabric.api.client.gametest.v1.FabricClientGameTest {
public void runTest(ClientGameTestContext c){new PixelShadows122ClientChecks(false,false,false,true).runTest(c);}
static void run(ClientGameTestContext c,TestServerContext server){
c.runOnClient(m->{VanillaLight.setEnabled(true);VanillaLight.setSaturation(100);VanillaLight.setColoredLights(false);VanillaLight.setSsgi(false);VanillaLight.setPbr(false);VanillaLight.setBloom(false);VanillaLight.setOreEmission(false);VanillaLight.setPixelShadows(false);VanillaLight.setEdgeHighlights(false);});
server.runOnServer(s->{
// Minecraft 26.3 rejects setting a clock to its existing value.
command(s,"time add 1");command(s,"time set 6000");
for(int x=-5;x<=24;x++)for(int z=-5;z<=24;z++){
// Contain lava: no flow below the platform into the following PBR fixture.
s.overworld().setBlockAndUpdate(new BlockPos(x,63,z),Blocks.STONE.defaultBlockState());
for(int y=64;y<=102;y++)s.overworld().setBlockAndUpdate(new BlockPos(x,y,z),y==64?Blocks.STONE.defaultBlockState():Blocks.AIR.defaultBlockState());
if(x>=5&&x<=16&&z>=4&&z<=14)s.overworld().setBlockAndUpdate(new BlockPos(x,64,z),Blocks.LAVA.defaultBlockState());
if(x>=5&&x<=16&&z>=8&&z<=14)s.overworld().setBlockAndUpdate(new BlockPos(x,68,z),Blocks.STONE.defaultBlockState());
if(x>=5&&x<=16&&z==12)for(int y=65;y<=67;y++)s.overworld().setBlockAndUpdate(new BlockPos(x,y,z),Blocks.CONCRETE.red().defaultBlockState());
}
});c.waitTicks(40);c.waitFor(m->m.levelRenderer.hasRenderedAllSections(),1200);
for(int scene=0;scene<2;scene++)for(int intensity:new int[]{35,100}){
c.runOnClient(m->VanillaLight.setSsgiIntensity(intensity));
for(int step=0;step<7;step++){
// Eye approaches the underside, crossing the old fixed normal-area threshold.
double gap=new double[]{2.0,1.0,.65,.4,.2,.1,.04}[step];
camera(c,server,10.5,68-gap-1.62,scene==0?9.5:11.5,65);look(c,server,0,-25);
c.runOnClient(m->VanillaLight.setSsgi(false));c.waitTicks(8);
var off=capture(c,"closeup-"+scene+"-"+intensity+"-"+step+"-off");
c.runOnClient(m->VanillaLight.setSsgi(true));c.waitFor(m->ScreenSpaceGI.applied(),1200);c.waitTicks(12);
var on=capture(c,"closeup-"+scene+"-"+intensity+"-"+step+"-on");
var stats=rowJump(off,on);
System.out.println("SSGI135_CLOSEUP scene="+scene+" intensity="+intensity+" gap="+gap+" "+stats);
if(scene==0&&gap<=.1)check(stats.jump<2&&stats.gain<.1,"Near-plane bounce fades without a horizontal bright strip: "+stats);
if(scene==1&&gap==.4)check(stats.rows>30&&stats.gain>1,"Close corner retains actual bounce instead of rejecting small world pixels: "+stats);
if(gap==.04)check(stats.gain<.05,"At contact, indirect light fades to the source image: "+stats);
}
}
int width=c.computeOnClient(m->m.getWindow().getScreenWidth()),height=c.computeOnClient(m->m.getWindow().getScreenHeight());
camera(c,server,10.5,65.98,11.5,65);look(c,server,0,-25);
c.runOnClient(m->{VanillaLight.setSsgiIntensity(100);m.getWindow().setWindowed(1280,720);});
c.waitFor(m->m.gameRenderer.mainRenderTarget().width!=width,200);c.waitTicks(15);
c.runOnClient(m->VanillaLight.setSsgi(false));c.waitTicks(8);var largeOff=capture(c,"closeup-large-off");
c.runOnClient(m->VanillaLight.setSsgi(true));c.waitTicks(15);var large=capture(c,"closeup-large-on");
var largeStats=rowJump(largeOff,large);check(largeStats.rows>30&&largeStats.gain>1,"High resolution with a visible source retains bounce: "+largeStats);
look(c,server,.00002f,-25.00002f);c.waitTicks(10);unchanged(large,capture(c,null),"Closeup has no subpixel normal threshold flicker");
look(c,server,0,-65);c.runOnClient(m->VanillaLight.setSsgi(false));c.waitTicks(8);var steepOff=capture(c,"closeup-offscreen-source-off");
c.runOnClient(m->VanillaLight.setSsgi(true));c.waitTicks(15);unchanged(steepOff,capture(c,"closeup-offscreen-source-on"),"Looking away from the source does not invent hidden light");
c.runOnClient(m->m.getWindow().setWindowed(width,height));c.waitFor(m->m.gameRenderer.mainRenderTarget().width==width,200);c.waitTicks(15);
c.runOnClient(m->{VanillaLight.setPbr(true);VanillaLight.setBloom(true);VanillaLight.setOreEmission(true);VanillaLight.setPixelShadows(true);VanillaLight.setEdgeHighlights(true);});
c.waitFor(m->ProceduralPbr.applied()&&ProceduralPbr.ready()&&Bloom.applied()&&PixelShadows.applied()&&PixelShadows.ready(),1200);
c.waitTicks(15);capture(c,"closeup-combined");
c.runOnClient(m->{VanillaLight.setSsgi(false);VanillaLight.setSsgiIntensity(35);VanillaLight.setPbr(false);});
System.out.println("SSGI135_CLOSEUP_PASS low-angle lava/stone and seven camera distances at 35/100");
}
private record Rows(double jump,double gain,int rows){}
private static Rows rowJump(Frame a,Frame b){
double previous=0,max=0,gain=0;int rows=0;
for(int y=a.height()/8;y<a.height()*7/8;y++){
double sum=0;int count=0;
for(int x=a.width()/4;x<a.width()*3/4;x++){
int i=y*a.width()+x,pa=a.pixels()[i],pb=b.pixels()[i];
int r=pa>>16&255,g=pa>>8&255,blue=pa&255;
if(Math.max(r,Math.max(g,blue))-Math.min(r,Math.min(g,blue))<12&&g>15){
sum+=Math.max(0,(pb>>16&255)-r);count++;
}
}
if(count<a.width()/8)continue;
double mean=sum/count;if(rows>0)max=Math.max(max,Math.abs(mean-previous));
previous=mean;gain+=mean;rows++;
}
return new Rows(max,gain/Math.max(rows,1),rows);
}
}
@@ -0,0 +1,39 @@
package fr.koka.sanctuary.client.shader;
import static fr.koka.sanctuary.client.shader.PixelShadows122ClientChecks.*;
import static fr.koka.sanctuary.client.shader.Sunshafts137ClientChecks.*;
import fr.koka.sanctuary.client.WeatherClient;
import fr.koka.sanctuary.weather.DailyWeather;
import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext;
import net.fabricmc.fabric.api.client.gametest.v1.context.TestServerContext;
/** Actual server weather -> native atmospheric fog -> shader scattering, same scene and manual strength. */
final class SunshaftWeather138ClientChecks {
static void run(ClientGameTestContext c,TestServerContext server){
check(Sunshafts.fogDensity(1024)==0&&Sunshafts.fogDensity(192)==0&&Sunshafts.fogDensity(80)==1,"Clear and full fog endpoints");
float previous=0;for(int end=192;end>=80;end--){float d=Sunshafts.fogDensity(end);check(d>=previous&&d-previous<.014,"Fog density is bounded, smooth and monotonic");previous=d;}
check(Sunshafts.fogDensity(Float.POSITIVE_INFINITY)==0&&Sunshafts.fogDensity(Float.NaN)==0,"Invalid fog distance cannot create NaN shafts");
c.runOnClient(m->{VanillaLight.setSunshafts(false);VanillaLight.setSunshaftIntensity(35);VanillaLight.setPixelShadows(false);VanillaLight.setBloom(false);VanillaLight.setLensFlare(false);VanillaLight.setOreEmission(false);VanillaLight.setPbr(false);VanillaLight.setSsgi(false);VanillaLight.setEdgeHighlights(false);});
roof(server,1);camera(c,server,100.5,101,110.5,70);aim(c,server);
profile(c,server,DailyWeather.Kind.CLEAR);enable(c,false);var clearOff=capture(c,"weather-shafts-clear-off");enable(c,true);var clear=capture(c,"weather-shafts-clear-35");double clearEnergy=energy(clearOff,clear);
server.runOnServer(s->command(s,"weather fog"));c.waitFor(m->WeatherClient.state().kind()==DailyWeather.Kind.FOG,200);
float[] transition=new float[12];for(int i=0;i<transition.length;i++){c.waitTicks(20);transition[i]=c.computeOnClient(m->Sunshafts.fogDensity(m.gameRenderer.gameRenderState().levelRenderState.cameraRenderState.fogData.environmentalEnd));if(i>0)check(transition[i]>=transition[i-1]-.002&&transition[i]-transition[i-1]<.4,"Live fog transition has no abrupt density jump");}
c.runOnClient(m->{check(m.gameRenderer.gameRenderState().levelRenderState.cameraRenderState.fogData.environmentalEnd<=80.01,"Fog command reaches actual native FogData");check(Sunshafts.applied(),"Atmospheric fog does not disable rays");});
enable(c,false);var fogOff=capture(c,"weather-shafts-fog-off");enable(c,true);var fog=capture(c,"weather-shafts-fog-35");double fogEnergy=energy(fogOff,fog);
check(fogEnergy>clearEnergy*1.7,"Fog visibly thickens scattering at the same intensity: "+clearEnergy+" -> "+fogEnergy);unchanged(fog,capture(c,null),"Stable fog adds no animated noise");
var reload=c.computeOnClient(net.minecraft.client.Minecraft::reloadResourcePacks);c.waitFor(m->reload.isDone()&&m.gui.overlay()==null,1200);reload.join();settle(c);unchanged(fog,capture(c,"weather-shafts-fog-reload"),"Weather coupling survives shader reload");
roof(server,0);settle(c);enable(c,false);var sealedOff=capture(c,null);enable(c,true);unchanged(sealedOff,capture(c,"weather-shafts-fog-sealed"),"Dense fog cannot illuminate a sealed room through walls");roof(server,1);settle(c);
profile(c,server,DailyWeather.Kind.RAIN);enable(c,false);var rainOff=capture(c,"weather-shafts-rain-off");enable(c,true);double rainEnergy=energy(rainOff,capture(c,"weather-shafts-rain-35"));check(rainEnergy>0&&rainEnergy<fogEnergy,"Rain stays visible but below thick fog: "+rainEnergy);
profile(c,server,DailyWeather.Kind.THUNDER);enable(c,false);var stormOff=capture(c,"weather-shafts-storm-off");enable(c,true);double stormEnergy=energy(stormOff,capture(c,"weather-shafts-storm-35"));check(stormEnergy<rainEnergy*.8,"Storm clouds reduce available sunlight: "+stormEnergy+" / "+rainEnergy);
profile(c,server,DailyWeather.Kind.CLEAR);enable(c,true);var restored=capture(c,"weather-shafts-restored-clear");unchanged(clear,restored,"Returning to clear restores the original rays without accumulated brightness");
c.runOnClient(m->check(VanillaLight.sunshaftIntensity()==35,"Weather never changes the player's intensity setting"));
c.runOnClient(m->VanillaLight.setSunshaftIntensity(0));c.waitTicks(10);c.runOnClient(m->check(!Sunshafts.allocated(),"Zero still frees atmospheric scattering"));
System.out.println("SUNSHAFT138_WEATHER_PASS clear="+clearEnergy+" fog="+fogEnergy+" rain="+rainEnergy+" storm="+stormEnergy+" smooth transitions, native fog, closed room, reload, restored clear and manual setting");
}
private static void profile(ClientGameTestContext c,TestServerContext server,DailyWeather.Kind kind){
server.runOnServer(s->command(s,"sanctuary weather "+kind.key()));
c.waitFor(m->WeatherClient.state().active()&&WeatherClient.state().kind()==kind,200);
c.waitFor(m->(kind.raining()?m.level.getRainLevel(1)>.999:m.level.getRainLevel(1)<.001)&&(kind.thundering()?m.level.getThunderLevel(1)>.999:m.level.getThunderLevel(1)<.001),600);
c.waitTicks(240);
}
}
@@ -0,0 +1,64 @@
package fr.koka.sanctuary.client.shader;
import static fr.koka.sanctuary.client.shader.PixelShadows122ClientChecks.*;
import java.nio.file.Files;
import net.fabricmc.fabric.api.client.gametest.v1.FabricClientGameTest;
import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext;
import net.fabricmc.fabric.api.client.gametest.v1.context.TestServerContext;
import net.fabricmc.loader.api.FabricLoader;
import net.minecraft.core.BlockPos;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.network.chat.Component;
import net.minecraft.util.Mth;
/** Native solar volume in a sealed room, a slotted roof and a leaf canopy. */
public final class Sunshafts137ClientChecks implements FabricClientGameTest {
public void runTest(ClientGameTestContext c){new PixelShadows122ClientChecks(false,false,false,false,true).runTest(c);}
static void run(ClientGameTestContext c,TestServerContext server){
c.runOnClient(m->{preferences();VanillaLight.setEnabled(true);VanillaLight.setSaturation(100);VanillaLight.setPbr(false);VanillaLight.setSsgi(false);VanillaLight.setBloom(false);VanillaLight.setOreEmission(false);VanillaLight.setLensFlare(false);VanillaLight.setEdgeHighlights(false);VanillaLight.setPixelShadows(false);VanillaLight.setSunshafts(false);VanillaLight.setShadowDistance(64);});
server.runOnServer(s->{command(s,"time add 1");command(s,"time set 3000");
for(int x=76;x<=124;x++)for(int z=76;z<=124;z++){
s.overworld().setBlockAndUpdate(new BlockPos(x,96,z),Blocks.STONE_BRICKS.defaultBlockState());
if(x==76||x==124||z==76||z==124)for(int y=97;y<=113;y++)s.overworld().setBlockAndUpdate(new BlockPos(x,y,z),Blocks.STONE_BRICKS.defaultBlockState());
}
});
roof(server,0);camera(c,server,100.5,101,110.5,70);aim(c,server);c.waitTicks(50);
var closedOff=capture(c,"shafts-closed-off");enable(c,true);var closed=capture(c,"shafts-closed-on");
check(gain(closedOff,closed)<300,"Sealed room receives no sunlight through opaque blocks: "+gain(closedOff,closed));
c.runOnClient(m->check(!PixelShadows.applied()&&PixelShadows.depthReady()&&!Bloom.allocated(),"Volume alone shares depth without surface shadows or bloom"));
roof(server,1);settle(c);enable(c,false);var off=capture(c,"shafts-slots-off");enable(c,true);var on=capture(c,"shafts-slots-35");long low=gain(off,on);
check(low>1000,"Roof openings form visible shafts: "+low);unchanged(on,capture(c,null),"Static shafts have no animated sampling noise");
c.runOnClient(m->VanillaLight.setSunshaftIntensity(100));settle(c);var strong=capture(c,"shafts-slots-100");check(gain(off,strong)>low*1.5,"Intensity increases volume energy");
camera(c,server,104.5,101,110.5,70);aim(c,server);settle(c);capture(c,"shafts-moved-four-blocks");
camera(c,server,100.5,101,110.5,70);aim(c,server);settle(c);unchanged(strong,capture(c,"shafts-returned"),"Moving and returning leaves no history or trailing light");
var reload=c.computeOnClient(net.minecraft.client.Minecraft::reloadResourcePacks);c.waitFor(m->reload.isDone()&&m.gui.overlay()==null,1200);reload.join();settle(c);unchanged(strong,capture(c,"shafts-reloaded"),"Volume recovers after resource reload");
int w=c.computeOnClient(m->m.getWindow().getScreenWidth()),h=c.computeOnClient(m->m.getWindow().getScreenHeight());
c.runOnClient(m->m.getWindow().setWindowed(961,541));c.waitFor(m->m.gameRenderer.mainRenderTarget().width!=off.width(),200);settle(c);
enable(c,false);var small=capture(c,null);enable(c,true);check(gain(small,capture(c,"shafts-resized"))>1000,"Bilateral volume survives odd dimensions");
c.runOnClient(m->m.getWindow().setWindowed(w,h));c.waitFor(m->m.gameRenderer.mainRenderTarget().width==off.width(),200);settle(c);
roof(server,2);settle(c);enable(c,false);var leafOff=capture(c,null);enable(c,true);var leafOn=capture(c,"shafts-leaves");long leaf=gain(leafOff,leafOn);double leafEnergy=energy(leafOff,leafOn);check(leaf>1000,"Leaf canopy transmits light: "+leaf);
roof(server,3);settle(c);enable(c,false);var openOff=capture(c,null);enable(c,true);var openOn=capture(c,"shafts-open");long open=gain(openOff,openOn);double openEnergy=energy(openOff,openOn);check(openEnergy>leafEnergy,"Leaf canopy attenuates normalized scattering on identical walls: "+leafEnergy+" / "+openEnergy);
look(c,server,0,0);settle(c);enable(c,false);var sideOff=capture(c,null);enable(c,true);check(gain(sideOff,capture(c,"shafts-sun-offscreen"))>1000,"Volume persists with sun outside the viewport");
long[] close=new long[2];int index=0;
for(double z:new double[]{123.5,123.875}){camera(c,server,100.5,101,z,70);look(c,server,0,0);settle(c);enable(c,false);var nearOff=capture(c,null);enable(c,true);close[index++]=gain(nearOff,capture(c,"shafts-wall-"+z));}
check(close[1]<=close[0]+100,"Approaching wall attenuates scattering instead of increasing it: "+close[0]+" / "+close[1]);
c.runOnClient(m->VanillaLight.setSunshaftIntensity(0));c.waitTicks(10);c.runOnClient(m->check(!Sunshafts.allocated()&&PixelShadows.shadowMap()==null,"Zero frees volume and unneeded shared map"));
c.runOnClient(m->VanillaLight.setSunshaftIntensity(35));enable(c,true);
server.runOnServer(s->command(s,"time set 18000"));c.waitTicks(30);c.runOnClient(m->check(!Sunshafts.allocated()&&!Sunshafts.applied(),"Solar volume stops at night"));
server.runOnServer(s->command(s,"time set 3000"));camera(c,server,100.5,101,110.5,70);aim(c,server);settle(c);
c.runOnClient(m->{VanillaLight.setPixelShadows(true);VanillaLight.setPbr(true);VanillaLight.setSsgi(true);VanillaLight.setBloom(true);VanillaLight.setLensFlare(true);VanillaLight.setEdgeHighlights(true);});activeFrames(c);capture(c,"shafts-combined");
c.runOnClient(m->{VanillaLight.setSunshafts(false);});c.waitTicks(10);c.runOnClient(m->check(!Sunshafts.allocated()&&PixelShadows.applied(),"OFF preserves independently enabled surface shadows"));
for(String locale:new String[]{"fr_fr","en_us"})c.runOnClient(m->{m.getLanguageManager().setSelected(locale);m.getLanguageManager().onResourceManagerReload(m.getResourceManager());for(String suffix:new String[]{"sunshafts","sunshafts_help","sunshaft_intensity","sunshaft_intensity_help"}){String key="sanctuary.shaders."+suffix;check(!Component.translatable(key).getString().equals(key),"Localized volume controls: "+locale);}});
System.out.println("SUNSHAFT137_PASS slotGain="+low+" leafGain="+leaf+" openGain="+open+" closed room, offscreen sun, camera return, proximity, reload/resize, shared map, zero/night, preferences, FR/EN");
SunshaftWeather138ClientChecks.run(c,server);
}
static void roof(TestServerContext server,int mode){server.runOnServer(s->{for(int x=76;x<=124;x++)for(int z=76;z<=124;z++)s.overworld().setBlockAndUpdate(new BlockPos(x,113,z),(mode==3||mode==1&&Math.floorMod(z,8)<3?Blocks.AIR:mode==2?Blocks.OAK_LEAVES:Blocks.OAK_PLANKS).defaultBlockState().trySetValue(net.minecraft.world.level.block.state.properties.BlockStateProperties.PERSISTENT,true));});}
static void aim(ClientGameTestContext c,TestServerContext server){var d=c.computeOnClient(m->PixelShadows.sunDirection(m.gameRenderer.gameRenderState().levelRenderState.skyRenderState.sunAngle));look(c,server,Mth.wrapDegrees((float)Math.toDegrees(Math.atan2(-d.x,d.z))+25),-12);}
static void settle(ClientGameTestContext c){int before=c.computeOnClient(m->Sunshafts.renderedFrames());c.waitFor(m->Sunshafts.applied()&&PixelShadows.ready()&&Sunshafts.renderedFrames()>before+10,1200);c.waitTicks(30);}
static void enable(ClientGameTestContext c,boolean enabled){c.runOnClient(m->VanillaLight.setSunshafts(enabled));if(enabled)settle(c);else c.waitTicks(10);}
private static long gain(Frame off,Frame on){long sum=0;for(int i=0;i<off.pixels().length;i++)for(int shift:new int[]{0,8,16})sum+=Math.max(0,(on.pixels()[i]>>shift&255)-(off.pixels()[i]>>shift&255));return sum;}
// Screen blending has less headroom on a brighter vanilla wall. Remove that factor
// before comparing roofs, and exclude the roof itself where geometry changes.
static double energy(Frame off,Frame on){double sum=0;int n=0;for(int y=off.height()/3;y<off.height();y++)for(int x=0;x<off.width();x++){int i=y*off.width()+x;for(int shift:new int[]{0,8,16}){int a=off.pixels()[i]>>shift&255,b=on.pixels()[i]>>shift&255;if(a<240){sum+=Math.max(0,b-a)/(255.0-a);n++;}}}return sum/Math.max(1,n);}
private static void preferences(){try{var p=FabricLoader.getInstance().getConfigDir().resolve("sanctuary-shaders.json");Files.writeString(p,"{\"bloom\":false,\"pixelShadows\":false}");forgetPreferences();check(VanillaLight.sunshafts()&&VanillaLight.sunshaftIntensity()==35&&!VanillaLight.bloom()&&!VanillaLight.pixelShadows(),"Missing shaft settings preserve existing choices");VanillaLight.setSunshafts(false);VanillaLight.setSunshaftIntensity(73);forgetPreferences();check(!VanillaLight.sunshafts()&&VanillaLight.sunshaftIntensity()==73,"Custom shafts persist");for(int n:new int[]{-10,0,35,100,150}){VanillaLight.setSunshaftIntensity(n);forgetPreferences();check(VanillaLight.sunshaftIntensity()==Math.clamp(n,0,100),"Intensity clamps and persists");}VanillaLight.setSunshaftIntensity(35);}catch(java.io.IOException e){throw new AssertionError(e);}}
}
@@ -0,0 +1,180 @@
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);});}
}
}
@@ -0,0 +1,13 @@
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};}
}
@@ -0,0 +1,11 @@
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;}
}
@@ -0,0 +1,17 @@
package fr.koka.sanctuary.gametest.mixin;
import net.minecraft.client.renderer.Lightmap;
import net.minecraft.client.renderer.state.LightmapRenderState;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Inject;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
/** Freeze only vanilla torch flicker for image comparisons; never packaged in the mod. */
@Mixin(Lightmap.class)
public abstract class Colored139LightmapProbeMixin {
@Inject(method="render",at=@At("HEAD"))
private void stableBlockLight(LightmapRenderState state,CallbackInfo ci){
if(Boolean.getBoolean("sanctuary.test.freezeBlockFlicker")){state.blockFactor=1;state.needsUpdate=true;}
}
}
@@ -0,0 +1,14 @@
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);}
}
@@ -0,0 +1,24 @@
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();}
}
@@ -0,0 +1,12 @@
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);}
}
@@ -0,0 +1,17 @@
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,6 +10,9 @@
"defaultRequire": 1
},
"client": [
"ReflectionFrameProbeMixin",
"ReflectionGpuProbeMixin",
"Colored139LightmapProbeMixin",
"Fourneau094ParticleProbeMixin",
"Tooltip093FontProbeMixin",
"ClientVsync046Mixin",
@@ -18,6 +21,8 @@
"Atlas026TextureProbeMixin",
"Inventory013MerchantAccess",
"Dynamic030TerrainProbeMixin",
"Star040RenderProbeMixin"
"Star040RenderProbeMixin",
"Water144FrameProbeMixin",
"Water144SectionProbeMixin"
]
}
@@ -43,6 +43,7 @@ public final class SanctuaryMod implements ModInitializer {
@Override
public void onInitialize() {
fr.koka.sanctuary.community.CommunityNetwork.register();
fr.koka.sanctuary.building.ColoredBricks.register();
fr.koka.sanctuary.gems.Gems.register();
fr.koka.sanctuary.sky.VortexClouds.register();
@@ -71,6 +72,7 @@ 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();
@@ -0,0 +1,29 @@
package fr.koka.sanctuary.client;
import java.util.List;
import net.minecraft.client.Minecraft;
import net.minecraft.client.gui.GuiGraphicsExtractor;
import net.minecraft.client.gui.components.AbstractWidget;
import net.minecraft.client.gui.narration.NarrationElementOutput;
import net.minecraft.network.chat.Component;
import net.minecraft.util.FormattedCharSequence;
/** One compact, wrapped message; ordinary text never becomes a clickable command. */
final class CommunityBubble extends AbstractWidget {
private final List<FormattedCharSequence> lines;
private final boolean own;
CommunityBubble(int width, String body, boolean own) {
super(0,0,width,20,Component.literal(body));
this.own=own;
lines=Minecraft.getInstance().font.split(getMessage(),Math.max(20,width-16));
height=12+lines.size()*11;
active=false;
}
@Override protected void extractWidgetRenderState(GuiGraphicsExtractor g,int mx,int my,float delta) {
int x=getX(),y=getY();
g.fill(x,y,x+width,y+height,own?0xC42B3732:0xB51B2028);
g.fill(x,y,x+2,y+height,own?0xFF819D87:0xFF5E6879);
for(int i=0;i<lines.size();i++)g.text(Minecraft.getInstance().font,lines.get(i),x+8,y+6+i*11,0xFFF0F0F0,false);
}
@Override protected void updateWidgetNarration(NarrationElementOutput output) { defaultButtonNarrationText(output); }
}
@@ -0,0 +1,61 @@
package fr.koka.sanctuary.client;
import com.google.gson.*;
import fr.koka.sanctuary.community.CommunityNetwork;
import java.util.*;
import java.util.function.Consumer;
import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents;
import net.fabricmc.fabric.api.client.networking.v1.*;
import net.minecraft.client.Minecraft;
import net.minecraft.client.gui.screens.Screen;
import net.minecraft.network.chat.Component;
public final class CommunityClient {
public static JsonObject overview = new JsonObject();
public static boolean stale = true;
public static String overviewError = "";
private static int sequence, pending, ticks;
private static Screen owner;
private static Consumer<JsonObject> callback;
private static boolean overviewRequest;
static final Map<String,Draft> DRAFTS = new HashMap<>();
static final class Draft { String operation = UUID.randomUUID().toString(), title = "", body = "", category = "info"; }
private CommunityClient() { }
public static net.minecraft.network.chat.MutableComponent text(String key, Object... args) { return Component.translatable("sanctuary.community." + key,args); }
public static boolean available() { return ClientPlayNetworking.canSend(CommunityNetwork.Request.TYPE); }
public static boolean pending() { return pending != 0; }
public static void register() {
ClientPlayNetworking.registerGlobalReceiver(CommunityNetwork.Response.TYPE,(r,c) -> c.client().execute(() -> {
if (r.sequence() != pending) return;
var result = JsonParser.parseString(r.json()).getAsJsonObject(); finish(result);
}));
ClientPlayConnectionEvents.DISCONNECT.register((h,c) -> { pending = 0; owner = null; callback = null; overview = new JsonObject(); stale = true; overviewError = ""; DRAFTS.clear(); });
ClientTickEvents.END_CLIENT_TICK.register(client -> {
if (pending != 0 && ++ticks > 200) { var result = new JsonObject(); result.addProperty("error","unavailable"); finish(result); }
});
}
private static void finish(JsonObject result) {
pending = 0;
if (overviewRequest) {
if (!result.has("error")) { overview = result; stale = false; overviewError = ""; }
else { stale = true; overviewError = str(result,"error"); }
}
var receiver = callback; var from = owner; callback = null; owner = null;
if (receiver != null && Minecraft.getInstance().gui.screen() == from) receiver.accept(result);
}
public static boolean request(Screen screen,String action,String id,String parent,String kind,String category,
String title,String body,int revision,int page,Consumer<JsonObject> receiver) {
if (!available() || pending != 0) return false;
owner = screen; callback = receiver; overviewRequest = action.equals("overview"); ticks = 0;
pending = ++sequence;
ClientPlayNetworking.send(new CommunityNetwork.Request(pending,action,id,parent,kind,category,title,body,revision,page));
return true;
}
public static void overview(Screen screen) { request(screen,"overview","","","article","info","","",0,0,r -> { }); }
public static void open(Screen parent,String kind,String id,boolean compose) {
Minecraft.getInstance().gui.setScreen(new CommunityScreen(parent,kind,id,compose));
}
static String str(JsonObject value,String key) { return value.has(key) ? value.get(key).getAsString() : ""; }
static int number(JsonObject value,String key) { return value.has(key) ? value.get(key).getAsInt() : 0; }
static boolean flag(JsonObject value,String key) { return value.has(key) && value.get(key).getAsBoolean(); }
}
@@ -0,0 +1,79 @@
package fr.koka.sanctuary.client;
import com.google.gson.*;
import java.util.*;
import java.util.function.Consumer;
import net.minecraft.client.Minecraft;
import net.minecraft.client.gui.GuiGraphicsExtractor;
import net.minecraft.client.gui.components.*;
import net.minecraft.client.gui.narration.NarrationElementOutput;
import net.minecraft.client.gui.screens.Screen;
import net.minecraft.network.chat.Component;
import static fr.koka.sanctuary.client.CommunityClient.*;
public final class CommunityPausePanels {
private final Screen screen;
private final List<Runnable> updates = new ArrayList<>();
private int ticks;
public CommunityPausePanels(Screen screen,Consumer<AbstractWidget> add) {
this.screen=screen;
int side=Math.min(220,(screen.width-244)/2);
if (side<145 || screen.height<235) {
int w=Math.max(40,(screen.width-28)/3);
int x=10;
for (String kind:new String[]{"article","bulletin","notice"}) {
String target=kind;
add.accept(Button.builder(text(kind),b->open(screen,target,"",false)).bounds(x,6,w,20).build()); x+=w+4;
}
} else {
var bulletin=Button.builder(text("bulletin"),b->open(screen,"bulletin","",false)).bounds(12,7,screen.width-24,20).build();
add.accept(bulletin);
updates.add(()->{
var items=items("bulletins");
Component message=text("bulletin").append(" · ").append(items.isEmpty()?text("bulletin_empty"):Component.literal(str(items.get(0).getAsJsonObject(),"title")));
if (!overviewError.isEmpty()) message=message.copy().append(" · ").append(text(overviewError));
else if (stale) message=message.copy().append(" · ").append(text("cached"));
bulletin.setMessage(message);
bulletin.setTooltip(Tooltip.create(items.isEmpty()?text("bulletin_empty"):Component.literal(str(items.get(0).getAsJsonObject(),"body"))));
});
panel(add,12,side,"article","articles"); panel(add,screen.width-side-12,side,"notice","notices");
}
tick();
}
private static JsonArray items(String key) { return overview.has(key)?overview.getAsJsonArray(key):new JsonArray(); }
private void panel(Consumer<AbstractWidget> add,int x,int w,String kind,String collection) {
int top=53,h=screen.height-top-12;
add.accept(new Panel(x,top,w,h,collection));
add.accept(Button.builder(text(kind),b->open(screen,kind,"",false)).bounds(x+6,top+7,w-12,20).build());
int count=Math.max(1,Math.min(3,(h-72)/43));
for(int i=0;i<count;i++) {
int index=i;
var button=Button.builder(text("loading"),b->{var list=items(collection);if(index<list.size())open(screen,kind,str(list.get(index).getAsJsonObject(),"id"),false);}).bounds(x+6,top+36+i*43,w-12,20).build();
updates.add(()->{
var list=items(collection); button.active=index<list.size(); button.visible=index<list.size() || index==0;
button.setMessage(index<list.size()?Component.literal((flag(list.get(index).getAsJsonObject(),"pinned")?"★ ":"")+str(list.get(index).getAsJsonObject(),"title")):text(overview.isEmpty()?(overviewError.isEmpty()?"loading":overviewError):"empty"));
}); add.accept(button);
}
add.accept(Button.builder(text("publish"),b->open(screen,kind,"",true)).bounds(x+6,top+h-27,w-12,20).build());
}
public void tick() {
for(var update:updates)update.run();
if(!pending() && (ticks++%100==0)) overview(screen);
}
private static final class Panel extends AbstractWidget {
private final String collection;
private final Map<String,net.minecraft.world.item.component.ResolvableProfile> profiles=new HashMap<>();
Panel(int x,int y,int w,int h,String collection) { super(x,y,w,h,Component.empty()); this.collection=collection; active=false; }
@Override protected void extractWidgetRenderState(GuiGraphicsExtractor g,int mx,int my,float delta) {
int x=getX(),y=getY(); g.fill(x,y,x+width,y+height,0xCE202020); g.fill(x,y,x+width,y+1,0xFF777777);
var font=Minecraft.getInstance().font;var list=items(collection);int count=Math.max(1,Math.min(3,(height-72)/43));
for(int i=0;i<Math.min(count,list.size());i++) {
var entry=list.get(i).getAsJsonObject();
var profile=profiles.computeIfAbsent(str(entry,"authorId"),id -> net.minecraft.world.item.component.ResolvableProfile.createUnresolved(UUID.fromString(id)));
PlayerFaceExtractor.extractRenderState(g,profile,x+8,y+57+i*43,12);
g.text(font,font.plainSubstrByWidth(str(entry,"authorName"),width-34),x+25,y+59+i*43,0xFFBBBBBB,false);
}
}
@Override protected void updateWidgetNarration(NarrationElementOutput output) { }
}
}
@@ -0,0 +1,178 @@
package fr.koka.sanctuary.client;
import com.google.gson.*;
import java.time.*;
import java.time.format.DateTimeFormatter;
import net.minecraft.client.gui.components.*;
import net.minecraft.client.gui.layouts.LinearLayout;
import net.minecraft.client.gui.screens.Screen;
import net.minecraft.network.chat.Component;
import static fr.koka.sanctuary.client.CommunityClient.*;
/** Native text-only pages: user content is always a literal Minecraft component. */
public final class CommunityScreen extends BlocodexFrame {
private final String kind;
private String id, editingId = "", message = "", expandedId = "";
private int page, revision;
private boolean composing, needsLoad = true, moderator;
private JsonObject data = new JsonObject();
private Draft draft;
private int ticks;
public CommunityScreen(Screen parent,String kind,String id,boolean compose) {
super(parent); this.kind = kind; this.id = id; this.composing = compose;
moderator = flag(overview,"moderator");
if (compose) useDraft("new:" + kind);
}
private void useDraft(String key) { draft = DRAFTS.computeIfAbsent(key, unused -> new Draft()); }
@Override protected void init() {
initFrame("history",text(kind)); contentWidth=Math.min(520,contentWidth); contentX=(width-contentWidth)/2; int w = contentWidth - 28;
var layout = LinearLayout.vertical().spacing(8);
if (!message.isEmpty()) line(layout,w,text(message));
if (!available()) line(layout,w,text("unavailable"));
if (composing) compose(layout,w);
else if (data.has("thread")) detail(layout,w,data.getAsJsonObject("thread"));
else listing(layout,w);
var scroll = new ScrollableLayout(minecraft,layout,Math.max(24,contentBottom-contentTop),ScrollableLayout.ReserveStrategy.RIGHT);
scroll.setPosition(contentX+8,contentTop); scroll.arrangeElements(); scroll.visitWidgets(this::addRenderableWidget);
}
private void listing(LinearLayout layout,int w) {
var tabs = layout.addChild(LinearLayout.horizontal().spacing(4));
for (String target : new String[]{"article","notice","bulletin"}) tabs.addChild(Button.builder(text(target), b -> open(parent,target,"",false)).width((w-8)/3).build());
var toolbar=layout.addChild(LinearLayout.horizontal().spacing(4));
if (!kind.equals("bulletin") || moderator) button(toolbar,(w-8)/3,"publish",() -> { composing = true; useDraft("new:"+kind); rebuildWidgets(); });
button(toolbar,(w-8)/3,"refresh",() -> needsLoad = true);
button(toolbar,(w-8)/3,"link",() -> minecraft.gui.setScreen(new LinkScreen(this)));
if (!data.has("page")) { line(layout,w,text("loading")); return; }
var listing = data.getAsJsonObject("page");
if (listing.getAsJsonArray("entries").isEmpty()) line(layout,w,text("empty"));
for (var item : listing.getAsJsonArray("entries")) {
var entry = item.getAsJsonObject();
layout.addChild(Button.builder(Component.literal((flag(entry,"pinned") ? "★ " : "") + str(entry,"title")), b -> { id=str(entry,"id"); page=0; needsLoad=true; }).width(w).build());
author(layout,w,entry,false);
if (kind.equals("notice")) line(layout,w,text("category."+str(entry,"category")).append(" · ").append(text(str(entry,"status"))));
}
pages(layout,w,listing);
}
private void detail(LinearLayout layout,int w,JsonObject thread) {
var entry = thread.getAsJsonObject("entry");
var toolbar=layout.addChild(LinearLayout.horizontal().spacing(5));
button(toolbar,90,"back",() -> { id=""; page=0; data=new JsonObject(); needsLoad=true; rebuildWidgets(); });
button(toolbar,90,"refresh",() -> needsLoad=true);
line(layout,w,Component.literal(str(entry,"title")).withStyle(net.minecraft.ChatFormatting.BOLD));
message(layout,w,entry);
if (kind.equals("notice")) line(layout,w,text("category."+str(entry,"category")).append(" · ").append(text(str(entry,"status"))));
if (!kind.equals("bulletin")) {
line(layout,w,text("replies"));
var replies = thread.getAsJsonObject("replies");
for (var item : replies.getAsJsonArray("entries")) message(layout,w,item.getAsJsonObject());
pages(layout,w,replies);
if (str(entry,"status").equals("open")) {
useDraft("reply:"+id);
var replyDraft=draft;
var box=layout.addChild(MultiLineEditBox.builder().setPlaceholder(text("reply_hint")).build(font,w,52,text("reply")));
box.setCharacterLimit(1000); box.setValue(replyDraft.body); box.setValueListener(value -> replyDraft.body=value);
button(layout,100,"reply",() -> request(this,"create",replyDraft.operation,id,"reply","info","",replyDraft.body,0,0,result -> {
if (result.has("error")) { message=str(result,"error"); rebuildWidgets(); return; }
DRAFTS.values().removeIf(value -> value==replyDraft); message="saved"; needsLoad=true; stale=true;
}));
}
}
}
private boolean own(JsonObject entry) { return minecraft.player!=null && str(entry,"authorId").equals(minecraft.player.getStringUUID()); }
private void author(LinearLayout layout,int w,JsonObject entry,boolean actions) {
var row=layout.addChild(LinearLayout.horizontal().spacing(6));
row.addChild(new PlayerFaceWidget(24,net.minecraft.world.item.component.ResolvableProfile.createUnresolved(java.util.UUID.fromString(str(entry,"authorId")))));
var labels=row.addChild(LinearLayout.vertical().spacing(3));
labels.addChild(new StringWidget(w-58,11,Component.literal(str(entry,"authorName")),font));
labels.addChild(new StringWidget(w-58,10,Component.literal(date(entry)).withStyle(net.minecraft.ChatFormatting.GRAY),font));
if(actions && (own(entry)||moderator)) row.addChild(Button.builder(text("actions"),b -> {
expandedId=expandedId.equals(str(entry,"id"))?"":str(entry,"id"); rebuildWidgets();
}).size(20,18).tooltip(Tooltip.create(text("actions_for",str(entry,"authorName")))).build());
}
private void message(LinearLayout layout,int w,JsonObject entry) {
author(layout,w,entry,true);
layout.addChild(new CommunityBubble(w,str(entry,"body"),own(entry)));
if(expandedId.equals(str(entry,"id"))) controls(layout,w,entry);
}
private void controls(LinearLayout layout,int w,JsonObject entry) {
boolean own = own(entry);
var row=layout.addChild(LinearLayout.horizontal().spacing(4)); int size=Math.min(100,(w-12)/4);
if (own) button(row,size,"edit",() -> {
editingId=str(entry,"id"); revision=number(entry,"revision"); composing=true; useDraft("edit:"+editingId);
if (draft.body.isEmpty()) { draft.title=str(entry,"title"); draft.body=str(entry,"body"); draft.category=str(entry,"category"); }
rebuildWidgets();
});
if (own || moderator) button(row,size,"hide",() -> minecraft.gui.setScreen(new net.minecraft.client.gui.screens.ConfirmScreen(
confirmed -> { minecraft.gui.setScreen(this); if (confirmed) mutate("hide",entry); },text("hide"),text("hide_confirm"))));
if ((own || moderator) && str(entry,"kind").equals("notice")) {
String action=str(entry,"status").equals("closed")?"reopen":"close"; button(row,size,action,() -> mutate(action,entry));
}
if (moderator && !str(entry,"kind").equals("reply")) button(row,size,flag(entry,"pinned")?"unpin":"pin",() -> mutate("pin",entry));
}
private void compose(LinearLayout layout,int w) {
boolean reply = !id.isEmpty() && (editingId.isEmpty() || !editingId.equals(id));
if (!reply) {
line(layout,w,text("title_label")); var titleBox=layout.addChild(new EditBox(font,w,20,text("title_label")));
titleBox.setMaxLength(120); titleBox.setValue(draft.title); titleBox.setResponder(value -> draft.title=value);
if (kind.equals("notice") && editingId.isEmpty()) button(layout,w,"category."+draft.category,() -> {
String[] categories={"info","work","need","event"}; int i=java.util.Arrays.asList(categories).indexOf(draft.category);
draft.category=categories[(i+1)%categories.length]; rebuildWidgets();
});
}
line(layout,w,text("body_label"));
var bodyBox=layout.addChild(MultiLineEditBox.builder().setPlaceholder(text("body_hint")).build(font,w,Math.max(60,Math.min(150,height/3)),text("body_label")));
bodyBox.setCharacterLimit(reply?1000:4000); bodyBox.setValue(draft.body); bodyBox.setValueListener(value -> draft.body=value);
button(layout,110,editingId.isEmpty()?"publish":"save",() -> {
String action=editingId.isEmpty()?"create":"edit";
request(this,action,editingId.isEmpty()?draft.operation:editingId,reply?id:"",reply?"reply":kind,draft.category,
reply?"":draft.title,draft.body,revision,0,result -> {
if (result.has("error")) { message=str(result,"error"); rebuildWidgets(); return; }
String saved=str(result.getAsJsonObject("result"),"id");
DRAFTS.values().removeIf(value -> value==draft); composing=false; editingId="";
if (!reply) id=saved; page=0; needsLoad=true; message="saved"; stale=true; rebuildWidgets();
});
});
button(layout,Math.min(w,220),"back_draft",() -> { composing=false; editingId=""; needsLoad=true; rebuildWidgets(); });
}
private void mutate(String action,JsonObject entry) {
request(this,action,str(entry,"id"),"",str(entry,"kind"),str(entry,"category"),"","",number(entry,"revision"),0,result -> {
message=result.has("error")?str(result,"error"):"saved";
if (!result.has("error") && action.equals("hide") && str(entry,"id").equals(id)) { id=""; page=0; }
stale=true; needsLoad=true; rebuildWidgets();
});
}
private void pages(LinearLayout layout,int w,JsonObject listing) {
if(page==0 && !flag(listing,"more"))return;
w=Math.min(w,280);
var row=layout.addChild(LinearLayout.horizontal().spacing(4));
row.addChild(Button.builder(text("previous"),b -> { page=Math.max(0,page-1); needsLoad=true; }).width((w-8)/3).build()).active=page>0;
row.addChild(new StringWidget((w-8)/3,20,text("page",page+1),font));
row.addChild(Button.builder(text("next"),b -> { page++; needsLoad=true; }).width((w-8)/3).build()).active=flag(listing,"more");
}
private void line(LinearLayout layout,int w,Component value) { layout.addChild(new MultiLineTextWidget(value,font).setMaxWidth(w)); }
private void button(LinearLayout layout,int w,String key,Runnable action) {
layout.addChild(Button.builder(text(key),b -> { if (!pending()) action.run(); }).width(w).build());
}
private static String date(JsonObject e) { return DateTimeFormatter.ofPattern("dd/MM HH:mm 'UTC'").withZone(ZoneOffset.UTC).format(Instant.ofEpochMilli(e.get("createdAt").getAsLong())); }
@Override public void tick() {
super.tick(); ticks++;
if (!composing && needsLoad && ticks%4==0 && request(this,id.isEmpty()?"list":"thread",id,"",kind,"info","","",0,page,result -> {
if (result.has("error")) message=str(result,"error");
else { data=result; moderator=flag(result,"moderator"); }
rebuildWidgets();
})) needsLoad=false;
}
@Override public boolean isPauseScreen() { return false; }
private static final class LinkScreen extends BlocodexFrame {
String code="", message="link_help";
LinkScreen(Screen parent) { super(parent); }
@Override protected void init() {
initFrame("history",text("link")); int w=contentWidth-28; var layout=LinearLayout.vertical().spacing(8);
layout.addChild(new MultiLineTextWidget(text(message),font).setMaxWidth(w));
var box=layout.addChild(new EditBox(font,w,20,text("code"))); box.setMaxLength(32); box.setValue(code); box.setResponder(value -> code=value);
layout.addChild(Button.builder(text("link"),b -> request(this,"link","","","article","info","",code,0,0,r -> { message=r.has("error")?str(r,"error"):"linked"; rebuildWidgets(); })).width(w).build());
layout.setPosition(contentX+8,contentTop); layout.arrangeElements(); layout.visitWidgets(this::addRenderableWidget);
}
@Override public boolean isPauseScreen() { return false; }
}
}
@@ -27,6 +27,15 @@ public final class DynamicLightClient {
private static DynamicLightConfig config;
private static final Map<Item,Integer> overrides=new IdentityHashMap<>();
private static int ticks;
private static boolean invalidatingSections;
private static final fr.koka.sanctuary.client.shader.ColoredLightMaterials colors=new fr.koka.sanctuary.client.shader.ColoredLightMaterials();
private static Object models;
public static boolean invalidatingSections(){return invalidatingSections;}
private static final class ColorMix {
double r,g,b;
void add(int light,int color){if(light<=0)return;double energy=Math.pow(.8,15-light);r=Math.max(r,(color>>16&255)*energy);g=Math.max(g,(color>>8&255)*energy);b=Math.max(b,(color&255)*energy);}
int result(){double m=Math.max(1e-8,Math.max(r,Math.max(g,b)));return (int)Math.round(r/m*255)<<16|(int)Math.round(g/m*255)<<8|(int)Math.round(b/m*255);}
}
private DynamicLightClient() {}
public static void register(KeyMapping.Category category) {
config=DynamicLightConfig.load();
@@ -50,7 +59,7 @@ public final class DynamicLightClient {
}
public static int pendingSections() {return field.pendingCount();}
private static void reset(ClientLevel level) {
field=new DynamicLightField();view=new View(level,field.snapshot());ticks=0;
field=new DynamicLightField();view=new View(level,field.snapshot());ticks=0;colors.clear();models=null;
}
private static int emission(ItemStack stack) {return overrides.getOrDefault(stack.getItem(),-1)>=0?overrides.get(stack.getItem()):ItemLightEmission.of(stack);}
private static void tick(Minecraft m) {
@@ -59,6 +68,8 @@ public final class DynamicLightClient {
if(ticks++%config.updateIntervalTicks==0) {
List<DynamicLightField.Source> sources=List.of();
if(config.enabled) {
var currentModels=m.getModelManager().getBlockStateModelSet();if(models!=currentModels){colors.clear();models=currentModels;}
boolean tinted=fr.koka.sanctuary.client.shader.VanillaLight.coloredLights();
var eye=m.getCameraEntity().getEyePosition();
var collect=new DynamicLightField.Collector(config.dropClusterSize);
double distance=config.range*config.range;
@@ -66,23 +77,26 @@ public final class DynamicLightClient {
if(entity.isRemoved() || entity.distanceToSqr(eye)>distance)continue;
boolean drop=entity instanceof ItemEntity;
int light=entity.isOnFire()?15:0;
var mix=new ColorMix();if(light>0)mix.add(light,0xFFAB55);
double y=entity.getY()+entity.getBbHeight()*.5;
if(entity instanceof ItemEntity item)light=Math.max(light,emission(item.getItem()));
if(entity instanceof ItemEntity item){int emitted=emission(item.getItem());light=Math.max(light,emitted);if(emitted>0&&tinted)mix.add(emitted,colors.color(item.getItem()));}
else if(entity instanceof LivingEntity living) {
if(living instanceof Player p && p.isSpectator())continue;
light=Math.max(light,Math.max(emission(living.getMainHandItem()),emission(living.getOffhandItem())));
light=Math.max(light,emission(living.getItemBySlot(EquipmentSlot.HEAD)));
light=Math.max(light,emission(HeadCosmeticService.appearance(living)));
for(var stack:new ItemStack[]{living.getMainHandItem(),living.getOffhandItem(),living.getItemBySlot(EquipmentSlot.HEAD),HeadCosmeticService.appearance(living)}){
int emitted=emission(stack);light=Math.max(light,emitted);if(emitted>0&&tinted)mix.add(emitted,colors.color(stack));
}
y=entity.getEyeY();
}
if(light>0)collect.add(entity.getId(),drop,entity.getX(),y,entity.getZ(),light);
if(light>0)collect.add(entity.getId(),drop,entity.getX(),y,entity.getZ(),light,tinted?mix.result():0xFFFFFF);
}
sources=collect.finish(config.maxSources,eye.x,eye.y,eye.z,m.player==null?-1:m.player.getId());
}
if(field.update(sources))view=new View(m.level,field.snapshot());
}
// Native renderer marks only resident sections; it neither loads chunks nor rebuilds the whole world.
field.drain(config.sectionUpdatesPerTick,key->m.levelExtractor.setSectionDirty(
DynamicLightField.sectionX(key),DynamicLightField.sectionY(key),DynamicLightField.sectionZ(key)));
invalidatingSections=true;
try{field.drain(config.sectionUpdatesPerTick,key->m.levelExtractor.setSectionDirty(
DynamicLightField.sectionX(key),DynamicLightField.sectionY(key),DynamicLightField.sectionZ(key)));}
finally{invalidatingSections=false;}
}
}
@@ -17,7 +17,9 @@ public final class SanctuaryKnowledgeClient implements ClientModInitializer {
}
public static void registerClient() {
CommunityClient.register();
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,16 +42,19 @@ 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_blur")||name.equals("bloom_downsample")?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_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());
}
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);
private static final RenderPipeline CORE=screen("bloom_core",new BlendFunction(BlendFactor.ONE,BlendFactor.ONE_MINUS_SRC_ALPHA,BlendFactor.ZERO,BlendFactor.ONE),false,false);
private static final RenderPipeline APPLY=screen("bloom_apply",new BlendFunction(BlendFactor.ONE,BlendFactor.ONE_MINUS_SRC_COLOR,BlendFactor.ZERO,BlendFactor.ONE),true,false);
private static final RenderPipeline FLARE_SOURCE=screen("lens_flare_source",null,true,false);
private static final RenderPipeline FLARE=screen("lens_flare",new BlendFunction(BlendFactor.ONE,BlendFactor.ONE_MINUS_SRC_COLOR,BlendFactor.ZERO,BlendFactor.ONE),true,false);
private static final BloomTerrain TERRAIN=new BloomTerrain();
private static final Matrix4f projection=new Matrix4f();
private static TextureTarget emission,small,ping;
private static TextureTarget emission,small,ping,flareSource;
private static MappableRingBuffer scene,horizontal,vertical;
private static int frames;
private static boolean applied;
@@ -61,49 +64,83 @@ public final class Bloom {
public static void dirtyChunk(int x,int z){TERRAIN.dirtyChunk(x,z);}
public static void render(){
applied=false;var mc=Minecraft.getInstance();
if(!VanillaLight.active()||!VanillaLight.bloom()||VanillaLight.bloomIntensity()==0||mc.level==null){release();return;}
boolean halo=VanillaLight.bloom()&&VanillaLight.bloomIntensity()>0;
boolean flare=VanillaLight.lensFlare()&&VanillaLight.lensFlareIntensity()>0;
boolean cores=VanillaLight.oreEmission()&&VanillaLight.oreIntensity()>0;
if(!VanillaLight.active()||(!halo&&!cores&&!flare)||mc.level==null){release();return;}
if(PanoramaCapture.rendering())return;
var state=mc.gameRenderer.gameRenderState().levelRenderState;var camera=state.cameraRenderState;
if(camera.fogType!=FogType.NONE)return;
var main=mc.gameRenderer.mainRenderTarget();
if(emission==null||emission.width!=main.width||emission.height!=main.height){
targetsClose();emission=target("emission",main.width,main.height);small=target("bloom low",Math.max(1,main.width/8),Math.max(1,main.height/8));ping=target("bloom ping",small.width,small.height);
targetsClose();emission=target("emission",main.width,main.height);
}
if(scene==null){scene=uniforms("bloom scene",144);horizontal=uniforms("bloom horizontal",16);vertical=uniforms("bloom vertical",16);}
var entries=TERRAIN.update();var atlas=TERRAIN.materials.atlas();
if(scene==null)scene=uniforms("bloom scene",192);
if(halo){
if(small==null){small=target("bloom low",Math.max(1,main.width/8),Math.max(1,main.height/8));ping=target("bloom ping",small.width,small.height);}
if(horizontal==null){horizontal=uniforms("bloom horizontal",16);vertical=uniforms("bloom vertical",16);}
}else haloClose();
if(flare){if(flareSource==null)flareSource=target("lens flare sources",2,1);}else flareClose();
// A celestial-only flare never needs to scan or mesh terrain emitters.
var entries=(halo||cores)?TERRAIN.update():List.<BloomTerrain.Entry>of();
if(!halo&&!cores)TERRAIN.close();
var atlas=TERRAIN.materials.atlas();
var vp=new Matrix4f(projection).mul(camera.viewRotationMatrix);var inverse=new Matrix4f(vp).invert();
var sun=PixelShadows.sunDirection(state.skyRenderState.sunAngle);var moon=PixelShadows.sunDirection(state.skyRenderState.moonAngle);
float celestial=state.skyRenderState.skybox==DimensionType.Skybox.OVERWORLD?state.skyRenderState.rainBrightness:0;
var flareSun=projectFlare(vp,sun,celestial);
var flareMoon=projectFlare(vp,moon,celestial*.35f);
var fog=camera.fogData;scene.rotate();
try(var mapped=scene.currentBuffer().map(false,true)){
Std140Builder.intoBuffer(mapped.data()).putMat4f(inverse)
.putVec4(VanillaLight.oreEmission()?VanillaLight.oreIntensity()/100f:0,RenderSystem.getDevice().getDeviceInfo().isZZeroToOne()?1:0,VanillaLight.bloomIntensity()/50f,0)
.putVec4(sun.x,sun.y,sun.z,celestial).putVec4(moon.x,moon.y,moon.z,celestial*.35f)
.putVec4(fog.renderDistanceStart,fog.renderDistanceEnd,fog.environmentalStart,fog.environmentalEnd).putVec4(fog.color.w,0,0,0);
.putVec4(fog.renderDistanceStart,fog.renderDistanceEnd,fog.environmentalStart,fog.environmentalEnd).putVec4(fog.color.w,0,0,0)
.putVec4(flareSun).putVec4(flareMoon).putVec4(flare?VanillaLight.lensFlareIntensity()/100f:0,(float)main.width/main.height,0,0);
}
if(halo){
float radius=.25f+VanillaLight.bloomRadius()*.0075f;
horizontal.rotate();try(var mapped=horizontal.currentBuffer().map(false,true)){Std140Builder.intoBuffer(mapped.data()).putVec4(radius/small.width,0,0,0);}
vertical.rotate();try(var mapped=vertical.currentBuffer().map(false,true)){Std140Builder.intoBuffer(mapped.data()).putVec4(0,radius/small.height,0,0);}
}
float radius=.25f+VanillaLight.bloomRadius()*.0075f;
horizontal.rotate();try(var mapped=horizontal.currentBuffer().map(false,true)){Std140Builder.intoBuffer(mapped.data()).putVec4(radius/small.width,0,0,0);}
vertical.rotate();try(var mapped=vertical.currentBuffer().map(false,true)){Std140Builder.intoBuffer(mapped.data()).putVec4(0,radius/small.height,0,0);}
// Gather transforms before opening any pass (the native dynamic ring may resize).
var transforms=new ArrayList<GpuBufferSlice>(entries.size());int max=0;
for(var entry:entries){var pos=entry.origin();var offset=new Vector3f((float)(pos.getX()-camera.pos.x),(float)(pos.getY()-camera.pos.y),(float)(pos.getZ()-camera.pos.z));
transforms.add(RenderSystem.getDynamicUniforms().writeTransform(new Matrix4f(vp).translate(offset),new Vector4f(1,1,1,1),offset,new Matrix4f()));max=Math.max(max,entry.indices());}
var indices=RenderSystem.getSequentialBuffer(PrimitiveTopology.QUADS);indices.requestIndexCount(max);
var nearest=RenderSystem.getSamplerCache().getClampToEdge(FilterMode.NEAREST);
screen(SKY,emission.getColorTextureView(),main.getColorTextureView(),true,null);
if(halo||flare)screen(SKY,emission.getColorTextureView(),main.getColorTextureView(),true,null);
else try(var clear=RenderSystem.getDevice().createCommandEncoder().createRenderPass(()->"Sanctuary clear emission",emission.getColorTextureView(),Optional.of(new Vector4f(0,0,0,0)))){ }
// Reduce the celestial-only mask before drawing any torch, ore or other terrain emitter.
if(flare)screen(FLARE_SOURCE,flareSource.getColorTextureView(),emission.getColorTextureView(),true,null);
if(atlas!=null&&!entries.isEmpty())try(var pass=RenderSystem.getDevice().createCommandEncoder().createRenderPass(()->"Sanctuary visible emitters",emission.getColorTextureView(),Optional.empty())){
pass.setPipeline(RenderSystem.getCompiledPipeline(EMITTERS));pass.setUniform("BloomScene",scene.currentBuffer());pass.setUniform("WorldDepth",main.getDepthTextureView(),nearest);
pass.setUniform("BlockAtlas",mc.getTextureManager().getTexture(TextureAtlas.LOCATION_BLOCKS).getTextureView(),nearest);pass.setUniform("EmissionMasks",atlas.getTextureView(),nearest);
pass.setIndexBuffer(indices.getBuffer(),indices.type());
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);}
}
screen(DOWNSAMPLE,small.getColorTextureView(),emission.getColorTextureView(),false,null);
screen(BLUR,ping.getColorTextureView(),small.getColorTextureView(),false,horizontal);
screen(BLUR,small.getColorTextureView(),ping.getColorTextureView(),false,vertical);
screen(CORE,main.getColorTextureView(),emission.getColorTextureView(),false,null);
screen(APPLY,main.getColorTextureView(),small.getColorTextureView(),true,null);
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);
}
if(cores)screen(CORE,main.getColorTextureView(),emission.getColorTextureView(),false,null);
if(halo)screen(APPLY,main.getColorTextureView(),small.getColorTextureView(),true,null);
if(flare)screen(FLARE,main.getColorTextureView(),flareSource.getColorTextureView(),true,null);
applied=true;frames++;
}
private static Vector4f projectFlare(Matrix4f vp,Vector3f direction,float strength){
var clip=vp.transform(new Vector4f(direction,0));
if(clip.w<=.05f||direction.y<=0||strength<=0)return new Vector4f(0,0,0,0);
float x=clip.x/clip.w*.5f+.5f,y=clip.y/clip.w*.5f+.5f;
// Actual sprite light is measured on GPU. This is only its projected sampling region.
float radius=Math.min(.3f,Math.abs(projection.m11())*.08f/clip.w);
float horizon=Math.clamp(direction.y/.08f,0,1);
return new Vector4f(x,y,radius,strength*horizon*horizon*(3-2*horizon));
}
private static void flareClose(){if(flareSource!=null){flareSource.destroyBuffers();flareSource=null;}}
private static void screen(RenderPipeline pipeline,GpuTextureView output,GpuTextureView input,boolean useScene,MappableRingBuffer blur){
try(var pass=RenderSystem.getDevice().createCommandEncoder().createRenderPass(()->"Sanctuary bloom",output,Optional.empty())){
pass.setPipeline(RenderSystem.getCompiledPipeline(pipeline));pass.setUniform("InputTexture",input,RenderSystem.getSamplerCache().getClampToEdge(pipeline==CORE||pipeline==SKY?FilterMode.NEAREST:FilterMode.LINEAR));
@@ -113,8 +150,15 @@ public final class Bloom {
}
private static TextureTarget target(String name,int w,int h){return new TextureTarget("Sanctuary "+name,w,h,name.equals("emission")?GpuFormat.RGBA8_UNORM:GpuFormat.RGBA16_FLOAT,null);}
private static MappableRingBuffer uniforms(String name,int size){return new MappableRingBuffer(()->"Sanctuary "+name,GpuBuffer.USAGE_UNIFORM|GpuBuffer.USAGE_MAP_WRITE,size);}
private static void targetsClose(){if(emission!=null){emission.destroyBuffers();small.destroyBuffers();ping.destroyBuffers();emission=small=ping=null;}}
public static void release(){TERRAIN.close();targetsClose();if(scene!=null){scene.close();horizontal.close();vertical.close();scene=horizontal=vertical=null;}applied=false;}
private static void haloClose(){
if(small!=null){small.destroyBuffers();ping.destroyBuffers();small=ping=null;}
if(horizontal!=null){horizontal.close();vertical.close();horizontal=vertical=null;}
}
private static void targetsClose(){haloClose();flareClose();if(emission!=null){emission.destroyBuffers();emission=null;}}
public static void release(){TERRAIN.close();targetsClose();if(scene!=null){scene.close();scene=null;}applied=false;}
static TextureTarget flareTarget(){return flareSource;}
static boolean flareAllocated(){return flareSource!=null;}
static boolean haloAllocated(){return small!=null||horizontal!=null;}
static boolean applied(){return applied;}
static boolean allocated(){return emission!=null;}
static boolean ready(){return TERRAIN.settled();}
@@ -0,0 +1,37 @@
package fr.koka.sanctuary.client.shader;
import fr.koka.sanctuary.client.DynamicLightClient;
import fr.koka.sanctuary.lighting.DynamicLightField;
import java.util.*;
import net.minecraft.client.Minecraft;
import net.minecraft.world.phys.Vec3;
/** Same bounded sources as native dynamic lighting, with visual interpolation only. */
final class ColoredDynamicLights {
static final int LIMIT=128;
static final class Light {
double x,y,z,r,g,b,visibility;int level;boolean seen;
Light(DynamicLightField.Source s){x=s.x();y=s.y();z=s.z();level=s.light();r=(s.color()>>16&255)/255.;g=(s.color()>>8&255)/255.;b=(s.color()&255)/255.;}
}
private final Map<DynamicLightField.Id,Light> lights=new HashMap<>();
private long last;
List<Light> update(Vec3 camera){
long now=System.nanoTime();double dt=last==0?0:Math.min(.1,(now-last)/1e9);last=now;
double ease=1-Math.exp(-dt*14),fade=dt/.3;
lights.values().forEach(l->l.seen=false);
var mc=Minecraft.getInstance();
for(var s:DynamicLightClient.snapshot(mc.level).sources()){
Light l=lights.computeIfAbsent(s.id(),id->new Light(s));l.seen=true;l.level=s.light();
boolean own=!s.id().drop()&&mc.player!=null&&s.id().value()==mc.player.getId()
&&mc.getCameraEntity()==mc.player&&mc.options.getCameraType().isFirstPerson();
double x=own?camera.x:s.x(),y=own?camera.y:s.y(),z=own?camera.z:s.z();
double step=own?1:ease;l.x+=(x-l.x)*step;l.y+=(y-l.y)*step;l.z+=(z-l.z)*step;
l.r+=((s.color()>>16&255)/255.-l.r)*ease;l.g+=((s.color()>>8&255)/255.-l.g)*ease;l.b+=((s.color()&255)/255.-l.b)*ease;
}
for(var it=lights.values().iterator();it.hasNext();){var l=it.next();l.visibility=Math.clamp(l.visibility+(l.seen?fade:-fade),0,1);if(!l.seen&&l.visibility<=0)it.remove();}
return lights.values().stream().filter(l->camera.distanceToSqr(l.x,l.y,l.z)<Math.pow(VanillaLight.coloredLightDistance()+l.level,2))
.sorted(Comparator.comparingDouble(l->camera.distanceToSqr(l.x,l.y,l.z))).limit(LIMIT).toList();
}
Vec3 position(long entity){var l=lights.get(new DynamicLightField.Id(false,entity));return l==null?null:new Vec3(l.x,l.y,l.z);}
void clear(){lights.clear();last=0;}
}
@@ -0,0 +1,73 @@
package fr.koka.sanctuary.client.shader;
import java.util.*;
import net.minecraft.client.Minecraft;
import net.minecraft.core.Direction;
import net.minecraft.core.registries.BuiltInRegistries;
import net.minecraft.resources.Identifier;
import net.minecraft.util.RandomSource;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.client.renderer.block.dispatch.BlockStateModelPart;
/** Colours are analysed once per state/resource reload, never from rendered GPU pixels. */
public final class ColoredLightMaterials {
private final Map<BlockState,Integer> colors=new IdentityHashMap<>();
private final Map<Identifier,Integer> sprites=new HashMap<>();
private boolean warm;
public int color(BlockState state){if(warm!=VanillaLight.warmTorches()){warm=VanillaLight.warmTorches();clear();}return colors.computeIfAbsent(state,this::analyse);}
public int color(net.minecraft.world.item.ItemStack stack){
var state=fr.koka.sanctuary.cosmetics.HeadCosmeticData.state(stack);
if(state!=null)return color(state);
var item=stack.getItem();
if(item==net.minecraft.world.item.Items.LAVA_BUCKET)return 0xFF6020;
if(item==net.minecraft.world.item.Items.BLAZE_ROD||item==net.minecraft.world.item.Items.BLAZE_POWDER)return 0xFFAB55;
if(item==net.minecraft.world.item.Items.GLOWSTONE_DUST)return color(net.minecraft.world.level.block.Blocks.GLOWSTONE.defaultBlockState());
if(item==net.minecraft.world.item.Items.GLOW_BERRIES)return 0xFFBD52;
if(item==net.minecraft.world.item.Items.GLOW_INK_SAC||item==net.minecraft.world.item.Items.GLOW_ITEM_FRAME)return 0x66EADD;
return 0xFFFFFF;
}
private int analyse(BlockState state){
var id=BuiltInRegistries.BLOCK.getKey(state.getBlock());String name=id.getPath();
if(id.getNamespace().equals("minecraft")){
if(name.contains("copper"))return 0x62FFA0;
if(name.startsWith("soul_"))return 0x55BDFF;
if(name.contains("redstone"))return 0xFF0602;
if(name.equals("torch")||name.equals("wall_torch"))return VanillaLight.warmTorches()?0xFFAB55:0xFFFFFF;
if(name.equals("lava"))return 0xFF6020;
if(name.equals("pearlescent_froglight"))return 0xFFC0EC;
if(name.equals("verdant_froglight"))return 0xB4FF82;
if(name.equals("ochre_froglight"))return 0xFFCD72;
if(name.contains("torch")||name.equals("lantern")||name.equals("fire")||name.equals("campfire")||name.equals("lava"))return 0xFFAB55;
}
var parts=new ArrayList<BlockStateModelPart>();
Minecraft.getInstance().getModelManager().getBlockStateModelSet().get(state).collectParts(RandomSource.create(0),parts);
var used=new HashSet<Identifier>();long r=0,g=0,b=0;int count=0;
for(var part:parts)for(int f=0;f<7;f++)for(var quad:part.getQuads(f==6?null:Direction.values()[f])){
var sprite=quad.materialInfo().sprite().contents().name();if(!used.add(sprite))continue;
int color=sprites.computeIfAbsent(sprite,this::sample);if(color<0)continue;
r+=color>>16&255;g+=color>>8&255;b+=color&255;count++;
}
if(count==0)return 0xFFFFFF;
return normalize((float)r/count,(float)g/count,(float)b/count);
}
private int sample(Identifier id){
try(var image=BloomMaterials.read(id)){
double r=0,g=0,b=0,weight=0;
// First animation frame; bounded sampling also covers high-resolution packs.
int w=image.getWidth(),h=Math.min(w,image.getHeight()),step=Math.max(1,Math.max(w,h)/32);
for(int y=0;y<h;y+=step)for(int x=0;x<w;x+=step){
int c=image.getPixel(x,y);if((c>>>24)<128)continue;
int cr=c>>16&255,cg=c>>8&255,cb=c&255,max=Math.max(cr,Math.max(cg,cb)),min=Math.min(cr,Math.min(cg,cb));
double k=Math.max(0,(max-96)/159.0);k*=k*(.2+(max-min)/255.0);
r+=cr*k;g+=cg*k;b+=cb*k;weight+=k;
}
return weight<.001?-1:normalize((float)(r/weight),(float)(g/weight),(float)(b/weight));
}catch(java.io.IOException e){return -1;}
}
private static int normalize(float r,float g,float b){
float max=Math.max(1,Math.max(r,Math.max(g,b)));
return Math.round(r/max*255)<<16|Math.round(g/max*255)<<8|Math.round(b/max*255);
}
public void clear(){colors.clear();sprites.clear();}
int cachedStates(){return colors.size();}
}
@@ -0,0 +1,161 @@
package fr.koka.sanctuary.client.shader;
import com.mojang.blaze3d.platform.NativeImage;
import it.unimi.dsi.fastutil.ints.IntArrayFIFOQueue;
import net.minecraft.client.Minecraft;
import net.minecraft.client.multiplayer.ClientLevel;
import net.minecraft.client.renderer.texture.DynamicTexture;
import net.minecraft.core.BlockPos;
import net.minecraft.core.Direction;
import net.minecraft.world.level.LightLayer;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.chunk.LevelChunk;
import net.minecraft.world.level.chunk.status.ChunkStatus;
/** Client-only RGB propagation. Snapshot, flood and packing share a bounded per-frame work budget. */
final class ColoredLightVolume implements AutoCloseable {
static int sideFor(int distance){return (distance+20)*2;}
static int columns(int side){return (int)Math.ceil(Math.sqrt(side));}
private static final Direction[] DIRECTIONS=Direction.values();
private final ColoredLightMaterials materials=new ColoredLightMaterials();
private ClientLevel level;
private Object models;
private Build build;
private DynamicTexture texture,previous,direction,previousDirection;
private boolean directional;
DynamicTexture direction(){return direction;}
DynamicTexture previousDirection(){return previousDirection;}
private BlockPos previousOrigin;
private int side,previousSide;
private long transitionStarted;
float blend(){return Math.clamp((System.nanoTime()-transitionStarted)/300_000_000f,0,1);}
DynamicTexture previous(){return previous;}
BlockPos previousOrigin(){return previousOrigin;}
int previousSide(){return previousSide;}
int side(){return side;}
private BlockPos origin;
private long revision,published=-1,lastStart;
private int completed;
private boolean warm;
void update(){
var mc=Minecraft.getInstance();var modelSet=mc.getModelManager().getBlockStateModelSet();
if(level!=mc.level||models!=modelSet){close();level=mc.level;models=modelSet;}
if(warm!=VanillaLight.warmTorches()){warm=VanillaLight.warmTorches();materials.clear();revision++;if(build!=null){build.close();build=null;}}
var p=mc.gameRenderer.gameRenderState().levelRenderState.cameraRenderState.pos;
boolean wantedDirection=VanillaLight.pbr()&&VanillaLight.pbrIntensity()>0;
int wantedSide=sideFor(VanillaLight.coloredLightDistance());
var wanted=new BlockPos(anchor(p.x,wantedSide),anchor(p.y,wantedSide),anchor(p.z,wantedSide));
long now=System.nanoTime();
if(previous!=null&&blend()>=1){previous.close();previous=null;previousOrigin=null;if(previousDirection!=null){previousDirection.close();previousDirection=null;}}
// Complete a snapshot even if the world keeps changing, then catch up; moving
// beyond it cancels immediately. Static worlds do no further scanning or flooding.
if(build!=null&&(build.directional!=wantedDirection||build.side!=wantedSide||!inside(build.origin,BlockPos.containing(p),build.side))){build.close();build=null;}
if(build==null&&previous==null&&(texture==null||directional!=wantedDirection||side!=wantedSide||!wanted.equals(origin)||published!=revision)&&now-lastStart>=100_000_000L){
build=new Build(level,wanted,wantedSide,revision,materials,wantedDirection);lastStart=now;
}
if(build!=null&&build.advance(now+2_000_000L)){
previous=texture;previousOrigin=origin;previousSide=side;previousDirection=direction;
direction=build.directionImage==null?null:new DynamicTexture(()->"Sanctuary light direction",build.takeDirectionImage());directional=build.directional;
texture=new DynamicTexture(()->"Sanctuary coloured light volume",build.takeImage());
side=build.side;transitionStarted=System.nanoTime();
origin=build.origin;published=build.revision;build.close();build=null;completed++;
}
}
private static int anchor(double p,int side){return Math.floorDiv((int)Math.floor(p),4)*4-side/2;}
private static boolean inside(BlockPos base,BlockPos p,int side){return p.getX()>=base.getX()&&p.getX()<base.getX()+side&&p.getY()>=base.getY()&&p.getY()<base.getY()+side&&p.getZ()>=base.getZ()&&p.getZ()<base.getZ()+side;}
void dirty(BlockPos p){if(origin!=null&&inside(origin,p,side)||build!=null&&inside(build.origin,p,build.side))revision++;}
void dirtyChunk(int x,int z){if(overlaps(origin,x,z,side)||build!=null&&overlaps(build.origin,x,z,build.side))revision++;}
void dirtySection(int x,int y,int z){if(overlapsSection(origin,x,y,z,side)||build!=null&&overlapsSection(build.origin,x,y,z,build.side))revision++;}
private static boolean overlapsSection(BlockPos o,int x,int y,int z,int side){return overlaps(o,x,z,side)&&y>=(o.getY()>>4)&&y<=((o.getY()+side-1)>>4);}
private static boolean overlaps(BlockPos o,int x,int z,int side){return o!=null&&x>=(o.getX()>>4)&&x<=((o.getX()+side-1)>>4)&&z>=(o.getZ()>>4)&&z<=((o.getZ()+side-1)>>4);}
DynamicTexture texture(){return texture;}
BlockPos origin(){return origin;}
boolean settled(){return texture!=null&&build==null&&revision==published&&blend()>=1;}
int completed(){return completed;}
int directionSample(BlockPos p){if(direction==null||!inside(origin,p,side))return 0;int x=p.getX()-origin.getX(),y=p.getY()-origin.getY(),z=p.getZ()-origin.getZ(),cols=columns(side);return direction.getPixels().getPixel(x+(y%cols)*side,z+(y/cols)*side);}
int cachedStates(){return materials.cachedStates();}
int sample(BlockPos p){if(texture==null||!inside(origin,p,side))return 0;int x=p.getX()-origin.getX(),y=p.getY()-origin.getY(),z=p.getZ()-origin.getZ(),cols=columns(side);return texture.getPixels().getPixel(x+(y%cols)*side,z+(y/cols)*side);}
public void close(){if(build!=null){build.close();build=null;}if(texture!=null){texture.close();texture=null;}if(previous!=null){previous.close();previous=null;}if(direction!=null){direction.close();direction=null;}if(previousDirection!=null){previousDirection.close();previousDirection=null;}materials.clear();level=null;models=null;origin=null;previousOrigin=null;side=previousSide=0;published=-1;revision=0;lastStart=0;}
private static final class Build implements AutoCloseable {
final int side,count,cols,chunkSide;
final ClientLevel level;final BlockPos origin;final long revision;final ColoredLightMaterials materials;
final BlockState[] states;
final byte[] red,green,blue,sky;
final boolean[] queued;final IntArrayFIFOQueue queue=new IntArrayFIFOQueue();
final LevelChunk[] chunks;final int cx,cz;
final BlockPos.MutableBlockPos pos=new BlockPos.MutableBlockPos();
NativeImage image,directionImage;final boolean directional;int scan,packed;
private static final double[] ENERGY=new double[256];
static{for(int i=129;i<256;i++)ENERGY[i]=Math.max(0,(Math.pow(.8,31-i/8.)-Math.pow(.8,15))/(1-Math.pow(.8,15)));}
Build(ClientLevel level,BlockPos origin,int side,long revision,ColoredLightMaterials materials,boolean directional){
this.directional=directional;this.side=side;count=side*side*side;cols=columns(side);chunkSide=(side+15)/16+1;
states=new BlockState[count];red=new byte[count];green=new byte[count];blue=new byte[count];sky=new byte[count];queued=new boolean[count];chunks=new LevelChunk[chunkSide*chunkSide];
image=new NativeImage(cols*side,((side+cols-1)/cols)*side,false);
if(directional)directionImage=new NativeImage(image.getWidth(),image.getHeight(),false);
this.level=level;this.origin=origin;this.revision=revision;this.materials=materials;
cx=origin.getX()>>4;cz=origin.getZ()>>4;
for(int z=0;z<chunkSide;z++)for(int x=0;x<chunkSide;x++)chunks[x+z*chunkSide]=level.getChunkSource().getChunk(cx+x,cz+z,ChunkStatus.FULL,false);
}
boolean advance(long deadline){
while(scan<count){scan(scan++);if((scan&127)==0&&System.nanoTime()>=deadline)return false;}
int work=0;
while(!queue.isEmpty()){
int i=queue.dequeueInt();queued[i]=false;spread(i);
if((++work&127)==0&&System.nanoTime()>=deadline)return false;
}
while(packed<count){
int i=packed++,x=i%side,z=i/side%side,y=i/(side*side);
image.setPixel(x+(y%cols)*side,z+(y/cols)*side,(sky[i]&255)<<24|(red[i]&255)<<16|(green[i]&255)<<8|(blue[i]&255));
if(directional)directionImage.setPixel(x+(y%cols)*side,z+(y/cols)*side,directionAt(i,x,y,z));
if((packed&511)==0&&System.nanoTime()>=deadline)return false;
}
return true;
}
void scan(int i){
int x=origin.getX()+(i%side),z=origin.getZ()+(i/side%side),y=origin.getY()+(i/(side*side));pos.set(x,y,z);
var chunk=chunks[((x>>4)-cx)+((z>>4)-cz)*chunkSide];
var state=chunk==null||level.isOutsideBuildHeight(y)?Blocks.BEDROCK.defaultBlockState():chunk.getBlockState(pos);states[i]=state;
if(chunk!=null&&!level.isOutsideBuildHeight(y))sky[i]=(byte)(level.getBrightness(LightLayer.SKY,pos)*17);
int emission=state.getLightEmission();if(emission<=0)return;
int c=materials.color(state);red[i]=(byte)encode(emission,c>>16&255);green[i]=(byte)encode(emission,c>>8&255);blue[i]=(byte)encode(emission,c&255);enqueue(i);
}
void spread(int i){
int r=red[i]&255,g=green[i]&255,b=blue[i]&255;if(Math.max(r,Math.max(g,b))<=136)return;
int x=i%side,z=i/side%side,y=i/(side*side);
for(var d:DIRECTIONS){
int nx=x+d.getStepX(),ny=y+d.getStepY(),nz=z+d.getStepZ();if(nx<0||nx>=side||ny<0||ny>=side||nz<0||nz>=side)continue;
int n=nx+nz*side+ny*side*side;
if(r-8<=(red[n]&255)&&g-8<=(green[n]&255)&&b-8<=(blue[n]&255))continue;
int loss=net.minecraft.world.level.lighting.LightEngine.getLightDampeningInto(states[i],states[n],d,Math.max(1,states[n].getLightDampening()))*8;
int nr=Math.max(0,r-loss),ng=Math.max(0,g-loss),nb=Math.max(0,b-loss);if(Math.max(nr,Math.max(ng,nb))<=128)continue;boolean changed=false;
if(nr>(red[n]&255)){red[n]=(byte)nr;changed=true;}if(ng>(green[n]&255)){green[n]=(byte)ng;changed=true;}if(nb>(blue[n]&255)){blue[n]=(byte)nb;changed=true;}
if(changed)enqueue(n);
}
}
double energy(int i){return ENERGY[Math.max(red[i]&255,Math.max(green[i]&255,blue[i]&255))];}
int directionAt(int i,int x,int y,int z){
double energy=energy(i);if(energy<=0)return 0;
double vx=0,vy=0,vz=0,total=0;
for(var d:DIRECTIONS){
int nx=x+d.getStepX(),ny=y+d.getStepY(),nz=z+d.getStepZ();
if(nx<0||nx>=side||ny<0||ny>=side||nz<0||nz>=side)continue;
int n=nx+nz*side+ny*side*side;double w=Math.max(0,energy(n)-energy);
if(w<=0||net.minecraft.world.level.lighting.LightEngine.getLightDampeningInto(states[n],states[i],d.getOpposite(),Math.max(1,states[i].getLightDampening()))>=15)continue;
vx+=d.getStepX()*w;vy+=d.getStepY()*w;vz+=d.getStepZ()*w;total+=w;
}
// The vector length is confidence: opposing arrivals cancel instead of flipping.
double scale=total>1e-12?127/total:0;
int a=(int)Math.round(255*energy/(energy+.08));
return a<<24|(int)Math.round(128+vx*scale)<<16|(int)Math.round(128+vy*scale)<<8|(int)Math.round(128+vz*scale);
}
NativeImage takeDirectionImage(){var result=directionImage;directionImage=null;return result;}
void enqueue(int i){if(!queued[i]){queued[i]=true;queue.enqueue(i);}}
NativeImage takeImage(){var result=image;image=null;return result;}
public void close(){if(image!=null){image.close();image=null;}if(directionImage!=null){directionImage.close();directionImage=null;}}
}
// An offset preserves dim colour channels below native level zero. The shader
// applies the native cutoff to overall energy, never to each channel separately.
static int encode(int emission,int channel){return channel==0?0:Math.clamp((int)Math.round((emission+16+Math.log(channel/255.0)/Math.log(1.25))*8),0,248);}
}
@@ -0,0 +1,117 @@
package fr.koka.sanctuary.client.shader;
import com.mojang.blaze3d.buffers.Std140Builder;
import com.mojang.blaze3d.pipeline.TextureTarget;
import com.mojang.blaze3d.systems.RenderSystem;
import com.mojang.renderpearl.api.GpuFormat;
import com.mojang.renderpearl.api.buffers.GpuBuffer;
import com.mojang.renderpearl.api.pipeline.*;
import com.mojang.renderpearl.api.textures.FilterMode;
import fr.koka.sanctuary.client.panorama.PanoramaCapture;
import java.util.Optional;
import net.minecraft.client.Minecraft;
import net.minecraft.client.renderer.MappableRingBuffer;
import net.minecraft.client.renderer.RenderPipelines;
import net.minecraft.core.BlockPos;
import net.minecraft.resources.Identifier;
import net.minecraft.world.level.material.FogType;
import org.joml.Matrix4f;
/** Visual local colour only: native block/sky light and gameplay remain unchanged. */
public final class ColoredLights {
private static final RenderPipeline APPLY=RenderPipelines.register(RenderPipeline.builder()
.withLocation(Identifier.parse("sanctuary:pipeline/colored_lights"))
.withVertexShader("core/screenquad").withFragmentShader(Identifier.parse("sanctuary:core/colored_lights"))
.withBindGroupLayout(BindGroupLayout.builder().withUniform("ColoredLightScene",UniformType.UNIFORM_BUFFER)
.withUniform("WorldDepth",UniformType.COMBINED_IMAGE_SAMPLER)
.withUniform("SceneColor",UniformType.COMBINED_IMAGE_SAMPLER)
.withUniform("LightVolume",UniformType.COMBINED_IMAGE_SAMPLER)
.withUniform("PreviousVolume",UniformType.COMBINED_IMAGE_SAMPLER).build())
.withPrimitiveTopology(PrimitiveTopology.TRIANGLES).withCull(false)
.withColorTargetState(new ColorTargetState(Optional.of(new BlendFunction(BlendFactor.ONE,BlendFactor.ZERO,BlendFactor.ZERO,BlendFactor.ONE)),GpuFormat.RGBA8_UNORM,ColorTargetState.WRITE_COLOR))
.withDepthStencilState(Optional.empty()).build());
private static final ColoredLightVolume VOLUME=new ColoredLightVolume();
private static final Matrix4f projection=new Matrix4f();
private static TextureTarget source;
private static net.minecraft.client.renderer.texture.DynamicTexture black;
private static final ColoredDynamicLights DYNAMIC=new ColoredDynamicLights();
private static long activated,lastRender;
private static float displayedDistance;
private static int dynamicCount;
private static MappableRingBuffer uniforms;
private static boolean applied,prepared;
private static int frames;
private ColoredLights(){}
public static void captureProjection(Matrix4f matrix){projection.set(matrix);}
public static void dirtyBlock(BlockPos pos){VOLUME.dirty(pos);}
public static void dirtyChunk(int x,int z){VOLUME.dirtyChunk(x,z);}
public static void dirtySection(int x,int y,int z){if(!fr.koka.sanctuary.client.DynamicLightClient.invalidatingSections())VOLUME.dirtySection(x,y,z);}
public static void prepare(){
applied=false;prepared=false;var mc=Minecraft.getInstance();
if(!VanillaLight.active()||!VanillaLight.coloredLights()||VanillaLight.coloredLightIntensity()==0||mc.level==null){release();return;}
if(PanoramaCapture.rendering())return;
var camera=mc.gameRenderer.gameRenderState().levelRenderState.cameraRenderState;
if(camera.fogType!=FogType.NONE)return;
long now=System.nanoTime();if(activated==0){activated=now;displayedDistance=VanillaLight.coloredLightDistance();}
double elapsed=lastRender==0?0:Math.min(.1,(now-lastRender)/1e9);lastRender=now;
displayedDistance+=(VanillaLight.coloredLightDistance()-displayedDistance)*(float)(1-Math.exp(-elapsed*10));
VOLUME.update();
if(black==null){var image=new com.mojang.blaze3d.platform.NativeImage(1,1,false);image.setPixel(0,0,0);black=new net.minecraft.client.renderer.texture.DynamicTexture(()->"Empty coloured light",image);}
var lights=DYNAMIC.update(camera.pos);dynamicCount=lights.size();
var current=VOLUME.texture()==null?black:VOLUME.texture();
var previous=VOLUME.previous()==null?black:VOLUME.previous();
var main=mc.gameRenderer.mainRenderTarget();
if(source==null||source.width!=main.width||source.height!=main.height){if(source!=null)source.destroyBuffers();source=new TextureTarget("Sanctuary coloured light source",main.width,main.height,GpuFormat.RGBA8_UNORM,null);}
if(uniforms==null)uniforms=new MappableRingBuffer(()->"Sanctuary coloured light scene",GpuBuffer.USAGE_UNIFORM|GpuBuffer.USAGE_MAP_WRITE,176+ColoredDynamicLights.LIMIT*32);
var base=VOLUME.origin()==null?BlockPos.containing(camera.pos):VOLUME.origin();var fog=camera.fogData;
var old=VOLUME.previousOrigin()==null?base:VOLUME.previousOrigin();
int side=VOLUME.texture()==null?1:VOLUME.side(),oldSide=VOLUME.previous()==null?1:VOLUME.previousSide();
uniforms.rotate();try(var mapped=uniforms.currentBuffer().map(false,true)){
var data=Std140Builder.intoBuffer(mapped.data()).putMat4f(new Matrix4f(projection).mul(camera.viewRotationMatrix).invert())
.putVec4((float)(camera.pos.x-base.getX()),(float)(camera.pos.y-base.getY()),(float)(camera.pos.z-base.getZ()),VanillaLight.coloredLightIntensity()/100f)
.putVec4(RenderSystem.getDevice().getDeviceInfo().isZZeroToOne()?1:0,Math.clamp((15-mc.level.getSkyDarken())/15f,0,1),fog.color.w,0)
.putVec4(fog.renderDistanceStart,fog.renderDistanceEnd,fog.environmentalStart,fog.environmentalEnd)
.putVec4(displayedDistance,VOLUME.texture()==null?0:VOLUME.blend(),Math.clamp((now-activated)/300_000_000f,0,1),lights.size())
.putVec4(side,ColoredLightVolume.columns(side),current.getPixels().getWidth(),current.getPixels().getHeight())
.putVec4((float)(camera.pos.x-old.getX()),(float)(camera.pos.y-old.getY()),(float)(camera.pos.z-old.getZ()),VOLUME.previous()==null?0:1)
.putVec4(oldSide,ColoredLightVolume.columns(oldSide),previous.getPixels().getWidth(),previous.getPixels().getHeight());
for(int i=0;i<ColoredDynamicLights.LIMIT;i++){if(i<lights.size()){var l=lights.get(i);data.putVec4((float)(l.x-camera.pos.x),(float)(l.y-camera.pos.y),(float)(l.z-camera.pos.z),l.level);}else data.putVec4(0,0,0,0);}
for(int i=0;i<ColoredDynamicLights.LIMIT;i++){if(i<lights.size()){var l=lights.get(i);data.putVec4((float)l.r,(float)l.g,(float)l.b,(float)l.visibility);}else data.putVec4(0,0,0,0);}
}
prepared=true;
}
static void bindDirections(com.mojang.renderpearl.api.commands.RenderPass pass){
var nearest=RenderSystem.getSamplerCache().getClampToEdge(FilterMode.NEAREST);
pass.setUniform("ColoredLightScene",uniforms.currentBuffer());
pass.setUniform("LightDirection",(VOLUME.direction()==null?black:VOLUME.direction()).getTextureView(),nearest);
pass.setUniform("PreviousDirection",(VOLUME.previousDirection()==null?black:VOLUME.previousDirection()).getTextureView(),nearest);
}
static boolean directionalReady(){return prepared&&VanillaLight.pbr();}
public static void render(){
if(!prepared)return;
var main=Minecraft.getInstance().gameRenderer.mainRenderTarget();
var current=VOLUME.texture()==null?black:VOLUME.texture();
var previous=VOLUME.previous()==null?black:VOLUME.previous();
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);
try(var pass=encoder.createRenderPass(()->"Sanctuary coloured light",main.getColorTextureView(),Optional.empty())){
pass.setPipeline(RenderSystem.getCompiledPipeline(APPLY));pass.setUniform("ColoredLightScene",uniforms.currentBuffer());
pass.setUniform("WorldDepth",main.getDepthTextureView(),nearest);pass.setUniform("SceneColor",source.getColorTextureView(),nearest);pass.setUniform("LightVolume",current.getTextureView(),nearest);pass.setUniform("PreviousVolume",previous.getTextureView(),nearest);pass.draw(3,1,0,0);
}
applied=true;frames++;
}
public static void release(){prepared=false;VOLUME.close();DYNAMIC.clear();activated=lastRender=0;dynamicCount=0;if(black!=null){black.close();black=null;}if(source!=null){source.destroyBuffers();source=null;}if(uniforms!=null){uniforms.close();uniforms=null;}applied=false;}
static boolean applied(){return applied;}
static boolean allocated(){return source!=null||VOLUME.texture()!=null;}
static boolean settled(){return VOLUME.settled();}
static int renderedFrames(){return frames;}
static int builds(){return VOLUME.completed();}
static int sample(BlockPos pos){return VOLUME.sample(pos);}
static net.minecraft.world.phys.Vec3 dynamicPosition(long entity){return DYNAMIC.position(entity);}
static int dynamicCount(){return dynamicCount;}
static float blend(){return VOLUME.blend();}
static int volumeSide(){return VOLUME.side();}
static int directionSample(BlockPos pos){return VOLUME.directionSample(pos);}
static int materials(){return VOLUME.cachedStates();}
}
@@ -0,0 +1,42 @@
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;}
}
@@ -0,0 +1,198 @@
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);
}
}
@@ -0,0 +1,18 @@
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();}
}
@@ -0,0 +1,52 @@
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;}
}
@@ -0,0 +1,130 @@
package fr.koka.sanctuary.client.shader;
import com.mojang.blaze3d.platform.NativeImage;
import java.util.*;
import net.minecraft.client.renderer.texture.DynamicTexture;
import net.minecraft.client.renderer.texture.TextureAtlasSprite;
import net.minecraft.core.registries.BuiltInRegistries;
import net.minecraft.world.level.block.state.BlockState;
/** One source-texture analysis per cached material. No frame readback and no generated files on disk. */
final class PbrMaterials implements AutoCloseable {
static final int INNER=16,TILE=20,GRID=64,SIZE=TILE*GRID,MAX_SPRITES=GRID*GRID;
record Profile(float roughness,float metal){}
record Slot(int x,int y,boolean empty){
float u(float f){return (x+2+Math.clamp(f,0,1)*INNER)/SIZE;}
float v(float f){return (y+2+Math.clamp(f,0,1)*INNER)/SIZE;}
}
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();}
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);
// Dry soil keeps its normal relief without a broad white specular coating.
if(java.util.Set.of("dirt","coarse_dirt","rooted_dirt","grass_block","dirt_path","farmland","podzol","mycelium").contains(name))return new Profile(.98f,0);
if(name.contains("wool")||name.contains("leaves"))return new Profile(.98f,0);
// All wooden shapes share a matte response, including Nether stems/hyphae.
String woodName=name.startsWith("stripped_")?name.substring(9):name;
for(String wood:new String[]{"oak","spruce","birch","jungle","acacia","dark_oak","mangrove","cherry","pale_oak","bamboo","crimson","warped"})
if(woodName.startsWith(wood+"_")&&Set.of("planks","log","wood","stem","hyphae","slab","stairs","fence","fence_gate","door","trapdoor","button","pressure_plate","sign","wall_sign","hanging_sign","wall_hanging_sign","block","mosaic","mosaic_slab","mosaic_stairs").contains(woodName.substring(wood.length()+1)))return new Profile(.98f,0);
if(name.contains("planks")||name.contains("log")||name.contains("wood"))return new Profile(.98f,0);
return new Profile(.82f,0);
}
Slot slot(TextureAtlasSprite sprite,BlockState state){
var profile=profile(state);String key=sprite.contents().name()+":"+profile;
var existing=slots.get(key);if(existing!=null)return existing;
if(slots.size()>=MAX_SPRITES)return new Slot(0,0,true);
int i=slots.size(),x=i%GRID*TILE,y=i/GRID*TILE;
if(atlas==null){atlas=new DynamicTexture("Sanctuary procedural PBR",SIZE,SIZE,false);atlas.getPixels().fillRect(0,0,SIZE,SIZE,0x008080FF);}
boolean empty=false;
try(var image=BloomMaterials.read(sprite.contents().name())){
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;
}
static int[] generate(int[] colors,int w,int h,Profile profile){
int[] result=new int[colors.length];
for(int y=0;y<h;y++)for(int x=0;x<w;x++){
int center=colors[y*w+x];float base=luma(center);
float left=height(colors[y*w+Math.floorMod(x-1,w)],base),right=height(colors[y*w+(x+1)%w],base);
float up=height(colors[Math.floorMod(y-1,h)*w+x],base),down=height(colors[(y+1)%h*w+x],base);
float nx=Math.clamp((left-right)*2,-.45f,.45f),ny=Math.clamp((up-down)*2,-.45f,.45f);
if((center>>>24)<128){nx=ny=0;}
int red=Math.round((nx*.5f+.5f)*255),green=Math.round((ny*.5f+.5f)*255);
result[y*w+x]=(Math.round(profile.metal*255)<<24)|(red<<16)|(green<<8)|Math.round(profile.roughness*255);
}
return result;
}
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();}
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;}
}
@@ -0,0 +1,165 @@
package fr.koka.sanctuary.client.shader;
import com.mojang.blaze3d.systems.RenderSystem;
import com.mojang.blaze3d.vertex.BufferBuilder;
import com.mojang.blaze3d.vertex.ByteBufferBuilder;
import com.mojang.renderpearl.api.GpuFormat;
import com.mojang.renderpearl.api.buffers.GpuBuffer;
import com.mojang.renderpearl.api.pipeline.PrimitiveTopology;
import com.mojang.renderpearl.api.vertex.VertexFormat;
import java.util.*;
import net.minecraft.client.Minecraft;
import net.minecraft.client.model.geom.builders.UVPair;
import net.minecraft.client.renderer.block.dispatch.BlockStateModelPart;
import net.minecraft.client.renderer.texture.TextureAtlas;
import net.minecraft.core.BlockPos;
import net.minecraft.core.Direction;
import net.minecraft.core.SectionPos;
import net.minecraft.util.RandomSource;
import net.minecraft.world.level.chunk.LevelChunkSection;
import net.minecraft.world.level.chunk.status.ChunkStatus;
import net.minecraft.world.phys.Vec3;
/** Nearby native sections, bounded incremental meshing; models and resource UV rotations remain native. */
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 final Map<Long,Section> sections=new HashMap<>();
final PbrMaterials materials=new PbrMaterials();
private Build building;
private long bytes;
private int completed;
private Object models,level;
private List<Section> visible=List.of();
record Entry(BlockPos origin,GpuBuffer vertices,int indices){}
List<Entry> update(){
var mc=Minecraft.getInstance();var current=mc.getModelManager().getBlockStateModelSet();
if(models!=current||level!=mc.level){close();models=current;level=mc.level;}
Vec3 camera=mc.gameRenderer.gameRenderState().levelRenderState.cameraRenderState.pos;
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.materialDistance()+16,2))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()));
var section=sections.get(key);
if(section==null||section.storage!=storage){
Entry retained=section==null?null:section.entry;
if(section!=null){section.entry=null;remove(section);}
section=new Section(key,pos.immutable(),storage);section.entry=retained;sections.put(key,section);
}
section.seen=frame;active.add(section);
}
visible=active;
for(var iterator=sections.values().iterator();iterator.hasNext();){var section=iterator.next();if(frame-section.seen>40){remove(section);iterator.remove();}}
long deadline=System.nanoTime()+3_000_000L;int started=building==null?0:1;
while(System.nanoTime()<deadline){
if(building==null){
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;}
building=new Build(next);
}
if(!advance(building,deadline))break;
var done=building;building=null;
try(done){
done.section.dirty=done.section.revision!=done.revision;
Entry replacement=null;
if(done.quads>0&&!done.overflow){
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){
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);
}
}
}
// Publish atomically. A dirty section stays visible while its new
// geometry is built, including when light updates arrive mid-build.
releaseEntry(done.section);done.section.entry=replacement;
if(replacement!=null)bytes+=replacement.vertices.size();
completed++;
}
}
return active.stream().map(s->s.entry).filter(Objects::nonNull).toList();
}
private boolean advance(Build build,long deadline){
var mc=Minecraft.getInstance();var models=mc.getModelManager().getBlockStateModelSet();
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;}
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);
var offset=state.getOffset(pos);
for(var part:parts)for(int f=0;f<7;f++){
Direction face=f==6?null:Direction.values()[f];
if(face!=null&&mc.level.getBlockState(pos.relative(face)).isSolidRender())continue;
for(var quad:part.getQuads(face)){
var material=quad.materialInfo();var sprite=material.sprite();
if(!sprite.atlasLocation().equals(TextureAtlas.LOCATION_BLOCKS))continue;
var slot=materials.slot(sprite,state);
if(slot.empty())continue;
if(build.quads>=16384){build.overflow=true;return true;}
int tint=0xFFFFFF,index=material.tintIndex();
if(index>=0&&index<tintSources.size())tint=tintSources.get(index).colorInWorld(state,mc.level,pos)&0xFFFFFF;
int sky=mc.level.getBrightness(net.minecraft.world.level.LightLayer.SKY,face==null?pos:pos.relative(face));
int color=(Math.round(sky/15f*255)<<24)|tint;
for(int v=0;v<4;v++){
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));
}
build.quads++;
}
}
}
if(System.nanoTime()>=deadline)return build.cursor==4096;
}
return true;
}
void dirtySection(int x,int y,int z){dirty(sections.get(SectionPos.asLong(x,y,z)));}
void dirty(BlockPos pos){for(int x=(pos.getX()-1)>>4;x<=(pos.getX()+1)>>4;x++)for(int y=(pos.getY()-1)>>4;y<=(pos.getY()+1)>>4;y++)for(int z=(pos.getZ()-1)>>4;z<=(pos.getZ()+1)>>4;z++)dirty(sections.get(SectionPos.asLong(x,y,z)));}
void dirtyChunk(int x,int z){for(var section:sections.values())if(Math.abs((section.origin.getX()>>4)-x)<=1&&Math.abs((section.origin.getZ()>>4)-z)<=1)dirty(section);}
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;}
boolean settled(){return building==null&&visible.stream().noneMatch(s->s.dirty);}
public void close(){if(building!=null){building.close();building=null;}for(var section:sections.values())remove(section);sections.clear();visible=List.of();materials.close();models=level=null;bytes=0;}
private static final class Section{
final long key;final BlockPos origin;final LevelChunkSection storage;long seen,revision;boolean dirty=true;Entry entry;
Section(long key,BlockPos origin,LevelChunkSection storage){this.key=key;this.origin=origin;this.storage=storage;}
}
private static final class Build implements AutoCloseable{
final Section section;final long revision;final ByteBufferBuilder buffer=new ByteBufferBuilder(65536);final BufferBuilder out=new BufferBuilder(buffer,PrimitiveTopology.QUADS,FORMAT);
int cursor,quads;boolean overflow;
Build(Section section){this.section=section;revision=section.revision;}
public void close(){buffer.close();}
}
}
@@ -0,0 +1,151 @@
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.ssrDistance())
.putVec4(Math.max(128,VanillaLight.ssrDistance()),32,VanillaLight.materialDistance(),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;}
}
@@ -55,7 +55,7 @@ public final class PixelShadows {
private static TextureTarget shadowMap;
private static MappableRingBuffer uniforms;
private static int renderedFrames;
private static boolean applied;
private static boolean applied, depthReady;
private PixelShadows() { }
public static void register() {
@@ -73,8 +73,9 @@ public final class PixelShadows {
/** Called after the world's depth is resolved and before the first-person hand and GUI. */
public static void render() {
applied = false;
depthReady = false;
var client = Minecraft.getInstance();
if (!VanillaLight.active() || !VanillaLight.pixelShadows() || client.level == null) {
if (!VanillaLight.active() || (!VanillaLight.pixelShadows() && !(VanillaLight.sunshafts() && VanillaLight.sunshaftIntensity()>0)) || client.level == null) {
release();
return;
}
@@ -84,12 +85,16 @@ public final class PixelShadows {
CameraRenderState camera = state.cameraRenderState;
var sun = sunDirection(sky.sunAngle);
boolean lunar = sun.y <= .08f;
if (lunar && !VanillaLight.pixelShadows()) { release(); return; }
if (lunar) sun = sunDirection(sky.moonAngle);
if (sky.skybox != DimensionType.Skybox.OVERWORLD || camera.fogType != FogType.NONE || sun.y <= .08f) return;
float moon = sky.moonPhase == null ? 1 : (float)(.5 + .5 * Math.cos(sky.moonPhase.index() * Math.PI / 4));
float strength = (lunar ? .10f * moon : .38f)
* Math.clamp((sun.y - .08f) / .16f, 0, 1) * sky.rainBrightness;
if (strength <= .001f) return;
// Rain hides the native solar sprite completely. The atmospheric volume still
// needs occluders for its weak scattered daylight; surface shadows stay off.
boolean solarVolume=!lunar&&VanillaLight.sunshafts()&&VanillaLight.sunshaftIntensity()>0;
if (strength <= .001f && !solarVolume) return;
int distance = VanillaLight.shadowDistance(), pixels = VanillaLight.shadowPixels();
TERRAIN.update(client.level, camera.pos, distance, sun);
int size = mapSize(distance, pixels);
@@ -139,6 +144,8 @@ public final class PixelShadows {
pass.drawIndexed(entry.indexCount(), 1, 0, 0, 0);
}
}
depthReady = !lunar;
if (!VanillaLight.pixelShadows() || strength <= .001f) { renderedFrames++; return; }
var main = client.gameRenderer.mainRenderTarget();
var nearest = RenderSystem.getSamplerCache().getClampToEdge(FilterMode.NEAREST);
try (var pass = encoder.createRenderPass(() -> "Sanctuary pixel shadows", main.getColorTextureView(), Optional.empty())) {
@@ -154,6 +161,8 @@ public final class PixelShadows {
}
static double anchor(double coordinate) { return Math.floor(coordinate / 16.0 + .5) * 16.0; }
static com.mojang.renderpearl.api.buffers.GpuBuffer materialUniforms(){return uniforms.currentBuffer();}
static com.mojang.renderpearl.api.textures.GpuTextureView materialDepth(){return shadowMap.getDepthTextureView();}
static Vector3f sunDirection(float angle) { return new Vector3f(-(float)Math.sin(angle), (float)Math.cos(angle), 0); }
static int mapSize(int distance, int pixels) {
int size = 512;
@@ -166,7 +175,9 @@ public final class PixelShadows {
if (shadowMap != null) { shadowMap.destroyBuffers(); shadowMap = null; }
if (uniforms != null) { uniforms.close(); uniforms = null; }
applied = false;
depthReady = false;
}
static boolean depthReady() { return depthReady; }
static int renderedFrames() { return renderedFrames; }
static boolean applied() { return applied; }
static boolean ready() { return TERRAIN.settled(); }
@@ -0,0 +1,119 @@
package fr.koka.sanctuary.client.shader;
import com.mojang.blaze3d.buffers.Std140Builder;
import com.mojang.blaze3d.pipeline.TextureTarget;
import com.mojang.blaze3d.systems.RenderSystem;
import com.mojang.renderpearl.api.GpuFormat;
import com.mojang.renderpearl.api.buffers.GpuBuffer;
import com.mojang.renderpearl.api.buffers.GpuBufferSlice;
import com.mojang.renderpearl.api.pipeline.*;
import com.mojang.renderpearl.api.textures.FilterMode;
import fr.koka.sanctuary.client.panorama.PanoramaCapture;
import java.util.*;
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.TextureAtlas;
import net.minecraft.core.BlockPos;
import net.minecraft.resources.Identifier;
import net.minecraft.world.level.dimension.DimensionType;
import net.minecraft.world.level.material.FogType;
import org.joml.Matrix4f;
import org.joml.Vector3f;
import org.joml.Vector4f;
/** Optional local material pass: cached texture normals, GGX highlights and diffuse relief. */
public final class ProceduralPbr {
private static final BindGroupLayout SCENE=BindGroupLayout.builder().withUniform("PbrScene",UniformType.UNIFORM_BUFFER)
.withUniform("PixelShadows",UniformType.UNIFORM_BUFFER).withUniform("SunDepth",UniformType.COMBINED_IMAGE_SAMPLER)
.withUniform("WorldDepth",UniformType.COMBINED_IMAGE_SAMPLER).withUniform("SceneColor",UniformType.COMBINED_IMAGE_SAMPLER)
.withUniform("ColoredLightScene",UniformType.UNIFORM_BUFFER).withUniform("LightDirection",UniformType.COMBINED_IMAGE_SAMPLER).withUniform("PreviousDirection",UniformType.COMBINED_IMAGE_SAMPLER)
.withUniform("BlockAtlas",UniformType.COMBINED_IMAGE_SAMPLER).withUniform("MaterialAtlas",UniformType.COMBINED_IMAGE_SAMPLER).build();
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)
.withColorTargetState(new ColorTargetState(Optional.empty(),GpuFormat.RGBA8_UNORM,ColorTargetState.WRITE_COLOR))
.withDepthStencilState(new DepthStencilState(CompareOp.EQUAL,false)).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 render(){
applied=false;var mc=Minecraft.getInstance();
if(!VanillaLight.active()||!VanillaLight.pbr()||VanillaLight.pbrIntensity()==0||mc.level==null){release();return;}
var state=mc.gameRenderer.gameRenderState().levelRenderState;var camera=state.cameraRenderState;var sky=state.skyRenderState;
if(PanoramaCapture.rendering()||camera.fogType!=FogType.NONE||sky.skybox!=DimensionType.Skybox.OVERWORLD)return;
var main=mc.gameRenderer.mainRenderTarget();
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 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));
float strength=(lunar?.12f*moon:1)*sky.rainBrightness*Math.clamp((sun.y-.04f)/.16f,0,1);
var sunColour=lunar?new org.joml.Vector3f(1):SolarColour.tint(sky);
boolean shadow=PixelShadows.applied();
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(fog.renderDistanceStart,fog.renderDistanceEnd,fog.environmentalStart,fog.environmentalEnd).putVec4(fog.color.w,ColoredLights.directionalReady()?1:0,lunar?0:1,0)
.putVec4(sunColour.x*sunColour.x,sunColour.y*sunColour.y,sunColour.z*sunColour.z,1)
.putVec4((float)(camera.pos.x%256),(float)(camera.pos.y%256),(float)(camera.pos.z%256),0)
.putVec4(0,0,0,0).putVec4(0,0,0,0);
}
var transforms=new ArrayList<GpuBufferSlice>(entries.size());int max=0;
for(var entry:entries){var p=entry.origin();var offset=new Vector3f((float)(p.getX()-camera.pos.x),(float)(p.getY()-camera.pos.y),(float)(p.getZ()-camera.pos.z));
transforms.add(RenderSystem.getDynamicUniforms().writeTransform(new Matrix4f(vp).translate(offset),new Vector4f(1,1,1,1),offset,new Matrix4f()));max=Math.max(max,entry.indices());}
var indices=RenderSystem.getSequentialBuffer(PrimitiveTopology.QUADS);indices.requestIndexCount(max);
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())){
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);
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);}
}
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();}
static boolean allocated(){return source!=null;}static int renderedFrames(){return frames;}
static int terrainEntries(){return TERRAIN.entryCount();}
static int terrainBuilds(){return TERRAIN.completed();}
static boolean terrainBuilding(){return TERRAIN.building();}
static int materialCount(){return TERRAIN.materials.count();}static int analyses(){return TERRAIN.materials.analyses();}
}
@@ -0,0 +1,100 @@
package fr.koka.sanctuary.client.shader;
import com.mojang.blaze3d.buffers.Std140Builder;
import com.mojang.blaze3d.pipeline.TextureTarget;
import com.mojang.blaze3d.systems.RenderSystem;
import com.mojang.renderpearl.api.GpuFormat;
import com.mojang.renderpearl.api.buffers.GpuBuffer;
import com.mojang.renderpearl.api.pipeline.*;
import com.mojang.renderpearl.api.textures.FilterMode;
import fr.koka.sanctuary.client.panorama.PanoramaCapture;
import java.util.Optional;
import net.minecraft.client.Minecraft;
import net.minecraft.client.renderer.MappableRingBuffer;
import net.minecraft.client.renderer.RenderPipelines;
import net.minecraft.resources.Identifier;
import net.minecraft.world.level.material.FogType;
import org.joml.Matrix4f;
/** Bounded, single-bounce diffuse lighting from this frame's visible surfaces. No GPU readback or history. */
public final class ScreenSpaceGI {
private static final BindGroupLayout LAYOUT=BindGroupLayout.builder()
.withUniform("GIParams",UniformType.UNIFORM_BUFFER)
.withUniform("WorldDepth",UniformType.COMBINED_IMAGE_SAMPLER)
.withUniform("SceneColor",UniformType.COMBINED_IMAGE_SAMPLER)
.withUniform("IndirectLight",UniformType.COMBINED_IMAGE_SAMPLER).build();
private static RenderPipeline pipeline(String name,boolean composite){
return RenderPipelines.register(RenderPipeline.builder().withLocation(Identifier.parse("sanctuary:pipeline/"+name))
.withVertexShader("core/screenquad").withFragmentShader(Identifier.parse("sanctuary:core/"+name))
.withBindGroupLayout(LAYOUT).withBindGroupLayout(BindGroupLayout.builder().withUniform("GIBlur",UniformType.UNIFORM_BUFFER).build()).withPrimitiveTopology(PrimitiveTopology.TRIANGLES).withCull(false)
.withColorTargetState(new ColorTargetState(composite?Optional.of(new BlendFunction(BlendFactor.ONE,BlendFactor.ZERO,BlendFactor.ZERO,BlendFactor.ONE)):Optional.empty(),
composite?GpuFormat.RGBA8_UNORM:GpuFormat.RGBA16_FLOAT,composite?ColorTargetState.WRITE_COLOR:ColorTargetState.WRITE_ALL))
.withDepthStencilState(Optional.empty()).build());
}
private static final RenderPipeline GATHER=pipeline("ssgi_gather",false),APPLY=pipeline("ssgi_apply",true),FILTER=pipeline("ssgi_filter",false);
private static final Matrix4f projection=new Matrix4f();
private static TextureTarget source,indirect,filtered;
private static MappableRingBuffer uniforms,horizontal,vertical;
private static boolean applied;
private static int frames;
private ScreenSpaceGI(){}
public static void captureProjection(Matrix4f matrix){projection.set(matrix);}
public static void render(){
applied=false;var mc=Minecraft.getInstance();
if(!VanillaLight.active()||!VanillaLight.ssgi()||VanillaLight.ssgiIntensity()==0||mc.level==null){release();return;}
if(PanoramaCapture.rendering())return;
var camera=mc.gameRenderer.gameRenderState().levelRenderState.cameraRenderState;
if(camera.fogType!=FogType.NONE)return;
var main=mc.gameRenderer.mainRenderTarget();
if(source==null||source.width!=main.width||source.height!=main.height){
release();source=new TextureTarget("Sanctuary SSGI source",main.width,main.height,GpuFormat.RGBA8_UNORM,null);
indirect=new TextureTarget("Sanctuary SSGI indirect",Math.max(1,(main.width+1)/2),Math.max(1,(main.height+1)/2),GpuFormat.RGBA16_FLOAT,null);
filtered=new TextureTarget("Sanctuary SSGI filtered",indirect.width,indirect.height,GpuFormat.RGBA16_FLOAT,null);
}
if(uniforms==null){
uniforms=new MappableRingBuffer(()->"Sanctuary SSGI",GpuBuffer.USAGE_UNIFORM|GpuBuffer.USAGE_MAP_WRITE,176);
horizontal=new MappableRingBuffer(()->"Sanctuary SSGI filter X",GpuBuffer.USAGE_UNIFORM|GpuBuffer.USAGE_MAP_WRITE,16);
vertical=new MappableRingBuffer(()->"Sanctuary SSGI filter Y",GpuBuffer.USAGE_UNIFORM|GpuBuffer.USAGE_MAP_WRITE,16);
}
horizontal.rotate();try(var m=horizontal.currentBuffer().map(false,true)){Std140Builder.intoBuffer(m.data()).putVec4(1,0,0,0);}
vertical.rotate();try(var m=vertical.currentBuffer().map(false,true)){Std140Builder.intoBuffer(m.data()).putVec4(0,1,0,0);}
var vp=new Matrix4f(projection).mul(camera.viewRotationMatrix);var fog=camera.fogData;
uniforms.rotate();try(var mapped=uniforms.currentBuffer().map(false,true)){
Std140Builder.intoBuffer(mapped.data()).putMat4f(new Matrix4f(vp).invert()).putMat4f(vp)
.putVec4(VanillaLight.ssgiIntensity()/100f,RenderSystem.getDevice().getDeviceInfo().isZZeroToOne()?1:0,2.5f,32)
.putVec4(fog.renderDistanceStart,fog.renderDistanceEnd,fog.environmentalStart,fog.environmentalEnd)
.putVec4(fog.color.w,0,0,0);
}
// Snapshot once, before adding indirect light: no feedback, no texture sampled while attached for writing.
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);
try(var pass=encoder.createRenderPass(()->"Sanctuary SSGI gather",indirect.getColorTextureView(),Optional.empty())){
pass.setPipeline(RenderSystem.getCompiledPipeline(GATHER));pass.setUniform("GIBlur",horizontal.currentBuffer());pass.setUniform("GIParams",uniforms.currentBuffer());
pass.setUniform("WorldDepth",main.getDepthTextureView(),nearest);pass.setUniform("SceneColor",source.getColorTextureView(),nearest);
// Unused by gather; bind a separate texture, never its own output.
pass.setUniform("IndirectLight",source.getColorTextureView(),nearest);pass.draw(3,1,0,0);
}
filter(encoder,indirect,filtered,horizontal);
filter(encoder,filtered,indirect,vertical);
try(var pass=encoder.createRenderPass(()->"Sanctuary SSGI composite",main.getColorTextureView(),Optional.empty())){
pass.setPipeline(RenderSystem.getCompiledPipeline(APPLY));pass.setUniform("GIBlur",horizontal.currentBuffer());pass.setUniform("GIParams",uniforms.currentBuffer());
pass.setUniform("WorldDepth",main.getDepthTextureView(),nearest);pass.setUniform("SceneColor",source.getColorTextureView(),nearest);
pass.setUniform("IndirectLight",indirect.getColorTextureView(),nearest);pass.draw(3,1,0,0);
}
applied=true;frames++;
}
private static void filter(com.mojang.renderpearl.api.commands.CommandEncoder encoder,TextureTarget input,TextureTarget output,MappableRingBuffer direction){
var main=Minecraft.getInstance().gameRenderer.mainRenderTarget();var nearest=RenderSystem.getSamplerCache().getClampToEdge(FilterMode.NEAREST);
try(var pass=encoder.createRenderPass(()->"Sanctuary SSGI bilateral filter",output.getColorTextureView(),Optional.empty())){
pass.setPipeline(RenderSystem.getCompiledPipeline(FILTER));pass.setUniform("GIParams",uniforms.currentBuffer());pass.setUniform("GIBlur",direction.currentBuffer());
pass.setUniform("WorldDepth",main.getDepthTextureView(),nearest);pass.setUniform("SceneColor",source.getColorTextureView(),nearest);
pass.setUniform("IndirectLight",input.getColorTextureView(),RenderSystem.getSamplerCache().getClampToEdge(FilterMode.LINEAR));pass.draw(3,1,0,0);
}
}
public static void release(){if(source!=null){source.destroyBuffers();indirect.destroyBuffers();filtered.destroyBuffers();source=indirect=filtered=null;}if(uniforms!=null){uniforms.close();horizontal.close();vertical.close();uniforms=horizontal=vertical=null;}applied=false;}
static boolean applied(){return applied;}
static boolean allocated(){return source!=null;}
static int renderedFrames(){return frames;}
}
@@ -14,16 +14,51 @@ 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));
list.addBig(new OptionInstance<Integer>("sanctuary.shaders.precision",OptionInstance.cachedConstantTooltip(
Component.translatable("sanctuary.shaders.precision_help")),(caption,value)->Component.translatable("sanctuary.shaders.precision_value",value),
new OptionInstance.Enum<>(List.of(8,16,32),Codec.INT),VanillaLight.shadowPixels(),VanillaLight::setShadowPixels));
new OptionInstance.Enum<>(List.of(8,16,32,64,128),Codec.INT),VanillaLight.shadowPixels(),VanillaLight::setShadowPixels));
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(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,256),Codec.INT),VanillaLight.pbrDistance(),VanillaLight::setPbrDistance));
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.ssr_distance",OptionInstance.cachedConstantTooltip(
Component.translatable("sanctuary.shaders.ssr_distance_help")),(caption,value)->Component.translatable("sanctuary.shaders.ssr_distance_value",value),
new OptionInstance.Enum<>(List.of(16,32,64,128,256),Codec.INT),VanillaLight.ssrDistance(),VanillaLight::setSsrDistance));
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(
Component.translatable("sanctuary.shaders.warm_torches_help")),VanillaLight.warmTorches(),VanillaLight::setWarmTorches));
slider("colored_light_intensity",VanillaLight.coloredLightIntensity(),VanillaLight::setColoredLightIntensity);
list.addBig(new OptionInstance<Integer>("sanctuary.shaders.colored_light_distance",OptionInstance.cachedConstantTooltip(
Component.translatable("sanctuary.shaders.colored_light_distance_help")),(caption,value)->Component.translatable("sanctuary.shaders.colored_light_distance_value",value),
new OptionInstance.Enum<>(List.of(8,16,32,64),Codec.INT),VanillaLight.coloredLightDistance(),VanillaLight::setColoredLightDistance));
list.addBig(OptionInstance.createBoolean("sanctuary.shaders.ssgi",OptionInstance.cachedConstantTooltip(
Component.translatable("sanctuary.shaders.ssgi_help")),VanillaLight.ssgi(),VanillaLight::setSsgi));
slider("ssgi_intensity",VanillaLight.ssgiIntensity(),VanillaLight::setSsgiIntensity);
list.addBig(OptionInstance.createBoolean("sanctuary.shaders.bloom",OptionInstance.cachedConstantTooltip(
Component.translatable("sanctuary.shaders.bloom_help")),VanillaLight.bloom(),VanillaLight::setBloom));
slider("bloom_intensity",VanillaLight.bloomIntensity(),VanillaLight::setBloomIntensity);
slider("bloom_radius",VanillaLight.bloomRadius(),VanillaLight::setBloomRadius);
list.addBig(OptionInstance.createBoolean("sanctuary.shaders.sunshafts",OptionInstance.cachedConstantTooltip(
Component.translatable("sanctuary.shaders.sunshafts_help")),VanillaLight.sunshafts(),VanillaLight::setSunshafts));
slider("sunshaft_intensity",VanillaLight.sunshaftIntensity(),VanillaLight::setSunshaftIntensity);
list.addBig(OptionInstance.createBoolean("sanctuary.shaders.lens_flare",OptionInstance.cachedConstantTooltip(
Component.translatable("sanctuary.shaders.lens_flare_help")),VanillaLight.lensFlare(),VanillaLight::setLensFlare));
slider("lens_flare_intensity",VanillaLight.lensFlareIntensity(),VanillaLight::setLensFlareIntensity);
list.addBig(OptionInstance.createBoolean("sanctuary.shaders.ore_emission",OptionInstance.cachedConstantTooltip(
Component.translatable("sanctuary.shaders.ore_emission_help")),VanillaLight.oreEmission(),VanillaLight::setOreEmission));
slider("ore_intensity",VanillaLight.oreIntensity(),VanillaLight::setOreIntensity);
@@ -0,0 +1,15 @@
package fr.koka.sanctuary.client.shader;
import net.minecraft.client.renderer.state.level.SkyRenderState;
import org.joml.Vector3f;
/** Uses the exact colour and fade already extracted for Minecraft's horizon. */
public final class SolarColour {
private SolarColour(){}
public static Vector3f tint(SkyRenderState sky){
var colour=sky.sunriseAndSunsetColor;
if(colour==null)return new Vector3f(1);
float weight=Math.clamp(colour.w(),0,1);
return new Vector3f(1).lerp(new Vector3f(colour.x(),colour.y(),colour.z()),weight);
}
}
@@ -0,0 +1,86 @@
package fr.koka.sanctuary.client.shader;
import com.mojang.blaze3d.buffers.Std140Builder;
import com.mojang.blaze3d.pipeline.TextureTarget;
import com.mojang.blaze3d.systems.RenderSystem;
import com.mojang.renderpearl.api.GpuFormat;
import com.mojang.renderpearl.api.buffers.GpuBuffer;
import com.mojang.renderpearl.api.pipeline.*;
import com.mojang.renderpearl.api.textures.FilterMode;
import fr.koka.sanctuary.client.panorama.PanoramaCapture;
import java.util.Optional;
import net.minecraft.client.Minecraft;
import net.minecraft.client.renderer.MappableRingBuffer;
import net.minecraft.client.renderer.RenderPipelines;
import net.minecraft.resources.Identifier;
/** Solar scattering in the existing shadow volume. No scene copy, readback, temporal history or terrain duplication. */
public final class Sunshafts {
private static final BindGroupLayout SCENE=BindGroupLayout.builder()
.withUniform("PixelShadows",UniformType.UNIFORM_BUFFER)
.withUniform("ShaftParams",UniformType.UNIFORM_BUFFER)
.withUniform("WorldDepth",UniformType.COMBINED_IMAGE_SAMPLER).build();
private static RenderPipeline pipeline(String name,boolean composite){
var inputs=BindGroupLayout.builder();
if(composite)inputs.withUniform("ShaftLight",UniformType.COMBINED_IMAGE_SAMPLER);
else inputs.withUniform("SunDepth",UniformType.COMBINED_IMAGE_SAMPLER);
return RenderPipelines.register(RenderPipeline.builder().withLocation(Identifier.parse("sanctuary:pipeline/"+name))
.withVertexShader("core/screenquad").withFragmentShader(Identifier.parse("sanctuary:core/"+name))
.withBindGroupLayout(SCENE).withBindGroupLayout(inputs.build())
.withPrimitiveTopology(PrimitiveTopology.TRIANGLES).withCull(false)
.withColorTargetState(new ColorTargetState(composite?Optional.of(new BlendFunction(BlendFactor.ONE,BlendFactor.ONE_MINUS_SRC_COLOR,BlendFactor.ZERO,BlendFactor.ONE)):Optional.empty(),
composite?GpuFormat.RGBA8_UNORM:GpuFormat.RGBA16_FLOAT,composite?ColorTargetState.WRITE_COLOR:ColorTargetState.WRITE_ALL))
.withDepthStencilState(Optional.empty()).build());
}
private static final RenderPipeline GATHER=pipeline("sunshaft_gather",false),APPLY=pipeline("sunshaft_apply",true);
private static TextureTarget light;
private static MappableRingBuffer settings;
private static boolean applied;
private static int frames;
private Sunshafts(){}
public static void render(){
applied=false;var mc=Minecraft.getInstance();
if(!VanillaLight.active()||!VanillaLight.sunshafts()||VanillaLight.sunshaftIntensity()==0||mc.level==null){release();return;}
if(PanoramaCapture.rendering())return;
if(!PixelShadows.depthReady()){release();return;}
var main=mc.gameRenderer.mainRenderTarget();int width=Math.max(1,(main.width+3)/4),height=Math.max(1,(main.height+3)/4);
if(light==null||light.width!=width||light.height!=height){release();light=new TextureTarget("Sanctuary sunshafts",width,height,GpuFormat.RGBA16_FLOAT,null);}
if(settings==null)settings=new MappableRingBuffer(()->"Sanctuary sunshafts",GpuBuffer.USAGE_UNIFORM|GpuBuffer.USAGE_MAP_WRITE,48);
var camera=mc.gameRenderer.gameRenderState().levelRenderState.cameraRenderState;
var fog=camera.fogData;
float mist=fogDensity(fog.environmentalEnd);
float rain=mc.level.getRainLevel(camera.cameraEntityPartialTicks);
float thunder=mc.level.getThunderLevel(camera.cameraEntityPartialTicks);
var sun=PixelShadows.sunDirection(mc.gameRenderer.gameRenderState().levelRenderState.skyRenderState.sunAngle);
float daylight=Math.clamp((sun.y-.08f)/.16f,0,1)*(1-.75f*rain)*(1-.65f*thunder);
float density=1+2.5f*mist+.8f*rain;
float spread=Math.max(mist,.35f*rain);
float extinction=1+.7f*mist+.25f*rain;
settings.rotate();try(var mapped=settings.currentBuffer().map(false,true)){
Std140Builder.intoBuffer(mapped.data()).putVec4(.85f+.15f*fog.color.x,.78f+.15f*fog.color.y,.66f+.15f*fog.color.z,0)
.putVec4(VanillaLight.sunshaftIntensity()/100f,Math.min(64,VanillaLight.shadowDistance()*.5f),0,0)
.putVec4(density,spread,extinction,daylight);
}
var encoder=RenderSystem.getDevice().createCommandEncoder();var nearest=RenderSystem.getSamplerCache().getClampToEdge(FilterMode.NEAREST);
try(var pass=encoder.createRenderPass(()->"Sanctuary solar volume",light.getColorTextureView(),Optional.empty())){
pass.setPipeline(RenderSystem.getCompiledPipeline(GATHER));pass.setUniform("PixelShadows",PixelShadows.materialUniforms());pass.setUniform("ShaftParams",settings.currentBuffer());
pass.setUniform("WorldDepth",main.getDepthTextureView(),nearest);pass.setUniform("SunDepth",PixelShadows.materialDepth(),nearest);pass.draw(3,1,0,0);
}
try(var pass=encoder.createRenderPass(()->"Sanctuary solar volume composite",main.getColorTextureView(),Optional.empty())){
pass.setPipeline(RenderSystem.getCompiledPipeline(APPLY));pass.setUniform("PixelShadows",PixelShadows.materialUniforms());pass.setUniform("ShaftParams",settings.currentBuffer());
pass.setUniform("WorldDepth",main.getDepthTextureView(),nearest);pass.setUniform("ShaftLight",light.getColorTextureView(),nearest);pass.draw(3,1,0,0);
}
applied=true;frames++;
}
/** Environmental fog has its own end distance; changing render distance is not weather. */
static float fogDensity(float environmentalEnd){
if(!Float.isFinite(environmentalEnd))return 0;
float t=Math.clamp((192-environmentalEnd)/112,0,1);
return t*t*(3-2*t);
}
public static void release(){if(light!=null){light.destroyBuffers();light=null;}if(settings!=null){settings.close();settings=null;}applied=false;}
static boolean applied(){return applied;}
static boolean allocated(){return light!=null;}
static int renderedFrames(){return frames;}
static TextureTarget target(){return light;}
}
@@ -16,14 +16,37 @@ 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=false;
int bloomIntensity=35;
boolean bloom=true;
boolean sunshafts=true;
int sunshaftIntensity=35;
boolean lensFlare=true;
int lensFlareIntensity=30;
boolean pbr=true;
int pbrIntensity=50;
int pbrBalanceVersion;
int pbrDistance=64;
int ssrDistance=64;
int reflectionDistanceVersion;
boolean ssr=true;
int ssrIntensity=20;
boolean ssgi=true;
boolean coloredLights=true;
boolean warmTorches=false;
int coloredLightIntensity=30;
int coloredLightDistance=16;
int ssgiIntensity=35;
int bloomIntensity=20;
int bloomRadius=50;
boolean oreEmission=true;
int oreIntensity=50;
boolean edgeHighlights=false;
boolean edgeHighlights=true;
int edgeIntensity=30;
int edgeDistance=32;
int shadowPixels=16;
@@ -35,19 +58,48 @@ 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);
if(preferences.reflectionDistanceVersion<1&&preferences.pbrDistance==32)preferences.pbrDistance=64;
preferences.reflectionDistanceVersion=1;
preferences.pbrDistance=normalizePbrDistance(preferences.pbrDistance);
preferences.ssrDistance=normalizePbrDistance(preferences.ssrDistance);
// Migrate the previous 80% baseline once; later explicit 80% choices persist.
if(preferences.pbrBalanceVersion<1&&preferences.pbrIntensity==80)preferences.pbrIntensity=50;
preferences.pbrBalanceVersion=1;
preferences.pbrIntensity=Math.clamp(preferences.pbrIntensity,0,100);
preferences.coloredLightIntensity=Math.clamp(preferences.coloredLightIntensity,0,100);
preferences.coloredLightDistance=normalizeColoredLightDistance(preferences.coloredLightDistance);
preferences.ssgiIntensity=Math.clamp(preferences.ssgiIntensity,0,100);
preferences.bloomIntensity=Math.clamp(preferences.bloomIntensity,0,100);
preferences.bloomRadius=Math.clamp(preferences.bloomRadius,0,100);
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=normalizeShadowValue(preferences.shadowPixels,8,16,32);
preferences.shadowPixels=normalizePrecision(preferences.shadowPixels);
preferences.shadowDistance=normalizeShadowDistance(preferences.shadowDistance);
}
return preferences.enabled;
}
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;}
@@ -55,6 +107,41 @@ public final class VanillaLight {
public static int edgeIntensity(){enabled();return preferences.edgeIntensity;}
public static void setEdgeHighlights(boolean value){enabled();preferences.edgeHighlights=value;save();}
public static void setEdgeIntensity(int value){enabled();preferences.edgeIntensity=Math.clamp(value,0,100);save();}
public static boolean sunshafts(){enabled();return preferences.sunshafts;}
public static int sunshaftIntensity(){enabled();return preferences.sunshaftIntensity;}
public static void setSunshafts(boolean value){enabled();preferences.sunshafts=value;save();}
public static void setSunshaftIntensity(int value){enabled();preferences.sunshaftIntensity=Math.clamp(value,0,100);save();}
public static boolean lensFlare(){enabled();return preferences.lensFlare;}
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 ssrDistance(){enabled();return preferences.ssrDistance;}
public static void setSsrDistance(int value){enabled();preferences.ssrDistance=normalizePbrDistance(value);save();}
static int materialDistance(){return Math.max(pbrDistance(),ssr()&&ssrIntensity()>0?ssrDistance():0);}
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:value<192?128:256;}
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();}
public static void setPbrIntensity(int value){enabled();preferences.pbrIntensity=Math.clamp(value,0,100);save();}
public static boolean warmTorches(){enabled();return preferences.warmTorches;}
public static void setWarmTorches(boolean value){enabled();preferences.warmTorches=value;save();}
public static boolean coloredLights(){enabled();return preferences.coloredLights;}
public static int coloredLightIntensity(){enabled();return preferences.coloredLightIntensity;}
public static void setColoredLights(boolean value){enabled();preferences.coloredLights=value;save();}
public static void setColoredLightIntensity(int value){enabled();preferences.coloredLightIntensity=Math.clamp(value,0,100);save();}
public static int coloredLightDistance(){enabled();return preferences.coloredLightDistance;}
public static void setColoredLightDistance(int value){enabled();preferences.coloredLightDistance=normalizeColoredLightDistance(value);save();}
static int normalizeColoredLightDistance(int value){return value<12?8:value<24?16:value<48?32:64;}
public static boolean ssgi(){enabled();return preferences.ssgi;}
public static int ssgiIntensity(){enabled();return preferences.ssgiIntensity;}
public static void setSsgi(boolean value){enabled();preferences.ssgi=value;save();}
public static void setSsgiIntensity(int value){enabled();preferences.ssgiIntensity=Math.clamp(value,0,100);save();}
public static boolean bloom(){enabled();return preferences.bloom;}
public static int bloomIntensity(){enabled();return preferences.bloomIntensity;}
public static int bloomRadius(){enabled();return preferences.bloomRadius;}
@@ -70,16 +157,18 @@ public final class VanillaLight {
public static int shadowDistance(){enabled();return preferences.shadowDistance;}
public static void setSaturation(int value){enabled();preferences.saturation=Math.clamp(value,0,200);save();}
public static void setPixelShadows(boolean value){enabled();preferences.pixelShadows=value;save();}
public static void setShadowPixels(int value){enabled();preferences.shadowPixels=normalizeShadowValue(value,8,16,32);save();}
public static void setShadowPixels(int value){enabled();preferences.shadowPixels=normalizePrecision(value);save();}
public static void setShadowDistance(int value){enabled();preferences.shadowDistance=normalizeShadowDistance(value);save();}
public static void setEnabled(boolean value){
enabled();preferences.enabled=value;
save();
}
/** Nearest supported value, clamped at either end; midpoint ties round up. */
private static int normalizeShadowValue(int value,int low,int middle,int high){
if(value<(low+middle)/2)return low;
return value<(middle+high)/2?middle:high;
private static int normalizePrecision(int value){
if(value<12)return 8;
if(value<24)return 16;
if(value<48)return 32;
return value<96?64:128;
}
private static int normalizeShadowDistance(int value){
if(value<24)return 16;
@@ -0,0 +1,94 @@
package fr.koka.sanctuary.community;
import java.util.*;
/** Versioned community rules shared by both persistence adapters. */
public final class Community {
public static final int VERSION = 1, PAGE_SIZE = 10;
public static final Set<String> KINDS = Set.of("article", "notice", "bulletin", "reply");
public static final Set<String> CATEGORIES = Set.of("info", "work", "need", "event");
private Community() { }
public record Actor(String id, String name, boolean moderator) {
public Actor { uuid(id); text(name, 64); }
}
public record Entry(String id, String parentId, String kind, String category,
String authorId, String authorName, String title, String body,
String status, boolean pinned, int revision, long createdAt, long updatedAt) {
public Entry {
uuid(id); uuid(authorId); if (!parentId.isEmpty()) uuid(parentId);
if (!KINDS.contains(kind) || !CATEGORIES.contains(category)
|| !Set.of("open", "closed", "hidden").contains(status) || revision < 1
|| createdAt < 0 || updatedAt < createdAt || kind.equals("reply") != !parentId.isEmpty()) fail("invalid");
text(authorName, 64); if (!kind.equals("reply")) text(title, 120);
else if (!title.isEmpty()) fail("invalid");
text(body, kind.equals("reply") ? 1000 : 4000);
}
}
public record Change(String action, String id, String parentId, String kind, String category,
String title, String body, int revision) { }
public record Page(List<Entry> entries, int page, boolean more) { }
public record ThreadView(Entry entry, Page replies) { }
public static final class Refused extends RuntimeException {
public final String code;
public Refused(String code) { super(code); this.code = code; }
}
public static void fail(String code) { throw new Refused(code); }
public static String uuid(String value) {
if (value == null || !value.matches("[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}")) fail("invalid");
return value;
}
public static String text(String value, int max) {
if (value == null || value.isBlank() || value.codePointCount(0, value.length()) > max
|| value.codePoints().anyMatch(c -> (Character.isISOControl(c) && c != '\n' && c != '\t')
|| c == 0xA7 || c >= 0xD800 && c <= 0xDFFF)) fail("invalid");
return value;
}
public static Entry apply(CommunityStore.Transaction tx, Actor actor, Change change, long now) throws Exception {
uuid(change.id());
if (change.action().equals("create")) {
if (!KINDS.contains(change.kind()) || !CATEGORIES.contains(change.category())) fail("invalid");
if (change.kind().equals("bulletin") && !actor.moderator()) fail("forbidden");
String parent = change.parentId();
if (change.kind().equals("reply")) {
uuid(parent);
Entry p = visible(tx.get(parent));
if (!Set.of("article", "notice").contains(p.kind())) fail("invalid");
// Lock parent first on both creation and retry, including already committed replies.
} else if (!parent.isEmpty()) fail("invalid");
Entry existing = tx.get(change.id());
if (existing != null) {
if (!existing.authorId().equals(actor.id()) || !existing.kind().equals(change.kind())
|| !existing.parentId().equals(parent) || !existing.category().equals(change.category())
|| !existing.title().equals(change.title()) || !existing.body().equals(change.body())) fail("conflict");
return visible(existing);
}
if (!parent.isEmpty() && !tx.get(parent).status().equals("open")) fail("closed");
Entry entry = new Entry(change.id(), parent, change.kind(), change.category(), actor.id(), actor.name(),
change.title(), change.body(), "open", false, 1, now, now);
tx.save(entry, true); return entry;
}
Entry old = visible(tx.get(change.id()));
if (!old.parentId().isEmpty()) visible(tx.get(old.parentId()));
if (old.revision() != change.revision()) fail("conflict");
boolean own = old.authorId().equals(actor.id());
String title = old.title(), body = old.body(), status = old.status(); boolean pinned = old.pinned();
switch (change.action()) {
case "edit" -> { if (!own) fail("forbidden"); title = change.title(); body = change.body(); }
case "hide" -> { if (!own && !actor.moderator()) fail("forbidden"); status = "hidden"; }
case "close", "reopen" -> {
if ((!own && !actor.moderator()) || !old.kind().equals("notice")) fail("forbidden");
status = change.action().equals("close") ? "closed" : "open";
}
case "pin" -> { if (!actor.moderator() || old.kind().equals("reply")) fail("forbidden"); pinned = !pinned; }
default -> fail("invalid");
}
Entry next = new Entry(old.id(), old.parentId(), old.kind(), old.category(), old.authorId(), old.authorName(),
title, body, status, pinned, Math.addExact(old.revision(), 1), old.createdAt(), Math.max(now, old.updatedAt()));
tx.save(next, false); return next;
}
public static Entry visible(Entry entry) {
if (entry == null || entry.status().equals("hidden")) fail("missing");
return entry;
}
public static int page(int page) { if (page < 0 || page > 100000) fail("invalid"); return page; }
}
@@ -0,0 +1,28 @@
package fr.koka.sanctuary.community;
import com.google.gson.GsonBuilder;
import fr.koka.sanctuary.progression.AtomicJson;
import java.nio.file.Path;
import java.nio.charset.StandardCharsets;
import java.util.Set;
import java.util.UUID;
public record CommunityConfig(String mode, String serverId, String jdbcUrl, String user, String passwordEnv) {
public CommunityConfig {
if (!Set.of("file", "database").contains(mode)) throw new IllegalArgumentException("Community mode");
Community.uuid(serverId);
if (jdbcUrl == null || !jdbcUrl.startsWith("jdbc:mariadb://") || user == null
|| passwordEnv == null || !passwordEnv.matches("[A-Z][A-Z0-9_]+")) throw new IllegalArgumentException("Community database configuration");
}
public static CommunityConfig load(Path path) throws Exception {
var gson = new GsonBuilder().setPrettyPrinting().create();
byte[] bytes = AtomicJson.read(path, 16384);
if (bytes != null) {
var root = AtomicJson.parse(new String(bytes, StandardCharsets.UTF_8));
AtomicJson.fields(root,"mode","serverId","jdbcUrl","user","passwordEnv");
return gson.fromJson(root, CommunityConfig.class);
}
var config = new CommunityConfig("file", UUID.randomUUID().toString(), "jdbc:mariadb://localhost:3306/sanctuary?sslMode=verify-full", "sanctuary_game", "SANCTUARY_COMMUNITY_DB_PASSWORD");
AtomicJson.write(path, null, gson.toJson(config), 16384); return config;
}
}
@@ -0,0 +1,114 @@
package fr.koka.sanctuary.community;
import com.google.gson.*;
import fr.koka.sanctuary.SanctuaryMod;
import fr.koka.sanctuary.operator.OperatorService;
import java.util.*;
import java.util.concurrent.*;
import net.fabricmc.fabric.api.event.lifecycle.v1.ServerLifecycleEvents;
import net.fabricmc.fabric.api.networking.v1.*;
import net.fabricmc.loader.api.FabricLoader;
import net.minecraft.network.RegistryFriendlyByteBuf;
import net.minecraft.network.codec.StreamCodec;
import net.minecraft.network.protocol.common.custom.CustomPacketPayload;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.level.storage.LevelResource;
import static fr.koka.sanctuary.community.Community.*;
public final class CommunityNetwork {
private static final Gson JSON = new Gson();
private static final Map<MinecraftServer, Runtime> RUNTIMES = new ConcurrentHashMap<>();
private static final org.slf4j.Logger LOG = org.slf4j.LoggerFactory.getLogger("sanctuary-community");
public record Request(int sequence, String action, String id, String parentId, String kind,
String category, String title, String body, int revision, int page) implements CustomPacketPayload {
public static final Type<Request> TYPE = new Type<>(SanctuaryMod.id("community_request_v1"));
public static final StreamCodec<RegistryFriendlyByteBuf, Request> CODEC = CustomPacketPayload.codec((p,b) -> {
b.writeVarInt(p.sequence); b.writeUtf(p.action,16); b.writeUtf(p.id,36); b.writeUtf(p.parentId,36);
b.writeUtf(p.kind,16); b.writeUtf(p.category,16); b.writeUtf(p.title,240); b.writeUtf(p.body,8000);
b.writeVarInt(p.revision); b.writeVarInt(p.page);
}, b -> new Request(b.readVarInt(),b.readUtf(16),b.readUtf(36),b.readUtf(36),b.readUtf(16),b.readUtf(16),b.readUtf(240),b.readUtf(8000),b.readVarInt(),b.readVarInt()));
public Type<Request> type() { return TYPE; }
}
public record Response(int sequence, String json) implements CustomPacketPayload {
public static final Type<Response> TYPE = new Type<>(SanctuaryMod.id("community_response_v1"));
public static final StreamCodec<RegistryFriendlyByteBuf, Response> CODEC = CustomPacketPayload.codec(
(p,b) -> { b.writeVarInt(p.sequence); b.writeUtf(p.json,131072); }, b -> new Response(b.readVarInt(),b.readUtf(131072)));
public Type<Response> type() { return TYPE; }
}
private CommunityNetwork() { }
public static void register() {
PayloadTypeRegistry.serverboundPlay().register(Request.TYPE, Request.CODEC);
PayloadTypeRegistry.clientboundPlay().register(Response.TYPE, Response.CODEC);
ServerPlayNetworking.registerGlobalReceiver(Request.TYPE, (r,c) -> c.server().execute(() -> request(c.player(),r)));
ServerLifecycleEvents.SERVER_STOPPED.register(server -> { var runtime = RUNTIMES.remove(server); if (runtime != null) runtime.stop(); });
ServerPlayConnectionEvents.DISCONNECT.register((handler,server) -> {
var runtime = RUNTIMES.get(server); if (runtime != null) runtime.rates.remove(handler.player.getUUID());
});
}
private static void send(ServerPlayer player, Request request, JsonObject data) {
if (!player.isRemoved() && ServerPlayNetworking.canSend(player, Response.TYPE))
ServerPlayNetworking.send(player, new Response(request.sequence(), data.toString()));
}
private static JsonObject error(String message) { var data = new JsonObject(); data.addProperty("error",message); return data; }
private static void request(ServerPlayer player, Request request) {
var server = player.level().getServer();
var runtime = RUNTIMES.computeIfAbsent(server, Runtime::new);
long now = System.nanoTime(); boolean reading = Set.of("overview","list","thread").contains(request.action());
long[] rates = runtime.rates.computeIfAbsent(player.getUUID(), key -> new long[2]); int index = reading ? 0 : 1;
if (runtime.pending.contains(player.getUUID()) || now - rates[index] < (reading ? 100_000_000L : 1_000_000_000L)) { send(player,request,error("busy")); return; }
rates[index] = now;
if (request.action().equals("link") && !server.usesAuthentication()) { send(player,request,error("online_required")); return; }
var actor = new Actor(player.getStringUUID(), player.getGameProfile().name(), OperatorService.allowed(player));
runtime.pending.add(player.getUUID());
try {
runtime.executor.execute(() -> {
JsonObject result;
try {
if (System.nanoTime() - now > 10_000_000_000L) fail("busy");
var store = runtime.store(); result = new JsonObject(); result.addProperty("moderator",actor.moderator());
result.addProperty("serverTime",System.currentTimeMillis()); result.addProperty("mode",runtime.config.mode());
switch (request.action()) {
case "overview" -> {
result.add("articles", JSON.toJsonTree(store.list("article","",0).entries().stream().limit(3).toList()));
result.add("notices", JSON.toJsonTree(store.list("notice","",0).entries().stream().limit(3).toList()));
result.add("bulletins", JSON.toJsonTree(store.list("bulletin","",0).entries().stream().limit(1).toList()));
}
case "list" -> result.add("page",JSON.toJsonTree(store.list(request.kind(),"",request.page())));
case "thread" -> result.add("thread",JSON.toJsonTree(store.thread(request.id(),request.page())));
case "link" -> { store.link(actor,request.body()); result.addProperty("linked",true); }
default -> result.add("result",JSON.toJsonTree(store.change(actor,new Change(request.action(),request.id(),request.parentId(),request.kind(),request.category(),request.title(),request.body(),request.revision()))));
}
} catch (Refused e) { result = error(e.code); }
catch (Exception e) { result = error("unavailable"); LOG.warn("Community operation unavailable ({})",e.getClass().getSimpleName()); }
JsonObject response = result;
server.execute(() -> {
runtime.pending.remove(player.getUUID());
if (RUNTIMES.get(server) == runtime && server.getPlayerList().getPlayer(player.getUUID()) == player) send(player,request,response);
});
});
} catch (RejectedExecutionException e) { runtime.pending.remove(player.getUUID()); send(player,request,error("busy")); }
}
private static final class Runtime {
final MinecraftServer server;
final ThreadPoolExecutor executor = new ThreadPoolExecutor(1,1,0,TimeUnit.SECONDS,new ArrayBlockingQueue<>(32),
task -> { var thread = new Thread(task,"Sanctuary community"); thread.setDaemon(true); return thread; });
final Map<UUID,long[]> rates = new HashMap<>();
final Set<UUID> pending = new HashSet<>();
CommunityConfig config;
CommunityStore store;
Runtime(MinecraftServer server) { this.server = server; }
CommunityStore store() throws Exception {
if (store != null) return store;
config = CommunityConfig.load(FabricLoader.getInstance().getConfigDir().resolve("sanctuary-community.json"));
if (config.mode().equals("file")) store = new FileCommunityStore(server.getWorldPath(LevelResource.ROOT).resolve("data/sanctuary-community.json"),config.serverId());
else {
String password = System.getenv(config.passwordEnv());
if (password == null) throw new IllegalArgumentException("Missing database credential environment variable");
store = new SqlCommunityStore(config.jdbcUrl(),config.user(),password,config.serverId());
}
return store;
}
void stop() { executor.shutdownNow(); }
}
}
@@ -0,0 +1,16 @@
package fr.koka.sanctuary.community;
import java.time.Clock;
import static fr.koka.sanctuary.community.Community.*;
public interface CommunityStore extends AutoCloseable {
interface Transaction {
Entry get(String id) throws Exception;
void save(Entry entry, boolean insert) throws Exception;
}
Page list(String kind, String parent, int page) throws Exception;
ThreadView thread(String id, int page) throws Exception;
Entry change(Actor actor, Change change) throws Exception;
default void link(Actor actor, String code) throws Exception { fail("database_only"); }
@Override default void close() throws Exception { }
}
@@ -0,0 +1,67 @@
package fr.koka.sanctuary.community;
import com.google.gson.*;
import fr.koka.sanctuary.progression.AtomicJson;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;
import java.time.Clock;
import java.util.*;
import static fr.koka.sanctuary.community.Community.*;
public final class FileCommunityStore implements CommunityStore {
private static final int LIMIT = 64 * 1024 * 1024;
private static final Gson JSON = new Gson();
private final Path path;
private final String serverId;
private final Clock clock;
private byte[] previous;
private Map<String, Entry> entries = new LinkedHashMap<>();
public FileCommunityStore(Path path, String serverId) throws Exception { this(path, serverId, Clock.systemUTC()); }
public FileCommunityStore(Path path, String serverId, Clock clock) throws Exception {
this.path = path; this.serverId = uuid(serverId); this.clock = clock;
previous = AtomicJson.read(path, LIMIT);
if (previous != null) {
var root = AtomicJson.parse(new String(previous, StandardCharsets.UTF_8)).getAsJsonObject();
AtomicJson.fields(root, "version", "serverId", "entries");
if (root.get("version").getAsInt() != VERSION || !root.get("serverId").getAsString().equals(serverId)) fail("schema");
for (var value : root.getAsJsonArray("entries")) {
Entry entry = JSON.fromJson(value, Entry.class);
if (entries.put(entry.id(), entry) != null) fail("invalid");
}
for (Entry entry : entries.values()) if (!entry.parentId().isEmpty()) {
Entry parent = entries.get(entry.parentId());
if (parent == null || !Set.of("article", "notice").contains(parent.kind())) fail("invalid");
}
}
}
@Override public synchronized Page list(String kind, String parent, int page) {
page(page); if (!KINDS.contains(kind)) fail("invalid");
if (!parent.isEmpty()) visible(entries.get(parent));
Comparator<Entry> order = Comparator.comparingLong(Entry::createdAt).thenComparing(Entry::id);
if (kind.equals("bulletin")) order = order.reversed();
else if (!kind.equals("reply")) order = Comparator.comparing(Entry::pinned).reversed().thenComparing(order.reversed());
var found = entries.values().stream().filter(e -> e.kind().equals(kind) && e.parentId().equals(parent)
&& !e.status().equals("hidden")).sorted(order).skip((long) page * PAGE_SIZE).limit(PAGE_SIZE + 1).toList();
return new Page(found.subList(0, Math.min(PAGE_SIZE, found.size())), page, found.size() > PAGE_SIZE);
}
@Override public synchronized ThreadView thread(String id, int page) {
Entry entry = visible(entries.get(uuid(id)));
if (entry.kind().equals("reply")) fail("missing");
return new ThreadView(entry, list("reply", id, page));
}
@Override public synchronized Entry change(Actor actor, Change change) throws Exception {
var candidate = new LinkedHashMap<>(entries);
boolean[] dirty = {false};
Entry result = Community.apply(new Transaction() {
public Entry get(String id) { return candidate.get(id); }
public void save(Entry entry, boolean insert) { candidate.put(entry.id(), entry); dirty[0] = true; }
}, actor, change, clock.millis());
if (dirty[0]) {
var root = new JsonObject(); root.addProperty("version", VERSION); root.addProperty("serverId", serverId);
root.add("entries", JSON.toJsonTree(candidate.values()));
previous = AtomicJson.write(path, previous, root.toString(), LIMIT);
entries = candidate;
}
return result;
}
}
@@ -0,0 +1,107 @@
package fr.koka.sanctuary.community;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.sql.*;
import java.time.Clock;
import java.util.*;
import static fr.koka.sanctuary.community.Community.*;
/** JDBC stays on the community worker. No credentials or raw SQL errors reach players. */
public final class SqlCommunityStore implements CommunityStore {
private final String url, user, password, serverId;
private final Clock clock;
public SqlCommunityStore(String url, String user, String password, String serverId) throws Exception {
this(url, user, password, serverId, Clock.systemUTC());
}
public SqlCommunityStore(String url, String user, String password, String serverId, Clock clock) throws Exception {
this.url = url; this.user = user; this.password = password; this.serverId = uuid(serverId); this.clock = clock;
Class.forName("org.mariadb.jdbc.Driver");
try (var c = connect(); var s = c.prepareStatement("SELECT version FROM sanctuary_community_schema WHERE id=1")) {
try (var result = s.executeQuery()) { if (!result.next() || result.getInt(1) != VERSION) fail("schema"); }
}
}
private Connection connect() throws SQLException {
var properties = new Properties(); properties.setProperty("user", user); properties.setProperty("password", password);
properties.setProperty("connectTimeout", "2000"); properties.setProperty("socketTimeout", "5000");
return DriverManager.getConnection(url, properties);
}
private PreparedStatement query(Connection c, String sql, Object... values) throws SQLException {
var s = c.prepareStatement(sql); s.setQueryTimeout(4);
for (int i = 0; i < values.length; i++) s.setObject(i + 1, values[i]);
return s;
}
private Entry row(ResultSet r) throws SQLException {
return new Entry(r.getString("id"), Objects.requireNonNullElse(r.getString("parent_id"), ""), r.getString("kind"),
r.getString("category"), r.getString("author_id"), r.getString("author_name"), r.getString("title"),
r.getString("body"), r.getString("status"), r.getBoolean("pinned"), r.getInt("revision"),
r.getLong("created_at"), r.getLong("updated_at"));
}
private Entry get(Connection c, String id, boolean lock) throws SQLException {
try (var s = query(c, "SELECT * FROM sanctuary_community_entries WHERE server_id=? AND id=?" + (lock ? " FOR UPDATE" : ""), serverId, id);
var r = s.executeQuery()) { return r.next() ? row(r) : null; }
}
private Page list(Connection c, String kind, String parent, int page) throws Exception {
page(page); if (!KINDS.contains(kind)) fail("invalid");
if (!parent.isEmpty()) visible(get(c, uuid(parent), false));
String parentSql = parent.isEmpty() ? "parent_id IS NULL" : "parent_id=?";
String order = kind.equals("reply") ? "created_at ASC,id ASC" : kind.equals("bulletin") ? "created_at DESC,id DESC" : "pinned DESC,created_at DESC,id DESC";
var values = new ArrayList<Object>(List.of(serverId, kind)); if (!parent.isEmpty()) values.add(parent);
values.add(PAGE_SIZE + 1); values.add(page * PAGE_SIZE);
try (var s = query(c, "SELECT * FROM sanctuary_community_entries WHERE server_id=? AND kind=? AND " + parentSql
+ " AND status<>'hidden' ORDER BY " + order + " LIMIT ? OFFSET ?", values.toArray()); var r = s.executeQuery()) {
var entries = new ArrayList<Entry>(); while (r.next()) entries.add(row(r));
return new Page(List.copyOf(entries.subList(0, Math.min(PAGE_SIZE, entries.size()))), page, entries.size() > PAGE_SIZE);
}
}
@Override public Page list(String kind, String parent, int page) throws Exception {
try (var c = connect()) { c.setAutoCommit(false); var result = list(c, kind, parent, page); c.commit(); return result; }
}
@Override public ThreadView thread(String id, int page) throws Exception {
try (var c = connect()) {
c.setAutoCommit(false); Entry entry = visible(get(c, uuid(id), false));
if (entry.kind().equals("reply")) fail("missing");
var result = new ThreadView(entry, list(c, "reply", id, page)); c.commit(); return result;
}
}
@Override public Entry change(Actor actor, Change change) throws Exception {
try (var c = connect()) {
c.setAutoCommit(false);
try {
Entry result = Community.apply(new Transaction() {
public Entry get(String id) throws Exception { return SqlCommunityStore.this.get(c, id, true); }
public void save(Entry e, boolean insert) throws Exception {
if (insert) {
try (var s = query(c, "INSERT INTO sanctuary_community_entries (server_id,id,parent_id,kind,category,author_id,author_name,title,body,status,pinned,revision,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
serverId, e.id(), e.parentId().isEmpty() ? null : e.parentId(), e.kind(), e.category(), e.authorId(), e.authorName(), e.title(), e.body(), e.status(), e.pinned(), e.revision(), e.createdAt(), e.updatedAt())) { s.executeUpdate(); }
} else {
try (var s = query(c, "UPDATE sanctuary_community_entries SET title=?,body=?,status=?,pinned=?,revision=?,updated_at=? WHERE server_id=? AND id=? AND revision=?",
e.title(), e.body(), e.status(), e.pinned(), e.revision(), e.updatedAt(), serverId, e.id(), e.revision() - 1)) {
if (s.executeUpdate() != 1) fail("conflict");
}
}
}
}, actor, change, clock.millis());
c.commit(); return result;
} catch (Exception error) { c.rollback(); throw error; }
}
}
@Override public void link(Actor actor, String code) throws Exception {
if (!code.matches("[0-9a-f]{32}")) fail("invalid");
String hash = HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(code.getBytes(StandardCharsets.US_ASCII)));
try (var c = connect()) {
c.setAutoCommit(false);
try {
long userId;
try (var s = query(c, "SELECT user_id,expires_at FROM sanctuary_community_links WHERE server_id=? AND token_hash=? FOR UPDATE", serverId, hash); var r = s.executeQuery()) {
if (!r.next() || r.getLong("expires_at") <= clock.millis()) { fail("link_expired"); return; }
userId = r.getLong("user_id");
}
try (var s = query(c, "SELECT player_id FROM sanctuary_community_identities WHERE server_id=? AND (player_id=? OR user_id=?) FOR UPDATE", serverId, actor.id(), userId); var r = s.executeQuery()) { if (r.next()) fail("already_linked"); }
try (var s = query(c, "INSERT INTO sanctuary_community_identities (server_id,player_id,user_id,player_name) VALUES (?,?,?,?)", serverId, actor.id(), userId, actor.name())) { s.executeUpdate(); }
try (var s = query(c, "DELETE FROM sanctuary_community_links WHERE server_id=? AND token_hash=?", serverId, hash)) { s.executeUpdate(); }
c.commit();
} catch (Exception error) { c.rollback(); throw error; }
}
}
}
@@ -7,7 +7,8 @@ import java.util.function.LongConsumer;
/** Immutable render snapshots; entity collection and the dirty queue belong to the client tick. */
public final class DynamicLightField {
public record Id(boolean drop, long value) {}
public record Source(Id id, double x, double y, double z, int light) {
public record Source(Id id, double x, double y, double z, int light, int color) {
public Source(Id id,double x,double y,double z,int light){this(id,x,y,z,light,0xFFFFFF);}
public Source {
if (!Double.isFinite(x+y+z) || light<1 || light>15) throw new IllegalArgumentException("light source");
}
@@ -20,9 +21,12 @@ public final class DynamicLightField {
private final int cellSize;
public Collector(int cellSize) {this.cellSize=Math.max(1,cellSize);}
public void add(long entity,boolean drop,double x,double y,double z,int light) {
add(entity,drop,x,y,z,light,0xFFFFFF);
}
public void add(long entity,boolean drop,double x,double y,double z,int light,int color) {
if(light<=0)return;
Id id=new Id(drop,drop ? sectionKey(floor(x/cellSize),floor(y/cellSize),floor(z/cellSize)) : entity);
Source candidate=new Source(id,snap(x),snap(y),snap(z),Math.min(15,light));
Source candidate=new Source(id,snap(x),snap(y),snap(z),Math.min(15,light),color);
sources.merge(id,candidate,(a,b)->compareRepresentative(a,b)<=0?a:b);
}
public List<Source> finish(int limit,double x,double y,double z,long priorityEntity) {
@@ -53,6 +57,7 @@ public final class DynamicLightField {
lists.long2ObjectEntrySet().forEach(e->bins.put(e.getLongKey(),e.getValue().toArray(Source[]::new)));
}
public int size() {return sources.size();}
public Collection<Source> sources(){return sources.values();}
public int sectionCount() {return bins.size();}
public int sampleCount(double x,double y,double z) {
Source[] near=bins.get(sectionKey(floor(x)>>4,floor(y)>>4,floor(z)>>4));return near==null?0:near.length;
@@ -0,0 +1,26 @@
package fr.koka.sanctuary.mixin.client;
import fr.koka.sanctuary.client.CommunityClient;
import fr.koka.sanctuary.client.CommunityPausePanels;
import net.minecraft.client.gui.screens.Screen;
import net.minecraft.client.gui.screens.PauseScreen;
import net.minecraft.network.chat.Component;
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(PauseScreen.class)
public abstract class CommunityPauseMixin extends Screen {
@Unique private CommunityPausePanels sanctuary$community;
protected CommunityPauseMixin(Component title) { super(title); }
@Inject(method="init",at=@At("TAIL"))
private void sanctuary$communityInit(CallbackInfo callback) {
sanctuary$community=null;
if (((PauseScreen)(Object)this).showsPauseMenu() && CommunityClient.available())
sanctuary$community=new CommunityPausePanels(this,this::addRenderableWidget);
}
@Inject(method="tick",at=@At("TAIL"))
private void sanctuary$communityTick(CallbackInfo callback) { if(sanctuary$community!=null)sanctuary$community.tick(); }
}
@@ -0,0 +1,22 @@
package fr.koka.sanctuary.mixin.client;
import fr.koka.sanctuary.client.ProgressionClient;
import fr.koka.sanctuary.progression.ProgressEffects;
import net.minecraft.client.Minecraft;
import net.minecraft.client.multiplayer.ClientPacketListener;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Inject;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
/** Respawn creates a new player/menu; extend it before the next inventory packet, not next tick. */
@Mixin(ClientPacketListener.class)
abstract class InventoryRespawnMixin {
@Inject(method = "handleRespawn", at = @At("RETURN"))
private void sanctuary$restoreInventoryLayout(CallbackInfo ci) {
var player = Minecraft.getInstance().player;
if (player != null && ProgressionClient.active()) {
ProgressEffects.bind(player, ProgressionClient.progress);
}
}
}
@@ -0,0 +1,48 @@
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);
}
}
@@ -0,0 +1,24 @@
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;
}
}
@@ -0,0 +1,19 @@
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;
}
}
@@ -0,0 +1,23 @@
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;}
}
@@ -0,0 +1,10 @@
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();
}
@@ -0,0 +1,10 @@
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();
}

Some files were not shown because too many files have changed in this diff Show More