Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8de569bbf1 |
@@ -1,5 +1,12 @@
|
||||
# Changelog
|
||||
|
||||
## beta.144 — Code d’accès à la création
|
||||
|
||||
- Hello World demande le code `SANC-XXXX-XXXX` quand le serveur a l’URL et le jeton du site.
|
||||
- Le client ne contacte pas le site. Le serveur vérifie, puis consomme le code au moment de créer l’habitant.
|
||||
- Le lien Discord est enregistré à part. Une création interrompue après `redeemed` peut se terminer sans nouveau code si le compte est encore connu.
|
||||
- [Contrat](docs/access-code-beta144.md).
|
||||
|
||||
## beta.110 — Atelier d’argile inclus
|
||||
|
||||
- Une seule livraison réunit l’atelier d’argile, les statuaires et l’import GLB de beta.106 avec les œufs et la neige saisonnière de beta.107 à beta.109.
|
||||
|
||||
@@ -20,20 +20,14 @@ La [vision complète](docs/vision.md) conserve les intentions ; le
|
||||
L’économie, les claims et les dimensions décrits dans la vision restent
|
||||
à implémenter.
|
||||
|
||||
La version **beta.144** ajoute une option d’**eau naturelle pixélisée** :
|
||||
couleur du biome, trois profils selon le fond, vert olive d’étang et visibilité
|
||||
adaptée en immersion.
|
||||
Les océans gardent leur couleur native et le profil clair.
|
||||
[Contrat et vérifications](docs/natural-water-beta144.md).
|
||||
La beta.144 est publiée et synchronisée dans **Sanctuary Beta** ;
|
||||
les 923 fichiers personnels suivis sont conservés.
|
||||
|
||||
La version **beta.143** garde le sampler natif de précipitation pour la neige
|
||||
saisonnière, mais ralentit son rythme : l'accumulation est espacée par un
|
||||
facteur 12 et la fonte par le facteur temps réel 72. Les protections existantes
|
||||
restent inchangées : neige construite, biomes froids, hiver, profils neigeux
|
||||
et météo Vanilla. Le [ticket](docs/realtime-snow-melt-beta143.md) documente le
|
||||
contrat et les vérifications.
|
||||
La beta.143 est publiée et synchronisée dans l’instance Sanctuary Beta ;
|
||||
les 923 fichiers personnels suivis sont conservés.
|
||||
La [livraison précédente](docs/clay-workshop-integration-beta110.md) réunit déjà
|
||||
l’atelier, les œufs et la neige saisonnière dans l’instance **Sanctuary Beta**.
|
||||
Les [synchronisations antérieures](docs/git-sync-beta092.md) restent documentées.
|
||||
@@ -44,16 +38,6 @@ La [carte native beta.005](docs/atlas-beta005.md) prend le relais après
|
||||
l’étude de Xaero et des alternatives libres.
|
||||
|
||||
|
||||
## beta.144 — Eau naturelle pixélisée
|
||||
|
||||
Eau claire, légèrement teintée ou vert olive d’étang selon le fond, avec
|
||||
textures et animation natives. Océans clairs, visibilité sous-marine adaptée,
|
||||
interrupteur et intensité dans les options Shaders.
|
||||
[Contrat et mesures](docs/natural-water-beta144.md) ·
|
||||
[Pack normal](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.144/Sanctuary-beta.144.mrpack) ·
|
||||
[Pack de test](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.144/Sanctuary-Test-beta.144.mrpack) ·
|
||||
[Tous les livrables](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.144).
|
||||
|
||||
## beta.143 — Neige saisonnière en temps réel
|
||||
|
||||
Accumulation ralentie par un facteur 12 et fonte saisonnière ralentie par le
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
# beta.144 — Code d’accès à la création
|
||||
|
||||
Le site accepte la candidature, whitelist le pseudo et envoie un code `SANC-XXXX-XXXX`.
|
||||
Hello World demande ce code avant de créer le personnage. Le client ne parle pas
|
||||
au site. Seul le serveur appelle l’API, avec le jeton `SANCTUARY_API_TOKEN`.
|
||||
|
||||
Sans ces réglages, Hello World reste celui de beta.143 : biographie, couleur et
|
||||
familier, sans code. Dès que l’URL et le jeton sont présents, un nouveau
|
||||
personnage exige un code reconnu. Un habitant déjà enregistré entre sans écran.
|
||||
|
||||
## Réglage serveur
|
||||
|
||||
Variables d’environnement, ou fichier `config/sanctuary/access.json` si une
|
||||
variable manque :
|
||||
|
||||
```json
|
||||
{"appUrl":"https://exemple.sanctuary","token":"..."}
|
||||
```
|
||||
|
||||
`appUrl` est l’origine du site, sans barre finale. Le serveur appelle
|
||||
`POST {appUrl}/api/v1/access-codes/verify` puis `redeem`. Le jeton n’est pas
|
||||
écrit dans les logs, le client ou le pack. Une URL ou un jeton illisible ferme
|
||||
la création : aucun personnage n’est inventé hors ligne.
|
||||
|
||||
## Parcours
|
||||
|
||||
1. La whitelist laisse passer le pseudo. L’écran s’ouvre tant que l’UUID n’a pas
|
||||
d’habitant.
|
||||
2. Le joueur saisit le code. L’affichage du champ et de l’exemple utilise
|
||||
l’alphabet galactique standard (`minecraft:alt`, la police de la table
|
||||
d’enchantement). La valeur envoyée reste le texte tapé. Un format complet
|
||||
déclenche `verify`, pas chaque frappe. « Code reconnu » n’écrit rien.
|
||||
3. Confirmer envoie le code de la session, la biographie, la couleur et le
|
||||
familier. Le pseudo envoyé au site est celui de la session.
|
||||
4. `redeem` répond `redeemed` avec `discord_id` : le lien est enregistré, puis
|
||||
l’habitant est créé. Les connexions suivantes sautent l’écran.
|
||||
5. Erreur, site injoignable ou jeton refusé : le joueur reste sur Hello World.
|
||||
|
||||
Une déconnexion avant `redeemed` ne consomme pas le code. Si le site a répondu
|
||||
`redeemed` et que l’écriture de l’habitant est interrompue, le lien Discord
|
||||
reste dans `data/sanctuary-access.json` (`pending`, schéma 1, graine du monde).
|
||||
La confirmation suivante termine le personnage sans rappeler `redeem`.
|
||||
|
||||
Si le code est déjà consommé et que le site ne renvoie plus `discord_id`, le
|
||||
joueur reste bloqué avec un message de reprise. Le site doit alors renvoyer
|
||||
`minecraft_username` et `discord_id` pour le même pseudo. Le mod accepte cette
|
||||
réponse, que `valid` soit vrai ou faux, et finit la création.
|
||||
|
||||
Le registre des habitants ne change pas. Le Discord est un fichier à part.
|
||||
Un fichier illisible est conservé et refuse l’accueil.
|
||||
|
||||
## Limites
|
||||
|
||||
Le gel dans le monde n’est pas ajouté : Hello World reste avant l’entrée, comme
|
||||
aujourd’hui. Inventaire, commandes et dimensions ne sont pas accessibles tant
|
||||
que l’habitant n’existe pas. La whitelist RCON reste celle du site. Un pseudo
|
||||
ajouté à la main, sans code, voit l’écran sans pouvoir le valider.
|
||||
@@ -1,209 +0,0 @@
|
||||
# WATER-144 — Eau naturelle pixélisée
|
||||
|
||||
Branche `codex/water-beta144`, socle beta.143 publiée (`97ff2b5`) et suivi
|
||||
`580b241`. Minecraft 26.3, Java 25, dépendances conservées.
|
||||
|
||||
## Contrat
|
||||
|
||||
**Options → Shaders → Eau naturelle** est activée par défaut avec Sanctuary.
|
||||
L’intensité (0–100 %, défaut 100 %) agit sur la surface et l’immersion.
|
||||
L’interrupteur ou 0 % retrouve le rendu précédent. Les préférences existantes
|
||||
restent conservées ; un moteur externe suspend l’effet comme les autres effets
|
||||
natifs Sanctuary.
|
||||
|
||||
La couleur native du biome reste la base, y compris les variantes d’océans.
|
||||
Les océans (`minecraft:is_ocean`) imposent le profil clair.
|
||||
Hors océan, le premier support situé au maximum huit blocs sous la cellule
|
||||
observée choisit le profil :
|
||||
|
||||
| Profil | Fond | Mélange olive #788448 | Alpha natif | Distance de brume native |
|
||||
| --- | --- | ---: | ---: | ---: |
|
||||
| Claire | Pierre, matériaux construits, inconnus | 0 % | ×0,55 | ×1 |
|
||||
| Légèrement teintée | Sable, sable rouge, gravier, argile | 10 % | ×0,80 | ×0,70 |
|
||||
| Étang | Terre, terre stérile/enracinée, herbe, podzol, mycélium, boue, racines de palétuvier boueuses | 85 % | ×1 | ×0,40 |
|
||||
|
||||
L’eau et les plantes aquatiques sans collision sont traversées. Un support
|
||||
immergé reste un fond ; absence de support, air ou données manquantes donnent
|
||||
une eau claire. La référence visuelle fournie pendant l’implémentation demande un véritable
|
||||
vert olive légèrement jaunâtre : le profil étang passe donc de 45 % à 85 %
|
||||
de mélange, tout en laissant les eaux claires et océaniques natives.
|
||||
Les surfaces profondes sans fond dans la limite restent donc
|
||||
claires. Les profils se mélangent aux sommets sur un bloc, sans filtrer les
|
||||
textures. Aux bords océaniques, les sommets partagés restent clairs et la
|
||||
transition occupe le bloc voisin hors océan. L’eau sous un toit suit son fond,
|
||||
pas l’éclairage du bâtiment.
|
||||
|
||||
La ressource client `assets/sanctuary/water_materials.json` accepte deux listes
|
||||
d’identifiants de blocs, `light` et `pond`, remplaçables par un resource pack.
|
||||
Les blocs absents des listes sont clairs. Une ressource invalide est signalée
|
||||
et reprend les familles embarquées. Le template personnel inclut cette ressource.
|
||||
|
||||
## Rendu et coût
|
||||
|
||||
Modification des seules couleurs/alpha des sommets de `FluidRenderer`, sans
|
||||
passe écran ni duplication de maillage. Textures, UV, animation, éclairage
|
||||
et tri des transparences restent natifs. Les calculs de profils sont différés
|
||||
jusqu’à l’émission d’un sommet visible, puis mutualisés dans la région compilée.
|
||||
Un test de palette élimine les sections sans eau avant toute copie de biomes.
|
||||
Les états de blocs viennent des copies natives ; les appartenances océaniques
|
||||
sont capturées avant l’envoi au worker. La présence du terrain est vérifiée dans le cache client avec chargement
|
||||
désactivé, sans utiliser `ClientLevel.hasChunk` (toujours vrai). Aucun
|
||||
chargement de chunk n’est demandé par le classificateur.
|
||||
|
||||
Un changement de bloc invalide la zone dépendante, huit blocs au-dessus et
|
||||
un bloc horizontalement. Les reconstructions natives recopient leurs données ;
|
||||
l’effet ne déclenche aucune reconstruction pour une lumière mobile.
|
||||
Un changement d’option reconstruit seulement les sections chargées contenant de
|
||||
l’eau ; les caches des autres effets restent disponibles. Le cache temporaire
|
||||
disparaît avec sa compilation, sans historique de monde.
|
||||
|
||||
Sous l’eau, le profil est échantillonné au maximum une fois par tick, avec
|
||||
transition de 0,5 seconde. La couleur et les distances environnementales sont
|
||||
adaptées dans le rendu natif du brouillard. La teinte finale est appliquée après
|
||||
la normalisation de la vision aquatique, pour éviter de transformer l’olive en
|
||||
jaune fluorescent ; la luminosité issue des modificateurs natifs de vision
|
||||
et le bonus du compagnon calmar sont conservés. Lave et brume atmosphérique ne changent
|
||||
pas. Il n’y a pas de simulation d’absorption volumétrique ni de reflet ajouté.
|
||||
|
||||
## Validation native
|
||||
|
||||
Les essais utilisent un monde plat jetable, graine 122, avec bassins sur quartz,
|
||||
sable et boue, support du sable, plantes aquatiques, toit, frontière de sections
|
||||
et changement de biome océan. Une copie personnelle de ressource remplace
|
||||
réellement les familles de matériaux puis est désactivée.
|
||||
|
||||
Les suites graphiques finales OpenGL et Vulkan sont réussies le 18 septembre
|
||||
2026 (`WATER144_PASS` et `WATER144_EXTRAS_PASS`). Elles couvrent surface,
|
||||
immersion, fond modifié, priorité océan, toit, plantes, cascades, lave inchangée,
|
||||
blocs gorgés d’eau, préférences FR/EN et rechargement des ressources/chunks.
|
||||
Les sommets partagés aux limites océaniques et le repli clair lorsque le cache
|
||||
client ne contient pas le terrain font l’objet d’assertions supplémentaires.
|
||||
Aucun autre client de test concurrent n’a été détecté pendant ces séries.
|
||||
Deux passages ciblés supplémentaires (`WATER144_TEXTURE_PASS`), Vulkan en
|
||||
1 min 18 s et OpenGL en 1 min 4 s, chargent de vraies textures personnelles
|
||||
32×32 à deux images animées pour l’eau calme et courante. Grille, animation
|
||||
visible et retour aux textures natives sont vérifiés après désactivation.
|
||||
Les fixtures, leurs alpha et les captures restent dans les preuves locales.
|
||||
|
||||
### Protocole de performance
|
||||
|
||||
MacBookPro17,1, Apple M1, 8 Gio, macOS 26.5.1 (25F80), Java 25,
|
||||
fenêtre logique 854×480, cible Retina **1708×960 pixels**, distance de rendu 6 chunks,
|
||||
ombres 32 blocs / 16 pixels, PBR 50 %, autres effets activés.
|
||||
VSync désactivée, limite 260 (illimitée), limitation AFK désactivée.
|
||||
Le mod de test vérifie le limiteur effectif et journalise le mode de présentation.
|
||||
Pendant les échantillons uniquement, il omet l’attente artificielle de
|
||||
`FramerateLimiter.limitDisplayFPS`, afin de mesurer sans temporisation
|
||||
applicative. Cette sonde reste absente du JAR de production. Les temps couvrent
|
||||
la frame native, présentation comprise, et ne sont pas des temps GPU isolés.
|
||||
Pour chaque scène : OFF/ON/ON/OFF/ON/OFF/OFF/ON, dix secondes par passage,
|
||||
avec stabilisation des sections puis quatre secondes de chauffe entre passages.
|
||||
Le déplacement avance de 120 blocs et laisse les sections se compiler
|
||||
naturellement, sans vidange artificielle des caches des autres effets.
|
||||
La scène de déplacement prolonge le bassin hors de la vue initiale : son terrain
|
||||
est préparé côté serveur de test, puis découvert normalement par le client.
|
||||
Le vérificateur exige des compilations réelles dans chaque passage en mouvement.
|
||||
Les moyennes des quatre médianes et P95 par état sont comparées avec les seuils
|
||||
+5 % et +2 ms. Les temps de compilation de sections sont consignés également.
|
||||
|
||||
Les essais courts initiaux, les exécutions concurrentes et les vérifications
|
||||
incomplètes du limiteur restent conservés comme preuves de développement ;
|
||||
ils ne fondent pas ces résultats. Les séries finales ont été exécutées après
|
||||
la fin du chantier SSR. Les variations négatives sont du bruit de mesure,
|
||||
pas une promesse d’accélération. Vulkan reste proche de 120 images/s dans
|
||||
cette chaîne de présentation, malgré le mode `IMMEDIATE` et l’absence de
|
||||
limiteur applicatif : les conclusions sont limitées à la machine et aux
|
||||
conditions ci-dessus.
|
||||
|
||||
| Moteur / scène | Médiane OFF → ON (ms) | Variation | P95 OFF → ON (ms) | Δ P95 (ms) |
|
||||
| --- | --- | ---: | --- | ---: |
|
||||
| vulkan / mixed | 8.069 → 8.092 | +0.29 % | 9.091 → 9.101 | +0.010 |
|
||||
| vulkan / large | 8.078 → 8.062 | -0.20 % | 9.126 → 9.114 | -0.012 |
|
||||
| vulkan / moving | 8.123 → 8.103 | -0.24 % | 9.103 → 9.080 | -0.023 |
|
||||
| opengl / mixed | 4.545 → 4.407 | -3.02 % | 7.080 → 6.733 | -0.347 |
|
||||
| opengl / large | 4.879 → 5.110 | +4.73 % | 7.519 → 7.767 | +0.248 |
|
||||
| opengl / moving | 6.817 → 6.338 | -7.02 % | 10.618 → 9.759 | -0.859 |
|
||||
|
||||
Les six comparaisons respectent les seuils : médiane ≤ +5 %, P95 ≤ +2 ms.
|
||||
Le cas limitant est la grande surface OpenGL (+4,73 %, +0,248 ms).
|
||||
Les compilations de sections sont mesurées sur le parcours en mouvement :
|
||||
|
||||
| Moteur | Sections OFF / ON | Médiane OFF → ON (ms) | P95 OFF → ON (ms) |
|
||||
| --- | ---: | --- | --- |
|
||||
| vulkan | 222 / 220 | 1.889 → 2.186 | 4.307 → 4.898 |
|
||||
| opengl | 222 / 220 | 1.528 → 1.540 | 5.492 → 5.227 |
|
||||
|
||||
Pour reproduire, avec Java 25 et un environnement graphique disponible :
|
||||
|
||||
```sh
|
||||
./gradlew :sanctuary:runClientGameTest -PsanctuaryClientTests=true \
|
||||
-PsanctuaryPixelShadows122ClientTests=true -PsanctuaryWater144ClientTests=true \
|
||||
-PsanctuaryClientNoVsync=true -PsanctuaryQuickTests=true \
|
||||
-PsanctuaryClientGraphicsBackend=vulkan > build/water144-vulkan.log 2>&1
|
||||
python3 scripts/check_water_performance.py build/water144-vulkan.log
|
||||
```
|
||||
|
||||
Répéter avec `opengl`, sans autre client graphique simultané. Le vérificateur
|
||||
refuse les séries incomplètes et les dépassements ; il écrit le reçu détaillé
|
||||
près du journal. Le marqueur `WATER144_PASS` atteste les contrôles fonctionnels.
|
||||
|
||||
### Assemblage et publication
|
||||
|
||||
Le GameTest serveur dédié reste exclu conformément au refus antérieur de son
|
||||
EULA. La commande de livraison est `./gradlew check build assemblePack
|
||||
assembleTestPack -x :sanctuary:runGameTest` ; la suite complète non exclue
|
||||
n’est pas déclarée réussie. Les contrôles natifs de l’eau utilisent le serveur
|
||||
intégré d’un monde jetable. Aucun essai Windows n’est revendiqué.
|
||||
|
||||
L’assemblage avec l’exclusion ci-dessus a réussi en 2 min 33 s (126 tâches).
|
||||
`python3 scripts/pack.py check` réussit. Les cinq archives passent le contrôle
|
||||
ZIP ; les deux MRpack embarquent exactement le même JAR Sanctuary et déclarent
|
||||
Fabric API 0.160.5+26.3 avec l’empreinte du manifeste source. L’export MRpack est
|
||||
exécuté depuis chaque dossier de staging packwiz. Les dépendances imbriquées
|
||||
Demeure et JEI sont vérifiées par l’assemblage natif du dépôt.
|
||||
|
||||
[Release beta.144](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.144) publiée depuis
|
||||
le commit source `83c377acc1b5a4d4137d7a93c397531f77915303` ; tag exact `beta.144`.
|
||||
Canal packwiz stable : `c8e28d878a55703bdc28fc046242b42b2fcfa9f6`.
|
||||
|
||||
Deux synchronisations isolées puis deux synchronisations de l’instance
|
||||
**Sanctuary Beta** ont réussi, jeu et lanceur fermés. Les secondes passes sont
|
||||
idempotentes. Un seul JAR Sanctuary est actif, et son SHA-256 correspond
|
||||
exactement à celui de la release et du manifeste stable.
|
||||
Les **923 fichiers personnels** suivis (sauvegardes, configurations,
|
||||
packs, captures et mods non gérés) ont conservé leurs empreintes. Aucun monde
|
||||
personnel ouvert. Sauvegarde des fichiers remplacés :
|
||||
`sanctuary-backups/before-beta.144/`.
|
||||
|
||||
Les neuf liens publics (cinq livrables, trois captures, manifeste SHA-256)
|
||||
ont été retéléchargés anonymement et leurs empreintes comparées. Les reçus de
|
||||
publication, de téléchargement et des quatre synchronisations restent dans
|
||||
`build/water144-publish.log`, `build/water144-public-downloads.json`,
|
||||
`build/water144-isolated.json` et `build/water144-prism.json`.
|
||||
Le checkout initial beta.122 et ses modifications restent préservés.
|
||||
Les sources sont sur `codex/water-beta144`, dans le worktree isolé.
|
||||
Les archives vérifiées sont aussi copiées dans `sanctuary-beta/build/`.
|
||||
Les preuves détaillées restent dans les dossiers ignorés :
|
||||
`build/water144-*.log`, `build/water144-*-performance.json`,
|
||||
`build/water144-*-isolation.json` et `build/water144-evidence/`.
|
||||
|
||||
## Artefacts vérifiés
|
||||
|
||||
| Artefact | Octets | SHA-256 |
|
||||
| --- | ---: | --- |
|
||||
| [sanctuary-beta.144.jar](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.144/sanctuary-beta.144.jar) | 11343257 | `1ddbe941f3262da8a1497ea5df4a992e260b80ae95b341623dd68f8f9729ae88` |
|
||||
| [Sanctuary-beta.144.mrpack](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.144/Sanctuary-beta.144.mrpack) | 10483196 | `d5ef5a781f5ee796117210d1f6afc96172461315faee34097323beff541ce6f0` |
|
||||
| [Sanctuary-Test-beta.144.mrpack](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.144/Sanctuary-Test-beta.144.mrpack) | 10502119 | `e7cc32ac19ae98ebbe7a9aac3dc08decdf09396c8175b597f87051399ff3812e` |
|
||||
| [Sanctuary-Template-beta.144.zip](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.144/Sanctuary-Template-beta.144.zip) | 4354678 | `e510c0040dc9053492715ac91381b7dbc5a6f7c7defbf81e7502d8e18dbb0ee6` |
|
||||
| [Sanctuary-Prism-auto-update.zip](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.144/Sanctuary-Prism-auto-update.zip) | 92423 | `c0920823f40d709c14e074d8d949f2cc3681c24140bf86f21c3be345cfc3e1f6` |
|
||||
|
||||
## Captures comparatives
|
||||
|
||||
Pièces jointes de la release, issues du client Vulkan de validation :
|
||||
[OFF](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.144/Sanctuary-beta.144-water-off.png),
|
||||
[ON](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.144/Sanctuary-beta.144-water-on.png),
|
||||
[profil étang en proximité](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.144/Sanctuary-beta.144-water-pond.png).
|
||||
Les captures sont exportées à 854×480 ; la cible de présentation mesurée est
|
||||
1708×960. Le [manifeste SHA-256](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.144/Sanctuary-beta.144-SHA256SUMS.txt) inclut les cinq
|
||||
livrables et ces trois captures. Les journaux et échantillons détaillés restent
|
||||
locaux, dans `build/water144-evidence/` et les reçus `build/water144-*.json`.
|
||||
@@ -14,20 +14,6 @@ JAR Sanctuary sont des pièces jointes de releases Gitea. Aucun JAR de mod ou
|
||||
dépendance téléchargée n'entre dans l'historique Git. Le canal Beta est distinct
|
||||
de l'ancien pack 26.2.
|
||||
|
||||
## beta.144 — Eau naturelle pixélisée
|
||||
|
||||
Trois profils selon le fond, teinte olive d’étang, océans natifs clairs et
|
||||
brume aquatique adaptée. [Contrat, captures et mesures](natural-water-beta144.md).
|
||||
[Release beta.144](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.144)
|
||||
publiée depuis `83c377acc1b5a4d4137d7a93c397531f77915303` ; canal
|
||||
`c8e28d878a55703bdc28fc046242b42b2fcfa9f6`. Deux synchronisations isolées puis deux
|
||||
dans l’instance Sanctuary Beta réussies et idempotentes. Un seul JAR Sanctuary
|
||||
actif, identique à l’artefact public. Les 923 fichiers personnels suivis gardent
|
||||
leurs empreintes ; aucun monde personnel ouvert. Sauvegarde préalable :
|
||||
`sanctuary-backups/before-beta.144/`. Les cinq livrables et leurs SHA-256 sont
|
||||
vérifiés dans `sanctuary-beta/build/`. Textures beta.121, Minecraft 26.3 et
|
||||
dépendances exactes conservés.
|
||||
|
||||
## beta.143 — Neige saisonnière en temps réel
|
||||
|
||||
Accumulation espacée par un facteur 12 et fonte par le facteur temps réel 72,
|
||||
|
||||
@@ -107,9 +107,3 @@ Les contrôles ne revendiquent pas un essai avec Iris ou un autre moteur externe
|
||||
Les ombres portées pixélisées sont livrées dans [beta.127](shader-beta127.md),
|
||||
intégrées sur la beta.126. Leur préparation locale portait un numéro beta.122
|
||||
non publié ; la release beta.122 existante n'est pas remplacée.
|
||||
|
||||
## beta.144 — Eau naturelle
|
||||
|
||||
Le [ticket WATER-144](natural-water-beta144.md) ajoute les profils clair,
|
||||
légèrement teinté et étang au rendu natif des fluides et à leur brouillard.
|
||||
La couleur du biome et les textures pixélisées restent la base.
|
||||
|
||||
@@ -34,9 +34,6 @@ if (clientRenderTests) {
|
||||
if (improvedTransparency.isPresent()) {
|
||||
systemProperty('sanctuary.test.improvedTransparency', improvedTransparency.get())
|
||||
}
|
||||
systemProperty('sanctuary.test.water144', providers.gradleProperty('sanctuaryWater144ClientTests').getOrElse('false'))
|
||||
systemProperty('sanctuary.test.water144ExtrasOnly', providers.gradleProperty('sanctuaryWater144ExtrasOnly').getOrElse('false'))
|
||||
systemProperty('sanctuary.test.water144PerfOnly', providers.gradleProperty('sanctuaryWater144PerfOnly').getOrElse('false'))
|
||||
systemProperty('sanctuary.test.pbr142', providers.gradleProperty('sanctuaryPbr142ClientTests').getOrElse('false'))
|
||||
systemProperty('sanctuary.test.directional141Only', providers.gradleProperty('sanctuaryDirectional141Only').getOrElse('false'))
|
||||
systemProperty('sanctuary.test.directional141', providers.gradleProperty('sanctuaryDirectional141ClientTests').getOrElse('false'))
|
||||
@@ -1140,6 +1137,14 @@ tasks.register('intro031Smoke', JavaExec) {
|
||||
}
|
||||
tasks.named('check') { dependsOn('intro031Smoke') }
|
||||
|
||||
tasks.register('access144Smoke', JavaExec) {
|
||||
group = 'verification'
|
||||
dependsOn('testClasses')
|
||||
classpath = sourceSets.test.runtimeClasspath
|
||||
mainClass = 'fr.koka.sanctuary.progression.AccessCodeSmoke'
|
||||
}
|
||||
tasks.named('check') { dependsOn('access144Smoke') }
|
||||
|
||||
if (providers.gradleProperty('sanctuaryQuickTests').map { it.toBoolean() }.getOrElse(false)) {
|
||||
dependencies { gametestRuntimeOnly project(':sanctuary-test') }
|
||||
}
|
||||
|
||||
+1
-2
@@ -151,7 +151,6 @@ public final class PixelShadows122ClientChecks implements FabricClientGameTest {
|
||||
c.runOnClient(m -> check(VanillaLight.active(), "Native Sanctuary shader is active without an external engine"));
|
||||
float morning = c.computeOnClient(m -> m.gameRenderer.gameRenderState().levelRenderState.skyRenderState.sunAngle);
|
||||
|
||||
if(Boolean.getBoolean("sanctuary.test.water144")){Water144ClientChecks.run(c,server);return;}
|
||||
if(coloredOnly){if(Boolean.getBoolean("sanctuary.test.directional141Only")){DirectionalLights141ClientChecks.run(c,server);return;}ColoredLights139ClientChecks.run(c,server);if(Boolean.getBoolean("sanctuary.test.colored140"))ColoredLights140ClientChecks.run(c,server);if(Boolean.getBoolean("sanctuary.test.directional141"))DirectionalLights141ClientChecks.run(c,server);return;}
|
||||
if(sunshaftOnly){Sunshafts137ClientChecks.run(c,server);return;}
|
||||
if(closeupOnly){SsgiCloseup135ClientChecks.run(c,server);Pbr134ClientChecks.run(c,server);return;}
|
||||
@@ -569,7 +568,7 @@ public final class PixelShadows122ClientChecks implements FabricClientGameTest {
|
||||
}
|
||||
|
||||
static Frame capture(ClientGameTestContext c, String name) {
|
||||
if (name != null) c.takeScreenshot((Boolean.getBoolean("sanctuary.test.water144")?"sanctuary-beta144-water-":"sanctuary-beta142-shader-") + name);
|
||||
if (name != null) c.takeScreenshot("sanctuary-beta142-shader-" + name);
|
||||
var result = new CompletableFuture<Frame>();
|
||||
c.runOnClient(m -> Screenshot.takeScreenshot(m.gameRenderer.mainRenderTarget(), image -> {
|
||||
try (image) {
|
||||
|
||||
-215
@@ -1,215 +0,0 @@
|
||||
package fr.koka.sanctuary.client.shader;
|
||||
import static fr.koka.sanctuary.client.shader.PixelShadows122ClientChecks.*;
|
||||
import fr.koka.sanctuary.gametest.Water144Metrics;
|
||||
import net.fabricmc.fabric.api.client.gametest.v1.context.*;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraft.world.level.block.Blocks;
|
||||
import java.nio.file.*;
|
||||
import java.util.*;
|
||||
|
||||
final class Water144ClientChecks {
|
||||
static void run(ClientGameTestContext c,TestServerContext server){
|
||||
c.runOnClient(m->{
|
||||
VanillaLight.setEnabled(true);VanillaLight.setNaturalWater(true);VanillaLight.setWaterIntensity(100);
|
||||
VanillaLight.setPbr(false);VanillaLight.setSsgi(false);VanillaLight.setBloom(false);VanillaLight.setOreEmission(false);
|
||||
VanillaLight.setPixelShadows(false);VanillaLight.setColoredLights(false);VanillaLight.setSunshafts(false);VanillaLight.setLensFlare(false);VanillaLight.setEdgeHighlights(false);
|
||||
});
|
||||
server.runOnServer(s->{
|
||||
command(s,"fill 47 94 -1 96 95 33 quartz_block");
|
||||
command(s,"fill 47 96 -1 47 100 33 glass");command(s,"fill 96 96 -1 96 100 33 glass");
|
||||
command(s,"fill 47 96 -1 96 100 -1 glass");command(s,"fill 47 96 33 96 100 33 glass");
|
||||
command(s,"fill 64 95 0 79 95 32 sand");command(s,"fill 80 95 0 95 95 32 mud");
|
||||
command(s,"fill 48 96 0 95 99 32 water");
|
||||
command(s,"setblock 84 96 8 seagrass");
|
||||
for(int x=50;x<95;x+=4)command(s,"setblock "+x+" 96 20 gold_block");
|
||||
});
|
||||
camera(c,server,72,125,16,70);c.waitTicks(50);settle(c);
|
||||
if(Boolean.getBoolean("sanctuary.test.water144ExtrasOnly")){
|
||||
resourceOverride(c);
|
||||
camera(c,server,88.5,96.2,8.5,70);look(c,server,0,0);c.waitTicks(20);capture(c,"pond-final-fog");
|
||||
var green=c.computeOnClient(m->new org.joml.Vector4f(m.gameRenderer.gameRenderState().levelRenderState.cameraRenderState.fogData.color));
|
||||
enable(c,false);var blue=c.computeOnClient(m->new org.joml.Vector4f(m.gameRenderer.gameRenderState().levelRenderState.cameraRenderState.fogData.color));capture(c,"native-final-fog");
|
||||
check(green.y>green.z&&green.y<Math.max(blue.x,Math.max(blue.y,blue.z))*.8,"Olive fog stays muted after native vision adaptation");
|
||||
enable(c,true);extras(c,server);return;
|
||||
}
|
||||
if(!Boolean.getBoolean("sanctuary.test.water144PerfOnly")){
|
||||
c.runOnClient(m->{
|
||||
check(NaturalWater.sample(m.level,56.5,99.1,8.5).equals(NaturalWater.CLEAR),"Constructed bed clear");
|
||||
check(NaturalWater.sample(m.level,72.5,99.1,8.5).equals(NaturalWater.LIGHT),"Sand lightly tinted");
|
||||
check(NaturalWater.sample(m.level,88.5,99.1,8.5).equals(NaturalWater.POND),"Mud pond");
|
||||
check(NaturalWater.sample(m.level,84.5,99.1,8.5).equals(NaturalWater.POND),"Seagrass is not a floor");
|
||||
check(NaturalWater.sample(m.level,88.5,110.1,8.5).equals(NaturalWater.CLEAR),"No bed in bounded search uses clear");
|
||||
var unloaded=new BlockPos(1_000_016,96,1_000_016);
|
||||
check(!NaturalWater.loaded(m.level,unloaded.getX()>>4,unloaded.getZ()>>4),"Fixture region is unloaded");
|
||||
var missing=new NaturalWater.Region(m.level,62500,5,62500);
|
||||
check(missing.corner(null,unloaded,0,0).equals(NaturalWater.CLEAR),"Unavailable biome snapshot forces clear without reading missing terrain");
|
||||
check(!NaturalWater.loaded(m.level,unloaded.getX()>>4,unloaded.getZ()>>4),"Classification does not load chunks");
|
||||
var boundary=NaturalWater.sample(m.level,80,99.1,8.5);check(boundary.tint()>.1&&boundary.tint()<.85,"Section boundary mixes profiles");
|
||||
});
|
||||
enable(c,false);var off=capture(c,"surface-off");enable(c,true);var on=capture(c,"surface-on");
|
||||
check(difference(off,on)>100000,"Visible surface colour and opacity change");
|
||||
c.runOnClient(m->VanillaLight.setWaterIntensity(0));settle(c);var zero=capture(c,"surface-zero");
|
||||
check(difference(off,zero)<difference(off,on)/4,"Zero restores native water (animated texture tolerance)");
|
||||
c.runOnClient(m->VanillaLight.setWaterIntensity(100));settle(c);
|
||||
camera(c,server,88.5,103,4.5,70);look(c,server,0,28);capture(c,"pond-closeup");
|
||||
camera(c,server,72,125,16,70);
|
||||
server.runOnServer(s->command(s,"fill 80 95 0 95 95 32 quartz_block"));c.waitTicks(30);settle(c);var changed=capture(c,"bed-changed");
|
||||
check(difference(changed,on)>100000,"Changing bed four blocks below surface refreshes water");
|
||||
server.runOnServer(s->command(s,"fill 80 95 0 95 95 32 mud"));c.waitTicks(25);settle(c);
|
||||
server.runOnServer(s->command(s,"fill 80 103 0 95 103 12 stone"));c.waitTicks(25);settle(c);
|
||||
c.runOnClient(m->check(NaturalWater.sample(m.level,88.5,99.1,8.5).equals(NaturalWater.POND),"Roof does not replace floor rule"));capture(c,"covered-pond");
|
||||
server.runOnServer(s->command(s,"fill 80 103 0 95 103 12 air"));c.waitTicks(20);
|
||||
for(int x:new int[]{56,72,88}){
|
||||
camera(c,server,x+.5,96.2,8.5,70);look(c,server,0,0);c.waitTicks(20);capture(c,"immersed-"+x);
|
||||
c.runOnClient(m->{check(m.gameRenderer.mainCamera().getFluidInCamera()==net.minecraft.world.level.material.FogType.WATER,"Camera immersed");
|
||||
float expected=x==56?1:x==72?.7f:.4f;check(Math.abs(NaturalWater.fogProfile().fog()-expected)<.001,"Immersion uses corresponding profile");});
|
||||
}
|
||||
var pondColor=c.computeOnClient(m->new org.joml.Vector4f(m.gameRenderer.gameRenderState().levelRenderState.cameraRenderState.fogData.color));
|
||||
check(pondColor.y>pondColor.z&&pondColor.y<.8f,"Olive fog avoids native neon normalization");
|
||||
float pondFog=c.computeOnClient(m->m.gameRenderer.gameRenderState().levelRenderState.cameraRenderState.fogData.environmentalEnd);
|
||||
enable(c,false);float nativeFog=c.computeOnClient(m->m.gameRenderer.gameRenderState().levelRenderState.cameraRenderState.fogData.environmentalEnd);
|
||||
check(Math.abs(pondFog/nativeFog-.4)<.01,"Rendered underwater fog distance matches pond profile");capture(c,"immersed-native");enable(c,true);
|
||||
camera(c,server,72,125,16,70);
|
||||
server.runOnServer(s->command(s,"fillbiome 80 88 0 95 104 32 minecraft:ocean"));c.waitTicks(35);settle(c);
|
||||
c.runOnClient(m->{
|
||||
check(NaturalWater.sample(m.level,88.5,99.1,8.5).equals(NaturalWater.CLEAR),"Ocean overrides muddy bed");
|
||||
boolean found=false;
|
||||
for(int x=72;x<89;x++){
|
||||
var p=new BlockPos(x,99,8);
|
||||
if(!NaturalWater.ocean(m.level,p)&&NaturalWater.ocean(m.level,p.east())){
|
||||
var left=new NaturalWater.Region(m.level,(x>>4)-1,5,-1);
|
||||
var right=new NaturalWater.Region(m.level,((x+1)>>4)-1,5,-1);
|
||||
check(left.corner(null,p,1,0).equals(right.corner(null,p.east(),0,0)),"Ocean edge emits identical shared vertices");
|
||||
found=true;break;
|
||||
}
|
||||
}
|
||||
check(found,"Native ocean boundary exercised");
|
||||
});capture(c,"ocean-over-mud");
|
||||
server.runOnServer(s->command(s,"fillbiome 80 88 0 95 104 32 minecraft:plains"));c.waitTicks(30);
|
||||
var reload=c.computeOnClient(net.minecraft.client.Minecraft::reloadResourcePacks);c.waitFor(m->reload.isDone()&&m.gui.overlay()==null,1200);reload.join();settle(c);capture(c,"resources-reloaded");
|
||||
resourceOverride(c);
|
||||
camera(c,server,400,120,16,70);c.waitTicks(30);camera(c,server,72,125,16,70);settle(c);
|
||||
c.runOnClient(m->{check(NaturalWater.sample(m.level,88.5,99.1,8.5).equals(NaturalWater.POND),"Reloaded chunks keep correct profile");
|
||||
VanillaLight.setNaturalWater(false);VanillaLight.setWaterIntensity(37);forgetPreferences();check(!VanillaLight.naturalWater()&&VanillaLight.waterIntensity()==37,"Water preferences persist");
|
||||
VanillaLight.setNaturalWater(true);VanillaLight.setWaterIntensity(100);
|
||||
for(String locale:new String[]{"fr_fr","en_us"}){m.getLanguageManager().setSelected(locale);m.getLanguageManager().onResourceManagerReload(m.getResourceManager());
|
||||
for(String key:new String[]{"natural_water","natural_water_help","water_intensity","water_intensity_help","water_intensity_value"}){String full="sanctuary.shaders."+key;check(!Component.translatable(full,100).getString().equals(full),"FR/EN "+full);}}
|
||||
});settle(c);
|
||||
}
|
||||
// All effects enabled; fixed inherited test quality (shadows 32 blocks, PBR 50%) for A/B comparison.
|
||||
c.runOnClient(m->{VanillaLight.setPbr(true);VanillaLight.setSsgi(true);VanillaLight.setBloom(true);VanillaLight.setOreEmission(true);
|
||||
VanillaLight.setPixelShadows(true);VanillaLight.setColoredLights(true);VanillaLight.setSunshafts(true);VanillaLight.setLensFlare(true);VanillaLight.setEdgeHighlights(true);});
|
||||
c.waitTicks(80);capture(c,"combined");
|
||||
benchmark(c,server);
|
||||
extras(c,server);
|
||||
System.out.println("WATER144_PASS surface, immersion, beds, ocean, section seam, roof, plants, invalidation, reload, preferences, FR/EN and combined rendering");
|
||||
}
|
||||
private static void enable(ClientGameTestContext c,boolean value){c.runOnClient(m->VanillaLight.setNaturalWater(value));settle(c);}
|
||||
private static void settle(ClientGameTestContext c){c.waitTicks(8);c.waitFor(m->m.levelRenderer.hasRenderedAllSections(),1200);c.waitTicks(20);}
|
||||
private static long difference(Frame a,Frame b){long value=0;for(int i=0;i<a.pixels().length;i++)value+=channelDifference(a.pixels()[i],b.pixels()[i]);return value;}
|
||||
private static void extras(ClientGameTestContext c,TestServerContext server){
|
||||
server.runOnServer(s->{
|
||||
for(int x:new int[]{100,108}){
|
||||
command(s,"fill "+x+" 95 0 "+(x+4)+" 104 16 glass");
|
||||
command(s,"fill "+(x+1)+" 96 1 "+(x+3)+" 103 15 air");
|
||||
command(s,"fill "+(x+1)+" 95 1 "+(x+3)+" 95 15 mud");
|
||||
}
|
||||
command(s,"setblock 102 102 2 water");command(s,"setblock 110 102 2 lava");
|
||||
command(s,"setblock 56 99 8 oak_slab[waterlogged=true]");
|
||||
});
|
||||
c.waitTicks(80);camera(c,server,106,103,25,70);look(c,server,179,20);settle(c);
|
||||
c.runOnClient(m->{
|
||||
var flow=m.level.getFluidState(new BlockPos(102,101,2));check(flow.is(net.minecraft.tags.FluidTags.WATER)&&!flow.isSource(),"Real falling water is rendered");
|
||||
check(m.level.getFluidState(new BlockPos(110,101,2)).is(net.minecraft.tags.FluidTags.LAVA),"Lava cascade remains lava");
|
||||
check(m.level.getBlockState(new BlockPos(56,99,8)).getValue(net.minecraft.world.level.block.state.properties.BlockStateProperties.WATERLOGGED),"Waterlogged slab preserved");
|
||||
});
|
||||
enable(c,false);var off=capture(c,"cascades-off");enable(c,true);var on=capture(c,"cascades-on");
|
||||
check(difference(off,on)>1000,"Waterfall responds to water option");
|
||||
int lava=0,stable=0;for(int i=0;i<off.pixels().length;i++){int a=off.pixels()[i];int r=a>>16&255,g=a>>8&255,b=a&255;
|
||||
if(r>160&&r>g*1.3&&b<90){lava++;if(channelDifference(a,on.pixels()[i])<70)stable++;}}
|
||||
check(lava>100&&stable>lava*.8,"Lava colours remain native (animation tolerance)");
|
||||
camera(c,server,56.5,102,8.5,70);capture(c,"waterlogged-slab");
|
||||
System.out.println("WATER144_EXTRAS_PASS falling water, unchanged lava and waterlogged slab");
|
||||
}
|
||||
private static net.minecraft.client.renderer.texture.SpriteContents waterSprite(net.minecraft.client.Minecraft m,String name){
|
||||
return m.getAtlasManager().getAtlasOrThrow(net.minecraft.data.AtlasIds.BLOCKS)
|
||||
.getSprite(net.minecraft.resources.Identifier.withDefaultNamespace("block/"+name)).contents();
|
||||
}
|
||||
private static void resourceOverride(ClientGameTestContext c){
|
||||
int originalWidth=c.computeOnClient(m->waterSprite(m,"water_still").width());
|
||||
int originalFrames=c.computeOnClient(m->waterSprite(m,"water_still").getUniqueFrames().size());
|
||||
var directory=c.computeOnClient(m->m.getResourcePackDirectory().resolve("water144-fixture"));
|
||||
try{
|
||||
Files.createDirectories(directory.resolve("assets/sanctuary"));
|
||||
var mod=net.fabricmc.loader.api.FabricLoader.getInstance().getModContainer("sanctuary").orElseThrow();
|
||||
Files.copy(mod.findPath("resourcepacks/template/pack.mcmeta").orElseThrow(),directory.resolve("pack.mcmeta"),StandardCopyOption.REPLACE_EXISTING);
|
||||
var textures=directory.resolve("assets/minecraft/textures/block");Files.createDirectories(textures);
|
||||
try(var pixels=new com.mojang.blaze3d.platform.NativeImage(32,64,false)){
|
||||
for(int y=0;y<64;y++)for(int x=0;x<32;x++)pixels.setPixel(x,y,((x/4+y/4+y/32)&1)==0?0xd0ffffff:0x80404040);
|
||||
for(String name:new String[]{"water_still","water_flow"}){
|
||||
pixels.writeToFile(textures.resolve(name+".png"));
|
||||
Files.writeString(textures.resolve(name+".png.mcmeta"),"{\"animation\":{\"frametime\":4,\"frames\":[0,1]}}");
|
||||
}
|
||||
}
|
||||
Files.writeString(directory.resolve("assets/sanctuary/water_materials.json"),"{\"light\":[],\"pond\":[\"minecraft:quartz_block\"]}");
|
||||
}catch(Exception e){throw new AssertionError(e);}
|
||||
c.runOnClient(m->{var repo=m.getResourcePackRepository();repo.reload();check(repo.addPack("file/water144-fixture"),"Custom materials pack discovered");});
|
||||
var future=c.computeOnClient(net.minecraft.client.Minecraft::reloadResourcePacks);c.waitFor(m->future.isDone()&&m.gui.overlay()==null,1200);future.join();settle(c);
|
||||
c.runOnClient(m->{check(NaturalWater.sample(m.level,56.5,99.1,8.5).equals(NaturalWater.POND),"Resource pack replaces material family");
|
||||
check(NaturalWater.sample(m.level,88.5,99.1,8.5).equals(NaturalWater.CLEAR),"Replacement removes bundled assignment");});
|
||||
c.runOnClient(m->{for(String name:new String[]{"water_still","water_flow"}){
|
||||
var sprite=waterSprite(m,name);check(sprite.width()==32&&sprite.height()==32&&sprite.getUniqueFrames().size()==2,"Custom pixel grid and animation loaded: "+name);
|
||||
}});
|
||||
var first=capture(c,"custom-materials");long animated=0;
|
||||
for(int i=0;i<8;i++){c.waitTicks(1);animated=Math.max(animated,difference(first,capture(c,"custom-animation-"+i)));}
|
||||
check(animated>100000,"Custom water animation remains visible");
|
||||
c.runOnClient(m->m.getResourcePackRepository().removePack("file/water144-fixture"));
|
||||
var restore=c.computeOnClient(net.minecraft.client.Minecraft::reloadResourcePacks);c.waitFor(m->restore.isDone()&&m.gui.overlay()==null,1200);restore.join();settle(c);
|
||||
c.runOnClient(m->{
|
||||
check(NaturalWater.sample(m.level,88.5,99.1,8.5).equals(NaturalWater.POND),"Disabling custom pack restores defaults");
|
||||
check(waterSprite(m,"water_still").width()==originalWidth&&waterSprite(m,"water_still").getUniqueFrames().size()==originalFrames,"Native water texture restored");
|
||||
});
|
||||
System.out.println("WATER144_TEXTURE_PASS custom 32px still/flow textures, alpha, two animation frames and restoration");
|
||||
}
|
||||
private static void benchmark(ClientGameTestContext c,TestServerContext s){
|
||||
int oldLimit=c.computeOnClient(m->m.options.framerateLimit().get());
|
||||
var oldInactivity=c.computeOnClient(m->m.options.inactivityFpsLimit().get());
|
||||
c.runOnClient(m->{m.options.framerateLimit().set(net.minecraft.client.Options.UNLIMITED_FRAMERATE_CUTOFF);m.options.inactivityFpsLimit().set(net.minecraft.client.InactivityFpsLimit.MINIMIZED);m.getFramerateLimitTracker().setFramerateLimit(net.minecraft.client.Options.UNLIMITED_FRAMERATE_CUTOFF);});
|
||||
var results=new LinkedHashMap<String,Object>();
|
||||
try {
|
||||
for(String scene:new String[]{"mixed","large","moving"}){
|
||||
if(scene.equals("large")){s.runOnServer(server->{command(server,"fill 31 94 -17 112 100 -17 glass");command(server,"fill 31 94 48 112 100 48 glass");
|
||||
command(server,"fill 31 94 -17 31 100 48 glass");command(server,"fill 112 94 -17 112 100 48 glass");
|
||||
command(server,"fill 32 93 -16 111 93 47 quartz_block");command(server,"fill 32 94 -16 111 94 47 sand");command(server,"fill 32 95 -16 111 99 47 water");});c.waitTicks(50);}
|
||||
if(scene.equals("moving")){
|
||||
// Test fixture only: prepare water beyond the initial client view so movement really compiles terrain.
|
||||
s.runOnServer(server->{
|
||||
command(server,"forceload add 112 -17 288 48");
|
||||
for(int x=7;x<=18;x++)for(int z=-2;z<=3;z++)server.overworld().getChunk(x,z);
|
||||
command(server,"fill 112 94 -17 288 100 -17 glass");command(server,"fill 112 94 48 288 100 48 glass");
|
||||
command(server,"fill 288 94 -17 288 100 48 glass");
|
||||
for(int x=112;x<288;x+=16){
|
||||
command(server,"fill "+x+" 93 -16 "+(x+15)+" 93 47 quartz_block");
|
||||
command(server,"fill "+x+" 94 -16 "+(x+15)+" 94 47 sand");
|
||||
command(server,"fill "+x+" 95 -16 "+(x+15)+" 99 47 water");
|
||||
}
|
||||
});c.waitTicks(80);
|
||||
}
|
||||
camera(c,s,72,125,16,70);settle(c);
|
||||
for(boolean enabled:new boolean[]{false,true,true,false,true,false,false,true}){
|
||||
camera(c,s,72,125,16,70);enable(c,enabled);c.waitTicks(80);c.runOnClient(m->{
|
||||
check(m.getFramerateLimitTracker().getFramerateLimit()==260&&!m.options.enableVsync().get(),"Uncapped native limiter and VSync disabled");
|
||||
System.out.println("WATER144_CONDITIONS limit="+m.getFramerateLimitTracker().getFramerateLimit()+" throttle="+m.getFramerateLimitTracker().getThrottleReason()+" renderLimit="+m.gameRenderer.gameRenderState().framerateLimit+" surface="+m.windowSurface().currentConfiguration()+" limiterBypass=true");
|
||||
Water144Metrics.start();});
|
||||
if(scene.equals("moving")){
|
||||
for(int i=0;i<200;i++){final int step=i;c.runOnClient(m->{m.player.setPos(48+step*.6,125,16);m.player.setDeltaMovement(net.minecraft.world.phys.Vec3.ZERO);});c.waitTicks(1);}
|
||||
}else c.waitTicks(200);
|
||||
var values=c.computeOnClient(m->Water144Metrics.stop());String key=scene+"-"+enabled+"-"+results.size();results.put(key,values);
|
||||
System.out.println("WATER144_PERF "+key+" "+Arrays.toString(values));
|
||||
}
|
||||
}
|
||||
try{Files.writeString(Path.of("water144-performance.json"),new com.google.gson.GsonBuilder().setPrettyPrinting().create().toJson(results));}catch(Exception e){throw new AssertionError(e);}
|
||||
}finally{c.runOnClient(m->{m.options.framerateLimit().set(oldLimit);m.getFramerateLimitTracker().setFramerateLimit(oldLimit);m.options.inactivityFpsLimit().set(oldInactivity);});}
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
package fr.koka.sanctuary.gametest;
|
||||
import java.util.*;
|
||||
public final class Water144Metrics {
|
||||
public static volatile boolean recording;
|
||||
private static final List<Long> frames=new ArrayList<>(),sections=new ArrayList<>();
|
||||
public static synchronized void frame(long ns){if(recording)frames.add(ns);}
|
||||
public static synchronized void section(long ns){if(recording)sections.add(ns);}
|
||||
public static synchronized void start(){frames.clear();sections.clear();recording=true;}
|
||||
public static synchronized double[] stop(){recording=false;return new double[]{frames.size(),quantile(frames,.5),quantile(frames,.95),sections.size(),quantile(sections,.5),quantile(sections,.95)};}
|
||||
private static double quantile(List<Long> values,double q){if(values.isEmpty())return 0;var sorted=values.stream().mapToLong(Long::longValue).sorted().toArray();return sorted[Math.min(sorted.length-1,(int)(sorted.length*q))]/1e6;}
|
||||
}
|
||||
-17
@@ -1,17 +0,0 @@
|
||||
package fr.koka.sanctuary.gametest.mixin;
|
||||
import fr.koka.sanctuary.gametest.Water144Metrics;
|
||||
import com.llamalad7.mixinextras.injector.wrapoperation.*;
|
||||
import net.minecraft.client.Minecraft;
|
||||
import org.spongepowered.asm.mixin.*;
|
||||
import org.spongepowered.asm.mixin.injection.*;
|
||||
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
|
||||
@Mixin(Minecraft.class)
|
||||
abstract class Water144FrameProbeMixin {
|
||||
@Unique private long sanctuary$start;
|
||||
// Benchmark only: the extracted frame state can retain a 120 FPS limit despite the option/tracker.
|
||||
@WrapOperation(method="renderFrame",at=@At(value="INVOKE",target="Lnet/minecraft/client/FramerateLimiter;limitDisplayFPS(I)V"))
|
||||
private void sanctuary$uncapped(int fps,Operation<Void> original){if(!Water144Metrics.recording)original.call(fps);}
|
||||
|
||||
@Inject(method="renderFrame",at=@At("HEAD")) private void start(boolean tick,CallbackInfo ci){if(Water144Metrics.recording)sanctuary$start=System.nanoTime();}
|
||||
@Inject(method="renderFrame",at=@At("RETURN")) private void end(boolean tick,CallbackInfo ci){if(Water144Metrics.recording)Water144Metrics.frame(System.nanoTime()-sanctuary$start);}
|
||||
}
|
||||
-17
@@ -1,17 +0,0 @@
|
||||
package fr.koka.sanctuary.gametest.mixin;
|
||||
import fr.koka.sanctuary.gametest.Water144Metrics;
|
||||
import com.llamalad7.mixinextras.injector.wrapmethod.WrapMethod;
|
||||
import com.llamalad7.mixinextras.injector.wrapoperation.Operation;
|
||||
import net.minecraft.client.renderer.chunk.*;
|
||||
import net.minecraft.client.renderer.SectionBufferBuilderPack;
|
||||
import net.minecraft.core.SectionPos;
|
||||
import com.mojang.blaze3d.vertex.VertexSorting;
|
||||
import org.spongepowered.asm.mixin.Mixin;
|
||||
@Mixin(SectionCompiler.class)
|
||||
abstract class Water144SectionProbeMixin {
|
||||
@WrapMethod(method="compile")
|
||||
private SectionCompiler.Results compile(SectionPos pos,RenderSectionRegion region,VertexSorting sorting,SectionBufferBuilderPack buffers,Operation<SectionCompiler.Results> original){
|
||||
boolean record=Water144Metrics.recording;long start=record?System.nanoTime():0;
|
||||
try{return original.call(pos,region,sorting,buffers);}finally{if(record)Water144Metrics.section(System.nanoTime()-start);}
|
||||
}
|
||||
}
|
||||
@@ -19,8 +19,6 @@
|
||||
"Atlas026TextureProbeMixin",
|
||||
"Inventory013MerchantAccess",
|
||||
"Dynamic030TerrainProbeMixin",
|
||||
"Star040RenderProbeMixin",
|
||||
"Water144FrameProbeMixin",
|
||||
"Water144SectionProbeMixin"
|
||||
"Star040RenderProbeMixin"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -12,13 +12,20 @@ import net.minecraft.client.gui.screens.Screen;
|
||||
import net.minecraft.client.renderer.RenderPipelines;
|
||||
import net.minecraft.resources.Identifier;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraft.network.chat.FontDescription;
|
||||
import net.minecraft.network.chat.Style;
|
||||
import net.minecraft.util.FormattedCharSequence;
|
||||
import net.minecraft.ChatFormatting;
|
||||
|
||||
/** Configuration-stage identity and starter slots. Closing cancels connection, never the saved offer. */
|
||||
public final class HelloWorldScreen extends Screen {
|
||||
private static final FontDescription GALACTIC = new FontDescription.Resource(Identifier.withDefaultNamespace("alt"));
|
||||
private static final Style GALACTIC_STYLE = Style.EMPTY.withFont(GALACTIC);
|
||||
private ProgressionPayloads.Hello offer;private final Runnable disconnect;
|
||||
private EditBox bio,familiarName;private int color=-1;private Button submit;private boolean waiting,identityLocked;
|
||||
private String draft="",starter="",nameDraft="";private List<StarterCompanions.Choice> starterChoices=List.of();
|
||||
private String draft="",starter="",nameDraft="",codeDraft="",codeStatus="",lastChecked="";
|
||||
private boolean accessRequired,accessPending,admitting;private EditBox codeBox;
|
||||
private List<StarterCompanions.Choice> starterChoices=List.of();
|
||||
private final List<Button> colors=new ArrayList<>(),starters=new ArrayList<>();
|
||||
private int panelX,panelY,panelWidth,bioLabelY,colorLabelY,starterLabelY,nameLabelY;private boolean compact;
|
||||
public HelloWorldScreen(ProgressionPayloads.Hello offer,Runnable disconnect){super(ProgressionService.label("hello_title"));this.offer=offer;this.disconnect=disconnect;}
|
||||
@@ -29,18 +36,29 @@ public final class HelloWorldScreen extends Screen {
|
||||
if(identityLocked){draft=welcome.bio();color=offer.colors().getFirst();}
|
||||
if(starterChoices.stream().noneMatch(c->c.mob().equals(starter)))starter="";
|
||||
}
|
||||
public void accept(StarterPayloads.Welcome welcome){if(bio!=null)draft=bio.getValue();load(welcome);waiting=false;if(!offer.colors().contains(color))color=-1;rebuildWidgets();}
|
||||
public void accept(ProgressionPayloads.Hello offer){this.offer=offer;waiting=false;if(bio!=null)draft=bio.getValue();if(!offer.colors().contains(color))color=-1;rebuildWidgets();}
|
||||
public void prepareAccess(boolean pending){accessRequired=true;accessPending=pending;if(pending)codeStatus="code_recognized";}
|
||||
public void requireAccess(boolean pending){boolean changed=!accessRequired||accessPending!=pending;prepareAccess(pending);if(changed&&minecraft!=null&&minecraft.gui.screen()==this){keepDrafts();rebuildWidgets();}}
|
||||
public void acceptCode(String code,String reason){if(admitting)return;if(!AccessCodes.normalize(code).equals(AccessCodes.normalize(codeDraft))){if(AccessCodes.validFormat(AccessCodes.normalize(codeDraft)))sendCheck(AccessCodes.normalize(codeDraft));return;}codeStatus=reason;waiting=false;updateSelection();}
|
||||
public void accept(StarterPayloads.Welcome welcome){keepDrafts();load(welcome);waiting=false;admitting=false;noteCodeError();if(!offer.colors().contains(color))color=-1;rebuildWidgets();}
|
||||
public void accept(ProgressionPayloads.Hello offer){keepDrafts();this.offer=offer;waiting=false;admitting=false;noteCodeError();if(!offer.colors().contains(color))color=-1;rebuildWidgets();}
|
||||
private void keepDrafts(){if(bio!=null)draft=bio.getValue();if(codeBox!=null)codeDraft=codeBox.getValue();if(familiarName!=null)nameDraft=familiarName.getValue();}
|
||||
private void noteCodeError(){if(offer.error().startsWith("code_"))codeStatus=offer.error();}
|
||||
@Override protected void init(){
|
||||
boolean naming=!starterChoices.isEmpty();compact=naming&&height<278;
|
||||
panelWidth=Math.min(520,width-20);panelX=(width-panelWidth)/2;panelY=Math.max(0,(height-(naming&&!compact?270:238))/2);colors.clear();starters.clear();familiarName=null;
|
||||
bioLabelY=panelY+(compact?37:44);colorLabelY=panelY+(compact?71:81);starterLabelY=panelY+(compact?145:177);nameLabelY=starterLabelY+36;
|
||||
boolean naming=!starterChoices.isEmpty();int shift=accessRequired?36:0;compact=naming&&height<278+shift;
|
||||
panelWidth=Math.min(520,width-20);panelX=(width-panelWidth)/2;panelY=Math.max(0,(height-((naming&&!compact?270:238)+shift))/2);colors.clear();starters.clear();familiarName=null;codeBox=null;
|
||||
bioLabelY=panelY+(compact?37:44)+shift;colorLabelY=panelY+(compact?71:81)+shift;starterLabelY=panelY+(compact?145:177)+shift;nameLabelY=starterLabelY+36;
|
||||
var skin=addRenderableWidget(new PlayerSkinWidget(64,145,minecraft.getEntityModels(),minecraft.getSkinManager().createLookup(minecraft.getGameProfile(),false)));skin.setPosition(panelX,panelY+35);
|
||||
int right=panelX+72,w=panelWidth-72;
|
||||
bio=addRenderableWidget(new EditBox(font,right,panelY+(compact?48:57),w,18,ProgressionService.label("bio")));bio.setMaxLength(256);bio.setHint(ProgressionService.label("bio_hint"));bio.setValue(draft);bio.setResponder(value->draft=value);
|
||||
if(accessRequired){
|
||||
codeBox=addRenderableWidget(new EditBox(font,right,panelY+(compact?48:57),w,18,ProgressionService.label("code")));
|
||||
codeBox.setMaxLength(32);codeBox.setHint(Component.translatable("sanctuary.progression.code_hint").withStyle(style -> style.withFont(GALACTIC)));codeBox.setValue(codeDraft);
|
||||
codeBox.addFormatter((text,offset) -> FormattedCharSequence.forward(text, GALACTIC_STYLE));
|
||||
codeBox.setResponder(value->{codeDraft=value;if(accessPending){updateSelection();return;}String normalized=AccessCodes.normalize(value);if(!AccessCodes.validFormat(normalized)){codeStatus=normalized.isEmpty()?"":"code_malformed";lastChecked="";updateSelection();return;}if(!normalized.equals(lastChecked))sendCheck(normalized);else updateSelection();});
|
||||
}
|
||||
bio=addRenderableWidget(new EditBox(font,right,panelY+(compact?48:57)+shift,w,18,ProgressionService.label("bio")));bio.setMaxLength(256);bio.setHint(ProgressionService.label("bio_hint"));bio.setValue(draft);bio.setResponder(value->draft=value);
|
||||
int cell=w/4;
|
||||
for(int i=0;i<offer.colors().size();i++){
|
||||
final int rgb=offer.colors().get(i);int rowHeight=compact?12:16;Button button=addRenderableWidget(new ColorButton(right+(i%4)*cell,panelY+(compact?82:94)+(i/4)*rowHeight,cell-2,rowHeight,rgb,i+1,b->{color=rgb;updateSelection();}));
|
||||
final int rgb=offer.colors().get(i);int rowHeight=compact?12:16;Button button=addRenderableWidget(new ColorButton(right+(i%4)*cell,panelY+(compact?82:94)+shift+(i/4)*rowHeight,cell-2,rowHeight,rgb,i+1,b->{color=rgb;updateSelection();}));
|
||||
button.setTooltip(Tooltip.create(InhabitantColorNames.label(rgb)));colors.add(button);
|
||||
}
|
||||
for(int i=0;i<starterChoices.size();i++)starters.add(addRenderableWidget(new StarterSlot(right+i*28,starterLabelY+11,starterChoices.get(i))));
|
||||
@@ -49,10 +67,16 @@ public final class HelloWorldScreen extends Screen {
|
||||
familiarName.setMaxLength(StarterCompanions.NAME_LIMIT);familiarName.setHint(Component.translatable("sanctuary.starter.name_hint"));familiarName.setValue(nameDraft);
|
||||
familiarName.setResponder(value->{nameDraft=value;updateSelection();});
|
||||
}
|
||||
int actionsY=panelY+(naming&&!compact?248:216);
|
||||
int actionsY=panelY+(naming&&!compact?248:216)+shift;
|
||||
addRenderableWidget(Button.builder(ProgressionService.label("cancel"),b->onClose()).bounds(panelX,actionsY,90,20).build());
|
||||
submit=addRenderableWidget(Button.builder(ProgressionService.label("enter"),b->{
|
||||
if(waiting||color<0||(!starterChoices.isEmpty()&&(starter.isEmpty()||!StarterCompanions.validName(nameDraft))))return;
|
||||
if(accessRequired){
|
||||
if(!(accessPending||"code_recognized".equals(codeStatus)))return;
|
||||
admitting=true;waiting=true;updateSelection();
|
||||
ClientConfigurationNetworking.send(new AccessPayloads.Admit(bio.getValue(),color,starterChoices.isEmpty()?"":starter,nameDraft,accessPending||codeBox==null?"":codeBox.getValue()));
|
||||
return;
|
||||
}
|
||||
if(!starterChoices.isEmpty()&&!nameDraft.isBlank()&&!ClientConfigurationNetworking.canSend(StarterPayloads.NamedCreate.TYPE)){
|
||||
offer=new ProgressionPayloads.Hello(offer.name(),offer.colors(),"starter_name_version_required");return;
|
||||
}
|
||||
@@ -62,9 +86,12 @@ public final class HelloWorldScreen extends Screen {
|
||||
else ClientConfigurationNetworking.send(new StarterPayloads.Create(bio.getValue(),color,starter));
|
||||
}).bounds(panelX+96,actionsY,panelWidth-96,20).build());updateSelection();
|
||||
}
|
||||
private void sendCheck(String normalized){lastChecked=normalized;codeStatus="code_checking";updateSelection();if(ClientConfigurationNetworking.canSend(AccessPayloads.Check.TYPE))ClientConfigurationNetworking.send(new AccessPayloads.Check(normalized));}
|
||||
private void updateSelection(){
|
||||
if(submit!=null)submit.active=!waiting&&color>=0&&(starterChoices.isEmpty()||(!starter.isEmpty()&&StarterCompanions.validName(nameDraft)));
|
||||
boolean codeOk=!accessRequired||accessPending||"code_recognized".equals(codeStatus);
|
||||
if(submit!=null)submit.active=!waiting&&color>=0&&(starterChoices.isEmpty()||(!starter.isEmpty()&&StarterCompanions.validName(nameDraft)))&&codeOk;
|
||||
if(familiarName!=null)familiarName.setEditable(!waiting);
|
||||
if(codeBox!=null)codeBox.setEditable(!waiting&&!accessPending);
|
||||
if(bio!=null)bio.setEditable(!waiting&&!identityLocked);colors.forEach(b->b.active=!waiting&&!identityLocked);starters.forEach(b->b.active=!waiting);
|
||||
}
|
||||
@Override public void extractBackground(GuiGraphicsExtractor g,int mx,int my,float a){
|
||||
@@ -73,13 +100,19 @@ public final class HelloWorldScreen extends Screen {
|
||||
@Override public void extractRenderState(GuiGraphicsExtractor g,int mx,int my,float a){
|
||||
super.extractRenderState(g,mx,my,a);g.centeredText(font,title,width/2,panelY+7,0xFFFFFFFF);
|
||||
g.text(font,Component.literal(offer.name()).withStyle(s->color<0?s:s.withColor(color)),panelX+72,panelY+(compact?24:29),0xFFFFFFFF);
|
||||
if(accessRequired){
|
||||
String codeKey=codeStatus.isEmpty()?"code":codeStatus;
|
||||
int codeColor="code_recognized".equals(codeStatus)?0xFF88CC88:codeStatus.isEmpty()?0xFFBBBBBB:0xFFFFFFAA;
|
||||
g.text(font,ProgressionService.label(codeKey),panelX+72,panelY+(compact?37:44),codeColor);
|
||||
}
|
||||
g.text(font,ProgressionService.label("bio"),panelX+72,bioLabelY,0xFFBBBBBB);g.text(font,ProgressionService.label("choose_color"),panelX+72,colorLabelY,0xFFBBBBBB);
|
||||
String status=waiting?"joining":offer.error();
|
||||
String shown=offer.error().startsWith("code_")?"":offer.error();
|
||||
String status=waiting?"joining":shown;
|
||||
if(!starterChoices.isEmpty()){
|
||||
g.text(font,status.isEmpty()?Component.translatable("sanctuary.starter.slots"):ProgressionService.label(status),panelX+72,starterLabelY,status.isEmpty()?0xFFBBBBBB:0xFFFFFFAA);
|
||||
g.text(font,Component.translatable("sanctuary.starter.name"),panelX+72,nameLabelY,0xFFBBBBBB);
|
||||
}
|
||||
else if(!status.isEmpty())g.centeredText(font,ProgressionService.label(status),width/2,panelY+204,0xFFFFFFAA);
|
||||
else if(!status.isEmpty())g.centeredText(font,ProgressionService.label(status),width/2,panelY+204+(accessRequired?36:0),0xFFFFFFAA);
|
||||
}
|
||||
private final class ColorButton extends Button.Plain {
|
||||
ColorButton(int x,int y,int w,int h,int rgb,int number,OnPress press){super(x,y,w,h,InhabitantColorNames.label(rgb).copy().withStyle(s->s.withColor(rgb)),press,ignored->ProgressionService.label("color_number",number).copy().append(": ").append(InhabitantColorNames.label(rgb)));setOverrideRenderHighlightedSprite(()->color==rgb||isHoveredOrFocused());}
|
||||
|
||||
@@ -11,6 +11,7 @@ import net.minecraft.client.gui.screens.Screen;
|
||||
|
||||
public final class ProgressionClient {
|
||||
public static ProgressRecord progress;
|
||||
private static boolean accessGate,accessPending;
|
||||
public static boolean operator;
|
||||
public static InhabitantStore.Identity identity;
|
||||
public static ProgressionConfig config=ProgressionConfig.DEFAULT;
|
||||
@@ -38,14 +39,21 @@ public final class ProgressionClient {
|
||||
CycleClient.register();
|
||||
EntityRendererRegistry.register(SanctuarySitting.SEAT,NoopRenderer::new);
|
||||
ClientConfigurationNetworking.registerGlobalReceiver(ProgressionPayloads.InventoryLayout.TYPE,(payload,context) -> {});
|
||||
ClientConfigurationNetworking.registerGlobalReceiver(AccessPayloads.Gate.TYPE,(payload,context)->context.client().execute(()->{
|
||||
accessGate=payload.required();accessPending=payload.pending();
|
||||
if(payload.required()&&context.client().gui.screen() instanceof HelloWorldScreen screen)screen.requireAccess(payload.pending());
|
||||
}));
|
||||
ClientConfigurationNetworking.registerGlobalReceiver(AccessPayloads.Status.TYPE,(payload,context)->context.client().execute(()->{
|
||||
if(context.client().gui.screen() instanceof HelloWorldScreen screen)screen.acceptCode(payload.code(),payload.reason());
|
||||
}));
|
||||
ClientConfigurationNetworking.registerGlobalReceiver(StarterPayloads.Welcome.TYPE,(payload,context)->context.client().execute(()->{
|
||||
if(context.client().gui.screen() instanceof HelloWorldScreen screen)screen.accept(payload);
|
||||
else context.client().gui.setScreen(new HelloWorldScreen(payload,()->context.responseSender().disconnect(ProgressionService.label("cancelled"))));
|
||||
else showHello(context.client(),new HelloWorldScreen(payload,()->context.responseSender().disconnect(ProgressionService.label("cancelled"))));
|
||||
}));
|
||||
ClientConfigurationNetworking.registerGlobalReceiver(ProgressionPayloads.Hello.TYPE,(payload,context) ->
|
||||
context.client().execute(() -> {
|
||||
if (context.client().gui.screen() instanceof HelloWorldScreen screen) screen.accept(payload);
|
||||
else context.client().gui.setScreen(new HelloWorldScreen(payload,
|
||||
else showHello(context.client(),new HelloWorldScreen(payload,
|
||||
() -> context.responseSender().disconnect(ProgressionService.label("cancelled"))));
|
||||
}));
|
||||
ClientPlayNetworking.registerGlobalReceiver(ProgressionPayloads.Snapshot.TYPE,(payload,context) ->
|
||||
@@ -91,9 +99,11 @@ public final class ProgressionClient {
|
||||
if (active() && ClientPlayNetworking.canSend(ProgressionPayloads.Action.TYPE))
|
||||
ClientPlayNetworking.send(new ProgressionPayloads.Action(target,progress.revision()));
|
||||
}
|
||||
private static void showHello(Minecraft client,HelloWorldScreen screen){if(accessGate)screen.prepareAccess(accessPending);client.gui.setScreen(screen);}
|
||||
private static void reset(Minecraft client) {
|
||||
if (client.player != null) ProgressEffects.clear(client.player);
|
||||
identity=null;progress=null;operator=false;config=ProgressionConfig.DEFAULT;names=MobNamesConfig.DEFAULT;
|
||||
accessGate=false;accessPending=false;
|
||||
wasSneaking=false;pendingSit=false;lastSneak=-100;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,6 @@ public final class SanctuaryKnowledgeClient implements ClientModInitializer {
|
||||
|
||||
public static void registerClient() {
|
||||
SanctuaryResourcePack.register();
|
||||
fr.koka.sanctuary.client.shader.NaturalWater.register();
|
||||
VortexCloudRenderer.register();
|
||||
fr.koka.sanctuary.client.shader.PixelShadows.register();
|
||||
fr.koka.sanctuary.client.shader.EdgeHighlights.register();
|
||||
|
||||
@@ -1,172 +0,0 @@
|
||||
package fr.koka.sanctuary.client.shader;
|
||||
|
||||
import com.google.gson.*;
|
||||
import fr.koka.sanctuary.SanctuaryMod;
|
||||
import java.util.*;
|
||||
import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents;
|
||||
import net.fabricmc.fabric.api.resource.v1.ResourceLoader;
|
||||
import net.minecraft.client.Minecraft;
|
||||
import net.minecraft.client.multiplayer.ClientLevel;
|
||||
import net.minecraft.client.renderer.block.BlockAndTintGetter;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.core.registries.BuiltInRegistries;
|
||||
import net.minecraft.resources.Identifier;
|
||||
import net.minecraft.server.packs.PackType;
|
||||
import net.minecraft.server.packs.resources.ResourceManagerReloadListener;
|
||||
import net.minecraft.tags.BiomeTags;
|
||||
import net.minecraft.tags.FluidTags;
|
||||
import net.minecraft.world.level.block.Block;
|
||||
import net.minecraft.world.level.block.state.BlockState;
|
||||
|
||||
/** Client-only water materials; no world writes, per-frame terrain scan or additional render pass. */
|
||||
public final class NaturalWater {
|
||||
public static final int DEPTH=8;
|
||||
public record Profile(float tint,float alpha,float fog) {
|
||||
public Profile mix(Profile b,float t){if(this==b||t==0)return this;if(t==1)return b;return new Profile(tint+(b.tint-tint)*t,alpha+(b.alpha-alpha)*t,fog+(b.fog-fog)*t);}
|
||||
}
|
||||
public static final Profile CLEAR=new Profile(0,.55f,1),LIGHT=new Profile(.10f,.80f,.70f),POND=new Profile(.85f,1,.40f);
|
||||
private static final Profile[] PROFILES={CLEAR,LIGHT,POND};
|
||||
private static volatile Map<Block,Integer> materials=Map.of();
|
||||
private static volatile float strength;
|
||||
private static ClientLevel previousLevel;
|
||||
private static Profile fogFrom=CLEAR,fogTarget=CLEAR;
|
||||
private static long fogChanged;
|
||||
private static boolean wasUnderwater;
|
||||
private NaturalWater(){}
|
||||
public static void register(){
|
||||
ResourceLoader.get(PackType.CLIENT_RESOURCES).registerReloadListener(Identifier.parse("sanctuary:water_materials"),
|
||||
(ResourceManagerReloadListener) manager->{
|
||||
try(var reader=manager.getResourceOrThrow(Identifier.parse("sanctuary:water_materials.json")).openAsReader()){
|
||||
var root=JsonParser.parseReader(reader).getAsJsonObject();var next=new HashMap<Block,Integer>();
|
||||
for(var entry:Map.of("light",1,"pond",2).entrySet()) for(var id:root.getAsJsonArray(entry.getKey())){
|
||||
var key=Identifier.parse(id.getAsString());
|
||||
if(!BuiltInRegistries.BLOCK.containsKey(key))throw new IllegalArgumentException("Unknown water material "+key);
|
||||
if(next.put(BuiltInRegistries.BLOCK.getValue(key),entry.getValue())!=null)throw new IllegalArgumentException("Duplicate water material "+key);
|
||||
}
|
||||
materials=Map.copyOf(next);
|
||||
}catch(Exception e){SanctuaryMod.LOGGER.warn("Invalid water materials; using bundled defaults",e);materials=defaults();}
|
||||
});
|
||||
ClientTickEvents.END_CLIENT_TICK.register(client->{
|
||||
float next=VanillaLight.active()&&VanillaLight.naturalWater()?VanillaLight.waterIntensity()/100f:0;
|
||||
if(previousLevel!=client.level||strength!=next){previousLevel=client.level;strength=next;wasUnderwater=false;
|
||||
if(client.level!=null)invalidateWaterSections(client);}
|
||||
if(client.level==null||next==0){wasUnderwater=false;return;}
|
||||
var camera=client.gameRenderer.mainCamera();
|
||||
if(camera.getFluidInCamera()!=net.minecraft.world.level.material.FogType.WATER){wasUnderwater=false;return;}
|
||||
var pos=camera.position();var target=sample(client.level,pos.x,pos.y,pos.z);
|
||||
if(!wasUnderwater){fogFrom=fogTarget=target;fogChanged=System.nanoTime();wasUnderwater=true;}
|
||||
else if(!target.equals(fogTarget)){fogFrom=fogProfile();fogTarget=target;fogChanged=System.nanoTime();}
|
||||
});
|
||||
}
|
||||
/** Preference changes only rebuild loaded water meshes, retaining unrelated shader caches. */
|
||||
private static void invalidateWaterSections(Minecraft client){
|
||||
var level=client.level;
|
||||
var center=BlockPos.containing(client.gameRenderer.mainCamera().position());
|
||||
int radius=client.options.getEffectiveRenderDistance()+1;
|
||||
for(int z=(center.getZ()>>4)-radius;z<=(center.getZ()>>4)+radius;z++)
|
||||
for(int x=(center.getX()>>4)-radius;x<=(center.getX()>>4)+radius;x++){
|
||||
var chunk=level.getChunkSource().getChunk(x,z,net.minecraft.world.level.chunk.status.ChunkStatus.FULL,false);
|
||||
if(chunk==null)continue;
|
||||
var sections=chunk.getSections();
|
||||
for(int i=0;i<sections.length;i++)if(!sections[i].hasOnlyAir()
|
||||
&§ions[i].getStates().maybeHas(state->state.getFluidState().is(FluidTags.WATER)))
|
||||
client.levelExtractor.setSectionDirty(x,level.getSectionYFromSectionIndex(i),z);
|
||||
}
|
||||
}
|
||||
private static Map<Block,Integer> defaults(){
|
||||
var result=new HashMap<Block,Integer>();
|
||||
for(String name:new String[]{"sand","red_sand","gravel","clay"})result.put(BuiltInRegistries.BLOCK.getValue(Identifier.withDefaultNamespace(name)),1);
|
||||
for(String name:new String[]{"dirt","coarse_dirt","rooted_dirt","grass_block","podzol","mycelium","mud","muddy_mangrove_roots"})result.put(BuiltInRegistries.BLOCK.getValue(Identifier.withDefaultNamespace(name)),2);
|
||||
return Map.copyOf(result);
|
||||
}
|
||||
public static float strength(){return strength;}
|
||||
public static Profile fogProfile(){return fogFrom.mix(fogTarget,Math.clamp((System.nanoTime()-fogChanged)/500_000_000f,0,1));}
|
||||
// ClientLevel.hasChunk always returns true; inspect the cache without requesting a chunk.
|
||||
public static boolean loaded(ClientLevel level,int x,int z){
|
||||
return level.getChunkSource().getChunk(x,z,net.minecraft.world.level.chunk.status.ChunkStatus.FULL,false)!=null;
|
||||
}
|
||||
public static boolean ocean(ClientLevel level,BlockPos pos){return loaded(level,pos.getX()>>4,pos.getZ()>>4)&&level.getBiome(pos).is(BiomeTags.IS_OCEAN);}
|
||||
private static int classify(java.util.function.Function<BlockPos,BlockState> read,BlockPos pos,Map<Block,Integer> table){
|
||||
var cursor=pos.mutable();
|
||||
for(int i=1;i<=DEPTH;i++){
|
||||
cursor.set(pos.getX(),pos.getY()-i,pos.getZ());var state=read.apply(cursor);
|
||||
if(state==null||state.isAir())return 0;
|
||||
if(state.isSolid()||!state.getCollisionShape(net.minecraft.world.level.EmptyBlockGetter.INSTANCE,cursor).isEmpty()){
|
||||
// Plants have no collision; a waterlogged solid remains a basin floor.
|
||||
return table.getOrDefault(state.getBlock(),0);
|
||||
}
|
||||
if(!state.getFluidState().is(FluidTags.WATER))return 0;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
public static Profile sample(ClientLevel level,double x,double y,double z){
|
||||
var pos=BlockPos.containing(x,y,z);if(ocean(level,pos))return CLEAR;
|
||||
int bx=(int)Math.floor(x-.5),bz=(int)Math.floor(z-.5),by=pos.getY();
|
||||
float dx=(float)(x-.5-bx),dz=(float)(z-.5-bz);
|
||||
return live(level,bx,by,bz).mix(live(level,bx+1,by,bz),dx).mix(live(level,bx,by,bz+1).mix(live(level,bx+1,by,bz+1),dx),dz);
|
||||
}
|
||||
private static Profile live(ClientLevel l,int x,int y,int z){
|
||||
var p=new BlockPos(x,y,z);if(!loaded(l,x>>4,z>>4)||ocean(l,p))return CLEAR;
|
||||
return PROFILES[classify(l::getBlockState,p,materials)];
|
||||
}
|
||||
/** Owned by a single native section compilation, with ocean data captured before worker submission. */
|
||||
public static final class Region {
|
||||
private final int ox,oy,oz;
|
||||
private final net.minecraft.world.level.biome.BiomeManager biomes;
|
||||
private byte[] oceanKinds;
|
||||
private byte[] profiles;
|
||||
private final Map<Block,Integer> table=materials;
|
||||
public final float intensity=strength;
|
||||
public Region(ClientLevel level,int minX,int minY,int minZ){
|
||||
ox=(minX+1)*16;oy=(minY+1)*16;oz=(minZ+1)*16;
|
||||
// Copy quart holders, then keep the native seeded biome zoom on the immutable copy.
|
||||
// Sampling only quart centres would misclassify the jagged edges of ocean biomes.
|
||||
var copied=new java.util.ArrayList<net.minecraft.core.Holder<net.minecraft.world.level.biome.Biome>>(216);
|
||||
int qx=(ox>>2)-1,qy=(oy>>2)-1,qz=(oz>>2)-1;
|
||||
for(int y=0;y<6;y++)for(int z=0;z<6;z++)for(int x=0;x<6;x++)
|
||||
copied.add(loaded(level,(qx+x)>>2,(qz+z)>>2)?level.getBiomeManager().getNoiseBiomeAtQuart(qx+x,qy+y,qz+z):null);
|
||||
biomes=level.getBiomeManager().withDifferentSource((x,y,z)->copied.get(((y-qy)*6+z-qz)*6+x-qx));
|
||||
}
|
||||
// A missing biome snapshot also forces clear water at unloaded region edges.
|
||||
public boolean ocean(int x,int y,int z){
|
||||
if(oceanKinds==null)oceanKinds=new byte[18*16*18];
|
||||
int key=((y-oy)*18+z-oz+1)*18+x-ox+1;
|
||||
int code=oceanKinds[key];if(code==0){var biome=biomes.getBiome(x,y,z);code=biome==null||biome.is(BiomeTags.IS_OCEAN)?2:1;oceanKinds[key]=(byte)code;}
|
||||
return code==2;
|
||||
}
|
||||
private Profile profile(BlockAndTintGetter region,int x,int y,int z){
|
||||
if(x<ox-1||x>ox+16||y<oy||y>oy+15||z<oz-1||z>oz+16)return CLEAR;
|
||||
if(ocean(x,y,z))return CLEAR;
|
||||
int key=((y-oy)*18+z-oz+1)*18+x-ox+1;
|
||||
if(profiles==null)profiles=new byte[18*16*18];
|
||||
int code=profiles[key];if(code==0){code=1+classify(region::getBlockState,new BlockPos(x,y,z),table);profiles[key]=(byte)code;}
|
||||
return PROFILES[code-1];
|
||||
}
|
||||
public Profile corner(BlockAndTintGetter region,BlockPos p,int dx,int dz){
|
||||
if(ocean(p.getX(),p.getY(),p.getZ()))return CLEAR;
|
||||
int x=p.getX()+dx-1,y=p.getY(),z=p.getZ()+dz-1;
|
||||
// Both cells sharing an ocean edge must emit the same clear vertex.
|
||||
// The one-block transition stays on the non-ocean side, preserving ocean priority.
|
||||
if(ocean(x,y,z)||ocean(x+1,y,z)||ocean(x,y,z+1)||ocean(x+1,y,z+1))return CLEAR;
|
||||
return profile(region,x,y,z).mix(profile(region,x+1,y,z),.5f).mix(profile(region,x,y,z+1).mix(profile(region,x+1,y,z+1),.5f),.5f);
|
||||
}
|
||||
}
|
||||
public interface RegionAccess { Region sanctuary$waterRegion(); }
|
||||
public static int color(int color,int base,Profile profile,float intensity){
|
||||
float tint=profile.tint*intensity;
|
||||
int r=color>>16&255,g=color>>8&255,b=color&255;
|
||||
// Restore directional face shading after tinting, including custom biome/resource-pack colours.
|
||||
float shade=(r+g+b)/(float)Math.max(1,(base>>16&255)+(base>>8&255)+(base&255));
|
||||
int rr=Math.clamp(Math.round(r*(1-tint)+120*shade*tint),0,255);
|
||||
int gg=Math.clamp(Math.round(g*(1-tint)+132*shade*tint),0,255);
|
||||
int bb=Math.clamp(Math.round(b*(1-tint)+72*shade*tint),0,255);
|
||||
int a=Math.round((color>>>24)*(1+(profile.alpha-1)*intensity));
|
||||
return a<<24|rr<<16|gg<<8|bb;
|
||||
}
|
||||
public static void dirty(BlockPos pos){
|
||||
if(strength==0)return;
|
||||
var renderer=Minecraft.getInstance().levelExtractor;
|
||||
renderer.setSectionRangeDirty((pos.getX()-1)>>4,pos.getY()>>4,(pos.getZ()-1)>>4,
|
||||
(pos.getX()+1)>>4,(pos.getY()+DEPTH)>>4,(pos.getZ()+1)>>4);
|
||||
}
|
||||
}
|
||||
@@ -14,9 +14,6 @@ public final class ShaderOptionsScreen extends OptionsSubScreen {
|
||||
list.addBig(OptionInstance.createBoolean("sanctuary.shaders.vanilla_light",OptionInstance.cachedConstantTooltip(
|
||||
Component.translatable(VanillaLight.externalEngine()?"sanctuary.shaders.external":"sanctuary.shaders.help")),
|
||||
VanillaLight.enabled(),VanillaLight::setEnabled));
|
||||
list.addBig(OptionInstance.createBoolean("sanctuary.shaders.natural_water",OptionInstance.cachedConstantTooltip(
|
||||
Component.translatable("sanctuary.shaders.natural_water_help")),VanillaLight.naturalWater(),VanillaLight::setNaturalWater));
|
||||
slider("water_intensity",VanillaLight.waterIntensity(),VanillaLight::setWaterIntensity);
|
||||
list.addBig(new OptionInstance<Integer>("sanctuary.shaders.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));
|
||||
|
||||
@@ -16,8 +16,6 @@ public final class VanillaLight {
|
||||
private VanillaLight() { }
|
||||
private static final class Preferences {
|
||||
boolean enabled=true;
|
||||
boolean naturalWater=true;
|
||||
int waterIntensity=100;
|
||||
int saturation=142;
|
||||
boolean pixelShadows=true;
|
||||
boolean bloom=true;
|
||||
@@ -60,7 +58,6 @@ public final class VanillaLight {
|
||||
preferences.oreIntensity=Math.clamp(preferences.oreIntensity,0,100);
|
||||
preferences.edgeIntensity=Math.clamp(preferences.edgeIntensity,0,100);
|
||||
preferences.edgeDistance=normalizeShadowDistance(preferences.edgeDistance);
|
||||
preferences.waterIntensity=Math.clamp(preferences.waterIntensity,0,100);
|
||||
preferences.saturation=Math.clamp(preferences.saturation,0,200);
|
||||
preferences.shadowPixels=normalizePrecision(preferences.shadowPixels);
|
||||
preferences.shadowDistance=normalizeShadowDistance(preferences.shadowDistance);
|
||||
@@ -69,10 +66,6 @@ public final class VanillaLight {
|
||||
}
|
||||
public static boolean externalEngine(){return EXTERNAL_ENGINE;}
|
||||
public static boolean active(){return ProgressionClient.active()&&!EXTERNAL_ENGINE&&enabled();}
|
||||
public static boolean naturalWater(){enabled();return preferences.naturalWater;}
|
||||
public static int waterIntensity(){enabled();return preferences.waterIntensity;}
|
||||
public static void setNaturalWater(boolean value){enabled();preferences.naturalWater=value;save();}
|
||||
public static void setWaterIntensity(int value){enabled();preferences.waterIntensity=Math.clamp(value,0,100);save();}
|
||||
public static int saturation(){enabled();return preferences.saturation;}
|
||||
public static boolean edgeHighlights(){enabled();return preferences.edgeHighlights;}
|
||||
public static int edgeDistance(){enabled();return preferences.edgeDistance;}
|
||||
|
||||
-48
@@ -1,48 +0,0 @@
|
||||
package fr.koka.sanctuary.mixin.client;
|
||||
import com.llamalad7.mixinextras.injector.wrapmethod.WrapMethod;
|
||||
import com.llamalad7.mixinextras.injector.wrapoperation.*;
|
||||
import com.mojang.blaze3d.vertex.VertexConsumer;
|
||||
import fr.koka.sanctuary.client.shader.NaturalWater;
|
||||
import net.minecraft.client.color.block.BlockTintSource;
|
||||
import net.minecraft.client.renderer.block.*;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.tags.FluidTags;
|
||||
import net.minecraft.world.level.block.state.BlockState;
|
||||
import net.minecraft.world.level.material.FluidState;
|
||||
import org.spongepowered.asm.mixin.*;
|
||||
import org.spongepowered.asm.mixin.injection.At;
|
||||
/** Native renderer instances belong to one section compiler. Context is restored even on failure. */
|
||||
@Mixin(FluidRenderer.class)
|
||||
abstract class NaturalWaterFluidMixin {
|
||||
@Unique private NaturalWater.Profile[] sanctuary$corners;
|
||||
@Unique private NaturalWater.Region sanctuary$snapshot;
|
||||
@Unique private BlockAndTintGetter sanctuary$region;
|
||||
@Unique private BlockPos sanctuary$pos;
|
||||
@Unique private float sanctuary$strength;
|
||||
@Unique private int sanctuary$x,sanctuary$z,sanctuary$base;
|
||||
@WrapMethod(method="tesselate")
|
||||
private void sanctuary$water(BlockAndTintGetter region,BlockPos pos,FluidRenderer.Output output,BlockState state,FluidState fluid,Operation<Void> original){
|
||||
var previous=sanctuary$snapshot;
|
||||
try{
|
||||
sanctuary$corners=null;sanctuary$snapshot=null;sanctuary$base=-1;
|
||||
if(fluid.is(FluidTags.WATER)&®ion instanceof NaturalWater.RegionAccess access){
|
||||
var snapshot=access.sanctuary$waterRegion();
|
||||
if(snapshot!=null){
|
||||
sanctuary$strength=snapshot.intensity;sanctuary$x=pos.getX()&15;sanctuary$z=pos.getZ()&15;
|
||||
sanctuary$snapshot=snapshot;sanctuary$region=region;sanctuary$pos=pos;
|
||||
}
|
||||
}
|
||||
original.call(region,pos,output,state,fluid);
|
||||
}finally{sanctuary$snapshot=previous;sanctuary$corners=null;}
|
||||
}
|
||||
@WrapOperation(method="tesselate",at=@At(value="INVOKE",target="Lnet/minecraft/client/color/block/BlockTintSource;colorInWorld(Lnet/minecraft/world/level/block/state/BlockState;Lnet/minecraft/client/renderer/block/BlockAndTintGetter;Lnet/minecraft/core/BlockPos;)I"))
|
||||
private int sanctuary$base(BlockTintSource source,BlockState state,BlockAndTintGetter region,BlockPos pos,Operation<Integer> original){return sanctuary$base=original.call(source,state,region,pos);}
|
||||
@WrapOperation(method="vertex",at=@At(value="INVOKE",target="Lcom/mojang/blaze3d/vertex/VertexConsumer;addVertex(FFFIFFIIFFF)V"))
|
||||
private void sanctuary$vertex(VertexConsumer consumer,float x,float y,float z,int color,float u,float v,int overlay,int light,float nx,float ny,float nz,Operation<Void> original){
|
||||
if(sanctuary$snapshot!=null){
|
||||
if(sanctuary$corners==null){var s=sanctuary$snapshot;var r=sanctuary$region;var p=sanctuary$pos;
|
||||
sanctuary$corners=new NaturalWater.Profile[]{s.corner(r,p,0,0),s.corner(r,p,1,0),s.corner(r,p,0,1),s.corner(r,p,1,1)};}
|
||||
int corner=(x-sanctuary$x>.5f?1:0)+(z-sanctuary$z>.5f?2:0);color=NaturalWater.color(color,sanctuary$base,sanctuary$corners[corner],sanctuary$strength);}
|
||||
original.call(consumer,x,y,z,color,u,v,overlay,light,nx,ny,nz);
|
||||
}
|
||||
}
|
||||
-24
@@ -1,24 +0,0 @@
|
||||
package fr.koka.sanctuary.mixin.client;
|
||||
import fr.koka.sanctuary.client.shader.NaturalWater;
|
||||
import net.minecraft.client.Camera;
|
||||
import net.minecraft.client.multiplayer.ClientLevel;
|
||||
import net.minecraft.client.renderer.fog.FogRenderer;
|
||||
import net.minecraft.world.level.material.FogType;
|
||||
import org.joml.Vector4f;
|
||||
import org.spongepowered.asm.mixin.Mixin;
|
||||
import org.spongepowered.asm.mixin.injection.*;
|
||||
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
|
||||
@Mixin(FogRenderer.class)
|
||||
abstract class NaturalWaterFogColorMixin {
|
||||
@Inject(method="computeFogColor",at=@At("RETURN"))
|
||||
private void sanctuary$waterColor(Camera camera,float ticks,ClientLevel level,int distance,float darken,Vector4f color,CallbackInfo ci){
|
||||
if(NaturalWater.strength()==0||camera.getFluidInCamera()!=FogType.WATER)return;
|
||||
float amount=NaturalWater.strength()*NaturalWater.fogProfile().tint();if(amount==0)return;
|
||||
// Apply after native water-vision normalization: normalizing olive to max=1 turns it neon yellow.
|
||||
// Retain native darkness/vision brightness, including a fully black blinded environment.
|
||||
float brightness=Math.max(color.x,Math.max(color.y,color.z));
|
||||
color.x+=(120/255f*brightness-color.x)*amount;
|
||||
color.y+=(132/255f*brightness-color.y)*amount;
|
||||
color.z+=(72/255f*brightness-color.z)*amount;
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
package fr.koka.sanctuary.mixin.client;
|
||||
import fr.koka.sanctuary.client.shader.NaturalWater;
|
||||
import net.minecraft.client.Camera;
|
||||
import net.minecraft.client.DeltaTracker;
|
||||
import net.minecraft.client.multiplayer.ClientLevel;
|
||||
import net.minecraft.client.renderer.fog.FogData;
|
||||
import net.minecraft.client.renderer.fog.environment.WaterFogEnvironment;
|
||||
import org.spongepowered.asm.mixin.Mixin;
|
||||
import org.spongepowered.asm.mixin.injection.*;
|
||||
import org.spongepowered.asm.mixin.injection.callback.*;
|
||||
@Mixin(WaterFogEnvironment.class)
|
||||
abstract class NaturalWaterFogMixin {
|
||||
@Inject(method="setupFog",at=@At("TAIL"))
|
||||
private void sanctuary$fog(FogData fog,Camera camera,ClientLevel level,float distance,DeltaTracker delta,CallbackInfo ci){
|
||||
float amount=NaturalWater.strength();if(amount==0)return;
|
||||
float factor=1+(NaturalWater.fogProfile().fog()-1)*amount;
|
||||
fog.environmentalStart*=factor;fog.environmentalEnd*=factor;fog.skyEnd*=factor;fog.cloudEnd*=factor;
|
||||
}
|
||||
}
|
||||
-23
@@ -1,23 +0,0 @@
|
||||
package fr.koka.sanctuary.mixin.client;
|
||||
import fr.koka.sanctuary.client.shader.NaturalWater;
|
||||
import net.minecraft.client.multiplayer.ClientLevel;
|
||||
import net.minecraft.client.renderer.chunk.RenderSectionRegion;
|
||||
import org.spongepowered.asm.mixin.*;
|
||||
import org.spongepowered.asm.mixin.injection.*;
|
||||
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
|
||||
@Mixin(RenderSectionRegion.class)
|
||||
abstract class NaturalWaterRegionMixin implements NaturalWater.RegionAccess {
|
||||
@Shadow @Final private ClientLevel level;
|
||||
@Shadow @Final private net.minecraft.client.renderer.chunk.SectionCopy[] sections;
|
||||
@Shadow @Final private int minSectionX,minSectionY,minSectionZ;
|
||||
@Unique private NaturalWater.Region sanctuary$water;
|
||||
@Inject(method="<init>",at=@At("RETURN"))
|
||||
private void sanctuary$snapshot(CallbackInfo ci){
|
||||
if(NaturalWater.strength()==0)return;
|
||||
// Native 3×3×3 region, centre index 13. Palette rejection avoids 216 biome reads for dry terrain.
|
||||
var states=((NaturalWaterSectionCopyAccess)sections[13]).sanctuary$states();
|
||||
if(states!=null&&states.maybeHas(state->state.getFluidState().is(net.minecraft.tags.FluidTags.WATER)))
|
||||
sanctuary$water=new NaturalWater.Region(level,minSectionX,minSectionY,minSectionZ);
|
||||
}
|
||||
public NaturalWater.Region sanctuary$waterRegion(){return sanctuary$water;}
|
||||
}
|
||||
-10
@@ -1,10 +0,0 @@
|
||||
package fr.koka.sanctuary.mixin.client;
|
||||
import net.minecraft.client.renderer.chunk.SectionCopy;
|
||||
import net.minecraft.world.level.chunk.PalettedContainer;
|
||||
import net.minecraft.world.level.block.state.BlockState;
|
||||
import org.spongepowered.asm.mixin.Mixin;
|
||||
import org.spongepowered.asm.mixin.gen.Accessor;
|
||||
@Mixin(SectionCopy.class)
|
||||
interface NaturalWaterSectionCopyAccess {
|
||||
@Accessor("section") PalettedContainer<BlockState> sanctuary$states();
|
||||
}
|
||||
@@ -17,7 +17,6 @@ import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
|
||||
abstract class PixelShadowDirtyMixin {
|
||||
@Inject(method="blockChanged", at=@At("HEAD"))
|
||||
private void sanctuary$dirty(BlockPos pos, int flags, CallbackInfo ci) {
|
||||
fr.koka.sanctuary.client.shader.NaturalWater.dirty(pos);
|
||||
PixelShadows.dirtyBlock(pos); ProceduralPbr.dirtyBlock(pos); Bloom.dirtyBlock(pos); ColoredLights.dirtyBlock(pos);
|
||||
}
|
||||
@Inject(method="allChanged", at=@At("HEAD"))
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
package fr.koka.sanctuary.progression;
|
||||
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
import java.util.Locale;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/** Site access-code contract. The client never calls the site; only these decisions run on the server. */
|
||||
public final class AccessCodes {
|
||||
private static final Pattern CODE = Pattern.compile("SANC-[A-Z0-9]{4}-[A-Z0-9]{4}");
|
||||
private static final Pattern USER = Pattern.compile("[A-Za-z0-9_]{3,16}");
|
||||
private static final Pattern DISCORD = Pattern.compile("[0-9]{1,20}");
|
||||
private AccessCodes() { }
|
||||
public record Result(boolean valid, String reason, String minecraftUsername, String discordId) {
|
||||
public static Result of(boolean valid, String reason, String username, String discordId) {
|
||||
return new Result(valid, reason == null ? "" : reason, username == null ? "" : username, discordId == null ? "" : discordId);
|
||||
}
|
||||
public static Result unreachable() { return of(false, "unreachable", "", ""); }
|
||||
public static Result unauthorized() { return of(false, "unauthorized", "", ""); }
|
||||
public static Result unconfigured() { return of(false, "unconfigured", "", ""); }
|
||||
}
|
||||
public record Redeem(boolean accepted, String reason, String minecraftUsername, String discordId) {
|
||||
public static Redeem accept(String username, String discordId) { return new Redeem(true, "code_recognized", username, discordId); }
|
||||
public static Redeem reject(String reason) { return new Redeem(false, reason, "", ""); }
|
||||
}
|
||||
public static String normalize(String code) { return code == null ? "" : code.trim().toUpperCase(Locale.ROOT); }
|
||||
public static boolean validFormat(String normalized) { return normalized != null && CODE.matcher(normalized).matches(); }
|
||||
public static boolean validUsername(String name) { return name != null && USER.matcher(name).matches(); }
|
||||
public static boolean validDiscord(String id) { return id != null && DISCORD.matcher(id).matches(); }
|
||||
public static boolean sameUser(String returned, String session) {
|
||||
return validUsername(session) && returned != null && returned.equalsIgnoreCase(session) && validUsername(returned);
|
||||
}
|
||||
public static Result parse(int status, String body) {
|
||||
if (status == 401) return Result.unauthorized();
|
||||
if (status == 429 || status < 200 || status >= 500) return Result.unreachable();
|
||||
try {
|
||||
var object = JsonParser.parseString(body).getAsJsonObject();
|
||||
if (!object.has("valid") || !object.has("reason")) return Result.unreachable();
|
||||
return Result.of(object.get("valid").getAsBoolean(), text(object, "reason"), text(object, "minecraft_username"), text(object, "discord_id"));
|
||||
} catch (RuntimeException error) { return Result.unreachable(); }
|
||||
}
|
||||
public static String verifyKey(Result result, String username) {
|
||||
if (result == null) return "code_unreachable";
|
||||
return switch (result.reason()) {
|
||||
case "unconfigured" -> "code_unconfigured";
|
||||
case "unauthorized" -> "code_unauthorized";
|
||||
case "unreachable" -> "code_unreachable";
|
||||
case "unused" -> result.valid() && sameUser(result.minecraftUsername(), username) && validDiscord(result.discordId()) ? "code_recognized" : "code_invalid";
|
||||
case "invalid" -> "code_invalid";
|
||||
case "already_used" -> "code_used";
|
||||
default -> "code_invalid";
|
||||
};
|
||||
}
|
||||
/** Redeem commits the code. An already used code is accepted only when the site still returns the same account. */
|
||||
public static Redeem redeem(Result result, String username) {
|
||||
if (result == null) return Redeem.reject("code_unreachable");
|
||||
return switch (result.reason()) {
|
||||
case "unconfigured" -> Redeem.reject("code_unconfigured");
|
||||
case "unauthorized" -> Redeem.reject("code_unauthorized");
|
||||
case "unreachable" -> Redeem.reject("code_unreachable");
|
||||
case "redeemed", "already_used" -> account(result, username);
|
||||
case "invalid" -> Redeem.reject("code_invalid");
|
||||
default -> Redeem.reject("code_invalid");
|
||||
};
|
||||
}
|
||||
private static Redeem account(Result result, String username) {
|
||||
if (!result.minecraftUsername().isEmpty() && !result.minecraftUsername().equalsIgnoreCase(username))
|
||||
return Redeem.reject("code_mismatch");
|
||||
if (!sameUser(result.minecraftUsername(), username) || !validDiscord(result.discordId()))
|
||||
return Redeem.reject("code_replay");
|
||||
return Redeem.accept(result.minecraftUsername(), result.discordId());
|
||||
}
|
||||
private static String text(JsonObject object, String key) {
|
||||
if (!object.has(key) || object.get(key).isJsonNull() || !object.get(key).isJsonPrimitive()) return "";
|
||||
return object.get(key).getAsString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package fr.koka.sanctuary.progression;
|
||||
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Duration;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
/** Server-only site access. The token stays in the environment or the server config, never in the client. */
|
||||
public final class AccessConfig {
|
||||
private static final Logger LOG = LoggerFactory.getLogger("Sanctuary/Access");
|
||||
private static final HttpClient HTTP = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5)).followRedirects(HttpClient.Redirect.NEVER).build();
|
||||
public interface Directory {
|
||||
AccessCodes.Result verify(String username, String code);
|
||||
AccessCodes.Result redeem(String username, String code);
|
||||
}
|
||||
private static volatile boolean enabled;
|
||||
private static volatile Directory directory = new Disabled();
|
||||
private AccessConfig() { }
|
||||
public static boolean enabled() { return enabled; }
|
||||
public static Directory directory() { return directory; }
|
||||
public static void load(Path configDir) {
|
||||
String url = env("SANCTUARY_APP_URL");
|
||||
String token = env("SANCTUARY_API_TOKEN");
|
||||
if (url.isEmpty() || token.isEmpty()) {
|
||||
var file = readFile(configDir.resolve("access.json"));
|
||||
if (url.isEmpty()) url = file.url;
|
||||
if (token.isEmpty()) token = file.token;
|
||||
}
|
||||
if (url.isEmpty() && token.isEmpty()) {
|
||||
enabled = false; directory = new Disabled();
|
||||
LOG.info("Sanctuary access codes are off. Set SANCTUARY_APP_URL and SANCTUARY_API_TOKEN to require a code.");
|
||||
return;
|
||||
}
|
||||
URI uri;
|
||||
try { uri = URI.create(url.endsWith("/") ? url.substring(0, url.length() - 1) : url); }
|
||||
catch (IllegalArgumentException error) { failClosed("Sanctuary access URL is invalid. Character creation stays closed."); return; }
|
||||
if (uri.getUserInfo() != null || uri.getRawQuery() != null || uri.getHost() == null) {
|
||||
failClosed("Sanctuary access URL must be a bare https origin. Character creation stays closed."); return;
|
||||
}
|
||||
String scheme = uri.getScheme();
|
||||
if (!"https".equalsIgnoreCase(scheme) && !"http".equalsIgnoreCase(scheme)) {
|
||||
failClosed("Sanctuary access URL must use https. Character creation stays closed."); return;
|
||||
}
|
||||
if (token.isEmpty()) { failClosed("Sanctuary access token is missing. Character creation stays closed."); return; }
|
||||
if ("http".equalsIgnoreCase(scheme)) LOG.warn("Sanctuary access URL is not HTTPS. Use HTTPS outside a local site.");
|
||||
enabled = true;
|
||||
directory = new HttpDirectory(uri, token);
|
||||
LOG.info("Sanctuary access codes are required before character creation.");
|
||||
}
|
||||
private static void failClosed(String message) {
|
||||
enabled = true; directory = new Disabled(); LOG.error(message);
|
||||
}
|
||||
private static String env(String key) {
|
||||
String value = System.getenv(key);
|
||||
return value == null ? "" : value.trim();
|
||||
}
|
||||
private record FileConfig(String url, String token) { }
|
||||
private static FileConfig readFile(Path file) {
|
||||
if (!Files.isRegularFile(file)) return new FileConfig("", "");
|
||||
try {
|
||||
var object = JsonParser.parseString(Files.readString(file, StandardCharsets.UTF_8)).getAsJsonObject();
|
||||
return new FileConfig(text(object, "appUrl"), text(object, "token"));
|
||||
} catch (Exception error) {
|
||||
LOG.error("Sanctuary access config cannot be read. Character creation stays closed.");
|
||||
return new FileConfig("http://invalid", "");
|
||||
}
|
||||
}
|
||||
private static String text(JsonObject object, String key) {
|
||||
if (!object.has(key) || object.get(key).isJsonNull()) return "";
|
||||
return object.get(key).getAsString().trim();
|
||||
}
|
||||
private static final class Disabled implements Directory {
|
||||
public AccessCodes.Result verify(String username, String code) { return AccessCodes.Result.unconfigured(); }
|
||||
public AccessCodes.Result redeem(String username, String code) { return AccessCodes.Result.unconfigured(); }
|
||||
}
|
||||
private static final class HttpDirectory implements Directory {
|
||||
private final URI origin;
|
||||
private final String token;
|
||||
HttpDirectory(URI origin, String token) { this.origin = origin; this.token = token; }
|
||||
public AccessCodes.Result verify(String username, String code) { return post("/api/v1/access-codes/verify", username, code); }
|
||||
public AccessCodes.Result redeem(String username, String code) { return post("/api/v1/access-codes/redeem", username, code); }
|
||||
private AccessCodes.Result post(String path, String username, String code) {
|
||||
var body = new JsonObject();
|
||||
body.addProperty("code", code);
|
||||
body.addProperty("minecraft_username", username);
|
||||
var request = HttpRequest.newBuilder(URI.create(origin.toString() + path)).timeout(Duration.ofSeconds(10))
|
||||
.header("Authorization", "Bearer " + token).header("Content-Type", "application/json").header("Accept", "application/json")
|
||||
.POST(HttpRequest.BodyPublishers.ofString(body.toString())).build();
|
||||
try {
|
||||
var response = HTTP.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
|
||||
if (response.statusCode() == 401) LOG.warn("Sanctuary access token was rejected");
|
||||
return AccessCodes.parse(response.statusCode(), response.body());
|
||||
} catch (Exception error) {
|
||||
LOG.warn("Sanctuary access endpoint is unreachable ({})", error.getClass().getSimpleName());
|
||||
return AccessCodes.Result.unreachable();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package fr.koka.sanctuary.progression;
|
||||
|
||||
import com.google.gson.GsonBuilder;
|
||||
import com.google.gson.JsonObject;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Path;
|
||||
import java.util.TreeMap;
|
||||
import java.util.UUID;
|
||||
|
||||
/** Discord binding for an inhabitant. Pending entries finish a creation interrupted after the site consumed the code. */
|
||||
public final class AccessLedger {
|
||||
private static final int LIMIT = 1024 * 1024;
|
||||
private final Path file;
|
||||
private final long seed;
|
||||
private byte[] previous;
|
||||
private final TreeMap<UUID, Binding> pending = new TreeMap<>();
|
||||
private final TreeMap<UUID, Binding> bound = new TreeMap<>();
|
||||
private boolean failed;
|
||||
public record Binding(String name, String discordId) { }
|
||||
public AccessLedger(Path file, long seed) throws IOException {
|
||||
this.file = file; this.seed = seed;
|
||||
previous = AtomicJson.read(file, LIMIT);
|
||||
if (previous == null) return;
|
||||
var root = AtomicJson.parse(new String(previous, StandardCharsets.UTF_8)).getAsJsonObject();
|
||||
AtomicJson.fields(root, "schema", "seed", "pending", "bound");
|
||||
if (root.get("schema").getAsInt() != 1 || root.get("seed").getAsLong() != seed)
|
||||
throw new IllegalArgumentException("Invalid access ledger");
|
||||
read(root.getAsJsonObject("pending"), pending);
|
||||
read(root.getAsJsonObject("bound"), bound);
|
||||
}
|
||||
public Binding known(UUID id) {
|
||||
var open = pending.get(id);
|
||||
return open != null ? open : bound.get(id);
|
||||
}
|
||||
public void remember(UUID id, String name, String discordId) throws IOException {
|
||||
if (failed) throw new IOException("Access ledger failed earlier");
|
||||
if (!AccessCodes.validUsername(name) || !AccessCodes.validDiscord(discordId))
|
||||
throw new IllegalArgumentException("Invalid access binding");
|
||||
if (bound.containsKey(id)) return;
|
||||
pending.put(id, new Binding(name, discordId));
|
||||
save();
|
||||
}
|
||||
public void commit(UUID id) throws IOException {
|
||||
if (failed) throw new IOException("Access ledger failed earlier");
|
||||
var binding = pending.remove(id);
|
||||
if (binding == null) return;
|
||||
bound.put(id, binding);
|
||||
save();
|
||||
}
|
||||
private static void read(JsonObject object, TreeMap<UUID, Binding> into) {
|
||||
for (var entry : object.entrySet()) {
|
||||
AtomicJson.fields(entry.getValue(), "name", "discordId");
|
||||
UUID id = UUID.fromString(entry.getKey());
|
||||
var value = entry.getValue().getAsJsonObject();
|
||||
String name = value.get("name").getAsString();
|
||||
String discordId = value.get("discordId").getAsString();
|
||||
if (!AccessCodes.validUsername(name) || !AccessCodes.validDiscord(discordId))
|
||||
throw new IllegalArgumentException("Invalid access binding");
|
||||
if (into.put(id, new Binding(name, discordId)) != null) throw new IllegalArgumentException("Duplicate access binding");
|
||||
}
|
||||
}
|
||||
private void save() throws IOException {
|
||||
var root = new JsonObject();
|
||||
root.addProperty("schema", 1);
|
||||
root.addProperty("seed", seed);
|
||||
root.add("pending", write(pending));
|
||||
root.add("bound", write(bound));
|
||||
try {
|
||||
previous = AtomicJson.write(file, previous, new GsonBuilder().setPrettyPrinting().create().toJson(root), LIMIT);
|
||||
} catch (IOException | RuntimeException error) { failed = true; throw error; }
|
||||
}
|
||||
private static JsonObject write(TreeMap<UUID, Binding> map) {
|
||||
var object = new JsonObject();
|
||||
map.forEach((id, binding) -> {
|
||||
var value = new JsonObject();
|
||||
value.addProperty("name", binding.name);
|
||||
value.addProperty("discordId", binding.discordId);
|
||||
object.add(id.toString(), value);
|
||||
});
|
||||
return object;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package fr.koka.sanctuary.progression;
|
||||
|
||||
import fr.koka.sanctuary.SanctuaryMod;
|
||||
import net.minecraft.network.FriendlyByteBuf;
|
||||
import net.minecraft.network.codec.StreamCodec;
|
||||
import net.minecraft.network.protocol.common.custom.CustomPacketPayload;
|
||||
|
||||
/** Additive Hello World handshake. Existing create packets stay unchanged when access codes are off. */
|
||||
public final class AccessPayloads {
|
||||
private AccessPayloads() { }
|
||||
public record Gate(boolean required, boolean pending) implements CustomPacketPayload {
|
||||
public static final Type<Gate> TYPE = new Type<>(SanctuaryMod.id("access_gate_v1"));
|
||||
public static final StreamCodec<FriendlyByteBuf, Gate> CODEC = CustomPacketPayload.codec(
|
||||
(p, b) -> { b.writeBoolean(p.required); b.writeBoolean(p.pending); },
|
||||
b -> new Gate(b.readBoolean(), b.readBoolean()));
|
||||
public Type<Gate> type() { return TYPE; }
|
||||
}
|
||||
public record Check(String code) implements CustomPacketPayload {
|
||||
public static final Type<Check> TYPE = new Type<>(SanctuaryMod.id("access_check_v1"));
|
||||
public static final StreamCodec<FriendlyByteBuf, Check> CODEC = CustomPacketPayload.codec(
|
||||
(p, b) -> b.writeUtf(p.code, 32), b -> new Check(b.readUtf(32)));
|
||||
public Type<Check> type() { return TYPE; }
|
||||
}
|
||||
public record Status(String code, String reason) implements CustomPacketPayload {
|
||||
public static final Type<Status> TYPE = new Type<>(SanctuaryMod.id("access_status_v1"));
|
||||
public static final StreamCodec<FriendlyByteBuf, Status> CODEC = CustomPacketPayload.codec(
|
||||
(p, b) -> { b.writeUtf(p.code, 32); b.writeUtf(p.reason, 64); },
|
||||
b -> new Status(b.readUtf(32), b.readUtf(64)));
|
||||
public Type<Status> type() { return TYPE; }
|
||||
}
|
||||
public record Admit(String bio, int color, String mob, String name, String code) implements CustomPacketPayload {
|
||||
public static final Type<Admit> TYPE = new Type<>(SanctuaryMod.id("access_admit_v1"));
|
||||
public static final StreamCodec<FriendlyByteBuf, Admit> CODEC = CustomPacketPayload.codec(
|
||||
(p, b) -> { b.writeUtf(p.bio, 256); b.writeInt(p.color); b.writeUtf(p.mob, 64); b.writeUtf(p.name, StarterCompanions.NAME_LIMIT); b.writeUtf(p.code, 32); },
|
||||
b -> new Admit(b.readUtf(256), b.readInt(), b.readUtf(64), b.readUtf(StarterCompanions.NAME_LIMIT), b.readUtf(32)));
|
||||
public Type<Admit> type() { return TYPE; }
|
||||
}
|
||||
}
|
||||
+113
-1
@@ -50,6 +50,7 @@ public final class ProgressionService {
|
||||
public StarterCompanions.Store starters;
|
||||
public CycleConfig cycleConfig=CycleConfig.DEFAULT;
|
||||
public TutorialConfig tutorialConfig=TutorialConfig.DEFAULT;
|
||||
public AccessLedger access;
|
||||
Session(InhabitantStore inhabitants, ProgressionConfig config, MobNamesConfig names) {
|
||||
this.inhabitants=inhabitants; this.config=config; this.names=names;
|
||||
}
|
||||
@@ -61,6 +62,10 @@ public final class ProgressionService {
|
||||
PayloadTypeRegistry.clientboundConfiguration().register(StarterPayloads.Welcome.TYPE,StarterPayloads.Welcome.CODEC);
|
||||
PayloadTypeRegistry.serverboundConfiguration().register(StarterPayloads.Create.TYPE,StarterPayloads.Create.CODEC);
|
||||
PayloadTypeRegistry.serverboundConfiguration().register(StarterPayloads.NamedCreate.TYPE,StarterPayloads.NamedCreate.CODEC);
|
||||
PayloadTypeRegistry.clientboundConfiguration().register(AccessPayloads.Gate.TYPE,AccessPayloads.Gate.CODEC);
|
||||
PayloadTypeRegistry.clientboundConfiguration().register(AccessPayloads.Status.TYPE,AccessPayloads.Status.CODEC);
|
||||
PayloadTypeRegistry.serverboundConfiguration().register(AccessPayloads.Check.TYPE,AccessPayloads.Check.CODEC);
|
||||
PayloadTypeRegistry.serverboundConfiguration().register(AccessPayloads.Admit.TYPE,AccessPayloads.Admit.CODEC);
|
||||
SanctuarySitting.register();
|
||||
fr.koka.sanctuary.inventory.HotbarService.register();
|
||||
fr.koka.sanctuary.inventory.InventorySortService.register();
|
||||
@@ -87,6 +92,9 @@ public final class ProgressionService {
|
||||
});
|
||||
ServerConfigurationConnectionEvents.CONFIGURE.register((handler,server) -> {
|
||||
if (session(server) == null) return;
|
||||
if (session(server).access != null && !ServerConfigurationNetworking.canSend(handler,AccessPayloads.Gate.TYPE)) {
|
||||
handler.disconnect(label("access_version_required")); return;
|
||||
}
|
||||
if (!ServerConfigurationNetworking.canSend(handler,ProgressionPayloads.Hello.TYPE)) {
|
||||
handler.disconnect(label("client_required")); return;
|
||||
}
|
||||
@@ -147,6 +155,12 @@ public final class ProgressionService {
|
||||
ServerConfigurationNetworking.registerGlobalReceiver(StarterPayloads.NamedCreate.TYPE,(payload,context)->context.server().execute(()->{
|
||||
HelloTask task=PENDING.get(context.packetListener());if(task!=null)task.createWithStarter(new StarterPayloads.Create(payload.bio(),payload.color(),payload.mob()),payload.name());
|
||||
}));
|
||||
ServerConfigurationNetworking.registerGlobalReceiver(AccessPayloads.Check.TYPE,(payload,context)->context.server().execute(()->{
|
||||
HelloTask task=PENDING.get(context.packetListener());if(task!=null)task.check(payload.code());
|
||||
}));
|
||||
ServerConfigurationNetworking.registerGlobalReceiver(AccessPayloads.Admit.TYPE,(payload,context)->context.server().execute(()->{
|
||||
HelloTask task=PENDING.get(context.packetListener());if(task!=null)task.admit(payload);
|
||||
}));
|
||||
ServerConfigurationNetworking.registerGlobalReceiver(ProgressionPayloads.IntroFinished.TYPE, (payload,context) ->
|
||||
context.server().execute(() -> {
|
||||
HelloTask task = PENDING.get(context.packetListener());
|
||||
@@ -194,6 +208,7 @@ public final class ProgressionService {
|
||||
private static void start(MinecraftServer server) {
|
||||
if (!SanctuaryGameplay.supported(server)) return;
|
||||
try {
|
||||
AccessConfig.load(CONFIG);
|
||||
var config=ProgressionConfig.load(CONFIG.resolve("progression.json"));
|
||||
var names=MobNamesConfig.load(CONFIG.resolve("mob-names.json"));
|
||||
Path world=server.getWorldPath(LevelResource.ROOT);
|
||||
@@ -210,6 +225,7 @@ public final class ProgressionService {
|
||||
session.cycleConfig=CycleConfig.load(CONFIG.resolve("cycle-factions.json"));
|
||||
session.tutorialConfig=TutorialConfig.load(CONFIG.resolve("tutorial.json"));
|
||||
session.cycles=new CycleStore(world.resolve("data/sanctuary-cycle-factions.json"),server.overworld().getSeed());
|
||||
if (AccessConfig.enabled()) session.access=new AccessLedger(world.resolve("data/sanctuary-access.json"),server.overworld().getSeed());
|
||||
for (var event:session.cycles.events()) {
|
||||
if (inhabitants.get(event.actor())==null || (!event.target().isEmpty() && inhabitants.get(UUID.fromString(event.target()))==null))
|
||||
throw new java.io.IOException("Cycle journal references a missing inhabitant");
|
||||
@@ -233,7 +249,10 @@ public final class ProgressionService {
|
||||
private static final Type TYPE=new Type("sanctuary:hello_world");
|
||||
private final ServerConfigurationPacketListenerImpl handler;
|
||||
private final MinecraftServer server;
|
||||
private boolean awaitingIntro,awaitingStarter;
|
||||
private boolean awaitingIntro,awaitingStarter,admitted,busy;
|
||||
private String lastChecked="",queuedCheck;
|
||||
private AccessPayloads.Admit queuedAdmit;
|
||||
private long lastCheckAt;
|
||||
HelloTask(ServerConfigurationPacketListenerImpl handler,MinecraftServer server) { this.handler=handler;this.server=server; }
|
||||
public Type type() { return TYPE; }
|
||||
public void start(Consumer<Packet<?>> sender) {
|
||||
@@ -255,6 +274,8 @@ public final class ProgressionService {
|
||||
void offer(String error) {
|
||||
var profile=((ProgressionConfigurationAccess)handler).sanctuary$profile();
|
||||
var session=session(server);var inhabitant=session.inhabitants.get(profile.id());var starters=session.starters.get(profile.id());
|
||||
if (session.access != null && ServerConfigurationNetworking.canSend(handler,AccessPayloads.Gate.TYPE))
|
||||
ServerConfigurationNetworking.send(handler,new AccessPayloads.Gate(true,session.access.known(profile.id())!=null));
|
||||
if(starters!=null){
|
||||
var identity=new ProgressionPayloads.Hello(profile.name(),inhabitant==null?session.inhabitants.available():java.util.List.of(inhabitant.color()),error);
|
||||
ServerConfigurationNetworking.send(handler,new StarterPayloads.Welcome(identity,inhabitant==null?"":inhabitant.bio(),inhabitant!=null,starters.choices()));
|
||||
@@ -263,6 +284,7 @@ public final class ProgressionService {
|
||||
void create(ProgressionPayloads.Create payload) {
|
||||
if (awaitingIntro || awaitingStarter) return;
|
||||
if (!handler.isAcceptingMessages()) { PENDING.remove(handler); return; }
|
||||
if (AccessConfig.enabled() && !admitted) { offer("code_required"); return; }
|
||||
Session session=session(server);
|
||||
if (session == null || !session.healthy) { handler.disconnect(label("storage_invalid")); return; }
|
||||
try {
|
||||
@@ -288,6 +310,7 @@ public final class ProgressionService {
|
||||
}
|
||||
void createWithStarter(StarterPayloads.Create payload,String name) {
|
||||
if(awaitingIntro||!handler.isAcceptingMessages())return;
|
||||
if (AccessConfig.enabled() && !admitted) { offer("code_required"); return; }
|
||||
var session=session(server);if(session==null||!session.healthy){handler.disconnect(label("storage_invalid"));return;}
|
||||
try {
|
||||
var profile=((ProgressionConfigurationAccess)handler).sanctuary$profile();var uuid=profile.id();
|
||||
@@ -303,6 +326,94 @@ public final class ProgressionService {
|
||||
awaitingStarter=false;beginIntro(uuid);
|
||||
}catch(Exception error){fail(error);}
|
||||
}
|
||||
void check(String code) {
|
||||
if (awaitingIntro || !handler.isAcceptingMessages()) return;
|
||||
var session=session(server);
|
||||
if (session == null || session.access == null) return;
|
||||
var profile=((ProgressionConfigurationAccess)handler).sanctuary$profile();
|
||||
String normalized=AccessCodes.normalize(code);
|
||||
if (session.access.known(profile.id()) != null) { status(normalized,"code_recognized"); return; }
|
||||
if (!AccessCodes.validFormat(normalized)) { status(normalized,"code_malformed"); return; }
|
||||
if (!AccessCodes.validUsername(profile.name())) { status(normalized,"code_username"); return; }
|
||||
if (busy) { queuedCheck=normalized; queuedAdmit=null; return; }
|
||||
if (normalized.equals(lastChecked) && System.nanoTime()-lastCheckAt < 1_000_000_000L) return;
|
||||
verify(profile.name(),normalized);
|
||||
}
|
||||
void verify(String username,String code) {
|
||||
busy=true; lastChecked=code; lastCheckAt=System.nanoTime(); queuedCheck=null;
|
||||
var directory=AccessConfig.directory();
|
||||
java.util.concurrent.CompletableFuture.supplyAsync(() -> directory.verify(username,code)).whenComplete((result,error) -> server.execute(() -> {
|
||||
busy=false;
|
||||
if (PENDING.get(handler) != this || !handler.isAcceptingMessages()) return;
|
||||
status(code, error != null || result == null ? "code_unreachable" : AccessCodes.verifyKey(result,username));
|
||||
if (queuedAdmit != null) { var next=queuedAdmit; queuedAdmit=null; admit(next); return; }
|
||||
if (queuedCheck != null && !queuedCheck.equals(code) && AccessCodes.validFormat(queuedCheck)) {
|
||||
String next=queuedCheck; queuedCheck=null; verify(username,next);
|
||||
}
|
||||
}));
|
||||
}
|
||||
void status(String code,String reason) {
|
||||
if (handler.isAcceptingMessages() && ServerConfigurationNetworking.canSend(handler,AccessPayloads.Status.TYPE))
|
||||
ServerConfigurationNetworking.send(handler,new AccessPayloads.Status(code,reason));
|
||||
}
|
||||
void admit(AccessPayloads.Admit payload) {
|
||||
if (awaitingIntro || !handler.isAcceptingMessages()) return;
|
||||
String normalized=AccessCodes.normalize(payload.code());
|
||||
if (busy) { queuedAdmit=payload; return; }
|
||||
var session=session(server);
|
||||
if (session == null || !session.healthy) { handler.disconnect(label("storage_invalid")); return; }
|
||||
if (session.access == null) { offer("code_unconfigured"); return; }
|
||||
var profile=((ProgressionConfigurationAccess)handler).sanctuary$profile();
|
||||
var uuid=profile.id();
|
||||
if (!ready(session,uuid,payload)) return;
|
||||
if (session.access.known(uuid) != null) { finishAdmit(session,uuid,payload); drain(); return; }
|
||||
if (!AccessCodes.validFormat(normalized)) { offer("code_malformed"); return; }
|
||||
if (!AccessCodes.validUsername(profile.name())) { offer("code_username"); return; }
|
||||
busy=true;
|
||||
String username=profile.name();
|
||||
var directory=AccessConfig.directory();
|
||||
java.util.concurrent.CompletableFuture.supplyAsync(() -> directory.redeem(username,normalized)).whenComplete((result,error) -> server.execute(() -> {
|
||||
busy=false;
|
||||
if (PENDING.get(handler) != this || !handler.isAcceptingMessages()) return;
|
||||
if (error != null || result == null) { offer("code_unreachable"); drain(); return; }
|
||||
var decision=AccessCodes.redeem(result,username);
|
||||
if (!decision.accepted()) { offer(decision.reason()); drain(); return; }
|
||||
try {
|
||||
session.access.remember(uuid,decision.minecraftUsername(),decision.discordId());
|
||||
finishAdmit(session,uuid,payload);
|
||||
} catch (Exception failure) { fail(failure); }
|
||||
drain();
|
||||
}));
|
||||
}
|
||||
boolean ready(Session session,UUID uuid,AccessPayloads.Admit payload) {
|
||||
var starters=session.starters.get(uuid);
|
||||
if (starters != null) {
|
||||
if (starters.choices().stream().noneMatch(choice -> choice.mob().equals(payload.mob()))) { offer("starter_required"); return false; }
|
||||
if (!StarterCompanions.validName(payload.name())) { offer("starter_name_invalid"); return false; }
|
||||
} else if (!payload.mob().isEmpty()) { offer("starter_required"); return false; }
|
||||
if (session.inhabitants.get(uuid) == null) {
|
||||
if (!InhabitantStore.validBio(payload.bio())) { offer("bio_invalid"); return false; }
|
||||
if (!session.inhabitants.available().contains(payload.color())) { offer("color_taken"); return false; }
|
||||
}
|
||||
return true;
|
||||
}
|
||||
void drain() {
|
||||
if (!handler.isAcceptingMessages() || PENDING.get(handler) != this) return;
|
||||
if (queuedAdmit != null) { var next=queuedAdmit; queuedAdmit=null; admit(next); }
|
||||
else if (queuedCheck != null && AccessCodes.validFormat(queuedCheck)) {
|
||||
String next=queuedCheck; queuedCheck=null;
|
||||
verify(((ProgressionConfigurationAccess)handler).sanctuary$profile().name(),next);
|
||||
}
|
||||
}
|
||||
void finishAdmit(Session session,UUID uuid,AccessPayloads.Admit payload) {
|
||||
admitted=true;
|
||||
try {
|
||||
if (payload.mob().isEmpty()) create(new ProgressionPayloads.Create(payload.bio(),payload.color()));
|
||||
else createWithStarter(new StarterPayloads.Create(payload.bio(),payload.color(),payload.mob()),payload.name());
|
||||
} finally { admitted=false; }
|
||||
try { if (session.inhabitants.get(uuid) != null) session.access.commit(uuid); }
|
||||
catch (Exception error) { fail(error); }
|
||||
}
|
||||
void fail(Exception error){
|
||||
session(server).healthy=false;LOG.error("Could not persist Hello World starter",error);handler.disconnect(label("storage_invalid"));
|
||||
}
|
||||
@@ -325,6 +436,7 @@ public final class ProgressionService {
|
||||
if (!restore(player)) return;
|
||||
sendNames(player,session);
|
||||
try {
|
||||
if (session.access != null) session.access.commit(player.getUUID());
|
||||
StarterCompanions.deliver(player,session.starters);
|
||||
if (session.inhabitants.arrive(player.getUUID())) player.level().getServer().getPlayerList()
|
||||
.broadcastSystemMessage(Component.literal("helloworld ").append(player.getDisplayName()),false);
|
||||
|
||||
@@ -267,6 +267,21 @@
|
||||
"sanctuary.progression.choose_color": "Choose your colour",
|
||||
"sanctuary.progression.cancel": "Cancel",
|
||||
"sanctuary.progression.enter": "Enter Sanctuary",
|
||||
"sanctuary.progression.code": "Access code",
|
||||
"sanctuary.progression.code_hint": "SANC-XXXX-XXXX",
|
||||
"sanctuary.progression.code_recognized": "Code recognised.",
|
||||
"sanctuary.progression.code_checking": "Checking the code…",
|
||||
"sanctuary.progression.code_malformed": "The code should look like SANC-XXXX-XXXX.",
|
||||
"sanctuary.progression.code_invalid": "This code does not match this name.",
|
||||
"sanctuary.progression.code_used": "This code has already been used.",
|
||||
"sanctuary.progression.code_replay": "This code is already used and Sanctuary cannot recover the account. Try again later.",
|
||||
"sanctuary.progression.code_unreachable": "Sanctuary is unreachable, try again.",
|
||||
"sanctuary.progression.code_unauthorized": "The server cannot reach Sanctuary.",
|
||||
"sanctuary.progression.code_unconfigured": "The server has no Sanctuary token.",
|
||||
"sanctuary.progression.code_required": "Enter the code sent in a private message.",
|
||||
"sanctuary.progression.code_username": "This name cannot receive a code.",
|
||||
"sanctuary.progression.code_mismatch": "The code does not match this session.",
|
||||
"sanctuary.progression.access_version_required": "This server requires the Sanctuary access code. Update the client.",
|
||||
"sanctuary.progression.joining": "Character saved, joining…",
|
||||
"sanctuary.progression.bio_invalid": "Use plain text for your bio (256 characters).",
|
||||
"sanctuary.progression.color_taken": "Colour just taken. Choose another one.",
|
||||
@@ -2319,10 +2334,5 @@
|
||||
"sanctuary.shaders.colored_light_distance_value": "Colored lights: %s blocks",
|
||||
"sanctuary.shaders.colored_light_distance_help": "View distance: 8 to 64 blocks. Longer distances use more memory and computation. Smooth range fade; each source keeps its native reach.",
|
||||
"sanctuary.shaders.warm_torches": "Warm torches",
|
||||
"sanctuary.shaders.warm_torches_help": "Off: ordinary torches keep the native color temperature. On: orange tint. Other sources retain their own colors.",
|
||||
"sanctuary.shaders.natural_water": "Natural water",
|
||||
"sanctuary.shaders.natural_water_help": "Biome colour, clarity from the bed. Clear oceans. Also changes underwater visibility.",
|
||||
"sanctuary.shaders.water_intensity": "Water intensity",
|
||||
"sanctuary.shaders.water_intensity_help": "At 0%, keeps native rendering. At 100%, applies all three profiles.",
|
||||
"sanctuary.shaders.water_intensity_value": "Water intensity: %s%%"
|
||||
"sanctuary.shaders.warm_torches_help": "Off: ordinary torches keep the native color temperature. On: orange tint. Other sources retain their own colors."
|
||||
}
|
||||
|
||||
@@ -267,6 +267,21 @@
|
||||
"sanctuary.progression.choose_color": "Choisis ta couleur",
|
||||
"sanctuary.progression.cancel": "Annuler",
|
||||
"sanctuary.progression.enter": "Entrer dans Sanctuary",
|
||||
"sanctuary.progression.code": "Code d’accès",
|
||||
"sanctuary.progression.code_hint": "SANC-XXXX-XXXX",
|
||||
"sanctuary.progression.code_recognized": "Code reconnu.",
|
||||
"sanctuary.progression.code_checking": "Vérification du code…",
|
||||
"sanctuary.progression.code_malformed": "Le code doit ressembler à SANC-XXXX-XXXX.",
|
||||
"sanctuary.progression.code_invalid": "Ce code ne correspond pas à ce pseudo.",
|
||||
"sanctuary.progression.code_used": "Ce code a déjà été utilisé.",
|
||||
"sanctuary.progression.code_replay": "Ce code est déjà consommé et Sanctuary ne peut pas retrouver le compte. Réessaie plus tard.",
|
||||
"sanctuary.progression.code_unreachable": "Sanctuary est injoignable, réessaie.",
|
||||
"sanctuary.progression.code_unauthorized": "Le serveur ne peut pas joindre Sanctuary.",
|
||||
"sanctuary.progression.code_unconfigured": "Le serveur n’a pas de jeton Sanctuary.",
|
||||
"sanctuary.progression.code_required": "Entre le code reçu en message privé.",
|
||||
"sanctuary.progression.code_username": "Ce pseudo ne peut pas recevoir de code.",
|
||||
"sanctuary.progression.code_mismatch": "Le code ne correspond pas à cette session.",
|
||||
"sanctuary.progression.access_version_required": "Ce serveur exige le code d’accès Sanctuary. Mets le client à jour.",
|
||||
"sanctuary.progression.joining": "Création enregistrée, arrivée en cours…",
|
||||
"sanctuary.progression.bio_invalid": "La bio doit contenir du texte simple (256 caractères).",
|
||||
"sanctuary.progression.color_taken": "Couleur déjà prise. Choisis-en une autre.",
|
||||
@@ -2319,10 +2334,5 @@
|
||||
"sanctuary.shaders.colored_light_distance_value": "Lumières colorées : %s blocs",
|
||||
"sanctuary.shaders.colored_light_distance_help": "Distance d'affichage : 8 à 64 blocs. Les grandes distances utilisent plus de mémoire et de calcul. Fondu aux limites ; la portée de chaque source reste native.",
|
||||
"sanctuary.shaders.warm_torches": "Torches chaudes",
|
||||
"sanctuary.shaders.warm_torches_help": "Désactivé : les torches ordinaires conservent la température de couleur native. Activé : teinte orangée. Les autres sources gardent leurs propres couleurs.",
|
||||
"sanctuary.shaders.natural_water": "Eau naturelle",
|
||||
"sanctuary.shaders.natural_water_help": "Couleur du biome, clarté selon le fond. Océans clairs. Modifie aussi la visibilité sous l’eau.",
|
||||
"sanctuary.shaders.water_intensity": "Intensité de l’eau",
|
||||
"sanctuary.shaders.water_intensity_help": "À 0 %, conserve le rendu natif. À 100 %, applique les trois profils.",
|
||||
"sanctuary.shaders.water_intensity_value": "Intensité de l’eau: %s%%"
|
||||
"sanctuary.shaders.warm_torches_help": "Désactivé : les torches ordinaires conservent la température de couleur native. Activé : teinte orangée. Les autres sources gardent leurs propres couleurs."
|
||||
}
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"light": ["minecraft:sand", "minecraft:red_sand", "minecraft:gravel", "minecraft:clay"],
|
||||
"pond": ["minecraft:dirt", "minecraft:coarse_dirt", "minecraft:rooted_dirt", "minecraft:grass_block", "minecraft:podzol", "minecraft:mycelium", "minecraft:mud", "minecraft:muddy_mangrove_roots"]
|
||||
}
|
||||
@@ -278,12 +278,7 @@
|
||||
"client.PixelShadowRendererMixin",
|
||||
"client.PixelShadowLevelMixin",
|
||||
"client.PixelShadowDirtyMixin",
|
||||
"client.PreferredGraphicsApiMixin",
|
||||
"client.NaturalWaterRegionMixin",
|
||||
"client.NaturalWaterFluidMixin",
|
||||
"client.NaturalWaterFogMixin",
|
||||
"client.NaturalWaterFogColorMixin",
|
||||
"client.NaturalWaterSectionCopyAccess"
|
||||
"client.PreferredGraphicsApiMixin"
|
||||
],
|
||||
"injectors": {
|
||||
"defaultRequire": 1
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
package fr.koka.sanctuary.progression;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.util.UUID;
|
||||
|
||||
public final class AccessCodeSmoke {
|
||||
public static void main(String[] args) throws Exception {
|
||||
require(AccessCodes.normalize(" sanc-ab3d-k7pq ").equals("SANC-AB3D-K7PQ"), "Codes are trimmed and uppercased");
|
||||
require(AccessCodes.validFormat("SANC-AB3D-K7PQ"), "Documented code shape is accepted");
|
||||
require(!AccessCodes.validFormat("SANC-AB3D-K7P") && !AccessCodes.validFormat("SANC-AB3D-K7PQX"), "Partial codes are refused");
|
||||
require(AccessCodes.validUsername("Steve") && !AccessCodes.validUsername("A") && !AccessCodes.validUsername("bad name"), "Session names stay Minecraft names");
|
||||
var unused = AccessCodes.parse(200, "{\"valid\":true,\"reason\":\"unused\",\"minecraft_username\":\"Steve\",\"discord_id\":\"123456789012345678\"}");
|
||||
require(AccessCodes.verifyKey(unused, "steve").equals("code_recognized"), "Verify matches the session name without consuming the code");
|
||||
require(AccessCodes.verifyKey(AccessCodes.parse(200, "{\"valid\":false,\"reason\":\"invalid\"}"), "Steve").equals("code_invalid"), "Unknown code stays on the screen");
|
||||
require(AccessCodes.verifyKey(AccessCodes.parse(200, "{\"valid\":false,\"reason\":\"already_used\"}"), "Steve").equals("code_used"), "Used code is not a fresh verification");
|
||||
require(AccessCodes.parse(401, "").reason().equals("unauthorized"), "A bad token is not a character");
|
||||
require(AccessCodes.parse(503, "nope").reason().equals("unreachable"), "An outage does not invent a character");
|
||||
var redeemed = AccessCodes.parse(200, "{\"valid\":true,\"reason\":\"redeemed\",\"minecraft_username\":\"Steve\",\"discord_id\":\"42\"}");
|
||||
var decision = AccessCodes.redeem(redeemed, "Steve");
|
||||
require(decision.accepted() && decision.discordId().equals("42"), "Redeem is the commitment");
|
||||
require(!AccessCodes.redeem(redeemed, "Alex").accepted(), "Another username cannot use the code");
|
||||
var replay = AccessCodes.parse(200, "{\"valid\":false,\"reason\":\"already_used\",\"minecraft_username\":\"Steve\",\"discord_id\":\"42\"}");
|
||||
require(AccessCodes.redeem(replay, "Steve").accepted(), "A consumed code can finish an interrupted creation when the account comes back");
|
||||
var stuck = AccessCodes.parse(200, "{\"valid\":false,\"reason\":\"already_used\"}");
|
||||
require(AccessCodes.redeem(stuck, "Steve").reason().equals("code_replay"), "A consumed code without an account does not create anyone");
|
||||
|
||||
var path = Files.createTempDirectory("sanctuary-access-").resolve("access.json");
|
||||
UUID id = UUID.fromString("01234567-89ab-cdef-fedc-ba9876543210");
|
||||
var ledger = new AccessLedger(path, 42L);
|
||||
ledger.remember(id, "Steve", "42");
|
||||
var reopened = new AccessLedger(path, 42L);
|
||||
require(reopened.known(id).discordId().equals("42"), "A crash after redeem keeps the Discord account");
|
||||
reopened.commit(id);
|
||||
var committed = new AccessLedger(path, 42L);
|
||||
require(committed.known(id).name().equals("Steve"), "The binding survives the character write");
|
||||
committed.commit(id);
|
||||
committed.remember(id, "Steve", "42");
|
||||
String saved = Files.readString(path);
|
||||
require(saved.indexOf(id.toString()) == saved.lastIndexOf(id.toString()), "An existing character is not returned to pending");
|
||||
byte[] broken = "{".getBytes();
|
||||
Files.write(path, broken);
|
||||
boolean refused = false;
|
||||
try { new AccessLedger(path, 42L); } catch (IllegalArgumentException error) { refused = true; }
|
||||
require(refused && Files.readAllBytes(path).length == 1, "A corrupt ledger is preserved and refused");
|
||||
System.out.println("ACCESS-01: code contract, replay and ledger passed");
|
||||
}
|
||||
private static void require(boolean value, String message) { if (!value) throw new AssertionError(message); }
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Check the eight alternating water benchmark passages per scene.
|
||||
|
||||
Usage: python3 scripts/check_water_performance.py build/water144-vulkan.log
|
||||
Writes the detailed receipt beside the log, and rejects incomplete/over-budget runs.
|
||||
The caller must also verify that no other graphics test ran during measurement.
|
||||
"""
|
||||
import json,re,sys
|
||||
from pathlib import Path
|
||||
log_path=Path(sys.argv[1])
|
||||
backend=log_path.stem
|
||||
log=log_path.read_text()
|
||||
rows={key:json.loads(data) for key,data in re.findall(r"WATER144_PERF (\S+) (\[[^\n]+\])",log)}
|
||||
assert len(rows)==24, ("Incomplete measurements",len(rows))
|
||||
assert log.count("WATER144_CONDITIONS limit=260 throttle=NONE")==24, "Measurements must be uncapped"
|
||||
result={"backend":backend,"samples":rows,"scenes":{}}
|
||||
for scene in ("mixed","large","moving"):
|
||||
off=[v for k,v in rows.items() if k.startswith(scene+"-false-")]
|
||||
on=[v for k,v in rows.items() if k.startswith(scene+"-true-")]
|
||||
assert len(off)==len(on)==4 and all(v[0]>50 for v in off+on)
|
||||
if scene=="moving":
|
||||
assert all(v[3]>0 for v in off+on), "Moving scene must compile terrain sections"
|
||||
median_off=sum(v[1] for v in off)/len(off);median_on=sum(v[1] for v in on)/len(on)
|
||||
p95_off=sum(v[2] for v in off)/len(off);p95_on=sum(v[2] for v in on)/len(on)
|
||||
value={"offMedianMs":median_off,"onMedianMs":median_on,"medianIncreasePercent":100*(median_on/median_off-1),"offP95Ms":p95_off,"onP95Ms":p95_on,"p95IncreaseMs":p95_on-p95_off}
|
||||
value["passed"]=value["medianIncreasePercent"]<=5 and value["p95IncreaseMs"]<=2
|
||||
result["scenes"][scene]=value
|
||||
log_path.with_name(log_path.stem+"-performance.json").write_text(json.dumps(result,indent=2)+"\n")
|
||||
print(json.dumps(result["scenes"],indent=2))
|
||||
assert all(v["passed"] for v in result["scenes"].values()), "Performance acceptance not met"
|
||||
@@ -8,6 +8,3 @@ ajoute les ombres portées pixélisées optionnelles, configurables depuis
|
||||
Ce dossier reste réservé aux futurs packs externes. Leur ajout au manifeste
|
||||
packwiz nécessitera version, URL, hash, licence et compatibilité vérifiée pour
|
||||
la version exacte du jeu et du moteur Iris.
|
||||
|
||||
La beta.144 ajoute l’[eau naturelle](../docs/natural-water-beta144.md) dans
|
||||
le rendu natif : profils selon le fond, océans clairs et brume sous-marine.
|
||||
|
||||
Reference in New Issue
Block a user