Compare commits

...
21 Commits
Author SHA1 Message Date
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
188 changed files with 6835 additions and 121 deletions
+84 -9
View File
@@ -20,12 +20,12 @@ 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.118** ajoute les seize murets en briques colorées, avec
leurs textures existantes, les raccords natifs et leurs recettes. Ils forment
une série de seize après les dalles dans l'onglet des blocs colorés.
Le [ticket de livraison](docs/colored-brick-walls-beta118.md) conserve les
contrôles et le suivi de distribution. Livraison en préparation ; l'instance
**Sanctuary Beta** publiée reste beta.117 jusqu'à sa synchronisation.
La version **beta.128** stabilise les ombres pixélisées, atténue le soleil au
zénith et ajoute les ombres légères de la lune et le feuillage irrégulier.
**Options → Shaders → Sanctuary · Vanilla Light** : les réglages de finesse et
de portée sont conservés. Le [ticket de livraison](docs/shader-beta128.md)
décrit le résultat et ses vérifications. Les gemmes, sculptures et découvertes
de beta.126 restent inchangées.
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.
@@ -36,10 +36,85 @@ La [carte native beta.005](docs/atlas-beta005.md) prend le relais après
l’étude de Xaero et des alternatives libres.
## 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).
## 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).
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
@@ -2118,7 +2193,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
@@ -2147,7 +2222,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
}
+16 -2
View File
@@ -65,5 +65,19 @@ identiques. Les textures existantes sont toutes conservées octet pour octet.
- JAR Sanctuary :
`fc6edf4046a8ace357d561ed942213a22c7a27164fa160cdb4316f72ec302575`.
Reçu : `build/walls118-artifact.json`. Publication et synchronisation finales
en cours.
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`.
+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`.
+116
View File
@@ -14,6 +14,122 @@ 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.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
+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.
+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`.
+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).
+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`.
+92
View File
@@ -0,0 +1,92 @@
# 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` |
+5
View File
@@ -102,3 +102,8 @@ 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.
+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.118
pack_version=beta.118
resource_pack_version=beta.090
mod_version=beta.128
pack_version=beta.128
resource_pack_version=beta.121
maven_group=fr.koka.sanctuary
jei_version=30.32.0-sanctuary.3
+37
View File
@@ -93,6 +93,34 @@ tasks.named('runGameTest') {
}
tasks.named('processGametestResources') {
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') }
@@ -440,6 +468,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'
@@ -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,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);}
}
@@ -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); }
}
@@ -18,7 +18,20 @@ import net.minecraft.world.phys.*;
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);
@@ -28,7 +41,7 @@ final class Sculpture113ClientChecks {
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:SculptureAnchor.values()){
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);
@@ -61,7 +74,7 @@ final class Sculpture113ClientChecks {
}
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 5120 footprints/orientations/anchors and colored direction markers");
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);
@@ -81,10 +94,12 @@ final class Sculpture113ClientChecks {
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");default -> {}
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.WEST&&anchor!=SculptureAnchor.EAST)check(Math.abs(minX-(1-maxX))<=1/16f,"Other axis centered to nearest whole voxel");
if(anchor!=SculptureAnchor.NORTH&&anchor!=SculptureAnchor.SOUTH)check(Math.abs(minZ-(1-maxZ))<=1/16f,"Other axis centered to nearest whole voxel");
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);}
}
@@ -48,7 +48,7 @@ public final class Sculpture116ClientChecks implements FabricClientGameTest {
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->SculptureMaterialSampler.sample(new ItemStack(item)).apply(source));rendered.add(expected);
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");
@@ -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);}
}
@@ -62,7 +62,7 @@ public final class Statuary106ClientChecks implements FabricClientGameTest {
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=c.computeOnClient(m->SculptureMaterialSampler.sample(new ItemStack(Items.CLAY)).apply(originalSource));
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");
@@ -119,7 +119,14 @@ public final class Statuary106ClientChecks implements FabricClientGameTest {
for(var direction:Direction.Plane.HORIZONTAL){
var pos=DIRECTIONS.get(direction);double px=pos.getX()+.5-direction.getStepX()*2,pz=pos.getZ()+.5-direction.getStepZ()*2;
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,direction.getOpposite());
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");
@@ -189,12 +196,14 @@ public final class Statuary106ClientChecks implements FabricClientGameTest {
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){}}
}
@@ -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,634 @@
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 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) {
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);
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.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);
// 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, 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, 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");
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"}) {
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-beta128-shadows-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; noon attenuation; 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 > .04 && noonStats.maximum < .14,
"Noon shadow is present but attenuated: " + 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 < .070) 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 checkPreferences() {
VanillaLight.setEnabled(true);
VanillaLight.setSaturation(100);
for (int[] pair : new int[][]{{Integer.MIN_VALUE, 8}, {11, 8}, {12, 16}, {16, 16}, {23, 16}, {24, 32}, {Integer.MAX_VALUE, 32}}) {
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}, {Integer.MAX_VALUE, 64}}) {
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);
}
private 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);
}
}
private 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);
}
private static void look(ClientGameTestContext c, TestServerContext server, float yaw, float pitch) {
server.runOnServer(s -> {
var p = s.getPlayerList().getPlayers().getFirst();
command(s, "tp @a " + p.getX() + " " + p.getY() + " " + p.getZ() + " " + yaw + " " + pitch);
});
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());
}
}
private 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);
}
}
private 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"));
}
}
private 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);
}
private static Frame capture(ClientGameTestContext c, String name) {
if (name != null) c.takeScreenshot("sanctuary-beta128-shadows-" + 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();
}
private 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);
}
private 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;
}
private static int brightness(int color) {
return ((color >> 16 & 255) + (color >> 8 & 255) + (color & 255)) / 3;
}
private 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) {
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());
}
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.getLanguageManager().setSelected(language);
m.getLanguageManager().onResourceManagerReload(m.getResourceManager());
}
}
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); }
}
@@ -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();
@@ -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,6 +22,8 @@ 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,lastMaterial=ItemStack.EMPTY;
private SculpturePalette palette;private Object sampledResources;
@@ -38,12 +41,18 @@ public final class ClayWorkshopScreen extends AbstractContainerScreen<ClayWorksh
@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")));
@@ -58,6 +67,24 @@ 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;
var material=menu.getSlot(0).getItem();var resources=minecraft.getModelManager().getBlockStateModelSet();
@@ -65,9 +92,9 @@ public final class ClayWorkshopScreen extends AbstractContainerScreen<ClayWorksh
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:palette.apply(source);
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"):text("material_ready",material.getHoverName());
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.
@@ -94,10 +121,10 @@ 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;}
@@ -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);
}
}
@@ -19,6 +19,7 @@ public final class SanctuaryKnowledgeClient implements ClientModInitializer {
public static void registerClient() {
SanctuaryResourcePack.register();
VortexCloudRenderer.register();
fr.koka.sanctuary.client.shader.PixelShadows.register();
ProgressionClient.register();
StarClient.register();
WeatherClient.register();
@@ -35,6 +36,7 @@ public final class SanctuaryKnowledgeClient implements ClientModInitializer {
FoodKnowledgeClient.register();
MultiblockScreen.register();
FourneauModels.register();
WrenchTextureModels.register();
MetatableClient.register();
ClayWorkshopScreen.register();
GraveScreen.register();
@@ -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(){}
}
@@ -24,7 +24,7 @@ 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> {
record Quad(float x,float y,float z,int side,int color){}
private record Placement(VoxelSculpture sculpture,Direction facing,SculptureAnchor anchor){}
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={
@@ -40,27 +40,20 @@ public final class StatuaryRenderer implements BlockEntityRenderer<StatuaryEntit
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(),entity.getBlockState().getValue(Statuary.FACING),entity.getBlockState().getValue(Statuary.ANCHOR));
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){
draw(state.mesh,pose,collector,state.lightCoords,OverlayTexture.NO_OVERLAY);
}
private static List<Quad> mesh(VoxelSculpture sculpture,Direction facing,SculptureAnchor anchor){
if(sculpture==null)return List.of();var key=new Placement(sculpture,facing,anchor);var cached=MESHES.get(key);if(cached!=null)return cached;
// Rotate integer cells before anchoring: odd dimensions never introduce half-pixel offsets.
int turns=switch(facing){case WEST -> 1;case NORTH -> 2;case EAST -> 3;default -> 0;};
for(int i=0;i<turns;i++)sculpture=sculpture.rotate();
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>();
int minX=16,minZ=16,maxX=0,maxZ=0;
for(var cell:sculpture.cells()){
minX=Math.min(minX,cell.x());minZ=Math.min(minZ,cell.z());
maxX=Math.max(maxX,cell.x()+1);maxZ=Math.max(maxZ,cell.z()+1);
}
float ox=(anchor.originX(maxX-minX)-minX)/16f,oz=(anchor.originZ(maxZ-minZ)-minZ)/16f;
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(key,cached);if(MESHES.size()>32)MESHES.remove(MESHES.keySet().iterator().next());return cached;
}
@@ -76,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,Direction.SOUTH,SculptureAnchor.CENTER);
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,190 @@
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.blaze3d.vertex.DefaultVertexFormat;
import com.mojang.renderpearl.api.GpuFormat;
import com.mojang.renderpearl.api.buffers.GpuBuffer;
import com.mojang.renderpearl.api.buffers.GpuBufferSlice;
import com.mojang.renderpearl.api.pipeline.*;
import com.mojang.renderpearl.api.textures.FilterMode;
import fr.koka.sanctuary.client.panorama.PanoramaCapture;
import java.util.*;
import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientLifecycleEvents;
import net.fabricmc.fabric.api.client.networking.v1.ClientPlayConnectionEvents;
import net.minecraft.client.Minecraft;
import net.minecraft.resources.Identifier;
import net.minecraft.core.BlockPos;
import net.minecraft.client.renderer.BindGroupLayouts;
import net.minecraft.client.renderer.MappableRingBuffer;
import net.minecraft.client.renderer.RenderPipelines;
import net.minecraft.client.renderer.state.level.CameraRenderState;
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;
/** Native sun/moon depth map and world-grid receivers. Never reads GPU pixels or changes gameplay light. */
public final class PixelShadows {
private static final RenderPipeline DEPTH = RenderPipelines.register(RenderPipeline.builder()
.withLocation(Identifier.parse("sanctuary:pipeline/pixel_shadow_depth"))
.withVertexShader(Identifier.parse("sanctuary:core/pixel_shadow_depth"))
.withFragmentShader(Identifier.parse("sanctuary:core/pixel_shadow_depth"))
.withBindGroupLayout(BindGroupLayouts.DYNAMIC_TRANSFORMS)
.withVertexBinding(0, DefaultVertexFormat.POSITION_COLOR)
.withPrimitiveTopology(PrimitiveTopology.QUADS).withCull(false)
.withColorTargetState(new ColorTargetState(Optional.empty(), GpuFormat.R8_UNORM, ColorTargetState.WRITE_COLOR))
.withDepthStencilState(new DepthStencilState(CompareOp.LESS_THAN, true)).build());
private static final RenderPipeline APPLY = RenderPipelines.register(RenderPipeline.builder()
.withLocation(Identifier.parse("sanctuary:pipeline/pixel_shadows"))
.withVertexShader("core/screenquad")
.withFragmentShader(Identifier.parse("sanctuary:core/pixel_shadows"))
.withBindGroupLayout(BindGroupLayout.builder()
.withUniform("PixelShadows", UniformType.UNIFORM_BUFFER)
.withUniform("WorldDepth", UniformType.COMBINED_IMAGE_SAMPLER)
.withUniform("SunDepth", UniformType.COMBINED_IMAGE_SAMPLER)
.withUniform("SunMaterial", UniformType.COMBINED_IMAGE_SAMPLER).build())
.withPrimitiveTopology(PrimitiveTopology.TRIANGLES).withCull(false)
.withColorTargetState(new ColorTargetState(Optional.of(BlendFunction.TRANSLUCENT), GpuFormat.RGBA8_UNORM, ColorTargetState.WRITE_COLOR))
.withDepthStencilState(Optional.empty()).build());
private static final ShadowTerrain TERRAIN = new ShadowTerrain();
private static final Matrix4f projection = new Matrix4f();
private static TextureTarget shadowMap;
private static MappableRingBuffer uniforms;
private static int renderedFrames;
private static boolean applied;
private PixelShadows() { }
public static void register() {
ClientPlayConnectionEvents.DISCONNECT.register((handler, client) -> release());
ClientLifecycleEvents.CLIENT_STOPPING.register(client -> release());
}
public static void captureProjection(Matrix4f matrix) { projection.set(matrix); }
public static void dirtyBlock(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++) TERRAIN.dirtySection(x,y,z);
}
public static void dirtyChunk(int x, int z) { TERRAIN.dirtyChunk(x, z); }
/** Called after the world's depth is resolved and before the first-person hand and GUI. */
public static void render() {
applied = false;
var client = Minecraft.getInstance();
if (!VanillaLight.active() || !VanillaLight.pixelShadows() || client.level == null) {
release();
return;
}
if (PanoramaCapture.rendering()) return;
var state = client.gameRenderer.gameRenderState().levelRenderState;
var sky = state.skyRenderState;
CameraRenderState camera = state.cameraRenderState;
var sun = sunDirection(sky.sunAngle);
boolean lunar = sun.y <= .08f;
if (lunar) sun = sunDirection(sky.moonAngle);
if (sky.skybox != DimensionType.Skybox.OVERWORLD || camera.fogType != FogType.NONE || sun.y <= .08f) return;
float moon = sky.moonPhase == null ? 1 : (float)(.5 + .5 * Math.cos(sky.moonPhase.index() * Math.PI / 4));
float strength = (lunar ? .10f * moon : .38f * zenithFade(sun.y))
* Math.clamp((sun.y - .08f) / .16f, 0, 1) * sky.rainBrightness;
if (strength <= .001f) return;
int distance = VanillaLight.shadowDistance(), pixels = VanillaLight.shadowPixels();
TERRAIN.update(client.level, camera.pos, distance, sun);
int size = mapSize(distance, pixels);
if (shadowMap == null || shadowMap.width != size) {
if (shadowMap != null) shadowMap.destroyBuffers();
shadowMap = new TextureTarget("Sanctuary celestial shadows", size, size, GpuFormat.R8_UNORM, GpuFormat.D32_FLOAT);
}
if (uniforms == null) uniforms = new MappableRingBuffer(() -> "Sanctuary pixel shadows",
GpuBuffer.USAGE_UNIFORM | GpuBuffer.USAGE_MAP_WRITE, 192);
double ox = anchor(camera.pos.x), oy = anchor(camera.pos.y), oz = anchor(camera.pos.z);
boolean zeroToOne = RenderSystem.getDevice().getDeviceInfo().isZZeroToOne();
var light = lightMatrix(sun, distance, size, ox, oy, oz, zeroToOne);
var inverse = new Matrix4f(projection).mul(camera.viewRotationMatrix).invert();
uniforms.rotate();
var fog = camera.fogData;
try (var mapped = uniforms.currentBuffer().map(false, true)) {
Std140Builder.intoBuffer(mapped.data()).putMat4f(inverse).putMat4f(light)
.putVec4((float)(camera.pos.x - ox), (float)(camera.pos.y - oy), (float)(camera.pos.z - oz), pixels)
.putVec4(sun.x, sun.y, sun.z, strength)
.putVec4(distance, zeroToOne ? 1 : 0, fog.renderDistanceStart, fog.renderDistanceEnd)
.putVec4(fog.environmentalStart, fog.environmentalEnd, fog.color.w, 0);
}
var entries = TERRAIN.entries();
var transforms = new ArrayList<GpuBufferSlice>(entries.size());
int maxIndices = 0;
for (var entry : entries) {
transforms.add(RenderSystem.getDynamicUniforms().writeTransform(new Matrix4f(light)
.translate((float)(entry.x() - ox), (float)(entry.y() - oy), (float)(entry.z() - oz)),
new Vector4f(1, 1, 1, 1),
// Bounded world coordinates keep foliage noise precise far from spawn.
new Vector3f(Math.floorMod(entry.x(), 4096), Math.floorMod(entry.y(), 4096),
Math.floorMod(entry.z(), 4096)), new Matrix4f()));
maxIndices = Math.max(maxIndices, entry.indexCount());
}
var indices = RenderSystem.getSequentialBuffer(PrimitiveTopology.QUADS);
indices.requestIndexCount(maxIndices);
var encoder = RenderSystem.getDevice().createCommandEncoder();
try (var pass = encoder.createRenderPass(com.mojang.renderpearl.api.commands.RenderPassDescriptor.builder(() -> "Sanctuary celestial depth")
.withColorAttachment(shadowMap.getColorTextureView(), Optional.of(new Vector4f(0, 0, 0, 0)))
.withDepthAttachment(shadowMap.getDepthTextureView(), OptionalDouble.of(1)).build())) {
pass.setPipeline(RenderSystem.getCompiledPipeline(DEPTH));
if (maxIndices > 0) 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.indexCount(), 1, 0, 0, 0);
}
}
var main = client.gameRenderer.mainRenderTarget();
var nearest = RenderSystem.getSamplerCache().getClampToEdge(FilterMode.NEAREST);
try (var pass = encoder.createRenderPass(() -> "Sanctuary pixel shadows", main.getColorTextureView(), Optional.empty())) {
pass.setPipeline(RenderSystem.getCompiledPipeline(APPLY));
pass.setUniform("PixelShadows", uniforms.currentBuffer());
pass.setUniform("WorldDepth", main.getDepthTextureView(), nearest);
pass.setUniform("SunDepth", shadowMap.getDepthTextureView(), nearest);
pass.setUniform("SunMaterial", shadowMap.getColorTextureView(), nearest);
pass.draw(3, 1, 0, 0);
}
renderedFrames++;
applied = true;
}
/** Smoothly soften overhead sunlight, without a noon on/off discontinuity. */
static float zenithFade(float elevation) {
float t = Math.clamp((elevation - .92f) / .08f, 0, 1);
return 1 - .75f * t * t * (3 - 2 * t);
}
static double anchor(double coordinate) { return Math.floor(coordinate / 16.0 + .5) * 16.0; }
static Vector3f sunDirection(float angle) { return new Vector3f(-(float)Math.sin(angle), (float)Math.cos(angle), 0); }
static int mapSize(int distance, int pixels) {
int size = 512;
while (size < 2 * (distance + 16) * pixels && size < 4096) size *= 2;
return size;
}
static Matrix4f lightMatrix(Vector3f sun, int distance, int size, double x, double y, double z, boolean zeroToOne) {
float extent = distance + 16, reach = 4 * (distance + 16);
// A fixed up axis is nonsingular even at noon. Snap the light-space origin to texels.
var view = new Matrix4f().lookAt(sun.x * reach, sun.y * reach, sun.z * reach, 0, 0, 0, 0, 0, 1);
double texel = 2.0 * extent / size;
double sx = view.m00() * x + view.m10() * y + view.m20() * z;
double sy = view.m01() * x + view.m11() * y + view.m21() * z;
var result = new Matrix4f().setOrtho(-extent, extent, -extent, extent, .1f, reach * 2, zeroToOne).mul(view);
result.m30(result.m30() + (float)((sx - Math.rint(sx / texel) * texel) / extent));
result.m31(result.m31() + (float)((sy - Math.rint(sy / texel) * texel) / extent));
return result;
}
public static void release() {
TERRAIN.close();
if (shadowMap != null) { shadowMap.destroyBuffers(); shadowMap = null; }
if (uniforms != null) { uniforms.close(); uniforms = null; }
applied = false;
}
static int renderedFrames() { return renderedFrames; }
static boolean applied() { return applied; }
static boolean ready() { return TERRAIN.settled(); }
static TextureTarget shadowMap() { return shadowMap; }
}
@@ -1,5 +1,7 @@
package fr.koka.sanctuary.client.shader;
import com.mojang.serialization.Codec;
import java.util.List;
import net.minecraft.client.Minecraft;
import net.minecraft.client.OptionInstance;
import net.minecraft.client.gui.screens.Screen;
@@ -15,5 +17,14 @@ public final class ShaderOptionsScreen extends OptionsSubScreen {
list.addBig(new OptionInstance<Integer>("sanctuary.shaders.saturation",OptionInstance.cachedConstantTooltip(
Component.translatable("sanctuary.shaders.saturation_help")),(caption,value)->Component.translatable("sanctuary.shaders.saturation_value",value),
new OptionInstance.IntRange(0,200),VanillaLight.saturation(),VanillaLight::setSaturation));
list.addBig(OptionInstance.createBoolean("sanctuary.shaders.pixel_shadows",OptionInstance.cachedConstantTooltip(
Component.translatable("sanctuary.shaders.pixel_shadows_help")),
VanillaLight.pixelShadows(),VanillaLight::setPixelShadows));
list.addBig(new OptionInstance<Integer>("sanctuary.shaders.shadow_pixels",OptionInstance.cachedConstantTooltip(
Component.translatable("sanctuary.shaders.shadow_pixels_help")),(caption,value)->Component.translatable("sanctuary.shaders.shadow_pixels_value",value),
new OptionInstance.Enum<>(List.of(8,16,32),Codec.INT),VanillaLight.shadowPixels(),VanillaLight::setShadowPixels));
list.addBig(new OptionInstance<Integer>("sanctuary.shaders.shadow_distance",OptionInstance.cachedConstantTooltip(
Component.translatable("sanctuary.shaders.shadow_distance_help")),(caption,value)->Component.translatable("sanctuary.shaders.shadow_distance_value",value),
new OptionInstance.Enum<>(List.of(16,32,64),Codec.INT),VanillaLight.shadowDistance(),VanillaLight::setShadowDistance));
}
}
@@ -0,0 +1,517 @@
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.blaze3d.vertex.DefaultVertexFormat;
import com.mojang.renderpearl.api.buffers.GpuBuffer;
import com.mojang.renderpearl.api.pipeline.PrimitiveTopology;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.IdentityHashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import net.minecraft.core.Direction;
import net.minecraft.tags.BlockTags;
import net.minecraft.core.SectionPos;
import net.minecraft.world.level.ChunkPos;
import net.minecraft.client.multiplayer.ClientLevel;
import net.minecraft.world.level.block.Block;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.chunk.LevelChunk;
import net.minecraft.world.level.chunk.LevelChunkSection;
import net.minecraft.world.level.chunk.status.ChunkStatus;
import net.minecraft.world.phys.AABB;
import net.minecraft.world.phys.Vec3;
import org.joml.Vector3fc;
/**
* Render-thread-only, read-only terrain casters. Call update BEFORE opening any render pass.
* Positions use POSITION_COLOR (three floats plus a foliage flag), relative to the entry's block
* origin; add that origin in the draw transform. Indices belong to RenderSystem's shared
* QUADS sequential buffer, which update grows before drawing. This cache never owns it.
*
* <p>Call dirtySection for edits (including affected neighbors), or dirtyChunk for a chunk
* packet that mutates an existing chunk. Identity changes/unloads are detected automatically.
* Call close on disable/disconnect/resource teardown; it is idempotent and the cache is reusable.
* A different ClientLevel, or update(null, ...), also releases all owned resources.
*/
public final class ShadowTerrain implements AutoCloseable {
/** Bounds metadata/work as well as geometry; current shader options use 16, 32 and 64. */
public static final int MAX_DISTANCE = 128;
private static final int MAX_SECTIONS_PER_UPDATE = 4;
private static final long BUILD_NANOS = 3_000_000L;
private static final long MAX_GPU_BYTES = 96L * 1024 * 1024;
private static final int MAX_SECTION_QUADS = 65_536;
private static final int MAX_SHAPES = 1024;
private static final int MAX_BOXES = 256;
private static final Direction[] DIRECTIONS = Direction.values();
/** Borrowed buffer: owned exclusively by this cache, valid until its next update/close. */
public record Entry(int x, int y, int z, GpuBuffer vertices, int indexCount) { }
private final Map<Long, LevelChunk> chunks = new HashMap<>();
private final Map<Long, Section> sections = new HashMap<>();
private final Map<BlockState, Shape> shapes = new IdentityHashMap<>();
private ClientLevel level;
private Bounds bounds;
private Build building;
private long gpuBytes;
private List<Entry> entries = List.of();
private boolean entriesChanged;
private int cameraSectionX, cameraSectionY, cameraSectionZ;
/**
* Refreshes loaded section metadata, then meshes at most four sections within a soft 3 ms
* budget. A large section resumes next call; a single shape lookup or GPU upload cannot be
* preempted. distance is in blocks, 0..MAX_DISTANCE. The receiver volume plus an eight-block guard
* is extruded by twice that distance toward the sun, rounded to sections and clipped
* to the level's build height. This includes loaded casters outside the camera frustum.
*
* <p>The 96 MiB vertex budget favors nearer sections. Evicted/deferred meshes are retried
* when room becomes available or the camera enters another section. Pathological geometry
* exceeding 256 boxes per state or 65,536 quads per section is omitted, never approximated
* by a full cube or uploaded partially. It can be retried after invalidation.
*/
public List<Entry> update(ClientLevel level, Vec3 camera, int distance, Vector3fc sun) {
RenderSystem.assertOnRenderThread();
if (level == null) {
close();
return entries;
}
Objects.requireNonNull(camera, "camera");
if (distance < 0 || distance > MAX_DISTANCE || !Double.isFinite(camera.x)
|| !Double.isFinite(camera.y) || !Double.isFinite(camera.z)) {
throw new IllegalArgumentException("Finite camera and shadow distance 0.." + MAX_DISTANCE + " required");
}
if (this.level != level) {
close();
this.level = level;
}
int radius = distance + 8;
double dx = sun.x() * distance * 2, dy = sun.y() * distance * 2, dz = sun.z() * distance * 2;
Bounds next = new Bounds(section(camera.x - radius + Math.min(0, dx)), section(camera.x + radius + Math.max(0, dx)),
Math.max(level.getMinSectionY(), section(camera.y - radius + Math.min(0, dy))),
Math.min(level.getMaxSectionY() - 1, section(camera.y + radius + Math.max(0, dy))),
section(camera.z - radius + Math.min(0, dz)), section(camera.z + radius + Math.max(0, dz)));
int cx = section(camera.x), cy = section(camera.y), cz = section(camera.z);
boolean moved = !next.equals(bounds) || cx != cameraSectionX || cy != cameraSectionY || cz != cameraSectionZ;
if (bounds != null && !next.equals(bounds)) {
// Changing the caster boundary changes which neighboring faces may be culled.
for (Section section : sections.values()) {
if (bounds.onBoundary(section) || next.onBoundary(section)) dirty(section);
}
}
bounds = next;
cameraSectionX = cx;
cameraSectionY = cy;
cameraSectionZ = cz;
reconcile();
if (moved) {
retryDeferred();
}
long deadline = System.nanoTime() + BUILD_NANOS;
int started = building == null ? 0 : 1;
try {
while (System.nanoTime() < deadline) {
if (building == null) {
if (started == MAX_SECTIONS_PER_UPDATE) break;
Section nearest = nearestDirty(camera);
if (nearest == null) break;
building = new Build(nearest);
started++;
}
if (!advance(building, deadline)) break;
Build finished = building;
building = null;
try (finished) {
upload(finished, camera);
}
}
} catch (RuntimeException | Error failure) {
cancelBuild();
throw failure;
}
return entries();
}
/** Immutable snapshot; consume after update and before another update or close. */
public List<Entry> entries() {
RenderSystem.assertOnRenderThread();
if (entriesChanged) {
var result = new ArrayList<Entry>();
for (Section section : sections.values()) {
if (section.entry != null) result.add(section.entry);
}
entries = List.copyOf(result);
entriesChanged = false;
}
return entries;
}
/** Outstanding rebuilds, including the in-progress section; excludes memory-deferred work. */
public int pendingSections() {
RenderSystem.assertOnRenderThread();
int pending = 0;
for (Section section : sections.values()) {
if (section.dirty && !section.deferred) pending++;
}
return pending;
}
/** No runnable mesh work after the last update; may still omit memory/complexity-limited sections. */
public boolean settled() {
return pendingSections() == 0;
}
/** Coordinates are section coordinates, not block origins. Caller also invalidates neighbors. */
public void dirtySection(int sx, int sy, int sz) {
RenderSystem.assertOnRenderThread();
Section section = sections.get(SectionPos.asLong(sx, sy, sz));
if (section != null) dirty(section);
}
/** Coordinates are chunk coordinates. Caller also invalidates adjacent chunks as needed. */
public void dirtyChunk(int cx, int cz) {
RenderSystem.assertOnRenderThread();
for (Section section : sections.values()) {
if (section.x == cx && section.z == cz) dirty(section);
}
}
private void dirty(Section section) {
section.dirty = true;
section.deferred = false;
if (building != null && building.section == section) cancelBuild();
}
/** Poll only chunk identities and section emptiness; never scan unchanged block storage. */
private void reconcile() {
var loaded = new HashMap<Long, LevelChunk>();
if (bounds.minY <= bounds.maxY) {
for (int x = bounds.minX; x <= bounds.maxX; x++) {
for (int z = bounds.minZ; z <= bounds.maxZ; z++) {
LevelChunk chunk = level.getChunkSource().getChunk(x, z, ChunkStatus.FULL, false);
long key = ChunkPos.pack(x, z);
if (chunk != null) loaded.put(key, chunk);
if (chunks.get(key) != chunk) dirtyChunkAndNeighbors(x, z);
}
}
}
for (long key : chunks.keySet()) {
if (!loaded.containsKey(key)) dirtyChunkAndNeighbors(ChunkPos.getX(key), ChunkPos.getZ(key));
}
chunks.clear();
chunks.putAll(loaded);
boolean freed = false;
for (Iterator<Section> iterator = sections.values().iterator(); iterator.hasNext();) {
Section section = iterator.next();
LevelChunk chunk = chunks.get(ChunkPos.pack(section.x, section.z));
if (!bounds.contains(section.x, section.y, section.z) || chunk != section.chunk
|| chunk.getSection(level.getSectionIndexFromSectionY(section.y)) != section.storage
|| section.storage.hasOnlyAir()) {
if (building != null && building.section == section) cancelBuild();
freed |= section.entry != null;
release(section);
iterator.remove();
}
}
for (LevelChunk chunk : chunks.values()) {
int x = chunk.getPos().x(), z = chunk.getPos().z();
for (int y = bounds.minY; y <= bounds.maxY; y++) {
LevelChunkSection storage = chunk.getSection(level.getSectionIndexFromSectionY(y));
long key = SectionPos.asLong(x, y, z);
if (!storage.hasOnlyAir() && !sections.containsKey(key)) {
sections.put(key, new Section(x, y, z, chunk, storage));
}
}
}
if (freed) retryDeferred();
}
private void dirtyChunkAndNeighbors(int x, int z) {
dirtyChunk(x, z);
dirtyChunk(x - 1, z);
dirtyChunk(x + 1, z);
dirtyChunk(x, z - 1);
dirtyChunk(x, z + 1);
}
private void retryDeferred() {
for (Section section : sections.values()) {
if (section.deferred) {
section.deferred = false;
section.dirty = true;
}
}
}
private Section nearestDirty(Vec3 camera) {
Section nearest = null;
double best = Double.POSITIVE_INFINITY;
for (Section section : sections.values()) {
if (section.dirty && !section.deferred) {
double distance = section.distanceSquared(camera);
if (distance < best) {
best = distance;
nearest = section;
}
}
}
return nearest;
}
private boolean advance(Build build, long deadline) {
while (build.cursor < 4096) {
int index = build.cursor++;
int x = index & 15, z = (index >> 4) & 15, y = index >> 8;
var state = build.section.storage.getBlockState(x, y, z);
boolean foliage = state.is(BlockTags.LEAVES);
Shape shape = foliage ? LEAVES : shape(state);
if (shape != null) {
if (shape.boxes == null) {
build.oversized = true;
return true;
}
int hidden = 0;
for (Direction direction : DIRECTIONS) {
if (foliage) break; // Retain internal leaf faces: each layer contributes transmission.
BlockState neighbor = neighbor(build.section, x + direction.getStepX(),
y + direction.getStepY(), z + direction.getStepZ());
Shape other = neighbor == null ? null : shape(neighbor);
if (other != null && (other.fullFaces & (1 << direction.getOpposite().ordinal())) != 0) {
hidden |= 1 << direction.ordinal();
}
}
for (AABB box : shape.boxes) {
for (Direction direction : DIRECTIONS) {
if ((hidden & (1 << direction.ordinal())) != 0 && atBoundary(box, direction)) continue;
if (build.quads == MAX_SECTION_QUADS) {
build.oversized = true;
return true;
}
face(build.vertices, box, x, y, z, direction, foliage ? 0xFFFFFFFF : 0xFF000000);
build.quads++;
}
}
}
if ((build.cursor & 31) == 0 && System.nanoTime() >= deadline) return build.cursor == 4096;
}
return true;
}
private BlockState neighbor(Section section, int x, int y, int z) {
if ((x | y | z) >= 0 && x < 16 && y < 16 && z < 16) {
return section.storage.getBlockState(x, y, z);
}
int sx = section.x + (x >> 4), sy = section.y + (y >> 4), sz = section.z + (z >> 4);
// Keep the exterior faces of the caster volume; no dependency on chunks outside it.
if (!bounds.contains(sx, sy, sz)) return null;
LevelChunk chunk = chunks.get(ChunkPos.pack(sx, sz));
if (chunk == null) return null;
return chunk.getSection(level.getSectionIndexFromSectionY(sy)).getBlockState(x & 15, y & 15, z & 15);
}
private Shape shape(BlockState state) {
// Retain the solid part of waterlogged slabs/stairs, but never mesh the fluid itself.
if (state.isAir() || state.is(BlockTags.LEAVES) || !state.canOcclude() || state.liquid()) return null;
Shape cached = shapes.get(state);
if (cached != null) return cached;
// In 26.3 occlusion shapes are cached on BlockState and take no level/position argument.
var voxel = state.getOcclusionShape();
var boxes = new ArrayList<AABB>();
voxel.forAllBoxes((x0, y0, z0, x1, y1, z1) -> {
if (boxes.size() <= MAX_BOXES) boxes.add(new AABB(x0, y0, z0, x1, y1, z1));
});
int fullFaces = 0;
for (Direction direction : DIRECTIONS) {
if (Block.isFaceFull(voxel, direction)) fullFaces |= 1 << direction.ordinal();
}
// An omitted shape must not hide the faces of the surrounding geometry.
Shape result = boxes.size() > MAX_BOXES ? new Shape(null, 0) : new Shape(boxes.toArray(AABB[]::new), fullFaces);
if (shapes.size() == MAX_SHAPES) shapes.clear();
shapes.put(state, result);
return result;
}
private void upload(Build build, Vec3 camera) {
Section section = build.section;
if (build.oversized || build.quads == 0) {
release(section);
section.dirty = false;
retryDeferred();
return;
}
try (var mesh = build.vertices.buildOrThrow()) {
long bytes = mesh.vertexBuffer().remaining();
long previous = section.entry == null ? 0 : section.entry.vertices.size();
// Release the old buffer before replacement, so even transient owned GPU memory is bounded.
release(section);
while (gpuBytes + bytes > MAX_GPU_BYTES) {
Section farthest = null;
double far = section.distanceSquared(camera);
for (Section candidate : sections.values()) {
double distance = candidate.distanceSquared(camera);
if (candidate.entry != null && distance > far) {
farthest = candidate;
far = distance;
}
}
if (farthest == null) {
section.deferred = true;
return;
}
release(farthest);
farthest.dirty = true;
farthest.deferred = true;
}
int indexCount = mesh.drawState().indexCount();
RenderSystem.getSequentialBuffer(PrimitiveTopology.QUADS).getBuffer(indexCount);
GpuBuffer buffer = RenderSystem.getDevice().createBuffer(() -> "Sanctuary shadow terrain "
+ section.x + "," + section.y + "," + section.z,
GpuBuffer.USAGE_VERTEX | GpuBuffer.USAGE_COPY_DST, mesh.vertexBuffer());
section.entry = new Entry(section.x << 4, section.y << 4, section.z << 4, buffer, indexCount);
gpuBytes += buffer.size();
section.dirty = false;
entriesChanged = true;
if (bytes < previous) retryDeferred();
}
}
private void release(Section section) {
if (section.entry != null) {
GpuBuffer buffer = section.entry.vertices;
gpuBytes -= buffer.size();
buffer.close();
section.entry = null;
entriesChanged = true;
}
}
private void cancelBuild() {
if (building != null) {
building.close();
building = null;
}
}
@Override
public void close() {
RenderSystem.assertOnRenderThread();
cancelBuild();
for (Section section : sections.values()) release(section);
sections.clear();
chunks.clear();
shapes.clear();
entries = List.of();
entriesChanged = false;
gpuBytes = 0;
bounds = null;
level = null;
}
private static int section(double coordinate) {
return (int) Math.floor(coordinate / 16.0);
}
private static boolean atBoundary(AABB box, Direction direction) {
return switch (direction) {
case DOWN -> box.minY == 0;
case UP -> box.maxY == 1;
case NORTH -> box.minZ == 0;
case SOUTH -> box.maxZ == 1;
case WEST -> box.minX == 0;
case EAST -> box.maxX == 1;
};
}
/** Counterclockwise exterior faces, matching the native sequential QUADS index order. */
private static void face(BufferBuilder out, AABB box, int x, int y, int z, Direction direction, int color) {
float x0 = x + (float) box.minX, x1 = x + (float) box.maxX;
float y0 = y + (float) box.minY, y1 = y + (float) box.maxY;
float z0 = z + (float) box.minZ, z1 = z + (float) box.maxZ;
switch (direction) {
case DOWN -> {
out.addVertex(x0, y0, z0).setColor(color); out.addVertex(x1, y0, z0).setColor(color);
out.addVertex(x1, y0, z1).setColor(color); out.addVertex(x0, y0, z1).setColor(color);
}
case UP -> {
out.addVertex(x0, y1, z0).setColor(color); out.addVertex(x0, y1, z1).setColor(color);
out.addVertex(x1, y1, z1).setColor(color); out.addVertex(x1, y1, z0).setColor(color);
}
case NORTH -> {
out.addVertex(x0, y0, z0).setColor(color); out.addVertex(x0, y1, z0).setColor(color);
out.addVertex(x1, y1, z0).setColor(color); out.addVertex(x1, y0, z0).setColor(color);
}
case SOUTH -> {
out.addVertex(x0, y0, z1).setColor(color); out.addVertex(x1, y0, z1).setColor(color);
out.addVertex(x1, y1, z1).setColor(color); out.addVertex(x0, y1, z1).setColor(color);
}
case WEST -> {
out.addVertex(x0, y0, z0).setColor(color); out.addVertex(x0, y0, z1).setColor(color);
out.addVertex(x0, y1, z1).setColor(color); out.addVertex(x0, y1, z0).setColor(color);
}
case EAST -> {
out.addVertex(x1, y0, z0).setColor(color); out.addVertex(x1, y1, z0).setColor(color);
out.addVertex(x1, y1, z1).setColor(color); out.addVertex(x1, y0, z1).setColor(color);
}
}
}
private record Bounds(int minX, int maxX, int minY, int maxY, int minZ, int maxZ) {
boolean contains(int x, int y, int z) {
return x >= minX && x <= maxX && y >= minY && y <= maxY && z >= minZ && z <= maxZ;
}
boolean onBoundary(Section section) {
return section.x == minX || section.x == maxX || section.y == minY || section.y == maxY
|| section.z == minZ || section.z == maxZ;
}
}
private record Shape(AABB[] boxes, int fullFaces) { }
private static final Shape LEAVES = new Shape(new AABB[]{new AABB(0, 0, 0, 1, 1, 1)}, 0);
private static final class Section {
final int x, y, z;
final LevelChunk chunk;
final LevelChunkSection storage;
Entry entry;
boolean dirty = true;
boolean deferred;
Section(int x, int y, int z, LevelChunk chunk, LevelChunkSection storage) {
this.x = x;
this.y = y;
this.z = z;
this.chunk = chunk;
this.storage = storage;
}
double distanceSquared(Vec3 camera) {
double dx = x * 16.0 + 8 - camera.x, dy = y * 16.0 + 8 - camera.y, dz = z * 16.0 + 8 - camera.z;
return dx * dx + dy * dy + dz * dz;
}
}
private static final class Build implements AutoCloseable {
final Section section;
final ByteBufferBuilder bytes = new ByteBufferBuilder(64 * 1024);
final BufferBuilder vertices = new BufferBuilder(bytes, PrimitiveTopology.QUADS, DefaultVertexFormat.POSITION_COLOR);
int cursor, quads;
boolean oversized;
Build(Section section) {
this.section = section;
}
@Override
public void close() {
bytes.close();
}
}
}
@@ -14,7 +14,13 @@ public final class VanillaLight {
||FabricLoader.getInstance().isModLoaded("oculus")||FabricLoader.getInstance().isModLoaded("canvas");
private static Preferences preferences;
private VanillaLight() { }
private static final class Preferences { boolean enabled=true;int saturation=142; }
private static final class Preferences {
boolean enabled=true;
int saturation=142;
boolean pixelShadows=false;
int shadowPixels=16;
int shadowDistance=32;
}
private static Path path(){return FabricLoader.getInstance().getConfigDir().resolve("sanctuary-shaders.json");}
public static boolean enabled(){
if(preferences==null){
@@ -22,17 +28,30 @@ public final class VanillaLight {
catch(Exception e){SanctuaryMod.LOGGER.warn("Cannot read Sanctuary shader preference",e);}
if(preferences==null)preferences=new Preferences();
preferences.saturation=Math.clamp(preferences.saturation,0,200);
preferences.shadowPixels=normalizeShadowValue(preferences.shadowPixels,8,16,32);
preferences.shadowDistance=normalizeShadowValue(preferences.shadowDistance,16,32,64);
}
return preferences.enabled;
}
public static boolean externalEngine(){return EXTERNAL_ENGINE;}
public static boolean active(){return ProgressionClient.active()&&!EXTERNAL_ENGINE&&enabled();}
public static int saturation(){enabled();return preferences.saturation;}
public static boolean pixelShadows(){enabled();return preferences.pixelShadows;}
public static int shadowPixels(){enabled();return preferences.shadowPixels;}
public static int shadowDistance(){enabled();return preferences.shadowDistance;}
public static void setSaturation(int value){enabled();preferences.saturation=Math.clamp(value,0,200);save();}
public static void setPixelShadows(boolean value){enabled();preferences.pixelShadows=value;save();}
public static void setShadowPixels(int value){enabled();preferences.shadowPixels=normalizeShadowValue(value,8,16,32);save();}
public static void setShadowDistance(int value){enabled();preferences.shadowDistance=normalizeShadowValue(value,16,32,64);save();}
public static void setEnabled(boolean value){
enabled();preferences.enabled=value;
save();
}
/** Nearest supported value, clamped at either end; midpoint ties round up. */
private static int normalizeShadowValue(int value,int low,int middle,int high){
if(value<(low+middle)/2)return low;
return value<(middle+high)/2?middle:high;
}
private static void save(){
try{
Files.createDirectories(path().getParent());var temporary=path().resolveSibling(path().getFileName()+".tmp");
@@ -34,13 +34,17 @@ public final class CompanionQueries {
&&familiar.egg().getItem() instanceof net.minecraft.world.item.SpawnEggItem?net.minecraft.world.item.SpawnEggItem.getType(familiar.egg()):entity.getType();
var id=BuiltInRegistries.ENTITY_TYPE.getKey(type).getPath();
if(CompanionType.find(id)==null)return;
var seen=new TreeSet<>(Arrays.asList(record.notes().getOrDefault("seen_mobs","").split(",")));seen.remove("");
if(seen.add(id))record=record.note("seen_mobs",String.join(",",seen));
record=discoverSpecies(record,List.of(id));
if(entity instanceof net.minecraft.world.entity.animal.fish.TropicalFish fish){
var variants=fishVariants(record);variants.set(fishVariant(fish));record=record.note("seen_fish_v1",Base64.getEncoder().encodeToString(variants.toByteArray()));
}
if(!record.equals(CompanionService.record(p)))CompanionService.save(p,record);
}
/** The same encounter record is used by natural observation and administrative discoveries. */
public static CompanionRecord discoverSpecies(CompanionRecord record,Collection<String> ids){
var seen=new TreeSet<>(Arrays.asList(record.notes().getOrDefault("seen_mobs","").split(",")));seen.remove("");
return seen.addAll(ids)?record.note("seen_mobs",String.join(",",seen)):record;
}
private static int fishVariant(net.minecraft.world.entity.animal.fish.TropicalFish fish){
int packed=fish.getPattern().getPackedId();return ((packed>>>8)*2+(packed&1))*256+fish.getBaseColor().getId()*16+fish.getPatternColor().getId();
}
@@ -0,0 +1,19 @@
package fr.koka.sanctuary.gems;
import com.mojang.serialization.MapCodec;
import fr.koka.sanctuary.worldgen.SanctuaryChunkGenerator;
import net.minecraft.core.BlockPos;
import net.minecraft.util.RandomSource;
import net.minecraft.world.level.WorldGenLevel;
import net.minecraft.world.level.chunk.ChunkGenerator;
import net.minecraft.world.level.levelgen.feature.*;
/** Native seeded ore veins, scoped to Sanctuary even when its biomes are shared with vanilla. */
public record GemOreFeature(OreFeature ore) implements Feature {
public static final MapCodec<GemOreFeature> CODEC=OreFeature.CODEC.xmap(GemOreFeature::new,GemOreFeature::ore);
@Override public MapCodec<GemOreFeature> codec(){return CODEC;}
@Override public boolean place(WorldGenLevel level,ChunkGenerator generator,RandomSource random,BlockPos origin){
if(!(level.getLevel().getChunkSource().getGenerator() instanceof SanctuaryChunkGenerator))return false;
return ore.place(level,generator,random,origin);
}
}
@@ -0,0 +1,50 @@
package fr.koka.sanctuary.gems;
import fr.koka.sanctuary.SanctuaryMod;
import java.util.List;
import net.fabricmc.fabric.api.biome.v1.BiomeModifications;
import net.fabricmc.fabric.api.creativetab.v1.CreativeModeTabEvents;
import net.minecraft.core.Registry;
import net.minecraft.core.registries.*;
import net.minecraft.resources.ResourceKey;
import net.minecraft.tags.TagKey;
import net.minecraft.util.valueproviders.UniformInt;
import net.minecraft.world.item.*;
import net.minecraft.world.level.block.*;
import net.minecraft.world.level.block.state.BlockBehaviour;
import net.minecraft.world.level.levelgen.GenerationStep;
/** Ordinary gem items and blocks; their distribution lives in data, not this registry. */
public final class Gems {
public record Family(String id,Item gem,Block block,Block ore,Block deepslateOre){}
public static final Family RUBY=family("ruby",DyeColor.RED);
public static final Family SAPPHIRE=family("sapphire",DyeColor.BLUE);
public static final List<Family> FAMILIES=List.of(RUBY,SAPPHIRE);
public static void register(){
Registry.register(BuiltInRegistries.FEATURE_TYPE,SanctuaryMod.id("gem_ore"),GemOreFeature.CODEC);
for(var f:FAMILIES){
var biomes=TagKey.create(Registries.BIOME,SanctuaryMod.id("has_ore/"+f.id));
BiomeModifications.addFeature(c->c.hasTag(biomes),GenerationStep.Decoration.UNDERGROUND_ORES,
ResourceKey.create(Registries.PLACED_FEATURE,SanctuaryMod.id("ore_"+f.id)));
}
CreativeModeTabEvents.modifyOutputEvent(CreativeModeTabs.INGREDIENTS).register(out->out.insertAfter(Items.EMERALD,RUBY.gem,SAPPHIRE.gem));
CreativeModeTabEvents.modifyOutputEvent(CreativeModeTabs.BUILDING_BLOCKS).register(out->out.insertAfter(Blocks.EMERALD_BLOCK,RUBY.block,SAPPHIRE.block));
CreativeModeTabEvents.modifyOutputEvent(CreativeModeTabs.NATURAL_BLOCKS).register(out->{
out.insertAfter(Blocks.EMERALD_ORE,RUBY.ore,SAPPHIRE.ore);
out.insertAfter(Blocks.DEEPSLATE_EMERALD_ORE,RUBY.deepslateOre,SAPPHIRE.deepslateOre);
});
}
private static Family family(String id,DyeColor color){
var key=SanctuaryMod.id(id);var item=Registry.register(BuiltInRegistries.ITEM,key,new Item(new Item.Properties().setId(ResourceKey.create(Registries.ITEM,key))));
var block=block(id+"_block",new Block(properties(id+"_block",Blocks.EMERALD_BLOCK).mapColor(color.getMapColor())));
var ore=block(id+"_ore",new DropExperienceBlock(UniformInt.of(3,7),properties(id+"_ore",Blocks.EMERALD_ORE)));
var deep=block("deepslate_"+id+"_ore",new DropExperienceBlock(UniformInt.of(3,7),properties("deepslate_"+id+"_ore",Blocks.DEEPSLATE_EMERALD_ORE)));
return new Family(id,item,block,ore,deep);
}
private static BlockBehaviour.Properties properties(String id,Block source){return BlockBehaviour.Properties.ofFullCopy(source).setId(ResourceKey.create(Registries.BLOCK,SanctuaryMod.id(id)));}
private static Block block(String id,Block block){
var key=SanctuaryMod.id(id);Registry.register(BuiltInRegistries.BLOCK,key,block);
Registry.register(BuiltInRegistries.ITEM,key,new BlockItem(block,new Item.Properties().setId(ResourceKey.create(Registries.ITEM,key)).useBlockDescriptionPrefix()));return block;
}
private Gems(){}
}
@@ -47,6 +47,7 @@ public final class BlockKnowledgeService {
private BlockKnowledgeService() {}
public static void register() {
DiscoveryCommands.register();
ServerPlayConnectionEvents.JOIN.register((handler, sender, server) -> observeInventory(handler.player));
// Fabric also reaches DISCONNECT through Netty's channelInactive callback.
ServerPlayConnectionEvents.DISCONNECT.register((handler, server) -> server.execute(() -> {
@@ -97,6 +98,12 @@ public final class BlockKnowledgeService {
public static void recordSeen(ServerPlayer player, Block block) {
if (block.defaultBlockState().isAir() || player.isSpectator()) return;
discover(player,block);
}
/** Explicit discoveries use the same record and events, including for a spectator command target. */
public static void discover(ServerPlayer player,Block block) {
if(block.defaultBlockState().isAir())return;
Session session = session(player);
if (session.failed || !session.memory.recordSeen(id(block))) return;
session.dirty = true;
@@ -0,0 +1,75 @@
package fr.koka.sanctuary.knowledge;
import com.mojang.brigadier.builder.ArgumentBuilder;
import com.mojang.brigadier.context.CommandContext;
import com.mojang.brigadier.exceptions.CommandSyntaxException;
import fr.koka.sanctuary.companion.*;
import fr.koka.sanctuary.operator.OperatorService;
import fr.koka.sanctuary.progression.ProgressEffects;
import fr.koka.sanctuary.recipes.*;
import java.util.*;
import net.fabricmc.fabric.api.command.v2.CommandRegistrationCallback;
import net.minecraft.commands.*;
import net.minecraft.commands.arguments.*;
import net.minecraft.core.registries.*;
import net.minecraft.network.chat.Component;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.entity.EntityType;
import net.minecraft.world.item.*;
import net.minecraft.world.level.block.Block;
/** One command routes through the existing discovery services and their dependent recipe/catalogue rules. */
public final class DiscoveryCommands {
private record Selection(List<Block> blocks,List<Item> items,List<EntityType<?>> mobs){}
@FunctionalInterface private interface SelectionArgument {Selection get(CommandContext<CommandSourceStack> context)throws CommandSyntaxException;}
public static void register(){
CommandRegistrationCallback.EVENT.register((dispatcher,registries,environment)->{
var root=Commands.literal("discovery").requires(OperatorService::canCommand);
root.then(targets(Commands.literal("all"),c->all(true,true,true)));
root.then(Commands.literal("blocks").then(targets(Commands.literal("all"),c->all(true,false,false))));
root.then(Commands.literal("items").then(targets(Commands.literal("all"),c->all(false,true,false))));
root.then(Commands.literal("mobs").then(targets(Commands.literal("all"),c->all(false,false,true))));
root.then(Commands.literal("block").then(targets(Commands.argument("block",ResourceArgument.resource(registries,Registries.BLOCK)),c->new Selection(List.of(ResourceArgument.getResource(c,"block",Registries.BLOCK).value()),List.of(),List.of()))));
root.then(Commands.literal("item").then(targets(Commands.argument("item",ResourceArgument.resource(registries,Registries.ITEM)),c->new Selection(List.of(),List.of(ResourceArgument.getResource(c,"item",Registries.ITEM).value()),List.of()))));
root.then(Commands.literal("mob").then(targets(Commands.argument("mob",ResourceArgument.resource(registries,Registries.ENTITY_TYPE)),c->new Selection(List.of(),List.of(),List.of(ResourceArgument.getResource(c,"mob",Registries.ENTITY_TYPE).value())))));
dispatcher.register(Commands.literal("sanctuary").then(root));
});
}
private static <T extends ArgumentBuilder<CommandSourceStack,T>> T targets(T node,SelectionArgument selection){
return node.executes(c->discover(c,selection.get(c),List.of(c.getSource().getPlayerOrException())))
.then(Commands.argument("players",EntityArgument.players()).executes(c->discover(c,selection.get(c),EntityArgument.getPlayers(c,"players"))));
}
private static Selection all(boolean blocks,boolean items,boolean mobs){
return new Selection(blocks?BuiltInRegistries.BLOCK.stream().filter(b->!b.defaultBlockState().isAir()).toList():List.of(),
items?BuiltInRegistries.ITEM.stream().filter(i->i!=Items.AIR).toList():List.of(),
mobs?BuiltInRegistries.ENTITY_TYPE.stream().filter(t->SpawnEggItem.byId(t).isPresent()).toList():List.of());
}
private static int discover(CommandContext<CommandSourceStack> context,Selection selection,Collection<ServerPlayer> players){
var source=context.getSource();
if(selection.blocks.stream().anyMatch(b->b.defaultBlockState().isAir())||selection.items.contains(Items.AIR)||selection.mobs.stream().anyMatch(t->SpawnEggItem.byId(t).isEmpty())){
source.sendFailure(Component.translatable("sanctuary.discovery.invalid"));return 0;
}
var species=selection.mobs.stream().map(BuiltInRegistries.ENTITY_TYPE::getKey).map(id->id.getNamespace().equals("minecraft")?id.getPath():id.toString()).toList();
int changed=0;
for(var player:players){
var record=CompanionService.record(player);
if(ProgressEffects.get(player)==null||record==null||!BlockKnowledgeService.healthy(player)||!RecipeService.healthy(player)){
source.sendFailure(Component.translatable("sanctuary.discovery.unavailable",player.getDisplayName()));continue;
}
// Validate bounded records before any write; no new save schema or unlock flag.
CompanionRecord next;
var discoveredItems=new HashSet<String>(RecipeService.knowledge(player).items);
for(var item:selection.items)discoveredItems.add(BuiltInRegistries.ITEM.getKey(item).toString());
for(var block:selection.blocks)if(block.asItem()!=Items.AIR)discoveredItems.add(BuiltInRegistries.ITEM.getKey(block.asItem()).toString());
try{next=CompanionQueries.discoverSpecies(record,species);if(discoveredItems.size()>RecipeKnowledge.MAX_IDS)throw new IllegalArgumentException("Discovery capacity");}
catch(IllegalArgumentException invalid){source.sendFailure(Component.translatable("sanctuary.discovery.unavailable",player.getDisplayName()));continue;}
CompanionService.save(player,next);
for(var block:selection.blocks)BlockKnowledgeService.discover(player,block);
for(var item:selection.items){if(item instanceof BlockItem b)BlockKnowledgeService.discover(player,b.getBlock());RecipeService.discover(player,item);}
RecipeService.refresh(player);
source.sendSuccess(()->Component.translatable("sanctuary.discovery.granted",player.getDisplayName(),selection.blocks.size(),selection.items.size(),selection.mobs.size()),true);changed++;
}
return changed;
}
private DiscoveryCommands(){}
}
@@ -0,0 +1,20 @@
package fr.koka.sanctuary.mixin;
import fr.koka.sanctuary.multiblock.WrenchTextures;
import net.minecraft.core.BlockPos;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.chunk.LevelChunk;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.injection.*;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable;
@Mixin(LevelChunk.class)
public abstract class WrenchTextureEditMixin {
@Inject(method="setBlockState(Lnet/minecraft/core/BlockPos;Lnet/minecraft/world/level/block/state/BlockState;I)Lnet/minecraft/world/level/block/state/BlockState;",at=@At("RETURN"))
private void sanctuary$clearTexture(BlockPos pos,BlockState state,int flags,CallbackInfoReturnable<BlockState> ci){
var before=ci.getReturnValue();
if(before!=null&&!before.is(state.getBlock())&&((LevelChunk)(Object)this).getLevel() instanceof ServerLevel)
WrenchTextures.forget((LevelChunk)(Object)this,pos);
}
}
@@ -0,0 +1,17 @@
package fr.koka.sanctuary.mixin;
import fr.koka.sanctuary.multiblock.WrenchTextures;
import net.minecraft.server.network.*;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.level.chunk.LevelChunk;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.injection.*;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
@Mixin(PlayerChunkSender.class)
public abstract class WrenchTextureSyncMixin {
@Inject(method="sendChunk",at=@At("RETURN"))
private static void sanctuary$textures(ServerGamePacketListenerImpl connection,ServerLevel level,LevelChunk chunk,CallbackInfo ci){
WrenchTextures.sendChunk(connection.player,chunk);
}
}
@@ -0,0 +1,38 @@
package fr.koka.sanctuary.mixin.client;
import com.llamalad7.mixinextras.injector.wrapoperation.Operation;
import com.llamalad7.mixinextras.injector.wrapoperation.WrapOperation;
import fr.koka.sanctuary.client.ResourcePackTemplate;
import java.nio.file.Path;
import net.minecraft.client.Minecraft;
import net.minecraft.client.gui.layouts.HeaderAndFooterLayout;
import net.minecraft.client.gui.layouts.LayoutElement;
import net.minecraft.client.gui.layouts.LinearLayout;
import net.minecraft.client.gui.screens.packs.PackSelectionScreen;
import org.spongepowered.asm.mixin.Final;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.Shadow;
import org.spongepowered.asm.mixin.injection.At;
/** Extra footer row only in resource packs, never in the world datapack selector. */
@Mixin(PackSelectionScreen.class)
public abstract class PackSelectionScreenMixin {
@Shadow @Final private Path packDir;
@WrapOperation(method = "init", at = @At(value = "INVOKE", target =
"Lnet/minecraft/client/gui/layouts/HeaderAndFooterLayout;addToFooter(Lnet/minecraft/client/gui/layouts/LayoutElement;)Lnet/minecraft/client/gui/layouts/LayoutElement;"))
private LayoutElement sanctuary$personalPack(HeaderAndFooterLayout layout, LayoutElement nativeButtons,
Operation<LayoutElement> original) {
if (!packDir.toAbsolutePath().normalize().equals(
Minecraft.getInstance().getResourcePackDirectory().toAbsolutePath().normalize())) {
return original.call(layout, nativeButtons);
}
layout.setFooterHeight(56);
LinearLayout rows = LinearLayout.vertical().spacing(4);
rows.addChild(ResourcePackTemplate.button(packDir));
rows.addChild(nativeButtons);
original.call(layout, rows);
// Vanilla still adds its Open folder / Done buttons to the original horizontal row.
return nativeButtons;
}
}
@@ -0,0 +1,20 @@
package fr.koka.sanctuary.mixin.client;
import fr.koka.sanctuary.client.shader.PixelShadows;
import net.minecraft.client.renderer.extract.LevelExtractor;
import net.minecraft.core.BlockPos;
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;
/** Geometry changes only: lighting-only section invalidations do not rebuild shadow casters. */
@Mixin(LevelExtractor.class)
abstract class PixelShadowDirtyMixin {
@Inject(method="blockChanged", at=@At("HEAD"))
private void sanctuary$dirty(BlockPos pos, int flags, CallbackInfo ci) {
PixelShadows.dirtyBlock(pos);
}
@Inject(method="allChanged", at=@At("HEAD"))
private void sanctuary$reload(CallbackInfo ci) { PixelShadows.release(); }
}
@@ -0,0 +1,25 @@
package fr.koka.sanctuary.mixin.client;
import fr.koka.sanctuary.client.shader.PixelShadows;
import net.minecraft.client.multiplayer.ClientLevel;
import net.minecraft.core.BlockPos;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.ChunkPos;
import net.minecraft.world.level.chunk.LevelChunk;
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;
@Mixin(ClientLevel.class)
abstract class PixelShadowLevelMixin {
@Inject(method="setBlocksDirty", at=@At("HEAD"))
private void sanctuary$block(BlockPos pos, BlockState before, BlockState after, CallbackInfo ci) {
if (before != after) PixelShadows.dirtyBlock(pos);
}
@Inject(method="onChunkLoaded", at=@At("TAIL"))
private void sanctuary$loaded(ChunkPos pos, CallbackInfo ci) { PixelShadows.dirtyChunk(pos.x(), pos.z()); }
@Inject(method="unload", at=@At("HEAD"))
private void sanctuary$unload(LevelChunk chunk, CallbackInfo ci) { PixelShadows.dirtyChunk(chunk.getPos().x(), chunk.getPos().z()); }
}
@@ -0,0 +1,26 @@
package fr.koka.sanctuary.mixin.client;
import com.llamalad7.mixinextras.injector.wrapoperation.Operation;
import com.llamalad7.mixinextras.injector.wrapoperation.WrapOperation;
import com.mojang.renderpearl.api.buffers.GpuBufferSlice;
import fr.koka.sanctuary.client.shader.PixelShadows;
import net.minecraft.client.renderer.GameRenderer;
import net.minecraft.client.renderer.ProjectionMatrixBuffer;
import org.joml.Matrix4f;
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;
@Mixin(GameRenderer.class)
abstract class PixelShadowRendererMixin {
@WrapOperation(method="renderLevel", at=@At(value="INVOKE", target="Lnet/minecraft/client/renderer/ProjectionMatrixBuffer;getBuffer(Lorg/joml/Matrix4f;)Lcom/mojang/renderpearl/api/buffers/GpuBufferSlice;"))
private GpuBufferSlice sanctuary$projection(ProjectionMatrixBuffer buffer, Matrix4f matrix, Operation<GpuBufferSlice> original) {
PixelShadows.captureProjection(matrix);
return original.call(buffer, matrix);
}
@Inject(method="renderLevel", at=@At(value="INVOKE", target="Lnet/minecraft/client/renderer/GameRenderer;render3dHud(Lnet/minecraft/client/renderer/state/level/CameraRenderState;Lnet/minecraft/client/renderer/state/level/PlayerRenderState;Lnet/minecraft/client/renderer/state/OptionsRenderState;Z)V"))
private void sanctuary$shadows(CallbackInfo ci) { PixelShadows.render(); }
@Inject(method={"close", "setLevel"}, at=@At("HEAD"))
private void sanctuary$close(CallbackInfo ci) { PixelShadows.release(); }
}
@@ -0,0 +1,26 @@
package fr.koka.sanctuary.mixin.client;
import fr.koka.sanctuary.client.SculptureParticles;
import fr.koka.sanctuary.plans.Statuary;
import net.minecraft.client.multiplayer.ClientLevel;
import net.minecraft.core.*;
import net.minecraft.world.level.block.state.BlockState;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.injection.*;
import org.spongepowered.asm.mixin.injection.callback.*;
@Mixin(ClientLevel.class)
public abstract class SculptureParticlesMixin {
@Inject(method="setBlock",at=@At("HEAD"))
private void sanctuary$remember(BlockPos pos,BlockState state,int flags,int recursion,CallbackInfoReturnable<Boolean> cir){
if(!state.is(Statuary.BLOCK))SculptureParticles.remember((ClientLevel)(Object)this,pos);
}
@Inject(method="addDestroyBlockEffect",at=@At("HEAD"),cancellable=true)
private void sanctuary$chips(BlockPos pos,BlockState state,CallbackInfo ci){
if(SculptureParticles.destroy((ClientLevel)(Object)this,pos,state))ci.cancel();
}
@Inject(method="addBreakingParticles",at=@At("HEAD"),cancellable=true)
private void sanctuary$chip(BlockPos pos,Direction side,BlockState state,CallbackInfo ci){
if(SculptureParticles.hit((ClientLevel)(Object)this,pos,side,state))ci.cancel();
}
}
@@ -0,0 +1,100 @@
package fr.koka.sanctuary.multiblock;
import java.util.*;
import net.fabricmc.fabric.api.event.player.AttackBlockCallback;
import net.fabricmc.fabric.api.networking.v1.ServerPlayConnectionEvents;
import net.minecraft.core.*;
import net.minecraft.network.chat.Component;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.*;
import net.minecraft.world.entity.LivingEntity;
import net.minecraft.world.item.*;
import net.minecraft.world.level.Level;
import net.minecraft.world.level.block.Block;
import net.minecraft.world.level.block.entity.BaseContainerBlockEntity;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.block.state.properties.Property;
/** Ordinary rotations and Steve debug gestures, both owned by the server. */
public final class GoldenWrench {
public static final String TEXTURE="sanctuary_texture";
private static final Map<ServerPlayer,Map<Block,String>> SELECTED=new WeakHashMap<>();
private static final Map<ServerPlayer,Integer> LAST_SELECTION=new WeakHashMap<>();
public static final class Tool extends Item {
public Tool(Properties properties){super(properties);}
@Override public boolean canDestroyBlock(ItemStack stack,BlockState state,Level level,BlockPos pos,LivingEntity entity){return false;}
}
public static void register(){
WrenchTextures.register();
AttackBlockCallback.EVENT.register((player,level,hand,pos,face)->{
if(!isWrench(player.getItemInHand(hand)))return InteractionResult.PASS;
if(player.isSpectator())return InteractionResult.FAIL;
if(player instanceof ServerPlayer p){if(!allowed(p,pos,hand,face))return InteractionResult.FAIL;
if(p.getItemInHand(hand).is(Multiblocks.STEVE_WRENCH))select(p,pos);
else p.sendSystemMessage(text("rotate_help"),true);}
return InteractionResult.SUCCESS;
});
ServerPlayConnectionEvents.DISCONNECT.register((handler,server)->{SELECTED.remove(handler.player);LAST_SELECTION.remove(handler.player);});
}
public static boolean isWrench(ItemStack stack){return stack.is(Multiblocks.WRENCH)||stack.is(Multiblocks.STEVE_WRENCH);}
public static boolean allowed(ServerPlayer p,BlockPos pos,InteractionHand hand,Direction face){
var tool=p.getItemInHand(hand);
if(!isWrench(tool))return false;
return p.isAlive()&&!p.isSpectator()&&p.getAbilities().mayBuild&&p.level().hasChunkAt(pos)
&&p.isWithinBlockInteractionRange(pos,0)&&p.level().mayInteract(p,pos)
&&p.mayUseItemAt(pos,face,p.getItemInHand(hand))
&&!p.blockActionRestricted(p.level(),pos,p.gameMode.getGameModeForPlayer())
&&(!(p.level().getBlockEntity(pos) instanceof BaseContainerBlockEntity container)||container.canOpen(p));
}
private static boolean assembled(ServerPlayer p,BlockPos pos,BlockState state){
return state.is(Metatable.BLOCK)||state.getBlock() instanceof SuperPistonBlocks.Base
||state.getBlock() instanceof SuperPistonBlocks.Part||MultiblockData.get(p.level()).at(pos)!=null;
}
public static List<String> properties(BlockState state){
var result=new ArrayList<>(state.getProperties().stream().map(Property::getName).sorted(Comparator.comparingInt(GoldenWrench::priority).thenComparing(s->s)).toList());
result.add(TEXTURE);return List.copyOf(result);
}
private static int priority(String name){return switch(name){case "facing","axis","rotation","orientation"->0;case "half","type","shape"->1;default->2;};}
public static String selected(ServerPlayer p,BlockState state){
var choices=properties(state);var value=SELECTED.getOrDefault(p,Map.of()).get(state.getBlock());
return value!=null&&choices.contains(value)?value:choices.getFirst();
}
private static void select(ServerPlayer p,BlockPos pos){
var state=p.level().getBlockState(pos);if(state.isAir())return;
if(assembled(p,pos,state)){p.sendSystemMessage(text("assembled"),true);return;}
// Creative start/continue callbacks can describe the same click twice. Match its native 5-tick repeat cadence.
int tick=p.level().getServer().getTickCount();var last=LAST_SELECTION.get(p);
if(last!=null&&tick-last<5)return;LAST_SELECTION.put(p,tick);
var choices=properties(state);var selected=SELECTED.computeIfAbsent(p,key->new HashMap<>());
var before=selected.get(state.getBlock());int previous=before==null?-1:choices.indexOf(before);
int next=previous<0?0:Math.floorMod(previous+(p.isShiftKeyDown()?-1:1),choices.size());
var property=choices.get(next);selected.put(state.getBlock(),property);
p.sendSystemMessage(text("selected",label(property),value(p,pos,state,property)),true);
}
public static void change(ServerPlayer p,BlockPos pos,InteractionHand hand,Direction face){
if(!allowed(p,pos,hand,face))return;
var state=p.level().getBlockState(pos);if(state.isAir()||assembled(p,pos,state))return;
if(p.getItemInHand(hand).is(Multiblocks.WRENCH)){
p.sendSystemMessage(text(WrenchRotation.turn(p.level(),pos,p.isShiftKeyDown())),true);return;
}
var name=selected(p,state);SELECTED.computeIfAbsent(p,key->new HashMap<>()).put(state.getBlock(),name);
if(name.equals(TEXTURE))WrenchTextures.turn(p.level(),pos,p.isShiftKeyDown()?-1:1);
else {
var property=state.getBlock().getStateDefinition().getProperty(name);
// Same flags as the vanilla debug stick: synchronize, preserve deliberately chosen shapes.
p.level().setBlock(pos,cycle(state,property,p.isShiftKeyDown()),Block.UPDATE_CLIENTS|Block.UPDATE_KNOWN_SHAPE);
}
p.sendSystemMessage(text("changed",label(name),value(p,pos,p.level().getBlockState(pos),name)),true);
}
public static <T extends Comparable<T>> BlockState cycle(BlockState state,Property<T> property,boolean reverse){
var values=new ArrayList<>(property.getPossibleValues());int index=values.indexOf(state.getValue(property));
return state.setValue(property,values.get(Math.floorMod(index+(reverse?-1:1),values.size())));
}
private static Object value(ServerPlayer p,BlockPos pos,BlockState state,String name){
return name.equals(TEXTURE)?(WrenchTextures.rotation(p.level().getChunkAt(pos),pos)*90)+"°":propertyValue(state,state.getBlock().getStateDefinition().getProperty(name));
}
private static <T extends Comparable<T>> String propertyValue(BlockState state,Property<T> property){return property.getName(state.getValue(property));}
private static Component label(String name){return name.equals(TEXTURE)?text("texture"):Component.literal(name);}
private static Component text(String name,Object...args){return Component.translatable("sanctuary.wrench."+name,args);}
private GoldenWrench(){}
}
@@ -80,10 +80,11 @@ public final class Metatable {
if(p.isShiftKeyDown()){dissolve(level,origin);p.sendSystemMessage(Multiblocks.text("separated"),true);}else open(p,origin);
return true;
}
if(!s.is(Blocks.CRAFTING_TABLE))return false;
if(!s.is(Blocks.CRAFTING_TABLE)||p.isShiftKeyDown())return false;
var candidates=new ArrayList<BlockPos>();
for(int z=0;z<3;z++)for(int x=0;x<3;x++){var o=pos.offset(-x,0,-z);if(state(level,o,false)==1)candidates.add(o);}
if(candidates.size()!=1){previews.remove(p);p.sendSystemMessage(Multiblocks.text(candidates.isEmpty()?"incomplete":"ambiguous"),true);return true;}
if(candidates.isEmpty()){previews.remove(p);return false;}
if(candidates.size()!=1){previews.remove(p);p.sendSystemMessage(Multiblocks.text("ambiguous"),true);return true;}
var origin=candidates.getFirst();var before=previews.remove(p);
if(before!=null&&before.level==level&&before.origin.equals(origin)&&before.until>=level.getGameTime()){
p.sendSystemMessage(Multiblocks.text(assemble(p,origin)?"assembled":"unavailable"),true);return true;
@@ -17,14 +17,21 @@ import net.minecraft.world.inventory.*;
import net.minecraft.world.item.*;
import net.minecraft.world.level.block.*;
import net.minecraft.world.level.block.entity.*;
import net.minecraft.world.level.block.state.properties.BlockStateProperties;
import net.minecraft.world.phys.Vec3;
import java.util.*;
import static fr.koka.sanctuary.multiblock.MultiblockData.Machine;
public final class Multiblocks {
public static final Item WRENCH=Registry.register(BuiltInRegistries.ITEM,SanctuaryMod.id("golden_wrench"),
new Item(new Item.Properties().stacksTo(1).setId(ResourceKey.create(Registries.ITEM,SanctuaryMod.id("golden_wrench")))));
new GoldenWrench.Tool(new Item.Properties().stacksTo(1)
.component(net.minecraft.core.component.DataComponents.LORE,new net.minecraft.world.item.component.ItemLore(List.of(Component.translatable("sanctuary.wrench.rotate_help"))))
.setId(ResourceKey.create(Registries.ITEM,SanctuaryMod.id("golden_wrench")))));
public static final Item STEVE_WRENCH=Registry.register(BuiltInRegistries.ITEM,SanctuaryMod.id("steve_wrench"),
new GoldenWrench.Tool(new Item.Properties().stacksTo(1).rarity(Rarity.EPIC)
.component(net.minecraft.core.component.DataComponents.ENCHANTMENT_GLINT_OVERRIDE,true)
.component(net.minecraft.core.component.DataComponents.LORE,new net.minecraft.world.item.component.ItemLore(List.of(
Component.translatable("sanctuary.wrench.steve_power"),Component.translatable("sanctuary.wrench.steve_help"))))
.setId(ResourceKey.create(Registries.ITEM,SanctuaryMod.id("steve_wrench")))));
public static final MenuType<MultiblockMenu> MENU=Registry.register(BuiltInRegistries.MENU,SanctuaryMod.id("multiblock"),
new ExtendedMenuType<>(MultiblockMenu::new,MultiblockMenu.View.CODEC));
private record Preview(ServerLevel level,BlockPos origin,boolean furnace,long until){}
@@ -32,11 +39,12 @@ public final class Multiblocks {
public static Component text(String key,Object...args){return Component.translatable("sanctuary.multiblock."+key,args);}
public static void register(){
FourneauAppearance.register();
GoldenWrench.register();
SuperPistons.register();
Metatable.register();
net.fabricmc.fabric.api.creativetab.v1.CreativeModeTabEvents.modifyOutputEvent(CreativeModeTabs.TOOLS_AND_UTILITIES).register(out->out.accept(WRENCH));
net.fabricmc.fabric.api.creativetab.v1.CreativeModeTabEvents.modifyOutputEvent(CreativeModeTabs.TOOLS_AND_UTILITIES).register(out->{out.accept(WRENCH);out.accept(STEVE_WRENCH);});
UseBlockCallback.EVENT.register((player,world,hand,hit)->{
boolean wrench=player.getItemInHand(hand).is(WRENCH);
boolean wrench=GoldenWrench.isWrench(player.getItemInHand(hand));
if(world.isClientSide())return wrench?InteractionResult.SUCCESS:InteractionResult.PASS;
if(!(player instanceof ServerPlayer p)||p.isSpectator())return InteractionResult.PASS;
var level=p.level();var pos=hit.getBlockPos();var machine=MultiblockData.get(level).at(pos);
@@ -44,7 +52,9 @@ public final class Multiblocks {
// Preserve vanilla sneak-placement (for example, attaching a hopper to the facade).
if(!wrench&&p.isShiftKeyDown()&&(!p.getMainHandItem().isEmpty()||!p.getOffhandItem().isEmpty()))return InteractionResult.PASS;
if(!level.mayInteract(p,pos)||!p.mayUseItemAt(pos,hit.getDirection(),p.getItemInHand(hand)))return InteractionResult.FAIL;
if(wrench){if(!Metatable.wrench(p,pos)&&!SuperPistons.wrench(p,pos,hit.getDirection()))useWrench(p,pos,hit.getDirection());return InteractionResult.SUCCESS;}
if(wrench){if(!GoldenWrench.allowed(p,pos,hand,hit.getDirection()))return InteractionResult.FAIL;
if(!Metatable.wrench(p,pos)&&!SuperPistons.wrench(p,pos,hit.getDirection())&&!useWrench(p,pos,hit.getDirection()))GoldenWrench.change(p,pos,hand,hit.getDirection());
return InteractionResult.SUCCESS;}
if(ready(level,machine)&&accessible(p,machine))open(p,machine,pos,0,"",0);
else p.sendSystemMessage(text("unavailable"),true);
return InteractionResult.SUCCESS;
@@ -102,31 +112,28 @@ public final class Multiblocks {
if(!MultiblockData.get(p.level()).add(m))return false;
closeViewers(p.level(),m);FourneauAppearance.changed(p.level(),m,true);return true;
}
private static void useWrench(ServerPlayer p,BlockPos pos,Direction face){
private static boolean useWrench(ServerPlayer p,BlockPos pos,Direction face){
var level=p.level();var data=MultiblockData.get(level);var m=data.at(pos);
if(m!=null){
if(!ready(level,m)||!accessible(p,m)){p.sendSystemMessage(text("unavailable"),true);return;}
if(!ready(level,m)||!accessible(p,m)){p.sendSystemMessage(text("unavailable"),true);return true;}
if(p.isShiftKeyDown()){dissolve(level,m);p.sendSystemMessage(text("separated"),true);}
else open(p,m,pos,0,"",0);
return;
return true;
}
var state=level.getBlockState(pos);
if(!state.is(Blocks.BARREL)&&!state.is(Blocks.FURNACE)){p.sendSystemMessage(text("hint"),true);return;}
if(!(level.getBlockEntity(pos) instanceof BaseContainerBlockEntity be)||!be.canOpen(p))return;
if(p.isShiftKeyDown()){
var property=state.hasProperty(BlockStateProperties.HORIZONTAL_FACING)?BlockStateProperties.HORIZONTAL_FACING:BlockStateProperties.FACING;
var current=state.getValue(property);var next=current.getAxis().isVertical()?p.getDirection():current.getClockWise();
level.setBlockAndUpdate(pos,state.setValue(property,next));p.sendSystemMessage(text("rotated"),true);return;
}
if(!state.is(Blocks.BARREL)&&!state.is(Blocks.FURNACE))return false;
if(!(level.getBlockEntity(pos) instanceof BaseContainerBlockEntity be)||!be.canOpen(p))return true;
if(p.isShiftKeyDown())return false;
boolean furnace=state.is(Blocks.FURNACE);var candidates=new ArrayList<Machine>();
for(int y=0;y<3;y++)for(int z=0;z<3;z++)for(int x=0;x<3;x++){
var candidate=new Machine(UUID.randomUUID(),pos.offset(-x,-y,-z),furnace,p.getDirection().getOpposite());
if(state(level,candidate)==1&&candidate.members().stream().noneMatch(q->data.at(q)!=null))candidates.add(candidate);
}
if(candidates.size()!=1){PREVIEWS.remove(p);p.sendSystemMessage(text(candidates.isEmpty()?"incomplete":"ambiguous"),true);return;}
if(candidates.isEmpty()){PREVIEWS.remove(p);return false;}
if(candidates.size()!=1){PREVIEWS.remove(p);p.sendSystemMessage(text("ambiguous"),true);return true;}
var candidate=candidates.getFirst();var previous=PREVIEWS.remove(p);
if(previous!=null&&previous.level()==level&&previous.origin().equals(candidate.origin())&&previous.furnace()==furnace&&previous.until()>=level.getGameTime()){
p.sendSystemMessage(text(assemble(p,candidate)?"assembled":"unavailable"),true);return;
p.sendSystemMessage(text(assemble(p,candidate)?"assembled":"unavailable"),true);return true;
}
PREVIEWS.put(p,new Preview(level,candidate.origin(),furnace,level.getGameTime()+200));
for(int axis=0;axis<3;axis++)for(int a=0;a<=3;a+=3)for(int b=0;b<=3;b+=3)for(int t=0;t<=6;t++){
@@ -134,6 +141,7 @@ public final class Multiblocks {
level.sendParticles(p,ParticleTypes.END_ROD,false,false,candidate.origin().getX()+px,candidate.origin().getY()+py,candidate.origin().getZ()+pz,1,0,0,0,0);
}
p.sendSystemMessage(text("confirm",text(furnace?"furnace":"barrel")),false);
return true;
}
public static void open(ServerPlayer p,Machine m,BlockPos anchor,int page,String query,int section){
open(p,m,anchor,page,query,section,new int[0]);
@@ -79,11 +79,10 @@ public final class SuperPistons {
}
if(!s.is(Blocks.PISTON)&&!s.is(Blocks.STICKY_PISTON))return false;
var f=s.getValue(FACING);boolean sticky=s.is(Blocks.STICKY_PISTON);
if(p.isShiftKeyDown()){
if(!s.getValue(EXTENDED)&&!l.hasNeighborSignal(clicked))l.setBlockAndUpdate(clicked,s.setValue(FACING,face));return true;
}
if(p.isShiftKeyDown())return false;
var found=new ArrayList<BlockPos>();for(int a=-1;a<=1;a++)for(int b=-1;b<=1;b++){var c=clicked.relative(u(f),a).relative(v(f),b);if(candidates(l,c,f,sticky))found.add(c);}
if(found.size()!=1){previews.remove(p);message(p,found.isEmpty()?"incomplete":"ambiguous");return true;}
if(found.isEmpty()){previews.remove(p);return false;}
if(found.size()!=1){previews.remove(p);message(p,"ambiguous");return true;}
var c=found.getFirst();for(var pos:tiles(c,f,0))if(!l.mayInteract(p,pos)||!p.mayUseItemAt(pos,face,p.getMainHandItem())){previews.remove(p);return true;}
var old=previews.remove(p);
if(old!=null&&old.level()==l&&old.center().equals(c)&&old.facing()==f&&old.sticky()==sticky&&old.until()>=l.getGameTime()){
@@ -0,0 +1,36 @@
package fr.koka.sanctuary.multiblock;
import java.util.List;
import net.minecraft.core.BlockPos;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.level.block.Block;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.block.state.properties.*;
import static net.minecraft.world.level.block.state.properties.BlockStateProperties.*;
/** Only native orientation properties; fluid, contents and gameplay state are never selectable. */
public final class WrenchRotation {
private static final List<Property<?>> ORIENTATIONS=List.of(HORIZONTAL_FACING,FACING,FACING_HOPPER,AXIS,HORIZONTAL_AXIS,ROTATION_16,ORIENTATION);
public static String turn(ServerLevel level,BlockPos pos,boolean reverse){
var before=level.getBlockState(pos);
if(linked(before))return "linked";
var property=ORIENTATIONS.stream().filter(before::hasProperty).findFirst().orElse(null);
if(property==null)return "no_rotation";
var oriented=GoldenWrench.cycle(before,property,reverse);
if(!oriented.canSurvive(level,pos))return "unsupported";
// Stairs and connections must reflect their real neighbours, not a debug-stick shape.
var after=Block.updateFromNeighbourShapes(oriented,level,pos);
if(!after.is(before.getBlock())||linked(after)||after.getFluidState()!=before.getFluidState()
||!after.canSurvive(level,pos))return "unsupported";
level.setBlock(pos,after,Block.UPDATE_ALL);
return "rotated";
}
private static boolean linked(BlockState state){
// A single-block operation cannot rotate another half, an attached head or a joined inventory.
return state.hasProperty(DOUBLE_BLOCK_HALF)||state.hasProperty(BED_PART)||state.hasProperty(SIDE_CHAIN_PART)&&state.getValue(SIDE_CHAIN_PART).isConnected()
||state.hasProperty(PISTON_TYPE)||state.hasProperty(EXTENDED)&&state.getValue(EXTENDED)
||state.hasProperty(CHEST_TYPE)&&state.getValue(CHEST_TYPE)!=ChestType.SINGLE;
}
private WrenchRotation(){}
}
@@ -0,0 +1,63 @@
package fr.koka.sanctuary.multiblock;
import com.mojang.serialization.Codec;
import fr.koka.sanctuary.SanctuaryMod;
import java.util.*;
import net.fabricmc.fabric.api.attachment.v1.*;
import net.fabricmc.fabric.api.networking.v1.*;
import net.minecraft.core.BlockPos;
import net.minecraft.network.RegistryFriendlyByteBuf;
import net.minecraft.network.codec.StreamCodec;
import net.minecraft.network.protocol.common.custom.CustomPacketPayload;
import net.minecraft.resources.Identifier;
import net.minecraft.server.level.*;
import net.minecraft.world.level.ChunkPos;
import net.minecraft.world.level.chunk.LevelChunk;
/** Optional per-chunk presentation data. Absent means original vanilla textures. */
public final class WrenchTextures {
public static final AttachmentType<Map<String,Integer>> ROTATIONS=AttachmentRegistry.createPersistent(
SanctuaryMod.id("wrench_textures_v1"),Codec.unboundedMap(Codec.STRING,Codec.intRange(1,3)).xmap(Map::copyOf,map->map));
public record Entry(BlockPos pos,int rotation){}
public record Update(Identifier dimension,ChunkPos chunk,boolean clear,List<Entry> entries) implements CustomPacketPayload {
public Update{entries=List.copyOf(entries);if(entries.size()>1024)throw new IllegalArgumentException("Texture batch too large");}
public static final Type<Update> TYPE=new Type<>(SanctuaryMod.id("wrench_textures_v1"));
public static final StreamCodec<RegistryFriendlyByteBuf,Update> CODEC=StreamCodec.of((b,p)->{
b.writeIdentifier(p.dimension);b.writeChunkPos(p.chunk);b.writeBoolean(p.clear);b.writeVarInt(p.entries.size());
for(var entry:p.entries){b.writeBlockPos(entry.pos);b.writeByte(entry.rotation);}
},b->{
var dimension=b.readIdentifier();var chunk=b.readChunkPos();boolean clear=b.readBoolean();int n=b.readVarInt();
if(n<0||n>1024)throw new IllegalArgumentException("Texture batch size");var entries=new ArrayList<Entry>(n);
for(int i=0;i<n;i++){var pos=b.readBlockPos();int rotation=b.readUnsignedByte();
if(rotation>3||ChunkPos.pack(pos)!=chunk.pack())throw new IllegalArgumentException("Invalid texture entry");entries.add(new Entry(pos,rotation));}
return new Update(dimension,chunk,clear,entries);
});
@Override public Type<? extends CustomPacketPayload> type(){return TYPE;}
}
public static void register(){PayloadTypeRegistry.clientboundPlay().register(Update.TYPE,Update.CODEC);}
public static int rotation(LevelChunk chunk,BlockPos pos){var map=chunk.getAttached(ROTATIONS);return map==null?0:map.getOrDefault(Long.toString(pos.asLong()),0);}
public static void turn(ServerLevel level,BlockPos pos,int delta){
var chunk=level.getChunkAt(pos);set(chunk,pos,Math.floorMod(rotation(chunk,pos)+delta,4));
}
public static void forget(LevelChunk chunk,BlockPos pos){if(rotation(chunk,pos)!=0)set(chunk,pos,0);}
private static void set(LevelChunk chunk,BlockPos pos,int value){
if(!(chunk.getLevel() instanceof ServerLevel level))return;
var old=chunk.getAttached(ROTATIONS);var next=old==null?new HashMap<String,Integer>():new HashMap<>(old);
if(value==0)next.remove(Long.toString(pos.asLong()));else next.put(Long.toString(pos.asLong()),value);
if(next.isEmpty())chunk.removeAttached(ROTATIONS);else chunk.setAttached(ROTATIONS,Map.copyOf(next));chunk.markUnsaved();
var packet=new Update(level.dimension().identifier(),chunk.getPos(),false,List.of(new Entry(pos.immutable(),value)));
for(var player:PlayerLookup.tracking(level,chunk.getPos()))send(player,packet);
}
public static void sendChunk(ServerPlayer player,LevelChunk chunk){
var entries=chunk.getAttached(ROTATIONS);var batch=new ArrayList<Entry>(1024);boolean clear=true;
if(entries!=null)for(var entry:entries.entrySet()){
BlockPos pos;try{pos=BlockPos.of(Long.parseLong(entry.getKey()));}catch(NumberFormatException ignored){continue;}
if(ChunkPos.pack(pos)!=chunk.getPos().pack()||chunk.getBlockState(pos).isAir())continue;
batch.add(new Entry(pos,entry.getValue()));
if(batch.size()==1024){send(player,new Update(player.level().dimension().identifier(),chunk.getPos(),clear,batch));clear=false;batch.clear();}
}
if(clear||!batch.isEmpty())send(player,new Update(player.level().dimension().identifier(),chunk.getPos(),clear,batch));
}
private static void send(ServerPlayer p,Update packet){if(ServerPlayNetworking.canSend(p,Update.TYPE))ServerPlayNetworking.send(p,packet);}
private WrenchTextures(){}
}
@@ -20,8 +20,11 @@ import net.minecraft.world.phys.*;
/** Native input/result slots. Server validates the material, applies the bounded palette and charges each take. */
public final class ClayWorkshopMenu extends AbstractContainerMenu {
public static final int HOTBAR_START=2,HOTBAR_END=HOTBAR_START+Inventory.SELECTION_SIZE,HOTBAR_X=75,HOTBAR_Y=176;
public record View(BlockPos pos,UUID token){
public static final StreamCodec<RegistryFriendlyByteBuf,View> CODEC=StreamCodec.of((b,p)->{b.writeBlockPos(p.pos);b.writeUUID(p.token);},b->new View(b.readBlockPos(),b.readUUID()));
public record View(BlockPos pos,UUID token,List<String> statues){
public View { statues=List.copyOf(statues); }
public static final StreamCodec<RegistryFriendlyByteBuf,View> CODEC=StreamCodec.of((b,p)->{
b.writeBlockPos(p.pos);b.writeUUID(p.token);b.writeVarInt(p.statues.size());for(var id:p.statues)b.writeUtf(id,128);
},b->{var pos=b.readBlockPos();var token=b.readUUID();int n=b.readVarInt();if(n<0||n>1024)throw new IllegalArgumentException("statue_discoveries");var ids=new ArrayList<String>(n);for(int i=0;i<n;i++)ids.add(b.readUtf(128));return new View(pos,token,ids);});
}
/** At most 24 KiB of cells plus 256 palette colors in one atomic, bounded selection, scoped to one open menu. */
public record Select(int menu,UUID token,VoxelSculpture sculpture,Item material,SculpturePalette palette) implements CustomPacketPayload {
@@ -68,14 +71,18 @@ public final class ClayWorkshopMenu extends AbstractContainerMenu {
var contents=item.get(DataComponents.CONTAINER);
return contents==null||!contents.nonEmptyItems().iterator().hasNext();
}
/** Plain clay retains the model's sampled colors; other blocks transfer their texture palette. */
public static VoxelSculpture appearance(VoxelSculpture source,Item material,SculpturePalette palette){
return material==Items.CLAY?source:palette.apply(source);
}
public void select(ServerPlayer p,VoxelSculpture sculpture,Item material,SculpturePalette palette){
if(level==null||!stillValid(p)||level.getGameTime()-lastSelection<4)return;
var hit=new BlockHitResult(Vec3.atCenterOf(view.pos),Direction.UP,view.pos,false);
if(UseBlockCallback.EVENT.invoker().interact(p,level,InteractionHand.MAIN_HAND,hit)!=InteractionResult.PASS||!stillValid(p))return;
lastSelection=level.getGameTime();
if(sculpture!=null&&(!material(input.getItem(0))||!input.getItem(0).is(material)||palette==null))return;
if(!Objects.equals(selected,sculpture)||!Objects.equals(selectedPalette,palette))
selectedResult=sculpture==null||palette==null?null:palette.apply(sculpture);
if(sculpture!=null&&(!StatueDiscoveries.allowedModel(sculpture.modelId(),view.statues())||!material(input.getItem(0))||!input.getItem(0).is(material)||palette==null))return;
if(!Objects.equals(selected,sculpture)||selectedMaterial!=material||!Objects.equals(selectedPalette,palette))
selectedResult=sculpture==null||palette==null?null:appearance(sculpture,material,palette);
selected=sculpture;selectedMaterial=material;selectedPalette=palette;refresh();broadcastChanges();
}
private void refresh(){
@@ -7,26 +7,29 @@ import net.minecraft.world.item.context.BlockPlaceContext;
import net.minecraft.world.level.block.Mirror;
import net.minecraft.world.level.block.Rotation;
/** World-space edge of the occupied block; CENTER is the legacy placement. */
/** World-space horizontal anchor; old names remain valid in saved blockstates. */
public enum SculptureAnchor implements StringRepresentable {
CENTER(null), NORTH(Direction.NORTH), SOUTH(Direction.SOUTH), EAST(Direction.EAST), WEST(Direction.WEST);
CENTER(0,0), NORTH(0,-1), SOUTH(0,1), EAST(1,0), WEST(-1,0),
NORTH_WEST(-1,-1), NORTH_EAST(1,-1), SOUTH_WEST(-1,1), SOUTH_EAST(1,1);
private final Direction direction;
SculptureAnchor(Direction direction){this.direction=direction;}
private final int x,z;
SculptureAnchor(int x,int z){this.x=x;this.z=z;}
public String getSerializedName(){return name().toLowerCase(Locale.ROOT);}
public SculptureAnchor rotate(Rotation rotation){return direction==null?this:of(rotation.rotate(direction));}
public SculptureAnchor mirror(Mirror mirror){return direction==null?this:of(mirror.mirror(direction));}
public SculptureAnchor rotate(Rotation rotation){return switch(rotation){
case NONE -> this;case CLOCKWISE_90 -> of(-z,x);case CLOCKWISE_180 -> of(-x,-z);case COUNTERCLOCKWISE_90 -> of(z,-x);
};}
public SculptureAnchor mirror(Mirror mirror){return switch(mirror){case NONE->this;case LEFT_RIGHT->of(x,-z);case FRONT_BACK->of(-x,z);};}
public static SculptureAnchor of(Direction direction){return switch(direction){
case NORTH -> NORTH;case SOUTH -> SOUTH;case EAST -> EAST;case WEST -> WEST;
default -> throw new IllegalArgumentException("Horizontal anchor required");
};}
private static SculptureAnchor of(int x,int z){for(var anchor:values())if(anchor.x==x&&anchor.z==z)return anchor;throw new IllegalArgumentException("Anchor outside grid");}
public static SculptureAnchor placement(BlockPlaceContext context){
var pos=context.getClickedPos();var hit=context.getClickLocation();
double x=hit.x-pos.getX()-.5,z=hit.z-pos.getZ()-.5;
if(Math.abs(x)<1e-7&&Math.abs(z)<1e-7)return of(context.getHorizontalDirection().getOpposite());
if(Math.abs(x)>Math.abs(z))return x<0?WEST:EAST;
return z<0?NORTH:SOUTH;
return of(band(hit.x-pos.getX()),band(hit.z-pos.getZ()));
}
public int originX(int width){return this==WEST?0:this==EAST?VoxelSculpture.EDGE-width:(VoxelSculpture.EDGE-width)/2;}
public int originZ(int depth){return this==NORTH?0:this==SOUTH?VoxelSculpture.EDGE-depth:(VoxelSculpture.EDGE-depth)/2;}
// The middle eight pixels, including the boundaries, keep the grid-aligned center.
static int band(double value){return value<.25?-1:value>.75?1:0;}
public int originX(int width){return x<0?0:x>0?VoxelSculpture.EDGE-width:(VoxelSculpture.EDGE-width)/2;}
public int originZ(int depth){return z<0?0:z>0?VoxelSculpture.EDGE-depth:(VoxelSculpture.EDGE-depth)/2;}
}
@@ -0,0 +1,38 @@
package fr.koka.sanctuary.plans;
import net.minecraft.core.Direction;
import net.minecraft.world.level.block.Block;
import net.minecraft.world.phys.shapes.VoxelShape;
/** Integer placement shared by rendering and tight, single-box selection/collision bounds. */
public final class SculptureGeometry {
public record Placement(VoxelSculpture sculpture, int offsetX, int offsetY, int offsetZ) {}
private SculptureGeometry() {}
public static Placement place(VoxelSculpture sculpture, Direction facing, SculptureAnchor anchor, SculptureHeight height) {
int turns = switch (facing) { case WEST -> 1; case NORTH -> 2; case EAST -> 3; default -> 0; };
for (int i = 0; i < turns; i++) sculpture = sculpture.rotate();
int minX = 16, minY = 16, minZ = 16, maxX = 0, maxY = 0, maxZ = 0;
for (var cell : sculpture.cells()) {
minX = Math.min(minX, cell.x()); minZ = Math.min(minZ, cell.z()); minY = Math.min(minY, cell.y());
maxX = Math.max(maxX, cell.x() + 1); maxZ = Math.max(maxZ, cell.z() + 1); maxY = Math.max(maxY, cell.y() + 1);
}
return new Placement(sculpture, anchor.originX(maxX - minX) - minX, height.originY(minY,maxY) - minY,
anchor.originZ(maxZ - minZ) - minZ);
}
/** Tight integer bounds, without rotating/copying cells or constructing a voxel grid. */
public static VoxelShape shape(VoxelSculpture sculpture, Direction facing, SculptureAnchor anchor, SculptureHeight height) {
int minX=16,minY=16,minZ=16,maxX=0,maxY=0,maxZ=0;
for(var cell:sculpture.cells()) {
minX=Math.min(minX,cell.x()); minY=Math.min(minY,cell.y()); minZ=Math.min(minZ,cell.z());
maxX=Math.max(maxX,cell.x()+1); maxY=Math.max(maxY,cell.y()+1); maxZ=Math.max(maxZ,cell.z()+1);
}
int width=maxX-minX,depth=maxZ-minZ;
if(facing.getAxis()==Direction.Axis.X){int swap=width;width=depth;depth=swap;}
int x=anchor.originX(width),z=anchor.originZ(depth);
int y=height.originY(minY,maxY);
return Block.box(x,y,z,x+width,y+maxY-minY,z+depth);
}
}
@@ -0,0 +1,19 @@
package fr.koka.sanctuary.plans;
import java.util.Locale;
import net.minecraft.util.StringRepresentable;
import net.minecraft.world.item.context.BlockPlaceContext;
/** ORIGINAL is the missing-property default: pre-beta.125 sculptures retain their exact Y offsets. */
public enum SculptureHeight implements StringRepresentable {
ORIGINAL, BOTTOM, CENTER, TOP;
public String getSerializedName(){return name().toLowerCase(Locale.ROOT);}
public int originY(int minY,int maxY){return switch(this){
case ORIGINAL -> minY;case BOTTOM -> 0;case CENTER -> (VoxelSculpture.EDGE-(maxY-minY))/2;case TOP -> VoxelSculpture.EDGE-(maxY-minY);
};}
public static SculptureHeight placement(BlockPlaceContext context){
return switch(SculptureAnchor.band(context.getClickLocation().y-context.getClickedPos().getY())){
case -1 -> BOTTOM;case 1 -> TOP;default -> CENTER;
};
}
}
@@ -23,12 +23,14 @@ import net.minecraft.world.level.material.PushReaction;
import net.minecraft.world.level.storage.loot.LootParams;
import net.minecraft.world.level.storage.loot.parameters.LootContextParams;
import net.minecraft.world.phys.*;
import net.minecraft.world.phys.shapes.*;
import net.minecraft.network.chat.Component;
/** The clay workshop crafts portable sculptures; a placed sculpture is only a decoration. */
public final class Statuary {
public static final EnumProperty<Direction> FACING=BlockStateProperties.HORIZONTAL_FACING;
public static final EnumProperty<SculptureAnchor> ANCHOR=EnumProperty.create("anchor",SculptureAnchor.class);
public static final EnumProperty<SculptureHeight> HEIGHT=EnumProperty.create("vertical_anchor",SculptureHeight.class);
public static final Block BLOCK=block("statuary",new SculptureBlock());
public static final Item ITEM=blockItem("statuary",BLOCK);
public static final Block WORKSHOP=block("clay_workshop",new WorkshopBlock());
@@ -49,7 +51,7 @@ public final class Statuary {
}
public static void open(ServerPlayer p,BlockPos pos){
if(!allowed(p,p.level(),pos))return;
var view=new ClayWorkshopMenu.View(pos,UUID.randomUUID());var level=p.level();
var view=new ClayWorkshopMenu.View(pos,UUID.randomUUID(),StatueDiscoveries.known(p));var level=p.level();
p.openMenu(new ExtendedMenuProvider<ClayWorkshopMenu.View>(){
public ClayWorkshopMenu.View getScreenOpeningData(ServerPlayer player){return view;}
public Component getDisplayName(){return WORKSHOP.getName();}
@@ -57,12 +59,33 @@ public final class Statuary {
});
}
private static Block block(String name,Block block){return Registry.register(BuiltInRegistries.BLOCK,SanctuaryMod.id(name),block);}
private static Item blockItem(String name,Block block){return Registry.register(BuiltInRegistries.ITEM,SanctuaryMod.id(name),new BlockItem(block,new Item.Properties().setId(ResourceKey.create(Registries.ITEM,SanctuaryMod.id(name))).useBlockDescriptionPrefix()));}
private static Item blockItem(String name,Block block){
var properties=new Item.Properties().setId(ResourceKey.create(Registries.ITEM,SanctuaryMod.id(name))).useBlockDescriptionPrefix();
return Registry.register(BuiltInRegistries.ITEM,SanctuaryMod.id(name),block==BLOCK?new SculptureItem(block,properties):new BlockItem(block,properties));
}
private static BlockBehaviour.Properties properties(String id){return BlockBehaviour.Properties.of().strength(1.5f).noOcclusion().noLootTable().pushReaction(PushReaction.IMMOVEABLE).setId(ResourceKey.create(Registries.BLOCK,SanctuaryMod.id(id)));}
private static final class SculptureItem extends BlockItem {
SculptureItem(Block block,Item.Properties properties){super(block,properties);}
@Override protected boolean canPlace(BlockPlaceContext context,BlockState state){
if(!super.canPlace(context,state))return false;
// The destination has no block entity yet: use the carried model for the native entity-overlap check.
var model=StatuaryEntity.sculpture(context.getItemInHand());
return model==null||context.getLevel().isUnobstructed(null,SculptureGeometry.shape(model,state.getValue(FACING),state.getValue(ANCHOR),state.getValue(HEIGHT)).move(context.getClickedPos()));
}
}
private static final class SculptureBlock extends Block implements EntityBlock {
SculptureBlock(){super(Statuary.properties("statuary"));registerDefaultState(stateDefinition.any().setValue(FACING,Direction.SOUTH).setValue(ANCHOR,SculptureAnchor.CENTER));}
@Override protected void createBlockStateDefinition(StateDefinition.Builder<Block,BlockState> builder){builder.add(FACING,ANCHOR);}
@Override public BlockState getStateForPlacement(BlockPlaceContext context){return defaultBlockState().setValue(FACING,context.getHorizontalDirection()).setValue(ANCHOR,SculptureAnchor.placement(context));}
SculptureBlock(){super(Statuary.properties("statuary").dynamicShape().isSuffocating((s,l,p)->false).isViewBlocking((s,l,p,box)->false));registerDefaultState(stateDefinition.any().setValue(FACING,Direction.SOUTH).setValue(ANCHOR,SculptureAnchor.CENTER).setValue(HEIGHT,SculptureHeight.ORIGINAL));}
@Override protected VoxelShape getShape(BlockState state,BlockGetter level,BlockPos pos,CollisionContext context){
return level.getBlockEntity(pos) instanceof StatuaryEntity entity?entity.shape(state):Shapes.empty();
}
// Lighting metadata is cached per blockstate, so it cannot describe each block entity's voxels.
// Decorative sculptures transmit light; their faces still receive ordinary world lighting.
@Override protected VoxelShape getOcclusionShape(BlockState state){return Shapes.empty();}
@Override protected boolean propagatesSkylightDown(BlockState state){return true;}
@Override protected int getLightDampening(BlockState state){return 0;}
@Override protected float getShadeBrightness(BlockState state,BlockGetter level,BlockPos pos){return 1;}
@Override protected void createBlockStateDefinition(StateDefinition.Builder<Block,BlockState> builder){builder.add(FACING,ANCHOR,HEIGHT);}
@Override public BlockState getStateForPlacement(BlockPlaceContext context){return defaultBlockState().setValue(FACING,context.getHorizontalDirection()).setValue(ANCHOR,SculptureAnchor.placement(context)).setValue(HEIGHT,SculptureHeight.placement(context));}
@Override protected BlockState rotate(BlockState state,Rotation rotation){return state.setValue(FACING,rotation.rotate(state.getValue(FACING))).setValue(ANCHOR,state.getValue(ANCHOR).rotate(rotation));}
@Override protected BlockState mirror(BlockState state,Mirror mirror){return state.setValue(FACING,mirror.mirror(state.getValue(FACING))).setValue(ANCHOR,state.getValue(ANCHOR).mirror(mirror));}
public BlockEntity newBlockEntity(BlockPos pos,BlockState state){return new StatuaryEntity(pos,state);}
@@ -9,13 +9,26 @@ import net.minecraft.world.item.component.TypedEntityData;
import net.minecraft.world.level.block.entity.BlockEntity;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.storage.*;
import net.minecraft.world.level.block.Block;
import net.minecraft.world.phys.shapes.VoxelShape;
/** New, additive block data only. No ticking or mutation of pre-existing world formats. */
public final class StatuaryEntity extends BlockEntity {
private VoxelSculpture sculpture;
private CompoundTag opaque;
private record ShapeCache(VoxelSculpture sculpture,BlockState state,VoxelShape shape){}
private volatile ShapeCache shapeCache;
// Keep unknown/future data recoverable by picking the small base, without a phantom full cube.
private static final VoxelShape MISSING_SHAPE=Block.box(6,0,6,10,4,10);
public StatuaryEntity(BlockPos pos,BlockState state){super(Statuary.TYPE,pos,state);}
public VoxelSculpture sculpture(){return sculpture;}
public VoxelShape shape(BlockState state){
var model=sculpture;if(model==null)return MISSING_SHAPE;
var cached=shapeCache;
if(cached!=null&&cached.sculpture()==model&&cached.state()==state)return cached.shape();
var shape=SculptureGeometry.shape(model,state.getValue(Statuary.FACING),state.getValue(Statuary.ANCHOR),state.getValue(Statuary.HEIGHT));
shapeCache=new ShapeCache(model,state,shape);return shape;
}
@Override protected void saveAdditional(ValueOutput out){
super.saveAdditional(out);
if(opaque!=null){out.store("sculpture",CompoundTag.CODEC,opaque);return;}
@@ -23,7 +36,7 @@ public final class StatuaryEntity extends BlockEntity {
out.store("sculpture",CompoundTag.CODEC,encode(sculpture));
}
@Override protected void loadAdditional(ValueInput in){
super.loadAdditional(in);sculpture=null;opaque=null;
super.loadAdditional(in);sculpture=null;opaque=null;shapeCache=null;
var data=in.read("sculpture",CompoundTag.CODEC).orElse(null);if(data==null)return;
try{
if(data.getIntOr("schema",-1)!=1)throw new IllegalArgumentException("schema");
@@ -18,5 +18,15 @@ public final class StatueDiscoveries {
||player.getStats().getValue(Stats.ENTITY_KILLED,type)>0||player.getStats().getValue(Stats.ENTITY_KILLED_BY,type)>0;
}).map(type->BuiltInRegistries.ENTITY_TYPE.getKey(type).toString()).sorted().toList();
}
/** Stable identifiers share the existing sculpture schema; imported models keep their content hash. */
public static String modelId(String entityId){
try{return "sanctuary:model/"+HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-256").digest(("sanctuary:statue/"+entityId).getBytes(java.nio.charset.StandardCharsets.UTF_8)));}
catch(java.security.NoSuchAlgorithmException impossible){throw new AssertionError(impossible);}
}
private static final class Models {
static final Map<String,String> IDS=BuiltInRegistries.ENTITY_TYPE.stream().filter(t->SpawnEggItem.byId(t).isPresent())
.map(t->BuiltInRegistries.ENTITY_TYPE.getKey(t).toString()).collect(java.util.stream.Collectors.toUnmodifiableMap(StatueDiscoveries::modelId,id->id));
}
public static boolean allowedModel(String model,List<String> known){var entity=Models.IDS.get(model);return entity==null||known.contains(entity);}
private StatueDiscoveries(){}
}
@@ -35,6 +35,7 @@ public final class RecipeService {
}
private static Session session(ServerPlayer p){return SESSIONS.computeIfAbsent(p,Session::new);}
public static RecipeKnowledge knowledge(ServerPlayer p){return session(p).data;}
public static boolean healthy(ServerPlayer p){return !session(p).invalid;}
public static void discover(ServerPlayer p,Item item){
if(ProgressEffects.get(p)==null||item==Items.AIR)return;var s=session(p);if(s.invalid)return;
s.dirty|=RecipeKnowledge.add(s.data.items,BuiltInRegistries.ITEM.getKey(item).toString());
@@ -0,0 +1,5 @@
{
"sources": [
{"type": "minecraft:single", "resource": "sanctuary:misc/sculpture_white", "sprite": "sanctuary:sculpture_particle"}
]
}
@@ -0,0 +1,7 @@
{
"variants": {
"": {
"model": "sanctuary:block/deepslate_ruby_ore"
}
}
}
@@ -0,0 +1,7 @@
{
"variants": {
"": {
"model": "sanctuary:block/deepslate_sapphire_ore"
}
}
}
@@ -0,0 +1,7 @@
{
"variants": {
"": {
"model": "sanctuary:block/ruby_block"
}
}
}
@@ -0,0 +1,7 @@
{
"variants": {
"": {
"model": "sanctuary:block/ruby_ore"
}
}
}
@@ -0,0 +1,7 @@
{
"variants": {
"": {
"model": "sanctuary:block/sapphire_block"
}
}
}
@@ -0,0 +1,7 @@
{
"variants": {
"": {
"model": "sanctuary:block/sapphire_ore"
}
}
}
@@ -0,0 +1,6 @@
{
"model": {
"type": "minecraft:model",
"model": "sanctuary:block/deepslate_ruby_ore"
}
}
@@ -0,0 +1,6 @@
{
"model": {
"type": "minecraft:model",
"model": "sanctuary:block/deepslate_sapphire_ore"
}
}
@@ -0,0 +1,6 @@
{
"model": {
"type": "minecraft:model",
"model": "sanctuary:item/ruby"
}
}
@@ -0,0 +1,6 @@
{
"model": {
"type": "minecraft:model",
"model": "sanctuary:block/ruby_block"
}
}
@@ -0,0 +1,6 @@
{
"model": {
"type": "minecraft:model",
"model": "sanctuary:block/ruby_ore"
}
}
@@ -0,0 +1,6 @@
{
"model": {
"type": "minecraft:model",
"model": "sanctuary:item/sapphire"
}
}
@@ -0,0 +1,6 @@
{
"model": {
"type": "minecraft:model",
"model": "sanctuary:block/sapphire_block"
}
}
@@ -0,0 +1,6 @@
{
"model": {
"type": "minecraft:model",
"model": "sanctuary:block/sapphire_ore"
}
}
@@ -0,0 +1,6 @@
{
"model": {
"type": "minecraft:model",
"model": "sanctuary:item/steve_wrench"
}
}
@@ -1,4 +1,5 @@
{
"sanctuary.statuary.clay_ready": "Clay: original model colors.",
"sanctuary.weather.selected": "Real Time weather: %s for %s (%s). The daily forecast resumes at midnight.",
"sanctuary.weather.overcast": "Overcast",
"sanctuary.weather.fog": "Fog",
@@ -1284,7 +1285,7 @@
"sanctuary.shaders.saturation": "Saturation",
"sanctuary.shaders.saturation_value": "Saturation: %s%%",
"sanctuary.shaders.saturation_help": "0%: grayscale. 100%: neutral. 142%: default setting. 200%: more vivid colours. Requires Vanilla Light; interface colours stay unchanged.",
"sanctuary.shaders.help": "Subtle colour grading and progressive fog from your viewpoint to the visible limit. No extra shadows or reflections.",
"sanctuary.shaders.help": "Subtle colour grading and progressive fog from your viewpoint to the visible limit. Optional pixel sun shadows, with no extra reflections.",
"sanctuary.shaders.external": "Sanctuary Vanilla Light is suspended while another shader engine is installed. Your preference is kept.",
"sanctuary.cosmetic.bed_other": "Decorative bed: you cannot sleep in a worn bed.",
"block.sanctuary.slime_springboard": "Temporary slime",
@@ -1753,6 +1754,14 @@
"sanctuary.preload.cities": "Preparing settlements",
"sanctuary.preload.cache_write": "Saving plans",
"sanctuary.familiar.dragon.lift": "On your head: hold %s to rise, release to glide.",
"sanctuary.resource_pack.customize": "Customize Sanctuary",
"sanctuary.resource_pack.open_copy": "Open my copy",
"sanctuary.resource_pack.creating": "Creating your copy…",
"sanctuary.resource_pack.customize_help": "Create an editable copy of Sanctuary resources. Enable it and move it to the top of the list. Updates preserve your changes.",
"sanctuary.resource_pack.copy_ready": "Copy ready! Enable Sanctuary-Custom and move it above other packs. Click to open its folder and guide.",
"sanctuary.resource_pack.copy_failed": "Copy failed — retry",
"sanctuary.resource_pack.copy_failed_help": "Check write permissions and the resourcepacks/Sanctuary-Custom path. See the game log for details.",
"sanctuary.resource_pack.custom_description": "Sanctuary — your editable copy",
"sanctuary.resource_pack.name": "Sanctuary textures",
"sanctuary.resource_pack.description": "Sanctuary textures — %s",
"sanctuary.familiar.personality": "Personality: %s",
@@ -1964,7 +1973,7 @@
"sanctuary.discovery.item_filter": "Show carried items or every item already discovered.",
"sanctuary.discovery.catalogue_required": "The Catalogue ability lets you browse discovered items you no longer carry.",
"sanctuary.discovery.loading": "Loading statistics…",
"sanctuary.discovery.unavailable": "Statistics unavailable. Refresh to try again.",
"sanctuary.discovery.unavailable": "Unable to update discoveries for %s; no changes made.",
"sanctuary.discovery.mobs_hint": "Mobs you have killed or that have killed you. Sightings alone are not recorded yet.",
"sanctuary.discovery.count": "%s entries",
"sanctuary.discovery.empty": "No entries match this filter.",
@@ -1979,7 +1988,7 @@
"sanctuary.multiblock.outputs": "Results",
"sanctuary.multiblock.heat": "Reserve: %s s",
"sanctuary.multiblock.progress": "Cooking: %s %%",
"sanctuary.multiblock.hint": "Right click: assemble / open. Sneak: rotate / separate.",
"sanctuary.multiblock.hint": "Left click: select a property. Right click: change its value. Sneak: reverse. Complete multiblock: assemble / open; sneak: separate.",
"sanctuary.multiblock.unavailable": "Machine inaccessible, incomplete or partly unloaded.",
"sanctuary.multiblock.separated": "Machine separated. Components retain their contents.",
"sanctuary.multiblock.rotated": "Component rotated.",
@@ -2162,7 +2171,7 @@
"sanctuary.statuary.folder": "Shared folder",
"sanctuary.statuary.refresh": "Refresh",
"sanctuary.statuary.models": "GLB models",
"sanctuary.statuary.placement": "The sculpture faces your gaze and snaps to the aimed block edge, on the pixel grid. Rotate adjusts the model before crafting.",
"sanctuary.statuary.placement": "Aim at the middle of the block to center the sculpture, or at an edge to snap to it. It faces your gaze and stays on the pixel grid. Rotate adjusts the model before crafting.",
"sanctuary.statuary.rotate": "Rotate",
"sanctuary.statuary.working": "Voxelizing to 16 × 16 × 16…",
"sanctuary.statuary.ready": "1 block = 1 sculpture. Shades come from your materials texture.",
@@ -2221,5 +2230,39 @@
"block.sanctuary.brown_brick_wall": "Brown Brick Wall",
"block.sanctuary.green_brick_wall": "Green Brick Wall",
"block.sanctuary.red_brick_wall": "Red Brick Wall",
"block.sanctuary.black_brick_wall": "Black Brick Wall"
"block.sanctuary.black_brick_wall": "Black Brick Wall",
"sanctuary.wrench.selected": "Selected property: %s = %s · right click to change",
"sanctuary.wrench.changed": "%s = %s",
"sanctuary.wrench.texture": "Texture rotation",
"sanctuary.wrench.assembled": "Assembled multiblock: right click to open, sneak-right click to separate before editing its components.",
"item.sanctuary.steve_wrench": "Steves Wrench",
"sanctuary.wrench.rotate_help": "Right click: orient · Sneak: reverse · Multiblocks: assemble/separate",
"sanctuary.wrench.steve_help": "Left click: select property · Right click: change · Sneak: reverse",
"sanctuary.wrench.rotated": "Orientation changed",
"sanctuary.wrench.no_rotation": "This block has no vanilla orientation.",
"sanctuary.wrench.linked": "This block has linked parts: separate or retract it before rotating.",
"sanctuary.wrench.unsupported": "This orientation does not fit the neighbouring blocks or support.",
"sanctuary.wrench.steve_power": "Free editing of block states and textures",
"sanctuary.statuary.animals": "Animals",
"sanctuary.statuary.imports": "Imports",
"sanctuary.statuary.no_animals": "Encounter creatures to unlock their sculptures.",
"sanctuary.statuary.animal_error": "This creatures model could not be sculpted.",
"item.sanctuary.ruby": "Ruby",
"block.sanctuary.ruby_block": "Block of Ruby",
"block.sanctuary.ruby_ore": "Ruby Ore",
"block.sanctuary.deepslate_ruby_ore": "Deepslate Ruby Ore",
"item.sanctuary.sapphire": "Sapphire",
"block.sanctuary.sapphire_block": "Block of Sapphire",
"block.sanctuary.sapphire_ore": "Sapphire Ore",
"block.sanctuary.deepslate_sapphire_ore": "Deepslate Sapphire Ore",
"sanctuary.discovery.granted": "Discoveries added for %s: %s blocks, %s items, %s species. Linked recipes updated; reopen catalogues.",
"sanctuary.discovery.invalid": "Choose a discoverable block, item or species.",
"sanctuary.shaders.pixel_shadows": "Pixel shadows",
"sanctuary.shaders.pixel_shadows_help": "Sun shadows and subtle moon shadows on a pixel grid. Dappled foliage and softer shadows at noon. Disabled by default; requires Vanilla Light.",
"sanctuary.shaders.shadow_pixels": "Shadow precision",
"sanctuary.shaders.shadow_pixels_value": "%s pixels/block",
"sanctuary.shaders.shadow_pixels_help": "Grid precision: 8, 16 or 32 pixels per block. Higher values make shadow pixels finer. Requires Vanilla Light and pixel shadows.",
"sanctuary.shaders.shadow_distance": "Shadow distance",
"sanctuary.shaders.shadow_distance_value": "%s blocks",
"sanctuary.shaders.shadow_distance_help": "Shadow range: 16, 32 or 64 blocks. Higher distances cost more performance. Requires Vanilla Light and pixel shadows."
}
@@ -1,4 +1,5 @@
{
"sanctuary.statuary.clay_ready": "Argile : couleurs originales du modèle.",
"sanctuary.weather.selected": "Météo Real Time : %s pour la journée du %s (%s). Le tirage quotidien reprend à minuit.",
"sanctuary.weather.overcast": "Ciel gris",
"sanctuary.weather.fog": "Brouillard",
@@ -1284,7 +1285,7 @@
"sanctuary.shaders.saturation": "Saturation",
"sanctuary.shaders.saturation_value": "Saturation : %s %%",
"sanctuary.shaders.saturation_help": "0 % : noir et blanc. 100 % : neutre. 142 % : réglage par défaut. 200 % : couleurs plus vives. Avec Vanilla Light activé ; linterface garde ses couleurs.",
"sanctuary.shaders.help": "Couleurs légèrement retravaillées et brume progressive depuis ton point de vue jusqu’à la limite visible. Sans ombres ni reflets supplémentaires.",
"sanctuary.shaders.help": "Couleurs légèrement retravaillées et brume progressive depuis ton point de vue jusqu’à la limite visible. Ombres pixelisées du soleil en option, sans reflets supplémentaires.",
"sanctuary.shaders.external": "Sanctuary Vanilla Light est suspendu lorsquun autre moteur de shaders est installé. Votre préférence est conservée.",
"sanctuary.cosmetic.bed_other": "Lit décoratif : impossible de dormir dans un lit porté.",
"block.sanctuary.slime_springboard": "Slime éphémère",
@@ -1753,6 +1754,14 @@
"sanctuary.preload.cities": "Préparation des implantations",
"sanctuary.preload.cache_write": "Enregistrement des plans",
"sanctuary.familiar.dragon.lift": "Sur la tête : maintenir %s pour monter, relâcher pour planer.",
"sanctuary.resource_pack.customize": "Personnaliser Sanctuary",
"sanctuary.resource_pack.open_copy": "Ouvrir ma copie",
"sanctuary.resource_pack.creating": "Création de la copie…",
"sanctuary.resource_pack.customize_help": "Crée une copie modifiable des ressources de Sanctuary. Activez-la et placez-la en haut de la liste. Vos modifications sont conservées lors des mises à jour.",
"sanctuary.resource_pack.copy_ready": "Copie prête ! Activez Sanctuary-Custom et placez-le au-dessus des autres packs. Cliquez pour ouvrir son dossier et son guide.",
"sanctuary.resource_pack.copy_failed": "Copie impossible — réessayer",
"sanctuary.resource_pack.copy_failed_help": "Vérifiez les droits d’écriture et le chemin resourcepacks/Sanctuary-Custom. Les détails sont dans le journal du jeu.",
"sanctuary.resource_pack.custom_description": "Sanctuary — votre copie modifiable",
"sanctuary.resource_pack.name": "Textures Sanctuary",
"sanctuary.resource_pack.description": "Textures Sanctuary — %s",
"sanctuary.familiar.personality": "Personnalité : %s",
@@ -1964,7 +1973,7 @@
"sanctuary.discovery.item_filter": "Afficher les objets portés ou tous les objets déjà découverts.",
"sanctuary.discovery.catalogue_required": "Laptitude Catalogue permet de consulter les objets découverts que tu ne portes plus.",
"sanctuary.discovery.loading": "Chargement des statistiques…",
"sanctuary.discovery.unavailable": "Statistiques indisponibles. Actualise pour réessayer.",
"sanctuary.discovery.unavailable": "Impossible de mettre à jour les découvertes de %s ; aucune modification effectuée.",
"sanctuary.discovery.mobs_hint": "Créatures déjà tuées ou responsables de ta mort. Les simples observations ne sont pas encore enregistrées.",
"sanctuary.discovery.count": "%s entrées",
"sanctuary.discovery.empty": "Aucune entrée pour ce filtre.",
@@ -1979,7 +1988,7 @@
"sanctuary.multiblock.outputs": "Résultats",
"sanctuary.multiblock.heat": "Réserve : %s s",
"sanctuary.multiblock.progress": "Cuisson : %s %%",
"sanctuary.multiblock.hint": "Clic droit : assembler / ouvrir. Maj : tourner / dissocier.",
"sanctuary.multiblock.hint": "Clic gauche : choisir un état. Clic droit : changer sa valeur. Maj : sens inverse. Multibloc complet : assembler / ouvrir ; Maj : dissocier.",
"sanctuary.multiblock.unavailable": "Machine inaccessible, incomplète ou partiellement déchargée.",
"sanctuary.multiblock.separated": "Machine dissociée. Les composants conservent leur contenu.",
"sanctuary.multiblock.rotated": "Composant tourné.",
@@ -2162,7 +2171,7 @@
"sanctuary.statuary.folder": "Dossier commun",
"sanctuary.statuary.refresh": "Actualiser",
"sanctuary.statuary.models": "Modèles GLB",
"sanctuary.statuary.placement": "La sculpture suit votre regard et se cale contre le bord visé du bloc, sur la grille de pixels. Tourner ajuste le modèle avant fabrication.",
"sanctuary.statuary.placement": "Visez le milieu du bloc pour centrer la sculpture, ou un bord pour ly caler. Elle suit votre regard et reste sur la grille de pixels. Tourner ajuste le modèle avant fabrication.",
"sanctuary.statuary.rotate": "Tourner",
"sanctuary.statuary.working": "Voxelisation en 16 × 16 × 16…",
"sanctuary.statuary.ready": "1 bloc = 1 sculpture. Les nuances viennent de la texture de votre matière.",
@@ -2221,5 +2230,39 @@
"block.sanctuary.brown_brick_wall": "Muret de briques marron",
"block.sanctuary.green_brick_wall": "Muret de briques vertes",
"block.sanctuary.red_brick_wall": "Muret de briques rouges",
"block.sanctuary.black_brick_wall": "Muret de briques noires"
"block.sanctuary.black_brick_wall": "Muret de briques noires",
"sanctuary.wrench.selected": "État choisi : %s = %s · clic droit pour modifier",
"sanctuary.wrench.changed": "%s = %s",
"sanctuary.wrench.texture": "Rotation des textures",
"sanctuary.wrench.assembled": "Multibloc assemblé : clic droit pour ouvrir, Maj-clic droit pour dissocier avant de modifier ses composants.",
"item.sanctuary.steve_wrench": "Clé à molette de Steve",
"sanctuary.wrench.rotate_help": "Clic droit : orienter · Maj : sens inverse · Multiblocs : assembler/dissocier",
"sanctuary.wrench.steve_help": "Clic gauche : choisir un état · Clic droit : modifier · Maj : sens inverse",
"sanctuary.wrench.rotated": "Orientation modifiée",
"sanctuary.wrench.no_rotation": "Ce bloc ne possède pas dorientation vanilla.",
"sanctuary.wrench.linked": "Bloc lié à une autre partie : dissocie-le ou rétracte-le avant de le tourner.",
"sanctuary.wrench.unsupported": "Cette orientation ne convient pas aux blocs voisins ou au support.",
"sanctuary.wrench.steve_power": "Édition libre des états et des textures",
"sanctuary.statuary.animals": "Animaux",
"sanctuary.statuary.imports": "Imports",
"sanctuary.statuary.no_animals": "Rencontrez des créatures pour débloquer leurs sculptures.",
"sanctuary.statuary.animal_error": "Impossible de sculpter le modèle de cette créature.",
"item.sanctuary.ruby": "Rubis",
"block.sanctuary.ruby_block": "Bloc de rubis",
"block.sanctuary.ruby_ore": "Minerai de rubis",
"block.sanctuary.deepslate_ruby_ore": "Minerai de rubis des abîmes",
"item.sanctuary.sapphire": "Saphir",
"block.sanctuary.sapphire_block": "Bloc de saphir",
"block.sanctuary.sapphire_ore": "Minerai de saphir",
"block.sanctuary.deepslate_sapphire_ore": "Minerai de saphir des abîmes",
"sanctuary.discovery.granted": "Découvertes ajoutées pour %s : %s blocs, %s objets, %s espèces. Recettes liées actualisées ; rouvre les catalogues.",
"sanctuary.discovery.invalid": "Choisis un bloc, un objet ou une espèce découvrable.",
"sanctuary.shaders.pixel_shadows": "Ombres pixelisées",
"sanctuary.shaders.pixel_shadows_help": "Ombres du soleil et ombres légères de la lune, sur une grille de pixels. Feuillage irrégulier et ombres atténuées à midi. Désactivées par défaut ; nécessite Vanilla Light.",
"sanctuary.shaders.shadow_pixels": "Précision des ombres",
"sanctuary.shaders.shadow_pixels_value": "%s pixels/bloc",
"sanctuary.shaders.shadow_pixels_help": "Précision de la grille : 8, 16 ou 32 pixels par bloc. Plus la valeur est élevée, plus les pixels des ombres sont fins. Nécessite Vanilla Light et les ombres pixelisées.",
"sanctuary.shaders.shadow_distance": "Distance des ombres",
"sanctuary.shaders.shadow_distance_value": "%s blocs",
"sanctuary.shaders.shadow_distance_help": "Portée des ombres : 16, 32 ou 64 blocs. Une distance plus élevée coûte davantage en performances. Nécessite Vanilla Light et les ombres pixelisées."
}
@@ -0,0 +1,6 @@
{
"parent": "minecraft:block/cube_all",
"textures": {
"all": "sanctuary:block/deepslate_ruby_ore"
}
}
@@ -0,0 +1,6 @@
{
"parent": "minecraft:block/cube_all",
"textures": {
"all": "sanctuary:block/deepslate_sapphire_ore"
}
}
@@ -0,0 +1,6 @@
{
"parent": "minecraft:block/cube_all",
"textures": {
"all": "sanctuary:block/ruby_block"
}
}
@@ -0,0 +1,6 @@
{
"parent": "minecraft:block/cube_all",
"textures": {
"all": "sanctuary:block/ruby_ore"
}
}
@@ -0,0 +1,6 @@
{
"parent": "minecraft:block/cube_all",
"textures": {
"all": "sanctuary:block/sapphire_block"
}
}
@@ -0,0 +1,6 @@
{
"parent": "minecraft:block/cube_all",
"textures": {
"all": "sanctuary:block/sapphire_ore"
}
}
@@ -0,0 +1,6 @@
{
"parent": "minecraft:item/generated",
"textures": {
"layer0": "sanctuary:item/ruby"
}
}
@@ -0,0 +1,6 @@
{
"parent": "minecraft:item/generated",
"textures": {
"layer0": "sanctuary:item/sapphire"
}
}
@@ -0,0 +1,4 @@
{
"parent": "minecraft:item/handheld",
"textures": {"layer0": "sanctuary:item/steve_wrench"}
}
@@ -0,0 +1,18 @@
#version 330
#extension GL_ARB_separate_shader_objects : require
layout(location = 0) in vec3 foliagePosition;
layout(location = 1) flat in float foliage;
layout(location = 0) out vec4 material;
uint mixBits(uint h) {
h ^= h >> 16; h *= 0x7feb352du; h ^= h >> 15; h *= 0x846ca68bu; return h ^ (h >> 16);
}
void main() {
// Fixed quarter-block cells: no frame/time/camera seed, texture-alpha sampling or CPU ray march.
// Each crossed leaf surface transmits 78%; independent layers converge towards solid shade.
if (foliage > 0.5) {
uvec3 p = uvec3(ivec3(floor((foliagePosition + 0.03125) * 4.0)));
uint h = mixBits(p.x ^ mixBits(p.y + 0x9e3779b9u) ^ mixBits(p.z + 0x85ebca6bu));
if (float(h & 65535u) / 65535.0 >= 0.22) discard;
}
material = vec4(foliage, 0.0, 0.0, 1.0);
}
@@ -0,0 +1,12 @@
#version 330
#extension GL_ARB_separate_shader_objects : require
#include <minecraft:dynamictransforms.glsl>
layout(location = 0) in vec3 Position;
layout(location = 1) in vec4 Color;
layout(location = 0) out vec3 foliagePosition;
layout(location = 1) flat out float foliage;
void main() {
gl_Position = ModelViewMat * vec4(Position, 1.0);
foliagePosition = Position + ModelOffset;
foliage = Color.r;
}
@@ -0,0 +1,86 @@
#version 330
#extension GL_ARB_separate_shader_objects : require
uniform sampler2D WorldDepth;
uniform sampler2D SunDepth;
uniform sampler2D SunMaterial;
layout(std140) uniform PixelShadows {
mat4 InverseViewProjection;
mat4 LightMatrix;
vec4 CameraFractionPixels;
vec4 SunStrength;
vec4 DistanceClipFog;
vec4 EnvironmentFog;
};
layout(location = 0) in vec2 texCoord;
layout(location = 0) out vec4 fragColor;
float linearFog(float d, float start, float end) {
return clamp((d - start) / max(end - start, 0.001), 0.0, 1.0);
}
float shadowAt(vec2 uv, vec3 receiver, vec2 planeSlope, bool includeOpaque) {
if (!includeOpaque && texture(SunMaterial, uv).r < 0.5) return 0.0;
// Compare each tap on the same receiving plane; an oblique floor must not shadow itself.
float depth = receiver.z + dot(uv - receiver.xy, planeSlope);
return depth > texture(SunDepth, uv).r + 0.00015 ? 1.0 : 0.0;
}
float bilinearShadow(vec2 uv, vec3 receiver, vec2 planeSlope, bool includeOpaque) {
vec2 size = vec2(textureSize(SunDepth, 0));
vec2 at = uv * size - 0.5;
vec2 base = (floor(at) + 0.5) / size;
vec2 t = fract(at), stepSize = 1.0 / size;
return mix(mix(shadowAt(base,receiver,planeSlope,includeOpaque),
shadowAt(base+vec2(stepSize.x,0.0),receiver,planeSlope,includeOpaque),t.x),
mix(shadowAt(base+vec2(0.0,stepSize.y),receiver,planeSlope,includeOpaque),
shadowAt(base+stepSize,receiver,planeSlope,includeOpaque),t.x),t.y);
}
float filteredShadow(vec3 coord, vec2 planeSlope, bool includeOpaque) {
// Four fixed bilinear taps span a quarter block, averaging leaf coverage into softer shades.
// The result is still evaluated once per world-grid cell, with no temporal/random jitter.
float r = 0.125 / (2.0 * (DistanceClipFog.x + 16.0));
return 0.25 * (bilinearShadow(coord.xy+vec2(-r,-r),coord,planeSlope,includeOpaque)
+ bilinearShadow(coord.xy+vec2( r,-r),coord,planeSlope,includeOpaque)
+ bilinearShadow(coord.xy+vec2(-r, r),coord,planeSlope,includeOpaque)
+ bilinearShadow(coord.xy+vec2( r, r),coord,planeSlope,includeOpaque));
}
void main() {
float depth = texture(WorldDepth, texCoord).r;
float clipDepth = DistanceClipFog.y > 0.5 ? depth : depth * 2.0 - 1.0;
vec4 projected = InverseViewProjection * vec4(texCoord * 2.0 - 1.0, clipDepth, 1.0);
vec3 relative = projected.xyz / projected.w;
// Derivatives before divergent branches; keep the receiving plane instead of snapping into a block.
vec3 normal = normalize(cross(dFdx(relative), dFdy(relative)));
if (dot(normal, -relative) < 0.0) normal = -normal;
if (depth <= 0.0 || length(relative) >= DistanceClipFog.x) discard;
// Depth derivatives vary with the camera. Use a stable axis for voxel faces.
vec3 absoluteNormal = abs(normal);
vec3 axis = absoluteNormal.x > absoluteNormal.y && absoluteNormal.x > absoluteNormal.z ? vec3(1,0,0)
: absoluteNormal.y > absoluteNormal.z ? vec3(0,1,0) : vec3(0,0,1);
bool voxelFace = max(max(absoluteNormal.x, absoluteNormal.y), absoluteNormal.z) > 0.995;
if (voxelFace) normal = axis * sign(dot(normal,axis));
vec3 p = relative + CameraFractionPixels.xyz;
float pixels = CameraFractionPixels.w;
vec3 snapped = (floor(p * pixels + 0.0001) + 0.5) / pixels;
// Vanilla voxel faces live on the 1/16 grid. Quantize their plane as well as the two face axes.
float plane = floor(dot(p,axis) * 16.0 + 0.5) / 16.0;
vec3 q = voxelFace ? snapped + axis * (plane - dot(snapped,axis))
: snapped + normal * dot(p - snapped, normal);
q += normal * 0.04 + SunStrength.xyz * 0.015;
vec4 light = LightMatrix * vec4(q, 1.0);
vec3 coord = light.xyz / light.w;
coord.xy = coord.xy * 0.5 + 0.5;
if (DistanceClipFog.y < 0.5) coord.z = coord.z * 0.5 + 0.5;
if (any(lessThan(coord, vec3(0.0))) || any(greaterThan(coord, vec3(1.0)))) discard;
// Cofactors transform the receiver normal without a per-fragment matrix inverse.
vec3 a = LightMatrix[0].xyz, b = LightMatrix[1].xyz, c = LightMatrix[2].xyz;
vec3 lightNormal = normal.x * cross(b,c) + normal.y * cross(c,a) + normal.z * cross(a,b);
vec2 planeSlope = abs(lightNormal.z) > 0.00000001
? -lightNormal.xy / lightNormal.z * (DistanceClipFog.y > 0.5 ? 2.0 : 1.0) : vec2(0.0);
bool leaf = texture(SunMaterial, coord.xy).r > 0.5;
float hardShadow = shadowAt(coord.xy, coord, planeSlope, true);
// Keep opaque-block edges crisp. Only foliage coverage receives the soft kernel.
// Leaf taps may include opaque occluders behind them, preserving shade under roofs.
float shade = !leaf && hardShadow > 0.5 ? 1.0 : filteredShadow(coord, planeSlope, leaf);
float fade = 1.0 - smoothstep(DistanceClipFog.x * 0.8, DistanceClipFog.x, length(relative));
float fog = max(linearFog(length(relative), EnvironmentFog.x, EnvironmentFog.y),
linearFog(max(length(relative.xz), abs(relative.y)), DistanceClipFog.z, DistanceClipFog.w));
fragColor = vec4(0.0, 0.0, 0.0, shade * SunStrength.w * fade * (1.0 - fog * EnvironmentFog.z));
}

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