Compare commits

...
66 Commits
Author SHA1 Message Date
koka 83c377acc1 Validate beta.144 water, unloaded boundaries and ocean seams
Build Sanctuary / build (push) Waiting to run
2026-09-18 18:42:50 +02:00
koka 7eef9454d4 Implement natural pixel water for beta.144; final performance validation pending 2026-09-18 14:44:07 +02:00
koka 580b2414da Document beta.143 publication and Prism synchronization
Build Sanctuary / build (push) Waiting to run
2026-09-18 12:44:49 +02:00
koka 97ff2b54cb Slow seasonal snow pacing in beta.143
Build Sanctuary / build (push) Waiting to run
2026-09-18 12:41:27 +02:00
koka 59346d7f0a Document verified beta.142 publication and Prism synchronization
Build Sanctuary / build (push) Waiting to run
2026-09-18 05:08:09 +02:00
koka ea151dd5df Prefer native Vulkan and stabilize PBR lighting in beta.142
Build Sanctuary / build (push) Waiting to run
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
koka afa77f3d62 docs: record beta.130 release and preserved Prism data
Build Sanctuary / build (push) Canceled after 0s
2026-09-17 21:06:31 +02:00
koka 534dfec4f8 feat(shader): selective bloom and shared precision in beta.130
Build Sanctuary / build (push) Canceled after 0s
2026-09-17 21:04:33 +02:00
koka 26e305d15f Record beta.129 publication and verified Prism synchronization
Build Sanctuary / build (push) Canceled after 0s
2026-09-17 19:13:09 +02:00
koka 35103a7ea8 Add connected edge highlights and stable shadow projection for beta.129
Build Sanctuary / build (push) Canceled after 0s
2026-09-17 19:11:11 +02:00
koka 6502883cc4 Document verified beta.128 publication and Prism synchronization
Build Sanctuary / build (push) Canceled after 0s
2026-09-17 18:34:55 +02:00
koka a4e96c0d99 Stabilize pixel shadows and add moonlight and foliage for beta.128
Build Sanctuary / build (push) Canceled after 0s
2026-09-17 18:33:07 +02:00
koka 7c63865157 Document verified beta.127 shader release and instance synchronization
Build Sanctuary / build (push) Canceled after 0s
2026-09-17 17:53:16 +02:00
koka 00be1eb0d0 Integrate native pixel shadows over beta.126 for beta.127
Build Sanctuary / build (push) Canceled after 0s
2026-09-17 17:51:19 +02:00
koka adde24aa7e Document verified beta.126 release and instance synchronization
Build Sanctuary / build (push) Canceled after 0s
2026-09-17 17:11:59 +02:00
koka 99860af312 Add Sanctuary gems, original clay colors and shared discovery commands for beta.126
Build Sanctuary / build (push) Canceled after 0s
2026-09-17 17:10:15 +02:00
koka 1547396c06 Document verified beta.125 release and instance synchronization
Build Sanctuary / build (push) Canceled after 0s
2026-09-17 16:37:18 +02:00
koka 4ec5b0d028 Add sculpture corner and height placement, voxel chips and discovered workshop models
Build Sanctuary / build (push) Canceled after 0s
2026-09-17 16:35:46 +02:00
koka 67e6e7c4f2 Record beta.124 publication and preserved Prism synchronization
Build Sanctuary / build (push) Canceled after 0s
2026-09-17 16:02:47 +02:00
koka 00179e30ef Use one cached bounding box per sculpture (beta.124)
Build Sanctuary / build (push) Canceled after 0s
2026-09-17 16:00:52 +02:00
koka 87f823af67 Record beta.123 publication and preserved Prism synchronization
Build Sanctuary / build (push) Canceled after 0s
2026-09-17 15:30:04 +02:00
koka 4552cc347b Separate safe golden rotations from Steve wrench powers (beta.123)
Build Sanctuary / build (push) Canceled after 0s
2026-09-17 15:28:06 +02:00
koka 49131b4c53 Record beta.122 publication and preserved Prism sync
Build Sanctuary / build (push) Canceled after 0s
2026-09-17 15:04:59 +02:00
koka edc7fc3dfb Match sculpture collisions to voxels and fix light occlusion (beta.122)
Build Sanctuary / build (push) Canceled after 0s
2026-09-17 15:02:05 +02:00
koka 9b0104d27a Record beta.121 publication and preserved Prism sync
Build Sanctuary / build (push) Canceled after 0s
2026-09-17 14:11:39 +02:00
koka d9b3dd6bed Add editable Sanctuary resource template and new wrench texture (beta.121)
Build Sanctuary / build (push) Canceled after 0s
2026-09-17 14:09:11 +02:00
koka e732da8800 Record beta.120 publication and verified Prism synchronization
Build Sanctuary / build (push) Canceled after 0s
2026-09-17 13:44:30 +02:00
koka 248dff47b7 Extend golden wrench to native block states and persistent texture rotation (beta.120)
Build Sanctuary / build (push) Canceled after 0s
2026-09-17 13:43:02 +02:00
koka 6aaabf1cb9 Normalize sculpture delivery note formatting
Build Sanctuary / build (push) Canceled after 0s
2026-09-17 13:16:24 +02:00
koka 72e9263339 Record beta.119 release and verified Prism synchronization
Build Sanctuary / build (push) Canceled after 0s
2026-09-17 13:16:12 +02:00
koka 3fd64e1f2a Center sculptures on aimed block middle without half-voxel offsets (beta.119)
Build Sanctuary / build (push) Canceled after 0s
2026-09-17 13:14:54 +02:00
koka 8af406a028 Record verified beta.118 publication and Prism update
Build Sanctuary / build (push) Canceled after 0s
2026-09-17 13:05:40 +02:00
koka 0a81b3c147 Add sixteen native colored brick walls (beta.118)
Build Sanctuary / build (push) Canceled after 0s
2026-09-17 13:03:18 +02:00
koka 68fd97d287 Record verified beta.117 publication and Prism update
Build Sanctuary / build (push) Canceled after 0s
2026-09-17 12:19:54 +02:00
koka 0f1efdec83 Sample texture grain for sculptures and preserve worn models (beta.117)
Build Sanctuary / build (push) Canceled after 0s
2026-09-17 12:18:10 +02:00
koka 5c666a4ebc Record verified beta.116 publication and Prism synchronization
Build Sanctuary / build (push) Canceled after 0s
2026-09-17 07:50:34 +02:00
koka e3ca9fcd63 Apply block texture palettes to workshop sculptures (beta.116)
Build Sanctuary / build (push) Canceled after 0s
2026-09-17 07:49:00 +02:00
koka 22e58b4b91 Fix multiblock furnace slot mapping and explain shared cooking (beta.115)
Build Sanctuary / build (push) Canceled after 0s
2026-09-17 07:28:40 +02:00
koka ed0e0b26aa Document beta.114 release and verified instance update
Build Sanctuary / build (push) Canceled after 0s
2026-09-17 06:59:17 +02:00
koka 21845bd736 Add chaotic flight when carried flying animals are struck (beta.114)
Build Sanctuary / build (push) Canceled after 0s
2026-09-17 06:57:34 +02:00
koka 4a6f63c52d Document beta.113 publication and verified Prism update
Build Sanctuary / build (push) Canceled after 0s
2026-09-17 06:39:24 +02:00
koka 6166d0aa9e Snap clay sculptures to aimed block edges on the voxel grid (beta.113)
Build Sanctuary / build (push) Canceled after 0s
2026-09-17 06:38:04 +02:00
koka 064701f210 Document beta.112 publication and verified Prism update
Build Sanctuary / build (push) Canceled after 0s
2026-09-17 04:01:02 +02:00
484 changed files with 19090 additions and 253 deletions
+256 -7
View File
@@ -20,9 +20,20 @@ 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.112** limite latelier dargile à la hotbar active du joueur,
dans un cadre compact. Les statuaires suivent toujours le regard à la pose.
Le [ticket](docs/clay-workshop-hotbar-beta112.md) décrit le comportement et les essais.
La version **beta.144** ajoute une option d**eau naturelle pixélisée** :
couleur du biome, trois profils selon le fond, vert olive d’étang 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 linstance 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à
latelier, les œufs et la neige saisonnière dans linstance **Sanctuary Beta**.
Les [synchronisations antérieures](docs/git-sync-beta092.md) restent documentées.
@@ -33,13 +44,251 @@ 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
minerais ; réglages par défaut 32/128/16 et ombres activées.
[Contrat et vérifications](docs/shader-beta130.md) ·
[Pack normal](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.130/Sanctuary-beta.130.mrpack) ·
[Pack de test](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.130/Sanctuary-Test-beta.130.mrpack).
## beta.129 — Reflets CTM et ombres jusqu'à 256 blocs
Reflets connectés réglables, cinq portées indépendantes, soleil plein à midi
et correction du réalignement cyclique de la grille d'ombres.
[Contrat et vérifications](docs/shader-beta129.md) ·
[Pack normal](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.129/Sanctuary-beta.129.mrpack) ·
[Pack de test](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.129/Sanctuary-Test-beta.129.mrpack).
## beta.128 — Ombres stables, lune et feuillage
Grille de réception stabilisée, contours nets des blocs, atténuation à midi,
ombre lunaire légère et feuillage nuancé selon son épaisseur.
[Contrat et vérifications](docs/shader-beta128.md) ·
[Pack normal](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.128/Sanctuary-beta.128.mrpack) ·
[Pack de test](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.128/Sanctuary-Test-beta.128.mrpack).
## beta.127 — Shader : ombres portées pixélisées
Ombres solaires natives, finesse 8/16/32 pixels par bloc et portée 16/32/64 blocs.
Option désactivée par défaut, réglable sans rechargement des ressources.
[Contrat et vérifications](docs/shader-beta127.md) ·
[Pack normal](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.127/Sanctuary-beta.127.mrpack) ·
[Pack de test](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.127/Sanctuary-Test-beta.127.mrpack).
## beta.126 — Gemmes, argile et découvertes
Rubis et saphir avec filons configurables, couleurs originales avec l'argile,
commandes de découverte globales ou ciblées.
[Contrat et vérifications](docs/gems-beta126.md) ·
[Pack normal](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.126/Sanctuary-beta.126.mrpack) ·
[Pack de test](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.126/Sanctuary-Test-beta.126.mrpack).
## beta.125 — Sculptures : placement, éclats et animaux
Coins et trois hauteurs sur la grille 1/16 ; particules issues des voxels ;
modèles découverts disponibles dans le Clay Workshop.
[Contrat et vérifications](docs/sculpture-placement-beta125.md) ·
[Pack normal](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.125/Sanctuary-beta.125.mrpack) ·
[Pack de test](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.125/Sanctuary-Test-beta.125.mrpack).
## beta.124 — Une boîte par sculpture
Sélection et collisions simplifiées, dimensions ajustées et lumière conservée.
[Contrat et vérifications](docs/sculpture-bounds-beta124.md) ·
[Pack normal](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.124/Sanctuary-beta.124.mrpack) ·
[Pack de test](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.124/Sanctuary-Test-beta.124.mrpack).
## beta.123 — Clé dorée et clé de Steve
Orientations cohérentes, inventaires et eau préservés ; clé de Steve pour
l’édition libre, utilisable aussi en survie. [Contrat et vérifications](docs/wrench-safety-beta123.md) ·
[Pack normal](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.123/Sanctuary-beta.123.mrpack) ·
[Pack de test](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.123/Sanctuary-Test-beta.123.mrpack).
## beta.122 — Sculptures : forme et lumière
Collisions et sélection sur les voxels réels, creux traversables, lumière sans
occlusion cubique. [Contrat et vérifications](docs/sculpture-shape-light-beta122.md) ·
[Pack normal](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.122/Sanctuary-beta.122.mrpack) ·
[Pack de test](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.122/Sanctuary-Test-beta.122.mrpack).
## beta.121 — Texture de clé et pack personnel
Copie complète des ressources, création depuis le jeu et guide FR/EN inclus.
[Contrat et vérifications](docs/resourcepack-template-beta121.md) ·
[Template personnel](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.121/Sanctuary-Template-beta.121.zip) ·
[Pack normal](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.121/Sanctuary-beta.121.mrpack) ·
[Pack de test](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.121/Sanctuary-Test-beta.121.mrpack).
## beta.120 — Clé dorée et états de blocs
Gestes du debug stick, rotation de textures et assemblage des machines conservé.
[Contrat et vérifications](docs/golden-wrench-beta120.md) ·
[Pack normal](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.120/Sanctuary-beta.120.mrpack) ·
[Pack de test](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.120/Sanctuary-Test-beta.120.mrpack).
## beta.119 — Sculptures au centre ou au bord
Pose selon le point visé, centrage sur la grille entière et ancrage conservé
en sauvegarde. [Contrat et vérifications](docs/sculpture-center-beta119.md) ·
[Pack normal](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.119/Sanctuary-beta.119.mrpack) ·
[Pack de test](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.119/Sanctuary-Test-beta.119.mrpack).
## beta.118 — Murets en briques colorées
Seize couleurs, fabrication et tailleur de pierre, eau, raccords et collisions
natifs. [Contrat et vérifications](docs/colored-brick-walls-beta118.md) ·
[Pack normal](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.118/Sanctuary-beta.118.mrpack) ·
[Pack de test](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.118/Sanctuary-Test-beta.118.mrpack).
## beta.117 — Grain des textures et sculptures en chapeau
Les petits cubes d'une sculpture reprennent plusieurs nuances des textures
de la matière. Le modèle reste intact dans le slot chapeau et au retrait.
[Contrat et vérifications](docs/sculpture-texture-grain-beta117.md) ·
[Pack normal](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.117/Sanctuary-beta.117.mrpack) ·
[Pack de test](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.117/Sanctuary-Test-beta.117.mrpack).
## beta.116 — Matières de sculpture et Fourneau
Un bloc de matière devient une sculpture aux couleurs de sa texture. Hotbar,
orientation et ancrage conservés. Le Fourneau accepte les dépôts dans chacune
de ses 27 cases et explique sa chauffe commune.
[Contrat et vérifications](docs/sculpture-materials-beta116.md) ·
[Pack normal](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.116/Sanctuary-beta.116.mrpack) ·
[Pack de test](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.116/Sanctuary-Test-beta.116.mrpack).
## beta.115 — Fourneau : dépôts et chauffe
Correctif du décalage par trois cases, combustible stable et progression
par cuisson. [Diagnostic et essais](docs/fourneau-transfers-beta115.md).
Étape locale intégrée à la livraison commune beta.116.
## beta.114 — Animaux portés et fuite en vol
Un animal volant porté entraîne son porteur après un coup. La trajectoire
aléatoire prend brièvement le dessus sur les commandes ; déposer lanimal
interrompt le vol. [Contrat et vérifications](docs/carried-flight-panic-beta114.md) ·
[Pack normal](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.114/Sanctuary-beta.114.mrpack) ·
[Pack de test](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.114/Sanctuary-Test-beta.114.mrpack).
## beta.113 — Sculptures dargile et alignement
Pose contre le bord visé, orientation selon le regard et coordonnées entières
pour chaque voxel, y compris les modèles de largeur impaire. Laccroche est
conservée à la reconnexion. [Contrat et vérifications](docs/clay-sculpture-grid-beta113.md) ·
[Pack normal](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.113/Sanctuary-beta.113.mrpack) ·
[Pack de test](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.113/Sanctuary-Test-beta.113.mrpack).
## beta.112 — Atelier dargile et hotbar
Neuf cases de hotbar et deux emplacements de fabrication, dans un cadre fixe.
Les transferts restent dans la hotbar ; une barre pleine ne consomme pas dargile.
[Contrat et vérifications](docs/clay-workshop-hotbar-beta112.md) ·
[Pack normal](build/Sanctuary-beta.112.mrpack) ·
[Pack de test](build/Sanctuary-Test-beta.112.mrpack).
[Pack normal](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.112/Sanctuary-beta.112.mrpack) ·
[Pack de test](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.112/Sanctuary-Test-beta.112.mrpack).
## beta.111 — Atelier dargile et pose orientée
@@ -2071,7 +2320,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.112 |
| Sanctuary / pack | beta.122 |
Java 25 et Python 3.11 ou plus récent sont nécessaires. Le script pack utilise
uniquement la bibliothèque standard et repère aussi une installation Python
@@ -2100,7 +2349,7 @@ décrits dans [Validation](docs/testing.md).
Résultats :
- `mods/sanctuary/build/libs/sanctuary-beta.112.jar` : mod à installer avec
- `mods/sanctuary/build/libs/sanctuary-beta.122.jar` : mod à installer avec
Fabric API sur la version Minecraft indiquée.
- `build/packwiz/` : pack de développement complet, avec le mod construit et
l'index vérifié. Voir [Installation packwiz](packwiz/README.md).
+9
View File
@@ -127,3 +127,12 @@ de modèles, butin et états reprennent leurs équivalents natifs 26.3.
`tools/generate-colored-bricks.py` documente cette transformation ;
`tools/colored-bricks-palette.json` conserve couleurs et empreintes des PNG fournis.
Ces ressources dérivées ne sont pas présentées comme des créations originales.
## Clé dorée beta.121
Le PNG 16 × 16 `golden_wrench.png` a été fourni par le créateur de Sanctuary
le 17 septembre 2026. Il est conservé octet pour octet dans le mod, le pack
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.
+19 -1
View File
@@ -15,7 +15,7 @@ tasks.register('verifyPack', Exec) {
tasks.register('assemblePack', Exec) {
group = 'distribution'
description = 'Assemble a local packwiz pack including the built Sanctuary mod.'
dependsOn(':sanctuary:build', 'verifyPack', 'assembleResourcePack')
dependsOn(':sanctuary:build', 'verifyPack', 'assembleResourcePack', 'assembleResourceTemplate')
commandLine('python3', 'scripts/pack.py', 'assemble')
}
@@ -46,3 +46,21 @@ tasks.register('assembleTestPack', Exec) {
dependsOn('assemblePack', ':sanctuary-test:build')
commandLine('python3', 'scripts/test_pack.py')
}
tasks.register('assembleResourceTemplate', Zip) {
group = 'distribution'
description = 'Export all Sanctuary client assets as an editable personal resource pack.'
dependsOn(':sanctuary:processResources', 'verifyResourcePack')
def resources = project(':sanctuary').layout.buildDirectory.dir('resources/main')
// Same precedence as the native exporter: built-in textures override mod defaults.
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
from(resources.map { it.dir('resourcepacks/textures/assets') }) { into 'assets' }
from(resources.map { it.dir('assets') }) { into 'assets' }
from(resources.map { it.dir('resourcepacks/template') })
from(resources.map { it.file('resourcepacks/textures/pack.png') })
exclude '**/.DS_Store'
destinationDirectory = layout.buildDirectory
archiveFileName = "Sanctuary-Template-${mod_version}.zip"
preserveFileTimestamps = false
reproducibleFileOrder = true
}
+91
View File
@@ -0,0 +1,91 @@
# CARRY-08 — Fuite des animaux volants portés — beta.114
Contrat du 17 septembre 2026, branche `codex/carried-flight-panic-beta114`.
Un coup reçu par un animal volant porté sur la tête déclenche une fuite de
40 à 60 ticks : il entraîne son porteur vers le haut, dans une direction
aléatoire, avec des embardées. Le serveur choisit le départ et la trajectoire,
transmis au client pour la physique native. Les touches de déplacement et le
regard ne pilotent pas cette brève fuite. La caméra reste libre. Un nouveau coup peut relancer la fuite avec un autre
cap après dix ticks, sans cumuler les vitesses.
Le porteur peut viser et frapper son animal volant. Les animaux ordinaires
reçoivent les dégâts habituels ; le familier conserve son coup amical sans
perte de santé. Les attaques extérieures acceptées déclenchent aussi la fuite.
Les autres protections entre passagers et porteurs restent en place.
Les espèces reprennent le groupe déjà utilisé pour planer, poule comprise.
La vitesse reste bornée, les collisions sont natives et aucun bloc nest
traversé ni déplacé. Déposer lanimal (Maj + clic droit), le perdre, mourir,
se déconnecter ou changer de dimension arrête la propulsion. Immersion,
mode spectateur, vol créatif, élytres et porteur lui-même passager linhibent.
La fin de fuite rend le contrôle au joueur et conserve le planeur existant
si lanimal reste porté. Les montures colossales gardent leur pilotage actuel.
L’état de fuite est temporaire, synchronisé et non sauvegardé. Aucun format
persistant, identifiant existant ou monde personnel nest modifié. Les nouveaux
retours dinterface sont traduits FR/EN. Client et serveur utilisent beta.114.
## Vérifications natives
`CarryPanic114ClientChecks` passe en **56 secondes** avec un serveur intégré
Minecraft 26.3 et un monde plat jetable de graine 114 :
- Véritable ciblage du perroquet sur la tête et frappe avec la touche native,
perte de santé de lanimal ordinaire, décollage et déplacement du joueur.
- Trajectoire synchronisée et priorité sur les touches avant/droite/saut et
le regard ; comparaison exacte avec la vitesse du tick de physique.
- Expiration, suppression de l’état temporaire et retour au planeur normal.
- 32 trajectoires tirées côté serveur couvrent les quatre quadrants, toutes
dans les bornes de durée et de vitesse ; Maj + clic droit natif interrompt.
- Sept espèces ordinaires : poule, chauve-souris, abeille, allay, perroquet,
blaze et breeze. Une vache portée reste protégée et ne déclenche aucun vol.
- Attaque extérieure, mort, activation du vol créatif et détachement arrêtent
ou déclenchent la réaction selon le contrat.
- Plafond bas : aucune traversée ; le passager bloqué est déposé par les
contrôles de place existants et la propulsion sinterrompt.
- Véritable coup amical sur un familier perroquet : lancement sans perte de
santé, puis arrêt immédiat lors du détachement.
```sh
./gradlew :sanctuary:runClientGameTest \
-PsanctuaryClientTests=true -PsanctuaryCarryPanic114ClientTests=true \
-PsanctuaryClientNoVsync=true -PsanctuaryQuickTests=true
```
Marqueurs `CARRY114_NATIVE_FLIGHT_PASS` et `CARRY114_PASS` dans
`build/carry-panic114-client.log`. Capture en jeu relue dans
`build/carry-panic114-evidence/`. Le GameTest dédié reste exclu conformément
au refus antérieur de son EULA. Pas de test Windows, de réseau distant ou de
latence simulée ; aucun monde personnel na été ouvert.
## Livraison
`./gradlew check build assemblePack assembleTestPack -x :sanctuary:runGameTest`
passe en **2 min 22 s**, 125 tâches. Les sources JAR correspondent exactement
aux sources du dépôt. Les classes et ressources hors de ce ticket restent
identiques à beta.113, notamment les sculptures dargile. Les deux packs
contiennent le même JAR, sans monde, GLB ni classes de test.
- Sanctuary-beta.114.mrpack : 10179428 octets, SHA-256
`92aac61c2c00a2206f3f5ac3d170ab058f6765526cf5a2a64c5336261db3f5bf`.
- Sanctuary-Test-beta.114.mrpack : 10198350 octets, SHA-256
`5e8ec1f18e34ca51e09e2738f2b04f9a1bbac5d14c7a7c2d766218a4cff6021f`.
- JAR Sanctuary : SHA-256
`18105b76f63008210c62d5728f999c7abc083a861ecc1de2c7ee87262dde5a40`.
Reçu local : `build/carry-panic114-artifact.json`.
## Publication et installation
La [release beta.114](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.114)
est publiée depuis `21845bd7367a260ee6a3d189417a8620aa5bf738`. Le tag exact
et les artefacts sont immuables ; les téléchargements publics sont vérifiés.
Le canal packwiz avance à `c26f3bb59491c9bb4bbdd27f59571ef84f417f7b`.
Deux synchronisations isolées puis deux passages dans **Sanctuary Beta** ont
réussi. Un seul JAR Sanctuary beta.114 est actif, et le second passage conserve
les mêmes hashes. Les **923 fichiers personnels et réglages suivis** restent
identiques. Aucun monde personnel na été ouvert. La sauvegarde ciblée est
`sanctuary-backups/before-beta.114/` dans linstance existante. Reçus locaux :
`build/carry-panic114-isolated.json` et `build/carry-panic114-prism.json`.
+90
View File
@@ -0,0 +1,90 @@
# STAT-04 — Sculptures dargile et accroche au bord — beta.113
Contrat du 17 septembre 2026, branche `codex/clay-sculpture-grid-beta113`.
Les miniatures de latelier deviennent des « sculptures dargile » / « Clay
sculptures ». Le nom du modèle reste dans leur infobulle. Les statues en blocs
du Métabli restent une catégorie distincte.
À la pose, le bord horizontal le plus proche du point visé accueille la
sculpture. Lautre axe est centré au pixel entier le plus proche, sur la grille
de 1/16 de bloc. Sur une face latérale, elle touche le bord du bloc support.
À égalité exacte entre deux bords, laxe Z départage ; au centre exact,
le bord côté joueur est retenu. Lorientation suit toujours le regard.
Le bouton Tourner prépare le modèle avant fabrication. Les marges horizontales vides du modèle ne créent pas de décalage au bord.
Tous les sommets restent
sur la grille entière, même avec des dimensions impaires et après rotation.
## Compatibilité préalable
La propriété native additive `anchor` du bloc `sanctuary:statuary` mémorise
`center`, `north`, `south`, `east` ou `west`. Un ancien état sans cette propriété
prend `center` : il reste centré, avec la correction visuelle du demi-voxel.
Le schéma 1, les voxels, noms de modèles et identifiants ne changent pas.
Il ny a aucun parcours ni réécriture de chunks existants. La sauvegarde native
conserve laccroche ; les rotations et miroirs de structures la transforment.
Casser et reposer choisit une nouvelle accroche sans modifier le modèle.
Les nouveaux objets emploient le nom traduit de leur type ; les noms déjà
enregistrés et les noms personnalisés restent conservés. La collision cubique
et la limite dune sculpture par bloc restent inchangées. Client et serveur
doivent utiliser ensemble beta.113.
## Vérifications natives
Le parcours `Statuary106ClientChecks`, étendu par `Sculpture113ClientChecks`,
passe en **1 min 25 s**, sur Minecraft 26.3 et un monde plat jetable de graine 106 :
- 5 120 combinaisons de largeur/profondeur, orientation et accroche : contrôle
de la géométrie émise, grille exacte de 1/16 et absence de dépassement du bloc.
- Modèles avec marges transparentes, repères colorés pour vérifier le sens du
rendu et égalité de la géométrie centrée entre lobjet et le bloc.
- Vrais clics de pose sur les quatre bords : orientation, bord côté serveur,
réception côté client, butin/repose et conservation après reconnexion.
- Contextes natifs de pose sur les faces latérales et coordonnées négatives.
- Rotation/miroir de chaque combinaison daccroche et dorientation ; lecture
des anciens états sans `anchor`, avec et sans `facing`.
- Noms français/anglais des objets fabriqués, menu aux échelles 2 et 3,
fabrication payée, Maj-clic limité à la hotbar et régression Métabli.
```sh
./gradlew :sanctuary:runClientGameTest \
-PsanctuaryClientTests=true -PsanctuaryStatuary106ClientTests=true \
-PsanctuaryClientNoVsync=true -PsanctuaryQuickTests=true
```
Marqueurs `STATUARY113_PASS` et `SCULPTURE113_GEOMETRY_PASS` dans
`build/clay-grid113-client.log`. Captures relues dans
`build/clay-grid113-evidence/`. Le GameTest dédié reste exclu conformément au
refus antérieur de son EULA ; ces essais utilisent le serveur intégré macOS.
Pas de validation Windows ni depuis deux ordinateurs. Aucun monde personnel
na été ouvert.
## Livraison
`./gradlew check build assemblePack assembleTestPack -x :sanctuary:runGameTest`
passe en **3 min 11 s**, 125 tâches. Les sources JAR correspondent exactement
aux sources du dépôt. Les classes et ressources hors du ticket sont identiques
à beta.112. Les deux packs contiennent le même JAR, sans monde, GLB ni test.
- Sanctuary-beta.113.mrpack : 10173807 octets, SHA-256
`ed082ff29a0e9a70fd6786bc024241b276cb516bc09fb5151d6cb324604429bb`.
- Sanctuary-Test-beta.113.mrpack : 10192733 octets, SHA-256
`387f54dc77884a9c9bccdcc1904778ca7f0b2fbda126754fdc49b10a492e4602`.
- JAR Sanctuary : SHA-256
`e242d9fd2edeede922a72a10e988e29c2b49dea52105cfc9fc8573154849ead3`.
Reçu local : `build/clay-grid113-artifact.json`.
## Publication et installation
La [release beta.113](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.113)
est publiée depuis `6166d0aa9eba3c3dd84b8b9f1501e3346231ac82`. Le tag exact
et les artefacts sont immuables ; les téléchargements publics sont vérifiés.
Le canal packwiz avance à `e4c16d517bfda4f2b688604cc61945a46a4513a9`.
Deux synchronisations isolées puis deux passages dans **Sanctuary Beta** ont
réussi. Un seul JAR Sanctuary beta.113 est actif, et le second passage conserve
les mêmes hashes. Les **923 fichiers personnels et réglages suivis** restent
identiques. Aucun monde personnel na été ouvert. La sauvegarde ciblée est
`sanctuary-backups/before-beta.113/` dans linstance existante. Reçus locaux :
`build/clay-grid113-isolated.json` et `build/clay-grid113-prism.json`.
+15 -2
View File
@@ -62,5 +62,18 @@ Les deux packs contiennent le même JAR, sans monde, GLB ni classes de tests.
- JAR : SHA-256
`9007a770bbe69db5846f93140d910514f10839cc34ec7c6315a9381d8850adaa`.
Reçu local : `build/clay-hotbar112-artifact.json`. Publication et mise à jour
de linstance stable préparées après ces contrôles.
Reçu local : `build/clay-hotbar112-artifact.json`.
## Publication et installation
La [release beta.112](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.112)
est publiée depuis `1eaa976ecf328c6c20afe5e4eb67c48118f87f95`. Le tag exact
et les artefacts sont immuables ; les téléchargements publics sont vérifiés.
Le canal packwiz avance à `573c5b0d84fcb3e9fec7cc8a83981cf35eaa1ead`.
Deux synchronisations isolées puis deux passages dans **Sanctuary Beta** ont
réussi. Un seul JAR Sanctuary beta.112 est actif, et le second passage conserve
les mêmes hashes. Les **923 fichiers personnels et réglages suivis** restent
identiques. Aucun monde personnel na été ouvert. La sauvegarde ciblée est
`sanctuary-backups/before-beta.112/` dans linstance existante. Reçus locaux :
`build/clay-hotbar112-isolated.json` et `build/clay-hotbar112-prism.json`.
+83
View File
@@ -0,0 +1,83 @@
# beta.118 — Murets en briques de couleur
Ticket sur `codex/colored-brick-walls-beta118`, Minecraft 26.3.
Les seize familles de briques reçoivent un muret natif `WallBlock`, sous
l'identifiant `sanctuary:<couleur>_brick_wall`. Chaque muret réutilise la texture
de briques fournie de sa couleur, sans nouvelle image ni recoloration.
Les murets forment une série de seize dans **Blocs colorés**, après les dalles
et avant les argiles, dans l'ordre des couleurs vanilla retenu en beta.103.
Les libellés sont traduits FR/EN.
Recettes : six briques de même couleur, en deux rangées de trois, donnent six
murets ; le tailleur de pierre donne un muret par bloc de briques. Les deux
recettes par couleur rejoignent la collection Argile et briques et leurs
déblocages. Totaux des ajouts colorés : 80 blocs, 112 objets dont ces blocs,
224 recettes. Les recettes et identifiants préexistants sont conservés.
Piliers, raccords entre couleurs et aux murets vanilla, côtés hauts/bas,
collisions et immersion dans l'eau viennent du jeu. Tags natifs `walls`
pour blocs et objets, pioche et butin de la bonne couleur. États, modèles
multipart et butins sont repris des ressources exactes Minecraft 26.3.
La génération reste reproductible dans `tools/generate-colored-bricks.py` ;
elle conserve les ajouts externes à ses briques, notamment la recette du
Clay Workshop et les membres Sculpture/Atelier. Le compilateur des collections
reste leur seul générateur. Aucun ajout au
terrain, changement de format ou migration de sauvegarde. Les dépendances
et le pack de textures intégré beta.090 restent inchangés.
## Vérifications et livraison
Le scénario natif `Bricks102ClientChecks`, complété par `Walls118Checks`,
passe en **38 secondes** sur un nouveau monde plat de graine 102 avec serveur
intégré Minecraft 26.3 :
- Seize murets réellement posés, dont une seconde série dans l'eau.
- Recette de six murets et tailleur de pierre pour chaque couleur ; les
anciennes recettes de briques, dalles, escaliers et argiles passent aussi.
- Raccords entre couleurs et au muret vanilla, retrait des piliers intermédiaires,
côtés relevés sous un bloc plein, collisions identiques au muret vanilla.
- Eau conservée, pioche, butin de la bonne couleur, collection et recettes
découvertes, recette du Clay Workshop toujours découverte, série créative
de seize dans l'ordre natif.
- États et modèles de tous les blocs de la famille vérifiés, sans texture
manquante ; sauvegarde et reconnexion conservent les murets et leur eau.
Log : `build/walls118-client.log`, marqueur `WALLS118_PASS`. Capture de la
galerie relue dans `build/walls118-evidence/`. Aucun essai Windows ou LAN ;
GameTest dédié exclu selon le refus antérieur de son EULA. Aucun monde personnel
ouvert. La régénération finale conserve à l'identique 2 024 fichiers de
ressources et métadonnées.
`check build assemblePack assembleTestPack -x :sanctuary:runGameTest` réussit
en **2 min 5 s**, 125 tâches dont 101 exécutées. Sources et JAR concordants,
intégrité ZIP, versions, modèles natifs, recettes et empreintes vérifiés.
La comparaison avec beta.117 limite les changements de classes à
`ColoredBricks` et ses classes internes ; les sculptures et chapeaux restent
identiques. Les textures existantes sont toutes conservées octet pour octet.
- `Sanctuary-beta.118.mrpack` : 10216437 octets, SHA-256
`75bb04aad981644888690aaeda9a293a94c5d32f6439ca24613365a41aba1b6b`.
- `Sanctuary-Test-beta.118.mrpack` : 10235359 octets, SHA-256
`f7f0a177b8472f0e8c34ad99a1ccef5d61396014017d260e934e910c4e6eb00f`.
- JAR Sanctuary :
`fc6edf4046a8ace357d561ed942213a22c7a27164fa160cdb4316f72ec302575`.
Reçu : `build/walls118-artifact.json`.
## Publication et instance
La [release beta.118](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.118)
est publiée : JAR, MRpack normal/Test et amorçage Prism, téléchargements publics
et empreintes vérifiés. Le tag immuable désigne
`0a81b3c1471c43d705eec4e31a8f6f1820049ca8`. Le canal packwiz avance au commit
`e6bab433bea9528f67d39af0d6c7bcb6443e4817`.
Deux synchronisations isolées puis deux dans l'unique instance **Sanctuary
Beta** réussissent, la seconde passe laissant les fichiers gérés identiques.
Un seul JAR Sanctuary beta.118 est actif. Les 923 fichiers personnels et
réglages suivis sont inchangés ; aucun monde personnel n'a été ouvert.
Copie préalable des fichiers remplacés dans `sanctuary-backups/before-beta.118/`.
Reçus : `build/walls118-isolated.json`, `build/walls118-prism.json`.
+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 01 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 01 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`.
+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 01 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/`.
+74
View File
@@ -0,0 +1,74 @@
# beta.115 — Dépôts et chauffe du Fourneau
Ticket sur `codex/fourneau-transfers-beta115`, après le signalement de piles
qui se réalignent pendant les dépôts rapides avec Maj, dans Ingrédients et
Combustibles, et d'une chauffe difficile à comprendre.
## Diagnostic
La méthode `mayPlace` de la case serveur utilisait `index`, masqué par le
champ hérité `Slot.index` (position affichée). Elle validait donc successivement
une entrée, un combustible et une sortie, au lieu des 27 entrées ou des
27 combustibles. Le client acceptait le dépôt puis le serveur le refusait.
Le test reproduit le refus dun déplacement de la case 1 vers la case 23.
Le correctif utilise explicitement `physicalIndex` pour la validation.
## Comportement
Les transferts Maj dans les vues complètes du Fourneau sont anticipés sur le
client, avec les mêmes destinations et règles de combustible que le serveur.
Les cases se remplissent dans l'ordre, après fusion des piles compatibles.
Un dépôt manuel conserve la case choisie ; aucun tri périodique des ingrédients
n'est ajouté. Les recherches masquent une partie du stock : leurs transferts
entrants continuent d'attendre la réponse du serveur, qui connaît tout le stock.
La chauffe emprunte du combustible à une autre case uniquement pendant le tick
natif. La pile non brûlée retourne immédiatement à sa case d'origine, avec tous
ses composants. Les restes natifs restent associés à leur cuisson : un seau de
lave devient un seau, puis un seau d'eau avec une éponge mouillée.
Les 27 entrées ont chacune une barre et une infobulle : cuisson en cours,
manque de combustible avec progression conservée, résultat bloqué, absence de
recette. Le panneau indique le nombre de cuissons et la réserve allumée en
secondes de travail cumulées. Ce n'est pas une durée réelle restante : les
cuissons parallèles se partagent la réserve. « Fonctionnement » explique les
onglets, les gestes et le rendement normal du combustible en FR/EN.
Les recettes, résultats, expérience et règles de trémies restent natifs.
Aucun changement de format, de monde ou de génération ; les 27 fours physiques
conservent l'unique copie des objets et de leurs temps de chauffe.
## Vérifications
Le test natif `Furnace115ClientChecks` passe avec un serveur intégré, sur un
nouveau monde plat de graine 115 : vrai glisser avec Maj sur neuf piles nommées,
remplissage consécutif, déplacement manuel vers la case 23 et répartition native
sur six cases de colonnes différentes. Les 27 cases de chaque onglet valident
leur rôle physique. Le combustible nommé conserve sa position et ses composants.
La cuisson parallèle, ses états synchronisés, la progression conservée à court
de combustible, les 27 cuissons ravitaillées, le seau de lave et l'éponge,
l'extraction des résultats et la sauvegarde native passent. Captures FR/EN
relues dans `build/furnace115-evidence/`. Marqueur `FURNACE115_PASS` dans
`build/furnace115-client.log`.
`check build assemblePack assembleTestPack -x :sanctuary:runGameTest` réussit
en 2 min 29 s, 125 tâches. Le panneau d'aide reçoit ensuite une dernière
vérification client et une reconstruction ; la livraison commune beta.116
repasse les contrôles complets en 2 min 23 s et le scénario Fourneau en 42 s.
Le GameTest dédié reste exclu conformément au refus antérieur de son EULA.
Pas d'essai Windows ou de réseau distant avec latence simulée. Aucun monde
personnel ouvert. Cette étape reste locale et rejoint la livraison commune
avec les matériaux de sculpture : [beta.116](sculpture-materials-beta116.md).
Archives locales vérifiées (même JAR Sanctuary, sources concordantes, aucune
classe de test ni changement de production extérieur au ticket) :
- `Sanctuary-beta.115.mrpack` : 10184430 octets, SHA-256
`8fcac63b1bf5a01f680fb46666924705db3a2675e102037278e9d650d04cbf22`.
- `Sanctuary-Test-beta.115.mrpack` : 10203354 octets, SHA-256
`5b98fdb8842690158a20b422d6aa5496d74e572b3429498ffb25476d24e87f8a`.
- JAR Sanctuary : `2066d3191d7ee27904927fab269d8160136f048742c3223713e8ed4fe90dd8f5`.
Reçu : `build/furnace115-artifact.json`.
+152
View File
@@ -0,0 +1,152 @@
# GEM-01 — Gemmes, argile et découvertes — beta.126
Contrat du 17 septembre 2026, branche `codex/gems-beta126`, Minecraft 26.3.
Deux gemmes natives complètent l'émeraude : `sanctuary:ruby` et
`sanctuary:sapphire`. Chaque famille comprend un bloc de stockage, un minerai
ordinaire et un minerai des abîmes. Les huit PNG du créateur sont copiés à
l'identique ; leurs empreintes figurent dans `tools/gems-textures-beta126.json`.
Les noms des fichiers français `saphir` sont associés aux identifiants anglais
stables `sapphire`, sans modifier les pixels. Les deux items utilisent les fichiers de remplacement
`Desktop/ruby.png` et `Desktop/saphir.png` fournis en fin de livraison.
L'ordre créatif est émeraude, rubis, saphir : ingrédients, blocs de stockage,
minerais ordinaires et minerais des abîmes dans leurs onglets natifs respectifs.
Minage à la pioche de fer ou mieux, résistance et expérience comme l'émeraude,
Fortune, Toucher de soie, cuisson/four à fusion et conversion 9 gemmes ↔ 1 bloc.
Les blocs peuvent former un socle de balise et les gemmes servir de paiement.
Les recettes rejoignent la collection actuelle de l'émeraude, sans ajouter de
transactions de villageois. Libellés français et anglais, tags communs `c:`.
## Génération procédurale et contrat de compatibilité
Profil initial **gemmes 1 / beta.126** : quatre tentatives par chunk et par
gemme, filons natifs de taille 3, hauteur uniforme Y0256, probabilité de rejet
au contact de l'air de 0,25. Ces nombres sont un réglage initial à équilibrer
lors de la future refonte ; aucun quota de gemmes par chunk n'est garanti.
Les variantes ordinaires remplacent les roches du tag vanilla
`stone_ore_replaceables`, les variantes des abîmes celles de
`deepslate_ore_replaceables`.
Les fichiers `worldgen/feature/ore_<gemme>.json`,
`worldgen/placed_feature/ore_<gemme>.json` et les tags de biomes
`has_ore/<gemme>` séparent les cibles, la forme, la fréquence, la hauteur et
les régions. Un datapack peut les remplacer sans recompiler le mod. Le code
réutilise `OreFeature` et le hasard natif issu de la graine ; il limite ces
filons aux mondes utilisant `SanctuaryChunkGenerator`. Les biomes vanilla
partagés avec Sanctuary restent compatibles sans ajouter les gemmes aux
mondes vanilla, au Nether ou à l'End.
Cette évolution additive est autorisée pour les **nouveaux chunks uniquement**.
Aucun remplissage rétrospectif, aucune régénération, aucun remplacement de chunk
sauvegardé ni activation d'expansion. Les codecs du relief, les dimensions et
le format des sauvegardes restent inchangés. Les filons suivent les protections
existantes de la décoration native. Les anciens profils expérimentaux précédant
le générateur Sanctuary unifié ne sont pas étendus.
## Argile spéciale dans le Clay Workshop
Le bloc d'argile vanilla conserve les couleurs échantillonnées sur le modèle
original, animal découvert ou import GLB. Les argiles colorées et les autres
blocs continuent à transférer leur palette de texture. Une même règle est
appliquée à l'aperçu client et au résultat serveur. Le changement de matériau
invalide le résultat même si les deux palettes sont identiques. Le texte de
l'atelier explique l'exception en français et en anglais.
Une sculpture coûte toujours un bloc ; aucune recette, découverte, donnée de
sculpture déjà fabriquée ni règle de placement n'est modifiée.
## Commande de découverte commune
`/sanctuary discovery all` découvre tous les blocs, objets et espèces enregistrés.
Les variantes `blocks all`, `items all` et `mobs all` ciblent une catégorie ;
`block <identifiant>`, `item <identifiant>` et `mob <identifiant>` ciblent une
entrée avec la complétion native. Un sélecteur de joueurs en fin de commande
est facultatif ; sans cible, elle s'applique au joueur qui l'exécute.
Exemples :
```mcfunction
/sanctuary discovery all
/sanctuary discovery mobs all
/sanctuary discovery block minecraft:stone
/sanctuary discovery item minecraft:diamond
/sanctuary discovery mob minecraft:cow
/sanctuary discovery all @a
```
La permission suit « Autoriser les commandes » en solo et les droits opérateur
sur serveur. La console doit préciser la cible. Les identifiants invalides,
l'air et les types d'entités sans espèce découvrable sont refusés.
Les blocs passent par le registre de découvertes et son événement existant ;
les objets passent par `RecipeService.discover`. Les collections et recettes
sont ensuite recalculées normalement. Les espèces rejoignent `seen_mobs`,
la même donnée que l'observation naturelle, consommée par les familiers,
le Métabli et le Clay Workshop. Il n'existe aucun drapeau de déblocage parallèle.
Les catalogues déjà ouverts sont à rouvrir après la commande.
La commande conserve les quantités possédées et les compteurs de combat,
minage, pose et fabrication. Elle ne donne pas d'objets, n'achète pas les skills
et n'attribue pas d'advancements. Les recettes réservées aux opérateurs gardent
leurs règles. Les historiques personnels sont enrichis sans changer leur
schéma ; la commande refuse les données illisibles plutôt que les remplacer.
## Vérifications
Client natif Minecraft 26.3 / Java 25, `Gems126ClientChecks`, réussi en
1 min 08 s. Les mondes sont jetables, sans sauvegarde personnelle ouverte :
- Les huit items et six blocs sont présents, avec les huit textures exactes.
Quatre groupes créatifs respectent émeraude → rubis → saphir, sans doublons.
- Douze recettes natives, découverte du bloc de stockage, outil fer minimum,
Fortune III, Toucher de soie et balises passent les contrôles serveur.
- Graine **126**, profil gemmes 1, 81 chunks autour de l'origine : 7 rubis dans
la pierre et 8 dans la roche des abîmes ; 15 saphirs dans la pierre et 10 dans
la roche des abîmes. La génération est refusée dans le monde plat vanilla.
Les minerais persistent à la reconnexion, et un minerai retiré ne réapparaît pas.
- L'argile conserve exactement les couleurs des voxels d'une vache et d'un GLB
texturé. Bois et argile colorée transfèrent toujours leur palette. Le retour
à l'argile, l'égalité aperçu/résultat et le coût d'un bloc en survie sont vérifiés.
- La commande native respecte « Autoriser les commandes », accepte une cible
et la console, refuse les identifiants invalides et permet une espèce, un
bloc, un objet, toutes les espèces ou tout le contenu. Les recettes liées
sont recalculées. La répétition est idempotente et les découvertes survivent
à la reconnexion sans inventer de compteurs de combat ou de possession.
Logs et captures dans `build/gems126-client.log` et
`mods/sanctuary/build/run/clientGameTest/screenshots/`. Le GameTest dédié
reste exclu conformément au refus antérieur de son EULA. Le build complet et
les archives sont vérifiés séparément avant publication.
Build final `./gradlew check build assemblePack assembleTestPack
-x :sanctuary:runGameTest` réussi en **2 min 10 s**, 126 tâches. Les contrôles
natifs de gameplay précèdent uniquement le remplacement final des deux PNG
d'items ; les huit fichiers livrés sont vérifiés à l'octet près contre les
sources, tous en 16 × 16. Les JAR de sources correspondent aux sources courantes.
L'audit entre les binaires beta.125 et beta.126 ne trouve que les 112 entrées
de production prévues. Les archives normal/Test contiennent le même mod et
le template reproduit exactement ses ressources exportables.
| Artefact | SHA-256 |
| --- | --- |
| `Sanctuary-beta.126.mrpack` | `716221ce24549781ab91f5437ad2310497b198a4b61c43c40c67a27984365ae5` |
| `Sanctuary-Test-beta.126.mrpack` | `bbf22d7dd72e6746538b4ec28686f699225d4a59372304e5db42140a80e88bd1` |
| `sanctuary-beta.126.jar` | `85dbe1b41d98751029bb62e87d259522f8609758896c797b1e02fa7656ec0342` |
## Publication et synchronisation
[Release beta.126](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.126)
publiée depuis `99860af312a1737d938812f3515fcc4acb4291b2` ; artefacts
immuables retéléchargés et vérifiés. Canal packwiz : `31749b95d21ce5bced6b9e5759499da6f8a67e6e`.
Deux synchronisations isolées, puis deux synchronisations de l'instance existante
**Sanctuary Beta** ont réussi. Un seul JAR Sanctuary beta.126, correspondant
à l'empreinte ci-dessus. Les **923 fichiers personnels** suivis sont conservés,
sauvegardes restées fermées ; copie préalable dans
`sanctuary-backups/before-beta.126/`.
Packs normal/Test et template copiés et vérifiés dans
`sanctuary-beta/build/`. Reçus locaux : `build/gems126-artifact.json`,
`build/gems126-isolated.json`, `build/gems126-prism.json` et
`build/gems126-publication.log`.
+109
View File
@@ -0,0 +1,109 @@
# TOOL-01 — Clé dorée et états de blocs — beta.120
Contrat du 17 septembre 2026, branche `codex/golden-wrench-beta120`.
Le créateur valide les gestes du debug stick : clic gauche choisit la propriété,
clic droit parcourt ses valeurs, Maj inverse le parcours. L'orientation est
proposée en premier, puis les propriétés de forme et les autres états natifs.
La sélection est personnelle, par type de bloc, pour la session courante.
Une indication dans la barre d'action affiche la propriété et sa valeur.
La clé ne casse pas le bloc, y compris en créatif, et ne remplace pas son type.
Les propriétés natives sont disponibles en survie avec la clé, sans condition
opérateur, dans le respect des droits de construction, protections, portée et
verrous de conteneurs. Comme avec le debug stick, les formes choisies restent
en place immédiatement ; les mises à jour ultérieures du jeu peuvent recalculer
les états dynamiques (redstone, connexions, cultures…).
Les multiblocs assemblés gardent leurs commandes d'ouverture/dissociation et
leurs propriétés techniques ne sont pas éditées individuellement. Un assemblage
complet garde priorité au clic droit ; Maj permet de régler les composants
non assemblés. Hors d'un assemblage complet, même un four, un baril, un piston
ou un établi reçoit l'action générique.
## Rotation de texture et contrat de stockage
La terre n'a pas d'orientation dans son blockstate vanilla. Une propriété
supplémentaire de la clé, « Rotation des textures », parcourt 0°, 90°, 180° et
270° sur les faces des modèles de blocs. La géométrie, l'éclairage, les teintes,
les ressources actives, le bloc, son inventaire et son butin restent natifs.
Les rendus spéciaux d'entités de blocs ne sont pas transformés par ce réglage
des faces ; leur orientation demeure modifiable par leurs états natifs.
Nouvelle donnée facultative `sanctuary:wrench_textures_v1`, attachée au chunk :
table immuable position entière → quart de tour (1 à 3). Une absence signifie
0°. Elle n'est créée qu'après une action de clé ; revenir à 0° retire l'entrée,
puis l'attachement s'il est vide. Aucun état vanilla ni format existant n'est
modifié ; aucun parcours, conversion ou régénération des anciens mondes.
Les modifications de blockstate du même type conservent la rotation. Casser,
remplacer ou déplacer par piston retire la rotation à l'ancienne position ;
les objets récupérés restent ordinaires. Les nouveaux clients la reçoivent
après le chunk natif, par lots bornés de 1 024 entrées au maximum.
Déconnexion, changement de dimension et déchargement libèrent le cache visuel.
Revenir à une ancienne version du mod supprime seulement cet effet visuel ;
les blocs conservent leurs identifiants, données et états natifs.
## Vérifications et livraison
Le parcours `Wrench120ClientChecks` passe en **47 s** sur Minecraft 26.3,
client macOS et serveur intégré, dans un monde plat jetable de graine 120 :
- **2 522 propriétés natives** parcourues, avec valeurs inverses et type conservé.
- Vrais clics sur les escaliers : orientation, moitié supérieure et forme,
sans casser le bloc en créatif ; gestes inverses et sélection distincte.
Les doubles événements créatifs d'un même clic sont filtrés à la cadence
native de cinq ticks.
- Terre en survie : rotation réelle des UV émis, positions des sommets et nombre
de faces identiques ; retour exact après quatre quarts de tour ou un inverse.
- Rechargement des ressources conservant les UV tournés ; capture de terre relue.
- Coffre tourné avec ses 23 diamants intacts, sans ouverture accidentelle.
- Assemblage/dissociation par vrais clics des quatre familles : Métabli, Fût,
Fourneau et super piston. Un clic gauche sur un assemblage ne modifie pas
les numéros de composants ; barils et pistons isolés tournent normalement.
- Refus en aventure, spectateur ou hors portée ; retrait des données visuelles
après remplacement, maintien lors d'un changement d'état du même bloc.
- Sauvegarde/reconnexion : rotation de terre côté serveur et client, état
natif de l'escalier et inventaire du coffre conservés.
```sh
./gradlew :sanctuary:runClientGameTest \
-PsanctuaryClientTests=true -PsanctuaryWrench120ClientTests=true \
-PsanctuaryClientNoVsync=true -PsanctuaryQuickTests=true
```
Marqueurs `WRENCH120_PASS` et `WRENCH120_PROPERTIES 2522` dans
`build/wrench120-client.log`, captures dans `build/wrench120-evidence/`.
Aucun monde personnel ouvert ; pas de validation Windows ni depuis deux
ordinateurs. Les rendus de faces natifs et le rechargement du pack sont vérifiés ;
pas de validation spécifique d'un shader tiers.
Le GameTest dédié reste exclu conformément au refus antérieur de son EULA.
`./gradlew check build assemblePack assembleTestPack -x :sanctuary:runGameTest`
passe en **2 min 12 s**, 125 tâches. Les sources JAR correspondent exactement
aux sources du dépôt. La comparaison avec beta.119 limite les différences de
production aux classes de clé/assemblage, aux deux nouveaux hooks, au rendu
et aux libellés prévus ; toutes les autres ressources sont identiques.
Les packs normal/Test contiennent le même JAR Sanctuary et aucune sauvegarde
ni fixture de test.
- Sanctuary-beta.120.mrpack : 10237973 octets, SHA-256
`86b5e480a12ba20b5c1e308c77dbb54bdf8a56806915fb32ca2d16f9a72cc9c9`.
- Sanctuary-Test-beta.120.mrpack : 10256895 octets, SHA-256
`2e3818483ba7dfa6e5652b97933e105a44e7cc4b269fc567116bf895d54bd8d9`.
- JAR Sanctuary : SHA-256
`8e35ef0f134a46b7760604175ad768264946d31fdd0694982e049e7a928fde2a`.
Reçu local : `build/wrench120-artifact.json`.
## Publication et installation
La [release beta.120](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.120)
est publiée depuis `248dff47b7a0ebd11026230581fe69641dbde76d`. Le tag exact et les
artefacts sont immuables ; leurs téléchargements publics sont vérifiés.
Le canal packwiz avance à `e8060764a444a58b0a17318a3821dfeb1a0c33c6`.
Deux synchronisations isolées puis deux dans **Sanctuary Beta** réussissent.
Un seul JAR Sanctuary beta.120 est actif ; le second passage est identique.
Les **923 fichiers personnels et réglages suivis** restent inchangés, sans
ouvrir de monde personnel. Copie préalable dans
`sanctuary-backups/before-beta.120/` de l'instance existante. Reçus locaux :
`build/wrench120-isolated.json` et `build/wrench120-prism.json`.
+189
View File
@@ -0,0 +1,189 @@
# 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.
Lintensité (0100 %, défaut 100 %) agit sur la surface et limmersion.
Linterrupteur ou 0 % retrouve le rendu précédent. Les préférences existantes
restent conservées ; un moteur externe suspend leffet comme les autres effets
natifs Sanctuary.
La couleur native du biome reste la base, y compris les variantes docé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 |
Leau 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 limplé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. Aux bords océaniques, les sommets partagés restent clairs et la
transition occupe le bloc voisin hors océan. Leau sous un toit suit son fond,
pas l’éclairage du bâtiment.
La ressource client `assets/sanctuary/water_materials.json` accepte deux listes
didentifiants 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 dun 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 lenvoi au worker. La présence du terrain est vérifiée dans le cache client avec chargement
désactivé, sans utiliser `ClientLevel.hasChunk` (toujours vrai). Aucun
chargement de chunk nest demandé par le classificateur.
Un changement de bloc invalide la zone dépendante, huit blocs au-dessus et
un bloc horizontalement. Les reconstructions natives recopient leurs données ;
leffet ne déclenche aucune reconstruction pour une lumière mobile.
Un changement doption reconstruit seulement les sections chargées contenant de
leau ; les caches des autres effets restent disponibles. Le cache temporaire
disparaît avec sa compilation, sans historique de monde.
Sous leau, 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 lolive 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 ny a pas de simulation dabsorption 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 finales OpenGL et Vulkan sont réussies le 18 septembre
2026 (`WATER144_PASS` et `WATER144_EXTRAS_PASS`). Elles couvrent surface,
immersion, fond modifié, priorité océan, toit, plantes, cascades, lave inchangée,
blocs gorgés deau, préférences FR/EN et rechargement des ressources/chunks.
Les sommets partagés aux limites océaniques et le repli clair lorsque le cache
client ne contient pas le terrain font lobjet dassertions supplémentaires.
Aucun autre client de test concurrent na été détecté pendant ces séries.
Deux passages ciblés supplémentaires (`WATER144_TEXTURE_PASS`), Vulkan en
1 min 18 s et OpenGL en 1 min 4 s, chargent de vraies textures personnelles
32×32 à deux images animées pour leau calme et courante. Grille, animation
visible et retour aux textures natives sont vérifiés après désactivation.
Les fixtures, leurs alpha et les captures restent dans les preuves locales.
### Protocole de performance
MacBookPro17,1, Apple M1, 8 Gio, macOS 26.5.1 (25F80), Java 25,
fenêtre logique 854×480, cible Retina **1708×960 pixels**, 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.
Le mod de test vérifie le limiteur effectif et journalise le mode de présentation.
Pendant les échantillons uniquement, il omet lattente artificielle de
`FramerateLimiter.limitDisplayFPS`, afin de mesurer sans temporisation
applicative. Cette sonde reste absente du JAR de production. Les temps couvrent
la frame native, présentation comprise, et ne sont pas des temps GPU isolés.
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.
La scène de déplacement prolonge le bassin hors de la vue initiale : son terrain
est préparé côté serveur de test, puis découvert normalement par le client.
Le vérificateur exige des compilations réelles dans chaque passage en mouvement.
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, les exécutions concurrentes et les vérifications
incomplètes du limiteur restent conservés comme preuves de développement ;
ils ne fondent pas ces résultats. Les séries finales ont été exécutées après
la fin du chantier SSR. Les variations négatives sont du bruit de mesure,
pas une promesse daccélération. Vulkan reste proche de 120 images/s dans
cette chaîne de présentation, malgré le mode `IMMEDIATE` et labsence de
limiteur applicatif : les conclusions sont limitées à la machine et aux
conditions ci-dessus.
| Moteur / scène | Médiane OFF → ON (ms) | Variation | P95 OFF → ON (ms) | Δ P95 (ms) |
| --- | --- | ---: | --- | ---: |
| vulkan / mixed | 8.069 → 8.092 | +0.29 % | 9.091 → 9.101 | +0.010 |
| vulkan / large | 8.078 → 8.062 | -0.20 % | 9.126 → 9.114 | -0.012 |
| vulkan / moving | 8.123 → 8.103 | -0.24 % | 9.103 → 9.080 | -0.023 |
| opengl / mixed | 4.545 → 4.407 | -3.02 % | 7.080 → 6.733 | -0.347 |
| opengl / large | 4.879 → 5.110 | +4.73 % | 7.519 → 7.767 | +0.248 |
| opengl / moving | 6.817 → 6.338 | -7.02 % | 10.618 → 9.759 | -0.859 |
Les six comparaisons respectent les seuils : médiane ≤ +5 %, P95 ≤ +2 ms.
Le cas limitant est la grande surface OpenGL (+4,73 %, +0,248 ms).
Les compilations de sections sont mesurées sur le parcours en mouvement :
| Moteur | Sections OFF / ON | Médiane OFF → ON (ms) | P95 OFF → ON (ms) |
| --- | ---: | --- | --- |
| vulkan | 222 / 220 | 1.889 → 2.186 | 4.307 → 4.898 |
| opengl | 222 / 220 | 1.528 → 1.540 | 5.492 → 5.227 |
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
nest pas déclarée réussie. Les contrôles natifs de leau utilisent le serveur
intégré dun monde jetable. Aucun essai Windows nest revendiqué.
Lassemblage avec lexclusion ci-dessus a réussi en 2 min 33 s (126 tâches).
`python3 scripts/pack.py check` réussit. Les cinq archives passent le contrôle
ZIP ; les deux MRpack embarquent exactement le même JAR Sanctuary et déclarent
Fabric API 0.160.5+26.3 avec lempreinte du manifeste source. Lexport MRpack est
exécuté depuis chaque dossier de staging packwiz. Les dépendances imbriquées
Demeure et JEI sont vérifiées par lassemblage natif du dépôt.
La release, le canal et linstance 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/`.
## Artefacts vérifiés
| Artefact | Octets | SHA-256 |
| --- | ---: | --- |
| `sanctuary-beta.144.jar` | 11343257 | `1ddbe941f3262da8a1497ea5df4a992e260b80ae95b341623dd68f8f9729ae88` |
| `Sanctuary-beta.144.mrpack` | 10483196 | `d5ef5a781f5ee796117210d1f6afc96172461315faee34097323beff541ce6f0` |
| `Sanctuary-Test-beta.144.mrpack` | 10502119 | `e7cc32ac19ae98ebbe7a9aac3dc08decdf09396c8175b597f87051399ff3812e` |
| `Sanctuary-Template-beta.144.zip` | 4354678 | `e510c0040dc9053492715ac91381b7dbc5a6f7c7defbf81e7502d8e18dbb0ee6` |
| `Sanctuary-Prism-auto-update.zip` | 92423 | `c0920823f40d709c14e074d8d949f2cc3681c24140bf86f21c3be345cfc3e1f6` |
## Captures comparatives
Pièces jointes de la release, issues du client Vulkan de validation :
[OFF](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.144/Sanctuary-beta.144-water-off.png),
[ON](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.144/Sanctuary-beta.144-water-on.png),
[profil étang en proximité](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.144/Sanctuary-beta.144-water-pond.png).
Les captures sont exportées à 854×480 ; la cible de présentation mesurée est
1708×960. Le fichier public `Sanctuary-beta.144-SHA256SUMS.txt` inclut les cinq
livrables et ces trois captures. Les journaux et échantillons détaillés restent
locaux, dans `build/water144-evidence/` et les reçus `build/water144-*.json`.
+326
View File
@@ -14,6 +14,332 @@ 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
commune aux ombres et au highlight. Réglages initiaux 32/128/16 et Pixel Shadows
activé ; anciennes préférences conservées. [Contrat et vérifications](shader-beta130.md).
[Release beta.130](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.130)
publiée ; canal `68e06353bf880af04c1c72d4e3a159f11f94b619`. Deux synchronisations isolées
puis deux dans la même instance **Sanctuary Beta** réussies. Les 923 fichiers
personnels suivis, réglages de shaders compris, gardent leurs hashes. Aucun
monde personnel ouvert ; sauvegarde préalable `sanctuary-backups/before-beta.130/`.
Packs normal/Test et template vérifiés et copiés dans `sanctuary-beta/build/`.
## beta.129 — reflets CTM et portées 16256 blocs
Reflets connectés entre tous les matériaux coplanaires, portée indépendante,
ombres à 128/256 blocs, intensité rétablie à midi et correction du réalignement
cyclique de la grille lumineuse. [Contrat et vérifications](shader-beta129.md).
[Release beta.129](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.129)
publiée ; canal `2eaa0130a4cd475c598beefdab40f48fc8681ab7`. Deux synchronisations isolées
puis deux dans la même instance **Sanctuary Beta** réussies. Les 923 fichiers
personnels suivis, réglages de shaders compris, sont conservés. Aucun monde
personnel ouvert ; copie préalable dans `sanctuary-backups/before-beta.129/`.
Packs normal/Test et template copiés et vérifiés dans `sanctuary-beta/build/`.
## beta.128 — stabilité des ombres, lune et feuillage
Ombres des blocs nettes et stabilisées, atténuation à midi, lune légère et
feuillage nuancé selon son épaisseur. [Contrat et vérifications](shader-beta128.md).
[Release beta.128](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.128)
publiée ; canal `eba01726ee132a6e548d129fa7ee04f24c880917`. Deux synchronisations isolées
puis deux dans la même instance **Sanctuary Beta** réussies. Les 923 fichiers
personnels suivis, réglages de shaders compris, sont conservés. Aucun monde
personnel ouvert ; copie préalable dans `sanctuary-backups/before-beta.128/`.
Packs normal/Test et template copiés et vérifiés dans `sanctuary-beta/build/`.
## beta.127 — shader et ombres pixélisées
Ombres solaires optionnelles intégrées à Sanctuary · Vanilla Light, avec finesse
et portée réglables. Socle beta.126 conservé. [Contrat et vérifications](shader-beta127.md).
[Release beta.127](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.127)
publiée ; canal `7fd24a43e01ee497a23311e3c78e1dd3dddd877a`. Deux synchronisations
isolées puis deux dans la même instance **Sanctuary Beta** réussies.
Les 923 fichiers personnels suivis, réglages de shaders compris, sont conservés.
Aucun monde personnel ouvert ; copie préalable dans `sanctuary-backups/before-beta.127/`.
Packs normal/Test et template copiés et vérifiés dans `sanctuary-beta/build/`.
## beta.126 — gemmes, argile et découvertes
Rubis et saphir, nouvelles textures d'items, argile conservant les couleurs
originales et commandes de découverte communes aux recettes et catalogues.
[Contrat et vérifications](gems-beta126.md).
[Release beta.126](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.126)
publiée ; canal `31749b95d21ce5bced6b9e5759499da6f8a67e6e`. Deux synchronisations
isolées puis deux dans la même instance **Sanctuary Beta** réussies.
Les 923 fichiers personnels et réglages suivis sont inchangés ; sauvegardes
restées fermées. Copie préalable dans `sanctuary-backups/before-beta.126/`.
Packs normal/Test et template copiés et vérifiés dans `sanctuary-beta/build/`.
## beta.125 — placement, éclats et animaux sculptés
Coins et trois hauteurs, particules de voxels et espèces découvertes au Clay
Workshop. [Contrat et vérifications](sculpture-placement-beta125.md).
[Release beta.125](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.125)
publiée ; canal `47eb833911a96eef0b13805dafe65b2ab7b95c87`. Deux
synchronisations isolées puis deux dans la même instance **Sanctuary Beta**
réussies. Les 923 fichiers personnels et réglages suivis sont inchangés ;
sauvegardes restées fermées. Copie préalable dans
`sanctuary-backups/before-beta.125/`. Packs normal/Test et template copiés
et vérifiés dans le dossier habituel `sanctuary-beta/build/`.
## beta.124 — une boîte par sculpture
Une boîte ajustée commune à la sélection et aux collisions ; détails physiques
simplifiés, orientation/ancrage et éclairage conservés.
[Contrat et vérifications](sculpture-bounds-beta124.md).
[Release beta.124](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.124)
publiée ; canal `67ebcc7d07f8b75e8988f6099e3d6e5704f123fb`. Deux
synchronisations isolées puis deux dans la même instance **Sanctuary Beta**
réussies. Les 923 fichiers personnels et réglages suivis sont inchangés ;
sauvegardes restées fermées. Copie préalable des fichiers remplacés dans
`sanctuary-backups/before-beta.124/`. Packs normal/Test et template également
copiés et vérifiés dans le dossier habituel `sanctuary-beta/build/`.
## beta.123 — clé dorée et clé de Steve
La clé dorée se limite aux orientations cohérentes ; la clé de Steve reprend
les états et textures libres, utilisables aussi en survie dès sa récupération.
[Contrat et vérifications](wrench-safety-beta123.md).
[Release beta.123](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.123)
publiée ; canal `f988d01b901f2d3eda0b4ab47a48540dc627d1b5`. Deux
synchronisations isolées puis deux dans la même instance **Sanctuary Beta**
réussies. Les 923 fichiers personnels et réglages suivis sont inchangés ;
sauvegardes restées fermées. Copie préalable des fichiers remplacés dans
`sanctuary-backups/before-beta.123/`.
## beta.122 — formes et lumière des sculptures
Sélection et collisions sur les voxels réels ; suppression de l'occlusion cubique.
[Contrat et vérifications](sculpture-shape-light-beta122.md).
[Release beta.122](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.122)
publiée ; canal `3d340bea0093b709c6018624795a1b3ca557b72d`. Deux
synchronisations isolées puis deux dans la même instance **Sanctuary Beta**
réussies. Les 923 fichiers personnels et réglages suivis sont inchangés ;
sauvegardes restées fermées. Copie préalable des fichiers remplacés dans
`sanctuary-backups/before-beta.122/`.
## beta.121 — texture de clé et pack personnel
PNG du créateur et copie complète modifiable depuis l'écran des packs.
[Contrat et vérifications](resourcepack-template-beta121.md).
[Release beta.121](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.121)
publiée ; canal `e3e6572da9288fbbcfa36bf6b7dd3aa0d1c54f5e`. Deux
synchronisations isolées puis deux dans la même instance **Sanctuary Beta**
réussies. Les 923 fichiers personnels et réglages suivis sont inchangés ;
sauvegardes restées fermées. Copie préalable des fichiers remplacés dans
`sanctuary-backups/before-beta.121/`. Le template personnel est créé par le
joueur, jamais géré ou réécrit par packwiz.
## beta.120 — clé dorée et états de blocs
Gestes du debug stick, rotation des textures et assemblages prioritaires.
[Contrat et vérifications](golden-wrench-beta120.md).
[Release beta.120](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.120)
publiée ; canal `e8060764a444a58b0a17318a3821dfeb1a0c33c6`. Deux
synchronisations isolées puis deux dans la même instance **Sanctuary Beta**
réussies. Les 923 fichiers personnels et réglages suivis sont inchangés ;
sauvegardes restées fermées. Copie préalable des fichiers remplacés dans
`sanctuary-backups/before-beta.120/`.
## beta.119 — pose centrée des sculptures
Centre accessible au point visé, bords conservés et grille entière de 1/16.
[Contrat et vérifications](sculpture-center-beta119.md).
[Release beta.119](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.119)
publiée ; canal `5e823af7807f524f6394acad402fe62328c7e4ab`. Deux
synchronisations isolées puis deux dans la même instance **Sanctuary Beta**
réussies. Les 923 fichiers personnels et réglages suivis sont inchangés ;
sauvegardes restées fermées. Copie préalable des fichiers remplacés dans
`sanctuary-backups/before-beta.119/`.
## beta.118 — murets en briques colorées
Seize murets natifs, recettes, tailleur, eau et série créative par couleurs.
[Contrat et vérifications](colored-brick-walls-beta118.md).
[Release beta.118](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.118)
publiée ; canal `e6bab433bea9528f67d39af0d6c7bcb6443e4817`. Deux
synchronisations isolées puis deux dans la même instance **Sanctuary Beta**
réussies. Les 923 fichiers personnels et réglages suivis sont inchangés ;
sauvegardes restées fermées. Copie préalable des fichiers remplacés dans
`sanctuary-backups/before-beta.118/`.
## beta.117 — grain des textures et sculptures en chapeau
Échantillonnage des pixels de plusieurs textures, grain stable même sur un
modèle uni et conservation des données du chapeau.
[Contrat et vérifications](sculpture-texture-grain-beta117.md).
[Release beta.117](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.117)
publiée ; canal `a5b75a70f86e42afb8e06ba4daa9880d61c6373e`. Deux
synchronisations isolées puis deux dans la même instance **Sanctuary Beta**
réussies. Les 923 fichiers personnels et réglages suivis sont inchangés ;
sauvegardes restées fermées. Copie préalable des fichiers remplacés dans
`sanctuary-backups/before-beta.117/`.
## beta.116 — palettes de sculpture et Fourneau
Le Clay Workshop utilise les nuances des textures de blocs ; le Fourneau
corrige les décalages de cases et explique la chauffe.
[Contrat et vérifications](sculpture-materials-beta116.md).
[Release beta.116](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.116)
publiée ; canal `c741870160f8a21f0a9793f20f0e624ebdbacdb6`. Deux
synchronisations isolées puis deux dans la même instance **Sanctuary Beta**
réussies. Les 923 fichiers personnels et réglages suivis sont inchangés ;
sauvegardes restées fermées. Copie préalable des fichiers remplacés dans
`sanctuary-backups/before-beta.116/`.
## beta.110 — version commune
Clay Workshop, Statuaire et import GLB intégrés aux œufs et à la neige saisonnière.
+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`.
+128
View File
@@ -0,0 +1,128 @@
# Préparation locale des ombres portées pixélisées (ancien numéro beta.122)
Ticket du 17 septembre 2026, branche locale `codex/pixel-shadows-beta122`.
Cette préparation n'a jamais été publiée sous le numéro beta.122, déjà utilisé
par une autre livraison. Elle est intégrée au socle beta.126 et livrée en
[beta.127](shader-beta127.md). Les chemins et mesures ci-dessous décrivent
les essais historiques dans le dossier de préparation.
Demande : ajouter aux réglages de Sanctuary · Vanilla Light des ombres portées
pixélisées, avec une finesse et une distance réglables.
## Utilisation
Dans **Options → Shaders**, activer **Sanctuary · Vanilla Light**, puis
**Ombres portées pixélisées**. L'option est désactivée par défaut pour conserver
le coût et le rendu des installations existantes. Les choix sont :
- finesse de **8, 16 ou 32 pixels par bloc**, défaut 16 ;
- distance de **16, 32 ou 64 blocs**, défaut 32.
Ces préférences sont conservées dans `config/sanctuary-shaders.json`, avec les
anciens champs de couleur. Leurs valeurs numériques sont ramenées aux choix
admis au chargement. Les libellés et explications sont disponibles en FR/EN.
Les réglages s'appliquent sans recharger les ressources.
## Rendu et périmètre
Une carte de profondeur rend les formes d'occultation des blocs solides chargés
autour de la caméra, indépendamment de leur présence à l'écran. Les dalles et
escaliers conservent leurs formes. Le cache est invalidé par les changements de
blocs, les paquets de chunks et les reconstructions du moteur natif.
La direction solaire vient du ciel natif extrait par Minecraft, et respecte
ainsi l'angle solaire de Sanctuary. L'effet s'atténue sous la pluie, à l'horizon,
dans la brume et à la limite de sa portée. Il s'arrête la nuit, dans les dimensions
sans soleil, dans les fluides et lorsqu'un moteur externe suspend Vanilla Light.
La profondeur du monde est lue directement par le GPU après le rendu du monde,
avant la main et l'interface. L'inverse de la projection réelle inclut les effets
de caméra. Les points recevant l'ombre sont arrondis sur une grille ancrée dans
le monde ; le plan de la face est conservé pour limiter les auto-ombres.
La carte solaire utilise également une origine alignée sur ses texels.
Le HUD, la main, les étoiles et les captures panoramiques techniques gardent leur
chemin de rendu. Aucun niveau de lumière de gameplay, règle serveur, sauvegarde
ou générateur n'est changé. Aucune dépendance de rendu n'est ajoutée.
Cette première version couvre les **formes d'occultation des blocs** : les entités
conservent leurs ombres natives ; les feuillages ajourés, modèles non occultants,
block entities animés, eau et verre ne projettent pas de nouvelles silhouettes.
Les transparences et particules déjà composées peuvent recevoir l'assombrissement
du terrain derrière elles. Il n'y a pas d'ombres de lune ou de torches, ni de
séparation de l'éclairage local émissif dans cette passe d'assombrissement.
## Coût et cycle de vie
Les maillages sont mis en cache par section de 16³, avec au plus quatre sections
commencées par image et un budget souple de 3 ms pour leur construction. Les
ombres apparaissent progressivement pendant la préparation après activation ou
déplacement. Le volume des obstacles part de la portée choisie avec une marge de huit blocs,
puis s’étend vers le soleil sur deux fois cette portée, arrondi aux sections.
Les obstacles au-delà de ce volume ou dans des chunks non chargés ne sont pas
représentés. Les mises à jour de lumière seule ne reconstruisent pas les formes.
Le cache de sommets est plafonné à 96 Mio et omet les formes pathologiques qui
dépassent ses limites. Les textures de profondeur font entre 512 et 4 096 pixels
de côté selon finesse et portée. Le coût exact dépend de la scène et du GPU.
Les ressources sont libérées à la désactivation, au changement de monde, à la
reconstruction des ressources et à l'arrêt du client. Cette livraison est locale :
aucun avancement du canal packwiz ni installation personnelle ne fait partie du
ticket.
## Vérification
Validation native sur **Minecraft 26.3, Java 25, macOS / Apple M1, OpenGL 4.1**,
dans un nouveau monde plat de développement, graine **122** :
- Comparaison GPU activation/désactivation sur une plateforme blanche avec
pilier, dalle et escalier : **920 échantillons assombris**, 63 412 inchangés,
aucun éclairci sur les 64 332 échantillons de sol retenus.
- Pilier entièrement hors du frustum : **42 048 échantillons** restent dans
son ombre. Le retrait du pilier restitue la lumière sur **42 048 / 42 048**
échantillons et préserve les 23 616 échantillons déjà éclairés. Sa repose
restitue la même ombre, sans désactiver le cache entre ces manipulations.
- Les trois finesses et les trois portées sont appliquées en direct ; les cartes
GPU sont redimensionnées aux dimensions attendues. Désactiver l'option libère
la texture et ne soumet plus de passe d'ombres.
- À minuit, activer ou désactiver l'option donne la même image et aucune passe
solaire n'est soumise. Au retour du jour, le rendu reprend.
- Le rechargement des ressources restitue la même image. Les préférences
persistent à la relecture du fichier et leurs valeurs extrêmes sont bornées.
- Menus FR/EN inspectés en capture ; les libellés tiennent dans les contrôles
natifs à la taille de test. Les vues de finesse 8/16/32 sont conservées.
```sh
./gradlew :sanctuary:runClientGameTest \
-PsanctuaryClientTests=true -PsanctuaryPixelShadows122ClientTests=true \
-PsanctuaryClientNoVsync=true -PsanctuaryQuickTests=true
```
Succès en **1 min 17 s**, marqueur `PIXEL_SHADOWS122_CLIENT_PASS` dans
`build/pixel-shadows122-client.log`. Captures inspectées dans
`build/pixel-shadows122-evidence/final/`. Les pixels sont relus uniquement dans
le test ; le code de rendu de production n'effectue aucune lecture CPU de GPU.
Les premiers démarrages ont permis de corriger les identifiants de pipelines,
la directive GLSL `#include` attendue par 26.3 et deux libellés répétés du menu.
La commande de livraison est :
```sh
./gradlew check build assemblePack assembleTestPack -x :sanctuary:runGameTest
```
Le serveur GameTest dédié est exclu, conformément au refus de son EULA déjà
consigné dans les livraisons précédentes. Le test graphique utilise le serveur
intégré de son monde neuf. Aucun monde personnel n'est ouvert.
La construction finale **réussit en 2 min 18 s** (126 tâches), journal
`build/pixel-shadows122-build-final.log`. Le contrôle des archives confirme
**868 sources Java** et **1 869 classes** identiques aux sorties construites,
les ressources GLSL/FR/EN exactes dans le JAR et le template, et le même JAR
dans les profils packwiz normal et Test. Aucune fixture cliente n'est embarquée.
Reçu : `build/pixel-shadows122-artifact.json`.
Artefacts locaux : `mods/sanctuary/build/libs/sanctuary-beta.122.jar`,
`build/packwiz/`, `build/packwiz-test/` et `build/Sanctuary-Template-beta.122.zip`.
SHA-256 du JAR :
`66987681cede1d2adc1dda449078e8fa85310eb2c3fecd7a905d772ce75739c9`.
Pas de mesure de FPS, d'essai Windows/Vulkan, de pack Iris ni de silhouettes
animées revendiqués. Le shader reste un assombrissement graphique appliqué au
rendu opaque et déjà composé, avec les limites de transparence décrites plus haut.
+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/`.
+125
View File
@@ -0,0 +1,125 @@
# RP-02 — Clé dorée et pack personnel — beta.121
Contrat du 17 septembre 2026, branche `codex/resourcepack-template-beta121`.
Le créateur fournit `golden_wrench.png` et valide un bouton qui crée une copie
personnelle complète, modifiable et jamais écrasée par les mises à jour.
## Résultat
La clé dorée reprend exactement le PNG fourni, 16 × 16, sans conversion ni
redessin. Le modèle reste un item plat `minecraft:item/handheld`. Empreinte :
`a97570908db75caf8e6fc2f5ecabf12c54fd99bfea7be634df66c4310dca22af`.
Le même fichier est fourni par le mod et par « Textures Sanctuary » ; désactiver
le pack intégré conserve donc aussi la nouvelle clé.
Dans **Options → Packs de ressources**, **Personnaliser Sanctuary** crée
`resourcepacks/Sanctuary-Custom/`. Le bouton devient **Ouvrir ma copie**.
La copie contient toutes les ressources client de Sanctuary : textures, modèles,
étiquettes d'items, blockstates, sons, shaders, langues et remplacements Minecraft
présents dans le pack intégré. Celui-ci prend priorité sur les assets de base
lors de l'export. Un guide FR/EN, la licence et les crédits accompagnent la copie.
Le joueur active sa copie avec les commandes natives et la place au-dessus des
packs qu'il veut remplacer. Créer la copie ne change ni l'ordre ni la sélection.
Une modification se recharge avec F3 + T ; désactiver le pack retrouve les
ressources fournies par le mod. L'écran de sélection des datapacks n'est pas modifié.
La copie est un **instantané**. Elle ne se synchronise pas avec les mises à jour :
les fichiers personnalisés restent toujours intacts. Le guide recommande de
supprimer les fichiers non personnalisés pour laisser les prochaines ressources
du mod s'appliquer. Pour repartir d'un instantané récent, renommer le dossier
existant puis créer une nouvelle copie. Aucun fichier utilisateur n'est écrasé.
La copie s'effectue hors du fil de rendu, dans un dossier temporaire voisin,
puis le résultat complet rejoint la liste native. Une erreur de copie est
signalée par le bouton et le journal du jeu ; un chemin occupé par un fichier
n'est pas remplacé. Aucune extraction au démarrage ni pendant les mises à jour.
## Distribution
`./gradlew assembleResourceTemplate` produit
`build/Sanctuary-Template-beta.121.zip`, un pack personnel complet à décompresser
dans `resourcepacks/Sanctuary-Custom/`. `assemblePack` le produit aussi.
`build/Sanctuary-Resource-Pack-beta.121.zip` reste l'export du pack graphique
intégré ; son manifeste versionné contient 151 fichiers et les images historiques
sont conservées. L'identifiant intégré `sanctuary:textures` reste stable et son
activation reste contrôlée par le joueur.
Le template ne contient pas tous les assets vanilla ni ceux des autres mods.
Il ne contient pas les recettes, règles serveur ou sauvegardes. Il conserve
les crédits et conditions des ressources d'origine. Aucune génération, aucun
identifiant de bloc et aucun format de sauvegarde ne changent.
## Vérifications
Le parcours `ResourceTemplate121ClientChecks` passe en **35 s** sur le client
Minecraft 26.3 macOS, sans ouvrir de monde :
- Bouton natif et disposition FR/EN aux échelles 2 et 3, captures relues.
- Export complet de **1 202 fichiers**, empreintes identiques aux ressources
sources après application des priorités du pack intégré.
- PNG fourni vérifié par SHA-256 et affiché avec sa transparence.
- Création sans changer la sélection ni l'ordre des packs.
- Modification d'une texture et ajout d'un fichier personnel : aucun octet
réécrit lors d'un second export ni au rechargement des ressources.
- Priorité réelle de la texture personnalisée, puis retour à la clé fournie
après désactivation. Activation et désactivation conservées à la redécouverte.
- Chemin occupé par un fichier refusé sans le modifier ; aucun bouton ajouté
à l'écran des datapacks.
```sh
./gradlew :sanctuary:runClientGameTest \
-PsanctuaryClientTests=true -PsanctuaryResourceTemplate121ClientTests=true \
-PsanctuaryClientNoVsync=true -PsanctuaryQuickTests=true
```
Marqueur `TEMPLATE121_PASS` dans `build/template121-client.log` ; captures et
empreintes de la copie dans `build/template121-evidence/`. Le premier essai
avait échoué dans l'aperçu du test qui créait un ItemStack avant connexion à
un monde ; cet aperçu affiche maintenant directement la texture résolue.
Le modèle plat existant n'est pas modifié. Pas d'essai Windows ni d'ouverture
automatisée du gestionnaire de fichiers. Le GameTest serveur dédié reste exclu
conformément au refus antérieur de son EULA.
`./gradlew check build assemblePack assembleTestPack -x :sanctuary:runGameTest`
passe en **2 min 10 s**, 126 tâches. Les sources JAR correspondent aux sources
Java du dépôt. La comparaison avec beta.120 limite les différences de production
aux deux nouvelles classes, au hook d'interface, aux libellés et aux ressources
prévues (12 entrées). Aucun changement de gameplay, de données serveur ou de
monde. Les packs normal/Test contiennent le même JAR Sanctuary, sans fixture
ni sauvegarde. Le ZIP du template et l'export natif ont exactement les mêmes
1 202 fichiers et empreintes. Le pack intégré conserve ses 151 fichiers vérifiés.
Reçu : `build/template121-artifact.json`. Artefacts locaux validés :
- `Sanctuary-beta.121.mrpack` : 10261791 octets, SHA-256
`040d68f342155120fa21e0ffcdf34aa38e3225c78862960536622de53ed9fd9f`.
- `Sanctuary-Test-beta.121.mrpack` : 10280716 octets, SHA-256
`7f5bbd4e13aea4c245d072725c094ca64826ee8debc8607623783c114d437685`.
- `Sanctuary-Resource-Pack-beta.121.zip` : 3757952 octets, SHA-256
`bbf3c18ea6abc64d951911215975cb6c48c7fc5e0246f6e5f5192a3d061c211d`.
- `Sanctuary-Template-beta.121.zip` : 4315131 octets, SHA-256
`6fad376c53474fa8bcb0a2e6ae4c13ea61ab91bdd78735be443032d485ed541a`.
JAR Sanctuary : SHA-256
`4276f4fc9834805e3e6d12cd19adba7837e3007656b77f31d525c7b976f9925d`.
## Publication et installation
La [release beta.121](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.121)
est publiée depuis `d9b3dd6bedf32448f8c6e5bfa4da10cb69968bc7`. Tag et artefacts immuables,
téléchargements publics vérifiés. Canal packwiz :
`e3e6572da9288fbbcfa36bf6b7dd3aa0d1c54f5e`.
Deux synchronisations isolées puis deux dans **Sanctuary Beta** réussissent.
Un seul JAR Sanctuary beta.121 est actif ; le second passage est identique.
Les **923 fichiers personnels et réglages suivis** restent inchangés,
y compris les packs de ressources. Aucune copie personnelle n'est créée par
la mise à jour : chaque joueur utilise le bouton quand il le souhaite.
Aucun monde personnel ouvert. Copie préalable dans
`sanctuary-backups/before-beta.121/` de l'instance existante ; reçus
`build/template121-isolated.json` et `build/template121-prism.json`.
- [Template personnel complet](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.121/Sanctuary-Template-beta.121.zip).
- [Pack normal](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.121/Sanctuary-beta.121.mrpack).
- [Pack de test](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.121/Sanctuary-Test-beta.121.mrpack).
+93
View File
@@ -0,0 +1,93 @@
# SCULPT-10 — Une boîte par sculpture — beta.124
Contrat du 17 septembre 2026, branche `codex/sculpture-bounds-beta124`.
Le créateur choisit **une seule boîte pour la sélection et les collisions**.
Ce ticket remplace la forme physique détaillée de beta.122.
La boîte englobe exactement les dimensions occupées par la sculpture, après
orientation et ancrage au centre ou au bord. Elle suit toujours la grille 1/16,
y compris les tailles impaires et les modèles comportant des marges vides.
Les trous et petits détails restent visibles mais n'ont plus leurs propres
surfaces de collision : on ne grimpe plus sur leurs petites marches. Une arche
se sélectionne et bloque désormais sur tout son volume englobant.
La pose utilise la même boîte avant la création de l'entité de bloc : elle
refuse une intersection avec le joueur, y compris dans les trous visuels.
Les petites sculptures laissent libre le reste du bloc autour de leur boîte.
La forme reste mise en cache par modèle et état ; elle ne se recalcule qu'après
un changement de modèle, d'orientation ou d'ancrage. Les requêtes physiques et
le contour partagent cette même forme native.
Le calcul parcourt les cellules une seule fois, sans créer de copies tournées
ni de grille de collisions 16³. Le contour émet toujours douze arêtes.
Il s'agit d'une simplification du travail géométrique ; aucun gain de FPS global
n'est annoncé sans mesure sur une scène représentative.
Le rendu des sculptures et les règles de lumière de beta.122 restent inchangés :
pas d'occlusion de cube plein, pas d'émission artificielle de lumière. Aucun
identifiant, format d'item ou de sauvegarde ne change. Les anciennes sculptures
utilisent la nouvelle forme à partir de leurs données existantes, sans réécriture
des mondes ni régénération de chunks. L'atelier et les clés restent inchangés.
## Vérifications
Le parcours `Sculpture122ClientChecks`, adapté au nouveau contrat, passe en
**39 s** sur Minecraft 26.3, client macOS et serveur intégré jetable de graine 122.
- Sélection native de tout le volume, y compris le trou visuel d'une arche ;
contour rectangulaire relu dans la capture.
- Même boîte physique et de sélection sur client et serveur ; intérieur rempli,
dimensions exactes sans profondeur artificielle de bloc entier.
- **80 cas** : boîte unique comparée aux limites du maillage réellement émis,
douze arêtes exactement, même objet réutilisé lors des requêtes répétées.
Arche, marges vides, cube de 4 096 cellules et voxel unique, dans les quatre
orientations et cinq ancrages.
- **5 120 placements** de rendu toujours alignés sur la grille entière.
- Pose refusée dans le joueur, y compris entre deux piliers séparés ; petite
sculpture autorisée à côté du joueur, hors de sa boîte physique.
- Cache renouvelé après changement d'orientation/ancrage ou de modèle ;
conservation après sauvegarde/reconnexion.
- **75 sculptures superposées** : ciel à 15 ; toit de pierre témoin sombre ;
lumière de bloc transmise avec atténuation normale ; pièce close à 0.
Marqueurs dans `build/sculpture124-client.log`, notamment
`SCULPTURE124_BOUNDS_PASS 80`, plus les contrôles de régression de beta.122.
Captures relues dans `build/sculpture124-evidence/`. Aucun essai Windows,
client distant ou shader tiers ; le coût des boîtes est simplifié sans benchmark
FPS global.
`./gradlew check build assemblePack assembleTestPack -x :sanctuary:runGameTest`
passe en **2 min 9 s**, 126 tâches (110 exécutées, 16 à jour).
Les sources des JAR correspondent aux sources du dépôt. La comparaison avec
beta.123 limite les différences de production aux trois classes de
`SculptureGeometry` ; ressources, clés, interfaces, données et éclairage restent
identiques. Les packs normal/Test contiennent le même JAR, sans sauvegarde
ni fixture. Le template personnel conserve exactement les ressources de beta.123.
- Pack normal : 10 271 059 octets, SHA-256
`2075e4ac7a7e7e2effcd82586d46769607ca1a67e8d9110b000fa378d1bc5506`.
- Pack Test : 10 289 985 octets, SHA-256
`88263e6896590ea9694b18084666e7234e934064e8d7d596d34866a68cc5ea23`.
- JAR Sanctuary : SHA-256
`b3969b1a3738b8a5d332b2083db0dd722900239f73035c335b738f70328d43f1`.
Reçu local : `build/sculpture124-artifact.json`. Les packs normal/Test et le
template sont aussi copiés, avec vérification octet pour octet, dans le dossier
habituel `sanctuary-beta/build/`.
Le GameTest dédié reste exclu conformément au refus antérieur de son EULA.
Aucun monde personnel n'est ouvert par les essais.
## Publication et installation
La [release beta.124](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.124)
est publiée depuis `00179e30ef0b45ef98378bc164a7810552dd1bdd`.
Le tag exact et les artefacts sont immuables, leurs téléchargements publics
vérifiés. Canal packwiz : `67ebcc7d07f8b75e8988f6099e3d6e5704f123fb`.
Deux synchronisations isolées puis deux dans **Sanctuary Beta** réussissent.
Un seul JAR Sanctuary beta.124 est actif, conforme au hash vérifié ; le second
passage ne change rien. Les **923 fichiers personnels et réglages suivis**
restent inchangés, sans ouverture de monde personnel. Copie préalable dans
`sanctuary-backups/before-beta.124/` de l'instance existante.
Reçus : `build/sculpture124-isolated.json` et `build/sculpture124-prism.json`.
+84
View File
@@ -0,0 +1,84 @@
# STAT-07 — Pose centrée des sculptures — beta.119
Contrat du 17 septembre 2026, branche `codex/sculpture-center-beta119`.
Viser le carré central de 8 × 8 pixels de la face supérieure ou inférieure
du bloc place la sculpture au centre. Les limites de cette zone sont incluses.
Autour, le bord horizontal le plus proche reste choisi ; une pose latérale
continue à s'appuyer contre le bloc support. L'orientation suit le regard.
L'aide du Clay Workshop explique les deux possibilités en français et anglais.
Le centrage utilise l'emprise réelle des voxels après rotation, en ignorant les
marges transparentes. Tous les sommets restent sur la grille de 1/16 de bloc.
Une dimension impaire ne peut pas être parfaitement symétrique dans un bloc de
16 pixels sans décaler ses voxels : elle prend donc le centrage entier le plus
proche, avec un pixel de différence entre les deux marges. Aucune position à
un demi-voxel n'est introduite.
## Compatibilité
La pose réutilise `anchor=center`, déjà présent depuis beta.113. Aucun nouvel
état, identifiant, composant ou format de sauvegarde. Les sculptures existantes
gardent leur ancrage ; casser et reposer choisit l'emplacement visé. Le rendu,
les couleurs, les chapeaux et les collisions restent inchangés. Aucun parcours
de monde existant, aucune régénération. Client et serveur doivent utiliser
ensemble la nouvelle version pour le choix de pose.
## Vérifications et livraison
Le parcours natif `Statuary106ClientChecks`, avec `Sculpture113ClientChecks`,
passe en **54 s** sur Minecraft 26.3/macOS :
- Vrais clics de pose centrée dans quatre orientations, sur client et serveur,
puis pose contre les quatre bords sans modifier les couleurs du modèle.
- Limites incluses et points immédiatement de part et d'autre de la zone,
faces supérieure/inférieure et positions positives/négatives ; faces
latérales toujours calées contre le support.
- 5 120 combinaisons de largeur/profondeur, orientation et ancrage : géométrie
effectivement rendue sur la grille exacte, emprise sans dépassement,
centrage pair/impair et marges transparentes.
- Sauvegarde/reconnexion des sculptures centrées et aux bords, sans fichier
source ; rotations/miroirs, anciens états, fabrication et hotbar vérifiés.
```sh
./gradlew :sanctuary:runClientGameTest \
-PsanctuaryClientTests=true -PsanctuaryStatuary106ClientTests=true \
-PsanctuaryClientNoVsync=true -PsanctuaryQuickTests=true
```
Marqueurs `SCULPTURE119_PASS`, `STATUARY113_PASS` et
`SCULPTURE113_GEOMETRY_PASS` dans `build/sculpture119-client.log`.
Captures conservées dans `build/sculpture119-evidence/`, vue centrée relue.
Monde plat jetable de graine 106 ; aucun monde personnel ouvert. Pas de
validation Windows ni de test depuis deux ordinateurs.
Le GameTest dédié reste exclu conformément au refus antérieur de son EULA ;
le parcours natif utilise le serveur intégré.
`./gradlew check build assemblePack assembleTestPack -x :sanctuary:runGameTest`
passe en **2 min 24 s**, 125 tâches. Les sources JAR correspondent aux sources
du dépôt ; seuls `SculptureAnchor.class` et le texte d'aide FR/EN diffèrent
parmi les classes et ressources de production de beta.118. Les deux packs
contiennent le même JAR, sans monde ni fixture de test.
- Sanctuary-beta.119.mrpack : 10216447 octets, SHA-256
`ce29702eaaac7301c77fb6af63a76c6bc1c0c704313ce1b8243c95b5fe59d893`.
- Sanctuary-Test-beta.119.mrpack : 10235369 octets, SHA-256
`a1a2845f9678b1144d7d9eab4867970cc1776fe0ad5d51722c8110b7c1d4c682`.
- JAR Sanctuary : SHA-256
`d353bd89b16c53ee1ee48836fa2ff4b311249b280992300c28bf03048d805bff`.
Reçu local : `build/sculpture119-artifact.json`.
## Publication et installation
La [release beta.119](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.119)
est publiée depuis `3fd64e1f2a77ea2584b087d6339cf605d8ee0e9b`. Le tag exact et les
artefacts sont immuables ; leurs téléchargements publics sont vérifiés.
Le canal packwiz avance à `5e823af7807f524f6394acad402fe62328c7e4ab`.
Deux synchronisations isolées puis deux dans **Sanctuary Beta** réussissent.
Un seul JAR Sanctuary beta.119 est actif ; le second passage est identique.
Les **923 fichiers personnels et réglages suivis** restent inchangés, sans
ouvrir de monde personnel. Copie préalable dans
`sanctuary-backups/before-beta.119/` de l'instance existante. Reçus locaux :
`build/sculpture119-isolated.json` et `build/sculpture119-prism.json`.
+102
View File
@@ -0,0 +1,102 @@
# beta.116 — Sculptures à partir des textures de blocs
Ticket sur `codex/sculpture-materials-beta116`, comprenant aussi le correctif
Fourneau beta.115 dans la livraison commune.
Le Clay Workshop accepte les objets-blocs comme matière. Le client prélève une
palette bornée dans la texture du bloc issue du pack actif, avec sa teinte, puis
le modèle conserve sa géométrie et transpose ses valeurs claires/sombres dans
cette palette. L'aperçu et le résultat utilisent le même calcul. Un bloc est
consommé par sculpture ; les neuf cases de hotbar restent les seules accessibles.
## Contrat des données et du réseau
Les sculptures existantes conservent exactement leurs voxels sauvegardés :
schéma 1 inchangé, identifiants `sanctuary:*` stables. Les nouvelles sculptures
stockent elles aussi leurs couleurs finales dans ce format. Aucun chargement
ne recolore les objets ou constructions existants. Orientation et ancrage
beta.113 inchangés, sans migration ni modification d'un monde personnel.
La sélection du menu ajoute l'identité de la matière et une palette de 256
couleurs opaques au maximum. Ce sont des données d'apparence fournies par le
client, comme le modèle déjà importé ; elles n'octroient ni objets ni propriétés
de bloc. Le serveur vérifie le menu, son jeton, l'accès à l'atelier, les limites
et l'objet effectivement présent. Il applique lui-même la palette et facture
chaque résultat. Un changement de matière invalide immédiatement l'ancien
résultat jusqu'à réception de la sélection correspondante. Les deux côtés
doivent utiliser beta.116. Aucun nouveau format de sauvegarde.
## Vérifications natives
`Sculpture116ClientChecks` passe en 1 min 7 s, sur un nouveau monde plat de
Minecraft 26.3, graine 116, puis la régression complète de l'atelier :
- Les 1 207 objets-blocs enregistrés ont une palette opaque, non vide et bornée.
- Fabrication payée avec pierre, planches de chêne, or, laine rouge, verre,
feuilles de chêne, argile et ardoise ; aperçu identique au résultat serveur.
- Conservation des tons clairs/sombres du modèle et de ses coordonnées, sans
convertir les 16 argiles colorées en une simple couleur unie.
- Une matière différente efface aussitôt l'ancien résultat ; une sélection
périmée ou un jeton incorrect ne fabrique rien et ne consomme aucun objet.
- Les conteneurs contenant des objets sont refusés ; ils peuvent servir de
matière une fois vidés. Les objets qui ne sont pas des blocs sont refusés.
- Bornes et opacité des palettes, véritable rechargement des ressources,
neuf cases de hotbar, stock plein, consommation exacte et restitution.
- Poses selon les quatre regards, accroche au bord, grille de voxels,
casse/repose, sauvegarde et reconnexion après suppression du fichier GLB.
- Interface FR/EN aux échelles 2/3, génération de plan au Métabli inchangée.
Marqueurs `SCULPTURE116_PALETTES`, `SCULPTURE116_MATERIALS_PASS`,
`SCULPTURE116_PASS` et régressions 106/111/112/113 dans
`build/sculpture116-client.log`. Captures relues dans
`build/sculpture116-evidence/`. Un libellé est raccourci en « Bloc » pour éviter
le contact avec « Sculpture » dans le cadre compact.
La palette vient de la texture représentative du modèle de bloc du pack actif,
avec la teinte locale ; une texture animée utilise sa première image. Les blocs
sans texture lisible utilisent leur couleur de carte. Il s'agit d'un transfert
de couleurs sur les voxels opaques, sans transparence, émission ou fonction
spéciale du bloc d'origine. Les sculptures existantes gardent leurs couleurs.
Essais natifs macOS avec serveur intégré ; pas de client Windows ni de test
LAN à plusieurs joueurs. Le serveur dédié de GameTest reste exclu selon le
refus antérieur de son EULA. Aucun monde personnel ouvert.
La régression native du Fourneau passe également dans beta.116, en 42 s :
`build/sculpture116-furnace.log`, captures FR/EN dans
`build/sculpture116-furnace-evidence/`. Les 27 cases, le glisser-déposer, les
combustibles et les états de cuisson restent conformes au ticket beta.115.
## Construction et archives
`check build assemblePack assembleTestPack -x :sanctuary:runGameTest` réussit
en 2 min 23 s : 125 tâches, dont 105 exécutées. Sources Java concordantes avec
les JAR sources, versions alignées et intégrité des deux MRpack vérifiées.
Le JAR conserve à l'identique le correctif Fourneau beta.115 ; seuls les
classes de matière de sculpture et libellés FR/EN changent dans la production.
Les classes de test et modèles GLB de test sont absents des archives.
- `Sanctuary-beta.116.mrpack` : 10189831 octets, SHA-256
`7df04a6e2a57e76a55ca57bd7520b05cbb573392690b401b868837498807d252`.
- `Sanctuary-Test-beta.116.mrpack` : 10208754 octets, SHA-256
`f4c9293e20384b05366d092fc7ee1abea275940a02373ee4036f73f0caa43747`.
- JAR Sanctuary :
`76a9205783c8b18506c32c9eef6c481d95fd3ba0746a2bdd558a194534a51a32`.
Reçu local : `build/sculpture116-artifact.json`.
## Publication et instance
La [release beta.116](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.116)
est publiée avec le JAR, les deux MRpack et l'amorçage Prism. Le tag immuable
désigne `e3ca9fcd6383f7646e4fea7e5098e7097ebb30aa` ; téléchargements publics
et empreintes vérifiés. Le canal packwiz avance au commit
`c741870160f8a21f0a9793f20f0e624ebdbacdb6`.
Deux synchronisations isolées, puis deux dans l'unique instance **Sanctuary
Beta**, réussissent. La seconde passe laisse les fichiers gérés inchangés,
avec un seul JAR Sanctuary beta.116. Minecraft 26.3, Loader 0.19.5 et Fabric
API 0.160.5+26.3 conservés. Les 923 fichiers personnels et réglages suivis
restent identiques ; aucun monde personnel n'a été ouvert. Sauvegarde des
fichiers remplacés dans `sanctuary-backups/before-beta.116/` de cette instance.
Reçus : `build/sculpture116-isolated.json`, `build/sculpture116-prism.json`.
+108
View File
@@ -0,0 +1,108 @@
# SCULPT-11 — Placement, éclats et modèles découverts — beta.125
Contrat du 17 septembre 2026, branche `codex/sculpture-placement-beta125`.
Le point visé choisit neuf positions horizontales (centre, quatre bords et quatre
coins) et trois hauteurs (bas, milieu et haut). Sur un support, la sculpture
repose au bas de son bloc ; sous un plafond, elle touche le haut ; sur une face
latérale, elle suit la hauteur visée et reste contre le support. Les sculptures
restent debout et s'orientent selon le regard horizontal du joueur.
La bande centrale occupe les huit pixels médians, limites incluses. Toute pose
reste sur la grille 1/16 : une dimension impaire utilise le centre entier le
plus proche, jamais un demi-voxel. Rendu, boîte unique de sélection et collision
partagent les mêmes offsets. La lumière conserve les règles de beta.122.
## Compatibilité additive des états existants
Les cinq anciennes valeurs `anchor` gardent exactement leurs noms. Quatre
valeurs de coin sont ajoutées. La nouvelle propriété `vertical_anchor` accepte
`bottom`, `center`, `top` et `original`, valeur par défaut réservée à la
compatibilité. La lecture d'un ancien bloc sans cette propriété conserve
l'ancienne hauteur, même si le modèle a des marges transparentes en bas.
Le bloc possède 144 états : 108 placements nouveaux et 36 états de compatibilité.
Le schéma 1 des sculptures, leurs données de voxels et tous leurs identifiants
restent inchangés. Aucun monde n'est parcouru ou réécrit, aucun chunk régénéré.
Les nouveaux ancrages nécessitent beta.125 ; le retour à une version antérieure
ne garantit pas leur position. Ce contrat autorise l'ajout compatible des états,
pas une migration destructive des sauvegardes personnelles.
## Particules
Les coups et la casse émettent des éclats de la couleur des surfaces voxelisées
réellement affichées, avec leur orientation et leur ancrage. Un coup vise la
surface réelle la plus proche du point sélectionné, même devant un trou visuel.
La casse échantillonne au plus 64 faces ; aucun calcul de texture n'est refait.
Les éclats utilisent une taille de voxel, la gravité et la lumière natives.
Une petite mémoire par monde (128 entrées de 64 faces, validité 40 ticks) conserve les
surfaces si la suppression du bloc précède l'événement de casse. Les autres
blocs gardent leurs particules habituelles.
## Clay Workshop
L'onglet Animaux utilise exactement les découvertes serveur du catalogue de
statues (rencontre, victoire ou mort face à l'espèce). Le menu transmet cette
liste à chaque ouverture, sans dépendre d'une visite préalable au Métabli.
Le modèle natif et sa texture sont capturés localement, voxelisés en miniature,
puis recolorés par la matière choisie. Le résultat reste fabriqué et payé côté
serveur. Les sélections d'espèces non découvertes sont refusées. L'onglet Imports
conserve les fichiers GLB. Aucun nouveau format de sculpture n'est introduit.
Libellés FR et EN.
## Vérifications
Le parcours natif `Sculpture125ClientChecks` passe en **1 min 2 s**, client
Minecraft 26.3 sur macOS et serveur intégré jetable (graines 125 et 122).
- 54 clics réels sur les six faces : centres, bords, coins et hauteurs.
- 432 contextes de pose, coordonnées positives/négatives, quatre orientations,
rotations et miroirs ; 144 états valides.
- 20 anciens états sans `vertical_anchor` conservent leur hauteur et marges.
- Couleur exacte des éclats au point frappé, surfaces réellement occupées,
limite de 64 fragments, événement après suppression et particules normales
d'un bloc de pierre témoin.
- Atelier : découvertes par rencontre, victoire et mort, transmission native
du menu, refus serveur d'une espèce inconnue, trois modèles natifs (vache,
mouton, cochon), paiement d'un bloc en survie et import GLB conservé.
- Sauvegarde et reconnexion avec les nouveaux coins et hauteurs.
- 576 formes comparées aux limites du maillage affiché, boîte unique partagée,
cache réutilisé et 12 arêtes ; 9 216 empreintes orientées sur grille entière.
- 75 sculptures superposées : ciel à 15, lumière de bloc à 13 après traversée,
pièce noire à 0. Pose dans le joueur refusée, même devant un trou visuel.
Marqueurs dans `build/sculpture125-client.log`. Captures de plafond, murs et
atelier relues dans `build/sculpture125-evidence/`. Aucun essai Windows,
client distant ou shader tiers ; trois espèces vérifiées visuellement, sans
prétendre avoir inspecté chaque espèce. Aucun benchmark FPS global.
Le GameTest dédié demeure exclu conformément au refus antérieur de son EULA.
Les mondes de test sont jetables ; aucun monde personnel n'est ouvert.
`./gradlew check build assemblePack assembleTestPack -x :sanctuary:runGameTest`
passe en **2 min 16 s**, 126 tâches (110 exécutées, 16 à jour). Les sources
des JAR correspondent exactement au dépôt. La comparaison avec beta.124
limite les différences aux classes de placement, particules et atelier prévues,
aux nouveaux libellés FR/EN, au mixin client et à la source blanche de particules.
Les packs normal/Test partagent le même JAR, sans monde ni fixture ; le template
reproduit les ressources du mod et du pack intégré.
- Sanctuary-beta.125.mrpack : 10286360 octets, SHA-256 `9277e59c640c40392ab3750b402bb840020884878f6ece6978e6c4653e18655e`.
- Sanctuary-Test-beta.125.mrpack : 10305282 octets, SHA-256 `9e3dbfa14b5382ba4eb62a78fa7d2161758c2f0e1fdc50fbd5ed776fd4ce32c5`.
- JAR Sanctuary : SHA-256 `b38b75b900dd8cbe015318c739d5547fd7eac52f4337fa517e98f3f6f0ce3b46`.
Reçu local : `build/sculpture125-artifact.json`. Les deux packs et le template
sont copiés et vérifiés dans le dossier habituel `sanctuary-beta/build/`.
## Publication et installation
La [release beta.125](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.125)
est publiée depuis `4ec5b0d028a23a9f753770988ffba61015e2e49a`.
Tag et artefacts immuables, téléchargements publics vérifiés. Canal packwiz :
`47eb833911a96eef0b13805dafe65b2ab7b95c87`.
Deux synchronisations isolées puis deux dans l'instance **Sanctuary Beta**
réussissent. Un seul JAR Sanctuary beta.125 est actif, conforme au hash vérifié ;
le second passage ne change rien. Les **923 fichiers personnels et réglages
suivis** restent inchangés, sans ouverture de monde personnel. Copie préalable
sous `sanctuary-backups/before-beta.125/`. Reçus :
`build/sculpture125-isolated.json` et `build/sculpture125-prism.json`.
+108
View File
@@ -0,0 +1,108 @@
# SCULPT-09 — Forme physique et lumière — beta.122
Contrat du 17 septembre 2026, branche `codex/sculpture-shape-light-beta122`.
Le créateur demande des bounding boxes adaptées aux sculptures et la suppression
des ombres cubiques visibles lorsqu'on en pose plusieurs côte à côte.
La sélection, le contact avec les entités et les collisions suivent les voxels
occupés de chaque sculpture, y compris les creux. Le rendu et la forme physique
partagent leur placement entier sur la grille 1/16, après orientation et ancrage.
Le centre, les quatre bords et les dimensions impaires gardent leur alignement.
La pose vérifie aussi les voxels portés avant que lentité de bloc existe :
elle refuse une intersection avec le joueur et autorise les espaces libres.
La forme est construite directement sur une grille native 16³ et mise en cache
par entité de bloc, modèle et état ; aucun calcul complet à chaque interrogation.
Les sculptures transmettent la lumière et ne sont plus utilisées comme cubes
pleins par l'occlusion ambiante. Leurs faces restent éclairées par le monde,
sans émission de lumière et sans rendu à luminosité maximale. Cette règle est
commune aux sculptures : l'éclairage natif stocké par bloc ne permet pas une
ombre distincte pour chaque petit voxel. L'atelier d'argile reste inchangé.
Aucun identifiant, blockstate, format d'item ou schéma sauvegardé ne change.
Les formes sont recalculées à partir des voxels existants ; aucune migration,
aucun parcours de monde ni régénération de chunk. Les données inconnues gardent
leur conservation opaque et une petite base sélectionnable pour récupérer l'item.
Le travail de documentation déjà présent dans l'autre branche reste intact.
## Vérifications et livraison
Le parcours `Sculpture122ClientChecks` passe en **37 s** sur Minecraft 26.3,
client macOS et serveur intégré, dans un monde plat jetable de graine 122 :
- Pose par la commande native d'utilisation ; sélection du pilier et visée
à travers le trou d'une arche jusqu'au bloc derrière, captures relues.
- Requêtes natives de collision côté serveur et client : voxels occupés solides,
espace vide libre, aucune profondeur artificielle d'un bloc entier.
- Pose refusée dans le joueur, sans consommer l'objet ; petite sculpture acceptée
dans la partie libre du même bloc, sans intersection avec le joueur.
- **80 cas** confrontent le volume et les faces de collision au maillage réellement
émis : arche creuse, marges vides, cube de 4 096 voxels, voxel unique, quatre
orientations et cinq ancrages. Chaque interrogation répétée réutilise la forme.
- Régression de **5 120 placements** du rendu existant : dimensions paires/impaires,
orientations et ancrages, plus marqueurs de direction et modèles avec marges.
- Changement d'orientation/ancrage puis changement des voxels sur la même entité :
cache recalculé et forme client synchronisée.
- **75 sculptures en trois couches** : skylight natif conservé à 15 partout.
Un toit de pierre témoin produit toujours une ombre. La lumière d'un bloc
traverse une sculpture avec l'atténuation de distance native (13 à deux blocs).
- Le rendu reçoit la lumière réelle du monde : 15 au ciel, **0 dans une pièce
fermée et obscure**. Aucun rendu artificiellement lumineux.
- Sauvegarde/reconnexion : modèle, collisions et lumière conservés sur les
deux côtés, sans fichier 3D source.
```sh
./gradlew :sanctuary:runClientGameTest \
-PsanctuaryClientTests=true -PsanctuarySculpture122ClientTests=true \
-PsanctuaryClientNoVsync=true -PsanctuaryQuickTests=true
```
Marqueurs `SCULPTURE122_PASS`, `SCULPTURE122_GEOMETRY_PASS`,
`SCULPTURE122_PLACEMENT_PASS`, `SCULPTURE122_LIGHT_PASS` et
`SCULPTURE113_GEOMETRY_PASS` dans `build/sculpture122-client.log` ; captures
conservées dans `build/sculpture122-evidence/`. Le test prépare explicitement
son sol à Y=0 : le preset plat nu commence à Y=-1.
Aucun monde personnel ouvert ; pas d'essai Windows ni depuis deux ordinateurs,
ni de garantie sur les ombres ajoutées par des shaders tiers.
Le GameTest serveur dédié reste exclu conformément au refus antérieur de son EULA.
`./gradlew check build assemblePack assembleTestPack -x :sanctuary:runGameTest`
passe en **2 min 13 s**, 126 tâches. Les sources JAR correspondent aux sources
Java du dépôt. Par rapport à beta.121, les différences de production se limitent
aux classes des sculptures et à la nouvelle géométrie partagée (14 entrées,
y compris le déplacement du sélecteur synthétique d'orientation). Toutes les
ressources client, données serveur et configurations de mixins sont identiques.
Les packs normal/Test contiennent le même JAR, sans sauvegarde ni fixture.
Le template beta.122 contient les mêmes ressources que beta.121, octet pour octet.
Reçu local : `build/sculpture122-artifact.json`.
- `Sanctuary-beta.122.mrpack` : 10267459 octets, SHA-256
`b6e24d65797eb7f16670d2f3a591b5f2476820608f72241ecd37fb78a486008e`.
- `Sanctuary-Test-beta.122.mrpack` : 10286380 octets, SHA-256
`90487ee3ad4298fccb8c24e8781af4cbf043d88dc0379fba5c21d99733dac92d`.
JAR Sanctuary : SHA-256
`ff8ebf89f39a64809fae6912253386793e9b5e66ec0adb9fa7521729893d406e`.
## Publication et installation
La [release beta.122](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.122)
est publiée depuis `edc7fc3dfb117cde61be42c1d79b03af11f5dec6`. Tag et artefacts immuables,
téléchargements publics vérifiés. Canal packwiz :
`3d340bea0093b709c6018624795a1b3ca557b72d`.
Deux synchronisations isolées puis deux dans **Sanctuary Beta** réussissent.
Un seul JAR Sanctuary beta.122 est actif ; le second passage est identique.
Les **923 fichiers personnels et réglages suivis** restent inchangés.
Aucun monde personnel ouvert. Copie préalable dans
`sanctuary-backups/before-beta.122/` de l'instance existante ; reçus
`build/sculpture122-isolated.json` et `build/sculpture122-prism.json`.
Le ticket a été réalisé dans le worktree `sanctuary-sculpture-beta122` afin de
préserver les changements de `docs/vision.md` et `docs/villageois-illageois.md`
déjà présents sur la branche de documentation du checkout principal.
- [Pack normal](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.122/Sanctuary-beta.122.mrpack).
- [Pack de test](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.122/Sanctuary-Test-beta.122.mrpack).
- [Template personnel](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.122/Sanctuary-Template-beta.122.zip).
+110
View File
@@ -0,0 +1,110 @@
# beta.117 — Grain des textures et sculptures en chapeau
Ticket sur `codex/sculpture-texture-grain-beta117`, Minecraft 26.3.
En beta.116, une couleur du modèle source était associée à une seule nuance
de matière. Un modèle uni restait donc uni malgré les pixels différents de la
texture. La sélection utilise maintenant les textures des faces du modèle de
bloc, avec leurs propres teintes du pack actif. Un échantillonnage de pixels
répartit leurs couleurs exactes dans les voxels, sans couleur interpolée.
## Coût et comportement
Au maximum 32 matériaux de faces et 16 × 16 échantillons par matériau sont lus,
puis ramenés à la palette existante de 256 entrées. Les répétitions conservent
la fréquence des nuances. Les pixels transparents sont écartés ; les textures
animées utilisent la première image. Les blocs sans texture lisible gardent
le repli antérieur sur la texture représentative, puis la couleur de carte.
La sélection d'une nuance utilise un mélange entier déterministe des coordonnées
de chaque voxel. Un modèle uni utilise toute la palette ; un modèle ombré
utilise des plages de nuances qui suivent ses zones claires et sombres.
Ce grain est un transfert de palette, pas une projection UV conservant les
motifs ou les veinures du bloc. Les voxels restent opaques.
Le calcul utilise Java standard et les modèles/textures déjà chargés par
Minecraft, sans bibliothèque supplémentaire. Aucun calcul de matière dans
le rendu par image. Le client ne rééchantillonne qu'au changement de matière
ou de ressources ; une rotation réutilise la palette. Le serveur conserve
le modèle recoloré pour les fabrications suivantes avec la même sélection.
## Chapeaux et données
Le système de blocs portés retirait `BLOCK_ENTITY_DATA` des objets pour isoler
les inventaires des coffres et fourneaux. Sur une sculpture, ce composant est
la géométrie elle-même : le slot contenait encore un objet, devenu invisible.
Les sculptures conservent désormais ce composant à l'équipement et au retrait.
Si une ancienne sculpture équipée a perdu ce composant, mais que sa session
de chapeau possède encore le modèle, il est restauré depuis cette donnée
serveur. La restauration ne fabrique aucune géométrie manquante. Les objets
déjà retirés et dépourvus de toute copie du modèle ne sont pas reconstructibles
par ce correctif. Les règles de contenu des coffres restent inchangées.
Identifiants, schéma 1 des sculptures, structure des accessoires et paquet
de sélection inchangés. Les couleurs déjà sauvegardées restent intactes.
Les deux côtés utilisent beta.117 pour le même calcul de prévisualisation et
de fabrication. Aucun monde personnel ouvert ni migration de sauvegarde.
## Vérifications
Le scénario client natif complet passe en 2 min 1 s sur macOS avec serveur
intégré, monde plat jetable de graine 116. Il reprend le scénario beta.116
avec un GLB entièrement uni, plus les régressions de l'atelier 106/111/112/113 :
- 1 207 objets-blocs échantillonnés, huit fabrications payées avec aperçu
identique au résultat serveur, consommation exacte d'un bloc.
- Répartition 25 % / 75 % de deux couleurs de texture sur un modèle uni,
aucune couleur inventée, conservation des zones claires et sombres.
- Couleurs réellement présentes dans les textures natives des faces, avec
plusieurs matériaux d'une bûche ; grain présent sur pierre, bois et minerai.
- Motif indépendant de l'ordre des voxels, stable et sauvegardé ; rechargement
des ressources, palette bornée, inventaire plein et sélections périmées.
- Vrais clics des slots hotbar/chapeau, conservation exacte des composants
et du nom personnalisé, icône et modèle porté visibles, retrait/rééquipement.
- Restauration d'un objet privé de sa géométrie depuis une ancienne session
équipée, puis sauvegarde et reconnexion du client avec le chapeau intact.
- Pose, bord visé, orientation, casse/repose, reconnexion sans fichier source,
menus FR/EN et génération du plan du Métabli inchangés.
Mesure indicative après échauffement, modèle maximal de 4 096 voxels,
101 applications sur le Mac de développement : médiane **0,283 ms**, p95
**0,971 ms**. Ce n'est pas une garantie de temps sur d'autres machines ; le
calcul n'est exécuté ni chaque image ni à chaque objet fabriqué identique.
Marqueurs `SCULPTURE117_GRAIN_PASS`, `SCULPTURE117_HAT_PASS`,
`SCULPTURE117_HAT_RECONNECT_PASS`, `SCULPTURE116_PASS` dans
`build/sculpture117-final-client.log`. Captures relues dans
`build/sculpture117-evidence/`. Pas d'essai Windows ou LAN. Le GameTest dédié
reste exclu selon le refus antérieur de son EULA.
`check build assemblePack assembleTestPack -x :sanctuary:runGameTest` réussit
en **3 min 7 s**, 125 tâches dont 105 exécutées. JAR sources concordants,
versions, contenu et intégrité des deux archives vérifiés. La comparaison
avec beta.116 limite les changements de production au calcul des matières,
à son cache dans le menu et au traitement des sculptures en chapeau.
Les modèles de test et classes de GameTest sont absents de la livraison.
- `Sanctuary-beta.117.mrpack` : 10191137 octets, SHA-256
`adbe5a40bbd050ebc84ff8aca61a7a3643dcf25ec8f2ac59b91561e48275cbbb`.
- `Sanctuary-Test-beta.117.mrpack` : 10210059 octets, SHA-256
`0dacb12b30bde66c647871101a4f4c6b0b58e7ceb29f7743398f8a569b958a6e`.
- JAR Sanctuary :
`1ce63f97f17c6098f3c86c6753f9b912bce39d88e05ba240423c38bdc97307ee`.
Reçu : `build/sculpture117-artifact.json`.
## Publication et instance
La [release beta.117](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.117)
est publiée : JAR, MRpack normal/Test et amorçage Prism, téléchargements publics
et empreintes vérifiés. Le tag immuable désigne
`0f1efdec830342e25d3316b356330ad937d0d942`. Le canal packwiz avance au commit
`a5b75a70f86e42afb8e06ba4daa9880d61c6373e`.
Deux synchronisations isolées puis deux dans l'unique instance **Sanctuary
Beta** réussissent, la seconde passe laissant les fichiers gérés identiques.
Un seul JAR Sanctuary beta.117 est actif. Les 923 fichiers personnels et
réglages suivis sont inchangés ; aucun monde personnel n'a été ouvert.
Copie préalable des fichiers remplacés dans `sanctuary-backups/before-beta.117/`.
Reçus : `build/sculpture117-isolated.json`, `build/sculpture117-prism.json`.
+88
View File
@@ -0,0 +1,88 @@
# SHADER-127 — intégration des ombres portées pixélisées
Demande du 17 septembre 2026 : livrer le shader dans **beta.127**.
Branche `codex/shader-beta127`, socle publié beta.126, Minecraft 26.3 / Java 25.
La préparation locale [des ombres pixélisées](pixel-shadows-source122.md) est
intégrée sans écraser les changements non publiés du dossier d'origine.
Les gemmes, dernières textures de rubis/saphir, l'argile spéciale et les
commandes de découverte de beta.126 sont conservées.
## Utilisation
**Options → Shaders → Sanctuary · Vanilla Light → Ombres portées pixélisées**.
L'option est désactivée par défaut ; son activation reste un choix du joueur.
Finesse : 8/16/32 pixels par bloc ; portée : 16/32/64 blocs. Défauts : 16 et 32.
Les paramètres FR/EN s'appliquent immédiatement et persistent dans
`config/sanctuary-shaders.json`. La mise à jour préserve les réglages personnels.
Le rendu est natif au mod : aucun pack Iris, téléchargement supplémentaire ou
nouvelle dépendance de rendu.
## Périmètre
Ombres solaires des formes d'occultation des blocs chargés, incluant dalles,
escaliers et obstacles hors écran. Carte de profondeur GPU, grille ancrée dans
le monde, atténuation selon soleil, pluie et brume. L'effet s'arrête la nuit,
dans les dimensions sans soleil, dans les fluides ou avec un moteur externe.
Le HUD et la main ne passent pas dans cet effet. La lumière de gameplay,
les règles serveur, les sauvegardes et la génération ne sont pas modifiées.
La préparation limite le cache à 96 Mio et sa construction à quatre sections
par image dans un budget souple de 3 ms. L'intégration supprime seulement une
invalidation de bord exécutée deux fois à l'identique dans le cache de terrain.
Les formes non occultantes, feuillages ajourés, eau/verre et entités animées ne
projettent pas de nouvelles silhouettes. Les transparences déjà composées
peuvent recevoir l'assombrissement du terrain situé derrière elles. Pas
d'ombres de torches/lune ni de mesure de FPS revendiquée.
## Vérifications et livraison
Client natif Minecraft 26.3 / Java 25 sur macOS, monde neuf plat, graine 122 :
`PixelShadows122ClientChecks` réussit sur la beta.127 en **1 min 26 s**.
Le nom de cette fixture historique est conservé pour suivre la même scène.
- Activation : 920 échantillons de sol assombris, 63 412 inchangés, aucun éclairci.
- Pilier hors écran : 42 048 échantillons restent ombrés ; le retrait du pilier
restitue la lumière sur les 42 048 et conserve les 23 616 déjà éclairés.
La repose rétablit la même ombre sans désactiver le cache.
- Trois finesses et trois portées, redimensionnement GPU, désactivation/libération,
arrêt nocturne, reprise diurne et rechargement des ressources vérifiés.
- Préférences persistantes et bornées ; captures FR/EN inspectées, sans texte
tronqué. La scène pilier/dalle/escalier est également inspectée visuellement.
Logs : `build/shader127-client.log`. Captures :
`mods/sanctuary/build/run/clientGameTest/screenshots/`.
Le serveur GameTest dédié reste exclu conformément au refus antérieur de son
EULA. Aucun monde personnel n'est ouvert. Aucun essai Windows/Vulkan ou FPS
n'est revendiqué ; la validation graphique est celle du client natif local.
Build `./gradlew check build assemblePack assembleTestPack
-x :sanctuary:runGameTest` réussi en **2 min 25 s**, 126 tâches. Les sources
Java dans les archives correspondent aux sources du ticket. La comparaison
avec le JAR beta.126 ne trouve que **21 entrées de production modifiées**,
toutes liées au shader, à son initialisation, ses mixins et ses libellés.
Les règles de jeu, données, textures de gemmes, sculptures et découvertes
sont identiques. Normal et Test contiennent le même JAR ; le template exporte
exactement les ressources du mod. Aucune fixture de test ni monde embarqué.
| Artefact | SHA-256 |
| --- | --- |
| `Sanctuary-beta.127.mrpack` | `23fa6ca0c5d1f1033e13e5370d669491b398d5a99e13f8bd1e983f998a24a20a` |
| `Sanctuary-Test-beta.127.mrpack` | `f185f3a743ba23cd6c024d1c1e46587706bf4f3edb5e14d7e5dee522a1ebf2c2` |
| `sanctuary-beta.127.jar` | `3ef3fab67f4f703864a1acc9ae2395c636d206f3128d3399140eaf045a46d5f3` |
## Publication et synchronisation
[Release beta.127](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.127)
publiée depuis `00be1eb0d0bfe042442d90255385033a7765092f` ; artefacts immuables retéléchargés
et vérifiés. Canal packwiz : `7fd24a43e01ee497a23311e3c78e1dd3dddd877a`.
Deux synchronisations isolées puis deux dans l'instance existante
**Sanctuary Beta** ont réussi. Les **923 fichiers personnels** suivis,
y compris les préférences de shaders, sont conservés. Aucun monde personnel
ouvert ; copie préalable dans `sanctuary-backups/before-beta.127/`.
Les packs normal/Test et le template sont aussi copiés et vérifiés dans
`sanctuary-beta/build/`. Reçus locaux : `build/shader127-artifact.json`,
`build/shader127-isolated.json`, `build/shader127-prism.json` et journal de
publication `build/shader127-publication.log`.
+106
View File
@@ -0,0 +1,106 @@
# SHADER-128 — stabilité, lune et feuillage
Demandes du 17 septembre 2026 : réduire le jitter des bords et à midi, conserver
des ombres de blocs nettes et un feuillage nuancé, ajouter une ombre lunaire
fine et une transmission
irrégulière du feuillage selon son épaisseur. Branche `codex/moon-foliage-beta128`,
socle publié beta.127, Minecraft 26.3 / Java 25.
## Résultat
Les faces de voxels reçoivent l'ombre sur une grille fixe ; leur plan est
stabilisé au 1/16 de bloc et les faces inclinées conservent leur normale. La
carte de profondeur utilise un ancrage par 16 blocs avec alignement de ses
texels. **Les ombres des blocs restent nettes** : le créateur a écarté leur
adoucissement après l'essai visuel. Le filtrage est réservé au feuillage.
Le soleil continue de se déplacer : ce mouvement naturel peut toujours faire
avancer une ombre d'une case. L'objectif est de supprimer les oscillations
liées à la caméra, pas de figer le temps.
Entre une élévation solaire de 0,92 et le zénith, l'intensité diminue doucement
jusqu'à 25 % de sa valeur habituelle : alpha maximal 0,095 au lieu de 0,38.
La lune réutilise la même carte et son angle natif ; alpha maximal 0,10 selon
sa phase, nul à la nouvelle lune. Horizon, pluie, portée et brume atténuent
les deux lumières. Pas de seconde passe de carte lunaire simultanée.
Les blocs du tag vanilla `leaves` projettent une couverture probabiliste fixe
sur une grille de quart de bloc. Les faces internes restent présentes : plus
le rayon traverse de couches, moins il a de chances de passer. Il s'agit d'une
approximation statistique de l'épaisseur, pas d'un comptage des feuilles ni
d'une lecture des trous de leur texture. Quatre échantillons bilinéaires sur
un quart de bloc mélangent cette couverture
en nuances, évaluées sur la grille pixélisée. Un masque R8 distingue feuilles
et blocs dans la même passe de profondeur, pour garder les silhouettes opaques
nettes. Les comparaisons suivent le plan récepteur pour éviter l'auto-ombrage
sur les surfaces obliques. Le motif ne dépend ni du temps ni de la caméra et
ne demande aucune lecture GPU côté CPU en production.
Les budgets du cache restent 96 Mio, quatre sections et 3 ms souples par image.
Le feuillage ajoute des faces ; son filtrage utilise seize comparaisons de
profondeur et, selon le cas, autant de lectures du masque. Les ombres opaques
pleines utilisent le chemin court. Le masque prend 4 Mio à la définition
par défaut, 16 Mio au maximum ; aucune promesse de FPS n'est faite.
Réglages existants et libellés FR/EN conservés/actualisés. Les ombres restent
optionnelles et désactivées par défaut. Règles, éclairage de gameplay, mondes
et génération inchangés. Les limitations de beta.127 concernant les entités,
fluides, verre et autres formes non occultantes subsistent.
## Vérifications
`PixelShadows122ClientChecks`, fixture historique étendue, réussit en **1 min
44 s** dans un monde neuf plat, graine 122, client natif macOS/OpenGL.
- Soleil : 920 échantillons de sol ombrés et 63 412 inchangés ; retrait/repose
d'un pilier hors écran restitue puis rétablit son ombre.
- Lune : ombre localisée mesurée ; arrêt des passes à la nouvelle lune.
- Midi : ombre atténuée et **zéro échantillon de transition adoucie sur 65 664**
dans la scène de contrôle du contour opaque.
- Caméra : petits déplacements autour de l'ancien ancrage entier et du nouvel
ancrage par 16 blocs, retour au point initial, petites rotations en vue
oblique, instants avant/après midi et passage de 5999 à 6001 ticks.
Comparaisons d'images avec tolérance de trois niveaux par canal et moins
de 1 % d'échantillons changés pour ces petits mouvements.
- Vue oblique : 19 637 échantillons ombrés, 46 027 inchangés sur le sol blanc.
- Feuillage : assombrissement moyen mesuré d'environ **18,6 %** pour une couche,
**31,7 %** pour quatre, contre **36,8 %** sous une couverture solide.
Motif fixe dans le temps ; modification des couches invalide le cache actif.
- Trois finesses/trois portées, désactivation/libération GPU, préférences
persistantes, rechargement des ressources, textes FR/EN et captures inspectés.
Commande native : `./gradlew :sanctuary:runClientGameTest
-PsanctuaryClientTests=true -PsanctuaryPixelShadows122ClientTests=true
-PsanctuaryClientNoVsync=true -PsanctuaryQuickTests=true` sous Java 25.
Logs : `build/shader128-client.log` ; captures sous
`mods/sanctuary/build/run/clientGameTest/screenshots/`.
`./gradlew check build assemblePack assembleTestPack
-x :sanctuary:runGameTest` réussit en **2 min 24 s**, 126 tâches.
Les sources archivées correspondent au ticket. **13 entrées de production**
diffèrent de beta.127, limitées aux classes du shader, GLSL et libellés FR/EN.
Gemmes, sculptures, découvertes, données et règles de jeu restent identiques.
Normal et Test embarquent le même JAR ; le template correspond exactement aux
ressources exportables. Aucun monde ni fixture de test embarqué.
Le serveur GameTest dédié reste exclu conformément au refus antérieur de son
EULA. Aucun monde personnel utilisé. Aucun essai Windows/Vulkan revendiqué.
## Artefacts vérifiés
| Artefact | SHA-256 |
| --- | --- |
| `Sanctuary-beta.128.mrpack` | `84125b18366afde52c8cba4bc7007257b1c3d8b79d413338abe64d0e0ae8feaf` |
| `Sanctuary-Test-beta.128.mrpack` | `6bfb10d8d63875bfb84e813d4f0a9faf5db30902cc0400deda0993c08b86e597` |
| `sanctuary-beta.128.jar` | `c54cbae7efaa7059c8fd4d28801087983379958fe34eedff51682d106d649c6a` |
## Publication et synchronisation
[Release beta.128](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.128)
publiée depuis `a4e96c0d99c976231d7ca92b1db6bdb6aa7083c6` ; les artefacts immuables ont été
retéléchargés et vérifiés. Canal packwiz : `eba01726ee132a6e548d129fa7ee04f24c880917`.
Deux synchronisations isolées puis deux dans l'instance existante
**Sanctuary Beta** ont réussi. Les **923 fichiers personnels** suivis,
y compris les réglages de shaders, sont conservés. Aucun monde personnel
ouvert ; copie préalable dans `sanctuary-backups/before-beta.128/`.
Packs normal/Test et template copiés et vérifiés dans `sanctuary-beta/build/`.
Reçus locaux : `build/shader128-artifact.json`, `build/shader128-isolated.json`,
`build/shader128-prism.json` ; journal : `build/shader128-publication.log`.
+119
View File
@@ -0,0 +1,119 @@
# SHADER-129 — arêtes connectées et portées étendues
Demandes du 17 septembre 2026 : highlight optionnel, puis raccordement CTM de
tous les blocs coplanaires, portée indépendante 16256 blocs, ombres à 128/256,
suppression de l'atténuation au zénith et du réalignement cyclique des ombres.
Branche `codex/edge-highlights-beta129`, socle beta.128, Minecraft 26.3 / Java 25.
## Résultat
Dans **Options → Shaders → Sanctuary · Vanilla Light**, les reflets sur les
arêtes sont désactivés par défaut. L'intensité va de 0 à 100 ; la valeur 30
éclaircit au maximum de 12 %. Leur portée se règle indépendamment des ombres :
**16, 32, 64, 128 ou 256 blocs**. Les anciens réglages restent conservés.
Le highlight raccorde les surfaces plates, y compris entre matériaux différents,
sans dessiner les jonctions intérieures. Il suit les contours visibles du relief,
y compris les dalles et escaliers. Il multiplie la couleur existante pour garder
les pixels des textures et composer avec les ombres. Ce CTM géométrique utilise
la profondeur déjà produite par Minecraft : aucune liste de blocs autorisés,
aucun pack CTM à fournir, aucune exploration CPU du volume de 256 blocs.
Une cassure/repose modifie le contour dès que le terrain natif est réaffiché.
Le trait proche suit la grille de 1/16 de bloc. À distance, la couverture devient
partielle pour limiter le scintillement des traits plus petits qu'un pixel.
La portée choisie s'efface progressivement dans son dernier quart et respecte
la brume ainsi que la distance de rendu. Une passe plein écran, quatre sondes
de profondeur et un tampon uniforme de 176 octets ; aucun retour GPU vers CPU
en production. L'arrêt de l'option ou une intensité nulle libèrent ce tampon.
Ce n'est pas un remplacement des textures par des variantes CTM. Le calcul
concerne les faces visibles alignées sur la grille : les faces hors écran,
les surfaces transparentes absentes de la profondeur et les géométries
arbitrairement inclinées ne sont pas reconstruites. À très grande distance,
les détails plus petits qu'un pixel peuvent rester imperceptibles. Aucun
chargement de chunk forcé, aucune promesse de FPS.
Les ombres proposent les cinq mêmes portées. La carte reste plafonnée à
4096 × 4096 : les grandes distances réduisent sa précision spatiale. Le cache
garde ses limites de 96 Mio, quatre sections et 3 ms souples de construction
par image ; la réconciliation des chunks est limitée aux ticks et changements
de caméra/terrain plutôt qu'à toutes les images.
L'intensité solaire reste pleine au zénith. Les contours opaques restent nets,
le filtrage du feuillage et les ombres légères de la lune sont conservés.
Le réalignement de la grille lumineuse a lieu au changement d'ancrage de la
caméra, avec transfert de phase par texels entiers. La rotation du soleil ne
réarrondit plus la projection de l'origine mondiale à chaque image, source
de sauts périodiques amplifiés aux grandes coordonnées. Le soleil continue
d'avancer ; une ombre pixélisée peut naturellement changer de case. Cette
correction ne modifie pas l'horloge serveur ni sa cadence native.
Textes FR/EN. Aucun changement de règles, sauvegardes, génération, gemmes,
sculptures ou découvertes.
## Vérifications et livraison
Le client natif réussit en **2 min 41 s**, dans un monde neuf plat, graine 122,
sur macOS / Apple M1 / OpenGL :
- CTM : 596 échantillons éclaircis sur le contour et 65 068 conservés ; les
20 centres et 20 jonctions coplanaires ciblés restent intacts, y compris
au changement de matériau. L'intensité 90 produit environ trois fois le gain.
- Ouverture d'un trou : 716 échantillons éclaircis ; la fermeture restitue
l'image connectée. Au-delà de 16 blocs, le réglage 16 supprime le contour ;
le réglage 64 le rétablit (254 échantillons éclaircis).
- Cinq portées CTM et cinq portées d'ombres, persistance/clamp, migration des
anciennes préférences, arrêt/libération, rechargement, caméra et vue oblique
avec ombres. Captures françaises/anglaises inspectées ; suppression ensuite
d'un préfixe redondant dans le libellé de portée.
- Soleil, lune, nouvelle lune, ombres opaques nettes à midi et transmission
du feuillage restent vérifiés. Casters hors écran et retrait/repose actifs.
- 2 000 petits pas solaires aux coordonnées ±100 000 vérifient l'absence de
saut de translation de la carte ; changement d'ancrage par texels entiers.
La séquence du vrai service realtime suit 294 images : angle avancé de
0,000694 radian, aucun rebond dans les 65 664 échantillons de sol suivis.
Sur cette courte séquence, aucune case opaque n'a changé ; cela ne constitue
pas une mesure de mouvement visible sur une journée entière.
La fixture initiale capturait parfois le terrain avant la fin de sa construction
native : elle attend désormais la réception de toute la plateforme et la fin
compilée des sections. Aucun changement de gameplay pour contourner ce problème.
Commande : `./gradlew :sanctuary:runClientGameTest -PsanctuaryClientTests=true
-PsanctuaryPixelShadows122ClientTests=true -PsanctuaryClientNoVsync=true
-PsanctuaryQuickTests=true`. Log `build/shader129-client.log`, captures sous
`mods/sanctuary/build/run/clientGameTest/screenshots/`.
`./gradlew check build assemblePack assembleTestPack -x :sanctuary:runGameTest`
réussit en **2 min 17 s**, 126 tâches. Une dernière passe dassemblage intègre
la simplification du libellé de portée (2 min 18 s). Les archives sont vérifiées :
**17 entrées de production** diffèrent de beta.128, limitées au shader et à ses
interfaces. Même JAR dans normal/Test, sources archivées conformes, ressources
du template exactes, assets des gemmes inchangés. Aucun monde ni test embarqué.
Le serveur GameTest dédié
reste exclu conformément au refus antérieur de son EULA. Aucun monde personnel
utilisé pour les essais, aucun résultat Windows/Vulkan revendiqué.
Archives vérifiées dans `build/` et recopiées dans le dossier de livraison
habituel `sanctuary-beta/build/` :
- Normal : 10 353 946 octets, SHA-256
`2c7735194faab8a699004e69ad543ce6e8fabdebe297b759bffd31fea2dcc2e8`.
- Test : 10 372 872 octets, SHA-256
`cca572ee313d1d7e66ed745bb48bac62490998760d9b7bbfc7a1f6d60c430dce`.
- JAR Sanctuary :
`ca3e2ff4e0717a3a54c1d57478d43aade39d001b71c4eec745d76f010f7def54`.
[Release beta.129](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.129)
publiée depuis `35103a7ea8635dc4ed26293f00e86d99aaacfca8`. Canal packwiz
`2eaa0130a4cd475c598beefdab40f48fc8681ab7`. Deux synchronisations isolées puis deux synchronisations
dans l'instance **Sanctuary Beta** ont réussi. Les **923 fichiers personnels**
suivis, dont les réglages de shader, sont inchangés. Aucun monde personnel ouvert.
Sauvegarde préalable des fichiers gérés : `sanctuary-backups/before-beta.129/`.
Le JAR installé a le hash vérifié ci-dessus. Reçus locaux :
`build/shader129-artifact.json`, `build/shader129-isolated.json` et
`build/shader129-prism.json`. Le checkout initial avec ses modifications en
cours a été préservé ; seule la copie des archives versionnées rejoint son
répertoire `build/`.
+131
View File
@@ -0,0 +1,131 @@
# SHADER-130 — bloom et précision commune
Demandes du 17 septembre 2026 : bloom réglable du soleil et des sources
lumineuses ; inclusions émissives du fer, cuivre, redstone, lapis, diamant,
émeraude, rubis, saphir, quartz et or ; réglage commun « Précision » pour
les ombres et le highlight. Branche `codex/bloom-beta130`, socle beta.129,
Minecraft 26.3 / Java 25.
## Résultat
Dans **Options → Shaders → Sanctuary · Vanilla Light**, le bloom possède
un interrupteur, une intensité et une diffusion réglables de 0 à 100.
Les minerais ont leur propre interrupteur et intensité. Le bloom reste
optionnel, désactivé initialement, avec une intensité préparée à 35, une
diffusion à 50 et une émission des minerais à 50. Une intensité globale
nulle arrête les passes et libère les ressources de cet effet.
Les nouveaux réglages par défaut sont **Pixel Shadows activé**, distance
des ombres **128 blocs**, highlight **32 blocs** et **Précision 16 pixels
par bloc**. La précision unique propose 8, 16 et 32 et contrôle simultanément
ombres et arêtes. La clé JSON historique `shadowPixels` est conservée :
les préférences déjà personnalisées ne sont pas écrasées.
Les vingt textures de minerais sont analysées depuis les ressources actives.
La palette de pierre, de deepslate ou de netherrack est comparée aux pixels
colorés des inclusions, avec un traitement du quartz et de l'or du Nether.
Les reflets clairs accolés aux inclusions sont conservés. Le masque est
calculé une fois par texture et conservé en cache jusqu'au rechargement des
ressources. Une texture optionnelle `<nom>_e.png` peut préciser ce masque
pour une texture candidate ; sa luminance maximale multipliée par l'alpha
remplace la détection automatique. Les couleurs affichées viennent toujours
de l'atlas Minecraft actif, y compris son animation.
Les sources sont les faces visibles des modèles de blocs lumineux, la lave,
le soleil et la lune. Les lampes éteintes ne produisent pas de halo. Le
niveau lumineux natif pondère les sources ordinaires ; l'émission des minerais
est purement graphique. La profondeur du terrain élimine les inclusions
cachées et masque le soleil derrière les constructions. Le cœur émissif
conserve ses pixels nets, tandis qu'un halo séparé est diffusé à résolution
réduite puis composé sur l'image.
Aucun niveau de lumière serveur, spawn, règle de jeu, format de sauvegarde,
identifiant ou paramètre de génération ne change. Aucun monde personnel
n'est ouvert pour les essais.
## Coût et limites
La géométrie suit uniquement les sections natives visibles et déjà chargées.
Leur palette permet d'écarter rapidement les sections sans source ; le cache
invalide les sections touchées lors des modifications. Construction répartie
sur les images, au plus quatre sections démarrées et un budget souple de
3 ms, cache GPU plafonné à 32 Mio et 16 384 faces par section. Une section
qui dépasse les limites est omise. Aucun chargement de chunk forcé.
Le masque GPU est plafonné à 256 textures, chacune échantillonnée au plus en
64 × 64 ; les textures natives 16 × 16 conservent leurs texels. Les masques
animés utilisent leur première image, tandis que la couleur reste animée.
La carte d'émission est à la résolution de l'écran ; la diffusion séparable
utilise deux cibles flottantes à 1/8 de largeur et de hauteur, sans retour
GPU vers CPU en production. Les allocations sont libérées à l'arrêt, au
rechargement et à la déconnexion, et recréées au changement de taille.
Cette première version ne reconstruit pas les sources hors écran, les
particules, les entités lumineuses ni les modèles dessinés exclusivement par
un renderer de block entity. Les packs très différents peuvent demander un
masque explicite. Les silhouettes des liquides sont approximées puis
masquées par la profondeur native. Aucune promesse de FPS ni validation
Windows/Vulkan ; les essais graphiques portent sur macOS / Apple M1 / OpenGL.
## Vérifications
Le test client ciblé réussit en **1 min 29 s**, dans un nouveau monde plat,
graine 122. Il analyse les vingt textures, refuse les pixels gris de leur
roche, mesure le gain nocturne et les variations réelles d'intensité/diffusion.
Couvrir les minerais de pierre supprime toute source émissive. Retirer les
blocs rétablit l'émission ; le rechargement des ressources fait de même.
Torche, glowstone, lampe alimentée, lave et soleil sont vérifiés, ainsi que
l'arrêt de la lampe et l'occultation du soleil par de la pierre. Les valeurs
par défaut et la conservation d'anciens choix personnalisés passent.
Log ciblé : `build/shader130-client-bloom.log` ; masques inspectables dans
`mods/sanctuary/build/run/clientGameTest/bloom130-ore-masks.png` et son CSV.
La suite shader complète réussit en **3 min 16 s** : casters hors écran,
retrait/repose, trois précisions, cinq portées, lune, zénith, feuillage,
realtime, caméra et rechargement restent vérifiés. Les arêtes CTM restent
raccordées entre matériaux ; 1 295 échantillons du contour sont éclaircis
à précision 8, 596 à 16 et 120 à 32. Les captures françaises/anglaises et
le rendu des minerais/du soleil sont inspectés.
Commandes natives : `./gradlew :sanctuary:runClientGameTest
-PsanctuaryClientTests=true -PsanctuaryPixelShadows122ClientTests=true
-PsanctuaryClientNoVsync=true -PsanctuaryQuickTests=true`. Le test ciblé utilise
`-PsanctuaryBloom130ClientTests=true` à la place de la propriété PixelShadows.
Log complet : `build/shader130-client-full.log` ; captures sous
`mods/sanctuary/build/run/clientGameTest/screenshots/`.
`./gradlew check build assemblePack assembleTestPack -x :sanctuary:runGameTest`
réussit en **2 min 24 s**, 126 tâches. Le serveur GameTest dédié reste exclu
conformément au refus antérieur de son EULA. La comparaison au binaire beta.129
trouve **24 entrées de production** modifiées, toutes limitées au shader et à
ses interfaces. Gameplay et textures des gemmes inchangés ; aucun test,
monde ou modèle GLB embarqué. Les sources archivées correspondent aux sources
Java, les packs normal/Test embarquent le même JAR et le template contient
exactement les ressources prévues.
Archives vérifiées dans `build/` et recopiées dans le dossier habituel
`sanctuary-beta/build/` :
- `Sanctuary-beta.130.mrpack` : 10386021 octets, SHA-256
`7a3fcd4e496fa136eb3e0896fa7b856ae91771123186fa8df13c8fb1e0cf3e9a`.
- `Sanctuary-Test-beta.130.mrpack` : 10404944 octets, SHA-256
`8de094fcbc58a300cc24d64c8d1b7931fdb8db354c75defa6bf2a2aa4a123a23`.
JAR : `1ac4d1b1339361fb665f03ca3bd359163db5037ffd90e684c5a461759f6076e7`.
[Release beta.130](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.130)
publiée depuis `534dfec4f88169a200702b50d1ba87d85c107bee`. Canal packwiz
`68e06353bf880af04c1c72d4e3a159f11f94b619`. Les téléchargements publics sont vérifiés par SHA-256.
Deux synchronisations isolées, puis deux dans la même instance **Sanctuary
Beta**, réussissent. Un seul JAR Sanctuary beta.130 est actif. Les
**923 fichiers personnels suivis** conservent leurs hashes, dont les mondes,
packs et préférences de shaders. Sauvegarde préalable dans
`sanctuary-backups/before-beta.130/` de l'instance. Aucun monde personnel ouvert.
Reçus locaux ignorés : `build/shader130-artifact.json`,
`build/shader130-isolated.json`, `build/shader130-prism.json` et
`build/shader130-publication.log`. Le checkout original conserve ses
modifications préexistantes ; seuls les trois artefacts versionnés vérifiés
sont recopiés dans son dossier `build/`.
+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 0100. À 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 0100. 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 0100, 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é 0100.
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é.
+11
View File
@@ -102,3 +102,14 @@ client natif : témoin `SHADER040_CLIENT_PASS` dans
`build/operator040-client-final.log`. Captures dans
`build/operator040-evidence/final/`, reçu dans `build/operator040-artifact.json`.
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.
+129
View File
@@ -0,0 +1,129 @@
# TOOL-02 — Clé dorée et clé de Steve — beta.123
Contrat du 17 septembre 2026, branche `codex/wrench-safety-beta123`.
Ce ticket remplace le contrat de la clé dorée de beta.120 : l'édition libre
reste disponible dans un objet distinct, la **Clé à molette de Steve**.
Le créateur précise que la clé de Steve reste utilisable en survie dès quun
joueur la récupère, sans condition opérateur. Elle na pas de recette.
## Clé dorée : orientations natives
L'identifiant `sanctuary:golden_wrench`, sa recette et sa texture restent stables.
Clic droit parcourt l'orientation native ; Maj parcourt en sens inverse.
Clic gauche rappelle les gestes, sans casser le bloc ni sélectionner un état.
Les familles de propriétés autorisées sont les orientations horizontales,
les six directions, les trois axes, la rotation native des panneaux et
l'orientation native des blocs qui la proposent. La règle porte sur les
propriétés Minecraft, sans catalogue à maintenir pour chaque type de bloc.
L'eau, la moitié d'un escalier, les inventaires, les états de croissance,
les quantités, la puissance et les autres propriétés ne sont jamais proposées.
Aucune transformation indépendante des textures n'est créée par la clé dorée.
Les textures suivent seulement le modèle orienté par Minecraft.
Les connexions et formes sont recalculées à partir des voisins réels, avec les
mises à jour natives actives. Une orientation privée de support est refusée
sans casser le bloc. L'eau déjà contenue dans un bloc est conservée.
Les coffres doubles, portes, lits, étagères reliées, pistons sortis et leurs
pièces mobiles ne peuvent pas être tournés individuellement. Ils restent dans
leur état courant et une indication explique le refus. Il faut replacer les
éléments concernés ou rétracter le piston ; ce ticket ne déplace pas un
ensemble lié. Les coffres simples conservent leur entité et leur inventaire.
L'assemblage, l'ouverture et la dissociation des Métablis, Fûts, Fourneaux et
super pistons restent prioritaires avec les deux clés. Les parties techniques
d'un multibloc Sanctuary assemblé ne sont pas éditées individuellement.
## Clé de Steve
Nouvel identifiant `sanctuary:steve_wrench`, dans la section créative des outils,
sans recette de survie. La possession de cet objet donne accès à ses pouvoirs,
y compris en survie sans commandes et sans statut opérateur, dans les deux
mains. Les restrictions natives de construction, aventure, spectateur, portée,
protection et verrouillage restent appliquées côté serveur.
Les gestes de beta.120 sont conservés : clic gauche choisit la propriété,
clic droit la modifie, Maj inverse le parcours. Tous les états natifs et la
rotation indépendante des textures y restent volontairement accessibles.
La clé de Steve est donc volontairement capable de produire des états non
naturels, y compris pour le joueur qui la récupère en survie.
La texture finale est le PNG 16 × 16 bleu fourni par le créateur dans ce ticket,
copié sans transformation dans `assets/sanctuary/textures/item/steve_wrench.png`.
SHA-256 : `0084c41e1f46958903075e21866facd97d85e03fb8e06742e25b2ab254956fa3`.
Le modèle reste un objet Minecraft 2D tenu en main, avec rareté épique et reflet.
La proposition imagegen intermédiaire n'est pas livrée. Les noms, messages et
indications des deux outils existent en français et en anglais.
## Sauvegardes et ressources
Aucune migration, modification de génération ni parcours des mondes existants.
Les transformations de texture précédemment sauvegardées sont conservées ;
la clé de Steve permet de les modifier. Les états non naturels déjà présents
ne sont pas automatiquement réécrits. Les clés dorées existantes reçoivent
immédiatement les nouvelles restrictions par leur identifiant inchangé.
Le template personnel inclut le nouvel objet à sa prochaine création ; une
copie personnelle existante n'est jamais écrasée.
## Vérifications
Le parcours `Wrench123ClientChecks` passe en **1 min 12 s**, client macOS et
serveur intégré, Minecraft 26.3, dans des mondes plats jetables de graines
123 et 120. Il reprend le parcours avancé de beta.120 sur la clé de Steve :
- Vrais clics dorés répétés sur escalier, coffre et terre : pas de nouvelle eau,
pas de sélection libre, pas de faux coffre double ni de rotation de texture.
- Rotation inverse, eau existante conservée, formes recalculées selon les voisins,
trois axes de bûches et six orientations des pistons isolés.
- Coffre double avec ses deux inventaires intacts ; portes, lits, pistons sortis
et pièces mobiles protégés ; torche murale sans support laissée intacte.
- Vrais clics Steve en survie sans commandes : états et textures accessibles,
dans les deux mains. Créatif accessible aussi sans commandes ; restrictions
aventure/spectateur/portée conservées.
- **2 522 propriétés natives** toujours réversibles pour Steve, UV réellement
tournés sans déplacer la géométrie, rechargement des ressources, inventaires
et sauvegarde/reconnexion conservés.
- Assemblage et dissociation des quatre familles avec la clé dorée par vrais
clics ; le PNG de Steve apparaît en main et dans la barre rapide.
Marqueurs `WRENCH123_SAFE_PASS`, `WRENCH123_PASS`, `WRENCH120_PASS` dans
`build/wrench123-client.log`. Captures relues dans `build/wrench123-evidence/`.
Pas de validation Windows, de client distant ou de shader tiers.
`./gradlew check build assemblePack assembleTestPack -x :sanctuary:runGameTest`
passe en **2 min 19 s**, 126 tâches (103 exécutées, 23 à jour).
Les sources des JAR correspondent aux sources du dépôt. La comparaison avec
beta.122 limite les dix entrées de production modifiées aux classes des clés,
aux libellés et aux ressources du nouvel objet. Les autres ressources,
notamment les sculptures et la texture dorée, sont identiques.
Les packs normal/Test contiennent le même JAR Sanctuary, sans sauvegarde ni
fixture de test. Le template ZIP correspond exactement aux ressources du JAR
et à ses ressources embarquées, dont le PNG bleu du créateur.
- Pack normal : 10 270 954 octets, SHA-256
`a7401dbef1bd1b888da2686368943eddc82d0d43e20449330c4849b4dff93e5f`.
- Pack Test : 10 289 877 octets, SHA-256
`c57cc6fc47f887b90850473e51407b610a6c5639c35fc2b78dc9ddb74f795b80`.
- JAR Sanctuary : SHA-256
`23e1a81c793a048c6375a0da07eb0bbe1a18f833a9ceb61a3c1dc5f1a3e65a30`.
Reçu local : `build/wrench123-artifact.json`.
Le GameTest dédié reste exclu conformément au refus antérieur de son EULA.
Aucun monde personnel n'est ouvert par les essais.
## Publication et installation
La [release beta.123](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.123)
est publiée depuis `4552cc347b7fded610b1eda7a67ffaad56a5e201`.
Le tag exact et les artefacts sont immuables ; les téléchargements publics
ont été vérifiés. Canal packwiz : `f988d01b901f2d3eda0b4ab47a48540dc627d1b5`.
Le template personnel publié inclut la clé de Steve ; SHA-256
`f84d3c1c0e38232add90fb943dc39fd3e89138576df86024ceaa311478ed5cb9`.
Deux synchronisations isolées puis deux dans **Sanctuary Beta** réussissent.
Un seul JAR Sanctuary beta.123 est actif, conforme au hash vérifié ; le second
passage ne change rien. Les **923 fichiers personnels et réglages suivis**
restent inchangés, sans ouverture de monde personnel. Copie préalable dans
`sanctuary-backups/before-beta.123/` de l'instance existante.
Reçus : `build/wrench123-isolated.json` et `build/wrench123-prism.json`.
+3 -3
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.112
pack_version=beta.112
resource_pack_version=beta.090
mod_version=beta.144
pack_version=beta.144
resource_pack_version=beta.121
maven_group=fr.koka.sanctuary
jei_version=30.32.0-sanctuary.3
+84
View File
@@ -25,6 +25,22 @@ 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.water144', providers.gradleProperty('sanctuaryWater144ClientTests').getOrElse('false'))
systemProperty('sanctuary.test.water144ExtrasOnly', providers.gradleProperty('sanctuaryWater144ExtrasOnly').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 +109,65 @@ 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') }
def shadows122Client = providers.gradleProperty('sanctuaryPixelShadows122ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('sanctuaryPixelShadows122ClientTests', shadows122Client)
if (shadows122Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.shader.PixelShadows122ClientChecks') }
def gems126Client = providers.gradleProperty('sanctuaryGems126ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('sanctuaryGems126ClientTests', gems126Client)
if (gems126Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.Gems126ClientChecks') }
def sculpture125Client = providers.gradleProperty('sanctuarySculpture125ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('sanctuarySculpture125ClientTests', sculpture125Client)
if (sculpture125Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.Sculpture125ClientChecks') }
def wrench123Client = providers.gradleProperty('sanctuaryWrench123ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('sanctuaryWrench123ClientTests', wrench123Client)
if (wrench123Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.Wrench123ClientChecks') }
def sculpture122Client = providers.gradleProperty('sanctuarySculpture122ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('sanctuarySculpture122ClientTests', sculpture122Client)
if (sculpture122Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.Sculpture122ClientChecks') }
def template121Client = providers.gradleProperty('sanctuaryResourceTemplate121ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('sanctuaryResourceTemplate121ClientTests', template121Client)
if (template121Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.ResourceTemplate121ClientChecks') }
def wrench120Client = providers.gradleProperty('sanctuaryWrench120ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('sanctuaryWrench120ClientTests', wrench120Client)
if (wrench120Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.Wrench120ClientChecks') }
def sculpture116Client = providers.gradleProperty('sanctuarySculpture116ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('sanctuarySculpture116ClientTests', sculpture116Client)
if (sculpture116Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.Sculpture116ClientChecks') }
def furnace115Client = providers.gradleProperty('sanctuaryFurnace115ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('sanctuaryFurnace115ClientTests', furnace115Client)
if (furnace115Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.Furnace115ClientChecks') }
def carryPanic114Client = providers.gradleProperty('sanctuaryCarryPanic114ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('sanctuaryCarryPanic114ClientTests', carryPanic114Client)
if (carryPanic114Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.CarryPanic114ClientChecks') }
def statuary106Client = providers.gradleProperty('sanctuaryStatuary106ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('sanctuaryStatuary106ClientTests', statuary106Client)
if (statuary106Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.Statuary106ClientChecks') }
@@ -430,6 +505,15 @@ tasks.withType(JavaCompile).configureEach {
}
processResources {
from(rootProject.file('ressources-pack/template')) {
exclude '**/.DS_Store'
into 'resourcepacks/template'
}
from(rootProject.file('LICENSE')) {
rename { 'LICENSE_sanctuary' }
into 'resourcepacks/template'
}
from(rootProject.file('THIRD_PARTY_NOTICES.md')) { into 'resourcepacks/template' }
from(rootProject.file('ressources-pack/sanctuary')) {
include 'assets/**', 'pack.mcmeta', 'pack.png'
exclude '**/.DS_Store'
@@ -37,10 +37,10 @@ public final class Bricks102ClientChecks implements FabricClientGameTest {
c.waitFor(m->ProgressionClient.active()&&m.player!=null&&m.gui.screen()==null&&!IntroSpawnFlash.active(),1200);
world.getServer().runOnServer(s->{
var p=s.getPlayerList().getPlayers().getFirst();var level=p.level();var pos=new BlockPos(0,3,0);
check(ColoredBricks.families().size()==16,"All colors registered");int index=0;
check(ColoredBricks.families().size()==16,"All colors registered");Walls118Checks.server(s);int index=0;
for(var f:ColoredBricks.families()){
String color=f.color().getName();
for(var block:List.of(f.bricks(),f.slab(),f.stairs(),f.clay()))check(block.asItem()!=Items.AIR,"Block item exists "+block);
for(var block:List.of(f.bricks(),f.slab(),f.stairs(),f.wall(),f.clay()))check(block.asItem()!=Items.AIR,"Block item exists "+block);
check(f.bricks().defaultBlockState().is(BlockTags.MINEABLE_WITH_PICKAXE),"Pickaxe tag "+color);
check(f.clay().defaultBlockState().is(BlockTags.MINEABLE_WITH_SHOVEL),"Shovel tag "+color);
var shovel=new ItemStack(Items.DIAMOND_SHOVEL);var silk=shovel.copy();silk.enchant(level.registryAccess().lookupOrThrow(Registries.ENCHANTMENT).getOrThrow(Enchantments.SILK_TOUCH),1);
@@ -52,7 +52,7 @@ public final class Bricks102ClientChecks implements FabricClientGameTest {
drop(Block.getDrops(f.stairs().defaultBlockState(),level,pos,null),f.stairs().asItem(),1);
check(f.slab().defaultBlockState().setValue(SlabBlock.WATERLOGGED,true).getFluidState().isSource(),"Slab water "+color);
check(f.stairs().defaultBlockState().setValue(StairBlock.WATERLOGGED,true).getFluidState().isSource(),"Stair water "+color);
for(String suffix:List.of("clay_ball_from_dye","clay_from_dye","brick_from_dye","bricks_from_dye","clay","bricks","brick_slab","brick_stairs","brick_slab_from_stonecutting","brick_stairs_from_stonecutting","clay_ball_smelting","clay_smelting"))check(s.getRecipeManager().byKey(ResourceKey.create(Registries.RECIPE,SanctuaryMod.id(color+"_"+suffix))).isPresent(),"Recipe loaded "+color+"_"+suffix);
for(String suffix:List.of("clay_ball_from_dye","clay_from_dye","brick_from_dye","bricks_from_dye","clay","bricks","brick_slab","brick_stairs","brick_slab_from_stonecutting","brick_stairs_from_stonecutting","brick_wall","brick_wall_from_stonecutting","clay_ball_smelting","clay_smelting"))check(s.getRecipeManager().byKey(ResourceKey.create(Registries.RECIPE,SanctuaryMod.id(color+"_"+suffix))).isPresent(),"Recipe loaded "+color+"_"+suffix);
craft(s,level,2,2,List.of(f.brick(),f.brick(),f.brick(),f.brick()),f.bricks().asItem(),1);
craft(s,level,2,2,List.of(f.clayBall(),f.clayBall(),f.clayBall(),f.clayBall()),f.clay().asItem(),1);
craft(s,level,3,1,List.of(f.bricks().asItem(),f.bricks().asItem(),f.bricks().asItem()),f.slab().asItem(),6);
@@ -62,39 +62,40 @@ public final class Bricks102ClientChecks implements FabricClientGameTest {
var input=new SingleRecipeInput(new ItemStack(f.clayBall()));var cooked=s.getRecipeManager().getRecipeFor(RecipeType.SMELTING,input,level).orElseThrow().value().assemble(input);check(cooked.is(f.brick()),"Smelt brick "+color);
input=new SingleRecipeInput(new ItemStack(f.clay()));cooked=s.getRecipeManager().getRecipeFor(RecipeType.SMELTING,input,level).orElseThrow().value().assemble(input);check(cooked.is(BuiltInRegistries.ITEM.getValue(net.minecraft.resources.Identifier.withDefaultNamespace(color+"_terracotta"))),"Smelt terracotta "+color);
var stoneInput=new SingleRecipeInput(new ItemStack(f.bricks()));
for(var variant:List.of(new Object[]{"brick_slab",f.slab().asItem(),2},new Object[]{"brick_stairs",f.stairs().asItem(),1})){
for(var variant:List.of(new Object[]{"brick_slab",f.slab().asItem(),2},new Object[]{"brick_stairs",f.stairs().asItem(),1},new Object[]{"brick_wall",f.wall().asItem(),1})){
var recipe=(StonecutterRecipe)s.getRecipeManager().byKey(ResourceKey.create(Registries.RECIPE,SanctuaryMod.id(color+"_"+variant[0]+"_from_stonecutting"))).orElseThrow().value();check(recipe.matches(stoneInput,level),"Stonecutting accepts input");var cut=recipe.assemble(stoneInput);check(cut.is((Item)variant[1])&&cut.getCount()==(int)variant[2],"Stonecutting result");
}
int x=(index%8)*3,z=(index/8)*5;level.setBlockAndUpdate(new BlockPos(x,1,z),f.bricks().defaultBlockState());level.setBlockAndUpdate(new BlockPos(x,2,z),f.slab().defaultBlockState().setValue(SlabBlock.TYPE,SlabType.TOP));level.setBlockAndUpdate(new BlockPos(x+1,1,z),f.stairs().defaultBlockState().setValue(StairBlock.FACING,Direction.SOUTH));level.setBlockAndUpdate(new BlockPos(x,1,z+1),f.clay().defaultBlockState());index++;
}
fr.koka.sanctuary.recipes.RecipeService.discover(p,Items.CLAY_BALL);fr.koka.sanctuary.recipes.RecipeService.refresh(p);
var known=fr.koka.sanctuary.recipes.RecipeService.knowledge(p);
for(var f:ColoredBricks.families())check(known.recipes.contains("sanctuary:"+f.color().getName()+"_brick_stairs_from_stonecutting"),"Collection unlock "+f.color());
check(known.recipes.contains("sanctuary:clay_workshop"),"Existing workshop recipe remains discoverable");
for(var f:ColoredBricks.families())check(known.recipes.contains("sanctuary:"+f.color().getName()+"_brick_stairs_from_stonecutting")&&known.recipes.contains("sanctuary:"+f.color().getName()+"_brick_wall")&&known.recipes.contains("sanctuary:"+f.color().getName()+"_brick_wall_from_stonecutting"),"Collection unlock "+f.color());
s.getCommands().performPrefixedCommand(s.createCommandSourceStack(),"sanctuary time vanilla");s.getCommands().performPrefixedCommand(s.createCommandSourceStack(),"time set noon");
p.teleportTo(11,8,19);p.setYRot(180);p.setXRot(26);p.getAbilities().flying=true;p.onUpdateAbilities();
});
c.runOnClient(m->{
CreativeModeTabs.tryRebuildTabContents(m.level.enabledFeatures(),true,m.level.registryAccess());
var blocks=BuiltInRegistries.CREATIVE_MODE_TAB.getOrThrow(CreativeModeTabs.COLORED_BLOCKS).value().getDisplayItems();
var ingredients=BuiltInRegistries.CREATIVE_MODE_TAB.getOrThrow(CreativeModeTabs.INGREDIENTS).value().getDisplayItems();
var ingredients=BuiltInRegistries.CREATIVE_MODE_TAB.getOrThrow(CreativeModeTabs.INGREDIENTS).value().getDisplayItems();Walls118Checks.creative(new ArrayList<>(blocks));
for(var f:ColoredBricks.families()){
for(var b:List.of(f.bricks(),f.slab(),f.stairs(),f.clay())){
for(var b:List.of(f.bricks(),f.slab(),f.stairs(),f.wall(),f.clay())){
check(blocks.stream().anyMatch(i->i.is(b.asItem())),"Creative color tab "+b);
for(var state:b.getStateDefinition().getPossibleStates()){
var parts=new ArrayList<net.minecraft.client.renderer.block.dispatch.BlockStateModelPart>();m.getModelManager().getBlockStateModelSet().get(state).collectParts(net.minecraft.util.RandomSource.create(0),parts);check(!parts.isEmpty(),"Baked model "+state);
var parts=new ArrayList<net.minecraft.client.renderer.block.dispatch.BlockStateModelPart>();m.getModelManager().getBlockStateModelSet().get(state).collectParts(net.minecraft.util.RandomSource.create(0),parts);check(b instanceof WallBlock||!parts.isEmpty(),"Baked model "+state);
for(var part:parts)for(var direction:new Direction[]{Direction.UP,Direction.DOWN,Direction.NORTH,Direction.SOUTH,Direction.EAST,Direction.WEST,null})for(var quad:part.getQuads(direction))check(!quad.materialInfo().sprite().contents().name().toString().contains("missingno"),"No missing texture "+state);
}
}
for(var item:List.of(f.clayBall(),f.brick()))check(ingredients.stream().anyMatch(i->i.is(item)),"Creative ingredients "+item);
}
});
c.waitTicks(20);c.runOnClient(m->{m.player.setYRot(180);m.player.setXRot(26);});c.waitTicks(4);c.takeScreenshot("bricks102-gallery");saved=world.getWorldSave();System.out.println("BRICKS102_NATIVE_PASS colors=16 recipes=192 blocks=64 items=96");
c.waitTicks(20);c.runOnClient(m->{m.player.setYRot(180);m.player.setXRot(26);});c.waitTicks(4);c.takeScreenshot("bricks102-gallery");saved=world.getWorldSave();System.out.println("BRICKS102_NATIVE_PASS colors=16 recipes=224 blocks=80 items=112");
}
try(var world=saved.open()){
c.waitFor(m->m.player!=null&&m.gui.screen()==null,1200);
world.getServer().runOnServer(s->{var level=s.getPlayerList().getPlayers().getFirst().level();int i=0;for(var f:ColoredBricks.families()){var pos=new BlockPos((i%8)*3,1,(i/8)*5);check(level.getBlockState(pos).is(f.bricks()),"Saved bricks "+f.color());check(level.getBlockState(pos.above()).getValue(SlabBlock.TYPE)==SlabType.TOP,"Saved slab state");i++;}});
world.getServer().runOnServer(s->{var level=s.getPlayerList().getPlayers().getFirst().level();Walls118Checks.saved(level);int i=0;for(var f:ColoredBricks.families()){var pos=new BlockPos((i%8)*3,1,(i/8)*5);check(level.getBlockState(pos).is(f.bricks()),"Saved bricks "+f.color());check(level.getBlockState(pos.above()).getValue(SlabBlock.TYPE)==SlabType.TOP,"Saved slab state");i++;}});
}
System.out.println("BRICKS102_PASS");
System.out.println("BRICKS102_PASS");System.out.println("WALLS118_PASS native placement, mixed connections, tall sides, waterlogging, collision, crafting, stonecutting, loot, collection, creative order, baked models and reconnect");
}
private static void craft(net.minecraft.server.MinecraftServer s,net.minecraft.server.level.ServerLevel level,int w,int h,List<Item> items,Item result,int count){var input=CraftingInput.of(w,h,items.stream().map(ItemStack::new).toList());var stack=s.getRecipeManager().getRecipeFor(RecipeType.CRAFTING,input,level).orElseThrow().value().assemble(input);check(stack.is(result)&&stack.getCount()==count,"Craft "+result);}
private static void drop(List<ItemStack> drops,Item item,int count){check(drops.size()==1&&drops.getFirst().is(item)&&drops.getFirst().getCount()==count,"Loot "+item+" expected "+count+" got "+drops);}
@@ -0,0 +1,115 @@
package fr.koka.sanctuary.client;
import com.mojang.blaze3d.platform.InputConstants;
import fr.koka.sanctuary.cosmetics.*;
import fr.koka.sanctuary.gametest.Starter039TestSupport;
import fr.koka.sanctuary.inventory.*;
import java.util.*;
import java.util.concurrent.atomic.*;
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.CameraType;
import net.minecraft.client.gui.components.Button;
import net.minecraft.client.gui.screens.worldselection.WorldCreationUiState;
import net.minecraft.client.input.KeyEvent;
import net.minecraft.core.BlockPos;
import net.minecraft.core.component.DataComponents;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.network.chat.contents.TranslatableContents;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.entity.*;
import net.minecraft.world.item.*;
import net.minecraft.world.item.component.CustomData;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.phys.*;
/** Real cursor/attack packets, synchronized player travel, interruption and collision regression. */
public final class CarryPanic114ClientChecks implements FabricClientGameTest {
public void runTest(ClientGameTestContext c){
var intro=new AtomicBoolean(true);
c.runOnClient(m->{m.options.renderDistance().set(6);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());
screen.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getMessage().getContents() instanceof TranslatableContents t&&t.getKey().equals("sanctuary.progression.enter")).findFirst().orElseThrow().onPress(enter());intro.set(false);
}});
try(var world=c.worldBuilder().adjustSettings(s->{s.setName("CarryPanic114 "+UUID.randomUUID());s.setSeed("114");s.setAllowCommands(true);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);var server=world.getServer();
server.runOnServer(s->{var p=player(s);p.getInventory().clearContent();p.getAbilities().invulnerable=true;p.onUpdateAbilities();reset(p);
for(int x=-24;x<=24;x++)for(int z=-24;z<=24;z++)p.level().setBlockAndUpdate(new BlockPos(x,0,z),Blocks.STONE.defaultBlockState());
s.getCommands().performPrefixedCommand(s.createCommandSourceStack(),"sanctuary time vanilla");s.getCommands().performPrefixedCommand(s.createCommandSourceStack(),"time set noon");});
int parrot=server.computeOnServer(s->carry(player(s),EntityTypes.PARROT).getId());
c.waitFor(m->m.level.getEntity(parrot)!=null&&CarryService.carried(m.level.getEntity(parrot)),150);
float health=server.computeOnServer(s->((LivingEntity)player(s).level().getEntity(parrot)).getHealth());
attack(c,parrot);c.waitFor(m->CarryPanic.active(m.player),100);
server.runOnServer(s->{var p=player(s);check(CarryPanic.active(p),"Real native attack triggers server panic");check(((LivingEntity)p.level().getEntity(parrot)).getHealth()<health,"Ordinary carried animal takes native damage");});
c.getInput().holdKey(o->o.keyUp);c.getInput().holdKey(o->o.keyRight);c.getInput().holdKey(o->o.keyJump);
c.runOnClient(m->{m.player.setYRot(170);m.player.setXRot(15);});c.waitTicks(12);
c.runOnClient(m->{check(m.player.getY()>2.5&&m.player.position().subtract(new Vec3(.5,1,.5)).horizontalDistance()>1,"Actual client moves upward and sideways");
check(CarryPanic.active(m.player),"Panic remains active through steering keys");
var wanted=m.player.getAttached(CarryPanic.FLIGHT).velocity(m.level.getGameTime());
// Level time can advance immediately after the player physics tick. Match either exact tick.
var previous=m.player.getAttached(CarryPanic.FLIGHT).velocity(m.level.getGameTime()-1);
check(Math.min(m.player.getDeltaMovement().distanceTo(wanted),m.player.getDeltaMovement().distanceTo(previous))<.00001,"Movement follows animal, not camera or held keys: actual="+m.player.getDeltaMovement()+", expected="+wanted+", flight="+m.player.getAttached(CarryPanic.FLIGHT)+", time="+m.level.getGameTime());check(m.player.fallDistance<.2,"Flight clears falling distance");
m.options.setCameraType(CameraType.THIRD_PERSON_BACK);});c.waitTicks(2);c.takeScreenshot("carry114-chaotic-flight");
c.getInput().releaseKey(o->o.keyUp);c.getInput().releaseKey(o->o.keyRight);c.getInput().releaseKey(o->o.keyJump);
c.waitTicks(65);c.waitFor(m->!CarryPanic.active(m.player),100);
server.runOnServer(s->{var p=player(s);check(!p.hasAttached(CarryPanic.FLIGHT)&&CarryGlide.supported(p),"Timeout removes panic and retains ordinary glider");check(p.connection.isAcceptingMessages(),"Flight is accepted by server");});
c.runOnClient(m->{check(m.player.getDeltaMovement().y<=0&&m.player.getDeltaMovement().y>=-CarryGlide.DESCENT-.02,"Normal gentle descent resumes");m.options.setCameraType(CameraType.FIRST_PERSON);});
System.out.println("CARRY114_NATIVE_FLIGHT_PASS cursor, real attack, damage, movement, input override, timeout, glide");
server.runOnServer(s->{var p=player(s);p.getRandom().setSeed(114);var bird=p.level().getEntity(parrot);var quadrants=new HashSet<Integer>();
for(int i=0;i<32;i++){
CarryPanic.clear(p);CarryPanic.start(bird);var flight=p.getAttached(CarryPanic.FLIGHT);check(flight.duration()>=40&&flight.duration()<=60,"Duration bounded at 23 seconds");quadrants.add((int)(flight.yaw()/(Math.PI/2)));
for(int t=0;t<=flight.duration();t++){var v=flight.velocity(flight.start()+t);check(v.horizontalDistance()<=.331&&v.y>=0&&v.y<=.241,"Velocity bounded without cumulative impulses");}
}
check(quadrants.size()==4,"Server random headings cover every quadrant");
});c.waitFor(m->CarryPanic.active(m.player),100);
c.getInput().holdKey(o->o.keyShift);c.waitTicks(2);c.getInput().holdKey(o->o.keyUse);c.waitTicks(1);c.getInput().releaseKey(o->o.keyUse);c.getInput().releaseKey(o->o.keyShift);
c.waitFor(m->!CarryService.carrying(m.player)&&!CarryPanic.active(m.player),100);
server.runOnServer(s->{check(!player(s).hasAttached(CarryPanic.FLIGHT),"Native drop clears flight immediately");player(s).level().getEntity(parrot).discard();reset(player(s));});
int cow=server.computeOnServer(s->carry(player(s),EntityTypes.COW).getId());
server.runOnServer(s->{var p=player(s);var animal=(LivingEntity)p.level().getEntity(cow);float before=animal.getHealth();p.attack(animal);
check(CarryService.blocksAttack(p,animal)&&animal.getHealth()==before&&!animal.hurtServer(p.level(),p.damageSources().playerAttack(p),1)&&!CarryPanic.active(p),"Terrestrial carry pair stays protected");CarryPanic.start(animal);check(!CarryPanic.active(p),"Ground animal cannot launch player");CarryService.release(animal,true);animal.discard();reset(p);});
// Every supported appearance can trigger the shared flight, including the gliding chicken.
server.runOnServer(s->{var p=player(s);for(var type:List.of(EntityTypes.CHICKEN,EntityTypes.BAT,EntityTypes.BEE,EntityTypes.ALLAY,EntityTypes.PARROT,EntityTypes.BLAZE,EntityTypes.BREEZE)){
var animal=carry(p,type);check(CarryGlide.flyer(animal)&&CarryPanic.canStrike(p,animal),"Carried flying species can be struck: "+type);
check(animal.hurtServer(p.level(),p.damageSources().playerAttack(p),1)&&CarryPanic.active(p),"Accepted damage launches carrier: "+type);CarryService.release(animal,true);animal.discard();check(!p.hasAttached(CarryPanic.FLIGHT),"Release clears each flight");
}});
int external=server.computeOnServer(s->carry(player(s),EntityTypes.PARROT).getId());
server.runOnServer(s->{var p=player(s);var bird=(LivingEntity)p.level().getEntity(external);var zombie=EntityTypes.ZOMBIE.create(p.level(),EntitySpawnReason.COMMAND);
check(bird.hurtServer(p.level(),p.damageSources().mobAttack(zombie),1)&&CarryPanic.active(p),"External accepted hit also triggers flight");
p.getAbilities().flying=true;CarryGlide.tick(p);check(!p.hasAttached(CarryPanic.FLIGHT),"Creative flying invalidates propulsion");p.getAbilities().flying=false;
CarryPanic.start(bird);bird.hurtServer(p.level(),p.damageSources().mobAttack(zombie),100);CarryGlide.tick(p);check(!CarryPanic.active(p),"Death stops propulsion");CarryService.detach(p);bird.discard();reset(p);});
int ceiling=server.computeOnServer(s->{var p=player(s);var bird=carry(p,EntityTypes.PARROT);for(int x=-3;x<=3;x++)for(int z=-3;z<=3;z++)p.level().setBlockAndUpdate(new BlockPos(x,4,z),Blocks.STONE.defaultBlockState());return bird.getId();});
c.waitFor(m->m.level.getEntity(ceiling)!=null&&CarryService.carried(m.level.getEntity(ceiling)),100);
server.runOnServer(s->{var p=player(s);((LivingEntity)p.level().getEntity(ceiling)).hurtServer(p.level(),p.damageSources().playerAttack(p),1);});c.waitTicks(12);
server.runOnServer(s->{var p=player(s);check(p.getY()<2&&p.level().noBlockCollision(p,p.getBoundingBox().deflate(.02)),"Player cannot fly through low ceiling");check(!CarryService.carrying(p)&&!CarryPanic.active(p),"Blocked carried animal is safely released");
p.level().getEntity(ceiling).discard();for(int x=-3;x<=3;x++)for(int z=-3;z<=3;z++)p.level().removeBlock(new BlockPos(x,4,z),false);reset(p);
var egg=new ItemStack(Items.PARROT_SPAWN_EGG);var size=new CompoundTag();size.putInt("schema",1);size.putInt("per_mille",1000);CustomData.update(DataComponents.CUSTOM_DATA,egg,t->t.put(FamiliarSize.KEY,size));AccessoryAccess.of(p).setItem(AccessoryContainer.FAMILIAR,egg);p.inventoryMenu.broadcastChanges();});
c.waitTicks(25);
int familiar=server.computeOnServer(s->{var p=player(s);var pet=FamiliarService.active(p);check(pet!=null,"Familiar spawned");pet.setNoAi(true);pet.setPos(p.position().add(0,0,2));check(CarryService.interact(p,pet),"Flying familiar picked up");return pet.getId();});
c.waitFor(m->m.level.getEntity(familiar)!=null&&CarryService.carried(m.level.getEntity(familiar)),100);
float petHealth=server.computeOnServer(s->FamiliarService.active(player(s)).getHealth());attack(c,familiar);c.waitFor(m->CarryPanic.active(m.player),100);
server.runOnServer(s->{var p=player(s);check(FamiliarService.active(p).getHealth()==petHealth&&CarryPanic.active(p),"Friendly familiar tap launches without hurting it");CarryService.detach(p);check(!p.hasAttached(CarryPanic.FLIGHT),"Lifecycle detach clears synchronized flight");});
System.out.println("CARRY114_PASS random flight, native ordinary/familiar strikes, seven species, outside hits, ground protection, drop/death/creative cancellation, ceiling collisions, timeout and normal glide");
}finally{intro.set(false);c.getInput().releaseKey(o->o.keyUp);c.getInput().releaseKey(o->o.keyRight);c.getInput().releaseKey(o->o.keyJump);c.getInput().releaseKey(o->o.keyShift);c.getInput().releaseKey(o->o.keyAttack);c.getInput().releaseKey(o->o.keyUse);}
}
private static void attack(ClientGameTestContext c,int id){
c.waitFor(m->m.level.getEntity(id).position().distanceTo(CarryService.head(m.player,m.level.getEntity(id)))<.01,100);
c.runOnClient(m->{m.options.setCameraType(CameraType.FIRST_PERSON);m.player.lookAt(net.minecraft.commands.arguments.EntityAnchorArgument.Anchor.EYES,m.level.getEntity(id).getBoundingBox().getCenter());});c.waitTicks(3);
c.runOnClient(m->check(m.hitResult instanceof EntityHitResult hit&&hit.getEntity().getId()==id,"Native cursor can pick own carried flyer: hit="+m.hitResult+", target="+m.level.getEntity(id).getBoundingBox()+", eye="+m.player.getEyePosition()+", view="+m.player.getLookAngle()+", eligible="+CarryPanic.canStrike(m.player,m.level.getEntity(id))));
c.getInput().holdKey(o->o.keyAttack);c.waitTicks(1);c.getInput().releaseKey(o->o.keyAttack);
}
private static Mob carry(ServerPlayer p,EntityType<? extends Mob> type){var mob=type.create(p.level(),EntitySpawnReason.COMMAND);mob.setNoAi(true);mob.setPos(p.position().add(0,0,2));p.level().addFreshEntity(mob);check(CarryService.interact(p,mob),"Animal picked up: "+type);return mob;}
private static void reset(ServerPlayer p){CarryService.detach(p);p.teleportTo(.5,1,.5);p.setDeltaMovement(Vec3.ZERO);p.resetFallDistance();p.setShiftKeyDown(false);}
private static ServerPlayer player(MinecraftServer s){return s.getPlayerList().getPlayers().getFirst();}
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);}
}
@@ -0,0 +1,42 @@
package fr.koka.sanctuary.client;
import fr.koka.sanctuary.companion.CompanionService;
import fr.koka.sanctuary.plans.*;
import java.nio.file.*;
import java.util.*;
import net.fabricmc.fabric.api.client.gametest.v1.context.*;
import net.minecraft.core.BlockPos;
import net.minecraft.world.entity.EntityTypes;
import net.minecraft.world.inventory.ContainerInput;
import net.minecraft.world.item.*;
import net.minecraft.world.level.GameType;
/** Native preview, server result and survival payment for the special plain-clay material. */
final class Clay126Checks {
static void exercise(ClientGameTestContext c,TestServerContext server){
var pos=new BlockPos(4,1,4);
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();p.teleportTo(3,1,3);p.setGameMode(GameType.SURVIVAL);p.getAbilities().invulnerable=true;p.onUpdateAbilities();p.level().setBlockAndUpdate(pos,Statuary.WORKSHOP.defaultBlockState());CompanionService.save(p,CompanionService.record(p).note("seen_mobs","cow"));Statuary.open(p,pos);p.containerMenu.getSlot(0).set(new ItemStack(Items.CLAY,2));p.containerMenu.broadcastChanges();});
c.waitForScreen(ClayWorkshopScreen.class);c.runOnClient(m->{m.options.guiScale().set(2);m.getWindow().setWindowed(1280,800);m.resizeGui();((ClayWorkshopScreen)m.gui.screen()).load(EntityTypes.COW);});
c.waitFor(m->{var screen=(ClayWorkshopScreen)m.gui.screen();return !screen.busy()&&screen.selected()!=null&&screen.selected().equals(StatuaryEntity.sculpture(m.player.containerMenu.getSlot(1).getItem()));},250);
var cow=c.computeOnClient(m->((ClayWorkshopScreen)m.gui.screen()).selected());check(cow.cells().stream().map(VoxelSculpture.Cell::color).distinct().count()>3,"Cow source retains texture colors");
for(var material:List.of(Items.OAK_PLANKS,fr.koka.sanctuary.building.ColoredBricks.families().getFirst().clay().asItem(),Items.CLAY)){
server.runOnServer(s->{var menu=s.getPlayerList().getPlayers().getFirst().containerMenu;menu.getSlot(0).set(new ItemStack(material,2));menu.broadcastChanges();});
var expected=c.computeOnClient(m->ClayWorkshopMenu.appearance(cow,material,SculptureMaterialSampler.sample(new ItemStack(material))));
check(material==Items.CLAY?expected.equals(cow):!expected.equals(cow),"Only plain clay retains original colors");
c.waitFor(m->expected.equals(StatuaryEntity.sculpture(m.player.containerMenu.getSlot(1).getItem())),180);
c.runOnClient(m->check(ItemStack.matches(((ClayWorkshopScreen)m.gui.screen()).preview(),m.player.containerMenu.getSlot(1).getItem()),"Preview matches authoritative material switch"));
}
c.takeScreenshot("clay126-original-cow");
c.runOnClient(m->m.gameMode.handleContainerInput(m.player.containerMenu.containerId,1,0,ContainerInput.PICKUP,m.player));
server.waitFor(s->{var menu=s.getPlayerList().getPlayers().getFirst().containerMenu;return cow.equals(StatuaryEntity.sculpture(menu.getCarried()))&&menu.getSlot(0).getItem().getCount()==1;},100);
c.waitFor(m->cow.equals(StatuaryEntity.sculpture(m.player.containerMenu.getCarried()))&&m.player.containerMenu.getSlot(0).getItem().getCount()==1,100);
Path file=PlanClient.directory().resolve("clay126-"+UUID.randomUUID()+".glb");
try{Files.createDirectories(file.getParent());Files.write(file,Statuary106ClientChecks.fixture());var id=GlbModel.load(file).id();c.runOnClient(m->((ClayWorkshopScreen)m.gui.screen()).load(file));
c.waitFor(m->{var screen=(ClayWorkshopScreen)m.gui.screen();return !screen.busy()&&screen.selected()!=null&&screen.selected().modelId().equals(id)&&screen.selected().equals(StatuaryEntity.sculpture(m.player.containerMenu.getSlot(1).getItem()));},250);
c.runOnClient(m->{var screen=(ClayWorkshopScreen)m.gui.screen();check(screen.selected().cells().stream().map(VoxelSculpture.Cell::color).distinct().count()>1,"Imported textured GLB retains its original colors");check(ItemStack.matches(screen.preview(),m.player.containerMenu.getSlot(1).getItem()),"Imported preview equals native result");});
}catch(Exception e){throw new AssertionError(e);}finally{try{Files.deleteIfExists(file);}catch(Exception ignored){}}
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();p.closeContainer();p.setGameMode(GameType.CREATIVE);p.getAbilities().flying=true;p.onUpdateAbilities();});c.waitFor(m->m.gui.screen()==null,100);
System.out.println("CLAY126_PASS original animal and GLB colors, oak and colored clay palette transfer, switch back, matching previews and one clay paid in survival");
}
private static void check(boolean ok,String message){if(!ok)throw new AssertionError(message);}
}
@@ -0,0 +1,47 @@
package fr.koka.sanctuary.client;
import fr.koka.sanctuary.companion.*;
import fr.koka.sanctuary.knowledge.*;
import fr.koka.sanctuary.plans.*;
import fr.koka.sanctuary.recipes.*;
import java.util.*;
import net.fabricmc.fabric.api.client.gametest.v1.context.*;
import net.minecraft.core.BlockPos;
import net.minecraft.core.registries.BuiltInRegistries;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.stats.Stats;
import net.minecraft.world.entity.EntityTypes;
import net.minecraft.world.item.*;
import net.minecraft.world.level.block.Blocks;
final class Discovery126Checks {
static void exercise(ClientGameTestContext c,TestServerContext server){
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();var dispatcher=s.getCommands().getDispatcher();
var source=p.createCommandSourceStack();var root=dispatcher.getRoot().getChild("sanctuary").getChild("discovery");
s.getWorldData().setAllowCommands(false);check(!root.canUse(source),"Discovery commands respect native Allow Commands");
try{dispatcher.execute("sanctuary discovery all",source);throw new AssertionError("Denied command executed");}catch(com.mojang.brigadier.exceptions.CommandSyntaxException expected){}
s.getWorldData().setAllowCommands(true);
var before=CompanionService.record(p);var items=new TreeSet<>(RecipeService.knowledge(p).items);int kills=p.getStats().getValue(Stats.ENTITY_KILLED,EntityTypes.WITHER);
command(p,"mob minecraft:wither");check(StatueDiscoveries.known(p).contains("minecraft:wither"),"Precise species unlocks sculpture catalogue");check(CompanionService.record(p).notes().get("seen_mobs").contains("wither"),"Same actual seen_mobs record");check(RecipeService.knowledge(p).items.equals(items),"Mob discovery invents no unrelated item discovery");check(p.getStats().getValue(Stats.ENTITY_KILLED,EntityTypes.WITHER)==kills,"No fake kill stat");
command(p,"block minecraft:pink_glazed_terracotta");var block=BlockKnowledgeService.knowledge(p,Blocks.GLAZED_TERRACOTTA.pick(DyeColor.PINK));check(block.seen()&&!block.possessed()&&block.mined()==0&&block.placed()==0,"Precise block is seen without fabricated possession or stats");check(RecipeService.knowledge(p).items.contains("minecraft:pink_glazed_terracotta")&&RecipeService.knowledge(p).collections.contains("sanctuary:terracotta"),"Block event unlocks its normal recipe collection");
command(p,"item minecraft:amethyst_shard");check(RecipeService.knowledge(p).items.contains("minecraft:amethyst_shard")&&RecipeService.knowledge(p).recipes.contains("minecraft:spyglass"),"Precise item unlocks linked recipes");
String old=CompanionService.record(p).json();check(run(p,"mob minecraft:item")==0,"Nonmob type refused");check(CompanionService.record(p).json().equals(old),"Rejected request does not change discoveries");
try{dispatcher.execute("sanctuary discovery item sanctuary:missing_item",source);throw new AssertionError("Missing ID accepted");}catch(com.mojang.brigadier.exceptions.CommandSyntaxException expected){}
command(p,"mobs all");allSpecies(p);var pos=new BlockPos(4,1,4);Statuary.open(p,pos);check(((ClayWorkshopMenu)p.containerMenu).view.statues().equals(StatueDiscoveries.known(p)),"Workshop sees the shared full catalogue");p.closeContainer();
command(p,"all");all(p);String species=CompanionService.record(p).json(),recipes=RecipeService.knowledge(p).json();var blocks=BlockKnowledgeService.snapshot(p);command(p,"all @s");check(species.equals(CompanionService.record(p).json())&&recipes.equals(RecipeService.knowledge(p).json())&&blocks.equals(BlockKnowledgeService.snapshot(p)),"Repeated all with target selector is idempotent");
check(CompanionService.record(p).paid()==before.paid()&&CompanionService.record(p).unlocked()==before.unlocked(),"Discoveries do not fabricate paid skills");
try{check(dispatcher.execute("sanctuary discovery mob minecraft:cow "+p.getGameProfile().name(),s.createCommandSourceStack())==1,"Console can address an online player");}catch(Exception e){throw new AssertionError(e);}
System.out.println("DISCOVERY126_PASS native permissions, precise block/item/species, linked recipes, shared catalogue, all three categories, all, selector, console, idempotence and unchanged combat/ownership counters");
});
c.waitFor(m->m.gui.screen()==null,100);
}
static void all(ServerPlayer p){
allSpecies(p);check(BlockKnowledgeService.snapshot(p).stream().allMatch(BlockKnowledge::known),"All block discoveries");
var knowledge=RecipeService.knowledge(p);for(var item:BuiltInRegistries.ITEM)if(item!=Items.AIR)check(knowledge.items.contains(BuiltInRegistries.ITEM.getKey(item).toString()),"All registered items discovered");
check(knowledge.recipes.contains("minecraft:spyglass")&&knowledge.recipes.contains("sanctuary:ruby_block"),"Vanilla and Sanctuary recipes are derived from discoveries");
}
static void allSpecies(ServerPlayer p){check(StatueDiscoveries.known(p).size()==BuiltInRegistries.ENTITY_TYPE.stream().filter(t->SpawnEggItem.byId(t).isPresent()).count(),"All registered species discovered");}
static void command(ServerPlayer p,String args){check(run(p,args)==1,"Discovery command succeeds: "+args);}
private static int run(ServerPlayer p,String args){try{return p.level().getServer().getCommands().getDispatcher().execute("sanctuary discovery "+args,p.createCommandSourceStack());}catch(Exception e){throw new AssertionError(e);}}
private static void check(boolean ok,String message){if(!ok)throw new AssertionError(message);}
}
@@ -0,0 +1,103 @@
package fr.koka.sanctuary.client;
import com.mojang.blaze3d.platform.InputConstants;
import fr.koka.sanctuary.gametest.Starter039TestSupport;
import fr.koka.sanctuary.multiblock.*;
import fr.koka.sanctuary.mixin.MultiblockFurnaceAccess;
import fr.koka.sanctuary.mixin.client.AbstractContainerScreenAccessor;
import java.util.*;
import java.util.concurrent.atomic.AtomicBoolean;
import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents;
import net.fabricmc.fabric.api.client.gametest.v1.*;
import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext;
import net.minecraft.client.gui.components.Button;
import net.minecraft.client.gui.screens.worldselection.WorldCreationUiState;
import net.minecraft.client.input.*;
import net.minecraft.core.*;
import net.minecraft.core.component.DataComponents;
import net.minecraft.network.chat.Component;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.inventory.ContainerInput;
import net.minecraft.world.item.*;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.block.entity.*;
/** Native quick-transfer, arbitrary slot placement, shared cooking and synchronized feedback. */
public final class Furnace115ClientChecks implements FabricClientGameTest {
private static final BlockPos ORIGIN=new BlockPos(2,1,2);
private static MultiblockData.Machine oven;
public void runTest(ClientGameTestContext c){
var intro=new AtomicBoolean(true);
c.runOnClient(m->{m.options.renderDistance().set(6);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());
screen.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getMessage().getContents() instanceof net.minecraft.network.chat.contents.TranslatableContents t&&t.getKey().equals("sanctuary.progression.enter")).findFirst().orElseThrow().onPress(enter());intro.set(false);
}});
try(var world=c.worldBuilder().adjustSettings(s->{s.setName("Furnace115 "+UUID.randomUUID());s.setSeed("115");s.setAllowCommands(true);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);var server=world.getServer();
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();p.getAbilities().invulnerable=true;p.onUpdateAbilities();p.getInventory().clearContent();p.teleportTo(1.5,1,1.5);
for(int x=-2;x<8;x++)for(int z=-2;z<8;z++)p.level().setBlockAndUpdate(new BlockPos(x,0,z),Blocks.STONE.defaultBlockState());
oven=new MultiblockData.Machine(UUID.randomUUID(),ORIGIN,true,Direction.NORTH);for(var pos:oven.members())p.level().setBlockAndUpdate(pos,Blocks.FURNACE.defaultBlockState());check(Multiblocks.assemble(p,oven),"Assemble");
for(int i=0;i<9;i++){var stack=new ItemStack(Items.RAW_IRON,8);stack.set(DataComponents.CUSTOM_NAME,Component.literal("Batch "+i));p.getInventory().setItem(i,stack);}Multiblocks.open(p,oven,ORIGIN,0,"",0);
});
c.waitFor(m->m.gui.screen() instanceof MultiblockScreen&&m.player.containerMenu.getSlot(54).hasItem(),200);
// One native Shift-drag crosses nine hotbar stacks, with no mouse release between them.
c.getInput().holdKey(o->o.keyShift);move(c,54);c.runOnClient(m->m.mouseHandler.onButton(m.getWindow().handle(),new MouseButtonInfo(InputConstants.MOUSE_BUTTON_LEFT,InputConstants.MOD_SHIFT),1));
for(int i=55;i<63;i++)move(c,i);
c.runOnClient(m->m.mouseHandler.onButton(m.getWindow().handle(),new MouseButtonInfo(InputConstants.MOUSE_BUTTON_LEFT,InputConstants.MOD_SHIFT),0));c.getInput().releaseKey(o->o.keyShift);
c.waitTicks(8);c.takeScreenshot("furnace115-inputs");
c.waitFor(m->m.player.containerMenu.getSlot(8).hasItem()&&!m.player.containerMenu.getSlot(62).hasItem(),150);
c.runOnClient(m->{for(int i=0;i<9;i++)check(m.player.containerMenu.getSlot(i).getItem().getHoverName().getString().equals("Batch "+i),"Shift-drag fills consecutive UI slots, no three-column gaps");});
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();var stock=new MultiblockContainer(p.level(),oven);for(int i=0;i<9;i++)check(stock.getItem(i*3).getCount()==8,"Physical inputs correspond to visible slots");});
// Move an ingredient to a distant, arbitrary slot using real inventory packets.
click(c,1,0,ContainerInput.PICKUP);click(c,23,0,ContainerInput.PICKUP);c.waitTicks(10);
c.runOnClient(m->{check(m.player.containerMenu.getSlot(1).getItem().isEmpty()&&m.player.containerMenu.getSlot(23).getItem().getCount()==8,"Manual placement remains in chosen cell: "+m.player.containerMenu.slots.stream().filter(slot->slot.hasItem()).map(slot->slot.index+":"+slot.getItem()).toList()+", cursor="+m.player.containerMenu.getCarried());});
click(c,23,0,ContainerInput.PICKUP);
click(c,-999,net.minecraft.world.inventory.AbstractContainerMenu.getQuickcraftMask(0,0),ContainerInput.QUICK_CRAFT);
for(int i:new int[]{1,9,10,13,14,22})click(c,i,net.minecraft.world.inventory.AbstractContainerMenu.getQuickcraftMask(1,0),ContainerInput.QUICK_CRAFT);
click(c,-999,net.minecraft.world.inventory.AbstractContainerMenu.getQuickcraftMask(2,0),ContainerInput.QUICK_CRAFT);click(c,23,0,ContainerInput.PICKUP);c.waitTicks(6);
c.runOnClient(m->{for(int i:new int[]{1,9,10,13,14,22})check(m.player.containerMenu.getSlot(i).getItem().getCount()==1,"Native drag distribution keeps every chosen column");check(m.player.containerMenu.getSlot(23).getItem().getCount()==2&&m.player.containerMenu.getCarried().isEmpty(),"Distributed count conserved");});
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();var stock=new MultiblockContainer(p.level(),oven);check(stock.getItem(69).getCount()==2&&stock.getItem(3).getCount()==1,"Manual distribution matches server");for(int i=0;i<27;i++)check(p.containerMenu.getSlot(i).mayPlace(new ItemStack(Items.RAW_IRON)),"Every visible input validates its physical input slot, not Slot.index");reset(stock);stock.setItem(0,new ItemStack(Items.RAW_IRON,4));stock.setItem(18,new ItemStack(Items.RAW_GOLD,4));
var coal=new ItemStack(Items.COAL,5);coal.set(DataComponents.CUSTOM_NAME,Component.literal("Stationary fuel"));stock.setItem(79,coal);
for(int tick=0;tick<205;tick++)MultiblockCooking.tick(p.level(),oven);
check(stock.getItem(79).getCount()==4&&stock.getItem(79).getHoverName().getString().equals("Stationary fuel"),"Named unburnt fuel stays in original cell");
for(int i=1;i<79;i+=3)check(stock.getItem(i).isEmpty(),"No displaced coal in other slots");
check(stock.getItem(2).is(Items.IRON_INGOT)&&stock.getItem(20).is(Items.GOLD_INGOT),"Shared heat cooks both arbitrary lanes");
Multiblocks.open(p,oven,ORIGIN,0,"",0);
});
c.waitTicks(6);c.runOnClient(m->{var menu=(MultiblockMenu)m.player.containerMenu;check(menu.cooking==2&&menu.heat>0&&menu.laneState(6)==MultiblockMenu.COOKING,"Real per-lane feedback synchronizes");});c.takeScreenshot("furnace115-cooking");
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();var stock=new MultiblockContainer(p.level(),oven);reset(stock);
stock.setItem(0,new ItemStack(Items.RAW_IRON));stock.setItem(3,new ItemStack(Items.DIAMOND));stock.setItem(6,new ItemStack(Items.RAW_GOLD));stock.setItem(8,new ItemStack(Items.GOLD_INGOT,64));
var a=(MultiblockFurnaceAccess)stock.cell(0);a.sanctuary$progress(80);a.sanctuary$total(200);p.containerMenu.broadcastChanges();
});c.waitTicks(6);
c.runOnClient(m->{var menu=(MultiblockMenu)m.player.containerMenu;check(menu.laneState(0)==MultiblockMenu.NO_FUEL&&menu.laneProgress(0)==40,"Paused progress is visible and preserved");check(menu.laneState(1)==MultiblockMenu.NO_RECIPE,"Invalid recipe explained");check(menu.laneState(2)==MultiblockMenu.BLOCKED,"Blocked output explained");check(menu.cooking==0&&menu.heat==0,"Cold furnace clearly shown");});c.takeScreenshot("furnace115-paused");
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();cookingRegressions(p);var stock=new MultiblockContainer(p.level(),oven);reset(stock);
for(int i=0;i<9;i++){var stack=new ItemStack(Items.COAL,5);stack.set(DataComponents.CUSTOM_NAME,Component.literal("Fuel "+i));p.getInventory().setItem(i,stack);}Multiblocks.open(p,oven,ORIGIN,0,"",1);
});c.waitFor(m->m.player.containerMenu instanceof MultiblockMenu menu&&menu.view.section()==1&&menu.getSlot(54).hasItem(),100);
// No cooking in this fixture: compare the predicted UI in the same call, before any server reply.
c.runOnClient(m->{var menu=m.player.containerMenu;for(int i=0;i<9;i++){
m.gameMode.handleContainerInput(menu.containerId,54+i,0,ContainerInput.QUICK_MOVE,m.player);
check(menu.getSlot(54+i).getItem().isEmpty()&&menu.getSlot(i).getItem().getCount()==5,"Immediate Shift prediction uses consecutive fuel cells");}
check(!menu.getSlot(9).mayPlace(new ItemStack(Items.DIAMOND)),"Client rejects invalid fuel like server");});
c.waitTicks(8);server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();var stock=new MultiblockContainer(p.level(),oven);for(int i=0;i<9;i++)check(stock.getItem(i*3+1).getCount()==5,"Fuel server mapping matches prediction");for(int i=0;i<27;i++){check(p.containerMenu.getSlot(i).mayPlace(new ItemStack(Items.COAL)),"All 27 fuel cells accept fuel");check(!p.containerMenu.getSlot(i).mayPlace(new ItemStack(Items.DIAMOND)),"All 27 fuel cells refuse other items");}});c.takeScreenshot("furnace115-fuel");
c.runOnClient(m->{var screen=(MultiblockScreen)m.gui.screen();screen.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getMessage().getString().equals(Multiblocks.text("how_to_cook").getString())).findFirst().orElseThrow().onPress(enter());check(screen.helpVisible(),"Help button opens its explanation");});c.waitTicks(5);c.takeScreenshot("furnace115-help");
var reload=c.computeOnClient(m->{m.getLanguageManager().setSelected("fr_fr");return m.reloadResourcePacks();});c.waitFor(m->reload.isDone()&&m.gui.overlay()==null,1200);reload.join();c.waitTicks(3);c.takeScreenshot("furnace115-help-fr");
System.out.println("FURNACE115_PASS native Shift-drag, consecutive input/fuel mapping, immediate prediction, arbitrary placement, stationary named fuel, heat/output/pause/recipe feedback, fuel budget, refuelling, buckets, save and result extraction");
}finally{intro.set(false);}
}
private static void cookingRegressions(ServerPlayer p){
var stock=new MultiblockContainer(p.level(),oven);reset(stock);stock.setItem(0,new ItemStack(Items.WET_SPONGE));stock.setItem(79,new ItemStack(Items.LAVA_BUCKET));for(int t=0;t<205;t++)MultiblockCooking.tick(p.level(),oven);
check(stock.getItem(2).is(Items.SPONGE)&&stock.getItem(1).is(Items.WATER_BUCKET)&&stock.getItem(79).isEmpty(),"Native lava and wet sponge remainder");
reset(stock);for(int i=0;i<81;i+=3)stock.setItem(i,new ItemStack(Items.RAW_IRON));stock.setItem(79,new ItemStack(Items.COAL));for(int t=0;t<90;t++)MultiblockCooking.tick(p.level(),oven);
int partial=((MultiblockFurnaceAccess)stock.cell(0)).sanctuary$progress();check(partial>0&&partial<200,"One coal cannot cook all 27 items");for(int t=0;t<30;t++)MultiblockCooking.tick(p.level(),oven);check(((MultiblockFurnaceAccess)stock.cell(0)).sanctuary$progress()==partial,"No lost paid progress when out of fuel");
stock.setItem(79,new ItemStack(Items.COAL,4));for(int t=0;t<205;t++)MultiblockCooking.tick(p.level(),oven);int produced=0;for(int i=2;i<81;i+=3)produced+=stock.getItem(i).getCount();check(produced==27,"Refuelling completes all lanes exactly once");
var f=(AbstractFurnaceBlockEntity)stock.cell(0);var restored=(AbstractFurnaceBlockEntity)BlockEntity.loadStatic(f.getBlockPos(),f.getBlockState(),f.saveWithFullMetadata(p.level().registryAccess()),p.level().registryAccess());check(((MultiblockFurnaceAccess)restored).sanctuary$heat()==((MultiblockFurnaceAccess)f).sanctuary$heat(),"Native heat survives save");
Multiblocks.open(p,oven,ORIGIN,0,"",2);var menu=(MultiblockMenu)p.containerMenu;check(!menu.getSlot(0).mayPlace(new ItemStack(Items.STONE)),"Results refuse input");check(!menu.quickMoveStack(p,0).isEmpty()&&stock.getItem(2).isEmpty(),"Native result slot can extract");
}
private static void reset(MultiblockContainer stock){stock.clearContent();for(int i=0;i<81;i+=3){var a=(MultiblockFurnaceAccess)stock.cell(i);a.sanctuary$heat(0);a.sanctuary$progress(0);}}
private static void click(ClientGameTestContext c,int slot,int button,ContainerInput type){c.runOnClient(m->m.gameMode.handleContainerInput(m.player.containerMenu.containerId,slot,button,type,m.player));}
private static void move(ClientGameTestContext c,int index){c.runOnClient(m->{var a=(AbstractContainerScreenAccessor)m.gui.screen();var slot=m.player.containerMenu.getSlot(index);var w=m.getWindow();double x=a.sanctuary$getLeftPos()+slot.x+8,y=a.sanctuary$getTopPos()+slot.y+8;double sx=(double)w.getScreenWidth()/w.getGuiScaledWidth(),sy=(double)w.getScreenHeight()/w.getGuiScaledHeight();m.mouseHandler.onMove(w.handle(),x*sx,y*sy,(x-m.mouseHandler.getScaledXPos(w))*sx,(y-m.mouseHandler.getScaledYPos(w))*sy);m.mouseHandler.handleAccumulatedMovement();});c.waitTick();}
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);}
}
@@ -0,0 +1,88 @@
package fr.koka.sanctuary.client;
import com.mojang.blaze3d.platform.InputConstants;
import fr.koka.sanctuary.SanctuaryMod;
import fr.koka.sanctuary.gems.*;
import fr.koka.sanctuary.gametest.Starter039TestSupport;
import java.util.*;
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.Button;
import net.minecraft.client.gui.screens.worldselection.WorldCreationUiState;
import net.minecraft.client.input.KeyEvent;
import net.minecraft.core.*;
import net.minecraft.core.registries.*;
import net.minecraft.network.chat.contents.TranslatableContents;
import net.minecraft.resources.ResourceKey;
import net.minecraft.tags.*;
import net.minecraft.util.RandomSource;
import net.minecraft.world.item.*;
import net.minecraft.world.item.crafting.*;
import net.minecraft.world.item.enchantment.Enchantments;
import net.minecraft.world.level.block.*;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.levelgen.GenerationStep;
/** Actual native registration, recipes, drops, tab order and naturally decorated Sanctuary chunks. */
public final class Gems126ClientChecks implements FabricClientGameTest {
public void runTest(ClientGameTestContext c){
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){
IntroClient.setQuickTestIntro(true);Starter039TestSupport.selectFirst(screen);var enter=new KeyEvent(InputConstants.KEY_RETURN,0,0);
screen.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getClass().getSimpleName().equals("ColorButton")).findFirst().orElseThrow().onPress(enter);
screen.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getMessage().getContents() instanceof TranslatableContents t&&t.getKey().equals("sanctuary.progression.enter")).findFirst().orElseThrow().onPress(enter);intro.set(false);
}});
try(var world=c.worldBuilder().adjustSettings(s->{s.setName("Gems126 registry "+UUID.randomUUID());s.setSeed("126");s.setAllowCommands(true);s.setGameMode(WorldCreationUiState.SelectedGameMode.CREATIVE);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->{
var p=s.getPlayerList().getPlayers().getFirst();var l=p.level();var pos=new BlockPos(0,4,0);var iron=new ItemStack(Items.IRON_PICKAXE);var silk=iron.copy();silk.enchant(l.registryAccess().lookupOrThrow(Registries.ENCHANTMENT).getOrThrow(Enchantments.SILK_TOUCH),1);var fortune=iron.copy();fortune.enchant(l.registryAccess().lookupOrThrow(Registries.ENCHANTMENT).getOrThrow(Enchantments.FORTUNE),3);
for(var f:Gems.FAMILIES){
for(var b:List.of(f.block(),f.ore(),f.deepslateOre())){var state=b.defaultBlockState();check(state.is(BlockTags.MINEABLE_WITH_PICKAXE)&&state.is(BlockTags.NEEDS_IRON_TOOL),"Native mining tags "+b);check(iron.isCorrectToolForDrops(state)&&!new ItemStack(Items.STONE_PICKAXE).isCorrectToolForDrops(state),"Iron tier enforced "+b);}
drop(Block.getDrops(f.block().defaultBlockState(),l,pos,null,p,iron),f.block().asItem(),1);
for(var ore:List.of(f.ore(),f.deepslateOre())){
drop(Block.getDrops(ore.defaultBlockState(),l,pos,null,p,iron),f.gem(),1);drop(Block.getDrops(ore.defaultBlockState(),l,pos,null,p,silk),ore.asItem(),1);
int max=0;for(int i=0;i<48;i++){var drops=Block.getDrops(ore.defaultBlockState(),l,pos,null,p,fortune);check(drops.size()==1&&drops.getFirst().is(f.gem()),"Fortune gem type");int n=drops.getFirst().getCount();check(n>=1&&n<=4,"Native Fortune III range");max=Math.max(max,n);}check(max>1,"Fortune increases gem yield");
var input=new SingleRecipeInput(new ItemStack(ore));for(var type:List.of(RecipeType.SMELTING,RecipeType.BLASTING)){var recipe=s.getRecipeManager().getRecipeFor(type,input,l).orElseThrow().value();check(recipe.assemble(input).is(f.gem()),"Native furnace recipe");}
}
var input=CraftingInput.of(3,3,Collections.nCopies(9,new ItemStack(f.gem())));drop(List.of(s.getRecipeManager().getRecipeFor(RecipeType.CRAFTING,input,l).orElseThrow().value().assemble(input)),f.block().asItem(),1);
input=CraftingInput.of(1,1,List.of(new ItemStack(f.block())));drop(List.of(s.getRecipeManager().getRecipeFor(RecipeType.CRAFTING,input,l).orElseThrow().value().assemble(input)),f.gem(),9);
check(f.block().defaultBlockState().is(BlockTags.BEACON_BASE_BLOCKS)&&new ItemStack(f.gem()).is(ItemTags.BEACON_PAYMENT_ITEMS),"Native beacon compatibility");
var feature=l.registryAccess().lookupOrThrow(Registries.FEATURE).getOrThrow(ResourceKey.create(Registries.FEATURE,SanctuaryMod.id("ore_"+f.id()))).value();check(feature instanceof GemOreFeature,"Data feature decoded");
check(!feature.place(l,l.getChunkSource().getGenerator(),RandomSource.create(126),pos),"Gem veins do not run in ordinary flat/vanilla worlds");
fr.koka.sanctuary.recipes.RecipeService.discover(p,f.gem());fr.koka.sanctuary.recipes.RecipeService.refresh(p);check(fr.koka.sanctuary.recipes.RecipeService.knowledge(p).recipes.contains("sanctuary:"+f.id()+"_block"),"Native discovery unlocks storage recipe");
}
int x=0;for(var block:List.of(Blocks.EMERALD_BLOCK,Gems.RUBY.block(),Gems.SAPPHIRE.block(),Blocks.EMERALD_ORE,Gems.RUBY.ore(),Gems.SAPPHIRE.ore(),Blocks.DEEPSLATE_EMERALD_ORE,Gems.RUBY.deepslateOre(),Gems.SAPPHIRE.deepslateOre())){l.setBlockAndUpdate(new BlockPos(x%3,1,x/3),block.defaultBlockState());x++;}
p.teleportTo(1.5,3,7);p.getAbilities().flying=true;p.onUpdateAbilities();p.getInventory().setItem(0,new ItemStack(Items.EMERALD));p.getInventory().setItem(1,new ItemStack(Gems.RUBY.gem()));p.getInventory().setItem(2,new ItemStack(Gems.SAPPHIRE.gem()));p.inventoryMenu.broadcastFullState();
System.out.println("GEMS126_SERVER_PASS eight registered items, six blocks, native loot/Fortune/Silk Touch, twelve recipes, mining tiers, beacons, discoveries and vanilla generation exclusion");
});
c.runOnClient(m->{CreativeModeTabs.tryRebuildTabContents(m.level.enabledFeatures(),true,m.level.registryAccess());order(CreativeModeTabs.INGREDIENTS,Items.EMERALD,Gems.RUBY.gem(),Gems.SAPPHIRE.gem());order(CreativeModeTabs.BUILDING_BLOCKS,Items.EMERALD_BLOCK,Gems.RUBY.block().asItem(),Gems.SAPPHIRE.block().asItem());order(CreativeModeTabs.NATURAL_BLOCKS,Items.EMERALD_ORE,Gems.RUBY.ore().asItem(),Gems.SAPPHIRE.ore().asItem());order(CreativeModeTabs.NATURAL_BLOCKS,Items.DEEPSLATE_EMERALD_ORE,Gems.RUBY.deepslateOre().asItem(),Gems.SAPPHIRE.deepslateOre().asItem());
for(var f:Gems.FAMILIES)for(var block:List.of(f.block(),f.ore(),f.deepslateOre())){var parts=new ArrayList<net.minecraft.client.renderer.block.dispatch.BlockStateModelPart>();m.getModelManager().getBlockStateModelSet().get(block.defaultBlockState()).collectParts(RandomSource.create(0),parts);check(!parts.isEmpty(),"Baked gem block model");for(var part:parts)for(var face:Direction.values())for(var quad:part.getQuads(face))check(!quad.materialInfo().sprite().contents().name().toString().contains("missingno"),"Native texture atlas resolves supplied gem PNG");}
System.out.println("GEMS126_CREATIVE_PASS emerald, ruby, sapphire adjacency in four groups; all six baked block models");
});
c.waitTicks(10);c.runOnClient(m->m.player.lookAt(net.minecraft.commands.arguments.EntityAnchorArgument.Anchor.EYES,new net.minecraft.world.phys.Vec3(1.5,1.5,1.5)));c.waitTicks(4);c.takeScreenshot("gems126-gallery");
Clay126Checks.exercise(c,world.getServer());
Discovery126Checks.exercise(c,world.getServer());
}
intro.set(true);net.fabricmc.fabric.api.client.gametest.v1.world.TestWorldSave saved;var found=new HashMap<BlockPos,BlockState>();
try(var world=c.worldBuilder().adjustSettings(s->{s.setName("Gems126 natural "+UUID.randomUUID());s.setSeed("126");s.setAllowCommands(true);s.setGenerateStructures(false);s.setGameMode(WorldCreationUiState.SelectedGameMode.CREATIVE);s.setWorldType(s.getNormalPresetList().stream().filter(e->e.preset()!=null&&e.preset().getRegisteredName().equals("sanctuary:sanctuary")).findFirst().orElseThrow());}).create()){
c.waitFor(m->m.player!=null&&m.gui.screen()==null&&ProgressionClient.active()&&!IntroSpawnFlash.active(),2400);
world.getServer().runOnServer(s->{var l=s.overworld();check(l.getChunkSource().getGenerator() instanceof fr.koka.sanctuary.worldgen.SanctuaryChunkGenerator,"Actual Sanctuary generator");
for(int cx=-4;cx<=4;cx++)for(int cz=-4;cz<=4;cz++){var chunk=l.getChunk(cx,cz);var cursor=new BlockPos.MutableBlockPos();for(int x=0;x<16;x++)for(int z=0;z<16;z++)for(int y=0;y<=256;y++){cursor.set(cx*16+x,y,cz*16+z);var state=chunk.getBlockState(cursor);if(state.is(Gems.RUBY.ore())||state.is(Gems.RUBY.deepslateOre())||state.is(Gems.SAPPHIRE.ore())||state.is(Gems.SAPPHIRE.deepslateOre()))found.put(cursor.immutable(),state);}}
for(var f:Gems.FAMILIES){long ordinary=found.values().stream().filter(b->b.is(f.ore())).count(),deep=found.values().stream().filter(b->b.is(f.deepslateOre())).count();System.out.println("GEMS126_NATURAL "+f.id()+" stone="+ordinary+" deepslate="+deep+" seed=126 chunks=81");check(ordinary+deep>0,"Both gems generate naturally without structure placement: "+f.id());}
var biome=l.getBiome(found.keySet().iterator().next()).value();var features=biome.getGenerationSettings().features().get(GenerationStep.Decoration.UNDERGROUND_ORES.ordinal());for(var f:Gems.FAMILIES)check(features.stream().anyMatch(h->h.is(ResourceKey.create(Registries.PLACED_FEATURE,SanctuaryMod.id("ore_"+f.id())))),"Native biome includes configurable gem placement");
Discovery126Checks.command(s.getPlayerList().getPlayers().getFirst(),"all");
var mined=found.keySet().iterator().next();l.setBlockAndUpdate(mined,Blocks.AIR.defaultBlockState());found.put(mined,Blocks.AIR.defaultBlockState());
});saved=world.getWorldSave();
}
try(var world=saved.open()){
c.waitFor(m->m.player!=null&&m.gui.screen()==null,2400);world.getServer().runOnServer(s->{Discovery126Checks.all(s.getPlayerList().getPlayers().getFirst());for(var e:found.entrySet())check(s.overworld().getBlockState(e.getKey())==e.getValue(),"Generated ores persist on reload without repopulation");});
}
System.out.println("GEMS126_PASS registry, recipes, drops, creative order, native models, natural Sanctuary generation seed 126 and save/reconnect");
}
private static void order(ResourceKey<CreativeModeTab> tab,Item anchor,Item ruby,Item sapphire){var items=new ArrayList<>(BuiltInRegistries.CREATIVE_MODE_TAB.getOrThrow(tab).value().getDisplayItems());int i=0;while(i<items.size()&&!items.get(i).is(anchor))i++;check(i+2<items.size()&&items.get(i+1).is(ruby)&&items.get(i+2).is(sapphire),"Emerald/ruby/sapphire order in "+tab);check(items.stream().filter(v->v.is(ruby)||v.is(sapphire)).count()==2,"No duplicate creative entries");}
private static void drop(List<ItemStack> drops,Item item,int n){check(drops.size()==1&&drops.getFirst().is(item)&&drops.getFirst().getCount()==n,"Native loot/craft expected "+n+" "+item+", got "+drops);}
private 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);}
@@ -0,0 +1,165 @@
package fr.koka.sanctuary.client;
import com.mojang.blaze3d.platform.InputConstants;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.MessageDigest;
import java.util.Arrays;
import java.util.HexFormat;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import net.fabricmc.fabric.api.client.gametest.v1.FabricClientGameTest;
import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext;
import net.fabricmc.loader.api.FabricLoader;
import net.minecraft.client.Minecraft;
import net.minecraft.client.gui.GuiGraphicsExtractor;
import net.minecraft.client.gui.components.Button;
import net.minecraft.client.gui.screens.Screen;
import net.minecraft.client.gui.screens.packs.PackSelectionScreen;
import net.minecraft.client.input.KeyEvent;
import net.minecraft.client.renderer.RenderPipelines;
import net.minecraft.network.chat.Component;
import net.minecraft.network.chat.contents.TranslatableContents;
import net.minecraft.resources.Identifier;
import net.minecraft.server.packs.repository.PackCompatibility;
/** Native screen/export, complete snapshot, real resource precedence and preservation of edits. */
public final class ResourceTemplate121ClientChecks implements FabricClientGameTest {
private static final String BUILTIN = "sanctuary:textures";
private static final String PERSONAL = "file/" + ResourcePackTemplate.FOLDER;
private static final String WRENCH = "sanctuary:textures/item/golden_wrench.png";
private static final String WRENCH_PATH = "assets/sanctuary/textures/item/golden_wrench.png";
private static final String WRENCH_SHA = "a97570908db75caf8e6fc2f5ecabf12c54fd99bfea7be634df66c4310dca22af";
public void runTest(ClientGameTestContext c) {
try { run(c); } catch (IOException error) { throw new AssertionError(error); }
}
private void run(ClientGameTestContext c) throws IOException {
var previous = c.computeOnClient(m -> m.gui.screen());
var packDirectory = c.computeOnClient(Minecraft::getResourcePackDirectory);
var selected = c.computeOnClient(m -> List.copyOf(m.getResourcePackRepository().getSelectedIds()));
var target = packDirectory.resolve(ResourcePackTemplate.FOLDER);
check(!Files.exists(target), "Fresh disposable client directory");
c.runOnClient(m -> {
check(source(m).equals(BUILTIN), "Built-in pack active");
check(hash(resource(m, WRENCH)).equals(WRENCH_SHA), "Exact creator PNG active");
m.getWindow().setWindowed(1280, 800);
});
for (int scale : new int[]{2, 3}) for (String language : List.of("fr_fr", "en_us")) {
c.runOnClient(m -> {
m.options.guiScale().set(scale); m.resizeGui();
m.getLanguageManager().setSelected(language);
m.getLanguageManager().onResourceManagerReload(m.getResourceManager());
screen(m, packDirectory);
var button = button(m, "customize");
check(button.getX() >= 0 && button.getRight() <= m.gui.screen().width, "Template button fits width");
check(button.getY() > 60 && button.getBottom() < m.gui.screen().height, "Template button fits height");
var done = m.gui.screen().children().stream().filter(Button.class::isInstance).map(Button.class::cast)
.filter(b -> key(b, "gui.done")).findFirst().orElseThrow();
check(done.getY() >= button.getBottom() + 4, "Separate native footer row");
check(m.font.width(button.getMessage()) < button.getWidth() - 8, "Localized label fits");
});
c.waitTicks(3); c.takeScreenshot("template121-create-" + language + "-" + scale);
}
c.runOnClient(m -> button(m, "customize").onPress(new KeyEvent(InputConstants.KEY_RETURN, 0, 0)));
c.waitFor(m -> m.gui.screen().children().stream().filter(Button.class::isInstance).map(Button.class::cast)
.anyMatch(b -> b.active && key(b, "sanctuary.resource_pack.open_copy")), 1200);
c.waitFor(m -> m.getResourcePackRepository().getAvailableIds().contains(PERSONAL), 1200);
c.runOnClient(m -> {
check(List.copyOf(m.getResourcePackRepository().getSelectedIds()).equals(selected), "Creating never changes selection");
check(m.getResourcePackRepository().getPack(PERSONAL).getCompatibility() == PackCompatibility.COMPATIBLE,
"Template format accepted by exact Minecraft version");
});
var mod = FabricLoader.getInstance().getModContainer("sanctuary").orElseThrow();
Map<String, String> expected = new TreeMap<>();
merge(expected, mod.findPath("assets").orElseThrow(), "assets/");
merge(expected, mod.findPath("resourcepacks/textures/assets").orElseThrow(), "assets/");
merge(expected, mod.findPath("resourcepacks/template").orElseThrow(), "");
expected.put("pack.png", hash(Files.readAllBytes(mod.findPath("resourcepacks/textures/pack.png").orElseThrow())));
check(snapshot(target).equals(expected), "Complete byte-identical snapshot with built-in precedence");
Files.writeString(packDirectory.getParent().resolve("template121-export-sha256.json"),
new com.google.gson.GsonBuilder().setPrettyPrinting().create().toJson(expected));
check(expected.containsKey("README.md") && expected.containsKey("LICENSE_sanctuary")
&& expected.containsKey("THIRD_PARTY_NOTICES.md"), "Editable copy includes guide and credits");
check(expected.keySet().stream().noneMatch(p -> p.startsWith("data/") || p.endsWith(".class")), "Client assets only");
c.waitTicks(4); c.takeScreenshot("template121-copy-ready");
c.runOnClient(m -> m.gui.setScreen(new Screen(Component.literal("Golden wrench")) {
@Override public void extractRenderState(GuiGraphicsExtractor g, int x, int y, float delta) {
g.fill(0, 0, width, height, 0xff292929);
g.centeredText(font, Component.translatable("item.sanctuary.golden_wrench"), width / 2, 55, 0xffffffff);
g.blit(RenderPipelines.GUI_TEXTURED, Identifier.parse(WRENCH), width / 2 - 48, 85, 0, 0, 96, 96, 16, 16, 16, 16);
}
}));
c.waitTicks(4); c.takeScreenshot("template121-wrench-exact-texture");
byte[] replacement = c.computeOnClient(m -> resource(m, "minecraft:textures/item/golden_pickaxe.png"));
Files.write(target.resolve(WRENCH_PATH), replacement);
Files.writeString(target.resolve("my-notes.txt"), "Personal edits must survive all subsequent exports.");
var edited = snapshot(target);
check(ResourcePackTemplate.create(packDirectory).equals(target.toAbsolutePath().normalize()), "Existing copy returned");
check(snapshot(target).equals(edited), "Second export preserves every edited byte and extra file");
c.runOnClient(m -> {
var repo = m.getResourcePackRepository(); repo.addPack(PERSONAL); m.options.updateResourcePacks(repo);
});
c.waitFor(m -> source(m).equals(PERSONAL) && m.gui.overlay() == null, 1200);
c.runOnClient(m -> {
check(Arrays.equals(resource(m, WRENCH), replacement), "Personal edit overrides built-in texture");
var repo = m.getResourcePackRepository(); repo.reload(); m.options.loadSelectedResourcePacks(repo);
check(repo.getSelectedIds().contains(PERSONAL), "Personal activation persists after rediscovery");
screen(m, packDirectory); button(m, "open_copy");
});
c.waitTicks(3); c.takeScreenshot("template121-personal-active");
var reload = c.computeOnClient(Minecraft::reloadResourcePacks);
c.waitFor(m -> reload.isDone() && m.gui.overlay() == null, 1200); reload.join();
check(snapshot(target).equals(edited), "Reload never writes personal assets");
c.runOnClient(m -> {
check(Arrays.equals(resource(m, WRENCH), replacement), "Reload still uses personal PNG");
var repo = m.getResourcePackRepository(); repo.removePack(PERSONAL); m.options.updateResourcePacks(repo);
});
c.waitFor(m -> source(m).equals(BUILTIN) && m.gui.overlay() == null, 1200);
c.runOnClient(m -> {
check(hash(resource(m, WRENCH)).equals(WRENCH_SHA), "Disabling restores creator texture");
var repo = m.getResourcePackRepository(); repo.reload(); m.options.loadSelectedResourcePacks(repo);
check(!repo.getSelectedIds().contains(PERSONAL), "Explicit opt-out retained");
});
Path collision = Files.createTempDirectory(packDirectory.getParent(), "template121-collision-");
Files.writeString(collision.resolve(ResourcePackTemplate.FOLDER), "Do not replace this file");
try { ResourcePackTemplate.create(collision); throw new AssertionError("Existing file must be refused"); }
catch (IOException expectedError) { check(Files.readString(collision.resolve(ResourcePackTemplate.FOLDER)).equals("Do not replace this file"), "Collision leaves file intact"); }
Path datapacks = Files.createTempDirectory(packDirectory.getParent(), "template121-datapacks-");
c.runOnClient(m -> {
screen(m, datapacks);
check(m.gui.screen().children().stream().filter(Button.class::isInstance).map(Button.class::cast)
.noneMatch(b -> key(b, "sanctuary.resource_pack.customize") || key(b, "sanctuary.resource_pack.open_copy")), "No button in datapack selector");
m.gui.setScreen(previous);
});
System.out.println("TEMPLATE121_PASS: " + expected.size() + " exact files; native button FR/EN at scales 2/3; complete copy; edits and selection preserved; actual override, reload and opt-out; collision safe; datapacks untouched");
}
private static void screen(Minecraft m, Path directory) {
m.gui.setScreen(new PackSelectionScreen(m.getResourcePackRepository(), repo -> {}, directory, Component.translatable("resourcePack.title")));
}
private static Button button(Minecraft m, String suffix) {
return m.gui.screen().children().stream().filter(Button.class::isInstance).map(Button.class::cast)
.filter(b -> key(b, "sanctuary.resource_pack." + suffix)).findFirst().orElseThrow();
}
private static boolean key(Button b, String key) { return b.getMessage().getContents() instanceof TranslatableContents t && t.getKey().equals(key); }
private static String source(Minecraft m) { return m.getResourceManager().getResource(Identifier.parse(WRENCH)).orElseThrow().sourcePackId(); }
private static byte[] resource(Minecraft m, String name) {
try (var in = m.getResourceManager().getResource(Identifier.parse(name)).orElseThrow().open()) { return in.readAllBytes(); }
catch (IOException error) { throw new AssertionError(error); }
}
private static String hash(byte[] bytes) {
try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(bytes)); }
catch (java.security.NoSuchAlgorithmException error) { throw new AssertionError(error); }
}
private static Map<String, String> snapshot(Path root) throws IOException { Map<String, String> result = new TreeMap<>(); merge(result, root, ""); return result; }
private static void merge(Map<String, String> result, Path root, String prefix) throws IOException {
try (var files = Files.walk(root)) { for (Path file : files.filter(Files::isRegularFile).toList()) result.put(prefix + root.relativize(file).toString().replace('\\', '/'), hash(Files.readAllBytes(file))); }
}
private static void check(boolean value, String message) { if (!value) throw new AssertionError(message); }
}
@@ -0,0 +1,105 @@
package fr.koka.sanctuary.client;
import fr.koka.sanctuary.plans.*;
import java.util.*;
import net.minecraft.client.Minecraft;
import net.minecraft.core.*;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.context.BlockPlaceContext;
import net.minecraft.world.level.block.*;
import net.minecraft.world.level.storage.TagValueInput;
import net.minecraft.util.ProblemReporter;
import net.minecraft.world.phys.*;
/** Checks emitted render geometry, not a duplicate of the placement implementation. */
final class Sculpture113ClientChecks {
static void placementContexts(ServerPlayer player){
for(var pos:List.of(new BlockPos(2,0,-2),new BlockPos(-2,0,2))){
for(var face:List.of(Direction.UP,Direction.DOWN)){
for(double x:new double[]{.25,.5,.75})for(double z:new double[]{.25,.5,.75}){
var hit=new Vec3(pos.getX()+x,pos.getY()+(face==Direction.UP?1:0),pos.getZ()+z);
var context=new BlockPlaceContext(player,InteractionHand.MAIN_HAND,new ItemStack(Statuary.ITEM),new BlockHitResult(hit,face,pos,false));
check(Statuary.BLOCK.getStateForPlacement(context).getValue(Statuary.ANCHOR)==SculptureAnchor.CENTER,"Middle square including boundaries selects center on top/bottom faces");
}
}
for(var edge:Direction.Plane.HORIZONTAL){
for(double offset:new double[]{.25-1e-6,.25+1e-6}){
var hit=new Vec3(pos.getX()+.5+edge.getStepX()*offset,pos.getY()+1,pos.getZ()+.5+edge.getStepZ()*offset);
var context=new BlockPlaceContext(player,InteractionHand.MAIN_HAND,new ItemStack(Statuary.ITEM),new BlockHitResult(hit,Direction.UP,pos,false));
var expected=offset<.25?SculptureAnchor.CENTER:SculptureAnchor.of(edge);
check(Statuary.BLOCK.getStateForPlacement(context).getValue(Statuary.ANCHOR)==expected,"Center boundary switches to the aimed edge");
}
var top=new Vec3(pos.getX()+.5+edge.getStepX()*.4,pos.getY()+1,pos.getZ()+.5+edge.getStepZ()*.4);
var context=new BlockPlaceContext(player,InteractionHand.MAIN_HAND,new ItemStack(Statuary.ITEM),new BlockHitResult(top,Direction.UP,pos,false));
var state=Statuary.BLOCK.getStateForPlacement(context);
check(context.getClickedPos().equals(pos.above())&&state.getValue(Statuary.ANCHOR)==SculptureAnchor.of(edge),"Top hit selects aimed edge at positive/negative coordinates");
var side=Vec3.atCenterOf(pos).add(edge.getStepX()*.5,0,edge.getStepZ()*.5);
context=new BlockPlaceContext(player,InteractionHand.MAIN_HAND,new ItemStack(Statuary.ITEM),new BlockHitResult(side,edge,pos,false));
check(Statuary.BLOCK.getStateForPlacement(context).getValue(Statuary.ANCHOR)==SculptureAnchor.of(edge.getOpposite()),"Side hit anchors against supporting block");
}
}
for(var facing:Direction.Plane.HORIZONTAL)for(var anchor:List.of(SculptureAnchor.CENTER,SculptureAnchor.NORTH,SculptureAnchor.SOUTH,SculptureAnchor.EAST,SculptureAnchor.WEST)){
var state=Statuary.BLOCK.defaultBlockState().setValue(Statuary.FACING,facing).setValue(Statuary.ANCHOR,anchor);
for(var rotation:Rotation.values()){
var actual=state.rotate(rotation);
check(actual.getValue(Statuary.ANCHOR)==(anchor==SculptureAnchor.CENTER?anchor:SculptureAnchor.of(rotation.rotate(Direction.valueOf(anchor.name())))),"Structure rotates anchor independently of facing");
}
for(var mirror:Mirror.values()){
var actual=state.mirror(mirror);
check(actual.getValue(Statuary.ANCHOR)==(anchor==SculptureAnchor.CENTER?anchor:SculptureAnchor.of(mirror.mirror(Direction.valueOf(anchor.name())))),"Structure mirrors anchor independently of facing");
}
var legacy=net.minecraft.nbt.NbtUtils.writeBlockState(state);legacy.getCompoundOrEmpty("properties").remove("anchor");
var loaded=net.minecraft.nbt.NbtUtils.readBlockState(net.minecraft.core.registries.BuiltInRegistries.BLOCK,legacy);
check(loaded.getValue(Statuary.FACING)==facing&&loaded.getValue(Statuary.ANCHOR)==SculptureAnchor.CENTER,"Beta.111/112 state keeps facing and defaults anchor");
}
}
static void geometry(Minecraft client,String model){
// Every footprint, odd and even, through all orientations and saved anchor states.
for(int w=1;w<=16;w++)for(int d=1;d<=16;d++){
var sculpture=new VoxelSculpture(model,"Grid fixture",w,2,d,List.of(new VoxelSculpture.Cell(0,0,0,0xFFFF0000),new VoxelSculpture.Cell(w-1,1,d-1,0xFF0000FF)));
for(var facing:Direction.Plane.HORIZONTAL)for(var anchor:SculptureAnchor.values())rendered(entity(client,sculpture,facing,anchor));
}
// Transparent margins in imported models must not leave a gap at the aimed edge.
var padded=new VoxelSculpture(model,"Padded fixture",13,2,15,List.of(new VoxelSculpture.Cell(2,0,3,-1),new VoxelSculpture.Cell(6,1,10,-1)));
for(var facing:Direction.Plane.HORIZONTAL)for(var anchor:SculptureAnchor.values())rendered(entity(client,padded,facing,anchor));
// Colored markers identify forward motion after rotation, independent of the origin shift.
var sculpture=new VoxelSculpture(model,"Direction fixture",3,2,7,List.of(new VoxelSculpture.Cell(0,0,0,0xFFFF0000),new VoxelSculpture.Cell(0,0,6,0xFF0000FF),new VoxelSculpture.Cell(2,1,0,0xFF00FF00)));
for(var facing:Direction.Plane.HORIZONTAL){
var mesh=mesh(entity(client,sculpture,facing,SculptureAnchor.CENTER));
var red=mesh.stream().filter(q->q.color()==0xFFFF0000).findFirst().orElseThrow();var blue=mesh.stream().filter(q->q.color()==0xFF0000FF).findFirst().orElseThrow();
check(blue.x()-red.x()==facing.getStepX()*6/16f&&blue.z()-red.z()==facing.getStepZ()*6/16f,"Colored front faces gaze");
}
var itemMesh=new StatuaryRenderer.ItemRenderer().extractArgument(StatuaryEntity.item(sculpture));
check(itemMesh.equals(mesh(entity(client,sculpture,Direction.SOUTH,SculptureAnchor.CENTER))),"Item preview shares exact centered grid geometry");
System.out.println("SCULPTURE113_GEOMETRY_PASS 9216 footprints/orientations/anchors and colored direction markers");
}
private static StatuaryEntity entity(Minecraft client,VoxelSculpture sculpture,Direction facing,SculptureAnchor anchor){
var entity=new StatuaryEntity(BlockPos.ZERO,Statuary.BLOCK.defaultBlockState().setValue(Statuary.FACING,facing).setValue(Statuary.ANCHOR,anchor));entity.setLevel(client.level);
var data=new CompoundTag();data.put("sculpture",StatuaryEntity.encode(sculpture));entity.loadWithComponents(TagValueInput.create(ProblemReporter.DISCARDING,client.level.registryAccess(),data));return entity;
}
private static List<StatuaryRenderer.Quad> mesh(StatuaryEntity entity){
var renderer=new StatuaryRenderer();var state=renderer.createRenderState();renderer.extractRenderState(entity,state,0,Vec3.ZERO,null);return state.mesh;
}
static void rendered(StatuaryEntity entity){
var mesh=mesh(entity);check(!mesh.isEmpty(),"Sculpture has visible geometry");
float minX=1,minZ=1,maxX=0,maxZ=0;
for(var q:mesh){
check(q.x()*16==Math.rint(q.x()*16)&&q.y()*16==Math.rint(q.y()*16)&&q.z()*16==Math.rint(q.z()*16),"Every rendered voxel is on the exact 1/16 grid");
check(q.x()>=0&&q.y()>=0&&q.z()>=0&&q.x()+1/16f<=1&&q.y()+1/16f<=1&&q.z()+1/16f<=1,"No voxel spills out of its block");
minX=Math.min(minX,q.x());maxX=Math.max(maxX,q.x()+1/16f);minZ=Math.min(minZ,q.z());maxZ=Math.max(maxZ,q.z()+1/16f);
}
var anchor=entity.getBlockState().getValue(Statuary.ANCHOR);
switch(anchor){
case NORTH -> check(minZ==0,"North edge flush");case SOUTH -> check(maxZ==1,"South edge flush");
case WEST -> check(minX==0,"West edge flush");case EAST -> check(maxX==1,"East edge flush");
case NORTH_WEST -> check(minX==0&&minZ==0,"North-west corner flush");case NORTH_EAST -> check(maxX==1&&minZ==0,"North-east corner flush");
case SOUTH_WEST -> check(minX==0&&maxZ==1,"South-west corner flush");case SOUTH_EAST -> check(maxX==1&&maxZ==1,"South-east corner flush");default -> {}
}
if(anchor==SculptureAnchor.CENTER||anchor==SculptureAnchor.NORTH||anchor==SculptureAnchor.SOUTH)check(Math.abs(minX-(1-maxX))<=1/16f,"Other axis centered to nearest whole voxel");
if(anchor==SculptureAnchor.CENTER||anchor==SculptureAnchor.EAST||anchor==SculptureAnchor.WEST)check(Math.abs(minZ-(1-maxZ))<=1/16f,"Other axis centered to nearest whole voxel");
}
private static void check(boolean ok,String message){if(!ok)throw new AssertionError(message);}
}
@@ -0,0 +1,84 @@
package fr.koka.sanctuary.client;
import com.mojang.blaze3d.platform.InputConstants;
import fr.koka.sanctuary.gametest.Starter039TestSupport;
import fr.koka.sanctuary.plans.*;
import java.nio.file.*;
import java.util.*;
import java.util.concurrent.atomic.AtomicBoolean;
import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents;
import net.fabricmc.fabric.api.client.gametest.v1.*;
import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext;
import net.fabricmc.fabric.api.client.networking.v1.ClientPlayNetworking;
import net.minecraft.client.gui.components.Button;
import net.minecraft.client.gui.screens.worldselection.WorldCreationUiState;
import net.minecraft.client.input.KeyEvent;
import net.minecraft.core.*;
import net.minecraft.core.component.DataComponents;
import net.minecraft.core.registries.BuiltInRegistries;
import net.minecraft.world.inventory.ContainerInput;
import net.minecraft.world.item.*;
import net.minecraft.world.item.component.ItemContainerContents;
import net.minecraft.world.level.block.Blocks;
/** All registered block materials, native paid crafts, stale input rejection and pack reload. */
public final class Sculpture116ClientChecks implements FabricClientGameTest {
private static final BlockPos WORKSHOP=new BlockPos(2,1,2);
public void runTest(ClientGameTestContext c){
var intro=new AtomicBoolean(true);
c.runOnClient(m->{m.options.renderDistance().set(6);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());screen.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getMessage().getContents() instanceof net.minecraft.network.chat.contents.TranslatableContents t&&t.getKey().equals("sanctuary.progression.enter")).findFirst().orElseThrow().onPress(enter());intro.set(false);
}});
Path file=PlanClient.directory().resolve("sculpture116-"+UUID.randomUUID()+".glb");
try{
Files.createDirectories(file.getParent());Files.write(file,Statuary106ClientChecks.fixture(true));var asset=GlbModel.load(file);var source=VoxelSculpture.of(asset.id(),asset.name(),StatueVoxelizer.miniature(asset.faces()));
try{new SculpturePalette(List.of());throw new AssertionError("Empty palette accepted");}catch(IllegalArgumentException expected){}
try{new SculpturePalette(Collections.nCopies(257,-1));throw new AssertionError("Oversized palette accepted");}catch(IllegalArgumentException expected){}
try{new SculpturePalette(List.of(0));throw new AssertionError("Transparent color accepted");}catch(IllegalArgumentException expected){}
net.fabricmc.fabric.api.client.gametest.v1.world.TestWorldSave saved;ItemStack hat;
try(var world=c.worldBuilder().adjustSettings(s->{s.setName("Sculpture116 "+UUID.randomUUID());s.setSeed("116");s.setAllowCommands(true);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()){
saved=world.getWorldSave();c.waitFor(m->ProgressionClient.active()&&m.player!=null&&m.gui.screen()==null&&!IntroSpawnFlash.active(),1200);var server=world.getServer();
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();p.getAbilities().invulnerable=true;p.onUpdateAbilities();p.getInventory().clearContent();p.teleportTo(.5,1,.5);for(int x=-4;x<8;x++)for(int z=-4;z<8;z++)p.level().setBlockAndUpdate(new BlockPos(x,0,z),Blocks.STONE.defaultBlockState());p.level().setBlockAndUpdate(WORKSHOP,Statuary.WORKSHOP.defaultBlockState());Statuary.open(p,WORKSHOP);});
c.waitForScreen(ClayWorkshopScreen.class);c.runOnClient(m->{m.options.guiScale().set(2);m.getWindow().setWindowed(1280,800);m.resizeGui();((ClayWorkshopScreen)m.gui.screen()).load(file);});
c.waitFor(m->!((ClayWorkshopScreen)m.gui.screen()).busy()&&((ClayWorkshopScreen)m.gui.screen()).selected()!=null,200);
c.runOnClient(m->{int count=0;for(var item:BuiltInRegistries.ITEM)if(item instanceof BlockItem){var stack=new ItemStack(item);check(ClayWorkshopMenu.material(stack),"Empty registered block accepted: "+item);var palette=SculptureMaterialSampler.sample(stack);check(palette!=null&&palette.colors().size()<=256&&palette.colors().stream().allMatch(v->(v>>>24)==255),"Bounded opaque palette: "+item);count++;}check(count>1000,"Whole block registry sampled");System.out.println("SCULPTURE116_PALETTES "+count+" registered block items");
var dark=new ItemStack(Items.SHULKER_BOX);dark.set(DataComponents.CONTAINER,ItemContainerContents.fromItems(List.of(new ItemStack(Items.DIAMOND))));check(!ClayWorkshopMenu.material(dark),"Filled container cannot lose contents");check(!ClayWorkshopMenu.material(new ItemStack(Items.DIAMOND)),"Non-block item refused");
check(source.cells().stream().map(VoxelSculpture.Cell::color).distinct().count()==1,"Native paid crafts start from a uniform model");Sculpture117Checks.run(source);});
var rendered=new HashSet<VoxelSculpture>();
for(var item:List.of(Items.STONE,Items.OAK_PLANKS,Items.GOLD_BLOCK,Items.WOOL.pick(DyeColor.RED),Items.GLASS,Items.OAK_LEAVES,Items.CLAY,Items.DEEPSLATE)){
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();p.getInventory().clearContent();var menu=(ClayWorkshopMenu)p.containerMenu;menu.getSlot(0).set(new ItemStack(item,2));check(menu.getSlot(1).getItem().isEmpty(),"Previous material result invalidates immediately");menu.broadcastChanges();});
var expected=c.computeOnClient(m->ClayWorkshopMenu.appearance(source,item,SculptureMaterialSampler.sample(new ItemStack(item))));rendered.add(expected);
c.waitFor(m->expected.equals(StatuaryEntity.sculpture(m.player.containerMenu.getSlot(1).getItem())),180);
c.runOnClient(m->check(ItemStack.matches(((ClayWorkshopScreen)m.gui.screen()).preview(),m.player.containerMenu.getSlot(1).getItem()),"Preview equals server-owned result"));
check(StatuaryEntity.decode(StatuaryEntity.encode(expected)).equals(expected),"Palette colors persist in unchanged schema 1");
c.takeScreenshot("sculpture116-"+BuiltInRegistries.ITEM.getKey(item).getPath());
c.runOnClient(m->m.gameMode.handleContainerInput(m.player.containerMenu.containerId,1,0,ContainerInput.PICKUP,m.player));
c.waitFor(m->m.player.containerMenu.getCarried().is(Statuary.ITEM)&&m.player.containerMenu.getSlot(0).getItem().getCount()==1,100);
server.waitFor(s->{var menu=s.getPlayerList().getPlayers().getFirst().containerMenu;return menu.getCarried().is(Statuary.ITEM)&&menu.getSlot(0).getItem().getCount()==1;},100);
server.runOnServer(s->{var menu=(ClayWorkshopMenu)s.getPlayerList().getPlayers().getFirst().containerMenu;check(expected.equals(StatuaryEntity.sculpture(menu.getCarried()))&&menu.getSlot(0).getItem().getCount()==1,"Exactly one actual block consumed per sculpture");});
c.runOnClient(m->m.gameMode.handleContainerInput(m.player.containerMenu.containerId,ClayWorkshopMenu.HOTBAR_START,0,ContainerInput.PICKUP,m.player));c.waitFor(m->m.player.containerMenu.getCarried().isEmpty(),100);server.waitFor(s->s.getPlayerList().getPlayers().getFirst().containerMenu.getCarried().isEmpty(),100);
}
check(rendered.size()>=7,"Distinct materials produce distinct sculptures");
c.waitTicks(6);var stone=c.computeOnClient(m->SculptureMaterialSampler.sample(new ItemStack(Items.STONE)));
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();var menu=(ClayWorkshopMenu)p.containerMenu;menu.getSlot(0).set(new ItemStack(Items.DIAMOND_BLOCK));menu.select(p,source,Items.STONE,stone);check(menu.getSlot(1).getItem().isEmpty(),"Stale material palette rejected by server");});
var diamond=c.computeOnClient(m->SculptureMaterialSampler.sample(new ItemStack(Items.DIAMOND_BLOCK)).apply(source));c.waitFor(m->diamond.equals(StatuaryEntity.sculpture(m.player.containerMenu.getSlot(1).getItem())),180);
c.runOnClient(m->{var menu=(ClayWorkshopMenu)m.player.containerMenu;ClientPlayNetworking.send(new ClayWorkshopMenu.Select(menu.containerId,UUID.randomUUID(),source,Items.DIAMOND_BLOCK,stone));});c.waitTicks(6);
server.runOnServer(s->{var menu=(ClayWorkshopMenu)s.getPlayerList().getPlayers().getFirst().containerMenu;check(diamond.equals(StatuaryEntity.sculpture(menu.getSlot(1).getItem()))&&menu.getSlot(0).getItem().getCount()==1,"Forged menu token cannot change recipe or spend input");});
var before=c.computeOnClient(m->m.getModelManager().getBlockStateModelSet());var reload=c.computeOnClient(m->m.reloadResourcePacks());c.waitFor(m->reload.isDone()&&m.gui.overlay()==null,1200);reload.join();c.waitTicks(8);
c.runOnClient(m->{check(before!=m.getModelManager().getBlockStateModelSet(),"Actual resource reload replaces sampling generation");check(diamond.equals(StatuaryEntity.sculpture(((ClayWorkshopScreen)m.gui.screen()).preview())),"Preview refreshes after pack reload");});
hat=SculptureHat117Checks.exercise(c,server,diamond);
System.out.println("SCULPTURE116_MATERIALS_PASS all block palettes, eight native paid materials, preview equality, shading, stale selections, input identity, token, palette bounds, containers and resource reload");
}
try(var world=saved.open()){
c.waitFor(m->m.player!=null&&m.gui.screen()==null,1200);c.waitTicks(12);
SculptureHat117Checks.verifyEquipped(c,world.getServer(),hat);
System.out.println("SCULPTURE117_HAT_RECONNECT_PASS equipped sculpture survives native save and fresh client reconnect");
}
}catch(java.io.IOException e){throw new AssertionError(e);}finally{intro.set(false);try{Files.deleteIfExists(file);}catch(java.io.IOException ignored){}}
new Statuary106ClientChecks().runTest(c);
System.out.println("SCULPTURE116_PASS materials plus full workshop/hotbar, placement, anchoring, drops, reconnect, FR/EN and Metabli regression");
}
private static KeyEvent enter(){return new KeyEvent(InputConstants.KEY_RETURN,0,0);}
private static void check(boolean ok,String why){if(!ok)throw new AssertionError(why);}
}
@@ -0,0 +1,59 @@
package fr.koka.sanctuary.client;
import fr.koka.sanctuary.plans.*;
import java.util.*;
import net.minecraft.client.Minecraft;
import net.minecraft.client.renderer.block.dispatch.BlockStateModelPart;
import net.minecraft.core.*;
import net.minecraft.util.*;
import net.minecraft.world.item.*;
import net.minecraft.world.level.block.Blocks;
/** Regression for flat-color models, exact texture colors, deterministic grain and bounded CPU work. */
final class Sculpture117Checks {
static void run(VoxelSculpture source){
var cells=new ArrayList<VoxelSculpture.Cell>();
for(int z=0;z<16;z++)for(int y=0;y<16;y++)for(int x=0;x<16;x++)cells.add(new VoxelSculpture.Cell(x,y,z,0xFF808080));
var flat=new VoxelSculpture(source.modelId(),"Flat",16,16,16,cells);
var colors=new ArrayList<Integer>();colors.addAll(Collections.nCopies(64,0xFF223344));colors.addAll(Collections.nCopies(192,0xFFCCBBAA));
var weighted=new SculpturePalette(colors);var out=weighted.apply(flat);
long dark=out.cells().stream().filter(v->v.color()==0xFF223344).count();
check(dark>900&&dark<1150,"Uniform source retains sampled 25% dark / 75% light texture distribution");
check(out.cells().stream().allMatch(v->colors.contains(v.color())),"Never invent an averaged color");
var reversed=new ArrayList<>(flat.cells());Collections.reverse(reversed);
var shuffled=weighted.apply(new VoxelSculpture(flat.modelId(),flat.name(),16,16,16,reversed));
check(Arrays.equals(out.packed(),shuffled.cells().reversed().stream().flatMapToInt(v->java.util.stream.IntStream.of(v.key(),v.color())).toArray()),"Sampling is independent of cell iteration order");
check(out.equals(weighted.apply(flat))&&out.equals(StatuaryEntity.decode(StatuaryEntity.encode(out))),"Stable pattern and persisted final colors");
var gradient=new ArrayList<Integer>();for(int i=0;i<256;i++)gradient.add(0xFF000000|i<<16|i<<8|i);
var palette=new SculpturePalette(gradient);
var shaded=new VoxelSculpture(flat.modelId(),flat.name(),16,16,16,cells.stream().map(v->new VoxelSculpture.Cell(v.x(),v.y(),v.z(),v.x()<8?0xFF000000:0xFFFFFFFF)).toList());
var result=palette.apply(shaded);
check(result.cells().stream().filter(v->v.x()<8).allMatch(v->SculpturePalette.lightness(v.color())<.5),"Dark regions sample dark shades");
check(result.cells().stream().filter(v->v.x()>=8).allMatch(v->SculpturePalette.lightness(v.color())>=.5),"Light regions sample light shades");
check(result.cells().stream().map(VoxelSculpture.Cell::color).distinct().count()>200,"Grain remains inside source shading regions");
var single=new SculpturePalette(List.of(0xFF123456));check(single.apply(flat).cells().stream().allMatch(v->v.color()==0xFF123456),"Truly uniform block textures remain uniform");
for(var item:List.of(Items.STONE,Items.OAK_PLANKS,Items.COAL_ORE,Items.OAK_LOG)){
var sampled=SculptureMaterialSampler.sample(new ItemStack(item));var sculpture=sampled.apply(flat);
check(sculpture.cells().stream().map(VoxelSculpture.Cell::color).distinct().count()>2,"Actual texture grain on flat model: "+item);
check(sculpture.cells().stream().allMatch(v->sampled.colors().contains(v.color())),"Exact sampled pixels: "+item);
}
// Log tops and bark are separate native face materials; both must supply exact colors.
var mc=Minecraft.getInstance();var state=Blocks.OAK_LOG.defaultBlockState();var parts=new ArrayList<BlockStateModelPart>();
mc.getModelManager().getBlockStateModelSet().get(state).collectParts(RandomSource.create(state.getSeed(BlockPos.ZERO)),parts);
var bySprite=new HashMap<String,Set<Integer>>();
for(var part:parts)for(var face:Direction.values())for(var quad:part.getQuads(face)){
var sample=PlanMaterials.faceSample(state,quad.materialInfo());var set=bySprite.computeIfAbsent(quad.materialInfo().sprite().contents().name().toString(),k->new HashSet<>());
for(int pixel:sample.pixels().argb()){int color=ARGB.multiply(pixel,sample.tint());if((color>>>24)>=128)set.add(color|0xFF000000);}
}
check(bySprite.size()>=2,"Native log has distinct face textures");var sampled=SculptureMaterialSampler.sample(new ItemStack(Items.OAK_LOG));
var union=new HashSet<Integer>();bySprite.values().forEach(union::addAll);
check(sampled.colors().stream().allMatch(union::contains),"All palette colors belong to baked face textures");
for(var faceColors:bySprite.values())check(sampled.colors().stream().anyMatch(faceColors::contains),"Each log texture contributes pixels");
for(int i=0;i<100;i++)palette.apply(flat);
long[] times=new long[101];for(int i=0;i<times.length;i++){long start=System.nanoTime();var measured=palette.apply(flat);times[i]=System.nanoTime()-start;check(measured.cells().size()==4096,"Full bounded model");}
Arrays.sort(times);
System.out.printf(java.util.Locale.ROOT,"SCULPTURE117_PERF 4096 voxels median %.3f ms, p95 %.3f ms%n",times[50]/1e6,times[95]/1e6);
System.out.println("SCULPTURE117_GRAIN_PASS flat model, weighted exact pixels, dark/light regions, deterministic cells, persistence and native face materials");
}
private static void check(boolean ok,String message){if(!ok)throw new AssertionError(message);}
}
@@ -0,0 +1,161 @@
package fr.koka.sanctuary.client;
import com.mojang.blaze3d.platform.InputConstants;
import fr.koka.sanctuary.gametest.Starter039TestSupport;
import fr.koka.sanctuary.plans.*;
import java.util.*;
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.Minecraft;
import net.minecraft.client.gui.components.Button;
import net.minecraft.client.gui.screens.worldselection.WorldCreationUiState;
import net.minecraft.client.input.KeyEvent;
import net.minecraft.core.*;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.network.chat.contents.TranslatableContents;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.util.ProblemReporter;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.item.BlockItem;
import net.minecraft.world.item.context.BlockPlaceContext;
import net.minecraft.world.level.*;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.storage.TagValueInput;
import net.minecraft.world.phys.*;
import net.minecraft.world.phys.shapes.*;
/** Single-box selection/collision bounds and light propagation compared with the real sculpture mesh. */
public final class Sculpture122ClientChecks implements FabricClientGameTest {
private static final String MODEL="sanctuary:model/"+"1".repeat(64);
private static final BlockPos TARGET=new BlockPos(0,1,0),SKY=new BlockPos(10,2,2),CONTROL=new BlockPos(18,2,2);
private static final VoxelSculpture ARCH=arch();
public void runTest(ClientGameTestContext c){
var intro=new AtomicBoolean(true);
c.runOnClient(m->{m.options.renderDistance().set(6);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(new KeyEvent(InputConstants.KEY_RETURN,0,0));
screen.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getMessage().getContents() instanceof TranslatableContents t&&t.getKey().equals("sanctuary.progression.enter")).findFirst().orElseThrow().onPress(new KeyEvent(InputConstants.KEY_RETURN,0,0));intro.set(false);
}});
net.fabricmc.fabric.api.client.gametest.v1.world.TestWorldSave saved;
try(var world=c.worldBuilder().adjustSettings(s->{s.setName("Sculpture122 "+UUID.randomUUID());s.setSeed("122");s.setAllowCommands(true);s.setGameMode(WorldCreationUiState.SelectedGameMode.CREATIVE);s.setWorldType(s.getNormalPresetList().stream().filter(e->e.preset()!=null&&e.preset().getRegisteredName().equals("sanctuary_test:flat")).findFirst().orElseThrow());}).create()){
saved=world.getWorldSave();c.waitFor(m->ProgressionClient.active()&&m.player!=null&&m.gui.screen()==null&&!IntroSpawnFlash.active(),1200);var server=world.getServer();
server.runOnServer(s->{var p=player(s);var l=p.level();for(int x=-14;x<=21;x++)for(int z=-6;z<=9;z++)l.setBlockAndUpdate(new BlockPos(x,0,z),Blocks.STONE.defaultBlockState());p.teleportTo(.5,1,3.2);p.setYRot(0);p.getInventory().setItem(p.getInventory().getSelectedSlot(),StatuaryEntity.item(ARCH));p.inventoryMenu.broadcastFullState();
s.getCommands().performPrefixedCommand(s.createCommandSourceStack(),"sanctuary time vanilla");s.getCommands().performPrefixedCommand(s.createCommandSourceStack(),"time set noon");
s.getCommands().performPrefixedCommand(s.createCommandSourceStack(),"sanctuary weather vanilla");s.getCommands().performPrefixedCommand(s.createCommandSourceStack(),"weather clear");
});
c.waitFor(m->m.player.distanceToSqr(.5,1,3.2)<.1&&m.player.getMainHandItem().is(Statuary.ITEM)&&m.level.getBlockState(TARGET.below()).is(Blocks.STONE),150);
c.runOnClient(m->m.player.lookAt(net.minecraft.commands.arguments.EntityAnchorArgument.Anchor.EYES,new Vec3(.5,.999,.5)));c.waitTicks(3);
c.runOnClient(m->check(m.hitResult instanceof BlockHitResult hit&&hit.getBlockPos().equals(TARGET.below())&&hit.getDirection()==Direction.UP,"Cursor aims at placement support"));
c.getInput().holdKey(o->o.keyUse);c.waitTicks(1);c.getInput().releaseKey(o->o.keyUse);c.waitTicks(3);
server.runOnServer(s->System.out.println("SCULPTURE122_PLACED "+player(s).level().getBlockState(TARGET)+" "+player(s).level().getBlockEntity(TARGET)));
c.waitFor(m->m.level.getBlockEntity(TARGET) instanceof StatuaryEntity e&&ARCH.equals(e.sculpture()),150);
server.runOnServer(s->{physical(player(s).level(),TARGET);placementOverlap(player(s));});
c.runOnClient(m->{physical(m.level,TARGET);geometry(m);Sculpture113ClientChecks.geometry(m,MODEL);});
c.runOnClient(m->m.player.lookAt(net.minecraft.commands.arguments.EntityAnchorArgument.Anchor.EYES,new Vec3(.0625,1.5,.5)));c.waitTicks(5);
c.runOnClient(m->check(m.hitResult instanceof BlockHitResult hit&&hit.getBlockPos().equals(TARGET),"Native cursor selects an occupied pillar"));
c.takeScreenshot("sculpture124-single-outline");
server.runOnServer(s->player(s).level().setBlockAndUpdate(TARGET.north(),Blocks.REDSTONE_BLOCK.defaultBlockState()));
c.waitFor(m->m.level.getBlockState(TARGET.north()).is(Blocks.REDSTONE_BLOCK),100);
c.runOnClient(m->m.player.lookAt(net.minecraft.commands.arguments.EntityAnchorArgument.Anchor.EYES,new Vec3(.5,1.5,.5)));c.waitTicks(5);
c.runOnClient(m->check(m.hitResult instanceof BlockHitResult hit&&hit.getBlockPos().equals(TARGET),"Native cursor selects the sculpture bounds even through its visual hole"));
c.takeScreenshot("sculpture124-select-volume");
// A state edit and fresh entity payload must invalidate geometry on both sides.
server.runOnServer(s->{var l=player(s).level();l.setBlockAndUpdate(TARGET,l.getBlockState(TARGET).setValue(Statuary.FACING,Direction.WEST).setValue(Statuary.ANCHOR,SculptureAnchor.EAST));});
c.waitFor(m->m.level.getBlockState(TARGET).getValue(Statuary.FACING)==Direction.WEST,100);
server.runOnServer(s->{var l=player(s).level();var bounds=l.getBlockState(TARGET).getShape(l,TARGET).bounds();check(bounds.maxX==1&&bounds.getXsize()==.25,"Server shape rotates and moves to aimed edge");});
c.runOnClient(m->{var bounds=m.level.getBlockState(TARGET).getShape(m.level,TARGET).bounds();check(bounds.maxX==1&&bounds.getXsize()==.25,"Client shape follows changed blockstate");});
var small=new VoxelSculpture(MODEL,"Small",1,1,1,List.of(new VoxelSculpture.Cell(0,0,0,-1)));
server.runOnServer(s->place(player(s).level(),TARGET,small,Direction.WEST,SculptureAnchor.EAST));
c.waitFor(m->m.level.getBlockEntity(TARGET) instanceof StatuaryEntity e&&small.equals(e.sculpture()),100);
c.runOnClient(m->check(m.level.getBlockState(TARGET).getShape(m.level,TARGET).bounds().getYsize()==1/16d,"Changed block entity payload invalidates cached shape"));
server.runOnServer(s->{var l=player(s).level();place(l,TARGET,ARCH,Direction.SOUTH,SculptureAnchor.CENTER);l.setBlockAndUpdate(TARGET.north(),Blocks.AIR.defaultBlockState());
for(int x=8;x<=12;x++)for(int z=0;z<=4;z++)for(int y=3;y<=5;y++)place(l,new BlockPos(x,y,z),ARCH,Direction.SOUTH,SculptureAnchor.CENTER);
for(int x=16;x<=20;x++)for(int z=0;z<=4;z++)l.setBlockAndUpdate(new BlockPos(x,3,z),Blocks.STONE.defaultBlockState());
l.setBlockAndUpdate(new BlockPos(-8,2,0),Blocks.GLOWSTONE.defaultBlockState());place(l,new BlockPos(-7,2,0),ARCH,Direction.SOUTH,SculptureAnchor.CENTER);
});
server.waitFor(s->{var l=player(s).level();return l.getBrightness(LightLayer.SKY,SKY)==15&&l.getBrightness(LightLayer.SKY,CONTROL)<15&&l.getBrightness(LightLayer.BLOCK,new BlockPos(-6,2,0))==13;},200);
c.waitFor(m->m.level.getBrightness(LightLayer.SKY,SKY)==15&&m.level.getBrightness(LightLayer.SKY,CONTROL)<15&&m.level.getBrightness(LightLayer.BLOCK,new BlockPos(-6,2,0))==13,200);
server.runOnServer(s->{var l=player(s).level();for(int x=8;x<=12;x++)for(int z=0;z<=4;z++)for(int y=3;y<=5;y++)check(l.getBrightness(LightLayer.SKY,new BlockPos(x,y,z))==15,"All 75 sculptures keep full native skylight");});
c.runOnClient(m->{var e=(StatuaryEntity)m.level.getBlockEntity(new BlockPos(10,4,2));var renderer=new StatuaryRenderer();var state=renderer.createRenderState();renderer.extractRenderState(e,state,0,Vec3.ZERO,null);check(net.minecraft.util.LightCoordsUtil.sky(state.lightCoords)==15,"Actual renderer receives full skylight inside a group");});
// Visual crowd at ground level: no whole-block AO squares between the models.
server.runOnServer(s->{var p=player(s);for(int x=-3;x<=3;x++)for(int z=-5;z<=-2;z++)place(p.level(),new BlockPos(x,1,z),ARCH,Direction.from2DDataValue(Math.floorMod(x+z,4)),SculptureAnchor.CENTER);p.teleportTo(.5,3,5.5);p.getAbilities().flying=true;p.onUpdateAbilities();});
c.waitFor(m->m.player.distanceToSqr(.5,3,5.5)<.1,100);c.runOnClient(m->m.player.lookAt(net.minecraft.commands.arguments.EntityAnchorArgument.Anchor.EYES,new Vec3(.5,1,-3)));c.waitTicks(12);c.takeScreenshot("sculpture122-crowd-light");
// Transparent-to-light is not emissive: in a closed room sculpture faces stay dark.
server.runOnServer(s->{var l=player(s).level();for(int x=-13;x<=-9;x++)for(int y=1;y<=5;y++)for(int z=5;z<=9;z++)l.setBlockAndUpdate(new BlockPos(x,y,z),x==-13||x==-9||y==1||y==5||z==5||z==9?Blocks.STONE.defaultBlockState():Blocks.AIR.defaultBlockState());place(l,new BlockPos(-11,2,7),ARCH,Direction.SOUTH,SculptureAnchor.CENTER);});
c.waitFor(m->m.level.getBlockEntity(new BlockPos(-11,2,7)) instanceof StatuaryEntity&&m.level.getBrightness(LightLayer.SKY,new BlockPos(-11,2,7))==0&&m.level.getBrightness(LightLayer.BLOCK,new BlockPos(-11,2,7))==0,200);
c.runOnClient(m->{var renderer=new StatuaryRenderer();var state=renderer.createRenderState();renderer.extractRenderState((StatuaryEntity)m.level.getBlockEntity(new BlockPos(-11,2,7)),state,0,Vec3.ZERO,null);check(state.lightCoords==0,"No fullbright workaround: a dark room produces dark faces");});
System.out.println("SCULPTURE122_LIGHT_PASS: 75 stacked models sky=15; opaque control shadow; block light=13 across sculpture; dark room=0; shade brightness=1");
}
try(var world=saved.open()){
c.waitFor(m->m.player!=null&&m.gui.screen()==null&&m.level.getBlockEntity(TARGET) instanceof StatuaryEntity e&&ARCH.equals(e.sculpture()),1200);
world.getServer().runOnServer(s->physical(player(s).level(),TARGET));c.runOnClient(m->physical(m.level,TARGET));
c.waitFor(m->m.level.getBrightness(LightLayer.SKY,SKY)==15,200);
System.out.println("SCULPTURE122_PASS: native placement, one-box outline including holes, single-box physical collisions, shared render geometry, cache refresh, light propagation and save/reconnect");
}
}
private static void placementOverlap(ServerPlayer player){
var pos=new BlockPos(3,1,0);var level=player.level();player.teleportTo(3.75,1,.5);player.setYRot(0);
var filled=new ArrayList<VoxelSculpture.Cell>();for(int x=0;x<16;x++)for(int y=0;y<16;y++)for(int z=0;z<16;z++)filled.add(new VoxelSculpture.Cell(x,y,z,-1));
var dense=new VoxelSculpture(MODEL,"Solid",16,16,16,filled);var stack=StatuaryEntity.item(dense);player.getInventory().setItem(player.getInventory().getSelectedSlot(),stack);
var hit=new BlockHitResult(new Vec3(3.01,1,.5),Direction.UP,pos.below(),false);
var blocked=((BlockItem)Statuary.ITEM).place(new BlockPlaceContext(player,InteractionHand.MAIN_HAND,stack,hit));
check(!blocked.consumesAction()&&level.getBlockState(pos).isAir()&&stack.getCount()==1,"Native placement refuses occupied voxels inside player without consuming sculpture");
player.teleportTo(3.5,1,.5);var pillars=new ArrayList<VoxelSculpture.Cell>();for(int y=0;y<16;y++)for(int x:new int[]{0,15})pillars.add(new VoxelSculpture.Cell(x,y,0,-1));
var hollow=new VoxelSculpture(MODEL,"Two pillars",16,16,1,pillars);stack=StatuaryEntity.item(hollow);player.getInventory().setItem(player.getInventory().getSelectedSlot(),stack);
var hollowBlocked=((BlockItem)Statuary.ITEM).place(new BlockPlaceContext(player,InteractionHand.MAIN_HAND,stack,hit));
check(!hollowBlocked.consumesAction()&&level.getBlockState(pos).isAir(),"Placement also rejects a player inside the enclosing box between separated pillars");player.teleportTo(3.75,1,.5);
var postCells=new ArrayList<VoxelSculpture.Cell>();for(int y=0;y<16;y++)for(int x=0;x<2;x++)for(int z=0;z<2;z++)postCells.add(new VoxelSculpture.Cell(x,y,z,-1));
var post=new VoxelSculpture(MODEL,"Post",2,16,2,postCells);stack=StatuaryEntity.item(post);player.getInventory().setItem(player.getInventory().getSelectedSlot(),stack);
var allowed=((BlockItem)Statuary.ITEM).place(new BlockPlaceContext(player,InteractionHand.MAIN_HAND,stack,hit));
check(allowed.consumesAction()&&level.getBlockState(pos).is(Statuary.BLOCK),"Native placement allows empty part of block beside player");
check(level.noCollision(player,player.getBoundingBox()),"Accepted sculpture does not intersect player");
level.setBlockAndUpdate(pos,Blocks.AIR.defaultBlockState());player.teleportTo(.5,1,3.2);player.getInventory().setItem(player.getInventory().getSelectedSlot(),StatuaryEntity.item(ARCH));player.inventoryMenu.broadcastFullState();
System.out.println("SCULPTURE122_PLACEMENT_PASS native item rejects occupied bounds including visual holes, allows space outside the box, preserves stack on rejection");
}
private static void physical(Level level,BlockPos pos){
var state=level.getBlockState(pos);var shape=state.getShape(level,pos);var collision=state.getCollisionShape(level,pos);
check(shape==collision&&shape.toAabbs().size()==1,"Selection and collision share one cached bounding box");check(shape==state.getShape(level,pos),"Selection queries reuse the cached box");check(shape.bounds().getZsize()==.25,"No invisible full-block depth");
check(level.clip(new ClipContext(new Vec3(.5,1.5,2),new Vec3(.5,1.5,-.1),ClipContext.Block.OUTLINE,ClipContext.Fluid.NONE,CollisionContext.empty())).getType()==HitResult.Type.BLOCK,"Outline ray selects the whole sculpture volume");
check(level.clip(new ClipContext(new Vec3(.0625,1.5,2),new Vec3(.0625,1.5,-.1),ClipContext.Block.COLLIDER,ClipContext.Fluid.NONE,CollisionContext.empty())).getType()==HitResult.Type.BLOCK,"Collision ray hits pillar");
check(level.clip(new ClipContext(new Vec3(.5,1.5,2),new Vec3(.5,1.5,-.1),ClipContext.Block.COLLIDER,ClipContext.Fluid.NONE,CollisionContext.empty())).getType()==HitResult.Type.BLOCK,"Physical collision uses the enclosing volume, including visual holes");
check(!level.noCollision(new AABB(.35,1.2,.35,.65,1.7,.65)),"Native collision query fills the inner volume instead of following tiny details");
check(!level.noCollision(new AABB(.02,1.2,.4,.1,1.7,.6)),"Native collision query blocks occupied pillar");
check(!state.isCollisionShapeFullBlock(level,pos)&&!state.isSolidRender()&&!state.canOcclude(),"Sculpture is not a solid occluding cube");
check(state.getOcclusionShape().isEmpty()&&state.propagatesSkylightDown()&&state.getLightDampening()==0&&state.getShadeBrightness(level,pos)==1,"Light metadata never reintroduces full-block occlusion");
check(!state.isSuffocating(level,pos)&&state.getLightEmission()==0,"Decorative shape neither suffocates nor emits light");
}
private static void geometry(Minecraft m){
var padded=new VoxelSculpture(MODEL,"Padded",13,5,15,List.of(new VoxelSculpture.Cell(2,2,3,-1),new VoxelSculpture.Cell(6,4,10,-1)));
var dense=new ArrayList<VoxelSculpture.Cell>();for(int x=0;x<16;x++)for(int y=0;y<16;y++)for(int z=0;z<16;z++)dense.add(new VoxelSculpture.Cell(x,y,z,-1));
var models=List.of(ARCH,padded,new VoxelSculpture(MODEL,"Dense",16,16,16,dense),new VoxelSculpture(MODEL,"One voxel",1,1,1,List.of(new VoxelSculpture.Cell(0,0,0,-1))));
int cases=0;for(var model:models)for(var direction:Direction.Plane.HORIZONTAL)for(var anchor:SculptureAnchor.values())for(var height:SculptureHeight.values()){
var state=Statuary.BLOCK.defaultBlockState().setValue(Statuary.FACING,direction).setValue(Statuary.ANCHOR,anchor).setValue(Statuary.HEIGHT,height);var entity=new StatuaryEntity(BlockPos.ZERO,state);entity.setLevel(m.level);load(entity,model,m.level);
var shape=entity.shape(state);check(shape==entity.shape(state),"Repeated geometry queries reuse the same shape");var boxes=shape.toAabbs();double volume=boxes.stream().mapToDouble(b->b.getXsize()*b.getYsize()*b.getZsize()).sum();check(boxes.size()==1&&volume>=model.cells().size()/4096d,"One box encloses all occupied cells");
var outline=entity.shape(state);check(outline==entity.shape(state),"Repeated outline queries reuse the cached box");
check(outline.toAabbs().size()==1&&outline.bounds().equals(shape.bounds()),"One outline box matches exact rendered extents for every orientation and anchor");
int[] edges={0};outline.forAllEdges((x1,y1,z1,x2,y2,z2)->edges[0]++);check(edges[0]==12,"Native outline emits only the 12 enclosing edges, independent of voxel count");
var renderer=new StatuaryRenderer();var render=renderer.createRenderState();renderer.extractRenderState(entity,render,0,Vec3.ZERO,null);
double minX=1,minY=1,minZ=1,maxX=0,maxY=0,maxZ=0;
for(var q:render.mesh){minX=Math.min(minX,q.x());minY=Math.min(minY,q.y());minZ=Math.min(minZ,q.z());maxX=Math.max(maxX,q.x()+1/16d);maxY=Math.max(maxY,q.y()+1/16d);maxZ=Math.max(maxZ,q.z()+1/16d);var center=new Vec3(q.x()+1/32d,q.y()+1/32d,q.z()+1/32d);check(boxes.stream().anyMatch(b->b.contains(center)),"Every rendered cell is physically present");
}
check(shape.bounds().equals(new AABB(minX,minY,minZ,maxX,maxY,maxZ)),"Single collision box exactly matches visible mesh extents");
cases++;
}
System.out.println("SCULPTURE124_BOUNDS_PASS "+cases+" cached single boxes agree with exact renderer extents; 12 outline edges each");
System.out.println("SCULPTURE122_GEOMETRY_PASS "+cases+" native bounding boxes agree with emitted mesh extents, plus cached 12-edge selection bounds for holes, padding, dense 4096 cells, single voxel, 4 directions, 9 anchors and 4 vertical modes");
}
private static void place(ServerLevel level,BlockPos pos,VoxelSculpture model,Direction facing,SculptureAnchor anchor){
var state=Statuary.BLOCK.defaultBlockState().setValue(Statuary.FACING,facing).setValue(Statuary.ANCHOR,anchor);level.setBlockAndUpdate(pos,state);var entity=(StatuaryEntity)level.getBlockEntity(pos);load(entity,model,level);entity.setChanged();level.sendBlockUpdated(pos,state,state,3);
}
private static void load(StatuaryEntity entity,VoxelSculpture model,Level level){var tag=new CompoundTag();tag.put("sculpture",StatuaryEntity.encode(model));entity.loadWithComponents(TagValueInput.create(ProblemReporter.DISCARDING,level.registryAccess(),tag));}
private static VoxelSculpture arch(){var cells=new ArrayList<VoxelSculpture.Cell>();for(int x=0;x<16;x++)for(int y=0;y<16;y++)for(int z=0;z<4;z++)if(x<2||x>=14||y>=14)cells.add(new VoxelSculpture.Cell(x,y,z,0xffc99679));return new VoxelSculpture(MODEL,"Arch",16,16,4,cells);}
private static ServerPlayer player(MinecraftServer server){return server.getPlayerList().getPlayers().getFirst();}
private static void check(boolean value,String reason){if(!value)throw new AssertionError(reason);}
}
@@ -0,0 +1,118 @@
package fr.koka.sanctuary.client;
import com.mojang.blaze3d.platform.InputConstants;
import fr.koka.sanctuary.gametest.Starter039TestSupport;
import fr.koka.sanctuary.plans.*;
import java.util.*;
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.Button;
import net.minecraft.client.gui.screens.worldselection.WorldCreationUiState;
import net.minecraft.client.input.KeyEvent;
import net.minecraft.core.*;
import net.minecraft.core.registries.BuiltInRegistries;
import net.minecraft.nbt.*;
import net.minecraft.network.chat.contents.TranslatableContents;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.*;
import net.minecraft.util.ProblemReporter;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.item.context.BlockPlaceContext;
import net.minecraft.world.level.block.*;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.storage.TagValueInput;
import net.minecraft.world.phys.*;
/** Native clicks on all six support faces; additive saved-state compatibility and real renderer bounds. */
public final class Sculpture125ClientChecks implements FabricClientGameTest {
private static final BlockPos SUPPORT=new BlockPos(0,4,0);
private static final String MODEL="sanctuary:model/"+"2".repeat(64);
private static final VoxelSculpture STATUE=statue();
private static final SculptureAnchor[][] ANCHORS={{SculptureAnchor.NORTH_WEST,SculptureAnchor.WEST,SculptureAnchor.SOUTH_WEST},{SculptureAnchor.NORTH,SculptureAnchor.CENTER,SculptureAnchor.SOUTH},{SculptureAnchor.NORTH_EAST,SculptureAnchor.EAST,SculptureAnchor.SOUTH_EAST}};
private static final SculptureHeight[] HEIGHTS={SculptureHeight.BOTTOM,SculptureHeight.CENTER,SculptureHeight.TOP};
private record Spot(Direction face,int u,int v){
Vec3 hit(BlockPos p){double a=.1+.4*u,b=.1+.4*v;return switch(face){
case UP,DOWN->new Vec3(p.getX()+a,p.getY()+(face==Direction.UP?1:0),p.getZ()+b);
case NORTH,SOUTH->new Vec3(p.getX()+a,p.getY()+b,p.getZ()+(face==Direction.SOUTH?1:0));
case WEST,EAST->new Vec3(p.getX()+(face==Direction.EAST?1:0),p.getY()+b,p.getZ()+a);
};}
SculptureAnchor anchor(){return switch(face){case UP,DOWN->ANCHORS[u][v];case NORTH->ANCHORS[u][2];case SOUTH->ANCHORS[u][0];case EAST->ANCHORS[0][u];case WEST->ANCHORS[2][u];};}
SculptureHeight height(){return face==Direction.UP?SculptureHeight.BOTTOM:face==Direction.DOWN?SculptureHeight.TOP:HEIGHTS[v];}
}
public void runTest(ClientGameTestContext c){
var intro=new AtomicBoolean(true);
c.runOnClient(m->{m.options.renderDistance().set(6);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);var enter=new KeyEvent(InputConstants.KEY_RETURN,0,0);
screen.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getClass().getSimpleName().equals("ColorButton")).findFirst().orElseThrow().onPress(enter);
screen.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getMessage().getContents() instanceof TranslatableContents t&&t.getKey().equals("sanctuary.progression.enter")).findFirst().orElseThrow().onPress(enter);intro.set(false);
}});
net.fabricmc.fabric.api.client.gametest.v1.world.TestWorldSave saved;
var persistent=new HashMap<BlockPos,BlockState>();
try(var world=c.worldBuilder().adjustSettings(s->{s.setName("Sculpture125 "+UUID.randomUUID());s.setSeed("125");s.setAllowCommands(true);s.setGameMode(WorldCreationUiState.SelectedGameMode.CREATIVE);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);var server=world.getServer();
server.runOnServer(s->{var p=player(s);var l=p.level();for(int x=-8;x<=8;x++)for(int z=-8;z<=8;z++)l.setBlockAndUpdate(new BlockPos(x,0,z),Blocks.STONE.defaultBlockState());p.getAbilities().flying=true;p.onUpdateAbilities();p.getInventory().setItem(p.getInventory().getSelectedSlot(),StatuaryEntity.item(STATUE));p.inventoryMenu.broadcastFullState();
s.getCommands().performPrefixedCommand(s.createCommandSourceStack(),"sanctuary time vanilla");s.getCommands().performPrefixedCommand(s.createCommandSourceStack(),"time set noon");s.getCommands().performPrefixedCommand(s.createCommandSourceStack(),"sanctuary weather vanilla");s.getCommands().performPrefixedCommand(s.createCommandSourceStack(),"weather clear");contexts(p);legacy(p);Sculpture113ClientChecks.placementContexts(p);l.setBlockAndUpdate(SUPPORT,Blocks.STONE.defaultBlockState());
});
c.waitFor(m->m.player.getMainHandItem().is(Statuary.ITEM)&&m.level.getBlockState(SUPPORT).is(Blocks.STONE),150);
int clicks=0;
for(var face:Direction.values()){
Vec3 eye= switch(face){case UP->new Vec3(.5,6,3);case DOWN->new Vec3(.5,1.3,2.5);case NORTH->new Vec3(.5,3,-2.2);case SOUTH->new Vec3(.5,3,3.2);case EAST->new Vec3(3.2,3,.5);case WEST->new Vec3(-2.2,3,.5);};
server.runOnServer(s->{var p=player(s);p.teleportTo(eye.x,eye.y,eye.z);p.setDeltaMovement(Vec3.ZERO);p.getAbilities().flying=true;p.onUpdateAbilities();});c.waitFor(m->m.player.distanceToSqr(eye)<.05,100);c.runOnClient(m->{m.player.getAbilities().flying=true;m.player.setDeltaMovement(Vec3.ZERO);m.player.onUpdateAbilities();});c.waitTicks(10);
for(int u=0;u<3;u++)for(int v=0;v<3;v++){
var spot=new Spot(face,u,v);var target=SUPPORT.relative(face);
server.runOnServer(s->{for(var direction:Direction.values())player(s).level().setBlockAndUpdate(SUPPORT.relative(direction),Blocks.AIR.defaultBlockState());});
c.waitFor(m->m.level.getBlockState(target).isAir(),100);
c.runOnClient(m->m.player.lookAt(net.minecraft.commands.arguments.EntityAnchorArgument.Anchor.EYES,spot.hit(SUPPORT)));c.waitTicks(3);
c.runOnClient(m->check(m.hitResult instanceof BlockHitResult h&&h.getBlockPos().equals(SUPPORT)&&h.getDirection()==face,"Native cursor hits requested support face "+spot+" instead of "+m.hitResult+" at "+m.hitResult.getLocation()+" eye "+m.player.getEyePosition()+" flying "+m.player.getAbilities().flying));
var facing=c.computeOnClient(m->{System.out.println("SCULPTURE125_AIM "+spot+" actual "+m.hitResult.getLocation()+" eye "+m.player.getEyePosition());return m.player.getDirection();});c.getInput().holdKey(o->o.keyUse);c.waitTicks(1);c.getInput().releaseKey(o->o.keyUse);c.waitTicks(3);
c.waitFor(m->m.level.getBlockEntity(target) instanceof StatuaryEntity e&&STATUE.equals(e.sculpture()),100);
server.runOnServer(s->{var l=player(s).level();var state=l.getBlockState(target);check(state.getValue(Statuary.ANCHOR)==spot.anchor()&&state.getValue(Statuary.HEIGHT)==spot.height()&&state.getValue(Statuary.FACING)==facing,"Server uses aimed position and horizontal gaze for "+spot+" got "+state+" expected facing "+facing);});
c.runOnClient(m->{var e=(StatuaryEntity)m.level.getBlockEntity(target);renderBounds(e);});
if(u==0&&v==2)c.takeScreenshot("sculpture125-"+face.getSerializedName());clicks++;
}
}
System.out.println("SCULPTURE125_CLICKS_PASS "+clicks+" real placements: all six faces, every corner/edge/center and supported heights");
c.runOnClient(m->Sculpture125ParticleChecks.exercise(m,SUPPORT.relative(Direction.EAST)));
Sculpture125WorkshopChecks.exercise(c,server);
server.runOnServer(s->{var l=player(s).level();for(var direction:Direction.values())l.setBlockAndUpdate(SUPPORT.relative(direction),Blocks.AIR.defaultBlockState());for(int x=0;x<3;x++)for(int y=0;y<3;y++){
var pos=new BlockPos(x*2-2,y+1,0);l.setBlockAndUpdate(pos.north(),Blocks.STONE.defaultBlockState());var state=Statuary.BLOCK.defaultBlockState().setValue(Statuary.FACING,Direction.SOUTH).setValue(Statuary.ANCHOR,ANCHORS[x][0]).setValue(Statuary.HEIGHT,HEIGHTS[y]);place(l,pos,state);persistent.put(pos,state);
}player(s).teleportTo(.5,2,5);});
c.waitFor(m->m.player.distanceToSqr(.5,2,5)<.05,100);c.runOnClient(m->m.player.lookAt(net.minecraft.commands.arguments.EntityAnchorArgument.Anchor.EYES,new Vec3(.5,2.5,.5)));c.waitTicks(8);c.takeScreenshot("sculpture125-wall-placements");saved=world.getWorldSave();
}
try(var world=saved.open()){
c.waitFor(m->m.player!=null&&m.gui.screen()==null,1200);world.getServer().runOnServer(s->{for(var entry:persistent.entrySet()){var state=player(s).level().getBlockState(entry.getKey());check(state==entry.getValue(),"New anchors and heights persist exactly");check(STATUE.equals(((StatuaryEntity)player(s).level().getBlockEntity(entry.getKey())).sculpture()),"Model data persists unchanged");}});
c.waitFor(m->persistent.keySet().stream().allMatch(p->m.level.getBlockEntity(p) instanceof StatuaryEntity e&&STATUE.equals(e.sculpture())),150);c.runOnClient(m->persistent.keySet().forEach(p->renderBounds((StatuaryEntity)m.level.getBlockEntity(p))));
}
new Sculpture122ClientChecks().runTest(c);
System.out.println("SCULPTURE125_PASS new placement gestures, native picking, collision/render agreement, legacy states and saved new positions");
}
private static void contexts(ServerPlayer p){int count=0;
for(var pos:List.of(new BlockPos(5,3,5),new BlockPos(-5,3,-5))){p.level().setBlockAndUpdate(pos,Blocks.STONE.defaultBlockState());
for(int turn=0;turn<4;turn++){p.setYRot(turn*90);for(var face:Direction.values())for(int u=0;u<3;u++)for(int v=0;v<3;v++){
var spot=new Spot(face,u,v);var context=new BlockPlaceContext(p,InteractionHand.MAIN_HAND,StatuaryEntity.item(STATUE),new BlockHitResult(spot.hit(pos),face,pos,false));var state=Statuary.BLOCK.getStateForPlacement(context);
check(context.getClickedPos().equals(pos.relative(face))&&state.getValue(Statuary.ANCHOR)==spot.anchor()&&state.getValue(Statuary.HEIGHT)==spot.height()&&state.getValue(Statuary.FACING)==p.getDirection(),"Native placement context agrees at signed coordinates: "+spot);count++;
}}p.level().setBlockAndUpdate(pos,Blocks.AIR.defaultBlockState());
}
check(Statuary.BLOCK.getStateDefinition().getPossibleStates().size()==144,"108 placement states plus 36 legacy-height states");
for(var anchor:SculptureAnchor.values())for(var height:SculptureHeight.values()){
var state=Statuary.BLOCK.defaultBlockState().setValue(Statuary.ANCHOR,anchor).setValue(Statuary.HEIGHT,height);var box=SculptureGeometry.shape(STATUE,Direction.SOUTH,anchor,height).bounds();
for(var rotation:Rotation.values()){var rotated=state.rotate(rotation);check(rotated.getValue(Statuary.HEIGHT)==height,"Yaw rotation retains height");var b=SculptureGeometry.shape(STATUE,rotated.getValue(Statuary.FACING),rotated.getValue(Statuary.ANCHOR),height).bounds();double ex=box.getCenter().x-.5,ez=box.getCenter().z-.5;double tx=switch(rotation){case NONE->ex;case CLOCKWISE_90->-ez;case CLOCKWISE_180->-ex;case COUNTERCLOCKWISE_90->ez;};double tz=switch(rotation){case NONE->ez;case CLOCKWISE_90->ex;case CLOCKWISE_180->-ez;case COUNTERCLOCKWISE_90->-ex;};check(Math.abs((b.getCenter().x-.5)-tx)<=1/16d&&Math.abs((b.getCenter().z-.5)-tz)<=1/16d&&b.minY==box.minY,"Rotated anchor follows native structure rotation without half voxels");}
for(var mirror:Mirror.values()){var mirrored=state.mirror(mirror);var b=SculptureGeometry.shape(STATUE,mirrored.getValue(Statuary.FACING),mirrored.getValue(Statuary.ANCHOR),height).bounds();double x=mirror==Mirror.FRONT_BACK?1-box.getCenter().x:box.getCenter().x,z=mirror==Mirror.LEFT_RIGHT?1-box.getCenter().z:box.getCenter().z;check(Math.abs(b.getCenter().x-x)<=1/16d&&Math.abs(b.getCenter().z-z)<=1/16d&&b.minY==box.minY,"Mirrored corner and vertical placement follow native structure mirror");}
}
System.out.println("SCULPTURE125_CONTEXT_PASS "+count+" contexts, signed coordinates, four headings, rotations/mirrors and 144 valid states");
}
private static void legacy(ServerPlayer p){int n=0;
for(var facing:Direction.Plane.HORIZONTAL)for(var anchor:List.of(SculptureAnchor.CENTER,SculptureAnchor.NORTH,SculptureAnchor.SOUTH,SculptureAnchor.WEST,SculptureAnchor.EAST)){
var state=Statuary.BLOCK.defaultBlockState().setValue(Statuary.FACING,facing).setValue(Statuary.ANCHOR,anchor).setValue(Statuary.HEIGHT,SculptureHeight.TOP);var nbt=NbtUtils.writeBlockState(state);nbt.getCompoundOrEmpty("properties").remove("vertical_anchor");var restored=NbtUtils.readBlockState(BuiltInRegistries.BLOCK,nbt);
check(restored.getValue(Statuary.HEIGHT)==SculptureHeight.ORIGINAL&&restored.getValue(Statuary.ANCHOR)==anchor&&restored.getValue(Statuary.FACING)==facing,"Old NBT keeps orientation and anchor and defaults original Y");var b=SculptureGeometry.shape(STATUE,facing,anchor,restored.getValue(Statuary.HEIGHT)).bounds();check(b.minY==2/16d&&b.maxY==10/16d,"Legacy vertical padding is preserved exactly");n++;
}System.out.println("SCULPTURE125_LEGACY_PASS "+n+" old blockstates retain exact placements without rewriting payloads");
}
private static void renderBounds(StatuaryEntity entity){var renderer=new StatuaryRenderer();var render=renderer.createRenderState();renderer.extractRenderState(entity,render,0,Vec3.ZERO,null);double x0=1,y0=1,z0=1,x1=0,y1=0,z1=0;for(var q:render.mesh){x0=Math.min(x0,q.x());y0=Math.min(y0,q.y());z0=Math.min(z0,q.z());x1=Math.max(x1,q.x()+1/16d);y1=Math.max(y1,q.y()+1/16d);z1=Math.max(z1,q.z()+1/16d);}var bounds=entity.shape(entity.getBlockState()).bounds();check(bounds.equals(new AABB(x0,y0,z0,x1,y1,z1)),"Native single box follows actual rendered offsets on all three axes");Sculpture113ClientChecks.rendered(entity);}
private static void place(ServerLevel level,BlockPos pos,BlockState state){level.setBlockAndUpdate(pos,state);var e=(StatuaryEntity)level.getBlockEntity(pos);var data=new CompoundTag();data.put("sculpture",StatuaryEntity.encode(STATUE));e.loadWithComponents(TagValueInput.create(ProblemReporter.DISCARDING,level.registryAccess(),data));e.setChanged();level.sendBlockUpdated(pos,state,state,3);}
private static VoxelSculpture statue(){var cells=new ArrayList<VoxelSculpture.Cell>();for(int y=2;y<10;y++)for(int x=y<6?2:1;x<=(y<6?4:5);x++)for(int z=y<6?1:0;z<=(y<6?3:4);z++)cells.add(new VoxelSculpture.Cell(x,y,z,y<6?0xff44aabb:0xffddb88b));return new VoxelSculpture(MODEL,"Placement statue",7,10,5,cells);}
private static ServerPlayer player(MinecraftServer s){return s.getPlayerList().getPlayers().getFirst();}
private static void check(boolean ok,String reason){if(!ok)throw new AssertionError(reason);}
}
@@ -0,0 +1,36 @@
package fr.koka.sanctuary.client;
import fr.koka.sanctuary.plans.*;
import java.lang.reflect.Field;
import java.util.*;
import net.minecraft.client.Minecraft;
import net.minecraft.client.particle.*;
import net.minecraft.core.*;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.phys.*;
final class Sculpture125ParticleChecks {
static void exercise(Minecraft mc,BlockPos pos){
var entity=(StatuaryEntity)mc.level.getBlockEntity(pos);var state=entity.getBlockState();
var render=new StatuaryRenderer();var rs=render.createRenderState();render.extractRenderState(entity,rs,0,Vec3.ZERO,null);
Set<Integer> colors=new HashSet<>();for(var q:rs.mesh)colors.add(q.color());
var queue=queue(mc);queue.clear();var q=rs.mesh.getLast();var hit=Vec3.atLowerCornerOf(pos).add(SculptureParticles.center(q));
mc.hitResult=new BlockHitResult(hit,Direction.UP,pos,false);mc.level.addBreakingBlockEffects(pos,Direction.UP,false);
check(queue.size()==1&&queue.peek() instanceof SculptureParticles.Fragment,"Native hit emits one voxel chip, no clay debris");
var chip=(SingleQuadParticle)queue.peek();check(color(chip)==q.color(),"Hit chip uses the exact targeted voxel color");
queue.clear();mc.level.addDestroyBlockEffect(pos,state);verify(queue,rs.mesh,colors,pos);int count=queue.size();
queue.clear();mc.level.setBlock(pos,Blocks.AIR.defaultBlockState(),3);check(mc.level.getBlockEntity(pos)==null,"Native block update removes sculpture before break effect");
mc.level.addDestroyBlockEffect(pos,state);verify(queue,rs.mesh,colors,pos);check(queue.size()==count,"Late break event retains voxel colors and offsets");
queue.clear();mc.level.addDestroyBlockEffect(pos,Blocks.STONE.defaultBlockState());check(!queue.isEmpty()&&queue.stream().noneMatch(SculptureParticles.Fragment.class::isInstance),"Other blocks retain native particles");queue.clear();
System.out.println("SCULPTURE125_PARTICLES_PASS native hit/destroy hooks, exact voxel colors, real surface origins, 64-chip bound, late removal and ordinary block regression");
}
private static void verify(Queue<Particle> queue,List<StatuaryRenderer.Quad> mesh,Set<Integer> colors,BlockPos pos){
check(!queue.isEmpty()&&queue.size()<=64,"Bounded destruction particles");var observed=new HashSet<Integer>();
for(var p:queue){check(p instanceof SculptureParticles.Fragment,"No clay fallback particles");int color=color((SingleQuadParticle)p);observed.add(color);check(colors.contains(color),"Particle retains an actual model color");var point=new Vec3(number(p,"x"),number(p,"y"),number(p,"z")).subtract(Vec3.atLowerCornerOf(pos));check(mesh.stream().anyMatch(q->q.color()==color&&SculptureParticles.center(q).equals(point)),"Particle belongs to an actual colored voxel face after rotation/anchor");check(((SingleQuadParticle)p).getQuadSize(0)==1/32f,"One voxel diameter, no oversized clay chips");}
check(observed.size()==colors.size(),"Both fixture material colors occur in destruction chips");
}
@SuppressWarnings("unchecked") private static Queue<Particle> queue(Minecraft mc){try{var f=ParticleEngine.class.getDeclaredField("particlesToAdd");f.setAccessible(true);return (Queue<Particle>)f.get(mc.particleEngine);}catch(Exception e){throw new AssertionError(e);}}
private static double number(Object value,String name){try{Class<?> type=value.getClass();Field f=null;while(type!=null){try{f=type.getDeclaredField(name);break;}catch(NoSuchFieldException ignored){type=type.getSuperclass();}}f.setAccessible(true);return ((Number)f.get(value)).doubleValue();}catch(Exception e){throw new AssertionError(e);}}
private static int color(SingleQuadParticle p){return 0xff000000|Math.round((float)number(p,"rCol")*255)<<16|Math.round((float)number(p,"gCol")*255)<<8|Math.round((float)number(p,"bCol")*255);}
private static void check(boolean ok,String reason){if(!ok)throw new AssertionError(reason);}
}
@@ -0,0 +1,53 @@
package fr.koka.sanctuary.client;
import fr.koka.sanctuary.companion.CompanionService;
import fr.koka.sanctuary.plans.*;
import java.nio.file.*;
import java.util.*;
import net.fabricmc.fabric.api.client.gametest.v1.context.*;
import net.minecraft.client.gui.components.Button;
import net.minecraft.client.input.KeyEvent;
import net.minecraft.core.BlockPos;
import net.minecraft.core.registries.BuiltInRegistries;
import net.minecraft.stats.Stats;
import net.minecraft.world.entity.EntityTypes;
import net.minecraft.world.inventory.ContainerInput;
import net.minecraft.world.item.*;
import net.minecraft.world.level.GameType;
final class Sculpture125WorkshopChecks {
static void exercise(ClientGameTestContext c,TestServerContext server){
var pos=new BlockPos(4,1,4);
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();p.teleportTo(3,1,3);p.setGameMode(GameType.SURVIVAL);p.getAbilities().invulnerable=true;p.onUpdateAbilities();p.level().setBlockAndUpdate(pos,Statuary.WORKSHOP.defaultBlockState());
CompanionService.save(p,CompanionService.record(p).note("seen_mobs",""));Statuary.open(p,pos);
});
c.waitForScreen(ClayWorkshopScreen.class);
c.runOnClient(m->{var menu=(ClayWorkshopMenu)m.player.containerMenu;check(!menu.view.statues().contains("minecraft:cow"),"Unseen cow is absent without visiting Metabli");var screen=(ClayWorkshopScreen)m.gui.screen();screen.load(EntityTypes.COW);check(screen.selected()==null&&!screen.busy(),"Unseen animal cannot start local generation");});
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();var menu=(ClayWorkshopMenu)p.containerMenu;menu.getSlot(0).set(new ItemStack(Items.OAK_PLANKS,2));var locked=new VoxelSculpture(StatueDiscoveries.modelId("minecraft:cow"),"Cow",1,1,1,List.of(new VoxelSculpture.Cell(0,0,0,-1)));menu.select(p,locked,Items.OAK_PLANKS,new SculpturePalette(List.of(-1)));check(menu.getSlot(1).getItem().isEmpty(),"Server rejects a locked animal model despite valid material");p.closeContainer();
CompanionService.save(p,CompanionService.record(p).note("seen_mobs","cow"));p.awardStat(Stats.ENTITY_KILLED.get(EntityTypes.SHEEP));p.awardStat(Stats.ENTITY_KILLED_BY.get(EntityTypes.PIG));
});
c.waitFor(m->m.gui.screen()==null,100);
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();Statuary.open(p,pos);var menu=(ClayWorkshopMenu)p.containerMenu;check(menu.view.statues().equals(StatueDiscoveries.known(p)),"Workshop uses the exact statue catalogue discoveries");menu.getSlot(0).set(new ItemStack(Items.OAK_PLANKS,4));menu.broadcastChanges();});
c.waitForScreen(ClayWorkshopScreen.class);
c.runOnClient(m->{m.options.guiScale().set(2);m.getWindow().setWindowed(1280,800);m.resizeGui();var menu=(ClayWorkshopMenu)m.player.containerMenu;check(menu.view.statues().containsAll(List.of("minecraft:cow","minecraft:sheep","minecraft:pig")),"Encounter, kill and killed-by unlocks are transmitted");});
for(var type:List.of(EntityTypes.COW,EntityTypes.SHEEP,EntityTypes.PIG)){
String model=StatueDiscoveries.modelId(BuiltInRegistries.ENTITY_TYPE.getKey(type).toString());
c.runOnClient(m->{var screen=(ClayWorkshopScreen)m.gui.screen();screen.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getMessage().getString().equals(type.getDescription().getString())).findFirst().orElseThrow().onPress(new KeyEvent(257,0,0));});
c.waitTicks(10);c.runOnClient(m->{var screen=(ClayWorkshopScreen)m.gui.screen();System.out.println("SCULPTURE125_WORKSHOP_DEBUG "+type+" busy "+screen.busy()+" source "+(screen.selected()==null?"null":screen.selected().modelId())+" input "+m.player.containerMenu.getSlot(0).getItem()+" output "+m.player.containerMenu.getSlot(1).getItem());});
c.waitFor(m->{var screen=(ClayWorkshopScreen)m.gui.screen();return !screen.busy()&&screen.selected()!=null&&screen.selected().modelId().equals(model)&&m.player.containerMenu.getSlot(1).getItem().is(Statuary.ITEM)&&StatuaryEntity.sculpture(m.player.containerMenu.getSlot(1).getItem()).modelId().equals(model);},250);
c.runOnClient(m->{var screen=(ClayWorkshopScreen)m.gui.screen();var source=screen.selected();check(source.cells().size()>20&&Math.max(source.width(),Math.max(source.height(),source.depth()))==16,"Native animal model voxelized at miniature scale");check(ItemStack.matches(screen.preview(),m.player.containerMenu.getSlot(1).getItem()),"Animal preview equals server-paid palette result");});
c.takeScreenshot("sculpture125-workshop-"+BuiltInRegistries.ENTITY_TYPE.getKey(type).getPath());
}
c.runOnClient(m->m.gameMode.handleContainerInput(m.player.containerMenu.containerId,1,0,ContainerInput.PICKUP,m.player));
c.waitFor(m->m.player.containerMenu.getCarried().is(Statuary.ITEM)&&m.player.containerMenu.getSlot(0).getItem().getCount()==3,100);
server.waitFor(s->{var menu=s.getPlayerList().getPlayers().getFirst().containerMenu;return menu.getCarried().is(Statuary.ITEM)&&menu.getSlot(0).getItem().getCount()==3;},100);
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();check(p.containerMenu.getSlot(0).getItem().getCount()==3&&p.containerMenu.getCarried().is(Statuary.ITEM),"Exactly one block consumed for native animal sculpture in survival");});
Path file=PlanClient.directory().resolve("sculpture125-"+UUID.randomUUID()+".glb");
try{Files.createDirectories(file.getParent());Files.write(file,Statuary106ClientChecks.fixture(true));c.runOnClient(m->{var screen=(ClayWorkshopScreen)m.gui.screen();screen.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getMessage().getString().equals(ClayWorkshopScreen.text("imports").getString())).findFirst().orElseThrow().onPress(new KeyEvent(257,0,0));screen.load(file);});
var id=GlbModel.load(file).id();c.waitFor(m->{var screen=(ClayWorkshopScreen)m.gui.screen();return !screen.busy()&&screen.selected()!=null&&screen.selected().modelId().equals(id)&&id.equals(Optional.ofNullable(StatuaryEntity.sculpture(m.player.containerMenu.getSlot(1).getItem())).map(VoxelSculpture::modelId).orElse(""));},250);c.takeScreenshot("sculpture125-workshop-import");
}catch(Exception e){throw new AssertionError(e);}finally{try{Files.deleteIfExists(file);}catch(Exception ignored){}}
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();p.closeContainer();p.setGameMode(GameType.CREATIVE);p.getAbilities().flying=true;p.onUpdateAbilities();});c.waitFor(m->m.gui.screen()==null,100);
System.out.println("SCULPTURE125_WORKSHOP_PASS encounter/kill/death unlocks, native menu sync, locked server rejection, three textured animals, paid survival craft and GLB import");
}
private static void check(boolean ok,String reason){if(!ok)throw new AssertionError(reason);}
}
@@ -0,0 +1,50 @@
package fr.koka.sanctuary.client;
import fr.koka.sanctuary.cosmetics.*;
import fr.koka.sanctuary.inventory.*;
import fr.koka.sanctuary.plans.*;
import net.fabricmc.fabric.api.client.gametest.v1.context.*;
import net.minecraft.client.gui.screens.inventory.InventoryScreen;
import net.minecraft.core.component.DataComponents;
import net.minecraft.network.chat.Component;
import net.minecraft.world.inventory.ContainerInput;
import net.minecraft.world.item.ItemStack;
/** Actual accessory-slot transfers and recovery from a saved beta.116 head session. */
final class SculptureHat117Checks {
static ItemStack exercise(ClientGameTestContext c,TestServerContext server,VoxelSculpture sculpture){
var original=StatuaryEntity.item(sculpture);original.set(DataComponents.CUSTOM_NAME,Component.literal("Sculpture chapeau 117"));
c.runOnClient(m->m.gui.screen().onClose());
server.waitFor(s->{var p=s.getPlayerList().getPlayers().getFirst();return p.containerMenu==p.inventoryMenu;},100);
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();p.getInventory().clearContent();p.getInventory().setItem(0,original.copy());p.inventoryMenu.broadcastFullState();});
c.waitFor(m->ItemStack.matches(m.player.inventoryMenu.getSlot(36).getItem(),original),100);
c.runOnClient(m->m.gui.setScreen(new InventoryScreen(m.player)));
click(c,36);c.waitFor(m->m.player.inventoryMenu.getCarried().is(Statuary.ITEM),100);
click(c,66);c.waitTicks(12);verifyEquipped(c,server,original);
c.takeScreenshot("sculpture117-hat-inventory");
click(c,66);c.waitFor(m->m.player.inventoryMenu.getCarried().is(Statuary.ITEM),100);
click(c,36);
server.waitFor(s->{var p=s.getPlayerList().getPlayers().getFirst();return p.inventoryMenu.getCarried().isEmpty()&&HeadEquipment.get(p).isEmpty()&&ItemStack.matches(p.getInventory().getItem(0),original);},100);
c.waitFor(m->ItemStack.matches(m.player.inventoryMenu.getSlot(36).getItem(),original)&&m.player.inventoryMenu.getSlot(66).getItem().isEmpty(),100);
c.runOnClient(m->check(!new StatuaryRenderer.ItemRenderer().extractArgument(m.player.inventoryMenu.getSlot(36).getItem()).isEmpty(),"Returned sculpture still renders in hotbar"));
click(c,36);click(c,66);c.waitTicks(8);verifyEquipped(c,server,original);
server.runOnServer(s->{
var p=s.getPlayerList().getPlayers().getFirst();var saved=HeadBlockService.save(p);check(saved!=null,"Authoritative worn model is saved");
var damaged=original.copy();damaged.remove(DataComponents.BLOCK_ENTITY_DATA);
AccessoryAccess.of(p).setItem(AccessoryContainer.HEAD,damaged);HeadEquipment.retainData(p,saved);
HeadBlockService.session(p);p.inventoryMenu.broadcastChanges();
check(ItemStack.matches(HeadEquipment.get(p),original),"Recover already stripped sculpture from its saved worn model");
});
c.waitTicks(8);verifyEquipped(c,server,original);
c.runOnClient(m->m.gui.screen().onClose());
System.out.println("SCULPTURE117_HAT_PASS native equip/remove/re-equip, slot mesh, exact components and old worn-session recovery");
return original;
}
static void verifyEquipped(ClientGameTestContext c,TestServerContext server,ItemStack original){
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();check(ItemStack.matches(HeadEquipment.get(p),original),"Equipped stack retains its geometry and name");check(p.getInventory().getItem(0).isEmpty()&&p.inventoryMenu.getCarried().isEmpty(),"Exactly one sculpture moved into head slot");});
c.waitFor(m->ItemStack.matches(m.player.inventoryMenu.getSlot(66).getItem(),original)&&StatuaryEntity.sculpture(original).equals(StatuaryEntity.sculpture(HeadCosmeticService.appearance(m.player))),150);
c.runOnClient(m->check(!new StatuaryRenderer.ItemRenderer().extractArgument(m.player.inventoryMenu.getSlot(66).getItem()).isEmpty(),"Equipped slot retains visible item geometry"));
}
private static void click(ClientGameTestContext c,int slot){c.runOnClient(m->m.gameMode.handleContainerInput(m.player.inventoryMenu.containerId,slot,0,ContainerInput.PICKUP,m.player));}
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);}
@@ -46,8 +46,8 @@ public final class Statuary106ClientChecks implements FabricClientGameTest {
net.fabricmc.fabric.api.client.gametest.v1.world.TestWorldSave saved;
try{
System.out.println("STATUARY106_LIBRARY "+file);Files.createDirectories(file.getParent());byte[] fixture=fixture();Files.write(file,fixture);
var asset=GlbModel.load(file);var pixels=StatueVoxelizer.miniature(asset.faces());var source=VoxelSculpture.of(asset.id(),asset.name(),pixels);
MODEL.set(source.modelId());check(source.height()==16&&source.width()<16,"Proportions, 16 voxel longest axis");check(source.cells().stream().map(VoxelSculpture.Cell::color).distinct().count()>1,"Embedded PNG retains multiple colors");
var asset=GlbModel.load(file);var pixels=StatueVoxelizer.miniature(asset.faces());var originalSource=VoxelSculpture.of(asset.id(),asset.name(),pixels);
MODEL.set(originalSource.modelId());check(originalSource.height()==16&&originalSource.width()<16,"Proportions, 16 voxel longest axis");check(originalSource.cells().stream().map(VoxelSculpture.Cell::color).distinct().count()>1,"Embedded PNG retains multiple colors");
check(CreationLibrary.files(CreationLibrary.Kind.MODEL).contains(file)&&!CreationLibrary.files(CreationLibrary.Kind.SCHEMATIC).contains(file),"One folder, different filters");
var invalid=fixture.clone();ByteBuffer.wrap(invalid).order(ByteOrder.LITTLE_ENDIAN).putInt(8,1);reject(invalid,"GLB header length");
var oversize=new byte[GlbModel.MAX_BYTES+1];reject(oversize,"File bound");
@@ -57,19 +57,22 @@ public final class Statuary106ClientChecks implements FabricClientGameTest {
reject(edit(fixture,j->{var a=new JsonArray();a.add(0);j.getAsJsonArray("nodes").get(0).getAsJsonObject().add("children",a);}),"Cyclic scene");
var interop=net.fabricmc.loader.api.FabricLoader.getInstance().getGameDir().resolve("../../../../../build/statuary106-evidence/BoxTextured.glb").normalize();
if(Files.exists(interop)){var box=GlbModel.read(interop);check(box.size()==12,"Independent Khronos indexed GLB");check(!StatueVoxelizer.miniature(box).isEmpty(),"Independent textured box voxelizes");System.out.println("STATUARY106_INTEROP_PASS Khronos BoxTextured GLB");}
check(source.rotate().rotate().rotate().rotate().equals(source),"Four rotations preserve ID and data");
try{new VoxelSculpture(source.modelId(),"bad",16,16,16,List.of(new VoxelSculpture.Cell(0,0,0,-1),new VoxelSculpture.Cell(0,0,0,-1)));throw new AssertionError("Duplicate cell accepted");}catch(IllegalArgumentException expected){}
check(originalSource.rotate().rotate().rotate().rotate().equals(originalSource),"Four rotations preserve ID and data");
try{new VoxelSculpture(originalSource.modelId(),"bad",16,16,16,List.of(new VoxelSculpture.Cell(0,0,0,-1),new VoxelSculpture.Cell(0,0,0,-1)));throw new AssertionError("Duplicate cell accepted");}catch(IllegalArgumentException expected){}
try(var world=c.worldBuilder().adjustSettings(s->{s.setName("Statuary106 "+UUID.randomUUID());s.setSeed("106");s.setAllowCommands(true);s.setGameMode(WorldCreationUiState.SelectedGameMode.CREATIVE);
s.setWorldType(s.getNormalPresetList().stream().filter(e->e.preset()!=null&&e.preset().getRegisteredName().equals("sanctuary_test:flat")).findFirst().orElseThrow());}).create()){
saved=world.getWorldSave();c.waitFor(m->ProgressionClient.active()&&m.player!=null&&m.gui.screen()==null&&!IntroSpawnFlash.active(),1200);
var source=originalSource;
var server=world.getServer();
server.runOnServer(s->{var p=player(s);p.teleportTo(.5,1,.5);p.getInventory().clearContent();p.setGameMode(GameType.SURVIVAL);for(int x=-7;x<=9;x++)for(int z=-5;z<=9;z++)p.level().setBlockAndUpdate(new BlockPos(x,0,z),Blocks.STONE.defaultBlockState());p.level().setBlockAndUpdate(WORKSHOP,Statuary.WORKSHOP.defaultBlockState());
var legacy=net.minecraft.nbt.NbtUtils.writeBlockState(Statuary.BLOCK.defaultBlockState());legacy.remove("properties");
check(net.minecraft.nbt.NbtUtils.readBlockState(net.minecraft.core.registries.BuiltInRegistries.BLOCK,legacy).getValue(Statuary.FACING)==Direction.SOUTH,"Legacy state defaults to unchanged model orientation");
check(net.minecraft.nbt.NbtUtils.readBlockState(net.minecraft.core.registries.BuiltInRegistries.BLOCK,legacy).getValue(Statuary.ANCHOR)==SculptureAnchor.CENTER,"Legacy state keeps centered anchor");
Sculpture113ClientChecks.placementContexts(p);
for(var q:Metatable.members(TABLE))p.level().setBlockAndUpdate(q,Blocks.CRAFTING_TABLE.defaultBlockState());check(Metatable.assemble(p,TABLE),"Metabli");
s.getCommands().performPrefixedCommand(s.createCommandSourceStack(),"sanctuary time vanilla");s.getCommands().performPrefixedCommand(s.createCommandSourceStack(),"time set noon");});
c.waitFor(m->m.level.getBlockState(WORKSHOP).is(Statuary.WORKSHOP)&&!m.player.isCreative(),200);
c.runOnClient(m->{m.options.guiScale().set(2);m.getWindow().setWindowed(1280,800);m.resizeGui();});
c.runOnClient(m->{m.options.guiScale().set(2);m.getWindow().setWindowed(1280,800);m.resizeGui();Sculpture113ClientChecks.geometry(m,source.modelId());});
open(c);c.runOnClient(m->((ClayWorkshopScreen)m.gui.screen()).onFilesDrop(List.of(file)));
c.waitFor(m->!((ClayWorkshopScreen)m.gui.screen()).busy()&&((ClayWorkshopScreen)m.gui.screen()).selected()!=null,200);c.waitTicks(10);
server.runOnServer(s->{var p=player(s);var menu=(ClayWorkshopMenu)p.containerMenu;check(menu.getSlot(1).getItem().isEmpty(),"No clay, no result");
@@ -79,6 +82,7 @@ public final class Statuary106ClientChecks implements FabricClientGameTest {
c.waitFor(m->m.player.containerMenu.getSlot(ClayWorkshopMenu.HOTBAR_START).getItem().getCount()==3,100);
c.runOnClient(m->m.gameMode.handleContainerInput(m.player.containerMenu.containerId,ClayWorkshopMenu.HOTBAR_START,0,ContainerInput.QUICK_MOVE,m.player));
c.waitFor(m->m.player.containerMenu.getSlot(1).getItem().is(Statuary.ITEM),100);
c.runOnClient(m->check(m.player.containerMenu.getSlot(1).getItem().getHoverName().getString().equals("Sculpture"),"Crafted sculpture has translated type name EN"));
c.takeScreenshot("statuary106-workshop-en");
checkWorkshopLayout(c);
c.runOnClient(m->{m.options.guiScale().set(3);m.resizeGui();});c.waitTicks(3);checkWorkshopLayout(c);c.takeScreenshot("statuary111-workshop-en-scale3");
@@ -94,8 +98,9 @@ public final class Statuary106ClientChecks implements FabricClientGameTest {
p.getInventory().setItem(18,ItemStack.EMPTY);p.getInventory().setItem(Inventory.SLOT_OFFHAND,ItemStack.EMPTY);fr.koka.sanctuary.inventory.SanctuaryOverflowAccess.of(p).setItem(17,ItemStack.EMPTY);
});
c.runOnClient(m->{m.options.guiScale().set(2);m.resizeGui();});
server.runOnServer(s->{var menu=(ClayWorkshopMenu)player(s).containerMenu;ORIGINAL.set(StatuaryEntity.sculpture(menu.getSlot(1).getItem()));check(ORIGINAL.get().equals(source),"Plain clay keeps exact colors and model ID");
for(var family:ColoredBricks.families()){var colored=ClayWorkshopMenu.material(source,new ItemStack(family.clay()));check(colored.modelId().equals(source.modelId())&&colored.cells().size()==source.cells().size()&&colored.cells().stream().map(VoxelSculpture.Cell::color).distinct().count()==1,"All 16 clays keep geometry and ID, recolor uniformly");}});
server.runOnServer(s->{var menu=(ClayWorkshopMenu)player(s).containerMenu;ORIGINAL.set(StatuaryEntity.sculpture(menu.getSlot(1).getItem()));check(ORIGINAL.get().equals(source),"Plain clay applies its sampled palette and preserves model ID");
});
c.runOnClient(m->{for(var family:ColoredBricks.families()){var palette=SculptureMaterialSampler.sample(new ItemStack(family.clay()));var colored=palette.apply(originalSource);check(colored.modelId().equals(source.modelId())&&colored.cells().size()==source.cells().size()&&colored.cells().stream().allMatch(cell->palette.colors().contains(cell.color())),"All 16 clays keep geometry and use actual texture shades");}});
c.runOnClient(m->m.gameMode.handleContainerInput(m.player.containerMenu.containerId,1,0,ContainerInput.PICKUP,m.player));
c.waitFor(m->m.player.containerMenu.getCarried().is(Statuary.ITEM),100);
c.waitTicks(6);
@@ -116,15 +121,22 @@ public final class Statuary106ClientChecks implements FabricClientGameTest {
server.runOnServer(s->{var p=player(s);p.teleportTo(px,1,pz);p.getInventory().setItem(p.getInventory().getSelectedSlot(),StatuaryEntity.item(source));p.inventoryMenu.broadcastFullState();});
c.waitFor(m->m.player.distanceToSqr(px,1,pz)<.05&&m.player.getMainHandItem().is(Statuary.ITEM),150);place(c,pos);
server.waitFor(s->player(s).level().getBlockState(pos).is(Statuary.BLOCK),100);
server.runOnServer(s->{var state=player(s).level().getBlockState(pos);check(state.getValue(Statuary.FACING)==direction&&state.getValue(Statuary.ANCHOR)==SculptureAnchor.CENTER,"Native center placement follows gaze on server");});
c.waitFor(m->m.level.getBlockEntity(pos) instanceof StatuaryEntity e&&source.equals(e.sculpture())&&e.getBlockState().getValue(Statuary.ANCHOR)==SculptureAnchor.CENTER,100);
c.runOnClient(m->Sculpture113ClientChecks.rendered((StatuaryEntity)m.level.getBlockEntity(pos)));
c.takeScreenshot("sculpture119-center-"+direction.getSerializedName());
server.runOnServer(s->{var p=player(s);p.level().removeBlock(pos,false);p.getInventory().setItem(p.getInventory().getSelectedSlot(),StatuaryEntity.item(source));p.inventoryMenu.broadcastFullState();});
c.waitFor(m->m.level.getBlockState(pos).isAir()&&m.player.getMainHandItem().is(Statuary.ITEM),100);place(c,pos,direction.getOpposite());
server.waitFor(s->player(s).level().getBlockState(pos).is(Statuary.BLOCK),100);
server.runOnServer(s->{var state=player(s).level().getBlockState(pos);check(state.getValue(Statuary.FACING)==direction,"Native placement faces gaze: "+direction);
check(state.getValue(Statuary.ANCHOR)==SculptureAnchor.of(direction.getOpposite()),"Aimed edge stored on server");
check(state.rotate(net.minecraft.world.level.block.Rotation.CLOCKWISE_90).getValue(Statuary.FACING)==direction.getClockWise(),"Structure rotation follows native directions");
check(state.mirror(net.minecraft.world.level.block.Mirror.LEFT_RIGHT).getValue(Statuary.FACING)==(direction.getAxis()==Direction.Axis.Z?direction.getOpposite():direction),"Structure mirror follows native directions");
check(((StatuaryEntity)player(s).level().getBlockEntity(pos)).sculpture().equals(source),"Placement never rewrites model voxels");});
c.waitFor(m->m.level.getBlockEntity(pos) instanceof StatuaryEntity e&&source.equals(e.sculpture()),100);
c.runOnClient(m->{var entity=(StatuaryEntity)m.level.getBlockEntity(pos);check(entity.getBlockState().getValue(Statuary.FACING)==direction,"Client receives direction");
var renderer=new StatuaryRenderer();var state=renderer.createRenderState();renderer.extractRenderState(entity,state,0,Vec3.ZERO,null);
var forward=new org.joml.Vector3f(0,0,1).rotate(com.mojang.math.Axis.YP.rotationDegrees(state.yaw));
check(Math.abs(forward.x()-direction.getStepX())<.001&&Math.abs(forward.z()-direction.getStepZ())<.001,"Rendered model faces gaze: "+direction);});
check(entity.getBlockState().getValue(Statuary.ANCHOR)==SculptureAnchor.of(direction.getOpposite()),"Client receives aimed edge");
Sculpture113ClientChecks.rendered(entity);});
c.takeScreenshot("statuary111-facing-"+direction.getSerializedName());
}
// Re-place a dropped north-facing model while looking west: no baked or cumulative turn.
@@ -133,17 +145,18 @@ public final class Statuary106ClientChecks implements FabricClientGameTest {
var drop=p.level().getEntitiesOfClass(ItemEntity.class,new AABB(north).inflate(1)).stream().filter(e->e.getItem().is(Statuary.ITEM)).findFirst().orElseThrow();
check(source.equals(StatuaryEntity.sculpture(drop.getItem())),"Direction does not alter dropped item");
p.getInventory().setItem(p.getInventory().getSelectedSlot(),drop.getItem().copy());drop.discard();p.teleportTo(west.getX()+2.5,1,west.getZ()+.5);p.inventoryMenu.broadcastFullState();});
c.waitFor(m->m.player.distanceToSqr(west.getX()+2.5,1,west.getZ()+.5)<.05&&m.player.getMainHandItem().is(Statuary.ITEM)&&m.level.getBlockState(west).isAir(),100);place(c,west);
c.waitFor(m->m.player.distanceToSqr(west.getX()+2.5,1,west.getZ()+.5)<.05&&m.player.getMainHandItem().is(Statuary.ITEM)&&m.level.getBlockState(west).isAir(),100);place(c,west,Direction.EAST);
server.waitFor(s->player(s).level().getBlockState(west).is(Statuary.BLOCK),100);
server.runOnServer(s->check(player(s).level().getBlockState(west).getValue(Statuary.FACING)==Direction.WEST,"Dropped statue takes new gaze"));
server.runOnServer(s->player(s).teleportTo(.5,1,.5));c.waitFor(m->m.player.distanceToSqr(.5,1,.5)<.05,100);
// Reuse the same file with colored clay; server, rather than the client, chooses the tint.
// Reuse the file with colored clay; server applies the palette bound to that actual material.
server.runOnServer(s->player(s).getInventory().setItem(player(s).getInventory().getSelectedSlot(),ItemStack.EMPTY));c.waitTicks(4);open(c);
c.runOnClient(m->((ClayWorkshopScreen)m.gui.screen()).load(file));c.waitFor(m->!((ClayWorkshopScreen)m.gui.screen()).busy()&&((ClayWorkshopScreen)m.gui.screen()).selected()!=null,200);c.waitTicks(10);
server.runOnServer(s->{var menu=(ClayWorkshopMenu)player(s).containerMenu;var blue=ColoredBricks.families().stream().filter(f->f.color()==DyeColor.BLUE).findFirst().orElseThrow();menu.getSlot(0).set(new ItemStack(blue.clay(),2));menu.broadcastChanges();});
c.waitFor(m->m.player.containerMenu.getSlot(1).getItem().is(Statuary.ITEM),100);language(c,"fr_fr");c.takeScreenshot("statuary106-workshop-fr-blue");
c.waitFor(m->m.player.containerMenu.getSlot(1).getItem().is(Statuary.ITEM),100);language(c,"fr_fr");c.runOnClient(m->check(m.player.containerMenu.getSlot(1).getItem().getHoverName().getString().equals("Sculpture"),"Crafted sculpture has translated type name FR"));c.takeScreenshot("statuary106-workshop-fr-blue");
checkWorkshopLayout(c);
server.runOnServer(s->{var menu=(ClayWorkshopMenu)player(s).containerMenu;var blue=StatuaryEntity.sculpture(menu.getSlot(1).getItem());check(blue.modelId().equals(MODEL.get())&&blue.cells().stream().allMatch(v->v.color()==0xFFA0A7BC),"Blue input overrides uploaded model colors");});
var expectedBlue=c.computeOnClient(m->SculptureMaterialSampler.sample(new ItemStack(ColoredBricks.families().stream().filter(f->f.color()==DyeColor.BLUE).findFirst().orElseThrow().clay())).apply(originalSource));
server.runOnServer(s->{var menu=(ClayWorkshopMenu)player(s).containerMenu;var blue=StatuaryEntity.sculpture(menu.getSlot(1).getItem());check(blue.equals(expectedBlue),"Blue input uses sampled palette and model shading");});
c.runOnClient(m->m.gameMode.handleContainerInput(m.player.containerMenu.containerId,1,0,ContainerInput.QUICK_MOVE,m.player));c.waitTicks(10);
server.runOnServer(s->{var p=player(s);check(p.containerMenu.getSlot(0).getItem().isEmpty(),"Shift crafting consumes stock");check(count(p,Statuary.ITEM)==2,"Shift crafting produces exactly paid quantity");});close(c);
// At the Metabli, the same source creates a full-sized construction plan, with no placement in survival.
@@ -180,13 +193,17 @@ public final class Statuary106ClientChecks implements FabricClientGameTest {
for(var direction:List.of(Direction.SOUTH,Direction.EAST,Direction.WEST)){
var pos=DIRECTIONS.get(direction);
server.runOnServer(s->check(player(s).level().getBlockState(pos).getValue(Statuary.FACING)==direction&&((StatuaryEntity)player(s).level().getBlockEntity(pos)).sculpture().equals(ORIGINAL.get()),"Direction and model survive server save/reload"));
server.runOnServer(s->check(player(s).level().getBlockState(pos).getValue(Statuary.ANCHOR)==SculptureAnchor.of(direction.getOpposite()),"Anchor survives server save/reload"));
c.waitFor(m->m.level.getBlockEntity(pos) instanceof StatuaryEntity e&&ORIGINAL.get().equals(e.sculpture())&&e.getBlockState().getValue(Statuary.FACING)==direction,150);
}
c.waitFor(m->m.level.getBlockEntity(SCULPTURE) instanceof StatuaryEntity e&&e.sculpture()!=null,150);c.runOnClient(m->check(((StatuaryEntity)m.level.getBlockEntity(SCULPTURE)).sculpture().equals(ORIGINAL.get()),"Fresh client receives server model without file"));
server.runOnServer(s->check(player(s).level().getBlockState(SCULPTURE).getValue(Statuary.ANCHOR)==SculptureAnchor.CENTER,"Centered placement survives server save/reload"));
c.waitFor(m->m.level.getBlockEntity(SCULPTURE) instanceof StatuaryEntity e&&e.sculpture()!=null,150);c.runOnClient(m->{check(((StatuaryEntity)m.level.getBlockEntity(SCULPTURE)).sculpture().equals(ORIGINAL.get()),"Fresh client receives server model without file");check(m.level.getBlockState(SCULPTURE).getValue(Statuary.ANCHOR)==SculptureAnchor.CENTER,"Fresh client receives centered anchor");});
c.getInput().lookAt(SCULPTURE);c.takeScreenshot("statuary106-reload-no-source");
}
System.out.println("STATUARY106_PASS GLB textured scene, bounded import, shared folder filters, native paid crafting, 16 clay colors, model ID, shift-click, place/drop/re-place, Metabli plan, cancellation, server save/reconnect without source");
System.out.println("STATUARY111_PASS nine-slice GUI FR/EN scales 2/3, four native gaze placements, renderer directions, structure rotation/mirror, legacy state default, drop/re-place and persisted orientation");
System.out.println("STATUARY113_PASS clay sculpture FR/EN, native aimed edges, voxel grid across every footprint/orientation/anchor, side placement, legacy states, rotated/mirrored anchor, save/reconnect");
System.out.println("SCULPTURE119_PASS native centered placements in four directions, center/edge boundaries, odd/even voxel grid, side anchoring and center save/reconnect");
System.out.println("STATUARY112_PASS fixed compact hotbar-only menu, native input transfer, hidden slots/offhand rejected, full hotbar no consumption, partial merge exact cost, closing restitution");
}catch(IOException e){throw new AssertionError(e);}finally{try{Files.deleteIfExists(file);}catch(IOException ignored){}}
}
@@ -200,8 +217,9 @@ public final class Statuary106ClientChecks implements FabricClientGameTest {
});}
private static void close(ClientGameTestContext c){c.runOnClient(m->m.gui.screen().onClose());c.waitFor(m->m.gui.screen()==null,100);}
private static void open(ClientGameTestContext c){c.runOnClient(m->m.gameMode.useItemOn(m.player,InteractionHand.MAIN_HAND,new BlockHitResult(Vec3.atCenterOf(WORKSHOP),Direction.UP,WORKSHOP,false)));c.waitForScreen(ClayWorkshopScreen.class);}
private static void place(ClientGameTestContext c,BlockPos pos){
c.runOnClient(m->m.player.lookAt(net.minecraft.commands.arguments.EntityAnchorArgument.Anchor.EYES,new Vec3(pos.getX()+.5,pos.getY()-.001,pos.getZ()+.5)));c.waitTicks(3);
private static void place(ClientGameTestContext c,BlockPos pos){place(c,pos,null);}
private static void place(ClientGameTestContext c,BlockPos pos,Direction edge){
c.runOnClient(m->m.player.lookAt(net.minecraft.commands.arguments.EntityAnchorArgument.Anchor.EYES,new Vec3(pos.getX()+.5+(edge==null?0:edge.getStepX()*.4),pos.getY()-.001,pos.getZ()+.5+(edge==null?0:edge.getStepZ()*.4))));c.waitTicks(3);
c.runOnClient(m->check(m.hitResult instanceof BlockHitResult hit&&hit.getBlockPos().equals(pos.below())&&hit.getDirection()==Direction.UP,"Native cursor targets support top face"));
c.getInput().holdKey(o->o.keyUse);c.waitTicks(1);c.getInput().releaseKey(o->o.keyUse);
}
@@ -218,14 +236,19 @@ public final class Statuary106ClientChecks implements FabricClientGameTest {
byte[] text=json.toString().getBytes(java.nio.charset.StandardCharsets.UTF_8);int padded=(text.length+3)/4*4;byte[] binary=Arrays.copyOfRange(file,20+length,file.length);var out=ByteBuffer.allocate(20+padded+binary.length).order(ByteOrder.LITTLE_ENDIAN);out.putInt(0x46546C67).putInt(2).putInt(out.capacity()).putInt(padded).putInt(0x4E4F534A).put(text);while(out.position()<20+padded)out.put((byte)' ');out.put(binary);return out.array();
}
/** A tiny authored cat built from triangle boxes, embedded 2×2 texture and a translated scene node. */
private static byte[] fixture()throws IOException{
static byte[] fixture()throws IOException{
return fixture(false);
}
static byte[] fixture(boolean uniform)throws IOException{
var xyz=new ArrayList<Float>();var uv=new ArrayList<Float>();
for(float[] box:List.of(new float[]{.2f,0,.1f,.8f,1,.65f},new float[]{.1f,1,.05f,.9f,1.7f,.7f},new float[]{.1f,1.7f,.1f,.3f,2.1f,.5f},new float[]{.7f,1.7f,.1f,.9f,2.1f,.5f},new float[]{.75f,.1f,.2f,1.1f,.65f,.5f})){
float x=box[0],y=box[1],z=box[2],X=box[3],Y=box[4],Z=box[5];float[][] v={{x,y,z},{X,y,z},{X,Y,z},{x,Y,z},{x,y,Z},{X,y,Z},{X,Y,Z},{x,Y,Z}};
int[][] quads={{0,3,2,1},{5,6,7,4},{4,7,3,0},{1,2,6,5},{3,7,6,2},{4,0,1,5}};
for(var q:quads)for(int i:new int[]{0,1,2,0,2,3}){for(float f:v[q[i]])xyz.add(f);uv.add(i==0||i==1?0f:1f);uv.add(i==0||i==3?1f:0f);}
}
var image=new java.awt.image.BufferedImage(2,2,java.awt.image.BufferedImage.TYPE_INT_ARGB);image.setRGB(0,0,0xFFFF9933);image.setRGB(1,0,0xFF44CCAA);image.setRGB(0,1,0xFF4477EE);image.setRGB(1,1,0xFFFFFFEE);var png=new ByteArrayOutputStream();javax.imageio.ImageIO.write(image,"png",png);
var image=new java.awt.image.BufferedImage(2,2,java.awt.image.BufferedImage.TYPE_INT_ARGB);image.setRGB(0,0,0xFFFF9933);image.setRGB(1,0,0xFF44CCAA);image.setRGB(0,1,0xFF4477EE);image.setRGB(1,1,0xFFFFFFEE);
if(uniform)for(int y=0;y<2;y++)for(int x=0;x<2;x++)image.setRGB(x,y,0xFF808080);
var png=new ByteArrayOutputStream();javax.imageio.ImageIO.write(image,"png",png);
int pbytes=xyz.size()*4,ubytes=uv.size()*4,ibytes=png.size(),total=pbytes+ubytes+ibytes;var binary=ByteBuffer.allocate((total+3)/4*4).order(ByteOrder.LITTLE_ENDIAN);for(float f:xyz)binary.putFloat(f);for(float f:uv)binary.putFloat(f);binary.put(png.toByteArray());
String json="""
{"asset":{"version":"2.0"},"scene":0,"scenes":[{"nodes":[0]}],"nodes":[{"mesh":0,"translation":[3,2,-1]}],
@@ -0,0 +1,73 @@
package fr.koka.sanctuary.client;
import fr.koka.sanctuary.building.ColoredBricks;
import java.util.*;
import net.minecraft.core.*;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.tags.*;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.item.*;
import net.minecraft.world.item.context.UseOnContext;
import net.minecraft.world.item.crafting.*;
import net.minecraft.world.level.block.*;
import net.minecraft.world.level.block.state.properties.WallSide;
import net.minecraft.world.phys.*;
/** Gameplay regressions using native placement, neighbor updates and recipe matching. */
final class Walls118Checks {
static void server(MinecraftServer server){
var player=server.getPlayerList().getPlayers().getFirst();var level=player.level();
for(int x=-1;x<=17;x++)for(int z=9;z<=13;z++)level.setBlockAndUpdate(new BlockPos(x,0,z),Blocks.STONE.defaultBlockState());
int index=0;
for(var family:ColoredBricks.families()){
var wall=family.wall();var state=wall.defaultBlockState();
check(wall instanceof WallBlock&&state.is(BlockTags.WALLS)&&state.is(BlockTags.MINEABLE_WITH_PICKAXE),"Native wall type, connection and mining tags");
check(new ItemStack(wall).is(ItemTags.WALLS),"Native wall item tag");
var input=CraftingInput.of(3,2,Collections.nCopies(6,new ItemStack(family.bricks())));
var recipe=server.getRecipeManager().getRecipeFor(RecipeType.CRAFTING,input,level).orElseThrow().value();var result=recipe.assemble(input);
check(result.is(wall.asItem())&&result.getCount()==6,"Six matching bricks craft six walls");
var drops=Block.getDrops(state,level,new BlockPos(0,2,0),null,player,new ItemStack(Items.DIAMOND_PICKAXE));
check(drops.size()==1&&drops.getFirst().is(wall.asItem())&&drops.getFirst().getCount()==1,"Wall drops its own color");
for(int z:new int[]{10,12}){
var pos=new BlockPos(index,1,z);
player.teleportTo(index+.5,1,z+2.5);
if(z==12)level.setBlockAndUpdate(pos,Blocks.WATER.defaultBlockState());
player.setItemInHand(InteractionHand.MAIN_HAND,new ItemStack(wall));
var hit=new BlockHitResult(Vec3.atBottomCenterOf(pos),Direction.UP,pos.below(),false);
var placed=player.getMainHandItem().useOn(new UseOnContext(player,InteractionHand.MAIN_HAND,hit));
check(placed.consumesAction(),"Native wall item placement: "+family.color()+" at "+pos+" = "+placed+", existing "+level.getBlockState(pos));
check(level.getBlockState(pos).is(wall),"Placed wall keeps its color");
check(level.getBlockState(pos).getValue(WallBlock.WATERLOGGED)==(z==12),"Native waterlogging on placement");
}
index++;
}
for(int i=1;i<15;i++){
var pos=new BlockPos(i,1,10);var state=level.getBlockState(pos);
check(state.getValue(WallBlock.EAST)==WallSide.LOW&&state.getValue(WallBlock.WEST)==WallSide.LOW&&!state.getValue(WallBlock.UP),"Mixed-color straight wall removes middle post");
level.setBlockAndUpdate(pos.above(),Blocks.STONE.defaultBlockState());
state=level.getBlockState(pos);check(state.getValue(WallBlock.EAST)==WallSide.TALL&&state.getValue(WallBlock.WEST)==WallSide.TALL,"Full block above raises connected sides");
var vanilla=Blocks.BRICK_WALL.withPropertiesOf(state);
check(state.getCollisionShape(level,pos).toAabbs().equals(vanilla.getCollisionShape(level,pos).toAabbs()),"Collision equals native brick wall");
level.removeBlock(pos.above(),false);
}
var end=new BlockPos(16,1,10);level.setBlockAndUpdate(end,Blocks.BRICK_WALL.defaultBlockState());
check(level.getBlockState(end.west()).getValue(WallBlock.EAST)==WallSide.LOW,"Connects to vanilla brick wall");
saved(level);
}
static void creative(List<ItemStack> display){
var colors=List.of(DyeColor.WHITE,DyeColor.LIGHT_GRAY,DyeColor.GRAY,DyeColor.BLACK,DyeColor.BROWN,DyeColor.RED,DyeColor.ORANGE,DyeColor.YELLOW,DyeColor.LIME,DyeColor.GREEN,DyeColor.CYAN,DyeColor.LIGHT_BLUE,DyeColor.BLUE,DyeColor.PURPLE,DyeColor.MAGENTA,DyeColor.PINK);
var families=ColoredBricks.families().stream().sorted(Comparator.comparingInt(f->colors.indexOf(f.color()))).toList();
int start=-1;for(int i=0;i<display.size();i++)if(display.get(i).is(families.getFirst().wall().asItem())){start=i;break;}
check(start>0,"Wall series is visible in Colored Blocks");
for(int i=0;i<16;i++)check(display.get(start+i).is(families.get(i).wall().asItem()),"Contiguous series of sixteen in native color order");
check(display.get(start-1).is(families.getLast().slab().asItem())&&display.get(start+16).is(families.getFirst().clay().asItem()),"Wall series follows slabs and precedes clay");
}
static void saved(ServerLevel level){
int i=0;for(var family:ColoredBricks.families()){
check(level.getBlockState(new BlockPos(i,1,10)).is(family.wall()),"Persisted wall color");
var wet=level.getBlockState(new BlockPos(i++,1,12));check(wet.is(family.wall())&&wet.getValue(WallBlock.WATERLOGGED)&&wet.getFluidState().isSource(),"Persisted waterlogged wall");
}
}
private static void check(boolean ok,String message){if(!ok)throw new AssertionError(message);}
}
@@ -0,0 +1,140 @@
package fr.koka.sanctuary.client;
import com.mojang.blaze3d.platform.InputConstants;
import fr.koka.sanctuary.gametest.Starter039TestSupport;
import fr.koka.sanctuary.multiblock.*;
import java.util.*;
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.fabricmc.fabric.api.client.renderer.v1.Renderer;
import net.minecraft.client.Minecraft;
import net.minecraft.client.gui.components.Button;
import net.minecraft.client.gui.screens.worldselection.WorldCreationUiState;
import net.minecraft.client.input.KeyEvent;
import net.minecraft.core.*;
import net.minecraft.core.registries.BuiltInRegistries;
import net.minecraft.network.chat.contents.TranslatableContents;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.util.RandomSource;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.item.*;
import net.minecraft.world.level.GameType;
import net.minecraft.world.level.block.*;
import net.minecraft.world.level.block.entity.*;
import net.minecraft.world.level.block.piston.PistonBaseBlock;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.block.state.properties.*;
import net.minecraft.world.phys.*;
/** Real client gestures and persisted server state; rendered UVs must change without moving geometry. */
public final class Wrench120ClientChecks implements FabricClientGameTest {
private static final BlockPos TARGET=new BlockPos(0,1,0),CHEST=new BlockPos(2,1,0),DIRT=new BlockPos(-2,1,0);
public void runTest(ClientGameTestContext c){
var intro=new AtomicBoolean(true);
c.runOnClient(m->{m.options.renderDistance().set(6);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());
screen.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getMessage().getContents() instanceof TranslatableContents t&&t.getKey().equals("sanctuary.progression.enter")).findFirst().orElseThrow().onPress(enter());intro.set(false);
}});
net.fabricmc.fabric.api.client.gametest.v1.world.TestWorldSave saved;
try(var world=c.worldBuilder().adjustSettings(s->{s.setName("Wrench120 "+UUID.randomUUID());s.setSeed("120");s.setAllowCommands(true);s.setGameMode(WorldCreationUiState.SelectedGameMode.CREATIVE);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);var server=world.getServer();
server.runOnServer(s->{var p=player(s);var l=p.level();p.teleportTo(.5,1,3.2);p.getInventory().setItem(p.getInventory().getSelectedSlot(),new ItemStack(Multiblocks.STEVE_WRENCH));p.inventoryMenu.broadcastFullState();
l.setBlockAndUpdate(TARGET,Blocks.OAK_STAIRS.defaultBlockState());l.setBlockAndUpdate(CHEST,Blocks.CHEST.defaultBlockState());((ChestBlockEntity)l.getBlockEntity(CHEST)).setItem(0,new ItemStack(Items.DIAMOND,23));l.setBlockAndUpdate(DIRT,Blocks.DIRT.defaultBlockState());
s.getCommands().performPrefixedCommand(s.createCommandSourceStack(),"sanctuary time vanilla");s.getCommands().performPrefixedCommand(s.createCommandSourceStack(),"time set noon");
int tested=0;for(var block:BuiltInRegistries.BLOCK)for(var property:block.getStateDefinition().getProperties()){
var before=block.defaultBlockState();var after=GoldenWrench.cycle(before,property,false);
check(after.is(block)&&GoldenWrench.cycle(after,property,true)==before,"Every registered native property has reversible values");tested++;
}
check(tested>1000,"Broad block property coverage");System.out.println("WRENCH120_PROPERTIES "+tested);
});
c.waitFor(m->m.player.getMainHandItem().is(Multiblocks.STEVE_WRENCH)&&m.player.distanceToSqr(.5,1,3.2)<.1&&m.level.getBlockState(TARGET).is(Blocks.OAK_STAIRS),150);
var stairs=c.computeOnClient(m->m.level.getBlockState(TARGET));click(c,TARGET,false,false);
server.waitFor(s->player(s).level().getBlockState(TARGET)!=stairs,100);c.waitFor(m->m.level.getBlockState(TARGET)!=stairs,100);
server.runOnServer(s->{var actual=player(s).level().getBlockState(TARGET);check(actual.getValue(StairBlock.FACING)!=stairs.getValue(StairBlock.FACING)&&actual.getValue(StairBlock.HALF)==stairs.getValue(StairBlock.HALF),"Right click changes facing only");});
click(c,TARGET,false,true);server.waitFor(s->player(s).level().getBlockState(TARGET)==stairs,100);
click(c,TARGET,true,false);server.runOnServer(s->check(GoldenWrench.selected(player(s),stairs).equals("half"),"Left click selects half without breaking creative block: "+GoldenWrench.selected(player(s),stairs)));
click(c,TARGET,false,false);server.waitFor(s->player(s).level().getBlockState(TARGET).getValue(StairBlock.HALF)==Half.TOP,100);
click(c,TARGET,true,false);server.runOnServer(s->check(GoldenWrench.selected(player(s),stairs).equals("shape"),"Next property is stair shape"));
click(c,TARGET,false,false);server.waitFor(s->player(s).level().getBlockState(TARGET).getValue(StairBlock.SHAPE)!=StairsShape.STRAIGHT,100);
c.takeScreenshot("wrench120-stairs");
// A container rotates in place; inventory and block entity survive the full native interaction.
click(c,CHEST,false,false);server.runOnServer(s->{var p=player(s);var chest=(ChestBlockEntity)p.level().getBlockEntity(CHEST);check(chest.getItem(0).is(Items.DIAMOND)&&chest.getItem(0).getCount()==23,"Chest contents retained");check(p.containerMenu==p.inventoryMenu,"Wrench does not open ordinary chest");check(chest.getBlockState().getValue(ChestBlock.FACING)!=Blocks.CHEST.defaultBlockState().getValue(ChestBlock.FACING),"Chest rotates");});
// Ordinary survival without commands retains Steves full powers; dirt keeps its native ID, shape and loot.
server.runOnServer(s->{s.getWorldData().setAllowCommands(false);player(s).setGameMode(GameType.SURVIVAL);});c.waitFor(m->!m.player.isCreative(),100);
var original=c.computeOnClient(m->geometry(m,DIRT));c.takeScreenshot("wrench120-dirt-before");
click(c,DIRT,false,false);server.waitFor(s->WrenchTextures.rotation(player(s).level().getChunkAt(DIRT),DIRT)==1,100);c.waitFor(m->WrenchTextureModels.rotation(DIRT)==1,100);
var rotated=c.computeOnClient(m->geometry(m,DIRT));checkUvs(original,rotated);c.takeScreenshot("wrench120-dirt-90");
server.runOnServer(s->check(player(s).level().getBlockState(DIRT)==Blocks.DIRT.defaultBlockState(),"Dirt type and native state unchanged"));
click(c,DIRT,true,false);server.runOnServer(s->check(player(s).level().getBlockState(DIRT).is(Blocks.DIRT),"Left click never breaks dirt in survival"));
click(c,DIRT,false,true);c.waitFor(m->WrenchTextureModels.rotation(DIRT)==0,100);checkGeometry(original,c.computeOnClient(m->geometry(m,DIRT)),"Reverse restores exact UVs");
for(int i=1;i<=4;i++){click(c,DIRT,false,false);int expected=i%4;c.waitFor(m->WrenchTextureModels.rotation(DIRT)==expected,100);}
checkGeometry(original,c.computeOnClient(m->geometry(m,DIRT)),"Four turns restore exact original geometry and UVs");
click(c,DIRT,false,false);c.waitFor(m->WrenchTextureModels.rotation(DIRT)==1,100);
var reload=c.computeOnClient(Minecraft::reloadResourcePacks);c.waitFor(m->reload.isDone()&&m.gui.overlay()==null,1200);reload.join();checkGeometry(rotated,c.computeOnClient(m->geometry(m,DIRT)),"Resource reload retains texture rotation");
// Replacing a block clears the visual receipt; blockstate edits retain it.
server.runOnServer(s->{var l=player(s).level();var q=new BlockPos(-3,1,0);l.setBlockAndUpdate(q,Blocks.OAK_LOG.defaultBlockState());WrenchTextures.turn(l,q,1);l.setBlock(q,Blocks.OAK_LOG.defaultBlockState().setValue(RotatedPillarBlock.AXIS,Direction.Axis.X),18);check(WrenchTextures.rotation(l.getChunkAt(q),q)==1,"State edit preserves texture rotation");l.removeBlock(q,false);l.setBlockAndUpdate(q,Blocks.OAK_LOG.defaultBlockState());check(WrenchTextures.rotation(l.getChunkAt(q),q)==0,"Break/replacement clears rotation");
var p=player(s);p.setGameMode(GameType.ADVENTURE);check(!GoldenWrench.allowed(p,TARGET,InteractionHand.MAIN_HAND,Direction.UP),"Adventure cannot edit");p.setGameMode(GameType.SPECTATOR);check(!GoldenWrench.allowed(p,TARGET,InteractionHand.MAIN_HAND,Direction.UP),"Spectator cannot edit");p.setGameMode(GameType.SURVIVAL);check(!GoldenWrench.allowed(p,TARGET.offset(100,0,0),InteractionHand.MAIN_HAND,Direction.UP),"Out-of-range edit refused");
});
// A full Metabli still assembles with two normal right clicks, then separates with sneak-use.
var table=new BlockPos(6,1,0);server.runOnServer(s->{var p=player(s);p.getInventory().setItem(p.getInventory().getSelectedSlot(),new ItemStack(Multiblocks.WRENCH));p.inventoryMenu.broadcastFullState();for(var q:Metatable.members(table))p.level().setBlockAndUpdate(q,Blocks.CRAFTING_TABLE.defaultBlockState());p.teleportTo(7.5,1,4);});
c.waitFor(m->m.player.distanceToSqr(7.5,1,4)<.1&&m.level.getBlockState(table).is(Blocks.CRAFTING_TABLE),100);
click(c,table.offset(1,0,2),false,false);click(c,table.offset(1,0,2),false,false);
server.waitFor(s->player(s).level().getBlockState(table).is(Metatable.BLOCK),100);c.waitFor(m->m.level.getBlockState(table).is(Metatable.BLOCK),100);
click(c,table.offset(1,0,2),true,false);server.runOnServer(s->check(Metatable.state(player(s).level(),table,true)==1,"Left click cannot corrupt a multiblock's part states"));
click(c,table.offset(1,0,2),false,true);server.waitFor(s->Metatable.state(player(s).level(),table,false)==1,100);
// Both container assemblers and the piston assembler must win over generic state editing.
for(var component:List.of(Blocks.BARREL,Blocks.FURNACE)){
server.runOnServer(s->{var l=player(s).level();for(int y=0;y<3;y++)for(int z=0;z<3;z++)for(int x=0;x<3;x++)l.setBlockAndUpdate(table.offset(x,y,z),component.defaultBlockState());});
c.waitFor(m->m.level.getBlockState(table.offset(1,0,2)).is(component),100);
click(c,table.offset(1,0,2),false,false);click(c,table.offset(1,0,2),false,false);
server.runOnServer(s->check(MultiblockData.get(player(s).level()).at(table)!=null,"Complete container machine assembles before generic rotation"));
click(c,table.offset(1,0,2),false,true);
server.runOnServer(s->check(MultiblockData.get(player(s).level()).at(table)==null,"Container machine still separates"));
}
server.runOnServer(s->{var l=player(s).level();for(int y=2;y>=0;y--)for(int z=0;z<3;z++)for(int x=0;x<3;x++)l.setBlockAndUpdate(table.offset(x,y,z),y==0?Blocks.PISTON.defaultBlockState().setValue(PistonBaseBlock.FACING,Direction.UP):Blocks.AIR.defaultBlockState());});
c.waitFor(m->m.level.getBlockState(table.offset(1,0,2)).is(Blocks.PISTON),100);
click(c,table.offset(1,0,2),false,false);click(c,table.offset(1,0,2),false,false);
server.waitFor(s->SuperPistons.status(player(s).level(),table.offset(1,0,1),Direction.UP,false)==1,100);
c.waitFor(m->m.level.getBlockState(table.offset(1,0,2)).is(SuperPistons.NORMAL),100);
click(c,table.offset(1,0,2),false,true);
server.runOnServer(s->check(player(s).level().getBlockState(table.offset(1,0,2)).is(Blocks.PISTON),"Super piston still separates before editing its parts"));
// An isolated barrel and piston now use generic states instead of an incomplete-structure error.
server.runOnServer(s->{var p=player(s);p.teleportTo(.5,1,3.2);p.level().setBlockAndUpdate(TARGET,Blocks.BARREL.defaultBlockState());});c.waitFor(m->m.player.distanceToSqr(.5,1,3.2)<.1&&m.level.getBlockState(TARGET).is(Blocks.BARREL),100);
click(c,TARGET,false,false);server.runOnServer(s->check(player(s).level().getBlockState(TARGET).getValue(BarrelBlock.FACING)!=Blocks.BARREL.defaultBlockState().getValue(BarrelBlock.FACING),"Isolated barrel rotates"));
server.runOnServer(s->player(s).level().setBlockAndUpdate(TARGET,Blocks.PISTON.defaultBlockState()));c.waitFor(m->m.level.getBlockState(TARGET).is(Blocks.PISTON),100);
click(c,TARGET,false,false);server.runOnServer(s->check(player(s).level().getBlockState(TARGET).getValue(PistonBaseBlock.FACING)!=Blocks.PISTON.defaultBlockState().getValue(PistonBaseBlock.FACING),"Isolated piston rotates"));
server.runOnServer(s->player(s).level().setBlock(TARGET,stairs.setValue(StairBlock.HALF,Half.TOP),18));saved=world.getWorldSave();
}
try(var world=saved.open()){
c.waitFor(m->m.player!=null&&m.gui.screen()==null,1200);world.getServer().runOnServer(s->{var l=player(s).level();check(WrenchTextures.rotation(l.getChunkAt(DIRT),DIRT)==1,"Server persisted dirt texture");check(l.getBlockState(TARGET).getValue(StairBlock.HALF)==Half.TOP,"Server persisted native edited state");check(((ChestBlockEntity)l.getBlockEntity(CHEST)).getItem(0).getCount()==23,"Saved chest keeps inventory");});
c.waitFor(m->WrenchTextureModels.rotation(DIRT)==1,150);c.getInput().lookAt(DIRT);c.takeScreenshot("wrench120-reconnect");
}
System.out.println("WRENCH120_PASS native creative/survival clicks, reverse, properties, textures and resource reload, unchanged geometry, filled chest, multiblock assembly priority, permissions, cleanup and save/reconnect");
}
static void click(ClientGameTestContext c,BlockPos pos,boolean left,boolean sneak){
if(sneak){c.getInput().holdKey(o->o.keyShift);c.waitTicks(3);}
c.runOnClient(m->m.player.lookAt(net.minecraft.commands.arguments.EntityAnchorArgument.Anchor.EYES,new Vec3(pos.getX()+.5,pos.getY()+.3,pos.getZ()+.5)));c.waitTicks(3);
c.runOnClient(m->check(m.hitResult instanceof BlockHitResult hit&&hit.getBlockPos().equals(pos),"Native cursor hits target "+pos+" instead of "+m.hitResult));
if(left){c.getInput().holdKey(o->o.keyAttack);c.waitTicks(1);c.getInput().releaseKey(o->o.keyAttack);}else{c.getInput().holdKey(o->o.keyUse);c.waitTicks(1);c.getInput().releaseKey(o->o.keyUse);}
c.waitTicks(5);if(sneak){c.getInput().releaseKey(o->o.keyShift);c.waitTicks(3);}
}
private static List<float[]> geometry(Minecraft m,BlockPos pos){
var result=new ArrayList<float[]>();var state=m.level.getBlockState(pos);
var emitter=Renderer.get().quadEmitter(q->{var data=new float[20];for(int i=0;i<4;i++){data[i*5]=q.x(i);data[i*5+1]=q.y(i);data[i*5+2]=q.z(i);data[i*5+3]=q.u(i);data[i*5+4]=q.v(i);}result.add(data);});
m.getModelManager().getBlockStateModelSet().get(state).emitQuads(emitter,m.level,pos,state,RandomSource.create(state.getSeed(pos)),face->false);check(!result.isEmpty(),"Native model emits real textured geometry");return result;
}
private static void checkUvs(List<float[]> before,List<float[]> after){
check(before.size()==after.size(),"Same face count");boolean changed=false;
for(int q=0;q<before.size();q++)for(int v=0;v<4;v++){for(int axis=0;axis<3;axis++)check(before.get(q)[v*5+axis]==after.get(q)[v*5+axis],"Texture turn never moves geometry");changed|=before.get(q)[v*5+3]!=after.get(q)[v*5+3]||before.get(q)[v*5+4]!=after.get(q)[v*5+4];}
check(changed,"Dirt UVs actually change");
}
private static void checkGeometry(List<float[]> a,List<float[]> b,String message){check(a.size()==b.size(),message);for(int i=0;i<a.size();i++)check(Arrays.equals(a.get(i),b.get(i)),message);}
private static ServerPlayer player(MinecraftServer s){return s.getPlayerList().getPlayers().getFirst();}
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);}
}
@@ -0,0 +1,111 @@
package fr.koka.sanctuary.client;
import com.mojang.blaze3d.platform.InputConstants;
import fr.koka.sanctuary.gametest.Starter039TestSupport;
import fr.koka.sanctuary.multiblock.*;
import java.util.*;
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.fabricmc.fabric.api.event.player.*;
import net.minecraft.client.gui.components.Button;
import net.minecraft.client.gui.screens.worldselection.WorldCreationUiState;
import net.minecraft.client.input.KeyEvent;
import net.minecraft.core.*;
import net.minecraft.core.component.DataComponents;
import net.minecraft.network.chat.contents.TranslatableContents;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.*;
import net.minecraft.world.item.*;
import net.minecraft.world.level.GameType;
import net.minecraft.world.level.block.*;
import net.minecraft.world.level.block.entity.ChestBlockEntity;
import net.minecraft.world.level.block.piston.PistonBaseBlock;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.block.state.properties.*;
import net.minecraft.world.phys.*;
/** Native golden-wrench clicks cannot create resources or split linked blocks. */
public final class Wrench123ClientChecks implements FabricClientGameTest {
private static final BlockPos TARGET=new BlockPos(0,1,0),CHEST=new BlockPos(2,1,0),DIRT=new BlockPos(-2,1,0);
public void runTest(ClientGameTestContext c){
var intro=new AtomicBoolean(true);
c.runOnClient(m->{m.options.renderDistance().set(6);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(new KeyEvent(InputConstants.KEY_RETURN,0,0));
screen.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getMessage().getContents() instanceof TranslatableContents t&&t.getKey().equals("sanctuary.progression.enter")).findFirst().orElseThrow().onPress(new KeyEvent(InputConstants.KEY_RETURN,0,0));intro.set(false);
}});
try(var world=c.worldBuilder().adjustSettings(s->{s.setName("Wrench123 "+UUID.randomUUID());s.setSeed("123");s.setAllowCommands(true);s.setGameMode(WorldCreationUiState.SelectedGameMode.CREATIVE);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);var server=world.getServer();
server.runOnServer(s->{var p=player(s);var l=p.level();for(int x=-5;x<=8;x++)for(int z=-4;z<=6;z++)l.setBlockAndUpdate(new BlockPos(x,0,z),Blocks.STONE.defaultBlockState());p.teleportTo(.5,1,3.2);equip(p,Multiblocks.WRENCH);
l.setBlockAndUpdate(TARGET,Blocks.OAK_STAIRS.defaultBlockState());l.setBlockAndUpdate(CHEST,Blocks.CHEST.defaultBlockState());((ChestBlockEntity)l.getBlockEntity(CHEST)).setItem(0,new ItemStack(Items.DIAMOND,23));l.setBlockAndUpdate(DIRT,Blocks.DIRT.defaultBlockState());
s.getCommands().performPrefixedCommand(s.createCommandSourceStack(),"sanctuary time vanilla");s.getCommands().performPrefixedCommand(s.createCommandSourceStack(),"time set noon");
});
c.waitFor(m->m.player.getMainHandItem().is(Multiblocks.WRENCH)&&m.player.distanceToSqr(.5,1,3.2)<.1&&m.level.getBlockState(TARGET).is(Blocks.OAK_STAIRS),150);
var stairs=c.computeOnClient(m->m.level.getBlockState(TARGET));
Wrench120ClientChecks.click(c,TARGET,false,false);server.runOnServer(s->check(player(s).level().getBlockState(TARGET).getValue(StairBlock.FACING)!=stairs.getValue(StairBlock.FACING),"Golden wrench changes native stair facing"));
Wrench120ClientChecks.click(c,TARGET,false,true);server.waitFor(s->player(s).level().getBlockState(TARGET)==stairs,100);
for(int i=0;i<5;i++){Wrench120ClientChecks.click(c,TARGET,true,false);Wrench120ClientChecks.click(c,TARGET,false,false);}
server.runOnServer(s->{var state=player(s).level().getBlockState(TARGET);check(!state.getValue(StairBlock.WATERLOGGED)&&state.getValue(StairBlock.HALF)==Half.BOTTOM&&state.getValue(StairBlock.SHAPE)==StairsShape.STRAIGHT,"Repeated native clicks never select water, half or fake stair shapes");});
for(int i=0;i<4;i++)Wrench120ClientChecks.click(c,CHEST,false,false);
server.runOnServer(s->{var p=player(s);var be=(ChestBlockEntity)p.level().getBlockEntity(CHEST);check(be.getItem(0).getCount()==23&&be.getBlockState()==Blocks.CHEST.defaultBlockState(),"Single chest remains single, dry and filled after four rotations");check(p.containerMenu==p.inventoryMenu,"Ordinary rotation does not open chest");});
Wrench120ClientChecks.click(c,DIRT,true,false);Wrench120ClientChecks.click(c,DIRT,false,false);
server.runOnServer(s->{var l=player(s).level();check(WrenchTextures.rotation(l.getChunkAt(DIRT),DIRT)==0&&l.getBlockState(DIRT).is(Blocks.DIRT),"Golden wrench never adds a texture transform");regressions(player(s));});
c.takeScreenshot("wrench123-golden-safe");
// Finding Steve's wrench grants its full powers in ordinary survival, without operator status.
server.runOnServer(s->{var p=player(s);p.setGameMode(GameType.SURVIVAL);s.getWorldData().setAllowCommands(false);equip(p,Multiblocks.STEVE_WRENCH);});
c.waitFor(m->!m.player.isCreative()&&m.player.getMainHandItem().is(Multiblocks.STEVE_WRENCH),100);
Wrench120ClientChecks.click(c,DIRT,true,false);Wrench120ClientChecks.click(c,DIRT,false,false);
server.waitFor(s->WrenchTextures.rotation(player(s).level().getChunkAt(DIRT),DIRT)==1,100);c.waitFor(m->WrenchTextureModels.rotation(DIRT)==1,100);
Wrench120ClientChecks.click(c,TARGET,false,false);Wrench120ClientChecks.click(c,TARGET,true,false);Wrench120ClientChecks.click(c,TARGET,false,false);
server.runOnServer(s->{var p=player(s);check(p.level().getBlockState(TARGET).getValue(StairBlock.HALF)==Half.TOP,"Native Steve edit works for ordinary survival without commands");
for(var hand:InteractionHand.values()){
p.setItemInHand(hand,new ItemStack(Multiblocks.STEVE_WRENCH));check(GoldenWrench.allowed(p,TARGET,hand,Direction.UP),"Survival use allowed in both hands");
GoldenWrench.change(p,DIRT,hand,Direction.UP);
}
check(WrenchTextures.rotation(p.level().getChunkAt(DIRT),DIRT)==3,"Both hands can freely edit textures in survival");
p.setItemInHand(InteractionHand.OFF_HAND,ItemStack.EMPTY);p.setGameMode(GameType.CREATIVE);check(GoldenWrench.allowed(p,TARGET,InteractionHand.MAIN_HAND,Direction.UP),"Creative access without commands enabled");
p.setGameMode(GameType.ADVENTURE);check(!GoldenWrench.allowed(p,TARGET,InteractionHand.MAIN_HAND,Direction.UP),"Adventure restrictions remain");p.setGameMode(GameType.SPECTATOR);check(!GoldenWrench.allowed(p,TARGET,InteractionHand.MAIN_HAND,Direction.UP),"Spectator cannot edit");p.setGameMode(GameType.CREATIVE);
var steve=new ItemStack(Multiblocks.STEVE_WRENCH);check(Boolean.TRUE.equals(steve.get(DataComponents.ENCHANTMENT_GLINT_OVERRIDE)),"Distinct enchanted Steve item");
});
c.waitFor(m->m.player.isCreative(),100);c.takeScreenshot("wrench123-steve-texture");
System.out.println("WRENCH123_SAFE_PASS native clicks, water/inventory preservation, linked blocks, real neighbour shapes, support checks, ordinary survival and creative use, adventure/spectator restrictions and both hands");
}
// Keep the full former debug-stick contract on Steve, including UVs/save/reload, and assembly on gold.
new Wrench120ClientChecks().runTest(c);
System.out.println("WRENCH123_PASS safe golden wrench and Steve wrench with native regression coverage");
}
private static void regressions(ServerPlayer p){
var l=p.level();
// Water stays present when rotating a legitimately waterlogged block.
l.setBlock(TARGET,Blocks.OAK_STAIRS.defaultBlockState().setValue(StairBlock.WATERLOGGED,true),18);
use(p,TARGET);check(l.getBlockState(TARGET).getValue(StairBlock.WATERLOGGED),"Existing water is preserved");
l.setBlock(TARGET,Blocks.OAK_STAIRS.defaultBlockState().setValue(StairBlock.FACING,Direction.WEST).setValue(StairBlock.SHAPE,StairsShape.INNER_LEFT),18);
use(p,TARGET);check(l.getBlockState(TARGET).getValue(StairBlock.SHAPE)==StairsShape.STRAIGHT&&!l.getBlockState(TARGET).getValue(StairBlock.WATERLOGGED),"Native shape recomputed against actual empty neighbours");
var a=CHEST;var b=CHEST.east();var base=Blocks.CHEST.defaultBlockState().setValue(ChestBlock.FACING,Direction.NORTH);
l.setBlock(a,base.setValue(ChestBlock.TYPE,ChestType.LEFT),18);l.setBlock(b,base.setValue(ChestBlock.TYPE,ChestType.RIGHT),18);
var first=(ChestBlockEntity)l.getBlockEntity(a);var second=(ChestBlockEntity)l.getBlockEntity(b);first.setItem(0,new ItemStack(Items.DIAMOND,23));second.setItem(0,new ItemStack(Items.EMERALD,17));
var left=l.getBlockState(a);var right=l.getBlockState(b);use(p,a);use(p,b);
check(l.getBlockState(a)==left&&l.getBlockState(b)==right&&l.getBlockEntity(a)==first&&l.getBlockEntity(b)==second&&first.getItem(0).getCount()==23&&second.getItem(0).getCount()==17,"Double chest remains linked with both original inventories");
var q=TARGET;var door=Blocks.OAK_DOOR.defaultBlockState();l.setBlock(q,door.setValue(DoorBlock.HALF,DoubleBlockHalf.LOWER),18);l.setBlock(q.above(),door.setValue(DoorBlock.HALF,DoubleBlockHalf.UPPER),18);
var lower=l.getBlockState(q);var upper=l.getBlockState(q.above());use(p,q);use(p,q.above());check(l.getBlockState(q)==lower&&l.getBlockState(q.above())==upper,"Both door halves protected");l.setBlock(q.above(),Blocks.AIR.defaultBlockState(),18);
var bed=Blocks.BED.red().defaultBlockState();l.setBlock(q,bed,18);use(p,q);check(l.getBlockState(q)==bed,"Bed half protected even if malformed already");
var extended=Blocks.PISTON.defaultBlockState().setValue(PistonBaseBlock.EXTENDED,true);l.setBlock(q,extended,18);use(p,q);check(l.getBlockState(q)==extended,"Extended piston protected");
for(var block:List.of(Blocks.PISTON_HEAD,Blocks.MOVING_PISTON)){l.setBlock(q,block.defaultBlockState(),18);use(p,q);check(l.getBlockState(q)==block.defaultBlockState(),"Attached/moving piston part protected");}
l.setBlock(q,Blocks.OAK_LOG.defaultBlockState(),18);var axes=EnumSet.noneOf(Direction.Axis.class);for(int i=0;i<3;i++){use(p,q);axes.add(l.getBlockState(q).getValue(RotatedPillarBlock.AXIS));}check(axes.size()==3,"All three native log axes supported");
l.setBlock(q,Blocks.PISTON.defaultBlockState(),18);var facings=EnumSet.noneOf(Direction.class);for(int i=0;i<6;i++){use(p,q);facings.add(l.getBlockState(q).getValue(PistonBaseBlock.FACING));}check(facings.size()==6,"Isolated piston supports all six native orientations");
// Turning a wall torch toward empty air must not break it or its support.
l.setBlock(q.north(),Blocks.STONE.defaultBlockState(),18);var torch=Blocks.WALL_TORCH.defaultBlockState().setValue(WallTorchBlock.FACING,Direction.SOUTH);l.setBlock(q,torch,18);use(p,q);check(l.getBlockState(q)==torch,"Unsupported rotation leaves wall-mounted block intact");l.setBlock(q.north(),Blocks.AIR.defaultBlockState(),18);
l.setBlock(q,Blocks.OAK_STAIRS.defaultBlockState(),18);
var top=GoldenWrench.cycle(l.getBlockState(q),StairBlock.HALF,false);check(top.getValue(StairBlock.HALF)==Half.TOP,"Steve still has native debug property cycle");
// Existing visual edits are preserved; the ordinary key cannot create or reset them.
WrenchTextures.turn(l,q,1);use(p,q);check(WrenchTextures.rotation(l.getChunkAt(q),q)==1,"Golden rotation preserves saved Steve texture data");WrenchTextures.turn(l,q,-1);
}
private static void use(ServerPlayer p,BlockPos pos){UseBlockCallback.EVENT.invoker().interact(p,p.level(),InteractionHand.MAIN_HAND,new BlockHitResult(Vec3.atCenterOf(pos),Direction.UP,pos,false));}
private static void equip(ServerPlayer p,Item item){p.getInventory().setItem(p.getInventory().getSelectedSlot(),new ItemStack(item));p.inventoryMenu.broadcastFullState();}
private static ServerPlayer player(MinecraftServer s){return s.getPlayerList().getPlayers().getFirst();}
private static void check(boolean ok,String message){if(!ok)throw new AssertionError(message);}
}
@@ -0,0 +1,140 @@
package fr.koka.sanctuary.client.shader;
import static fr.koka.sanctuary.client.shader.PixelShadows122ClientChecks.*;
import com.mojang.blaze3d.platform.NativeImage;
import java.nio.file.*;
import java.util.*;
import java.util.concurrent.CompletableFuture;
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.client.Screenshot;
import net.minecraft.core.BlockPos;
import net.minecraft.core.registries.BuiltInRegistries;
import net.minecraft.resources.Identifier;
import net.minecraft.world.level.block.Blocks;
/** Actual source texels, native GPU masks, occlusion and user controls in an isolated world. */
public final class Bloom130ClientChecks implements FabricClientGameTest {
public void runTest(ClientGameTestContext c){new PixelShadows122ClientChecks(true).runTest(c);}
static final List<String> ORES=ores();
private static List<String> ores(){var result=new ArrayList<String>();for(String name:List.of("iron","copper","redstone","lapis","diamond","emerald","ruby","sapphire","gold")){
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();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());
for(int y=65;y<=70;y++)s.overworld().setBlockAndUpdate(new BlockPos(x,y,z),Blocks.AIR.defaultBlockState());
}
for(int i=0;i<ORES.size();i++){var state=BuiltInRegistries.BLOCK.getValue(Identifier.parse(ORES.get(i))).defaultBlockState();check(state.getLightEmission()==0,"Ores do not acquire server-side light: "+ORES.get(i));s.overworld().setBlockAndUpdate(orePos(i),state);}
s.overworld().setBlockAndUpdate(new BlockPos(14,65,9),Blocks.REDSTONE_LAMP.defaultBlockState());
});
camera(c,server,10.5,71,9.5,60);c.waitTicks(40);c.waitFor(m->m.levelRenderer.hasRenderedAllSections(),1200);
var off=capture(c,"bloom-ores-off");
c.runOnClient(m->{VanillaLight.setOreEmission(false);VanillaLight.setBloom(true);});frames(c);
check(luminous(emission(c))==0,"White/grey rock, ordinary surfaces and unlit lamps do not seed bloom");
unchanged(off,capture(c,"bloom-ores-disabled"),"Disabling ore emission leaves the original scene intact");
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);
c.runOnClient(m->VanillaLight.setBloomRadius(0));frames(c);var tight=capture(c,"bloom-spread-0");
c.runOnClient(m->VanillaLight.setBloomRadius(100));frames(c);var wide=capture(c,"bloom-spread-100");
check(difference(tight,wide)>100,"Spread changes the real GPU halo");
c.runOnClient(m->VanillaLight.setOreIntensity(0));frames(c);check(luminous(emission(c))==0,"Zero ore strength suppresses both cores and halos");
unchanged(off,capture(c,null),"Zero ore strength restores the source image");
c.runOnClient(m->{VanillaLight.setOreIntensity(100);VanillaLight.setBloomIntensity(35);VanillaLight.setBloomRadius(50);});frames(c);
server.runOnServer(s->{for(int i=0;i<ORES.size();i++)s.overworld().setBlockAndUpdate(orePos(i).above(),Blocks.STONE.defaultBlockState());});c.waitTicks(25);frames(c);
check(luminous(emission(c))==0,"Covering veins with stone removes all emission: no through-wall ore glow");
capture(c,"bloom-covered-ores");
server.runOnServer(s->{for(int i=0;i<ORES.size();i++)s.overworld().setBlockAndUpdate(orePos(i).above(),Blocks.AIR.defaultBlockState());});c.waitTicks(25);frames(c);
check(luminous(emission(c))>100,"Removing covers reveals the genuine visible inclusion masks again");
var reload=c.computeOnClient(net.minecraft.client.Minecraft::reloadResourcePacks);c.waitFor(m->reload.isDone()&&m.gui.overlay()==null,1200);reload.join();frames(c);
check(luminous(emission(c))>100,"Resource reload rebuilds emitter meshes and masks");
c.runOnClient(m->VanillaLight.setOreEmission(false));
server.runOnServer(s->s.overworld().setBlockAndUpdate(new BlockPos(14,65,9),Blocks.TORCH.defaultBlockState()));c.waitTicks(25);frames(c);
int torch=luminous(emission(c));check(torch>3,"A native torch seeds bloom: "+torch);capture(c,"bloom-torch");
server.runOnServer(s->s.overworld().setBlockAndUpdate(new BlockPos(14,65,9),Blocks.GLOWSTONE.defaultBlockState()));c.waitTicks(25);frames(c);
check(luminous(emission(c))>torch,"A full luminous block contributes its real textured surface");capture(c,"bloom-glowstone");
server.runOnServer(s->s.overworld().setBlockAndUpdate(new BlockPos(14,65,9),Blocks.STONE.defaultBlockState()));c.waitTicks(25);frames(c);
check(luminous(emission(c))==0,"Replacing the light source removes its cached emission");
server.runOnServer(s->{s.overworld().setBlockAndUpdate(new BlockPos(14,65,9),Blocks.REDSTONE_LAMP.defaultBlockState());s.overworld().setBlockAndUpdate(new BlockPos(14,64,9),Blocks.REDSTONE_BLOCK.defaultBlockState());});
c.waitTicks(25);frames(c);check(luminous(emission(c))>10,"Powered lamp emits through its on-state texture");capture(c,"bloom-lamp-on");
server.runOnServer(s->s.overworld().setBlockAndUpdate(new BlockPos(14,64,9),Blocks.CONCRETE.black().defaultBlockState()));
c.waitTicks(25);frames(c);check(luminous(emission(c))==0,"Unpowered lamp loses its glow after its native state update");
server.runOnServer(s->s.overworld().setBlockAndUpdate(new BlockPos(14,65,9),Blocks.LAVA.defaultBlockState()));
c.waitTicks(25);frames(c);check(luminous(emission(c))>10,"Native visible lava seeds bloom");capture(c,"bloom-lava");
server.runOnServer(s->command(s,"time set 3000"));camera(c,server,10.5,90,9.5,70);
var direction=c.computeOnClient(m->PixelShadows.sunDirection(m.gameRenderer.gameRenderState().levelRenderState.skyRenderState.sunAngle));
look(c,server,(float)Math.toDegrees(Math.atan2(-direction.x,direction.z)),-(float)Math.toDegrees(Math.asin(direction.y)));
c.runOnClient(m->VanillaLight.setBloom(false));c.waitTicks(8);var sunOff=capture(c,"bloom-sun-off");
c.runOnClient(m->VanillaLight.setBloom(true));frames(c);var sunMask=emission(c);
check(luminous(sunMask)>40,"The actual sun disc seeds bloom");
double sunlight=gain(sunOff,capture(c,"bloom-sun-on"),sunMask);check(sunlight>100,"Sun halo extends beyond its source disc: "+sunlight);
server.runOnServer(s->{var eye=s.getPlayerList().getPlayers().getFirst().getEyePosition();var center=BlockPos.containing(eye.x+direction.x*8,eye.y+direction.y*8,eye.z+direction.z*8);
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),Blocks.STONE.defaultBlockState());});
c.waitTicks(30);frames(c);check(luminous(emission(c))==0,"Solid terrain in front of the sun suppresses its bloom source");capture(c,"bloom-sun-occluded");
c.runOnClient(m->VanillaLight.setBloom(false));c.waitTicks(6);var coveredOff=capture(c,null);
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("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.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);}}
private static void analyseTextures(){
var report=new StringBuilder("texture,selected,total,neutral_rock_selected\n");
try(var materials=new BloomMaterials();var sheet=new NativeImage(5*16*6,4*32*6,false)){
sheet.fillRect(0,0,sheet.getWidth(),sheet.getHeight(),0xFF16191E);
for(int index=0;index<ORES.size();index++){
var block=Identifier.parse(ORES.get(index));var id=block.withPath("block/"+block.getPath());
try(var image=BloomMaterials.read(id)){
int w=image.getWidth(),h=image.getHeight();int[] pixels=new int[w*h];for(int y=0;y<h;y++)for(int x=0;x<w;x++)pixels[y*w+x]=image.getPixel(x,y);
var mask=materials.mask(id,pixels,w,h,true,false);int count=0,grey=0;
for(int i=0;i<mask.length;i++){if(mask[i]>0){count++;int c=pixels[i],r=c>>16&255,g=c>>8&255,b=c&255;if(r==g&&g==b&&r<215)grey++;}}
check(count>=4&&count<w*h*.7,"Texture has a selective inclusion mask: "+id+" "+count+"/"+mask.length);
if(!id.getPath().contains("quartz"))check(grey==0,"Neutral host rock never emits: "+id);
report.append(id).append(',').append(count).append(',').append(mask.length).append(',').append(grey).append('\n');
for(int y=0;y<32*6;y++)for(int x=0;x<16*6;x++){
int source=(y/6%16)*w/16*w+(x/6)*w/16;int color=pixels[source];if(y>=16*6&&mask[source]==0)color=0xFF16191E;
sheet.setPixel((index%5)*16*6+x,(index/5)*32*6+y,color);
}
}
}
var root=FabricLoader.getInstance().getGameDir();sheet.writeToFile(root.resolve("bloom130-ore-masks.png"));Files.writeString(root.resolve("bloom130-ore-masks.csv"),report.toString());
System.out.println("BLOOM130_MASKS\n"+report);
}catch(java.io.IOException e){throw new AssertionError(e);}
}
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 Frame emission(ClientGameTestContext c){var result=new CompletableFuture<Frame>();c.runOnClient(m->Screenshot.takeScreenshot(Bloom.emissionTarget(),image->{try(image){result.complete(new Frame(image.getWidth(),image.getHeight(),image.getPixels()));}}));c.waitFor(m->result.isDone(),200);return result.join();}
private static int luminous(Frame frame){int count=0;for(int color:frame.pixels())if((color&0xFFFFFF)!=0)count++;return count;}
private static double gain(Frame off,Frame on,Frame source){double result=0;for(int i=0;i<off.pixels().length;i++)if(source==null||(source.pixels()[i]&0xFFFFFF)==0)result+=Math.max(0,brightness(on.pixels()[i])-brightness(off.pixels()[i]));return result;}
private static int difference(Frame a,Frame b){int n=0;for(int i=0;i<a.pixels().length;i++)if(channelDifference(a.pixels()[i],b.pixels()[i])>3)n++;return n;}
}
@@ -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;}
}
@@ -0,0 +1,222 @@
package fr.koka.sanctuary.client.shader;
import static fr.koka.sanctuary.client.shader.PixelShadows122ClientChecks.*;
import java.nio.file.Files;
import java.util.ArrayList;
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 org.joml.Matrix4f;
import org.joml.Vector4f;
/** Native GPU checks sharing the isolated flat-world fixture and its preference backup. */
final class EdgeHighlights129ClientChecks {
static void run(ClientGameTestContext c, TestServerContext server) {
c.runOnClient(m -> preferences());
server.runOnServer(s -> {
for (int x=10;x<=14;x++) for (int z=6;z<=10;z++) {
s.overworld().setBlockAndUpdate(new BlockPos(x,65,z),
(Math.floorMod(x,8)<4 ? Blocks.STONE : Blocks.OAK_PLANKS).defaultBlockState());
}
});
camera(c,server,12.5,72,8.5,60);
c.waitTicks(35);
shadows(c,false);
edge(c,false);
var off=capture(c,"edges-flat-off");
c.waitTicks(6);
unchanged(off,capture(c,null),"Highlight OFF control is stable");
edge(c,true);
var on=capture(c,"edges-flat-on-30");
var low=measure(off,on);
check(low.brighter>100 && low.stable>low.samples*.55 && low.darker<low.samples*.005,
"Highlight brightens local strips, preserves face interiors: "+low);
grid(c,off,on);
c.runOnClient(m -> VanillaLight.setShadowPixels(8));frames(c);
var coarse=measure(off,capture(c,"edges-shared-precision-8"));
c.runOnClient(m -> VanillaLight.setShadowPixels(32));frames(c);
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}) {
c.runOnClient(m -> VanillaLight.setEdgeDistance(distance));
frames(c);
check(c.computeOnClient(m -> VanillaLight.edgeDistance())==distance,"All CTM ranges are available");
unchanged(on,capture(c,null),"Near CTM contours unchanged at range "+distance);
}
c.runOnClient(m -> VanillaLight.setEdgeDistance(32));
c.runOnClient(m -> {
check(PixelShadows.shadowMap()==null,"Highlight works without allocating a cast-shadow map");
VanillaLight.setEdgeIntensity(90);
});
frames(c);
var high=measure(off,capture(c,"edges-flat-on-90"));
check(high.meanGain>low.meanGain*1.8,"Intensity changes visible brightness: low="+low+" high="+high);
c.runOnClient(m -> VanillaLight.setEdgeIntensity(0));
c.waitTicks(6);
c.runOnClient(m -> check(!EdgeHighlights.applied() && !EdgeHighlights.allocated(),"Zero intensity frees its uniform buffer"));
unchanged(off,capture(c,"edges-zero"),"Zero intensity reproduces OFF exactly");
c.runOnClient(m -> VanillaLight.setEdgeIntensity(30));
frames(c);
camera(c,server,11.99999,72,8.5,60);
var anchor=capture(c,null);
camera(c,server,12.00001,72,8.5,60);
unchanged(anchor,capture(c,"edges-anchor-crossing"),"Grid remains stable across an integer camera anchor");
camera(c,server,12.5,72,8.5,60);
unchanged(on,capture(c,null),"Returning to the camera restores the same grid");
var reload=c.computeOnClient(net.minecraft.client.Minecraft::reloadResourcePacks);
c.waitFor(m -> reload.isDone() && m.gui.overlay()==null,1200);
reload.join();
c.runOnClient(m -> {
forgetPreferences();
check(VanillaLight.edgeHighlights() && VanillaLight.edgeIntensity()==30,"Highlight preferences survive reload and disk reread");
});
frames(c);
unchanged(on,capture(c,"edges-after-reload"),"Resource reload retains the highlight image");
edge(c,false);
unchanged(off,capture(c,"edges-off-restored"),"Disabling leaves no residual highlight");
// A new hole creates a connected boundary immediately; filling it removes that boundary.
server.runOnServer(s -> s.overworld().setBlockAndUpdate(new BlockPos(12,65,8),Blocks.AIR.defaultBlockState()));
c.waitTicks(20);
var holeOff=capture(c,null);
edge(c,true);
var hole=measure(holeOff,capture(c,"edges-ctm-hole"));
check(hole.brighter>low.brighter+20,"Removing an interior block creates a new CTM contour");
server.runOnServer(s -> s.overworld().setBlockAndUpdate(new BlockPos(12,65,8),Blocks.STONE.defaultBlockState()));
c.waitTicks(20);
unchanged(on,capture(c,null),"Filling the hole reconnects the flat surface");
// The actual radius gates a visible contour, independently of shadow range.
camera(c,server,12.5,88,8.5,30);
edge(c,false);
var farOff=capture(c,null);
c.runOnClient(m -> VanillaLight.setEdgeDistance(16));
edge(c,true);
unchanged(farOff,capture(c,"edges-beyond-16"),"CTM beyond selected 16-block radius is absent");
c.runOnClient(m -> {VanillaLight.setEdgeDistance(64);VanillaLight.setEdgeIntensity(90);});
frames(c);
var far=measure(farOff,capture(c,"edges-inside-64"));
check(far.brighter>40,"Extending the CTM radius restores a distant contour: "+far);
c.runOnClient(m -> {VanillaLight.setEdgeDistance(32);VanillaLight.setEdgeIntensity(30);});
edge(c,false);
server.runOnServer(s -> {
for (int x=9;x<=13;x+=2) for(int y=65;y<=67;y++)
s.overworld().setBlockAndUpdate(new BlockPos(x,y,12),
(x==9 ? Blocks.CONCRETE.red() : x==11 ? Blocks.STONE_BRICKS : Blocks.OAK_PLANKS).defaultBlockState());
s.overworld().setBlockAndUpdate(new BlockPos(10,65,11),Blocks.STONE_SLAB.defaultBlockState());
s.overworld().setBlockAndUpdate(new BlockPos(12,65,11),Blocks.OAK_STAIRS.defaultBlockState());
});
camera(c,server,12.5,72,7.5,70);
look(c,server,15,55);
shadows(c,true);
var reliefOff=capture(c,"edges-relief-with-shadows-off");
edge(c,true);
var reliefOn=capture(c,"edges-relief-with-shadows-on");
var relief=measure(reliefOff,reliefOn);
check(relief.brighter>100 && relief.stable>relief.samples*.5 && relief.darker<relief.samples*.01,
"Highlight composes with cast shadows and coloured textured blocks: "+relief);
look(c,server,15.00002f,55.00002f);
unchanged(reliefOn,capture(c,null),"Tiny oblique camera rotation keeps highlights stable");
c.runOnClient(m -> VanillaLight.setEnabled(false));
c.waitTicks(6);
c.runOnClient(m -> check(!EdgeHighlights.applied() && !EdgeHighlights.allocated(),"Main shader OFF also releases highlight resources"));
c.runOnClient(m -> VanillaLight.setEnabled(true));
frames(c);
edge(c,false);
System.out.println("EDGE_HIGHLIGHTS129_PASS connected contours and unchanged flat joins; independent shadows; intensity; zero/OFF release; "
+"camera anchors; oblique textured scene; resource reload; legacy preference migration; low="+low+" high="+high+" relief="+relief);
}
private static void preferences() {
var path=FabricLoader.getInstance().getConfigDir().resolve("sanctuary-shaders.json");
try {
// 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,"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();
check(VanillaLight.edgeIntensity()==Math.clamp(value,0,100),"Saved intensity is bounded");
}
for(int[] pair:new int[][]{{-1,16},{24,32},{48,64},{96,128},{192,256},{9999,256}}) {
VanillaLight.setEdgeDistance(pair[0]);forgetPreferences();
check(VanillaLight.edgeDistance()==pair[1],"Saved CTM distance is bounded and normalized");
}
VanillaLight.setEdgeDistance(32);
VanillaLight.setEdgeIntensity(30);
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); }
}
private static void edge(ClientGameTestContext c,boolean enabled) {
c.runOnClient(m -> VanillaLight.setEdgeHighlights(enabled));
if(enabled)frames(c);
else {
c.waitTicks(6);
c.runOnClient(m -> check(!EdgeHighlights.applied() && !EdgeHighlights.allocated(),"OFF frees resources"));
}
}
private static void frames(ClientGameTestContext c) {
int before=c.computeOnClient(m -> EdgeHighlights.renderedFrames());
c.waitFor(m -> EdgeHighlights.applied() && EdgeHighlights.renderedFrames()>before+8,200);
c.waitTicks(6);
}
private record Difference(int samples,int brighter,int stable,int darker,double meanGain) { }
private static Difference measure(Frame off,Frame on) {
int samples=0,brighter=0,stable=0,darker=0;double gain=0;
for(int y=off.height()/10;y<off.height()*9/10;y+=2) for(int x=off.width()/10;x<off.width()*9/10;x+=2) {
int i=y*off.width()+x,a=off.pixels()[i],b=on.pixels()[i];
int delta=brightness(b)-brightness(a);samples++;gain+=delta;
if(delta>=4)brighter++;
if(delta<=-4)darker++;
if(channelDifference(a,b)<=2)stable++;
}
var result=new Difference(samples,brighter,stable,darker,gain/samples);
System.out.println("EDGE_HIGHLIGHTS129_PIXELS "+result);
return result;
}
private static void grid(ClientGameTestContext c,Frame off,Frame on) {
var samples=c.computeOnClient(m -> {
var camera=m.gameRenderer.gameRenderState().levelRenderState.cameraRenderState;
var vp=new Matrix4f(camera.projectionMatrix).mul(camera.viewRotationMatrix);
var result=new ArrayList<int[]>();
for(int x=11;x<=14;x++) for(int z=6;z<=10;z++) for(int edge=0;edge<2;edge++) {
var p=vp.transform(new Vector4f((float)(x+(edge==0?.5:1.0/32)-camera.pos.x),
(float)(66-camera.pos.y),(float)(z+.5-camera.pos.z),1));
int px=(int)((p.x/p.w*.5+.5)*off.width());
int py=(int)((.5-p.y/p.w*.5)*off.height());
if(px>=10 && px<off.width()-10 && py>=10 && py<off.height()-10)result.add(new int[]{py*off.width()+px,edge});
}
return result;
});
int centers=0,unchanged=0,edges=0,bright=0;
for(var sample:samples) {
int a=off.pixels()[sample[0]],b=on.pixels()[sample[0]];
if(sample[1]==0){centers++;if(channelDifference(a,b)<=2)unchanged++;}
else {edges++;if(brightness(b)-brightness(a)>=4)bright++;}
}
check(centers>=15 && unchanged>centers*.95 && edges>=15 && bright==0,
"Connected block centers and coplanar joins remain untouched: centers="+unchanged+"/"+centers+" edges="+bright+"/"+edges);
System.out.println("EDGE_HIGHLIGHTS129_GRID centers="+unchanged+"/"+centers+" edges="+bright+"/"+edges);
}
}
@@ -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,75 @@
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);
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");
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 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.profile("oak_planks").roughness()>PbrMaterials.profile("polished_andesite").roughness(),"Wood remains rougher than polished stone");
}
private static void preferences(){try{
var p=FabricLoader.getInstance().getConfigDir().resolve("sanctuary-shaders.json");Files.writeString(p,"{\"ssgi\":true,\"ssgiIntensity\":35,\"bloom\":false}");forgetPreferences();
check(VanillaLight.pbr()&&VanillaLight.pbrIntensity()==80&&VanillaLight.ssgi()&&!VanillaLight.bloom(),"Missing PBR setting defaults ON while preserving explicit old choices");
for(int n:new int[]{-1,0,50,100,101}){VanillaLight.setPbr(true);VanillaLight.setPbrIntensity(n);forgetPreferences();check(VanillaLight.pbr()&&VanillaLight.pbrIntensity()==Math.clamp(n,0,100),"PBR preference persistence/clamp");}
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,695 @@
package fr.koka.sanctuary.client.shader;
import com.mojang.blaze3d.platform.InputConstants;
import fr.koka.sanctuary.client.HelloWorldScreen;
import fr.koka.sanctuary.client.IntroClient;
import fr.koka.sanctuary.client.IntroSpawnFlash;
import fr.koka.sanctuary.client.ProgressionClient;
import fr.koka.sanctuary.gametest.Starter039TestSupport;
import java.io.IOException;
import java.nio.file.Files;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
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.fabricmc.fabric.api.client.gametest.v1.context.TestServerContext;
import net.fabricmc.loader.api.FabricLoader;
import net.minecraft.client.CameraType;
import net.minecraft.client.Minecraft;
import net.minecraft.client.Screenshot;
import net.minecraft.client.gui.components.Button;
import net.minecraft.client.gui.screens.worldselection.WorldCreationUiState;
import net.minecraft.client.input.KeyEvent;
import net.minecraft.client.renderer.culling.Frustum;
import net.minecraft.core.BlockPos;
import net.minecraft.core.Direction;
import net.minecraft.network.chat.Component;
import net.minecraft.network.chat.contents.TranslatableContents;
import net.minecraft.server.MinecraftServer;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.block.StairBlock;
import net.minecraft.world.level.gamerules.GameRules;
import net.minecraft.world.phys.AABB;
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,ssgiOnly,pbrOnly,closeupOnly,sunshaftOnly,coloredOnly;
public PixelShadows122ClientChecks(){this(false);}
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);
private static final BlockPos STAIR = new BlockPos(5, FLOOR + 3, 5);
private static final int DARK_DELTA = 8;
@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(net.minecraft.client.PreferredGraphicsApi.OPENGL.getBackendsToTry()[0] instanceof com.mojang.renderpearl.backend.opengl.GlBackend, "Explicit OpenGL remains available");
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 {
originalPreferences = Files.exists(path) ? Files.readAllBytes(path) : null;
} catch (IOException e) {
throw new AssertionError("Cannot back up shader preferences", e);
}
var originalOptions = c.computeOnClient(Options::capture);
var onboarding = new AtomicBoolean(true);
ClientTickEvents.END_CLIENT_TICK.register(m -> {
if (onboarding.get() && m.gui.screen() instanceof HelloWorldScreen screen) {
// Public test support works across packages; enter through the native widgets.
IntroClient.setQuickTestIntro(true);
if (!screen.starterChoices().isEmpty()) 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());
screen.children().stream().filter(Button.class::isInstance).map(Button.class::cast)
.filter(b -> b.getMessage().getContents() instanceof TranslatableContents t
&& t.getKey().equals("sanctuary.progression.enter"))
.findFirst().orElseThrow().onPress(enter());
onboarding.set(false);
}
});
try {
c.runOnClient(m -> {
m.options.renderDistance().set(6);
m.options.simulationDistance().set(5);
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 -> {
s.setName("PixelShadows122 " + UUID.randomUUID());
s.setSeed("122");
s.setAllowCommands(true);
s.setGameMode(WorldCreationUiState.SelectedGameMode.CREATIVE);
s.setWorldType(s.getNormalPresetList().stream().filter(e -> e.preset() != null
&& e.preset().getRegisteredName().equals("sanctuary_test:flat"))
.findFirst().orElseThrow());
}).create()) {
world.getConnection().waitForChunksDownload(2400);
c.waitFor(m -> m.player != null && ProgressionClient.active()
&& m.gui.screen() == null && !IntroSpawnFlash.active(), 1200);
c.getInput().pressKey(InputConstants.KEY_F1);
var server = world.getServer();
server.runOnServer(s -> {
command(s, "sanctuary time vanilla");
command(s, "sanctuary weather vanilla");
command(s, "weather clear 100000");
if (s.getGlobalGameRules().get(GameRules.ADVANCE_TIME))
command(s, "gamerule " + GameRules.ADVANCE_TIME.id() + " false");
check(!s.getGlobalGameRules().get(GameRules.ADVANCE_TIME), "Native daylight rule is frozen");
command(s, "time set 3000");
command(s, "gamemode spectator @a");
if (s.getGlobalGameRules().get(GameRules.SPAWN_MOBS))
command(s, "gamerule " + GameRules.SPAWN_MOBS.id() + " false");
var level = s.overworld();
var entities = new java.util.ArrayList<net.minecraft.world.entity.Entity>();
level.getAllEntities().forEach(entities::add);
entities.stream().filter(e -> !(e instanceof net.minecraft.server.level.ServerPlayer))
.forEach(net.minecraft.world.entity.Entity::discard);
// The elevated platform isolates the samples from grass, mobs and flat-world lighting.
for (int x = -24; x <= 24; x++) for (int z = -24; z <= 24; z++) {
level.setBlockAndUpdate(new BlockPos(x, FLOOR, z), Blocks.CONCRETE.white().defaultBlockState());
}
pillar(s, true);
// Coloured casters cannot be mistaken for neutral white receiving ground.
level.setBlockAndUpdate(SLAB, Blocks.RED_SANDSTONE_SLAB.defaultBlockState());
level.setBlockAndUpdate(STAIR, Blocks.RED_SANDSTONE_STAIRS.defaultBlockState()
.setValue(StairBlock.FACING, Direction.WEST));
});
camera(c, server, -2, FLOOR + 18, 1, 70);
c.waitFor(m -> m.level.getBlockState(new BlockPos(0, FLOOR + 8, 0)).is(Blocks.CONCRETE.red())
&& m.level.getBlockState(SLAB).is(Blocks.RED_SANDSTONE_SLAB)
&& m.level.getBlockState(STAIR).is(Blocks.RED_SANDSTONE_STAIRS), 200);
c.waitFor(m -> {
for(int x=-24;x<=24;x+=8) for(int z=-24;z<=24;z+=8)
if(!m.level.getBlockState(new BlockPos(x,FLOOR,z)).is(Blocks.CONCRETE.white()))return false;
return m.levelRenderer.hasRenderedAllSections();
},1200);
c.waitTicks(40);
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(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);
var off = capture(c, "day-off");
c.waitTicks(6);
unchanged(off, capture(c, null), "Fixed camera/daylight OFF control");
shadows(c, true);
var on = capture(c, "day-on-pillar-slab-stair");
var day = compare(off, on);
localShadow(day, "Pillar/slab/stair daylight ground", .70);
c.runOnClient(m -> {
check(Float.compare(morning, m.gameRenderer.gameRenderState().levelRenderState.skyRenderState.sunAngle) == 0,
"Native rule actually freezes the rendered sun angle");
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, 64, 128, 16}) {
c.runOnClient(m -> VanillaLight.setShadowPixels(pixels));
activeFrames(c);
c.runOnClient(m -> check(PixelShadows.shadowMap().width == PixelShadows.mapSize(32, pixels),
"Live pixel density reallocates the correct depth map"));
capture(c, "precision-" + pixels);
}
for (int distance : new int[]{16, 64, 128, 256, 32}) {
c.runOnClient(m -> VanillaLight.setShadowDistance(distance));
activeFrames(c);
c.runOnClient(m -> check(PixelShadows.shadowMap().width == PixelShadows.mapSize(distance, 16),
"Live distance reallocates the correct depth map"));
}
shadows(c, false);
unchanged(off, capture(c, "day-off-restored"), "Disabling removes the shadow without residual grading");
// Aim at the shadow's far end, with its entire caster outside the actual camera frustum.
shadows(c, true);
double shadowX = c.computeOnClient(m -> {
var sun = PixelShadows.sunDirection(m.gameRenderer.gameRenderState().levelRenderState.skyRenderState.sunAngle);
check(sun.y > .2f && Math.abs(sun.x / sun.y) > .5f, "Morning has an oblique sun for offscreen casting");
return 1.0 - 7.0 * sun.x / sun.y;
});
camera(c, server, shadowX, FLOOR + 5, 1, 30);
c.runOnClient(m -> {
var state = m.gameRenderer.gameRenderState().levelRenderState.cameraRenderState;
var frustum = new Frustum(state.viewRotationMatrix, state.projectionMatrix);
frustum.prepare(state.pos.x, state.pos.y, state.pos.z);
check(!frustum.isVisible(PILLAR), "Whole pillar is outside the receiver camera frustum");
check(!frustum.isVisible(new AABB(SLAB)) && !frustum.isVisible(new AABB(STAIR)),
"Slab and stair are also outside the offscreen capture");
});
shadows(c, false);
var offscreenOff = capture(c, "offscreen-off");
shadows(c, true);
var offscreenOn = capture(c, "offscreen-on");
localShadow(compare(offscreenOff, offscreenOn), "Offscreen pillar still casts onto visible ground", .20);
// Do not toggle/release/rebuild the renderer here: real server edits must invalidate its live cache.
server.runOnServer(s -> pillar(s, false));
c.waitFor(m -> m.level.getBlockState(new BlockPos(0, FLOOR + 8, 0)).isAir(), 200);
activeFrames(c);
var removed = capture(c, "offscreen-caster-removed");
restoredGround(offscreenOff, offscreenOn, removed);
server.runOnServer(s -> pillar(s, true));
c.waitFor(m -> m.level.getBlockState(new BlockPos(0, FLOOR + 8, 0)).is(Blocks.CONCRETE.red()), 200);
activeFrames(c);
var replaced = capture(c, "offscreen-caster-replaced");
localShadow(compare(offscreenOff, replaced), "Replacing caster restores its shadow", .20);
unchanged(offscreenOn, replaced, "Same caster restores the same ground shadow");
// beta.128: the moon now casts a weaker shadow using its native celestial angle.
server.runOnServer(s -> command(s, "time set 15000"));
c.waitFor(m -> PixelShadows.sunDirection(m.gameRenderer.gameRenderState().levelRenderState.skyRenderState.sunAngle).y < 0, 200);
camera(c, server, -2, FLOOR + 18, 1, 70);
shadows(c, false);
var moonOff = capture(c, "moon-off");
shadows(c, true);
var moonOn = capture(c, "moon-on");
var moonStats = opacity(moonOff, moonOn);
check(moonStats.dark > 100 && moonStats.maximum > .025 && moonStats.maximum < .14,
"Moon creates localized subtle shadows: " + moonStats);
unchanged(moonOn, capture(c, null), "Frozen moon shadow is stable");
server.runOnServer(s -> command(s, "time set 111000"));
c.waitFor(m -> m.gameRenderer.gameRenderState().levelRenderState.skyRenderState.moonPhase.index() == 4
&& !PixelShadows.applied(), 200);
c.waitTicks(6);
int newMoonFrames = c.computeOnClient(m -> PixelShadows.renderedFrames());
c.waitTicks(6);
c.runOnClient(m -> check(PixelShadows.renderedFrames() == newMoonFrames, "New moon submits no shadow pass"));
noonAndFoliage(c, server);
c.waitFor(m -> PixelShadows.sunDirection(m.gameRenderer.gameRenderState().levelRenderState.skyRenderState.sunAngle).y > .2f,
200);
shadows(c, true);
camera(c, server, -2, FLOOR + 18, 1, 70);
c.waitTicks(30);
var beforeReload = capture(c, "before-reload");
var reload = c.computeOnClient(Minecraft::reloadResourcePacks);
c.waitFor(m -> reload.isDone() && m.gui.overlay() == null, 1200);
reload.join();
c.runOnClient(m -> {
forgetPreferences();
check(VanillaLight.enabled() && VanillaLight.pixelShadows()
&& VanillaLight.shadowPixels() == 16 && VanillaLight.shadowDistance() == 32,
"Resource reload and disk reread retain enabled shadow preferences");
});
activeFrames(c);
unchanged(beforeReload, capture(c, "after-reload"), "Resource reload preserves the rendered ground shadow");
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", "ssgi", "ssgi_intensity", "bloom", "bloom_radius", "ore_emission"}) {
String fullKey = "sanctuary.shaders." + key;
check(!Component.translatable(fullKey).getString().equals(fullKey), "Translated " + locale + " " + key);
}
m.gui.setScreen(new ShaderOptionsScreen(null));
widgets(m.gui.screen()).filter(net.minecraft.client.gui.components.CycleButton.class::isInstance)
.map(net.minecraft.client.gui.components.AbstractWidget.class::cast).forEach(button -> {
check(m.font.width(button.getMessage()) < button.getWidth() - 8,
"Localized shader cycle fits: " + button.getMessage().getString());
});
});
c.waitTicks(3);
c.takeScreenshot("sanctuary-beta142-shader-options-" + locale);
}
c.runOnClient(m -> m.gui.setScreen(null));
}
System.out.println("PIXEL_SHADOWS122_CLIENT_PASS: bounded saved preferences; native GPU ON/OFF ground; "
+ "offscreen caster; live removal/replacement; subtle moon; full-strength noon; foliage density; camera stability; resource reload; FR/EN screenshots");
} finally {
onboarding.set(false);
c.runOnClient(m -> {
PixelShadows.release();
originalOptions.restore(m);
IntroClient.setQuickTestIntro(false);
try {
if (originalPreferences == null) Files.deleteIfExists(path);
else Files.write(path, originalPreferences);
} catch (IOException e) {
throw new AssertionError("Cannot restore shader preferences", e);
} finally {
forgetPreferences();
}
});
}
}
private static void noonAndFoliage(ClientGameTestContext c, TestServerContext server) {
// Overhead caster keeps a visible noon shadow while the camera sits below it.
server.runOnServer(s -> {
pillar(s, false);
s.overworld().setBlockAndUpdate(SLAB, Blocks.AIR.defaultBlockState());
s.overworld().setBlockAndUpdate(STAIR, Blocks.AIR.defaultBlockState());
canopy(s, 1, false, 3);
command(s, "time set 6000");
});
camera(c, server, .99999, FLOOR + 5, 1, 70);
c.waitTicks(30);
shadows(c, false);
var noonOff = capture(c, "noon-off");
shadows(c, true);
var noonOn = capture(c, "noon-on");
var noonStats = opacity(noonOff, noonOn);
crispEdge(noonOff, noonOn);
check(noonStats.dark > 100 && noonStats.maximum > .34 && noonStats.maximum < .42,
"Noon shadow keeps its full daytime strength: " + noonStats);
camera(c, server, 1.00001, FLOOR + 5, 1, 70);
unchanged(noonOn, capture(c, "noon-camera-subpixel"), "Noon tiny motion across former integer anchor");
camera(c, server, .99999, FLOOR + 5, 1, 70);
unchanged(noonOn, capture(c, null), "Noon camera return");
look(c, server, 35, 60);
shadows(c, false);
var obliqueOff = capture(c, "noon-oblique-off");
shadows(c, true);
var obliqueNoon = capture(c, "noon-oblique");
localShadow(compare(obliqueOff, obliqueNoon), "Oblique noon camera sees actual white receiving ground", .10);
look(c, server, 35.00002f, 60.00002f);
unchanged(obliqueNoon, capture(c, null), "Subpixel rotation with an oblique receiver");
// Both sides of noon, frozen individually: no frame-to-frame edge flicker.
for (int time : new int[]{5990, 6010}) {
server.runOnServer(s -> command(s, "time set " + time));
c.waitTicks(10);
var stable = capture(c, "noon-" + time);
c.waitTicks(10);
unchanged(stable, capture(c, null), "Frozen near-noon " + time);
}
server.runOnServer(s -> command(s, "time set 5999"));
c.waitTicks(10);
var beforeNoon = capture(c, null);
server.runOnServer(s -> command(s, "time set 6001"));
c.waitTicks(10);
unchanged(beforeNoon, capture(c, "noon-crossing"), "Continuous passage through noon has no large flip");
server.runOnServer(s -> {
canopy(s, 1, true, 16);
command(s, "time set 3000");
});
camera(c, server, 0, FLOOR + 5, 0, 60);
c.waitTicks(30);
shadows(c, false);
var thinOff = capture(c, "leaves-thin-off");
shadows(c, true);
var thinOn = capture(c, "leaves-thin-on");
var thin = opacity(thinOff, thinOn);
c.waitTicks(8);
unchanged(thinOn, capture(c, null), "Leaf noise is independent of frame/time");
// Keep the live cache: adding inner leaf layers must invalidate it too.
server.runOnServer(s -> canopy(s, 4, true, 16));
c.waitTicks(30);
activeFrames(c);
var thickOn = capture(c, "leaves-thick-on");
shadows(c, false);
var thickOff = capture(c, "leaves-thick-off");
var thick = opacity(thickOff, thickOn);
server.runOnServer(s -> canopy(s, 4, false, 16));
c.waitTicks(30);
var solidOff = capture(c, "canopy-solid-off");
shadows(c, true);
var solid = opacity(solidOff, capture(c, "canopy-solid-on"));
check(thin.mean > .02 && thick.mean > thin.mean + .025 && solid.mean > thick.mean + .01,
"Depth increases foliage opacity, bounded by solid: thin=" + thin + " thick=" + thick + " solid=" + solid);
check(thin.dark > 100 && thin.lit > 100, "Thin foliage has both light holes and shadows: " + thin);
server.runOnServer(s -> {
for (int x=-16;x<=16;x++) for(int z=-16;z<=16;z++) for(int y=FLOOR+10;y<FLOOR+13;y++)
s.overworld().setBlockAndUpdate(new BlockPos(x,y,z), Blocks.AIR.defaultBlockState());
canopy(s, 1, true, 16);
});
c.waitTicks(30);
// Crossing a 16-block origin boundary must preserve the same world shadow.
camera(c, server, 7.99999, FLOOR + 5, 0, 60);
activeFrames(c);
var boundary = capture(c, null);
camera(c, server, 8.00001, FLOOR + 5, 0, 60);
activeFrames(c);
unchanged(boundary, capture(c, "anchor-boundary"), "Subpixel motion across 16-block anchor");
System.out.println("SHADOWS128_PASS moon/noon plus stable anchors; foliage thin=" + thin + " thick=" + thick + " solid=" + solid);
server.runOnServer(s -> {
for (int x=-16;x<=16;x++) for(int z=-16;z<=16;z++) for(int y=FLOOR+9;y<FLOOR+13;y++)
s.overworld().setBlockAndUpdate(new BlockPos(x,y,z), Blocks.AIR.defaultBlockState());
pillar(s, true);
});
}
private static void canopy(MinecraftServer s, int layers, boolean leaves, int radius) {
var state = leaves ? Blocks.OAK_LEAVES.defaultBlockState().setValue(net.minecraft.world.level.block.LeavesBlock.PERSISTENT, true)
: Blocks.CONCRETE.red().defaultBlockState();
for (int x=-radius;x<=radius;x++) for(int z=-radius;z<=radius;z++) for(int y=FLOOR+9;y<FLOOR+9+layers;y++)
s.overworld().setBlockAndUpdate(new BlockPos(x,y,z), state);
}
private static void crispEdge(Frame off, Frame on) {
int samples = 0, intermediate = 0;
for (int y=off.height/10;y<off.height*9/10;y+=2) for (int x=off.width/10;x<off.width*9/10;x+=2) {
int i=y*off.width+x;
if (!whiteGround(off.pixels[i])) continue;
samples++;
double ratio=(brightness(off.pixels[i])-brightness(on.pixels[i]))/(double)brightness(off.pixels[i]);
if (ratio > .015 && ratio < .32) intermediate++;
}
check(samples > 1000 && intermediate < samples / 200,
"Opaque noon edge stays crisp, no foliage smoothing: " + intermediate + "/" + samples);
System.out.println("SHADOWS128_CRISP intermediate=" + intermediate + "/" + samples);
}
private record Opacity(int samples, int dark, int lit, double mean, double maximum) { }
private static Opacity opacity(Frame off, Frame on) {
sameSize(off,on);
int samples=0,dark=0,lit=0;double sum=0,maximum=0;
for(int y=off.height/10;y<off.height*9/10;y+=2) for(int x=off.width/10;x<off.width*9/10;x+=2) {
int i=y*off.width+x,a=off.pixels[i],b=on.pixels[i];
int r=a>>16&255,g=a>>8&255,blue=a&255;
if(Math.min(r,Math.min(g,blue))<24 || Math.max(r,Math.max(g,blue))-Math.min(r,Math.min(g,blue))>64)continue;
double ratio=(brightness(a)-brightness(b))/(double)brightness(a);
samples++;sum+=ratio;maximum=Math.max(maximum,ratio);
if(ratio>.02)dark++;else if(Math.abs(ratio)<.01)lit++;
}
check(samples>1000,"White concrete (including blue moonlight) available for shadow opacity");
var result=new Opacity(samples,dark,lit,sum/samples,maximum);
System.out.println("SHADOWS128_OPACITY " + result);
return result;
}
private static java.util.stream.Stream<net.minecraft.client.gui.components.events.GuiEventListener> widgets(
net.minecraft.client.gui.components.events.ContainerEventHandler root) {
return root.children().stream().flatMap(widget -> widget instanceof net.minecraft.client.gui.components.events.ContainerEventHandler child
? 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()==80&&VanillaLight.ssgiIntensity()==35,"PBR default 80, SSGI strength retained");
Files.writeString(path,"{\"pbr\":false,\"ssgi\":false,\"bloomIntensity\":73}");forgetPreferences();
check(!VanillaLight.pbr()&&!VanillaLight.ssgi()&&VanillaLight.bloomIntensity()==73,"Explicit personal toggles and intensity survive changed defaults");
Files.writeString(path,"{\"bloom\":false}");forgetPreferences();
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}, {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]);
}
for (int[] pair : new int[][]{{Integer.MIN_VALUE, 16}, {23, 16}, {24, 32}, {32, 32}, {47, 32}, {48, 64}, {95,64}, {96,128}, {191,128}, {192,256}, {256,256}, {Integer.MAX_VALUE, 256}}) {
VanillaLight.setShadowDistance(pair[0]);
forgetPreferences();
check(VanillaLight.shadowDistance() == pair[1], "Persisted shadow distance rounds/clamps " + pair[0]);
}
for (boolean enabled : new boolean[]{true, false}) {
VanillaLight.setPixelShadows(enabled);
forgetPreferences();
check(VanillaLight.pixelShadows() == enabled && VanillaLight.enabled(), "Saved shadow toggle is independent of main shader");
}
VanillaLight.setShadowPixels(16);
VanillaLight.setShadowDistance(32);
}
static void forgetPreferences() {
try {
var field = VanillaLight.class.getDeclaredField("preferences");
field.setAccessible(true);
field.set(null, null);
} catch (ReflectiveOperationException e) {
throw new AssertionError("Cannot reread actual shader preferences", e);
}
}
static void camera(ClientGameTestContext c, TestServerContext server, double x, double y, double z, int fov) {
server.runOnServer(s -> {
var p = s.getPlayerList().getPlayers().getFirst();
command(s, "tp @a " + x + " " + y + " " + z + " 0 90");
p.setDeltaMovement(Vec3.ZERO);
});
c.waitFor(m -> m.player.position().distanceToSqr(new Vec3(x, y, z)) < .0001
&& Math.abs(m.player.getXRot() - 90) < .001, 200);
c.runOnClient(m -> {
m.options.fov().set(fov);
m.player.setYRot(0);
m.player.yRotO = 0;
m.player.setXRot(90);
m.player.xRotO = 90;
m.player.setDeltaMovement(Vec3.ZERO);
});
c.waitTicks(15);
}
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() + 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);
c.waitTicks(15);
}
private static void pillar(MinecraftServer s, boolean present) {
for (int x = 0; x < 2; x++) for (int z = 0; z < 2; z++) for (int y = FLOOR + 1; y <= FLOOR + 8; y++) {
s.overworld().setBlockAndUpdate(new BlockPos(x, y, z),
(present ? Blocks.CONCRETE.red() : Blocks.AIR).defaultBlockState());
}
}
static void command(MinecraftServer s, String command) {
try {
// Dispatcher exceptions fail the fixture instead of leaving an ignored command error in chat.
s.getCommands().getDispatcher().execute(command, s.createCommandSourceStack());
} catch (com.mojang.brigadier.exceptions.CommandSyntaxException e) {
throw new AssertionError("Fixture command failed: " + command, e);
}
}
static void shadows(ClientGameTestContext c, boolean enabled) {
c.runOnClient(m -> VanillaLight.setPixelShadows(enabled));
if (enabled) activeFrames(c);
else {
c.waitTicks(6);
int frames = c.computeOnClient(m -> {
check(!PixelShadows.applied() && PixelShadows.shadowMap() == null, "OFF releases the GPU shadow target");
return PixelShadows.renderedFrames();
});
c.waitTicks(3);
c.runOnClient(m -> check(PixelShadows.renderedFrames() == frames, "OFF submits no shadow frames"));
}
}
static void activeFrames(ClientGameTestContext c) {
int before = c.computeOnClient(m -> PixelShadows.renderedFrames());
c.waitFor(m -> PixelShadows.applied() && PixelShadows.ready() && PixelShadows.renderedFrames() > before + 12, 200);
// Allow the bounded terrain cache and native light/chunk uploads to settle before reading pixels.
c.waitTicks(30);
}
static Frame capture(ClientGameTestContext c, String 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) {
result.complete(new Frame(image.getWidth(), image.getHeight(), image.getPixels()));
} catch (Throwable e) {
result.completeExceptionally(e);
}
}));
c.waitFor(m -> result.isDone(), 200);
return result.join();
}
record Frame(int width, int height, int[] pixels) { }
private record Difference(int sampled, int ground, int dark, int stable, int brighter) { }
private static Difference compare(Frame before, Frame after) {
sameSize(before, after);
int sampled = 0, ground = 0, dark = 0, stable = 0, brighter = 0;
// Skip borders and sample every other pixel; no PNG decoding, image libraries or golden screenshot dependency.
for (int y = before.height / 10; y < before.height * 9 / 10; y += 2) {
for (int x = before.width / 10; x < before.width * 9 / 10; x += 2) {
sampled++;
int a = before.pixels[y * before.width + x], b = after.pixels[y * after.width + x];
if (!whiteGround(a)) continue;
ground++;
int delta = brightness(a) - brightness(b);
if (delta >= DARK_DELTA) dark++;
if (delta <= -DARK_DELTA) brighter++;
if (channelDifference(a, b) <= 3) stable++;
}
}
return new Difference(sampled, ground, dark, stable, brighter);
}
private static void localShadow(Difference d, String label, double minimumStable) {
System.out.println("PIXEL_SHADOWS122_PIXELS " + label + ": " + d);
check(d.ground > d.sampled * .50, label + ": a majority of samples must be actual white receiving ground: " + d);
check(d.dark >= Math.max(24, d.ground / 1000), label + ": measurable cast-shadow darkening: " + d);
check(d.stable > d.ground * minimumStable, label + ": unshadowed ground stays unchanged; reject a global tint: " + d);
check(d.brighter < Math.max(12, d.ground / 100), label + ": shadows must not brighten ground: " + d);
}
static void unchanged(Frame before, Frame after, String label) {
sameSize(before, after);
int sampled = 0, changed = 0;
for (int y = before.height / 10; y < before.height * 9 / 10; y += 2) {
for (int x = before.width / 10; x < before.width * 9 / 10; x += 2) {
sampled++;
int i = y * before.width + x;
if (channelDifference(before.pixels[i], after.pixels[i]) > 3) changed++;
}
}
check(sampled > 0 && changed <= sampled / 100, label + ": changed=" + changed + "/" + sampled);
}
private static void restoredGround(Frame off, Frame on, Frame removed) {
sameSize(off, on);
sameSize(off, removed);
int shadow = 0, restored = 0, lit = 0, stable = 0;
for (int y = off.height / 10; y < off.height * 9 / 10; y += 2) {
for (int x = off.width / 10; x < off.width * 9 / 10; x += 2) {
int i = y * off.width + x;
if (!whiteGround(off.pixels[i])) continue;
if (brightness(off.pixels[i]) - brightness(on.pixels[i]) >= DARK_DELTA) {
shadow++;
if (brightness(removed.pixels[i]) - brightness(on.pixels[i]) >= DARK_DELTA
&& channelDifference(off.pixels[i], removed.pixels[i]) <= 4) restored++;
} else if (channelDifference(off.pixels[i], on.pixels[i]) <= 3) {
lit++;
if (channelDifference(on.pixels[i], removed.pixels[i]) <= 3) stable++;
}
}
}
check(shadow >= 24 && restored > shadow * .85,
"Removing offscreen caster relights its former shadow: " + restored + "/" + shadow);
check(lit >= 24 && stable > lit * .98,
"Removing caster preserves already sunlit ground: " + stable + "/" + lit);
System.out.println("PIXEL_SHADOWS122_EDIT restored=" + restored + "/" + shadow + " litStable=" + stable + "/" + lit);
}
private static boolean whiteGround(int color) {
int r = color >> 16 & 255, g = color >> 8 & 255, b = color & 255;
return Math.min(r, Math.min(g, b)) > 100 && Math.max(r, Math.max(g, b)) - Math.min(r, Math.min(g, b)) < 24;
}
static int brightness(int color) {
return ((color >> 16 & 255) + (color >> 8 & 255) + (color & 255)) / 3;
}
static int channelDifference(int a, int b) {
return Math.max(Math.abs((a >> 16 & 255) - (b >> 16 & 255)),
Math.max(Math.abs((a >> 8 & 255) - (b >> 8 & 255)), Math.abs((a & 255) - (b & 255))));
}
private static void sameSize(Frame a, Frame b) {
check(a.width == b.width && a.height == b.height && a.pixels.length == b.pixels.length,
"GPU comparison requires the same framebuffer dimensions");
}
private record Options(int distance, int simulationDistance, int fov, boolean bob,
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.improvedTransparency().get());
}
void restore(Minecraft m) {
m.options.renderDistance().set(distance);
m.options.simulationDistance().set(simulationDistance);
m.options.fov().set(fov);
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());
}
}
private static KeyEvent enter() { return new KeyEvent(InputConstants.KEY_RETURN, 0, 0); }
static void check(boolean ok, String message) { if (!ok) throw new AssertionError(message); }
}
@@ -0,0 +1,115 @@
package fr.koka.sanctuary.client.shader;
import static fr.koka.sanctuary.client.shader.PixelShadows122ClientChecks.*;
import fr.koka.sanctuary.realtime.RealtimeService;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Collections;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;
import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext;
import net.fabricmc.fabric.api.client.gametest.v1.context.TestServerContext;
import net.fabricmc.fabric.api.client.rendering.v1.level.LevelRenderEvents;
final class RealtimeShadows129ClientChecks {
private static void projectionContinuity() {
// Far coordinates amplify the old per-frame rounding failure. Moving the sun must not
// add a periodic translation to the shadow raster while the camera remains stationary.
var grid=new ShadowProjection();
float last=0; int jumps=0; float greatest=0;
for(int i=0;i<2000;i++) {
var matrix=grid.matrix(PixelShadows.sunDirection(.7f+i*.000001f),32,2048,100000,64,-100000,false);
if(i>0){float step=Math.abs(matrix.m31()-last);greatest=Math.max(greatest,step);if(step>1e-6f)jumps++;}
last=matrix.m31();
}
check(jumps==0,"Slow light rotation has no cyclical raster translation far from spawn: "+greatest);
// Recentring transfers the phase modulo integral texels, preserving the sampled grid.
var sun=PixelShadows.sunDirection(.71f);
var before=grid.matrix(sun,32,2048,100000,64,-100000,false);
var after=grid.matrix(sun,32,2048,100016,64,-100000,false);
var a=before.transform(new org.joml.Vector4f(4,1,2,1));
var b=after.transform(new org.joml.Vector4f(-12,1,2,1));
for(float value:new float[]{(b.x-a.x)*1024,(b.y-a.y)*1024})
check(Math.abs(value-Math.rint(value))<.001,"Camera recentering moves only whole shadow texels");
}
static void run(ClientGameTestContext c, TestServerContext server) {
projectionContinuity();
var enabled=new AtomicBoolean(false);
var trace=Collections.synchronizedList(new ArrayList<double[]>());
LevelRenderEvents.END_EXTRACTION.register(context -> {
if(enabled.get() && trace.size()<4096)trace.add(new double[]{System.nanoTime()/1e9,context.levelState().skyRenderState.sunAngle});
});
var original=new AtomicReference<Clock>();
try {
server.runOnServer(s -> {
var session=RealtimeService.session(s);
check(session!=null,"Isolated world has the actual real-time service");
try {
var field=session.getClass().getDeclaredField("clock");field.setAccessible(true);
original.set((Clock)field.get(session));
var morning=session.sample().day().noon().minusSeconds(3*3600);
field.set(session,Clock.offset(Clock.systemUTC(),Duration.between(Instant.now(),morning)));
check(RealtimeService.setMode(s,true),"Real-time mode enabled through actual server service");
} catch(ReflectiveOperationException e) {throw new AssertionError(e);}
catch(Exception e) {throw new AssertionError(e);}
});
c.waitFor(m -> PixelShadows.sunDirection(m.gameRenderer.gameRenderState().levelRenderState.skyRenderState.sunAngle).y>.2,200);
c.waitTicks(40);
double shadowX=c.computeOnClient(m -> {
var sun=PixelShadows.sunDirection(m.gameRenderer.gameRenderState().levelRenderState.skyRenderState.sunAngle);
return 1-7*sun.x/sun.y;
});
camera(c,server,shadowX,69,1,30);
shadows(c,false);
var off=capture(c,null);
shadows(c,true);
enabled.set(true);
var frames=new ArrayList<Frame>();
for(int i=0;i<24;i++) {c.waitTicks(6);frames.add(capture(c,i==0?"realtime-start":i==23?"realtime-end":null));}
c.runOnClient(m -> enabled.set(false));
double travel=Math.IEEEremainder(trace.getLast()[1]-trace.getFirst()[1],Math.PI*2);
int reverse=0;double largest=0;
for(int i=1;i<trace.size();i++) {
double delta=Math.IEEEremainder(trace.get(i)[1]-trace.get(i-1)[1],Math.PI*2)*Math.signum(travel);
if(delta<-.0000001){reverse++;largest=Math.max(largest,-delta);}
}
int samples=0,changed=0,rebound=0;
for(int y=off.height()/10;y<off.height()*9/10;y+=2) for(int x=off.width()/10;x<off.width()*9/10;x+=2) {
int index=y*off.width()+x,a=off.pixels()[index];
int r=a>>16&255,g=a>>8&255,b=a&255;
if(Math.min(r,Math.min(g,b))<100 || Math.max(r,Math.max(g,b))-Math.min(r,Math.min(g,b))>24)continue;
samples++;int flips=0;
boolean last=brightness(a)-brightness(frames.getFirst().pixels()[index])>=8;
for(var frame:frames) {
boolean dark=brightness(a)-brightness(frame.pixels()[index])>=8;
if(last!=dark)flips++;
last=dark;
}
if(flips>0)changed++;
if(flips>1)rebound++;
}
check(trace.size()>30 && Math.abs(travel)>.00001,"Real-time shadows actually remain dynamic");
check(rebound<Math.max(8,samples/1000),"Real-time opaque contours must not repeatedly rebound: "+rebound);
System.out.println("REALTIME129_PASS frames="+trace.size()+" rawTravel="+travel+" reversals="+reverse+" largestReverse="+largest
+" pixels="+samples+" changed="+changed+" rebound="+rebound);
try {
var out=new StringBuilder("seconds,raw_angle\n");
for(var sample:trace)out.append(sample[0]).append(',').append(sample[1]).append('\n');
java.nio.file.Files.writeString(net.fabricmc.loader.api.FabricLoader.getInstance().getGameDir().resolve("shadow129-clock.csv"),out);
}catch(java.io.IOException e){throw new AssertionError(e);}
} finally {
enabled.set(false);
server.runOnServer(s -> {
try {
RealtimeService.setMode(s,false);
if(original.get()!=null){var session=RealtimeService.session(s);var field=session.getClass().getDeclaredField("clock");field.setAccessible(true);field.set(session,original.get());}
command(s,"time set 3000");
}catch(Exception e){throw new AssertionError(e);}
});
}
}
}
@@ -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,215 @@
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.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")){
resourceOverride(c);
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 unloaded=new BlockPos(1_000_016,96,1_000_016);
check(!NaturalWater.loaded(m.level,unloaded.getX()>>4,unloaded.getZ()>>4),"Fixture region is unloaded");
var missing=new NaturalWater.Region(m.level,62500,5,62500);
check(missing.corner(null,unloaded,0,0).equals(NaturalWater.CLEAR),"Unavailable biome snapshot forces clear without reading missing terrain");
check(!NaturalWater.loaded(m.level,unloaded.getX()>>4,unloaded.getZ()>>4),"Classification does not load chunks");
var boundary=NaturalWater.sample(m.level,80,99.1,8.5);check(boundary.tint()>.1&&boundary.tint()<.85,"Section boundary mixes profiles");
});
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");
boolean found=false;
for(int x=72;x<89;x++){
var p=new BlockPos(x,99,8);
if(!NaturalWater.ocean(m.level,p)&&NaturalWater.ocean(m.level,p.east())){
var left=new NaturalWater.Region(m.level,(x>>4)-1,5,-1);
var right=new NaturalWater.Region(m.level,((x+1)>>4)-1,5,-1);
check(left.corner(null,p,1,0).equals(right.corner(null,p.east(),0,0)),"Ocean edge emits identical shared vertices");
found=true;break;
}
}
check(found,"Native ocean boundary exercised");
});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");
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 net.minecraft.client.renderer.texture.SpriteContents waterSprite(net.minecraft.client.Minecraft m,String name){
return m.getAtlasManager().getAtlasOrThrow(net.minecraft.data.AtlasIds.BLOCKS)
.getSprite(net.minecraft.resources.Identifier.withDefaultNamespace("block/"+name)).contents();
}
private static void resourceOverride(ClientGameTestContext c){
int originalWidth=c.computeOnClient(m->waterSprite(m,"water_still").width());
int originalFrames=c.computeOnClient(m->waterSprite(m,"water_still").getUniqueFrames().size());
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);
var textures=directory.resolve("assets/minecraft/textures/block");Files.createDirectories(textures);
try(var pixels=new com.mojang.blaze3d.platform.NativeImage(32,64,false)){
for(int y=0;y<64;y++)for(int x=0;x<32;x++)pixels.setPixel(x,y,((x/4+y/4+y/32)&1)==0?0xd0ffffff:0x80404040);
for(String name:new String[]{"water_still","water_flow"}){
pixels.writeToFile(textures.resolve(name+".png"));
Files.writeString(textures.resolve(name+".png.mcmeta"),"{\"animation\":{\"frametime\":4,\"frames\":[0,1]}}");
}
}
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");});
c.runOnClient(m->{for(String name:new String[]{"water_still","water_flow"}){
var sprite=waterSprite(m,name);check(sprite.width()==32&&sprite.height()==32&&sprite.getUniqueFrames().size()==2,"Custom pixel grid and animation loaded: "+name);
}});
var first=capture(c,"custom-materials");long animated=0;
for(int i=0;i<8;i++){c.waitTicks(1);animated=Math.max(animated,difference(first,capture(c,"custom-animation-"+i)));}
check(animated>100000,"Custom water animation remains visible");
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");
check(waterSprite(m,"water_still").width()==originalWidth&&waterSprite(m,"water_still").getUniqueFrames().size()==originalFrames,"Native water texture restored");
});
System.out.println("WATER144_TEXTURE_PASS custom 32px still/flow textures, alpha, two animation frames and restoration");
}
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);m.getFramerateLimitTracker().setFramerateLimit(net.minecraft.client.Options.UNLIMITED_FRAMERATE_CUTOFF);});
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);}
if(scene.equals("moving")){
// Test fixture only: prepare water beyond the initial client view so movement really compiles terrain.
s.runOnServer(server->{
command(server,"forceload add 112 -17 288 48");
for(int x=7;x<=18;x++)for(int z=-2;z<=3;z++)server.overworld().getChunk(x,z);
command(server,"fill 112 94 -17 288 100 -17 glass");command(server,"fill 112 94 48 288 100 48 glass");
command(server,"fill 288 94 -17 288 100 48 glass");
for(int x=112;x<288;x+=16){
command(server,"fill "+x+" 93 -16 "+(x+15)+" 93 47 quartz_block");
command(server,"fill "+x+" 94 -16 "+(x+15)+" 94 47 sand");
command(server,"fill "+x+" 95 -16 "+(x+15)+" 99 47 water");
}
});c.waitTicks(80);
}
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->{
check(m.getFramerateLimitTracker().getFramerateLimit()==260&&!m.options.enableVsync().get(),"Uncapped native limiter and VSync disabled");
System.out.println("WATER144_CONDITIONS limit="+m.getFramerateLimitTracker().getFramerateLimit()+" throttle="+m.getFramerateLimitTracker().getThrottleReason()+" renderLimit="+m.gameRenderer.gameRenderState().framerateLimit+" surface="+m.windowSurface().currentConfiguration()+" limiterBypass=true");
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.getFramerateLimitTracker().setFramerateLimit(oldLimit);m.options.inactivityFpsLimit().set(oldInactivity);});}
}
}
@@ -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,17 @@
package fr.koka.sanctuary.gametest.mixin;
import fr.koka.sanctuary.gametest.Water144Metrics;
import com.llamalad7.mixinextras.injector.wrapoperation.*;
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;
// Benchmark only: the extracted frame state can retain a 120 FPS limit despite the option/tracker.
@WrapOperation(method="renderFrame",at=@At(value="INVOKE",target="Lnet/minecraft/client/FramerateLimiter;limitDisplayFPS(I)V"))
private void sanctuary$uncapped(int fps,Operation<Void> original){if(!Water144Metrics.recording)original.call(fps);}
@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,7 @@
"defaultRequire": 1
},
"client": [
"Colored139LightmapProbeMixin",
"Fourneau094ParticleProbeMixin",
"Tooltip093FontProbeMixin",
"ClientVsync046Mixin",
@@ -18,6 +19,8 @@
"Atlas026TextureProbeMixin",
"Inventory013MerchantAccess",
"Dynamic030TerrainProbeMixin",
"Star040RenderProbeMixin"
"Star040RenderProbeMixin",
"Water144FrameProbeMixin",
"Water144SectionProbeMixin"
]
}
@@ -44,6 +44,7 @@ public final class SanctuaryMod implements ModInitializer {
@Override
public void onInitialize() {
fr.koka.sanctuary.building.ColoredBricks.register();
fr.koka.sanctuary.gems.Gems.register();
fr.koka.sanctuary.sky.VortexClouds.register();
fr.koka.sanctuary.loading.PreloadProgress.register();
fr.koka.sanctuary.progression.SanctuaryGameplay.register();
@@ -12,7 +12,7 @@ import net.minecraft.world.level.block.state.BlockBehaviour;
/** Native building blocks; no world-generation, ticking entities or custom inventory. */
public final class ColoredBricks {
public record Family(DyeColor color,Block bricks,Block slab,Block stairs,Block clay,Item clayBall,Item brick){}
public record Family(DyeColor color,Block bricks,Block slab,Block stairs,Block wall,Block clay,Item clayBall,Item brick){}
private static final List<Family> FAMILIES=new ArrayList<>();
// The order used by the native 26.3 Colored Blocks tab, rather than dye numeric IDs.
private static final List<DyeColor> CREATIVE_COLORS=List.of(
@@ -27,15 +27,17 @@ public final class ColoredBricks {
var bricks=block(name+"_bricks",new Block(properties(name+"_bricks",Blocks.BRICKS,color)));
var slab=block(name+"_brick_slab",new SlabBlock(properties(name+"_brick_slab",Blocks.BRICK_SLAB,color)));
var stairs=block(name+"_brick_stairs",new Stairs(bricks,properties(name+"_brick_stairs",Blocks.BRICK_STAIRS,color)));
var wall=block(name+"_brick_wall",new WallBlock(properties(name+"_brick_wall",Blocks.BRICK_WALL,color)));
var clay=block(name+"_clay",new Block(properties(name+"_clay",Blocks.CLAY,color)));
var clayBall=item(name+"_clay_ball");var brick=item(name+"_brick");
FAMILIES.add(new Family(color,bricks,slab,stairs,clay,clayBall,brick));
FAMILIES.add(new Family(color,bricks,slab,stairs,wall,clay,clayBall,brick));
}
var display=FAMILIES.stream().sorted(Comparator.comparingInt(f->CREATIVE_COLORS.indexOf(f.color()))).toList();
CreativeModeTabEvents.modifyOutputEvent(CreativeModeTabs.COLORED_BLOCKS).register(out->{
for(var f:display)out.accept(f.bricks);
for(var f:display)out.accept(f.stairs);
for(var f:display)out.accept(f.slab);
for(var f:display)out.accept(f.wall);
for(var f:display)out.accept(f.clay);
});
CreativeModeTabEvents.modifyOutputEvent(CreativeModeTabs.INGREDIENTS).register(out->{
@@ -14,6 +14,7 @@ import net.minecraft.network.chat.Component;
import net.minecraft.resources.Identifier;
import net.minecraft.world.entity.player.Inventory;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.Items;
/** File selection is local; the two ordinary inventory slots and every crafted item are server-owned. */
public final class ClayWorkshopScreen extends AbstractContainerScreen<ClayWorkshopMenu> {
@@ -21,26 +22,37 @@ public final class ClayWorkshopScreen extends AbstractContainerScreen<ClayWorksh
private static final Identifier SLOT=Identifier.withDefaultNamespace("container/slot");
private static final Identifier SELECTION=fr.koka.sanctuary.SanctuaryMod.id("hud/hotbar_selection");
private static final ExecutorService WORKER=Executors.newSingleThreadExecutor(r->{var t=new Thread(r,"Sanctuary clay models");t.setDaemon(true);return t;});
private boolean imported;
private List<net.minecraft.world.entity.EntityType<?>> animals=List.of();
private List<Path> files=List.of();private int page,generation,ticks;private boolean busy,pending;
private VoxelSculpture source,previewSource;private ItemStack preview=ItemStack.EMPTY,lastClay=ItemStack.EMPTY;
private VoxelSculpture source,previewSource;private ItemStack preview=ItemStack.EMPTY,lastMaterial=ItemStack.EMPTY;
private SculpturePalette palette;private Object sampledResources;
private Component status=text("choose_model");private Button rotate;
public ClayWorkshopScreen(ClayWorkshopMenu menu,Inventory inv,Component title){super(menu,inv,title,310,202);}
public static Component text(String key,Object...args){return Component.translatable("sanctuary.statuary."+key,args);}
public static void register(){
net.minecraft.client.gui.screens.MenuScreens.register(Statuary.MENU,ClayWorkshopScreen::new);StatuaryRenderer.register();
net.fabricmc.fabric.api.client.item.v1.ItemTooltipCallback.EVENT.register((stack,context,flags,lines)->{
if(!stack.is(Statuary.ITEM))return;var sculpture=StatuaryEntity.sculpture(stack);if(sculpture!=null){lines.add(text("model_name",sculpture.name()));if(flags.isAdvanced())lines.add(Component.literal(sculpture.modelId()));}
if(!stack.is(Statuary.ITEM))return;var sculpture=StatuaryEntity.sculpture(stack);if(sculpture!=null){
if(!stack.getHoverName().getString().equals(Statuary.BLOCK.getName().getString()))lines.add(Statuary.BLOCK.getName());
lines.add(text("model_name",sculpture.name()));if(flags.isAdvanced())lines.add(Component.literal(sculpture.modelId()));}
});
}
@Override protected void init(){
super.init();
try{files=CreationLibrary.files(CreationLibrary.Kind.MODEL);}catch(Exception e){status=ModelImportErrors.message(e);}
page=Math.min(page,Math.max(0,(files.size()-1)/4));
for(int i=0;i<4;i++){int at=page*4+i;if(at>=files.size())break;var file=files.get(at);
var label=Component.literal(file.getFileName().toString());var b=addRenderableWidget(Button.builder(label,p->load(file)).bounds(leftPos+10,topPos+32+i*18,128,17).build());b.setTooltip(Tooltip.create(label));}
animals=menu.view.statues().stream().map(net.minecraft.resources.Identifier::tryParse).filter(Objects::nonNull)
.<net.minecraft.world.entity.EntityType<?>>map(id->net.minecraft.core.registries.BuiltInRegistries.ENTITY_TYPE.getValue(id)).filter(Objects::nonNull)
.sorted(Comparator.comparing(t->t.getDescription().getString())).toList();
int count=imported?files.size():animals.size();page=Math.min(page,Math.max(0,(count-1)/4));
addRenderableWidget(Button.builder(text("animals"),b->{imported=false;page=0;rebuildWidgets();}).bounds(leftPos+10,topPos+19,62,16).build()).active=imported;
addRenderableWidget(Button.builder(text("imports"),b->{imported=true;page=0;rebuildWidgets();}).bounds(leftPos+74,topPos+19,64,16).build()).active=!imported;
for(int i=0;i<4;i++){int at=page*4+i;if(at>=count)break;
var label=imported?Component.literal(files.get(at).getFileName().toString()):animals.get(at).getDescription();
var b=addRenderableWidget(Button.builder(label,p->{if(imported)load(files.get(at));else load(animals.get(at));}).bounds(leftPos+10,topPos+36+i*17,128,17).build());b.setTooltip(Tooltip.create(label));}
addRenderableWidget(Button.builder(Component.literal(""),b->{page--;rebuildWidgets();}).bounds(leftPos+10,topPos+106,22,18).build()).active=page>0;
addRenderableWidget(Button.builder(text("folder"),b->folder()).bounds(leftPos+35,topPos+106,78,18).build());
addRenderableWidget(Button.builder(Component.literal(""),b->{page++;rebuildWidgets();}).bounds(leftPos+116,topPos+106,22,18).build()).active=(page+1)*4<files.size();
addRenderableWidget(Button.builder(Component.literal(""),b->{page++;rebuildWidgets();}).bounds(leftPos+116,topPos+106,22,18).build()).active=(page+1)*4<count;
addRenderableWidget(Button.builder(text("refresh"),b->rebuildWidgets()).bounds(leftPos+10,topPos+127,128,18).build());
rotate=addRenderableWidget(Button.builder(text("rotate"),b->{if(source!=null){source=source.rotate();pending=true;}}).bounds(leftPos+148,topPos+127,152,18).build());rotate.active=source!=null&&!busy;
rotate.setTooltip(Tooltip.create(text("placement")));
@@ -55,14 +67,43 @@ public final class ClayWorkshopScreen extends AbstractContainerScreen<ClayWorksh
source=sculpture;pending=true;status=text("ready",sculpture.name());
}));
}
public void load(net.minecraft.world.entity.EntityType<?> type){
var id=net.minecraft.core.registries.BuiltInRegistries.ENTITY_TYPE.getKey(type).toString();
if(busy||!menu.view.statues().contains(id))return;
try{
var entity=type.create(minecraft.level,net.minecraft.world.entity.EntitySpawnReason.MOB_SUMMONED);
if(entity==null)throw new IllegalArgumentException("unsupported_model");
entity.setId(Integer.MIN_VALUE+125);entity.setPos(minecraft.player.position());
// Native renderers and textures are read on the client thread; only voxelization runs in the worker.
var faces=StatueModels.capture(entity);String name=type.getDescription().getString();
busy=true;status=text("working");int token=++generation;
CompletableFuture.supplyAsync(()->VoxelSculpture.of(StatueDiscoveries.modelId(id),name,StatueVoxelizer.miniature(faces)),WORKER)
.whenComplete((sculpture,error)->minecraft.execute(()->{
if(token!=generation||minecraft.gui.screen()!=this||minecraft.player==null||minecraft.player.containerMenu!=menu)return;
busy=false;if(error!=null){fr.koka.sanctuary.SanctuaryMod.LOGGER.warn("Unable to sculpt creature {}",id,error);status=text("animal_error");return;}
source=sculpture;pending=true;status=text("ready",name);
}));
}catch(Exception e){fr.koka.sanctuary.SanctuaryMod.LOGGER.warn("Unable to capture creature {}",id,e);status=text("animal_error");}
}
@Override protected void containerTick(){
super.containerTick();rotate.active=source!=null&&!busy;
if(pending&&++ticks>=5){ticks=0;pending=false;ClientPlayNetworking.send(new ClayWorkshopMenu.Select(menu.containerId,menu.view.token(),source));}
var clay=menu.getSlot(0).getItem();
if(previewSource!=source||!ItemStack.matches(lastClay,clay)){
previewSource=source;lastClay=clay.copy();var colored=ClayWorkshopMenu.material(source,clay);
preview=source==null?ItemStack.EMPTY:StatuaryEntity.item(colored==null?source:colored);
var material=menu.getSlot(0).getItem();var resources=minecraft.getModelManager().getBlockStateModelSet();
boolean changedMaterial=!ItemStack.isSameItemSameComponents(lastMaterial,material)||sampledResources!=resources;
if(previewSource!=source||changedMaterial){
previewSource=source;lastMaterial=material.copy();sampledResources=resources;
if(changedMaterial)palette=SculptureMaterialSampler.sample(material);
var colored=source==null?null:palette==null?source:ClayWorkshopMenu.appearance(source,material.getItem(),palette);
preview=colored==null?ItemStack.EMPTY:StatuaryEntity.item(colored);pending=true;
if(source!=null&&!busy)status=palette==null?text("choose_material"):material.is(Items.CLAY)?text("clay_ready"):text("material_ready",material.getHoverName());
}
// Retry a selection that raced the input packet or server rate limit. The result is the
// acknowledgement; never predict consumption or manufacture an item on the client.
boolean needsResult=source!=null&&palette!=null&&!ItemStack.matches(preview,menu.getSlot(1).getItem());
if(++ticks>=5&&(pending||needsResult)){
ticks=0;pending=false;
if(source!=null&&palette!=null)ClientPlayNetworking.send(new ClayWorkshopMenu.Select(menu.containerId,menu.view.token(),source,material.getItem(),palette));
}
}
@Override public void onFilesDrop(List<Path> files){if(!files.isEmpty())load(files.getFirst());}
@Override public void removed(){generation++;super.removed();}
@@ -80,12 +121,13 @@ public final class ClayWorkshopScreen extends AbstractContainerScreen<ClayWorksh
g.text(font,Component.literal(""),leftPos+254,topPos+111,0xFF404040,false);
var detail=font.split(status,290);for(int i=0;i<Math.min(2,detail.size());i++)g.text(font,detail.get(i),leftPos+10,topPos+150+i*font.lineHeight,0xFF404040,false);
if(detail.size()>2&&mx>=leftPos+10&&mx<leftPos+300&&my>=topPos+148&&my<topPos+174)g.setTooltipForNextFrame(font,status,mx,my);
if(files.isEmpty())g.text(font,text("empty"),leftPos+10,topPos+32,0xFF404040,false);
if(imported?files.isEmpty():animals.isEmpty()){var lines=font.split(text(imported?"empty":"no_animals"),128);for(int i=0;i<Math.min(6,lines.size());i++)g.text(font,lines.get(i),leftPos+10,topPos+38+i*font.lineHeight,0xFF404040,false);}
}
@Override protected void extractLabels(GuiGraphicsExtractor g,int mx,int my){
g.text(font,title,10,8,0xFF303030,false);g.text(font,text("models"),10,22,0xFF404040,false);
g.text(font,title,10,8,0xFF303030,false);
g.text(font,text("input"),230-font.width(text("input"))/2,95,0xFF303030,false);g.text(font,text("output"),imageWidth-10-font.width(text("output")),95,0xFF303030,false);
}
ItemStack preview(){return preview;}
public boolean busy(){return busy;}
public VoxelSculpture selected(){return source;}
}
@@ -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;}
}
}
@@ -11,6 +11,7 @@ public final class MultiblockScreen extends ContainerScreen {
private EditBox search;
private Button previous,next,apply;
private double wheel;
private boolean showHelp;
private final java.util.List<Button> sections=new java.util.ArrayList<>();
public MultiblockScreen(MultiblockMenu menu,Inventory inv,Component title){super(menu,inv,title);}
private MultiblockMenu machine(){return (MultiblockMenu)menu;}
@@ -26,7 +27,10 @@ public final class MultiblockScreen extends ContainerScreen {
apply=addRenderableWidget(Button.builder(Multiblocks.text("search"),b->change(0,v.section())).bounds(x,topPos+20,w,20).build());
previous=addRenderableWidget(Button.builder(Component.literal("<"),b->change(v.page()-1,v.section())).bounds(x,topPos+44,w/2-1,20).build());
next=addRenderableWidget(Button.builder(Component.literal(">"),b->change(v.page()+1,v.section())).bounds(x+w/2+1,topPos+44,w/2-1,20).build());
if(v.furnace())for(int n=0;n<3;n++){int section=n;var b=addRenderableWidget(Button.builder(Multiblocks.text(new String[]{"inputs","fuel","outputs"}[n]),button->change(0,section)).bounds(x,topPos+84+n*22,w,20).build());sections.add(b);}
if(v.furnace())for(int n=0;n<3;n++){int section=n;var b=addRenderableWidget(Button.builder(Multiblocks.text(new String[]{"inputs","fuel","outputs"}[n]),button->change(0,section)).bounds(x,topPos+44+n*22,w,20).build());sections.add(b);}
if(v.furnace())addRenderableWidget(Button.builder(Multiblocks.text("how_to_cook"),b->showHelp=!showHelp).bounds(x,topPos+142,w,20)
.tooltip(Tooltip.create(Multiblocks.text("cooking_help"))).build());
if(v.furnace()){previous.visible=false;next.visible=false;}
controls();
}
@Override public boolean mouseScrolled(double x,double y,double horizontal,double vertical){
@@ -41,14 +45,46 @@ public final class MultiblockScreen extends ContainerScreen {
@Override protected void containerTick(){super.containerTick();controls();}
@Override protected void extractLabels(net.minecraft.client.gui.GuiGraphicsExtractor g,int mouseX,int mouseY){
super.extractLabels(g,mouseX,mouseY);var m=machine();int x=imageWidth+4;
g.text(font,Multiblocks.text("page",m.view.page()+1,m.view.pages()),x,68,0xFFFFFFFF,false);
if(!m.view.furnace())g.text(font,Multiblocks.text("page",m.view.page()+1,m.view.pages()),x,68,0xFFFFFFFF,false);
if(m.view.furnace()){
g.text(font,Multiblocks.text("heat",m.heat),x,154,0xFFFFFFFF,false);
g.text(font,Multiblocks.text("progress",m.progress),x,166,0xFFFFFFFF,false);
g.text(font,Multiblocks.text("heat",m.heat),x,116,0xFFFFFFFF,false);
g.text(font,Multiblocks.text("cooking_count",m.cooking),x,128,0xFFFFFFFF,false);
}
for(int i=m.view.indices().length;i<m.displaySlots();i++){var s=menu.getSlot(i);g.fill(s.x,s.y,s.x+16,s.y+16,0xB0181818);}
}
public java.util.List<net.minecraft.client.renderer.Rect2i> extraAreas(){return search==null?java.util.List.of():java.util.List.of(new net.minecraft.client.renderer.Rect2i(search.getX()-2,search.getY()-2,search.getWidth()+4,machine().view.furnace()?182:82));}
@Override protected void extractSlot(net.minecraft.client.gui.GuiGraphicsExtractor g,net.minecraft.world.inventory.Slot slot,int x,int y){
super.extractSlot(g,slot,x,y);var m=machine();
if(!m.view.furnace()||m.view.section()!=0||slot.index>=m.view.indices().length)return;
int lane=m.view.indices()[slot.index]/3,state=m.laneState(lane);if(state==MultiblockMenu.IDLE)return;
int color=switch(state){case MultiblockMenu.COOKING->0xFFFFB23F;case MultiblockMenu.NO_FUEL->0xFFE1CE62;default->0xFFE06B5F;};
g.fill(slot.x,slot.y+14,slot.x+16,slot.y+16,0xFF343434);
g.fill(slot.x,slot.y+14,slot.x+Math.max(1,16*m.laneProgress(lane)/100),slot.y+16,color);
}
@Override protected java.util.List<Component> getTooltipFromContainerItem(net.minecraft.world.item.ItemStack stack){
var lines=new java.util.ArrayList<>(super.getTooltipFromContainerItem(stack));var m=machine();
if(m.view.furnace()&&hoveredSlot!=null&&hoveredSlot.index<m.view.indices().length){
int lane=m.view.indices()[hoveredSlot.index]/3;
if(m.view.section()==1)lines.add(Multiblocks.text("shared_fuel").copy().withStyle(net.minecraft.ChatFormatting.GRAY));
else {lines.add(Multiblocks.text("lane",lane+1).copy().withStyle(net.minecraft.ChatFormatting.GRAY));
lines.add(Multiblocks.text("state."+new String[]{"idle","cooking","no_fuel","blocked","no_recipe"}[m.laneState(lane)],m.laneProgress(lane)));}
}
return lines;
}
@Override public void extractRenderState(net.minecraft.client.gui.GuiGraphicsExtractor g,int x,int y,float partial){
super.extractRenderState(g,x,y,partial);
if(showHelp){
g.nextStratum();
g.tooltip(font,font.split(Multiblocks.text("cooking_help"),Math.min(260,width-32)).stream()
.map(net.minecraft.client.gui.screens.inventory.tooltip.ClientTooltipComponent::create).toList(),
8,24,net.minecraft.client.gui.screens.inventory.tooltip.DefaultTooltipPositioner.INSTANCE,null,false);
}
}
@Override public boolean mouseClicked(net.minecraft.client.input.MouseButtonEvent event,boolean doubleClick){
if(showHelp){showHelp=false;return true;}
return super.mouseClicked(event,doubleClick);
}
boolean helpVisible(){return showHelp;}
public java.util.List<net.minecraft.client.renderer.Rect2i> extraAreas(){return search==null?java.util.List.of():java.util.List.of(new net.minecraft.client.renderer.Rect2i(search.getX()-2,search.getY()-2,search.getWidth()+4,machine().view.furnace()?166:82));}
public static void register(){
net.minecraft.client.gui.screens.MenuScreens.<net.minecraft.world.inventory.ChestMenu,MultiblockScreen>register(Multiblocks.MENU,(m,i,t)->new MultiblockScreen((MultiblockMenu)m,i,t));
net.fabricmc.fabric.api.client.item.v1.ItemTooltipCallback.EVENT.register((stack,context,flags,lines)->{if(stack.is(Multiblocks.WRENCH))lines.add(Multiblocks.text("hint").copy().withStyle(net.minecraft.ChatFormatting.GRAY));});
@@ -0,0 +1,107 @@
package fr.koka.sanctuary.client;
import com.mojang.blaze3d.Blaze3D;
import fr.koka.sanctuary.SanctuaryMod;
import java.io.IOException;
import java.nio.file.FileAlreadyExistsException;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.Comparator;
import net.fabricmc.loader.api.FabricLoader;
import net.minecraft.client.Minecraft;
import net.minecraft.client.gui.components.Button;
import net.minecraft.client.gui.components.Tooltip;
import net.minecraft.network.chat.Component;
import net.minecraft.util.Util;
/** Explicit, one-time export. The player's copy is never a managed mod resource. */
public final class ResourcePackTemplate {
public static final String FOLDER = "Sanctuary-Custom";
private ResourcePackTemplate() {}
public static Button button(Path packDirectory) {
Path target = packDirectory.resolve(FOLDER);
return Button.builder(label(Files.isDirectory(target, LinkOption.NOFOLLOW_LINKS)
? "open_copy" : "customize"), button -> {
if (Files.isDirectory(target, LinkOption.NOFOLLOW_LINKS)) {
Blaze3D.openPath(target);
return;
}
button.active = false;
button.setMessage(label("creating"));
Util.ioPool().execute(() -> {
try {
create(packDirectory);
Minecraft.getInstance().execute(() -> {
button.active = true;
button.setMessage(label("open_copy"));
button.setTooltip(Tooltip.create(label("copy_ready")));
});
} catch (IOException | RuntimeException error) {
SanctuaryMod.LOGGER.error("Could not create personal Sanctuary resource pack", error);
Minecraft.getInstance().execute(() -> {
button.active = true;
button.setMessage(label("copy_failed"));
button.setTooltip(Tooltip.create(label("copy_failed_help")));
});
}
});
}).width(308).tooltip(Tooltip.create(label("customize_help"))).build();
}
/** Returns an existing personal directory untouched, including after mod upgrades. */
public static synchronized Path create(Path packDirectory) throws IOException {
Path directory = packDirectory.toAbsolutePath().normalize();
Files.createDirectories(directory);
Path target = directory.resolve(FOLDER);
if (Files.exists(target, LinkOption.NOFOLLOW_LINKS)) return existing(target);
var mod = FabricLoader.getInstance().getModContainer(SanctuaryMod.ID).orElseThrow();
// Keep incomplete packs outside the directory watched by the native pack screen.
Path staging = Files.createTempDirectory(directory.getParent(), ".sanctuary-template-");
try {
copyTree(mod.findPath("assets").orElseThrow(), staging.resolve("assets"));
copyTree(mod.findPath("resourcepacks/textures/assets").orElseThrow(), staging.resolve("assets"));
copyTree(mod.findPath("resourcepacks/template").orElseThrow(), staging);
Files.copy(mod.findPath("resourcepacks/textures/pack.png").orElseThrow(), staging.resolve("pack.png"));
try {
Files.move(staging, target);
} catch (FileAlreadyExistsException race) {
return existing(target);
}
return target;
} finally {
if (Files.exists(staging)) {
try (var files = Files.walk(staging)) {
for (Path file : files.sorted(Comparator.reverseOrder()).toList()) Files.delete(file);
}
}
}
}
private static Path existing(Path target) throws IOException {
if (!Files.isDirectory(target, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Personal pack path already exists and is not a directory: " + target);
}
return target;
}
private static void copyTree(Path source, Path destination) throws IOException {
try (var files = Files.walk(source)) {
for (Path file : files.toList()) {
if (Files.isSymbolicLink(file)) throw new IOException("Unexpected asset symlink: " + file);
// String paths also work when the mod is loaded from a ZIP filesystem.
Path output = destination.resolve(source.relativize(file).toString()).normalize();
if (!output.startsWith(destination)) throw new IOException("Invalid asset path: " + file);
if (Files.isDirectory(file)) Files.createDirectories(output);
else Files.copy(file, output, StandardCopyOption.REPLACE_EXISTING);
}
}
}
private static Component label(String key) {
return Component.translatable("sanctuary.resource_pack." + key);
}
}
@@ -18,7 +18,10 @@ public final class SanctuaryKnowledgeClient implements ClientModInitializer {
public static void registerClient() {
SanctuaryResourcePack.register();
fr.koka.sanctuary.client.shader.NaturalWater.register();
VortexCloudRenderer.register();
fr.koka.sanctuary.client.shader.PixelShadows.register();
fr.koka.sanctuary.client.shader.EdgeHighlights.register();
ProgressionClient.register();
StarClient.register();
WeatherClient.register();
@@ -35,6 +38,7 @@ public final class SanctuaryKnowledgeClient implements ClientModInitializer {
FoodKnowledgeClient.register();
MultiblockScreen.register();
FourneauModels.register();
WrenchTextureModels.register();
MetatableClient.register();
ClayWorkshopScreen.register();
GraveScreen.register();
@@ -0,0 +1,47 @@
package fr.koka.sanctuary.client;
import fr.koka.sanctuary.plans.*;
import java.util.*;
import net.minecraft.client.renderer.block.dispatch.BlockStateModelPart;
import net.minecraft.client.resources.model.geometry.BakedQuad;
import net.minecraft.core.*;
import net.minecraft.util.ARGB;
import net.minecraft.util.RandomSource;
import net.minecraft.world.item.*;
/** Bounded samples of the baked faces' actual textures, with their own tint from the active pack. */
final class SculptureMaterialSampler {
private static final int MAX_MATERIALS=32,SAMPLE_EDGE=16;
static SculpturePalette sample(ItemStack stack){
if(!ClayWorkshopMenu.material(stack))return null;
var state=((BlockItem)stack.getItem()).getBlock().defaultBlockState();var sample=PlanMaterials.sample(state);
var mc=net.minecraft.client.Minecraft.getInstance();
var parts=new ArrayList<BlockStateModelPart>();
mc.getModelManager().getBlockStateModelSet().get(state).collectParts(RandomSource.create(state.getSeed(BlockPos.ZERO)),parts);
var materials=new LinkedHashSet<BakedQuad.MaterialInfo>();
for(var part:parts)for(int face=0;face<=6;face++)for(var quad:part.getQuads(face==6?null:Direction.values()[face])){
if(materials.size()<MAX_MATERIALS)materials.add(quad.materialInfo());
}
var pixels=new ArrayList<Integer>();
for(var material:materials)add(pixels,PlanMaterials.faceSample(state,material));
if(pixels.isEmpty()){
var tints=mc.getBlockColors().getTintSources(state);
int tint=tints.isEmpty()?0xFFFFFFFF:tints.getFirst().colorInWorld(state,mc.level,mc.player.blockPosition());
add(pixels,new PlanMaterials.Sample(sample.pixels(),tint,sample.average()));
}
if(pixels.isEmpty())pixels.add(sample.average()|0xFF000000);
// Keep repeated pixels: they retain the sampled frequency of each shade.
pixels.sort(Comparator.comparingDouble(SculpturePalette::lightness).thenComparingInt(Integer::intValue));
var palette=new ArrayList<Integer>();int count=Math.min(SculpturePalette.MAX_COLORS,pixels.size());
for(int i=0;i<count;i++)palette.add(pixels.get((int)((i+.5)*pixels.size()/count)));
return new SculpturePalette(palette);
}
private static void add(List<Integer> pixels,PlanMaterials.Sample sample){
var texture=sample.pixels();int width=Math.min(SAMPLE_EDGE,texture.width()),height=Math.min(SAMPLE_EDGE,texture.height());
for(int y=0;y<height;y++)for(int x=0;x<width;x++){
int sx=(2*x+1)*texture.width()/(2*width),sy=(2*y+1)*texture.height()/(2*height);
int color=ARGB.multiply(texture.argb()[sy*texture.width()+sx],sample.tint());
if((color>>>24)>=128)pixels.add(color|0xFF000000);
}
}
}
@@ -0,0 +1,70 @@
package fr.koka.sanctuary.client;
import fr.koka.sanctuary.SanctuaryMod;
import fr.koka.sanctuary.plans.*;
import java.util.*;
import net.minecraft.client.Minecraft;
import net.minecraft.client.multiplayer.ClientLevel;
import net.minecraft.client.particle.SingleQuadParticle;
import net.minecraft.core.*;
import net.minecraft.data.AtlasIds;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.phys.*;
/** Chips use the already-colored surface voxels, not the technical block's clay particle material. */
public final class SculptureParticles {
private record Removed(long tick,BlockState state,List<StatuaryRenderer.Quad> mesh){}
// A break event can arrive after the block update. Keep a short, bounded snapshot in that world only.
private static final Map<ClientLevel,LinkedHashMap<BlockPos,Removed>> REMOVED=new WeakHashMap<>();
public static void remember(ClientLevel level,BlockPos pos){
if(!(level.getBlockEntity(pos) instanceof StatuaryEntity e)||e.sculpture()==null)return;
var cache=REMOVED.computeIfAbsent(level,l->new LinkedHashMap<>());
var quads=mesh(e);var sample=new ArrayList<StatuaryRenderer.Quad>();int n=Math.min(64,quads.size());
for(int i=0;i<n;i++)sample.add(quads.get((int)((i+.5)*quads.size()/n)));
cache.put(pos.immutable(),new Removed(level.getGameTime(),e.getBlockState(),List.copyOf(sample)));
while(cache.size()>128)cache.remove(cache.keySet().iterator().next());
}
private static List<StatuaryRenderer.Quad> mesh(StatuaryEntity e){var s=e.getBlockState();return StatuaryRenderer.mesh(e.sculpture(),s.getValue(Statuary.FACING),s.getValue(Statuary.ANCHOR),s.getValue(Statuary.HEIGHT));}
public static boolean destroy(ClientLevel level,BlockPos pos,BlockState state){
if(!state.is(Statuary.BLOCK))return false;
List<StatuaryRenderer.Quad> quads=List.of();
if(level.getBlockEntity(pos) instanceof StatuaryEntity e)quads=mesh(e);
var cache=REMOVED.get(level);var old=cache==null?null:cache.remove(pos);
if(quads.isEmpty()&&old!=null&&old.state==state&&level.getGameTime()-old.tick<=40)quads=old.mesh;
int count=Math.min(64,quads.size());var random=level.getRandom();
for(int i=0;i<count;i++){
// Stratified surface sampling covers colors across the sculpture without 4096 particles.
int start=i*quads.size()/count,end=(i+1)*quads.size()/count;
var q=quads.get(start+random.nextInt(end-start));var point=center(q);
spawn(level,pos,point,q.color(),(random.nextDouble()-.5)*.12,random.nextDouble()*.12,(random.nextDouble()-.5)*.12);
}
return true;
}
public static boolean hit(ClientLevel level,BlockPos pos,Direction side,BlockState state){
if(!state.is(Statuary.BLOCK))return false;
if(!(level.getBlockEntity(pos) instanceof StatuaryEntity entity))return true;
var quads=mesh(entity);if(quads.isEmpty())return true;
var mc=Minecraft.getInstance();Vec3 aim=mc.hitResult instanceof BlockHitResult h&&h.getBlockPos().equals(pos)?h.getLocation().subtract(Vec3.atLowerCornerOf(pos)):new Vec3(.5,.5,.5);
StatuaryRenderer.Quad nearest=null;double distance=Double.POSITIVE_INFINITY;
// The selection box can cover a hole: use the nearest actual surface, never empty space.
for(var q:quads){double d=center(q).distanceToSqr(aim);if(d<distance){distance=d;nearest=q;}}
if(nearest!=null)spawn(level,pos,center(nearest),nearest.color(),side.getStepX()*.035,.03+side.getStepY()*.035,side.getStepZ()*.035);
return true;
}
static Vec3 center(StatuaryRenderer.Quad q){
double x=q.x()+1/32d,y=q.y()+1/32d,z=q.z()+1/32d;
return switch(q.side()){case 0->new Vec3(x,y,q.z());case 1->new Vec3(x,y,q.z()+1/16d);case 2->new Vec3(q.x(),y,z);case 3->new Vec3(q.x()+1/16d,y,z);case 4->new Vec3(x,q.y()+1/16d,z);default->new Vec3(x,q.y(),z);};
}
private static void spawn(ClientLevel level,BlockPos pos,Vec3 point,int color,double dx,double dy,double dz){
Minecraft.getInstance().particleEngine.add(new Fragment(level,Vec3.atLowerCornerOf(pos).add(point),color,dx,dy,dz));
}
static final class Fragment extends SingleQuadParticle {
Fragment(ClientLevel level,Vec3 pos,int color,double dx,double dy,double dz){
super(level,pos.x,pos.y,pos.z,Minecraft.getInstance().getAtlasManager().getAtlasOrThrow(AtlasIds.BLOCKS).getSprite(SanctuaryMod.id("sculpture_particle")));
setColor(((color>>16)&255)/255f,((color>>8)&255)/255f,(color&255)/255f);
quadSize=1/32f;gravity=1;friction=.96f;lifetime=12+random.nextInt(12);setParticleSpeed(dx,dy,dz);
}
@Override protected Layer getLayer(){return Layer.OPAQUE_TERRAIN;}
}
private SculptureParticles(){}
}
@@ -4,7 +4,6 @@ import fr.koka.sanctuary.SanctuaryMod;
import fr.koka.sanctuary.plans.*;
import java.util.*;
import com.mojang.blaze3d.vertex.PoseStack;
import com.mojang.math.Axis;
import com.mojang.serialization.MapCodec;
import net.minecraft.client.renderer.*;
import net.minecraft.client.renderer.blockentity.*;
@@ -15,6 +14,7 @@ import net.minecraft.client.renderer.special.SpecialModelRenderer;
import net.minecraft.client.renderer.state.level.CameraRenderState;
import net.minecraft.client.renderer.texture.OverlayTexture;
import net.minecraft.core.component.DataComponents;
import net.minecraft.core.Direction;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.component.TypedEntityData;
import net.minecraft.world.phys.Vec3;
@@ -23,8 +23,9 @@ import org.joml.Vector3fc;
/** Shared block/item render geometry: opaque visible faces, generated once per cached sculpture. */
public final class StatuaryRenderer implements BlockEntityRenderer<StatuaryEntity,StatuaryRenderer.State> {
private record Quad(float x,float y,float z,int side,int color){}
private static final Map<VoxelSculpture,List<Quad>> MESHES=new LinkedHashMap<>(32,.75f,true);
record Quad(float x,float y,float z,int side,int color){}
private record Placement(VoxelSculpture sculpture,Direction facing,SculptureAnchor anchor,SculptureHeight height){}
private static final Map<Placement,List<Quad>> MESHES=new LinkedHashMap<>(32,.75f,true);
private static final Map<TypedEntityData<?>,VoxelSculpture> ITEMS=new LinkedHashMap<>(32,.75f,true);
private static final float[][] FACES={
{0,0,0, 0,1,0, 1,1,0, 1,0,0, 0,0,-1},
@@ -35,26 +36,26 @@ public final class StatuaryRenderer implements BlockEntityRenderer<StatuaryEntit
{0,0,1, 0,0,0, 1,0,0, 1,0,1, 0,-1,0}
};
private static final int[][] STEP={{0,0,-1},{0,0,1},{-1,0,0},{1,0,0},{0,1,0},{0,-1,0}};
public static final class State extends BlockEntityRenderState {List<Quad> mesh=List.of();float yaw;}
public static final class State extends BlockEntityRenderState {List<Quad> mesh=List.of();}
public State createRenderState(){return new State();}
@Override public void extractRenderState(StatuaryEntity entity,State state,float delta,Vec3 camera,ModelFeatureRenderer.CrumblingOverlay breakProgress){
BlockEntityRenderer.super.extractRenderState(entity,state,delta,camera,breakProgress);state.mesh=mesh(entity.sculpture());
state.yaw=-entity.getBlockState().getValue(Statuary.FACING).toYRot();
BlockEntityRenderer.super.extractRenderState(entity,state,delta,camera,breakProgress);
state.mesh=mesh(entity.sculpture(),entity.getBlockState().getValue(Statuary.FACING),entity.getBlockState().getValue(Statuary.ANCHOR),entity.getBlockState().getValue(Statuary.HEIGHT));
}
public void submit(State state,PoseStack pose,SubmitNodeCollector collector,CameraRenderState camera){
pose.pushPose();pose.translate(.5,0,.5);pose.rotateDegrees(Axis.YP,state.yaw);pose.translate(-.5,0,-.5);
draw(state.mesh,pose,collector,state.lightCoords,OverlayTexture.NO_OVERLAY);pose.popPose();
draw(state.mesh,pose,collector,state.lightCoords,OverlayTexture.NO_OVERLAY);
}
private static List<Quad> mesh(VoxelSculpture sculpture){
if(sculpture==null)return List.of();var cached=MESHES.get(sculpture);if(cached!=null)return cached;
static List<Quad> mesh(VoxelSculpture sculpture,Direction facing,SculptureAnchor anchor,SculptureHeight height){
if(sculpture==null)return List.of();var key=new Placement(sculpture,facing,anchor,height);var cached=MESHES.get(key);if(cached!=null)return cached;
var placed=SculptureGeometry.place(sculpture,facing,anchor,height);sculpture=placed.sculpture();
var occupied=new BitSet(4096);for(var cell:sculpture.cells())occupied.set(cell.key());var quads=new ArrayList<Quad>();
float ox=(16-sculpture.width())/32f,oz=(16-sculpture.depth())/32f;
float ox=placed.offsetX()/16f,oy=placed.offsetY()/16f,oz=placed.offsetZ()/16f;
for(var cell:sculpture.cells())for(int side=0;side<6;side++){
var s=STEP[side];int x=cell.x()+s[0],y=cell.y()+s[1],z=cell.z()+s[2];
if(x>=0&&x<16&&y>=0&&y<16&&z>=0&&z<16&&occupied.get(x|y<<4|z<<8))continue;
quads.add(new Quad(ox+cell.x()/16f,cell.y()/16f,oz+cell.z()/16f,side,cell.color()));
quads.add(new Quad(ox+cell.x()/16f,oy+cell.y()/16f,oz+cell.z()/16f,side,cell.color()));
}
cached=List.copyOf(quads);MESHES.put(sculpture,cached);if(MESHES.size()>32)MESHES.remove(MESHES.keySet().iterator().next());return cached;
cached=List.copyOf(quads);MESHES.put(key,cached);if(MESHES.size()>32)MESHES.remove(MESHES.keySet().iterator().next());return cached;
}
private static void draw(List<Quad> mesh,PoseStack pose,SubmitNodeCollector collector,int light,int overlay){
if(mesh.isEmpty())return;
@@ -68,7 +69,7 @@ public final class StatuaryRenderer implements BlockEntityRenderer<StatuaryEntit
var data=stack.get(DataComponents.BLOCK_ENTITY_DATA);if(data==null)return List.of();
var sculpture=ITEMS.get(data);
if(sculpture==null){sculpture=StatuaryEntity.sculpture(stack);if(sculpture==null)return List.of();ITEMS.put(data,sculpture);if(ITEMS.size()>64)ITEMS.remove(ITEMS.keySet().iterator().next());}
return mesh(sculpture);
return mesh(sculpture,Direction.SOUTH,SculptureAnchor.CENTER,SculptureHeight.ORIGINAL);
}
public void submit(List<Quad> mesh,PoseStack pose,SubmitNodeCollector collector,int light,int overlay,boolean foil,int outline){draw(mesh,pose,collector,light,overlay);}
public void getExtents(java.util.function.Consumer<Vector3fc> out){out.accept(new Vector3f(0,0,0));out.accept(new Vector3f(1,1,1));}
@@ -0,0 +1,65 @@
package fr.koka.sanctuary.client;
import fr.koka.sanctuary.multiblock.WrenchTextures;
import java.util.*;
import java.util.function.Predicate;
import net.fabricmc.fabric.api.client.event.lifecycle.v1.*;
import net.fabricmc.fabric.api.client.model.loading.v1.ModelLoadingPlugin;
import net.fabricmc.fabric.api.client.model.loading.v1.wrapper.WrapperBlockStateModel;
import net.fabricmc.fabric.api.client.networking.v1.*;
import net.fabricmc.fabric.api.client.renderer.v1.mesh.*;
import net.minecraft.client.Minecraft;
import net.minecraft.client.renderer.block.BlockAndTintGetter;
import net.minecraft.client.renderer.block.dispatch.BlockStateModel;
import net.minecraft.core.*;
import net.minecraft.util.RandomSource;
import net.minecraft.world.level.ChunkPos;
import net.minecraft.world.level.block.state.BlockState;
/** Immutable visual snapshots, usable from mesh workers and rebuilt after resource reloads. */
public final class WrenchTextureModels {
private static volatile Map<Long,Map<BlockPos,Integer>> chunks=Map.of();
public static int rotation(BlockPos pos){return chunks.getOrDefault(ChunkPos.pack(pos),Map.of()).getOrDefault(pos,0);}
public static void register(){
ClientPlayNetworking.registerGlobalReceiver(WrenchTextures.Update.TYPE,(packet,context)->context.client().execute(()->apply(packet)));
ClientPlayConnectionEvents.DISCONNECT.register((handler,client)->chunks=Map.of());
ClientLevelEvents.AFTER_CLIENT_LEVEL_CHANGE.register((client,level)->chunks=Map.of());
ClientChunkEvents.CHUNK_UNLOAD.register((level,chunk)->{var next=new HashMap<>(chunks);next.remove(chunk.getPos().pack());chunks=Map.copyOf(next);});
ModelLoadingPlugin.register(context->context.modifyBlockModelAfterBake().register((model,bake)->new Rotated(model)));
}
private static void apply(WrenchTextures.Update packet){
var client=Minecraft.getInstance();if(client.level==null||!client.level.dimension().identifier().equals(packet.dimension()))return;
long key=packet.chunk().pack();var old=chunks.getOrDefault(key,Map.of());var dirty=new HashSet<BlockPos>();
var entries=packet.clear()?new HashMap<BlockPos,Integer>():new HashMap<>(old);if(packet.clear())dirty.addAll(old.keySet());
for(var entry:packet.entries()){
var before=entry.rotation()==0?entries.remove(entry.pos()):entries.put(entry.pos(),entry.rotation());
if(!Objects.equals(before,entry.rotation()))dirty.add(entry.pos());
}
var next=new HashMap<>(chunks);if(entries.isEmpty())next.remove(key);else next.put(key,Map.copyOf(entries));chunks=Map.copyOf(next);
var sections=new HashSet<SectionPos>();for(var pos:dirty)sections.add(SectionPos.of(pos));
for(var section:sections)client.levelExtractor.setSectionDirty(section.x(),section.y(),section.z());
}
static boolean rotate(MutableQuadView quad,int turns){
// Rotate texture coordinates only: geometry, light, tints and materials stay native.
for(int turn=0;turn<turns;turn++){
float u=quad.u(0),v=quad.v(0);
for(int vertex=0;vertex<3;vertex++)quad.uv(vertex,quad.u(vertex+1),quad.v(vertex+1));
quad.uv(3,u,v);
}
return true;
}
private record Geometry(Object original,int rotation){}
private static final class Rotated extends WrapperBlockStateModel {
Rotated(BlockStateModel original){super(original);}
@Override public void emitQuads(QuadEmitter emitter,BlockAndTintGetter view,BlockPos pos,BlockState state,RandomSource random,Predicate<Direction> cull){
int turns=rotation(pos);if(turns==0){wrapped.emitQuads(emitter,view,pos,state,random,cull);return;}
emitter.pushTransform(quad->rotate(quad,turns));
try{wrapped.emitQuads(emitter,view,pos,state,random,cull);}finally{emitter.popTransform();}
}
@Override public Object createGeometryKey(BlockAndTintGetter view,BlockPos pos,BlockState state,RandomSource random){
var key=wrapped.createGeometryKey(view,pos,state,random);int turns=rotation(pos);
return turns==0||key==null?key:new Geometry(key,turns);
}
}
private WrenchTextureModels(){}
}
@@ -0,0 +1,165 @@
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 com.mojang.renderpearl.api.textures.GpuTextureView;
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;
/** Selective emissive bloom. No brightness threshold on ordinary world blocks, no scene-copy or readback. */
public final class Bloom {
private static Identifier id(String path){return Identifier.parse("sanctuary:"+path);}
private static final BindGroupLayout SCENE=BindGroupLayout.builder().withUniform("BloomScene",UniformType.UNIFORM_BUFFER)
.withUniform("WorldDepth",UniformType.COMBINED_IMAGE_SAMPLER).build();
private static final RenderPipeline EMITTERS=RenderPipelines.register(RenderPipeline.builder()
.withLocation(id("pipeline/bloom_emitters")).withVertexShader(id("core/bloom_emitters")).withFragmentShader(id("core/bloom_emitters"))
.withBindGroupLayout(BindGroupLayouts.DYNAMIC_TRANSFORMS).withBindGroupLayout(SCENE)
.withBindGroupLayout(BindGroupLayout.builder().withUniform("BlockAtlas",UniformType.COMBINED_IMAGE_SAMPLER).withUniform("EmissionMasks",UniformType.COMBINED_IMAGE_SAMPLER).build())
.withVertexBinding(0,BloomTerrain.FORMAT).withPrimitiveTopology(PrimitiveTopology.QUADS).withCull(false)
.withColorTargetState(new ColorTargetState(Optional.empty(),GpuFormat.RGBA8_UNORM,ColorTargetState.WRITE_ALL))
.withDepthStencilState(Optional.empty()).build());
private static RenderPipeline screen(String name,BlendFunction blend,boolean scene,boolean blur){
var layout=BindGroupLayout.builder().withUniform("InputTexture",UniformType.COMBINED_IMAGE_SAMPLER);
if(blur)layout.withUniform("BlurParams",UniformType.UNIFORM_BUFFER);
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")||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 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,flareSource;
private static MappableRingBuffer scene,horizontal,vertical;
private static int frames;
private static boolean applied;
private Bloom(){}
public static void captureProjection(Matrix4f matrix){projection.set(matrix);}
public static void dirtyBlock(BlockPos pos){TERRAIN.dirty(pos);}
public static void dirtyChunk(int x,int z){TERRAIN.dirtyChunk(x,z);}
public static void render(){
applied=false;var mc=Minecraft.getInstance();
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);
}
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(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);}
}
// 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);
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);}
}
if(halo){
screen(DOWNSAMPLE,small.getColorTextureView(),emission.getColorTextureView(),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));
if(useScene){pass.setUniform("BloomScene",scene.currentBuffer());pass.setUniform("WorldDepth",Minecraft.getInstance().gameRenderer.mainRenderTarget().getDepthTextureView(),RenderSystem.getSamplerCache().getClampToEdge(FilterMode.NEAREST));}
if(blur!=null)pass.setUniform("BlurParams",blur.currentBuffer());pass.draw(3,1,0,0);
}
}
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 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();}
static int renderedFrames(){return frames;}
static TextureTarget emissionTarget(){return emission;}
}
@@ -0,0 +1,111 @@
package fr.koka.sanctuary.client.shader;
import com.mojang.blaze3d.platform.NativeImage;
import java.util.*;
import net.minecraft.client.Minecraft;
import net.minecraft.client.renderer.texture.DynamicTexture;
import net.minecraft.client.renderer.texture.TextureAtlasSprite;
import net.minecraft.core.registries.BuiltInRegistries;
import net.minecraft.resources.Identifier;
import net.minecraft.world.level.block.state.BlockState;
/** Analyse source texels once per resource reload, never rendered frames or GPU pixels. */
final class BloomMaterials implements AutoCloseable {
static final int TILE=68, INNER=64, SIZE=TILE*16, MAX_SPRITES=256;
record Slot(int x,int y,boolean empty) {
float u(float local){return (x+2+Math.clamp(local,0,1)*INNER)/SIZE;}
float v(float local){return (y+2+Math.clamp(local,0,1)*INNER)/SIZE;}
}
private final Map<String,Slot> slots=new HashMap<>();
private final Map<BlockState,Boolean> ores=new IdentityHashMap<>();
private final Map<Identifier,int[]> hosts=new HashMap<>();
private DynamicTexture atlas;
private boolean dirty;
private static final Set<String> ORES=Set.of("iron","copper","redstone","lapis","diamond","emerald","ruby","sapphire","quartz","gold");
boolean ore(BlockState state){return ores.computeIfAbsent(state,s->{
var id=BuiltInRegistries.BLOCK.getKey(s.getBlock());
if(!id.getNamespace().equals("minecraft")&&!id.getNamespace().equals("sanctuary"))return false;
String name=id.getPath().replaceFirst("^(deepslate_|nether_)","");
return name.endsWith("_ore")&&ORES.contains(name.substring(0,name.length()-4));
});}
boolean candidate(BlockState state){return ore(state)||state.getLightEmission()>0||state.emissiveRendering();}
Slot slot(TextureAtlasSprite sprite,boolean ore,boolean fullEmission){
String key=sprite.contents().name()+":"+ore+":"+fullEmission;
var cached=slots.get(key);if(cached!=null)return cached;
if(slots.size()>=MAX_SPRITES)return new Slot(0,0,true);
int index=slots.size(),x=(index%16)*TILE,y=(index/16)*TILE;
if(atlas==null){atlas=new DynamicTexture("Sanctuary emissive masks",SIZE,SIZE,false);atlas.getPixels().fillRect(0,0,SIZE,SIZE,0);}
boolean any=false;
var id=sprite.contents().name();
try(var image=read(id)){
int frameWidth=Math.min(image.getWidth(),sprite.contents().width()),frameHeight=Math.min(image.getHeight(),sprite.contents().height());
int w=Math.min(INNER,frameWidth),h=Math.min(INNER,frameHeight);
int[] colors=new int[w*h];for(int iy=0;iy<h;iy++)for(int ix=0;ix<w;ix++)colors[iy*w+ix]=image.getPixel(ix*frameWidth/w,iy*frameHeight/h);
int[] mask=mask(id,colors,w,h,ore,fullEmission);
// Standard optional _e texture takes precedence, including in personal resource packs.
var override=Minecraft.getInstance().getResourceManager().getResource(texture(id.withSuffix("_e")));
if(override.isPresent())try(var stream=override.get().open();var explicit=NativeImage.read(stream)){
for(int iy=0;iy<h;iy++)for(int ix=0;ix<w;ix++){
int c=explicit.getPixel(ix*explicit.getWidth()/w,iy*Math.min(explicit.getHeight(),frameHeight)/h);
mask[iy*w+ix]=(c>>>24)*Math.max((c>>16)&255,Math.max((c>>8)&255,c&255))/255;
}
}
for(int iy=0;iy<TILE;iy++)for(int ix=0;ix<TILE;ix++){
int value=mask[Math.clamp((iy-2)*h/INNER,0,h-1)*w+Math.clamp((ix-2)*w/INNER,0,w-1)];
any|=value>0;atlas.getPixels().setPixel(x+ix,y+iy,0xFF000000|value*0x010101);
}
}catch(java.io.IOException exception){
fr.koka.sanctuary.SanctuaryMod.LOGGER.warn("Cannot analyse emissive texture {}",id,exception);
}
var result=new Slot(x,y,!any);slots.put(key,result);dirty=true;return result;
}
int[] mask(Identifier id,int[] colors,int width,int height,boolean ore,boolean fullEmission){
int[] result=new int[colors.length];String name=id.getPath();
boolean quartz=name.contains("quartz"),nether=name.contains("nether_gold")||quartz;
int[] host=ore?host(nether?"netherrack":name.contains("deepslate")?"deepslate":"stone"):new int[0];
boolean structural=!ore&&!fullEmission&&(name.contains("furnace_side")||name.contains("furnace_top")
||name.contains("smoker_side")||name.contains("smoker_top")||name.contains("smoker_bottom")
||name.contains("campfire_log")&&!name.contains("lit")||name.contains("respawn_anchor_side")
||name.contains("respawn_anchor_bottom"));
for(int i=0;i<colors.length;i++){
int c=colors[i],r=c>>16&255,g=c>>8&255,b=c&255,max=Math.max(r,Math.max(g,b)),min=Math.min(r,Math.min(g,b));
if((c>>>24)<128||structural)continue;
if(ore){
int difference=255;
for(int base:host){int br=base>>16&255,bg=base>>8&255,bb=base&255;
difference=Math.min(difference,Math.max(Math.abs(r-br),Math.max(Math.abs(g-bg),Math.abs(b-bb))));}
boolean inclusion=nether?(quartz?g>r*.65&&b>r*.5&&max>110:r>110&&g>75&&g>b*1.45):max-min>=10;
if(inclusion&&difference>=10)result[i]=255;
}else{
// Luminosity is tied to the block state; cold/off variants never enter this path.
// Dark casings/wood are suppressed; live atlas colours and animation stay native.
float value=fullEmission?1:Math.clamp((max-100)/110f,0,1);
if(name.contains("torch")||name.contains("furnace")||name.contains("smoker")||name.contains("campfire"))
value*=Math.clamp((max-min-35)/65f,0,1);
result[i]=Math.round(value*255);
}
}
if(ore&&!nether){
// Retain pale/white highlights touching coloured inclusions, without growing into grey rock.
int[] seed=result.clone();
for(int y=0;y<height;y++)for(int x=0;x<width;x++)if(seed[y*width+x]==0){
int c=colors[y*width+x],r=c>>16&255,g=c>>8&255,b=c&255;
if((c>>>24)<128||Math.min(r,Math.min(g,b))<215)continue;
for(int dy=-1;dy<=1;dy++)for(int dx=-1;dx<=1;dx++)if(x+dx>=0&&x+dx<width&&y+dy>=0&&y+dy<height&&seed[(y+dy)*width+x+dx]>0)result[y*width+x]=255;
}
}
return result;
}
private int[] host(String name){return hosts.computeIfAbsent(Identifier.withDefaultNamespace("block/"+name),id->{
try(var image=read(id)){var values=new LinkedHashSet<Integer>();for(int y=0;y<Math.min(64,image.getHeight());y++)for(int x=0;x<Math.min(64,image.getWidth());x++)values.add(image.getPixel(x*image.getWidth()/Math.min(64,image.getWidth()),y*image.getHeight()/Math.min(64,image.getHeight())));return values.stream().mapToInt(Integer::intValue).toArray();}
catch(java.io.IOException exception){return new int[0];}
});}
static Identifier texture(Identifier id){return Identifier.fromNamespaceAndPath(id.getNamespace(),"textures/"+id.getPath()+".png");}
static NativeImage read(Identifier id)throws java.io.IOException{try(var stream=Minecraft.getInstance().getResourceManager().getResourceOrThrow(texture(id)).open()){return NativeImage.read(stream);}}
DynamicTexture atlas(){if(dirty){atlas.upload();dirty=false;}return atlas;}
int count(){return slots.size();}
public void close(){if(atlas!=null){atlas.close();atlas=null;}slots.clear();ores.clear();hosts.clear();dirty=false;}
}
@@ -0,0 +1,147 @@
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;
/** Native visible sections only, palette rejection and bounded incremental emitter meshing. */
final class BloomTerrain 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 static final long MAX_BYTES=32L*1024*1024;
private final Map<Long,Section> sections=new HashMap<>();
final BloomMaterials materials=new BloomMaterials();
private Build building;
private long bytes;
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;}
long frame=mc.level.getGameTime();var active=new ArrayList<Section>();
for(var render:mc.levelRenderer.visibleSections()){
long key=render.getSectionNode();var pos=render.getRenderOrigin();
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){if(section!=null)remove(section);section=new Section(key,pos.immutable(),storage);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();}}
Vec3 camera=mc.gameRenderer.gameRenderState().levelRenderState.cameraRenderState.pos;
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(materials::candidate)){next.dirty=false;continue;}
building=new Build(next);
}
if(!advance(building,deadline))break;
var done=building;building=null;
try(done){
done.section.dirty=false;
if(done.quads>0&&!done.overflow){
try(var mesh=done.out.buildOrThrow()){
long size=mesh.vertexBuffer().remaining();
if(bytes+size<=MAX_BYTES){
var buffer=RenderSystem.getDevice().createBuffer(()->"Sanctuary bloom emitters",GpuBuffer.USAGE_VERTEX|GpuBuffer.USAGE_COPY_DST,mesh.vertexBuffer());
int count=mesh.drawState().indexCount();RenderSystem.getSequentialBuffer(PrimitiveTopology.QUADS).getBuffer(count);
done.section.entry=new Entry(done.section.origin,buffer,count);bytes+=buffer.size();
}
}
}
}
}
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(materials.candidate(state)){
var pos=build.section.origin.offset(x,y,z);boolean ore=materials.ore(state);
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,ore,material.lightEmission()>0||state.emissiveRendering()||sprite.isAnimated());
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;
// Alpha marks ore vs. native emitter; the mask alpha is not a gameplay light value.
int light=state.emissiveRendering()?15:Math.max(state.getLightEmission(),material.lightEmission());
int alpha=ore?Math.round(light/15f*120):128+Math.round(light/15f*127);
int color=(alpha<<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));
}
build.quads++;
}
}
if(state.liquid()&&state.getLightEmission()>0)fluid(build,pos,x,y,z,state.getFluidState().getHeight(mc.level,pos));
}
if((build.cursor&31)==0&&System.nanoTime()>=deadline)return build.cursor==4096;
}
return true;
}
private void fluid(Build build,BlockPos pos,int x,int y,int z,float height){
var mc=Minecraft.getInstance();var sprite=((TextureAtlas)mc.getTextureManager().getTexture(TextureAtlas.LOCATION_BLOCKS)).getSprite(net.minecraft.resources.Identifier.withDefaultNamespace("block/lava_still"));
var slot=materials.slot(sprite,false,true);if(slot.empty())return;
// Native world depth retains the actual fluid silhouette and occludes these candidate faces.
float[][][] faces={{{0,height,0},{0,height,1},{1,height,1},{1,height,0}},{{0,0,0},{0,height,0},{1,height,0},{1,0,0}},
{{0,0,1},{1,0,1},{1,height,1},{0,height,1}},{{0,0,0},{0,0,1},{0,height,1},{0,height,0}},{{1,0,0},{1,height,0},{1,height,1},{1,0,1}}};
for(var face:faces){if(build.quads>=16384){build.overflow=true;return;}
for(int i=0;i<4;i++){float u=i>=2?1:0,v=i==1||i==2?1:0;var p=face[i];
build.out.addVertex(x+p[0],y+p[1],z+p[2]).setColor(0xFFFFFFFF).setUv(sprite.getU(u),sprite.getV(v)).setUv3(slot.u(u),slot.v(v));}
build.quads++;
}
}
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){remove(section);section.dirty=true;}}
private void remove(Section section){if(building!=null&&building.section==section){building.close();building=null;}if(section.entry!=null){bytes-=section.entry.vertices.size();section.entry.vertices.close();section.entry=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;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 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;}
public void close(){buffer.close();}
}
}
@@ -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);}
}

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